import { test, expect, type Page } from '@playwright/test';
import {
    BASE_URL,
    ADMIN,
    SUPER_ADMIN,
    loginAs,
    registerAndLogin,
    adjustWalletAsAuditor,
    readWalletBalance,
} from '../helpers';

// Pusoy Dos: 4-seat paid tables (Bronze/Silver/Gold), empty seats are filled
// after the match wait and the pot is 4 x the entry. The specs shorten the
// wait to 3s and drive the player's seat with the HINT button (the server's
// own suggestion of a legal play), so every hand runs to the end.

type PusoyConfig = { enabled: boolean; testUsersOnly?: boolean; botFill?: boolean; chat?: boolean; seats?: '3' | '4'; wait?: string; turn?: string };

async function configurePusoy(page: Page, cfg: PusoyConfig): Promise<void> {
    await loginAs(page, ADMIN.mobile, ADMIN.password);
    await page.goto(`${BASE_URL}/wfbo/pusoy/settings`, { waitUntil: 'domcontentloaded' });
    await page.locator('[data-testid="toggle-pd-enabled"]').setChecked(cfg.enabled);
    await page.locator('[data-testid="toggle-pd-test-users-only"]').setChecked(cfg.testUsersOnly ?? false);
    await page.locator('[data-testid="toggle-pd-bot-fill"]').setChecked(cfg.botFill ?? true);
    await page.locator('[data-testid="toggle-pd-chat-enabled"]').setChecked(cfg.chat ?? true);
    await page.selectOption('[data-testid="input-pd-seats"]', cfg.seats ?? '4');
    await page.fill('[data-testid="input-pd-match-wait"]', cfg.wait ?? '10');
    await page.fill('[data-testid="input-pd-turn-seconds"]', cfg.turn ?? '20');
    await page.click('[data-testid="btn-save-pd-settings"]');
    await expect(page.locator('[data-testid="flash-success"]')).toBeVisible();
}

/** Flags a user as a test account via WFBO ▸ Users (super admin only). */
async function markTestAccount(page: Page, mobile: string): Promise<void> {
    await loginAs(page, SUPER_ADMIN.mobile, SUPER_ADMIN.password);
    await page.goto(`${BASE_URL}/wfbo/users?search=${encodeURIComponent(mobile)}`);
    const row = page.locator('[data-testid^="user-row-"]').filter({ hasText: mobile });
    const id  = (await row.getAttribute('data-testid'))!.replace('user-row-', '');
    await page.locator(`[data-testid="form-enable-test-account-${id}"]`).evaluate((f: HTMLFormElement) => f.submit());
    await expect(page.locator(`[data-testid="badge-test-account-${id}"]`)).toBeVisible();
}

/**
 * Plays the viewer's seat until the hand's results show: passes whenever it
 * may, and when holding the lead plays the 3♣ (opening) or its lowest card -
 * both always legal.
 */
async function playHandToTheEnd(page: Page): Promise<void> {
    const result = page.locator('[data-testid="pd-result"]');
    const deadline = Date.now() + 150_000;

    while (Date.now() < deadline) {
        if (await result.isVisible()) {
            return;
        }
        const pass = page.locator('[data-testid="pd-pass"]');
        if (await pass.isEnabled()) {
            await pass.click();
        } else if ((await page.locator('[data-testid="pd-banner"]').textContent())?.includes('YOUR LEAD')) {
            const threeOfClubs = page.locator('[data-testid="pd-card-0"]');
            const card = (await threeOfClubs.count()) > 0 ? threeOfClubs : page.locator('[data-testid="pd-hand"] [data-testid^="pd-card-"]').first();
            await card.click();
            await page.click('[data-testid="pd-play"]');
        }
        await page.waitForTimeout(500);
    }
    throw new Error('The hand did not finish in time.');
}

test.describe.serial('Pusoy — card table', () => {
    test.setTimeout(240_000);

    test.beforeAll(async ({ browser }) => {
        const page = await browser.newPage();
        await configurePusoy(page, { enabled: true, wait: '3' });
        await page.close();
    });

    test.afterAll(async ({ browser }) => {
        const page = await browser.newPage();
        await configurePusoy(page, { enabled: false, testUsersOnly: true });
        await page.close();
    });

    test('test accounts only: hidden from regular players, playable by a test account', async ({ page }) => {
        await configurePusoy(page, { enabled: true, testUsersOnly: true, wait: '3' });

        const player = await registerAndLogin(page);
        await expect(page.locator('[data-testid="play-pusoy-card"]')).toHaveCount(0);
        await page.goto(`${BASE_URL}/play/pusoy`);
        await expect(page).toHaveURL(`${BASE_URL}/`);

        await markTestAccount(page, player.mobile);
        await loginAs(page, player.mobile, player.password);
        await expect(page.locator('[data-testid="play-pusoy-card"]')).toBeVisible();
        await page.goto(`${BASE_URL}/play/pusoy`);
        await expect(page.locator('[data-testid="pd-lobby"]')).toBeVisible();

        // Back to open play for the rest of the specs.
        await configurePusoy(page, { enabled: true, wait: '3' });
    });

    test('home page shows the Pusoy card and the lobby lists the three tables', async ({ page }) => {
        await registerAndLogin(page);
        await expect(page.locator('[data-testid="play-pusoy-card"]')).toBeVisible();
        await expect(page.locator('[data-testid="play-pusoy-card"] img')).toHaveAttribute('src', /pusoy-dos-banner.*\.png/);
        await expect(page.locator('[data-testid="pusoy-pvp-badge"]')).toHaveText('Player vs Player');

        await page.goto(`${BASE_URL}/play/pusoy`);
        await expect(page.locator('[data-testid="pd-lobby"]')).toBeVisible();
        for (const tier of ['bronze', 'silver', 'gold']) {
            await expect(page.locator(`[data-testid="pd-join-${tier}"]`)).toBeVisible();
        }
        // No free practice table.
        await expect(page.locator('[data-testid="pd-join-practice"]')).toHaveCount(0);
        // All three tables are always open.
        for (const tier of ['bronze', 'silver', 'gold']) {
            await expect(page.locator(`[data-testid="pd-join-${tier}"]`)).toBeEnabled();
        }
        await expect(page.locator('[data-testid="pd-join-bronze"]')).toContainText('Winner takes ₱360');
    });

    test('bronze: the table fills, the pot is 4 entries, the hand plays out and settles to the wallet', async ({ page }) => {
        const player = await registerAndLogin(page);
        await adjustWalletAsAuditor(page, player.mobile, '500', 'Pusoy e2e');
        await loginAs(page, player.mobile, player.password);

        await page.goto(`${BASE_URL}/play/pusoy`);
        expect(await readWalletBalance(page)).toBe(500);

        await page.click('[data-testid="pd-join-bronze"]');
        await expect(page.locator('[data-testid="pd-table"]')).toBeVisible();
        // Seated within the 3s wait; the other three seats are filled.
        await expect(page.locator('[data-testid="pd-hand"] [data-testid^="pd-card-"]')).toHaveCount(13, { timeout: 20_000 });
        for (const pos of [1, 2, 3]) {
            await expect(page.locator(`[data-testid="pd-seat-${pos}"]`)).not.toHaveClass(/is-empty/);
        }
        await expect(page.locator('[data-testid="pd-seat-0"]')).toContainText('You');
        await expect(page.locator('[data-testid="pd-prize"]')).toHaveText(/Pot ₱360\.00$/);
        await expect(page.locator('[data-testid="wallet-bar-amount"]')).toContainText('400.00');

        // Tapping a card selects it.
        const firstCard = page.locator('[data-testid="pd-hand"] [data-testid^="pd-card-"]').first();
        await firstCard.click();
        await expect(firstCard).toHaveAttribute('aria-pressed', 'true');
        await firstCard.click();
        await expect(firstCard).toHaveAttribute('aria-pressed', 'false');

        await playHandToTheEnd(page);
        await expect(page.locator('[data-testid="pd-result"] tr')).toHaveCount(4);
        const won = (await page.locator('[data-testid="pd-result-title"]').textContent())?.includes('YOU WIN');
        // Going out first pays the ₱400 pot minus the 10% fee.
        await expect(page.locator('[data-testid="wallet-bar-amount"]')).toContainText(won ? '760.00' : '400.00');

        // No automatic next hand - back to the table list.
        await page.click('[data-testid="pd-back"]');
        await expect(page.locator('[data-testid="pd-lobby"]')).toBeVisible();
        await page.goto(`${BASE_URL}/play/pusoy/history`);
        await expect(page.locator('[data-testid="pd-history-row"]')).toHaveCount(1);
        await expect(page.locator('[data-testid="pd-history-row"]')).toContainText(won ? 'Won' : 'Lose');
    });

    test('a player without enough balance cannot join a paid table', async ({ page }) => {
        await registerAndLogin(page);
        await page.goto(`${BASE_URL}/play/pusoy`);
        await page.click('[data-testid="pd-join-bronze"]');

        await expect(page.locator('.pd-toast')).toContainText('Not enough wallet balance');
        await expect(page.locator('[data-testid="pd-lobby"]')).toBeVisible();
    });

    test('bots off: a lone player waits for a full table, and is dealt in once bots are switched back on', async ({ page, browser }, testInfo) => {
        const admin = await browser.newPage();
        await configurePusoy(admin, { enabled: true, botFill: false, wait: '3' });

        const player = await registerAndLogin(page);
        await adjustWalletAsAuditor(page, player.mobile, '200', 'Pusoy e2e');
        await loginAs(page, player.mobile, player.password);
        await page.goto(`${BASE_URL}/play/pusoy`);
        await page.click('[data-testid="pd-join-bronze"]');

        await expect(page.locator('[data-testid="pd-banner"]')).toContainText('WAITING FOR PLAYERS', { timeout: 15_000 });
        await expect(page.locator('[data-testid="pd-banner"]')).toContainText('1 of 4');
        // Waiting players show in their seats before the deal, the viewer at the bottom.
        await expect(page.locator('[data-testid="pd-seat-0"]')).not.toHaveClass(/is-empty/);
        await expect(page.locator('[data-testid="pd-seat-0"]')).toContainText('You');
        for (const pos of [1, 2, 3]) {
            await expect(page.locator(`[data-testid="pd-seat-${pos}"]`)).toHaveClass(/is-empty/);
        }
        // Well past the 3s match wait: still no cards and nothing taken from the wallet.
        await page.waitForTimeout(6_000);
        await expect(page.locator('[data-testid="pd-hand"] [data-testid^="pd-card-"]')).toHaveCount(0);
        await expect(page.locator('[data-testid="wallet-bar-amount"]')).toContainText('200.00');

        // Chat works while waiting: on a phone, your message bubbles over "You" before any deal.
        await page.setViewportSize({ width: 375, height: 667 });
        await page.addStyleTag({ content: '.sf-toolbar { display: none !important; }' }).catch(() => {});
        await page.click('[data-testid="pd-chat-toggle"]');
        await page.fill('[data-testid="pd-chat-input"]', 'Tara, laro tayo!');
        await page.click('[data-testid="pd-chat-send"]');
        const waitingBubble = page.locator('[data-testid="pd-seat-0"] .pd-seat__bubble');
        await expect(waitingBubble).toHaveText('Tara, laro tayo!');
        await expect(waitingBubble).toHaveClass(/show/);
        await expect(waitingBubble).toBeInViewport({ ratio: 1 });
        // Sending closes the composer on a phone - the bubble is the message.
        await expect(page.locator('[data-testid="pd-chat-panel"]')).toBeHidden();
        await page.screenshot({ path: testInfo.outputPath('waiting-chat-bubble.png') });
        await page.setViewportSize({ width: 1280, height: 720 });

        // Desktop keeps the docked chat open after sending, with the message in the log.
        await expect(page.locator('[data-testid="pd-chat-panel"]')).toBeHidden();
        await page.click('[data-testid="pd-chat-toggle"]');
        await page.fill('[data-testid="pd-chat-input"]', 'Desktop hello');
        await page.click('[data-testid="pd-chat-send"]');
        await expect(page.locator('[data-testid="pd-chat-list"]')).toContainText('Desktop hello');
        await expect(page.locator('[data-testid="pd-chat-panel"]')).toBeVisible();

        await configurePusoy(admin, { enabled: true, botFill: true, wait: '3' });
        await admin.close();

        await expect(page.locator('[data-testid="pd-hand"] [data-testid^="pd-card-"]')).toHaveCount(13, { timeout: 30_000 });
        await expect(page.locator('[data-testid="wallet-bar-amount"]')).toContainText('100.00');
        await page.click('[data-testid="pd-leave"]');
    });

    test('3 players per table: 17 cards (18 for the 3♣ holder), two opponents, pot 3 entries', async ({ page, browser }) => {
        const admin = await browser.newPage();
        await configurePusoy(admin, { enabled: true, seats: '3', wait: '3' });

        const player = await registerAndLogin(page);
        await adjustWalletAsAuditor(page, player.mobile, '200', 'Pusoy e2e');
        await loginAs(page, player.mobile, player.password);
        await page.goto(`${BASE_URL}/play/pusoy`);
        await expect(page.locator('[data-testid="pd-join-bronze"]')).toContainText('Winner takes ₱270');
        await page.click('[data-testid="pd-join-bronze"]');

        const cards = page.locator('[data-testid="pd-hand"] [data-testid^="pd-card-"]');
        await expect.poll(async () => cards.count(), { timeout: 20_000 }).toBeGreaterThanOrEqual(17);
        expect([17, 18]).toContain(await cards.count());
        await expect(page.locator('[data-testid="pd-prize"]')).toHaveText(/Pot ₱270\.00$/);
        // Left and right opponents only - nobody across.
        await expect(page.locator('[data-testid="pd-seat-1"]')).not.toHaveClass(/is-empty/);
        await expect(page.locator('[data-testid="pd-seat-3"]')).not.toHaveClass(/is-empty/);
        await expect(page.locator('[data-testid="pd-seat-2"]')).toBeHidden();

        await page.click('[data-testid="pd-leave"]');
        await configurePusoy(admin, { enabled: true, wait: '3' });
        await admin.close();
    });

    test('a player who never acts only passes - their cards are never played - and returns to the tables after the hand', async ({ page, browser }) => {
        const admin = await browser.newPage();
        await configurePusoy(admin, { enabled: true, wait: '3', turn: '5' });

        const player = await registerAndLogin(page);
        await adjustWalletAsAuditor(page, player.mobile, '200', 'Pusoy e2e');
        await loginAs(page, player.mobile, player.password);
        await page.goto(`${BASE_URL}/play/pusoy`);
        await page.click('[data-testid="pd-join-bronze"]');
        await expect(page.locator('[data-testid="pd-hand"] [data-testid^="pd-card-"]')).toHaveCount(13, { timeout: 20_000 });

        // Hands off: every turn times out. On the player's turn the clock is shown big -
        // a countdown bar above PLAY/PASS plus seconds on their seat, red for the last 5s.
        const turnBar = page.locator('[data-testid="pd-turnbar"]');
        await expect(turnBar).toBeVisible({ timeout: 120_000 });
        await expect(page.locator('[data-testid="pd-turnbar-label"]')).toHaveText(/^YOUR TURN · \d+s$/);
        await expect(turnBar).toHaveClass(/is-urgent/);
        await expect(page.locator('[data-testid="pd-seat-timer-0"]')).toHaveText(/^\d+s$/);
        await expect(page.locator('[data-testid="pd-seat-0"]')).toHaveClass(/is-urgent/);
        await expect(turnBar).toBeHidden({ timeout: 15_000 });

        await expect(page.locator('[data-testid="pd-result"]')).toBeVisible({ timeout: 240_000 });
        await expect(page.locator('[data-testid="pd-result-sub"]')).toContainText('with 13 cards left');
        await expect(page.locator('[data-testid="pd-hand"] [data-testid^="pd-card-"]')).toHaveCount(13);

        // Then straight back to the table list - no next hand is dealt.
        await expect(page.locator('[data-testid="pd-lobby"]')).toBeVisible({ timeout: 20_000 });
        await expect(page.locator('[data-testid="wallet-bar-amount"]')).toContainText('100.00');

        await configurePusoy(admin, { enabled: true, wait: '3' });
        await admin.close();
    });

    test('two players from the same IP are never seated at the same table', async ({ browser }) => {
        const ctxA = await browser.newContext();
        const ctxB = await browser.newContext();
        const a = await ctxA.newPage();
        const b = await ctxB.newPage();

        for (const page of [a, b]) {
            const player = await registerAndLogin(page);
            await adjustWalletAsAuditor(page, player.mobile, '200', 'Pusoy e2e');
            await loginAs(page, player.mobile, player.password);
            await page.goto(`${BASE_URL}/play/pusoy`);
        }
        await a.click('[data-testid="pd-join-bronze"]');
        await b.click('[data-testid="pd-join-bronze"]');

        await expect(a.locator('[data-testid="pd-hand"] [data-testid^="pd-card-"]')).toHaveCount(13, { timeout: 20_000 });
        await expect(b.locator('[data-testid="pd-hand"] [data-testid^="pd-card-"]')).toHaveCount(13, { timeout: 20_000 });
        // Both test browsers share one IP, so neither sees the other ("Test U.") at their table.
        for (const page of [a, b]) {
            await expect(page.locator('[data-testid^="pd-seat-"]').filter({ hasText: 'Test U.' })).toHaveCount(0);
        }

        await a.click('[data-testid="pd-leave"]');
        await b.click('[data-testid="pd-leave"]');
        await ctxA.close();
        await ctxB.close();
    });

    test('table chat: typed messages reach the table, bad words are masked, admins see the original', async ({ page, browser }, testInfo) => {
        const player = await registerAndLogin(page);
        await adjustWalletAsAuditor(page, player.mobile, '200', 'Pusoy e2e');
        await loginAs(page, player.mobile, player.password);
        await page.goto(`${BASE_URL}/play/pusoy`);
        // Nothing to chat about in the lobby.
        await expect(page.locator('[data-testid="pd-chat-toggle"]')).toBeHidden();

        await page.click('[data-testid="pd-join-bronze"]');
        await expect(page.locator('[data-testid="pd-hand"] [data-testid^="pd-card-"]')).toHaveCount(13, { timeout: 20_000 });

        // The chat button sits right beside PLAY (no more BY SUIT button there, nor a chat button by Leave).
        await expect(page.locator('#pd-actions [data-testid="pd-chat-toggle"]')).toBeVisible();
        await expect(page.locator('[data-testid="pd-sort"]')).toHaveCount(0);
        const playBox = (await page.locator('[data-testid="pd-play"]').boundingBox())!;
        const toggleBox = (await page.locator('[data-testid="pd-chat-toggle"]').boundingBox())!;
        expect(toggleBox.x).toBeGreaterThanOrEqual(playBox.x + playBox.width);
        expect(Math.abs((toggleBox.y + toggleBox.height / 2) - (playBox.y + playBox.height / 2))).toBeLessThan(4);
        await page.click('[data-testid="pd-chat-toggle"]');
        const panel = page.locator('[data-testid="pd-chat-panel"]');
        await expect(panel).toBeVisible();
        // Desktop: the chat docks beside the table, not over it.
        await expect(page.locator('[data-testid="pd-chat-dock"] [data-testid="pd-chat-panel"]')).toBeVisible();
        const panelBox = (await panel.boundingBox())!;
        const shellBox = (await page.locator('#pd-shell').boundingBox())!;
        expect(panelBox.x).toBeGreaterThanOrEqual(shellBox.x + shellBox.width);
        // The docked panel keeps just its ×; the CLOSE button is for the phone bar.
        await expect(page.locator('[data-testid="pd-chat-done"]')).toBeHidden();

        // A message: in the log and in a bubble over your own seat.
        await page.fill('[data-testid="pd-chat-input"]', 'Good luck!');
        await page.click('[data-testid="pd-chat-send"]');
        const messages = page.locator('[data-testid="pd-chat-msg"]');
        await expect(messages).toHaveCount(1);
        await expect(messages.first()).toContainText('You');
        await expect(messages.first()).toContainText('Good luck!');
        await expect(page.locator('[data-testid="pd-seat-0"] .pd-seat__bubble')).toContainText('Good luck!');

        // Typed (Enter sends); the bad word is masked for players.
        await page.fill('[data-testid="pd-chat-input"]', 'ang bobo mo');
        await page.press('[data-testid="pd-chat-input"]', 'Enter');
        await expect(messages).toHaveCount(2);
        await expect(messages.nth(1)).toContainText('ang **** mo');
        await expect(page.locator('[data-testid="pd-chat-input"]')).toHaveValue('');
        // Messages are capped short enough to show whole in a seat bubble.
        await expect(page.locator('[data-testid="pd-chat-input"]')).toHaveAttribute('maxlength', '40');
        // Sending stays in the game - no full-page loading overlay.
        await expect(page.locator('#loading-overlay')).toBeHidden();
        await page.screenshot({ path: testInfo.outputPath('table-chat-open.png') });

        // On a phone there's no log to read: the chat is a composer bar along the bottom and
        // messages show as bubbles on the seats, so the table and your hand stay in view.
        await page.setViewportSize({ width: 375, height: 667 }); // a small phone: the bar covers your own seat
        await page.reload();
        // The Symfony dev toolbar (fixed, bottom of viewport) would sit over the bar.
        await page.addStyleTag({ content: '.sf-toolbar { display: none !important; }' }).catch(() => {});
        await page.click('[data-testid="pd-chat-toggle"]');
        await expect(panel).toBeVisible();
        await expect(page.locator('[data-testid="pd-chat-list"]')).toBeHidden();
        const bar = (await panel.boundingBox())!;
        const firstCard = (await page.locator('[data-testid="pd-hand"] [data-testid^="pd-card-"]').first().boundingBox())!;
        expect(bar.y + bar.height).toBe(667);
        expect(bar.y).toBeGreaterThan(firstCard.y);
        // No quick messages - just the input, SEND and CLOSE.
        await expect(page.locator('.pd-chat-phrase')).toHaveCount(0);
        // Nothing fixed on the page (wallet bar) sits over the input row.
        await page.locator('[data-testid="pd-chat-input"]').click({ trial: true });
        await page.locator('[data-testid="pd-chat-send"]').click({ trial: true });
        await page.screenshot({ path: testInfo.outputPath('table-chat-mobile.png') });
        // CLOSE beside SEND gets you straight back to PLAY/PASS.
        await page.click('[data-testid="pd-chat-done"]');
        await expect(panel).toBeHidden();
        await expect(page.locator('[data-testid="pd-play"]')).toBeInViewport();

        await page.click('[data-testid="pd-chat-toggle"]');
        await page.fill('[data-testid="pd-chat-input"]', 'Thanks!');
        await page.click('[data-testid="pd-chat-send"]');
        // Sending closes the composer - the table and PLAY/PASS are back in view ...
        await expect(panel).toBeHidden();
        await expect(page.locator('[data-testid="pd-play"]')).toBeInViewport();
        // ... and your message pops up on top of "You", fully in view.
        const mine = page.locator('[data-testid="pd-seat-0"] .pd-seat__bubble');
        await expect(mine).toHaveText('Thanks!');
        await expect(mine).toHaveClass(/show/);
        await expect(mine).toBeInViewport({ ratio: 1 });
        const mineBox = (await mine.boundingBox())!;
        const youBox = (await page.locator('[data-testid="pd-seat-0"]').boundingBox())!;
        expect(mineBox.y + mineBox.height).toBeLessThanOrEqual(youBox.y);
        expect(youBox.y + youBox.height).toBeLessThanOrEqual(bar.y);
        await page.screenshot({ path: testInfo.outputPath('table-chat-mobile-sent.png') });
        await page.setViewportSize({ width: 1280, height: 720 });

        // Admins see the original on the hand's page.
        const admin = await browser.newPage();
        await loginAs(admin, ADMIN.mobile, ADMIN.password);
        await admin.goto(`${BASE_URL}/wfbo/pusoy/results`);
        await admin.locator('[data-testid="pd-hand-link"]').first().click();
        const log = admin.locator('[data-testid="pd-chat-log-row"]');
        await expect(log).toHaveCount(3);
        await expect(log.nth(0)).toContainText('Good luck!');
        await expect(log.nth(1)).toContainText('ang bobo mo');
        await expect(log.nth(2)).toContainText('Thanks!');

        // Turned off: the chat button goes away.
        await configurePusoy(admin, { enabled: true, chat: false, wait: '3' });
        await expect(page.locator('[data-testid="pd-chat-toggle"]')).toBeHidden({ timeout: 10_000 });
        await expect(page.locator('[data-testid="pd-chat-panel"]')).toBeHidden();

        await configurePusoy(admin, { enabled: true, wait: '3' });
        await admin.close();
        await page.click('[data-testid="pd-leave"]');
    });
    test('private table: create one with a code, share the invite link, friends join with the code, the host deals', async ({ page, browser }, testInfo) => {
        const host = await registerAndLogin(page);
        await adjustWalletAsAuditor(page, host.mobile, '500', 'Pusoy e2e');
        await loginAs(page, host.mobile, host.password);
        await page.goto(`${BASE_URL}/play/pusoy`);

        // Not a public table: the host picks the entry (tier) and the number of players.
        await expect(page.locator('[data-testid="pd-private"]')).toBeVisible();
        await expect(page.locator('[data-testid="pd-private"]')).toContainText('10% house fee');
        await page.selectOption('[data-testid="pd-private-tier"]', 'bronze');
        await page.selectOption('[data-testid="pd-private-seats"]', '3');
        await page.click('[data-testid="pd-private-create"]');

        await expect(page.locator('[data-testid="pd-table"]')).toBeVisible();
        const badge = page.locator('[data-testid="pd-private-badge"]');
        await expect(badge).toBeVisible();
        const code = (await page.locator('[data-testid="pd-private-code-label"]').textContent())!.trim();
        expect(code).toMatch(/^[A-HJ-NP-Z2-9]{6}$/);
        await expect(page.locator('[data-testid="pd-prize"]')).toContainText('PRIVATE BRONZE');
        await expect(page.locator('[data-testid="pd-prize"]')).toHaveText(/Pot ₱270\.00$/);
        await expect(page.locator('[data-testid="pd-banner"]')).toContainText('WAITING FOR FRIENDS');
        await expect(page.locator('[data-testid="pd-banner"]')).toContainText('1 of 3');
        await expect(page.locator('[data-testid="pd-seat-0"]')).toContainText('You');
        // No bots and no deal until every seat is taken - the host's DEAL stays off.
        await expect(page.locator('[data-testid="pd-deal"]')).toBeVisible();
        await expect(page.locator('[data-testid="pd-deal"]')).toBeDisabled();
        await expect(page.locator('[data-testid="pd-private-status"]')).toContainText('Waiting for 2 more players');
        // Nothing is charged until a hand is dealt.
        await expect(page.locator('[data-testid="wallet-bar-amount"]')).toContainText('500.00');
        await page.screenshot({ path: testInfo.outputPath('private-table-waiting.png') });

        // A friend opens the invite link: the code is filled in for them.
        const friendCtx = await browser.newContext();
        const friend = await friendCtx.newPage();
        const friendUser = await registerAndLogin(friend);
        await adjustWalletAsAuditor(friend, friendUser.mobile, '500', 'Pusoy e2e');
        await loginAs(friend, friendUser.mobile, friendUser.password);
        await friend.goto(`${BASE_URL}/play/pusoy?code=${code.toLowerCase()}`);
        await expect(friend.locator('[data-testid="pd-private-code"]')).toHaveValue(code);

        // A wrong code finds nothing.
        await friend.fill('[data-testid="pd-private-code"]', 'ZZZZ22');
        await friend.click('[data-testid="pd-private-join"]');
        await expect(friend.locator('[data-testid="pd-private-error"]')).toContainText('No open table with that code.');

        // The right code finds the table - but both test browsers share one IP, and
        // private tables keep the same-IP rule, so this friend is turned away.
        await friend.fill('[data-testid="pd-private-code"]', code);
        await friend.click('[data-testid="pd-private-join"]');
        await expect(friend.locator('[data-testid="pd-private-error"]')).toContainText('Someone on your network is already at that table.');
        await expect(friend.locator('[data-testid="pd-lobby"]')).toBeVisible();
        await friend.screenshot({ path: testInfo.outputPath('private-table-join.png') });

        // The host leaves - the last one out closes the table and its code stops working.
        await page.click('[data-testid="pd-leave"]');
        await expect(page.locator('[data-testid="pd-lobby"]')).toBeVisible();
        await friend.fill('[data-testid="pd-private-code"]', code);
        await friend.click('[data-testid="pd-private-join"]');
        await expect(friend.locator('[data-testid="pd-private-error"]')).toContainText('No open table with that code.');
        await friendCtx.close();
    });

    test('private table: opening one needs the entry in the wallet', async ({ page }) => {
        await registerAndLogin(page);
        await page.goto(`${BASE_URL}/play/pusoy`);
        await page.selectOption('[data-testid="pd-private-tier"]', 'gold');
        await page.click('[data-testid="pd-private-create"]');

        await expect(page.locator('[data-testid="pd-private-error"]')).toContainText('Not enough wallet balance');
        await expect(page.locator('[data-testid="pd-lobby"]')).toBeVisible();
    });
});
