import { test, expect } from '../support/fixtures.js';

/**
 * The phone's transport (#59, #56).
 *
 * #56 reports that "the playback controls are the most important thing
 * in the mobile app and they are tiny". Measured at the reference
 * device's 424x439 before this, every one of them was **33x21px**, and
 * the favourite beside them — which #59 keeps on the bar — was
 * **18x14px**, the smallest control in the app.
 *
 * #59 is what makes the sizes affordable: five controls plus a queue
 * button at 44px does not fit 424 CSS px, so the bar carries three and
 * the rest are on the full-screen view.
 *
 * **The assertion that matters is not the pixel count.** Plan 018's
 * matrix promises that *no action is ever unreachable at any supported
 * size*, and #59 removes three controls from the phone's bar — so the
 * first thing this file checks is that all three are still reachable,
 * by walking the route a user would. A spec that only measured the
 * survivors would be green on a build that had made shuffle
 * unreachable, which is the failure mode this pair of issues is one
 * mistake away from.
 */
type Page = import('@playwright/test').Page;

/** The reference device's real viewport. */
const DEVICE = { width: 424, height: 439 };
const PHONE = { width: 390, height: 780 };
const DESKTOP = { width: 1280, height: 800 };

/**
 * The touch-target floor. 44px is what #56's Findings name and what
 * #55's queue header was sized to, so the app has one number.
 */
const TARGET = 44;

/** The play button is named for its action, not its identity. */
const PLAY_PAUSE = /^(Play|Pause)$/;

const barControls = (page: Page) =>
  page.locator('audio-player player-controls');

/**
 * `name` may be a regex, and for play/pause it must be: that button is
 * named for the *action*, so it is "Pause" while a track runs and
 * "Play" when it stops. An exact 'Play' made these tests wait out a
 * fixture track (11.1s each, passing by luck) and would have failed
 * outright against `LONG_TRACK`. A test about a control's size does not
 * care what the transport is doing.
 */
async function sizeOf(
  page: Page,
  name: string | RegExp,
): Promise<[number, number]> {
  const box = await page
    .getByRole('button', { name, exact: typeof name === 'string' })
    .boundingBox();

  expect(box, `no button named ${name}`).not.toBeNull();

  return [box!.width, box!.height];
}

/** Put something in the queue, so the transport has a track to act on. */
async function stageATrack(page: Page): Promise<void> {
  await page.evaluate(async () => {
    const tracks = (await window.__yjEvents.call(
      'library.Library.GetTracks',
      [0],
      10_000,
    )) as { FilePath: string }[];

    await window.__yjEvents.call(
      'queue.Queue.SetQueue',
      [tracks.slice(0, 4).map((t) => t.FilePath), 0, false, { type: '', id: 0, label: '' }],
      10_000,
    );
  });
}

test.describe('the phone bar carries three controls', () => {
  test.beforeEach(async ({ app }) => {
    await app.setViewportSize(DEVICE);
    await stageATrack(app);
  });

  test('drops shuffle, repeat and the queue from the bar', async ({ app }) => {
    const bar = barControls(app);

    await expect(bar.getByRole('button', { name: 'Previous track' })).toBeVisible();
    await expect(bar.getByRole('button', { name: 'Next track' })).toBeVisible();

    // Not in the bar's own subtree. Asserted against the bar rather
    // than the page, because the whole point is that they moved rather
    // than went away -- a page-wide `not.toBeVisible()` would fail the
    // moment Now Playing is open and would be asserting the wrong
    // thing besides.
    await expect(bar.getByRole('button', { name: 'Shuffle' })).toHaveCount(0);
    await expect(bar.getByRole('button', { name: /^Repeat/ })).toHaveCount(0);
    await expect(app.locator('#queue-button')).toBeHidden();
  });

  /**
   * The promise, walked. Every control #59 takes off the bar is
   * reachable from the mini player's art in one tap.
   */
  test('leaves every removed control reachable from Now Playing', async ({
    app,
  }) => {
    await app.getByTestId('open-now-playing').click();

    await expect(app.getByTestId('main-content')).toHaveAttribute(
      'data-active-view',
      'now-playing',
    );

    await expect(app.getByRole('button', { name: 'Shuffle' })).toBeVisible();
    await expect(app.getByRole('button', { name: /^Repeat/ })).toBeVisible();
    await expect(app.getByRole('button', { name: 'Show the queue' })).toBeVisible();
  });

  test('sizes what is left for a thumb', async ({ app }) => {
    for (const name of ['Previous track', 'Next track']) {
      const [w, h] = await sizeOf(app, name);

      expect(w, `${name} width`).toBeGreaterThanOrEqual(TARGET);
      expect(h, `${name} height`).toBeGreaterThanOrEqual(TARGET);
    }

    // Play is deliberately bigger than its neighbours: a row of
    // identical squares says every action is equally likely, which is
    // not true of play.
    const [pw, ph] = await sizeOf(app, PLAY_PAUSE);
    const [nw] = await sizeOf(app, 'Next track');

    expect(ph).toBeGreaterThanOrEqual(TARGET);
    expect(pw).toBeGreaterThan(nw);
  });

  /**
   * The favourite was 18x14 and is one of the three controls #59
   * keeps, so it is part of this issue rather than a nicety.
   */
  test('sizes the favourite, which was the smallest control in the app', async ({
    app,
  }) => {
    const fav = app
      .locator('now-playing')
      .getByRole('button', { name: /Favorites$/ });

    const box = await fav.boundingBox();

    expect(box).not.toBeNull();
    expect(box!.width).toBeGreaterThanOrEqual(TARGET);
    expect(box!.height).toBeGreaterThanOrEqual(TARGET);
  });

  /**
   * **The route to the queue must not depend on what is playing.**
   *
   * `now-playing` renders two branches, and the no-track one had no
   * `.expand` button on its placeholder — so with nothing loaded there
   * was no way to Now Playing, and once #59 takes the queue button off
   * the bar that makes the *queue* unreachable. The queue is persisted
   * across restarts, so "tracks queued, nothing playing" is a state the
   * app launches into.
   *
   * This is asserted with the queue explicitly emptied rather than by
   * relying on the app not having played anything: `make e2e` runs one
   * long-lived app across every spec file (#168), so "no track loaded"
   * is otherwise whatever the file before this one left behind — which
   * is how the underlying fault first showed up as a flake in a spec
   * about something else.
   */
  test('reaches the queue with nothing playing', async ({ app }) => {
    await app.evaluate(async () => {
      await window.__yjEvents.call('queue.Queue.Clear', [], 10_000);
    });

    await expect(app.getByTestId('open-now-playing')).toBeVisible();

    await app.getByTestId('open-now-playing').click();
    await app.getByTestId('npv-queue').click();

    await expect(app.locator('#queue-panel')).toHaveAttribute('open', '');
  });

  test('still fits, with nothing to scroll sideways to', async ({ app }) => {
    const fit = await app.evaluate(() => ({
      scroll: document.body.scrollWidth,
      client: document.body.clientWidth,
    }));

    expect(fit.scroll).toBe(fit.client);
  });
});

test.describe('the full-screen transport is the page', () => {
  test.beforeEach(async ({ app }) => {
    await app.setViewportSize(DEVICE);
    await stageATrack(app);
    await app.getByTestId('open-now-playing').click();
  });

  test('draws all five, larger than the bar draws any', async ({ app }) => {
    const [pw, ph] = await sizeOf(app, PLAY_PAUSE);

    expect(pw).toBeGreaterThanOrEqual(56);
    expect(ph).toBeGreaterThanOrEqual(56);

    for (const name of ['Shuffle', 'Previous track', 'Next track']) {
      const [w, h] = await sizeOf(app, name);

      expect(w, `${name} width`).toBeGreaterThanOrEqual(TARGET);
      expect(h, `${name} height`).toBeGreaterThanOrEqual(TARGET);
    }
  });

  test('fits at both phone widths', async ({ app }) => {
    for (const size of [DEVICE, PHONE]) {
      await app.setViewportSize(size);

      const fit = await app.evaluate(() => ({
        scroll: document.body.scrollWidth,
        client: document.body.clientWidth,
      }));

      expect(fit.scroll, `${size.width}px`).toBe(fit.client);
    }
  });
});

/**
 * **The desktop bar is not what either issue is about, and must not
 * move.** Both are `Platform/Android`; this is the guard that says so
 * in a way a build can check.
 *
 * It caught a real regression while it was being written: a generic
 * `font-size` on the buttons took them from the UA stylesheet's 13.3px
 * to the shell's 16px and grew every one from 33x21 to 36x24 — a
 * change nobody asked for, invisible to every other assertion here.
 */
test.describe('the desktop bar is untouched', () => {
  test.beforeEach(async ({ app }) => {
    await app.setViewportSize(DESKTOP);
    await stageATrack(app);
  });

  test('keeps all five controls and the queue button', async ({ app }) => {
    const bar = barControls(app);

    for (const name of ['Shuffle', 'Previous track', 'Next track']) {
      await expect(bar.getByRole('button', { name })).toBeVisible();
    }

    await expect(bar.getByRole('button', { name: /^Repeat/ })).toBeVisible();
    await expect(app.locator('#queue-button')).toBeVisible();
  });

  /**
   * **The mechanism, because the pixels are the engine's.**
   *
   * The first version of this asserted the literal `'33x21'`, measured
   * on `main` in Chromium — and WebKit draws the same button **36x24**,
   * so it failed in CI on a build where nothing was wrong. A button's
   * box comes from the UA stylesheet when the author sets nothing, and
   * what each UA sets is its own business.
   *
   * What this PR must not do is *set* anything here, so that is what is
   * asserted: our two box properties are unset, and the font is still
   * the UA's rather than the shell's. That is precisely the regression
   * this caught the first time — a generic `font-size: inherit` took
   * these from the UA's default to 16px — and it catches it in either
   * engine.
   */
  test('sets no size of its own on the desktop bar', async ({ app }) => {
    const measured = await barControls(app).evaluate((el) => {
      // A bare button with no author styles: whatever this engine
      // gives one is what the bar's buttons must still be.
      const probe = document.createElement('button');

      document.body.appendChild(probe);

      const uaFontSize = getComputedStyle(probe).fontSize;

      probe.remove();

      return [...el.shadowRoot!.querySelectorAll('button')].map((b) => {
        const cs = getComputedStyle(b);
        const r = b.getBoundingClientRect();

        return {
          minWidth: cs.minWidth,
          minHeight: cs.minHeight,
          usesUaFont: cs.fontSize === uaFontSize,
          size: `${Math.round(r.width)}x${Math.round(r.height)}`,
        };
      });
    });

    expect(measured).toHaveLength(5);

    for (const m of measured) {
      expect(m.minWidth, 'min-width').toBe('0px');
      expect(m.minHeight, 'min-height').toBe('0px');
      expect(m.usesUaFont, 'font-size is still the UA default').toBe(true);
    }

    // And all five are the same box: `.play` takes a larger size in
    // both sized contexts, so this is what says the desktop is neither
    // of them.
    expect(new Set(measured.map((m) => m.size)).size).toBe(1);
  });
});
