import { expect, test, type Page } from './fixtures';

// Behaviour/display coverage for the polished game screen, driven entirely by the mock
// transport (no backend). These lock the round-1..4 interactions so future UI edits
// surface as a failing assertion — to be re-agreed or fixed. The pure logic behind them
// (placement, check-word, board labels, result badges) is unit-tested separately.

async function openGame(page: Page): Promise<void> {
  await page.goto('/');
  await page.getByRole('button', { name: /guest/i }).click();
  await page.getByRole('button', { name: /Ann/ }).click(); // the seeded active game vs Ann
  await expect(page.locator('[data-cell]').first()).toBeVisible();
  // Wait for the screen-slide transition to settle so only the game pane remains; until it
  // does, the leaving lobby pane is also in the DOM, which would make shared locators ambiguous.
  await expect(page.locator('.pane')).toHaveCount(1);
}

test('offline shows the Connecting indicator and softly disables server actions', async ({ page }) => {
  await openGame(page);
  // The Exchange/Pass tab is a server action; on my turn it is live (pass is always available,
  // so unlike before it no longer goes inert when the bag empties).
  const draw = page.locator('.tab').first();
  await expect(draw).toBeEnabled();
  await expect(page.getByText('Connecting…')).toHaveCount(0);

  // Drop the connection (mock-only hook): the header swaps the title for the spinner +
  // "Connecting…", and the server action goes inert.
  await page.evaluate(() => (window as unknown as { __conn: { offline(): void } }).__conn.offline());
  await expect(page.getByText('Connecting…')).toBeVisible();
  await expect(draw).toBeDisabled();

  // Reconnect: the indicator clears and the action is live again.
  await page.evaluate(() => (window as unknown as { __conn: { online(): void } }).__conn.online());
  await expect(page.getByText('Connecting…')).toHaveCount(0);
  await expect(draw).toBeEnabled();
});

test('placing a tile and confirming via ✅ commits the move', async ({ page }) => {
  await openGame(page);
  await page.locator('.rack .tile').first().click();
  await page.locator('[data-cell]:not(.filled)').nth(30).click();
  await expect(page.locator('[data-cell].pending')).toHaveCount(1);

  await page.locator('.make').click(); // ✅ commits the move directly (no popover)

  // After the commit the placement is cleared: no pending tile, no ✅ control.
  await expect(page.locator('[data-cell].pending')).toHaveCount(0);
  await expect(page.locator('.make')).toBeHidden();
});

// Regression: taking a hint must NOT fire the post-move interstitial. Firing on the hint interrupted
// placing the preview and reverted the board on the ad's close while the hint stayed spent (the
// reported bug). In mock mode the ad stub shows an "ad fired" toast, standing in for a real VK ad.
test('taking a hint does not fire the interstitial', async ({ page }) => {
  await openGame(page);
  // Take a hint (the control confirms on a second tap). It produces a legal preview, so the ✅
  // control appears — proof the hint fired.
  const hint = page.getByRole('button', { name: 'Hint' });
  await hint.click();
  await hint.click();
  await expect(page.locator('.make')).toBeVisible();
  // A spurious ad would have toasted by now; none may.
  await page.waitForTimeout(400);
  await expect(page.getByText('ad fired')).toHaveCount(0);
});

// The interstitial fires once the move is CONFIRMED (never on the hint / pass / exchange / resign).
test('confirming a move fires the interstitial', async ({ page }) => {
  await openGame(page);
  await page.locator('.rack .tile').first().click();
  await page.locator('[data-cell]:not(.filled)').nth(30).click();
  await expect(page.locator('[data-cell].pending')).toHaveCount(1);

  await page.locator('.make').click();
  await expect(page.getByText('ad fired')).toBeVisible();
});

test('a placed tile is saved as a draft and restored on reopening the game', async ({ page }) => {
  await openGame(page);
  await page.locator('.rack .tile').first().click();
  await page.locator('[data-cell]:not(.filled)').nth(30).click();
  await expect(page.locator('[data-cell].pending')).toHaveCount(1);
  await page.waitForTimeout(600); // let the debounced draft save flush to the mock store

  // Leave the game and reopen it. The mock keeps the saved composition, so the pending tile is
  // restored without re-placing it.
  await page.evaluate(() => (location.hash = '/'));
  await page.getByRole('button', { name: /Ann/ }).click();
  await expect(page.locator('[data-cell]').first()).toBeVisible();
  await expect(page.locator('[data-cell].pending')).toHaveCount(1);
});

test('new game: variant buttons show a rules summary and the move-limit', async ({ page }) => {
  await page.goto('/');
  await page.getByRole('button', { name: /guest/i }).click();
  await page.getByRole('button', { name: /🎲/ }).click(); // lobby tab bar -> auto-match
  await expect(page.locator('.vrules').first()).toBeVisible(); // per-variant rules summary
  await expect(page.locator('.movelimit')).toBeVisible(); // turn-time under the buttons
});

test('new game: auto-match shows the off-by-default rule toggle from the start (no layout jump on selection)', async ({ page }) => {
  await page.goto('/');
  await page.getByRole('button', { name: /guest/i }).click();
  await page.getByRole('button', { name: /🎲/ }).click(); // auto-match
  // Several variants are offered, so nothing is selected: Start is disabled. The rule toggle is shown
  // from the start (a Russian variant is available), so selecting one does not shift the layout.
  const start = page.getByRole('button', { name: /Start game/i });
  await expect(start).toBeDisabled();
  const toggle = page.getByLabel('Multiple words per turn');
  await expect(toggle).toBeVisible();
  await expect(toggle).not.toBeChecked();
  // Selecting the Russian Scrabble variant highlights it and enables Start; the toggle stays put.
  await page.locator('.variant', { hasText: 'Скрэббл' }).click();
  await expect(page.locator('.variant.selected')).toHaveCount(1);
  await expect(start).toBeEnabled();
  await expect(toggle).toBeVisible();
  await expect(toggle).not.toBeChecked();
});

test('single-word game shows the one-word rule label in the history header', async ({ page }) => {
  await page.goto('/');
  await page.getByRole('button', { name: /guest/i }).click();
  // g3 is a finished Russian single-word game (vs Rick); open it from the lobby.
  await page.getByRole('button', { name: /Rick/ }).click();
  await expect(page.locator('[data-cell]').first()).toBeVisible();
  await expect(page.locator('.pane')).toHaveCount(1);
  // The status bar no longer carries a "1️⃣" badge (dropped as noise); the rule surfaces in the
  // history header instead. Tapping the scoreboard opens the history with the spelled-out label.
  await expect(page.locator('.oneword')).toHaveCount(0);
  await page.locator('.scoreboard').click();
  await expect(page.locator('.oneword-label')).toBeVisible();
});

test('a pending tile recalls on double-tap, not on a single tap', async ({ page }) => {
  await openGame(page);
  await page.locator('.rack .tile').first().click();
  await page.locator('[data-cell]:not(.filled)').nth(30).click();
  await expect(page.locator('[data-cell].pending')).toHaveCount(1);

  // A single tap must NOT recall it (recall was too easy to trigger).
  await page.waitForTimeout(350); // clear the double-tap window from the placing tap
  await page.locator('[data-cell].pending').first().click();
  await expect(page.locator('[data-cell].pending')).toHaveCount(1);

  // A double-tap (two synchronous clicks on the same cell) recalls it to the rack.
  await page.locator('[data-cell].pending').first().evaluate((el: HTMLElement) => {
    el.click();
    el.click();
  });
  await expect(page.locator('[data-cell].pending')).toHaveCount(0);
});

test('the board is always a gapless checkerboard; no grid-lines setting', async ({ page }) => {
  await openGame(page);
  await expect(page.locator('.grid')).toBeVisible();
  // The board has no lined variant any more: its cells sit flush, with no inter-cell gap.
  await expect(page.locator('.grid')).toHaveCSS('column-gap', '0px');

  // Settings no longer offers a grid-lines toggle.
  await page.evaluate(() => (location.hash = '/settings'));
  await expect(page.locator('.gridlines')).toHaveCount(0);
});

test('shuffle reorders the rack but keeps the same tiles', async ({ page }) => {
  await openGame(page);
  const before = await page.locator('.rack .tile').allTextContents();
  expect(before.length).toBeGreaterThan(1);

  await page.locator('button:has-text("🔀")').click(); // the shuffle tab (no pending tiles)
  await page.waitForTimeout(650); // let the hop animation settle

  // Same multiset of tiles after the shuffle — no tile is dropped or duplicated.
  const after = await page.locator('.rack .tile').allTextContents();
  expect([...after].sort()).toEqual([...before].sort());
});

test('history slides the board down on a score tap and closes on a board tap', async ({ page }) => {
  await openGame(page);
  await page.locator('.scoreboard').click(); // tapping the score bar opens the history
  await expect(page.locator('.history')).toBeVisible();
  await expect(page.locator('.boardwrap.slid')).toBeVisible();

  // Tap the (now inert) board's visible top strip below the history to close it.
  await page.locator('.boardwrap').click({ position: { x: 30, y: 12 } });
  await expect(page.locator('.history')).toBeHidden();
});

test('history: a swipe-up close does not make a follow-up score tap jump', async ({ page }) => {
  await openGame(page);
  await page.locator('.scoreboard').click();
  await expect(page.locator('.boardwrap.slid')).toBeVisible();

  // Swipe up on the inert board to close it. Dispatched on the board so the gesture is
  // engine-deterministic — a mouse swipe over a pointer-events:none child is simulated
  // inconsistently across engines, whereas a real touch swipe lands the same way this does.
  const bw = page.locator('.boardwrap');
  const box = (await bw.boundingBox())!;
  const cx = box.x + box.width / 2;
  await bw.dispatchEvent('pointerdown', { clientX: cx, clientY: 500, bubbles: true });
  await bw.dispatchEvent('pointermove', { clientX: cx, clientY: 450, bubbles: true });
  await expect(page.locator('.history')).toBeHidden();

  // A score tap now cleanly reopens the history — the stale-open "jump" no longer happens.
  await page.locator('.scoreboard').click();
  await expect(page.locator('.history')).toBeVisible();
  await expect(page.locator('.boardwrap.slid')).toBeVisible();
});

test('Exchange/Pass dialog: a selection exchanges, no selection offers a pass', async ({ page }) => {
  await openGame(page);
  await page.locator('button:has-text("🔄")').click(); // the merged Exchange/Pass tab
  await expect(page.locator('.exch')).toBeVisible();

  // With nothing selected the confirm button offers a pass; selecting tiles flips it to an
  // exchange of that many (the two are distinct game actions behind one dialog).
  const confirm = page.locator('button.confirm');
  await expect(confirm).toHaveText('Pass without exchanging');
  await page.locator('.etile').first().click();
  await expect(page.locator('.etile.sel')).toHaveCount(1);
  await expect(confirm).toHaveText('Exchange 1');
  await confirm.click();
  await expect(page.locator('.exch')).toBeHidden();
});

test('passing goes through the Exchange/Pass dialog with no tiles selected', async ({ page }) => {
  await openGame(page);
  const draw = page.locator('button:has-text("🔄")'); // the merged Exchange/Pass tab
  await expect(draw).toBeEnabled();
  await draw.click();
  // No tiles selected -> the confirm button passes the turn (a pass, not an exchange of zero).
  await page.locator('button.confirm').click();
  await expect(page.locator('.exch')).toBeHidden();
  // The pass hands the turn over, so the tab goes inert.
  await expect(draw).toBeDisabled();
});

test('check-word sanitises input and shows a verdict', async ({ page }) => {
  await openGame(page);
  await page.locator('.scoreboard').click(); // open the history
  await page.getByRole('button', { name: 'Chat' }).click(); // 💬 -> comms hub
  await expect(page.locator('.pane')).toHaveCount(1);
  await page.getByRole('button', { name: 'Dictionary' }).click(); // 🔎 -> dictionary tab

  const input = page.locator('.check input');
  await input.fill('qz9!a'); // digits/punctuation dropped, letters upper-cased
  await expect(input).toHaveValue('QZA');

  await page.locator('.check button').click(); // Check (enabled: length 3)
  await expect(page.locator('.ok, .bad')).toBeVisible();

  // A found word offers an external dictionary lookup link that opens in a new tab; the
  // verdict still carries the complaint button.
  const lookup = page.getByRole('link', { name: 'Look it up' });
  await expect(lookup).toBeVisible();
  await expect(lookup).toHaveAttribute('target', '_blank');
  await expect(lookup).toHaveAttribute('href', /qza$/);
});

test('dropping the game ends it and shows the result', async ({ page }) => {
  await openGame(page);
  await page.locator('.scoreboard').click(); // open the history
  await page.getByRole('button', { name: 'Drop game' }).click(); // 🏁 in the history header
  await page.locator('button.danger').click(); // confirm in the modal
  await expect(page.locator('.turnstrip.result')).toBeVisible();
});

test('resigning reveals the full board: closes the history and zooms out', async ({ page }) => {
  await openGame(page);

  // Zoom in (double-tap an empty cell), then open the history — the two states the resign
  // confirmation must clear so the resigned game shows its full board.
  await page
    .locator('[data-cell]:not(.filled)')
    .nth(20)
    .evaluate((el: HTMLElement) => {
      el.click();
      el.click();
    });
  await expect(page.locator('.viewport.zoomed')).toBeVisible();
  await page.locator('.scoreboard').click(); // open the history (the 🏁 lives in its header)
  await expect(page.locator('.boardwrap.slid')).toBeVisible();

  await page.getByRole('button', { name: 'Drop game' }).click(); // 🏁
  await page.locator('button.danger').click(); // confirm

  await expect(page.locator('.turnstrip.result')).toBeVisible(); // the game ended
  await expect(page.locator('.history')).toHaveCount(0); // history auto-closed (portrait)
  await expect(page.locator('.viewport.zoomed')).toHaveCount(0); // board zoomed out
});

test('a placed tile drags from one board cell to another (relocation)', async ({ page }) => {
  await openGame(page);
  await page.locator('.rack .tile').first().click();
  await page.locator('[data-cell]:not(.filled)').nth(30).click();
  const pending = page.locator('[data-cell].pending');
  await expect(pending).toHaveCount(1);
  const from = `${await pending.first().getAttribute('data-row')},${await pending.first().getAttribute('data-col')}`;

  const target = page.locator('[data-cell]:not(.filled):not(.pending)').nth(45);
  const fb = await pending.first().boundingBox();
  const tb = await target.boundingBox();
  // Pointer-drag the placed tile to a new cell (mouse events synthesise pointer events).
  await page.mouse.move(fb!.x + fb!.width / 2, fb!.y + fb!.height / 2);
  await page.mouse.down();
  await page.mouse.move(tb!.x + tb!.width / 2, tb!.y + tb!.height / 2, { steps: 10 });
  await page.mouse.up();

  // Still exactly one pending tile (relocated, not duplicated), now at a different cell.
  await expect(pending).toHaveCount(1);
  const to = `${await pending.first().getAttribute('data-row')},${await pending.first().getAttribute('data-col')}`;
  expect(to).not.toBe(from);
});

test('a placed tile drags back to a chosen rack slot (recall-to-position)', async ({ page }) => {
  await openGame(page);
  // Place the first rack tile (slot 0) on the board, remembering its letter.
  await page.locator('.rack .tile').first().click();
  const placed = (await page.locator('.rack .tile .letter').first().textContent()) ?? '';
  await page.locator('[data-cell]:not(.filled)').nth(30).click();
  const pending = page.locator('[data-cell].pending');
  await expect(pending).toHaveCount(1);

  // Drag it from the board back onto a later rack slot (the 4th visible tile), not its origin.
  const fb = await pending.first().boundingBox();
  const slot = await page.locator('[data-rack] .tile').nth(3).boundingBox();
  await page.mouse.move(fb!.x + fb!.width / 2, fb!.y + fb!.height / 2);
  await page.mouse.down();
  await page.mouse.move(slot!.x + 2, slot!.y + slot!.height / 2, { steps: 10 });
  await page.mouse.up();

  // Recalled (no pending) and reinserted at the drop slot — slot 3, not its origin slot 0.
  await expect(pending).toHaveCount(0);
  await expect(page.locator('[data-rack] .tile .letter').nth(3)).toHaveText(placed);
});

test('comms hub: chat and dictionary share a screen, back returns to the game', async ({ page }) => {
  await openGame(page);
  await page.locator('.scoreboard').click(); // open the history
  await page.getByRole('button', { name: 'Chat' }).click(); // 💬 in the history header
  await expect(page).toHaveURL(/\/game\/g1\/chat$/);
  await expect(page.locator('.pane')).toHaveCount(1); // let the slide transition settle
  await expect(page.locator('.chat')).toBeVisible();

  // The Dictionary tab switches in place (same screen, no navigation).
  await page.getByRole('button', { name: 'Dictionary' }).click();
  await expect(page.locator('.check input')).toBeVisible();

  // The header back chevron returns to the game.
  await expect(page.locator('.back')).toHaveCount(1);
  await page.locator('.back').click();
  await expect(page).toHaveURL(/\/game\/g1$/);
  await expect(page.locator('.pane')).toHaveCount(1);
});

test('the board-label mode in Settings changes the on-board labels', async ({ page }) => {
  await openGame(page);
  // beginner (default) renders split "3× / word" labels.
  await expect(page.locator('.bsplit').first()).toBeVisible();
  await expect(page.locator('.b1')).toHaveCount(0);

  // Switch to "classic" in Settings (in-SPA hash nav keeps the guest session).
  await page.evaluate(() => (location.hash = '/settings'));
  await page.locator('.seg').nth(2).locator('.opt').nth(1).click(); // board labels -> classic
  await page.evaluate(() => (location.hash = '/game/g1'));

  // classic renders single "3W"/"2L" labels and no split labels.
  await expect(page.locator('.b1').first()).toBeVisible();
  await expect(page.locator('.bsplit')).toHaveCount(0);
});
