import { test, expect } from '../support/fixtures.js';
import type { Page } from '@playwright/test';

/**
 * How far the scroll test scrolls. One constant, because the guard and
 * the assertion have to agree about it — they did not, which is #133.
 */
const SCROLL_TARGET = 80;

/**
 * Plan 007 phase 5: expanding an album shows its tracks.
 *
 * `perf.p2` files `cover-grid`'s `renderSplitGrid` as dead code carried
 * in the bundle. It was a **missing feature** whose data path already
 * worked: Enter on an album card fetched the album's tracks over the
 * IPC and ran the whole split state machine, and then `render()` drew
 * the single grid regardless because it never consulted `splitMode`.
 *
 * This spec is here rather than only in the component tier because two
 * of the three things that had to be true are about the real app: that
 * the route from a card to `track-details` exists at all (a plain click
 * navigates to the catalog page instead, so the dropdown is the only
 * one), and that the grid keeps its scroll position when the dropdown
 * opens — which it did not until the scroll container was given an
 * overflow, having never scrolled in its life.
 */
test.describe('the album dropdown', () => {
  test.beforeEach(async ({ app }) => {
    await app.getByTestId('nav-albums').click();
    await expect(app.getByTestId('main-content')).toHaveAttribute(
      'data-active-view',
      'albums',
    );
  });

  test.afterEach(async ({ app }) => {
    // The suite shares one backend process in file order, and an open
    // dropdown changes what the next spec's selectors match.
    await closeDropdown(app);
    await app.getByTestId('nav-tracks').click();
  });

  test('Enter on a card draws that album’s tracks', async ({ app }) => {
    await expandCard(app, 1);

    await expect
      .poll(() => dropdownState(app))
      .toMatchObject({ present: true, split: true });

    const state = await dropdownState(app);

    expect(state.rows).toBeGreaterThan(0);
    expect(state.rows).toBe(state.tracks);
  });

  test('a track in it reaches Track Details', async ({ app }) => {
    // The only route from the albums grid to a track. A plain click on
    // a card navigates to `explore-album-details` instead.
    await expandCard(app, 1);
    await expect.poll(() => dropdownState(app)).toMatchObject({
      present: true,
    });

    await app.evaluate(() => {
      document
        .querySelector('cover-grid')
        ?.shadowRoot?.querySelector('album-dropdown')
        ?.shadowRoot?.querySelector('.track-row')
        ?.dispatchEvent(
          new MouseEvent('contextmenu', {
            bubbles: true,
            composed: true,
            clientX: 300,
            clientY: 400,
          }),
        );
    });

    // The panel is shared with the album menu and used to be labelled
    // "Album actions" unconditionally — which nothing could observe
    // while the only menu that could open on a track was unreachable.
    await expect(app.getByRole('menu', { name: 'Track actions' }))
      .toBeVisible();

    await app.getByRole('menuitem', { name: 'Track Details' }).click();

    await expect(
      app.getByRole('dialog', { name: 'Track Details' }),
    ).toBeVisible();

    await app.keyboard.press('Escape');
    await expect(
      app.getByRole('dialog', { name: 'Track Details' }),
    ).toHaveCount(0);
  });

  test('the grid it opens in can be scrolled', async ({ app }) => {
    // `cover-grid` carried the same `.grid-scroll-container` markup as
    // `artists-view` with no rule for the class, so it never scrolled:
    // the container grew to its full content height inside an
    // `overflow: hidden` host and everything past the first screenful
    // was unreachable. Invisible on eight albums, fatal on a real
    // library — measured at 5 000 albums, 186 984 px of content in a
    // 772 px box.
    //
    // The fixture does not scroll at the default viewport, so this
    // shrinks the window until it does. Without that, every form of
    // this assertion passes against a scrollTop that is 0 both times
    // and could not have moved.
    await app.setViewportSize({ width: 900, height: 600 });

    try {
      // The container has to be a scroller at all, which is the thing
      // the defect behind this spec broke and is a property rather
      // than a moment.
      await expect
        .poll(() => scrollRange(app))
        .toMatchObject({ overflowY: 'auto' });

      // **Scrolling it and reading it back are one round trip** (#151).
      //
      // #133 made the guard ask for the range this needs rather than
      // for "scrollable at all", which was necessary and is not
      // sufficient: a guard and the write it guards are separate
      // `evaluate` calls, so the grid can satisfy the range and settle
      // out of it before the write lands. It still does — observed as
      // `Expected 80, Received 10` in the second of three consecutive
      // full-suite runs, with the spec green alone on the same app
      // straight afterwards.
      //
      // Polling harder cannot close a window between two moments; only
      // removing the window can. So the probe sets `scrollTop` and
      // returns what it reads back, in one page-side call, and the
      // poll retries *that* — which also means the assertion is about
      // what the grid did rather than about what it was ready to do.
      await expect.poll(() => scrollTo(app, SCROLL_TARGET)).toBe(SCROLL_TARGET);

      // And the dropdown it opens is on screen, wherever the manager
      // decides that leaves the scroll. It is *not* "the position is
      // preserved": `scrollToShowDropdown` deliberately moves it to
      // reveal the dropdown, which on a library this small is most of
      // the way back to the top (80 → 4, with the content *taller*
      // than before, so it is not clamping). At 5 000 albums, with the
      // expanded card mid-viewport, the same code preserved 2891
      // exactly.
      await expandCard(app, 1);
      await expect.poll(() => dropdownState(app)).toMatchObject({
        present: true,
      });

      await expect.poll(() => dropdownOnScreen(app)).toBe(true);
    } finally {
      await app.setViewportSize({ width: 1440, height: 900 });
    }
  });

  test('the arrow keys still move by a row across the split', async ({
    app,
  }) => {
    // The dropdown draws two virtualizers where there was one, and the
    // roving tab stop indexes the whole album list rather than either
    // half. Home and End have to cross the dropdown, and ArrowDown has
    // to move by a row rather than to the end — which it did not, in
    // any of these grids, because `offsetTop` inside a virtualizer is
    // always 0 and every rendered card counted as one row.
    await app.setViewportSize({ width: 700, height: 700 });

    try {
      await expandCard(app, 1);
      await expect.poll(() => dropdownState(app)).toMatchObject({
        present: true,
        split: true,
      });

      const moves = await app.evaluate(async () => {
        const grid = document.querySelector('cover-grid');
        const root = grid?.shadowRoot;
        const container = root?.querySelector('.grid-scroll-container');
        const cards = () =>
          [...(root?.querySelectorAll<HTMLElement>('.album-card') ?? [])];
        const at = () => {
          const active = root?.activeElement as HTMLElement | null;

          return active ? Number(active.dataset['index']) : null;
        };
        const press = async (key: string) => {
          container?.dispatchEvent(
            new KeyboardEvent('keydown', {
              key,
              bubbles: true,
              composed: true,
            }),
          );
          await new Promise((r) => setTimeout(r, 400));

          return at();
        };

        cards()[0]?.focus();

        const columns = cards().filter(
          (c) =>
            Math.round(c.getBoundingClientRect().top) ===
            Math.round(cards()[0]!.getBoundingClientRect().top),
        ).length;
        const down = await press('ArrowDown');
        const end = await press('End');
        const home = await press('Home');

        return { columns, down, end, home, last: cards().length - 1 };
      });

      expect(moves.columns).toBeGreaterThan(1);
      expect(moves.down).toBe(moves.columns);
      expect(moves.end).toBe(moves.last);
      expect(moves.home).toBe(0);
    } finally {
      await app.setViewportSize({ width: 1440, height: 900 });
    }
  });
});

/** Focus a card and press Enter, which is the only thing that expands one. */
async function expandCard(app: Page, index: number): Promise<void> {
  // The cards come from a virtualizer, so they are not there when the
  // view is: a keydown dispatched into an empty grid hits nothing, and
  // what fails is the *poll* several lines later, which reads as the
  // dropdown being broken rather than as a race. This spec flaked on
  // roughly one run in two on main without this wait.
  await expect.poll(() => cardCount(app)).toBeGreaterThan(index);

  await app.evaluate((i) => {
    const card = document
      .querySelector('cover-grid')
      ?.shadowRoot?.querySelectorAll<HTMLElement>('.album-card')[i];

    card?.focus();
    card?.dispatchEvent(
      new KeyboardEvent('keydown', {
        key: 'Enter',
        bubbles: true,
        composed: true,
      }),
    );
  }, index);
}

async function cardCount(app: Page): Promise<number> {
  return app.evaluate(
    () =>
      document
        .querySelector('cover-grid')
        ?.shadowRoot?.querySelectorAll('.album-card').length ?? 0,
  );
}

async function closeDropdown(app: Page): Promise<void> {
  await app.evaluate(() => {
    const grid = document.querySelector('cover-grid') as
      | (Element & { expandedAlbumId: number | null })
      | null;

    if (grid) grid.expandedAlbumId = null;
  });
}

/** Whether the grid is a scroller at all, which is what the bug broke. */
async function scrollRange(app: Page) {
  return app.evaluate((target) => {
    const sc = document
      .querySelector('cover-grid')
      ?.shadowRoot?.querySelector('.grid-scroll-container');

    return {
      // Reported for the failure message rather than waited on: `room`
      // was the guard #133 strengthened, and #151 is that a guard in
      // its own round trip cannot speak for the write in the next one.
      // `scrollTo` below is the assertion now; this says *why* it did
      // not reach the target when it does not.
      room: !!sc && sc.scrollHeight - sc.clientHeight >= target,
      overflowY: sc ? getComputedStyle(sc).overflowY : '',
    };
  }, SCROLL_TARGET);
}

/**
 * Scroll the grid and report where it actually landed, in one call.
 *
 * The whole point is that the set and the read share a moment: a
 * `scrollTop` write is clamped to the range *at the instant it lands*,
 * so reading it back in a second round trip asks a container that may
 * have re-laid out in between.
 */
async function scrollTo(app: Page, target: number): Promise<number> {
  return app.evaluate((to) => {
    const sc = document
      .querySelector('cover-grid')
      ?.shadowRoot?.querySelector('.grid-scroll-container');

    if (!sc) return -1;

    sc.scrollTop = to;

    return sc.scrollTop;
  }, target);
}

/** Whether the open dropdown is inside the scroll container's viewport. */
async function dropdownOnScreen(app: Page): Promise<boolean> {
  return app.evaluate(() => {
    const grid = document.querySelector('cover-grid');
    const sc = grid?.shadowRoot?.querySelector('.grid-scroll-container');
    const dd = grid?.shadowRoot?.querySelector('album-dropdown');

    if (!sc || !dd) return false;

    const box = sc.getBoundingClientRect();
    const it = dd.getBoundingClientRect();

    return it.bottom > box.top && it.top < box.bottom;
  });
}

async function dropdownState(app: Page) {
  return app.evaluate(() => {
    const grid = document.querySelector('cover-grid') as
      | (Element & { splitMode: boolean; expandedTracks: unknown[] })
      | null;
    const dropdown = grid?.shadowRoot?.querySelector('album-dropdown');

    return {
      present: !!dropdown,
      split: grid?.splitMode ?? false,
      tracks: grid?.expandedTracks?.length ?? 0,
      rows: dropdown?.shadowRoot?.querySelectorAll('.track-row').length ?? 0,
    };
  });
}
