import { Page, Dialog, expect } from '@playwright/test';

export const BASE_URL = process.env.APP_URL ?? 'http://localhost:8000';

// Returns a YYYY-MM-DD date string that is unique per call (any two calls whose
// Date.now() values differ by a non-multiple of 100000ms get distinct dates, which
// in practice means any two calls at all, since page interactions always take a
// measurable, non-round number of milliseconds).
// Event lists are sorted by event date, not creation order, so tests that need to
// find "the row I just created" should locate it by this unique date text rather
// than by list position (.first()/.last()), which is unreliable when multiple spec
// files share the same un-isolated database across a test run.
export function uniqueFutureDate(): string {
    const anchor = Date.UTC(2030, 0, 1);
    const dayOffset = Date.now() % 100000; // 0..99999 days (~273 years), stays in valid Date range
    const ms = anchor + dayOffset * 86400000;
    return new Date(ms).toISOString().slice(0, 10);
}

// Fixture users seeded by AppFixtures — login uses mobile number
export const USER       = { mobile: '+10000000001', password: 'UserPass1!' };
export const AGENT      = { mobile: '+10000000002', password: 'AgentPass1!' };
export const AGENT2     = { mobile: '+10000000006', password: 'Agent2Pass1!' };
export const ADMIN      = { mobile: '+10000000003', password: 'AdminPass1!' };
export const FINANCE    = { mobile: '+10000000004', password: 'FinancePass1!' };
export const OPERATIONS = { mobile: '+10000000005', password: 'OperationsPass1!' };
export const SUPER_ADMIN = { mobile: '+10000000007', password: 'SuperAdminPass1!' };
export const AUDITOR    = { mobile: '+10000000008', password: 'AuditorPass1!' };
// Seeded with is_withdrawals_blocked = 1 (DB-only flag).
export const BLOCKED_USER = { mobile: '+10000000009', password: 'BlockedPass1!' };
// A second ROLE_FINANCE-only user, for tests proving one finance handler can't act on another's assigned deposits.
export const FINANCE2   = { mobile: '+10000000010', password: 'Finance2Pass1!' };

export async function loginAs(page: Page, mobile: string, password: string, retries = 2): Promise<void> {
    for (let attempt = 1; attempt <= retries; attempt++) {
        await page.context().clearCookies();
        await page.goto(`${BASE_URL}/login`, { waitUntil: 'domcontentloaded', timeout: 60000 });
        await page.waitForSelector('[data-testid="input-mobile"]', { timeout: 20000 });
        await page.fill('[data-testid="input-mobile"]', mobile);
        await page.fill('[data-testid="input-password"]', password);
        await page.click('[data-testid="btn-login"]');

        const error = page.locator('[data-testid="login-error"]');
        const result = await Promise.race([
            page.waitForURL(`${BASE_URL}/`, { timeout: 30000 }).then(() => 'ok' as const),
            error.waitFor({ state: 'visible', timeout: 5000 }).then(() => 'error' as const),
        ]);

        if (result === 'ok') return;

        const errorText = await error.textContent();
        if (attempt < retries) continue;
        throw new Error(`Login failed after ${retries} attempts: ${errorText ?? 'unknown error'}`);
    }
}

/** Enables the Mines game via WFBO settings (idempotent). Leaves the browser logged in as ADMIN. */
export async function ensureMinesEnabled(page: Page): Promise<void> {
    await loginAs(page, ADMIN.mobile, ADMIN.password);
    await page.goto(`${BASE_URL}/wfbo/mines/settings`, { waitUntil: 'domcontentloaded' });
    const toggle = page.locator('[data-testid="toggle-mg-enabled"]');
    if (!(await toggle.isChecked())) {
        await toggle.check();
        await page.click('[data-testid="btn-save-mg-settings"]');
        await expect(page.locator('[data-testid="flash-success"]')).toBeVisible();
    }
}

/** Enables Live Racing via WFBO settings (idempotent). Leaves the browser logged in as ADMIN. */
export async function ensureLiveRacingEnabled(page: Page): Promise<void> {
    await loginAs(page, ADMIN.mobile, ADMIN.password);
    await page.goto(`${BASE_URL}/wfbo/live-racing/settings`, { waitUntil: 'domcontentloaded' });
    const toggle = page.locator('[data-testid="toggle-live-racing-enabled"]');
    if (!(await toggle.isChecked())) {
        await toggle.check();
        await page.click('[data-testid="btn-save-live-racing-settings"]');
        await expect(page.locator('[data-testid="flash-success"]')).toBeVisible();
    }
}

/**
 * Plays one Mines round to resolution (cash-out or bust) for whichever user is
 * currently logged in. Assumes Mines is already enabled (see ensureMinesEnabled).
 * Returns false (without placing a bet) if the wallet balance is too low.
 */
export async function playMinesRound(page: Page): Promise<boolean> {
    await page.goto(`${BASE_URL}/play/mines`, { waitUntil: 'domcontentloaded' });

    // The wallet balance is server-rendered correctly, but a Stimulus controller
    // re-animates it counting up from ₱0 over 600ms once JS connects — read too
    // early and this sees a mid-animation value instead of the real balance.
    await page.waitForTimeout(700);

    const balanceEl = page.locator('[data-testid="wallet-balance"]');
    const balanceBefore = parseFloat((await balanceEl.textContent() ?? '0').replace(/[^\d.]/g, ''));
    if (balanceBefore < 5) {
        return false;
    }

    await page.locator('[data-testid="mg-bet-chip"][data-amount="5"]').click();
    await page.locator('[data-testid="mg-tier-green"]').click();
    await page.click('[data-testid="btn-mg-start"]');
    await expect(page.locator('[data-testid="btn-mg-cashout"]')).toBeVisible();

    const cashOutBtn = page.locator('[data-testid="btn-mg-cashout"]');
    const resultTitle = page.locator('[data-testid="mg-result-title"]');
    let resolved = false;
    for (let i = 0; i < 16 && !resolved; i++) {
        const tile = page.locator(`[data-testid="mg-tile-${i}"]`);
        if (await tile.isDisabled()) continue;
        await tile.click();

        if (await resultTitle.isVisible().catch(() => false)) {
            resolved = true;
            break;
        }
        if (await cashOutBtn.isEnabled().catch(() => false)) {
            await cashOutBtn.click();
            resolved = true;
        }
    }

    await expect(resultTitle).toBeVisible();
    await page.click('#playAgainBtn');
    await expect(page.locator('[data-testid="btn-mg-start"]')).toBeVisible();
    return true;
}

/** Register a brand-new user with a unique mobile number, then log them in. */
export async function registerAndLogin(
    page: Page,
    password = 'TestPass1!',
): Promise<{ mobile: string; password: string }> {
    const ts     = Date.now();
    const mobile = `+639${ts.toString().slice(-9)}`;

    await page.context().clearCookies();
    await page.goto(`${BASE_URL}/register`, { waitUntil: 'domcontentloaded', timeout: 60000 });
    await page.fill('[data-testid="input-first-name"]', 'Test');
    await page.fill('[data-testid="input-last-name"]',  'User');
    await page.fill('[data-testid="input-mobile-number"]', mobile);
    await page.fill('[data-testid="input-password"]',   password);
    await page.fill('[data-testid="input-password-confirm"]', password);
    await page.click('[data-testid="btn-register"]');
    await page.waitForURL(`${BASE_URL}/login`, { timeout: 30000 });

    // Clear any existing session to avoid stale CSRF token from previous auth
    await page.context().clearCookies();
    await page.goto(`${BASE_URL}/login`, { waitUntil: 'domcontentloaded', timeout: 60000 });
    await page.waitForSelector('[data-testid="input-mobile"]', { timeout: 20000 });
    await page.fill('[data-testid="input-mobile"]',   mobile);
    await page.fill('[data-testid="input-password"]', password);
    await page.click('[data-testid="btn-login"]');
    await page.waitForURL(`${BASE_URL}/`, { timeout: 30000 });

    return { mobile, password };
}

// ── Live Racing builders ─────────────────────────────────────────────────────
// Shared setup flows for the racing specs. Everything runs through the WFBO
// admin UI (there is no admin API) and the player betting wizard, matching the
// conventions of the file-local helpers they replace. The database is shared
// across the whole run, so every arena/event gets a unique name/date and ids
// are read back from data-testid attributes.

/** Ticket price per bet type key, e.g. { win: '10.00', pick_4: '12.00' }. */
export type TicketPrices = Record<string, string>;

/**
 * Mirrors the WFBO management form's field naming (WfboController::managementFormFields):
 * snake_case conversion that does NOT insert an underscore before digits, so
 * pick_4 -> "pick4" -> input-pick4-amount and daily_double_plus_1 ->
 * "daily_double_plus1" -> input-daily-double-plus1-amount.
 */
function managementAmountFieldTestId(betTypeKey: string): string {
    const camel = betTypeKey
        .split('_')
        .map((part, i) => (i === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1)))
        .join('');
    const snake = camel.replace(/(?<!^)[A-Z]/g, (letter) => `_${letter}`).toLowerCase();
    return `input-${snake.replace(/_/g, '-')}-amount`;
}

export async function createArena(page: Page, namePrefix: string, prices: TicketPrices): Promise<string> {
    const arenaName = `${namePrefix} Arena ${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;

    await page.goto(`${BASE_URL}/wfbo/managements/new`);
    await page.fill('[data-testid="input-name"]', arenaName);
    for (const [key, amount] of Object.entries(prices)) {
        await page.fill(`[data-testid="${managementAmountFieldTestId(key)}"]`, amount);
    }
    await page.click('[data-testid="btn-submit"]');
    await expect(page).toHaveURL(`${BASE_URL}/wfbo/managements`);

    return arenaName;
}

export async function createEventShowUrl(page: Page, arenaName: string): Promise<{ eventUrl: string; eventId: string }> {
    const dateStr = uniqueFutureDate();

    await page.goto(`${BASE_URL}/wfbo/events/new`);
    await page.selectOption('[data-testid="select-event-management"]', { label: arenaName });
    await page.fill('[data-testid="input-event-date"]', dateStr);
    await page.click('[data-testid="btn-submit"]');
    await expect(page).toHaveURL(`${BASE_URL}/wfbo/events`);

    const row  = page.locator('[data-testid^="event-row-"]').filter({ hasText: dateStr });
    const href = await row.locator('[data-testid^="btn-view-"]').getAttribute('href');
    const eventUrl = href ?? '';
    return { eventUrl, eventId: eventUrl.split('/').pop() ?? '' };
}

export async function addRace(page: Page, eventUrl: string, raceNumber: number): Promise<{ raceUrl: string; raceId: string }> {
    await page.goto(`${BASE_URL}${eventUrl}`);
    await page.click('[data-testid="btn-new-race"]');
    await page.fill('[data-testid="input-race-number"]', String(raceNumber));
    await page.click('[data-testid="btn-submit"]');

    const row  = page.locator('[data-testid^="race-row-"]').filter({ hasText: String(raceNumber) });
    const href = await row.locator('[data-testid^="btn-view-race-"]').getAttribute('href');
    const raceUrl = href ?? '';
    return { raceUrl, raceId: raceUrl.split('/').pop() ?? '' };
}

export async function addHorse(page: Page, raceUrl: string, horseNumber: number, name?: string): Promise<string> {
    await page.goto(`${BASE_URL}${raceUrl}`);
    await page.click('[data-testid="btn-new-horse"]');
    await page.fill('[data-testid="input-horse-number"]', String(horseNumber));
    await page.fill('[data-testid="input-horse-name"]', name ?? `Horse ${horseNumber}`);
    await page.fill('[data-testid="input-horse-jockey"]', `Jockey ${horseNumber}`);
    await page.fill('[data-testid="input-horse-trainer"]', `Trainer ${horseNumber}`);
    await page.click('[data-testid="btn-submit"]');

    const row = page.locator('[data-testid^="horse-row-"]').last();
    const testId = await row.getAttribute('data-testid');
    return testId?.replace('horse-row-', '') ?? '';
}

export async function addHorses(page: Page, raceUrl: string, count: number): Promise<string[]> {
    const ids: string[] = [];
    for (let n = 1; n <= count; n++) {
        ids.push(await addHorse(page, raceUrl, n));
    }
    return ids;
}

/** Offers single-race bet types on their race via the event's bet-types tab. */
export async function offerSingleBetTypes(page: Page, eventUrl: string, entries: Array<{ key: string; raceId: string }>): Promise<void> {
    await page.goto(`${BASE_URL}${eventUrl}?tab=bet-types`);
    for (const { key, raceId } of entries) {
        await page.check(`[data-testid="checkbox-leg-${key}-${raceId}"]`);
    }
    await page.click('[data-testid="btn-save-bet-types"]');
    await expect(page.locator('[data-testid="flash-success"]')).toBeVisible();
}

/**
 * Adds one or more multi-race combos for a bet type key. Each combo is an
 * ordered list of raceIds forming one leg sequence.
 */
export async function offerMultiBetTypeCombos(page: Page, eventUrl: string, key: string, combos: string[][]): Promise<void> {
    await page.goto(`${BASE_URL}${eventUrl}?tab=bet-types`);
    for (let i = 0; i < combos.length; i++) {
        await page.click(`[data-testid="btn-add-combo-${key}"]`);
        for (const raceId of combos[i]) {
            await page.check(`[data-testid="checkbox-combo-${key}-new-${i}-${raceId}"]`);
        }
    }
    await page.click('[data-testid="btn-save-bet-types"]');
    await expect(page.locator('[data-testid="flash-success"]')).toBeVisible();
}

export async function publishEvent(page: Page, eventUrl: string): Promise<void> {
    await page.goto(`${BASE_URL}${eventUrl}`);
    page.once('dialog', dialog => dialog.accept());
    await page.click('[data-testid="btn-publish-event"]');
}

export async function closeBetting(page: Page, raceUrl: string): Promise<void> {
    await page.goto(`${BASE_URL}${raceUrl}`);
    page.once('dialog', dialog => dialog.accept());
    await page.click('[data-testid="btn-close-betting"]');
    await expect(page.locator('[data-testid="flash-success"]')).toBeVisible();
}

/** Bulk-scratches a horse while its race is still open ("before close"). */
export async function scratchHorseBeforeClose(page: Page, raceUrl: string, horseId: string): Promise<void> {
    await page.goto(`${BASE_URL}${raceUrl}`);
    await page.check(`[data-testid="checkbox-scratch-${horseId}"]`);
    await page.click('[data-testid="btn-save-scratches"]');
    await expect(page.locator('[data-testid="flash-success"]')).toBeVisible();
}

export interface ResultEntry {
    /**
     * Winning combinations per eventBetType id. Each combination's `horseIds`
     * are in position (single types) or leg (multi types) order; `dividend` is
     * that exact combination's payout. Enter more than one to cover a dead heat.
     */
    combinations?: Record<string, Array<{ horseIds: string[]; dividend: string }>>;
    /** odd_even winning parity + dividend, per eventBetType id. */
    parities?: Record<string, { selection: 'odd' | 'even'; dividend: string }>;
    /** Horses scratched during this result save (always after close). */
    scratchesAfterClose?: string[];
    /** eventBetType ids to mark fully refunded (voids every bet of that type). */
    refunds?: string[];
}

export async function saveResult(page: Page, raceUrl: string, entry: ResultEntry): Promise<void> {
    await page.goto(`${BASE_URL}${raceUrl}`);

    for (const horseId of entry.scratchesAfterClose ?? []) {
        await page.check(`[data-testid="checkbox-result-scratch-${horseId}"]`);
    }

    for (const eventBetTypeId of entry.refunds ?? []) {
        await page.check(`[data-testid="checkbox-refund-${eventBetTypeId}"]`);
    }

    for (const [eventBetTypeId, combos] of Object.entries(entry.combinations ?? {})) {
        const block = page.locator(`[data-testid="combo-bettype-${eventBetTypeId}"]`);
        for (let i = 1; i < combos.length; i++) {
            await block.locator('[data-add-combo]').click();
        }
        for (let i = 0; i < combos.length; i++) {
            const row     = block.locator('[data-combo-row]').nth(i);
            const selects = row.locator('select');
            for (let s = 0; s < combos[i].horseIds.length; s++) {
                await selects.nth(s).selectOption(combos[i].horseIds[s]);
            }
            await row.locator('input[type="number"]').fill(combos[i].dividend);
        }
    }

    for (const [eventBetTypeId, parity] of Object.entries(entry.parities ?? {})) {
        const row = page.locator(`[data-testid="combo-bettype-${eventBetTypeId}"] [data-combo-row]`).first();
        await row.locator('select').selectOption(parity.selection);
        await row.locator('input[type="number"]').fill(parity.dividend);
    }

    page.once('dialog', dialog => dialog.accept());
    await page.click('[data-testid="btn-save-result"]');

    // Saving a result records the winning combinations (and derives the race
    // winner from them); it no longer settles bets. Settlement is a separate
    // step on the dedicated Settlements page - see settleAndMarkRace().
    await expect(page.locator('[data-testid="flash-success"]')).toBeVisible();
}

/**
 * Runs a race through the dedicated Live Racing Settlements page: clicks its
 * "Settle" button (which sweeps pending bets, then fires the mark-settled
 * request) and waits until the race drops off the not-settled list — i.e.
 * `EventRace::isSettled()` is now true. Only after this does the race appear
 * on the player Race Results page (`/play/live-racing/results`).
 *
 * Assumes the caller is (or can log in as) ADMIN. `withReceipt` picks which
 * button: true = "Settle + Receipt" (default, writes player wallet_transaction
 * rows), false = "Settle Only".
 */
export async function settleAndMarkRace(page: Page, raceId: string, withReceipt = true): Promise<void> {
    await loginAs(page, ADMIN.mobile, ADMIN.password);
    await page.goto(`${BASE_URL}/wfbo/live-racing/settlements`);
    const btn = withReceipt
        ? `[data-testid="btn-settle-race-${raceId}"]`
        : `[data-testid="btn-settle-race-only-${raceId}"]`;
    await page.click(btn);
    await expect(page.locator(`[data-testid="settlement-text-${raceId}"]`))
        .toContainText('settled', { timeout: 20000 });

    // The mark-settled POST fires right after the sweep and isn't awaited by
    // the page — confirm it landed by reloading until the row is gone.
    await expect.poll(async () => {
        await page.goto(`${BASE_URL}/wfbo/live-racing/settlements`);
        return page.locator(`[data-testid="settlement-race-row-${raceId}"]`).count();
    }, { timeout: 15000 }).toBe(0);
}

/**
 * Resolves an EventBetType id by locating the wizard bet-type-KEY tab with
 * the given label, then reading whichever combo radio is first inside its
 * panel. Label text is compared against normalized textContent (template
 * whitespace makes anchored regexes fail against raw textContent). Key tabs
 * are always all present in the DOM (no race-based filtering), so a label
 * match is normally unambiguous. When a key has more than one combo instance
 * (e.g. two separate Daily Double race pairs), this resolves to the first
 * one - callers needing a specific other combo aren't covered by this helper.
 */
export async function betTypeCardId(page: Page, label: string | RegExp): Promise<string> {
    const tabs  = page.locator('.wizard-key-tab');
    const count = await tabs.count();

    for (let i = 0; i < count; i++) {
        const tab  = tabs.nth(i);
        const text = (await tab.textContent())
            ?.replace(/\s+/g, ' ')
            .replace(/\s*Multi-race\b\s*/i, '')   // drop the multi-kind badge text
            .trim() ?? '';
        const hit  = typeof label === 'string' ? text === label : label.test(text);
        if (hit) {
            const key   = await tab.getAttribute('data-key');
            const radio = page.locator(`.wizard-key-panel[data-key="${key}"] .wizard-bet-radio`).first();
            return (await radio.getAttribute('value')) ?? '';
        }
    }

    throw new Error(`No wizard bet type tab found for ${label}`);
}

export interface WizardBetOptions {
    eventId: string;
    /**
     * The race to select when this bet type is offered on more than one race
     * (a nested race tab then exists per race - see `tab-race-{raceId}-bet-{cardId}`).
     * Not needed when the bet type has only one race tab (nothing to choose),
     * or for multi-race combos (use `legs` instead, keyed by each leg's raceId).
     * Still required alongside `oddEven`, since the ODD/EVEN radio testid is
     * always scoped to a race id regardless of whether a tab bar renders.
     */
    raceId?: string;
    /** Card label, or a RegExp to disambiguate similar labels ("Daily Double" vs "Daily Double Plus 1"). */
    cardLabel: string | RegExp;
    /** Single-kind picks keyed by position (1-based). */
    positions?: Record<number, string[]>;
    /** Multi-kind picks keyed by raceId. */
    legs?: Record<string, string[]>;
    oddEven?: 'odd' | 'even';
    /**
     * Bet amount (per-combo stake for custom bet types). Defaults to
     * the arena's configured ticket price (minimum per-combo stake).
     * For per-ticket bet types (quartet, pentafecta, super_six, pick_4, pick_5, pick_6, wta),
     * use `qty` instead.
     */
    amount?: string;
    /**
     * For per-ticket bet types: the ticket quantity. Defaults to 1.
     * Ignored for custom bet types (use `amount` instead).
     */
    qty?: string;
}

/**
 * Toggle a wizard pick on. The horse/odd-even inputs are `visually-hidden-input`
 * (0-size, clipped, behind the row's horizontal scroller and the dev toolbar), so
 * Playwright's `page.check()` on the input itself is unreliable — click the
 * wrapping `<label>` instead and verify the input flipped.
 */
async function toggleWizardPick(page: Page, testid: string): Promise<void> {
    const input = page.locator(`[data-testid="${testid}"]`);
    // The wizard JS drops the `disabled` attribute once the leg/card is active.
    await expect(input).toBeEnabled({ timeout: 15000 });
    if (await input.isChecked()) {
        return;
    }
    const label = input.locator('xpath=ancestor::label[1]');
    await label.scrollIntoViewIfNeeded();
    await label.click();
    await expect(input).toBeChecked();
}

export async function placeWizardBet(page: Page, opts: WizardBetOptions): Promise<void> {
    // Surface server-side rejections: the wizard reports failures through a
    // native alert(), which Playwright otherwise silently dismisses.
    let alertText: string | null = null;
    const onDialog = (dialog: Dialog): void => {
        alertText ??= dialog.message();
        void dialog.dismiss();
    };
    page.on('dialog', onDialog);

    try {
        await page.goto(`${BASE_URL}/play/live-racing/${opts.eventId}`);
        // The Symfony dev toolbar (fixed, bottom of viewport) can sit over the
        // wizard's lower rows and intercept clicks — take it out of the way.
        await page.addStyleTag({ content: '.sf-toolbar { display: none !important; }' }).catch(() => {});

        const cardId = await betTypeCardId(page, opts.cardLabel);
        const key    = await page.locator(`[data-testid="bet-panel-${cardId}"]`).getAttribute('data-key');
        await page.click(`[data-testid="tab-bet-key-${key}"]`);

        // A key with more than one combo instance (e.g. two separate Daily
        // Double race pairs) needs its specific combo selected explicitly -
        // clicking the key tab alone only activates the default (first) one.
        const cardRadio = page.locator(`[data-testid="radio-wizard-bet-${cardId}"]`);
        if (!(await cardRadio.isChecked())) {
            await cardRadio.check();
        }

        if (opts.raceId) {
            const raceTab = page.locator(`[data-testid="tab-race-${opts.raceId}-bet-${cardId}"]`);
            if (await raceTab.count() > 0) {
                await raceTab.click();
            }
        }

        if (opts.oddEven) {
            await toggleWizardPick(page, `radio-${opts.oddEven}-${cardId}-${opts.raceId}`);
        }
        for (const [position, horseIds] of Object.entries(opts.positions ?? {})) {
            for (const horseId of horseIds) {
                await toggleWizardPick(page, `checkbox-pos-${position}-horse-${horseId}-bet-${cardId}`);
            }
        }
        for (const [raceId, horseIds] of Object.entries(opts.legs ?? {})) {
            // A multi-race bet type's legs are all enabled together once its
            // combo is picked, but only the active leg tab's horse list is
            // visible/checkable - switch to each leg's own tab first.
            const legTab = page.locator(`[data-testid="tab-race-${raceId}-bet-${cardId}"]`);
            if (await legTab.count() > 0) {
                await legTab.click();
            }
            for (const horseId of horseIds) {
                await toggleWizardPick(page, `checkbox-leg-${raceId}-horse-${horseId}-bet-${cardId}`);
            }
        }

        // Step 1 holds the horse picker AND the amount input AND the submit
        // button (the wizard is 2 steps: pick+amount, then "Done").
        const amount   = opts.amount ?? await defaultWizardAmount(page);
        const qtyInput = page.locator('[data-testid="input-wizard-qty"]');
        if (await qtyInput.isVisible()) {
            await qtyInput.fill(amount);
        } else {
            await page.fill('[data-testid="input-wizard-amount"]', amount);
        }

        await page.click('[data-testid="btn-wizard-submit"]');
        await expect(page.locator('[data-testid="wizard-step-2"]')).toBeVisible();
    } catch (error) {
        throw new Error(`${error instanceof Error ? error.message : error}${alertText ? ` [wizard alert: ${alertText}]` : ''}`);
    } finally {
        page.off('dialog', onDialog);
    }
}

/**
 * ticket price (read off the active bet-type-key tab) x combos - see
 * WizardBetOptions.amount. Returned as a whole number: the custom-amount
 * field is integer-only (step="1", the server rejects non-whole amounts),
 * and for per-ticket bets the value is a ticket quantity.
 */
async function defaultWizardAmount(page: Page): Promise<string> {
    return page.evaluate(() => {
        const tab = document.querySelector<HTMLElement>('.wizard-key-tab.is-active');
        const perTicket = tab?.dataset.isPerTicket === '1';
        if (perTicket) {
            return '1';
        }
        const ticketPrice = parseFloat(tab?.dataset.ticketPrice ?? '0') || 0;
        const comboText = document.getElementById('wizard-summary-combos')?.textContent ?? '';
        const combos = parseInt(comboText.replace(/\D/g, ''), 10) || 1;
        return String(Math.ceil(ticketPrice) * combos);
    });
}

export function walletAmount(text: string | null): number {
    return parseFloat((text ?? '').replace(/[^0-9.]/g, ''));
}

/** Reads the player wallet bar, waiting out the count-up animation. */
export async function readWalletBalance(page: Page): Promise<number> {
    await page.waitForTimeout(700);
    return walletAmount(await page.locator('[data-testid="wallet-bar-amount"]').textContent());
}

/** Registers a new user under a specific referral code, then logs them in. */
export async function registerUserWithReferralCode(
    page: Page,
    referralCode: string,
    password = 'TestPass1!',
): Promise<{ mobile: string; password: string }> {
    const ts     = Date.now();
    const mobile = `+639${ts.toString().slice(-9)}`;

    await page.context().clearCookies();
    await page.goto(`${BASE_URL}/register`, { waitUntil: 'domcontentloaded', timeout: 60000 });
    await page.fill('[data-testid="input-first-name"]', 'Referred');
    await page.fill('[data-testid="input-last-name"]',  'Player');
    await page.fill('[data-testid="input-mobile-number"]', mobile);
    await page.fill('[data-testid="input-referral-code"]', referralCode);
    await page.fill('[data-testid="input-password"]',   password);
    await page.fill('[data-testid="input-password-confirm"]', password);
    await page.click('[data-testid="btn-register"]');
    await page.waitForURL(`${BASE_URL}/login`, { timeout: 30000 });

    await loginAs(page, mobile, password);
    return { mobile, password };
}

/**
 * Credits (or debits, negative) a user's wallet via the WFBO System Adjustment
 * form. Logs in as the seeded AUDITOR account — only ROLE_AUDITOR may submit
 * wallet adjustments.
 */
export async function adjustWalletAsAuditor(page: Page, userMobile: string, amount: string, reason: string): Promise<void> {
    await loginAs(page, AUDITOR.mobile, AUDITOR.password);
    await page.goto(`${BASE_URL}/wfbo/users?search=${encodeURIComponent(userMobile)}`);

    const row  = page.locator('[data-testid^="user-row-"]').filter({ hasText: userMobile });
    const href = await row.locator('[data-testid^="btn-view-transactions-"]').getAttribute('href');
    await page.goto(`${BASE_URL}${href ?? ''}`);

    await page.fill('[data-testid="adj-amount"]', amount);
    await page.fill('[data-testid="adj-reason"]', reason);
    await page.click('[data-testid="adj-submit"]');
    await expect(page.locator('[data-testid="flash-success"]')).toContainText('Wallet credit');
}

/** Reads any user's current wallet balance as seen by ADMIN (WFBO transactions page). */
export async function readUserWalletAsAdmin(page: Page, userMobile: string): Promise<number> {
    await loginAs(page, ADMIN.mobile, ADMIN.password);
    await page.goto(`${BASE_URL}/wfbo/users?search=${encodeURIComponent(userMobile)}`);
    const row  = page.locator('[data-testid^="user-row-"]').filter({ hasText: userMobile });
    const href = await row.locator('[data-testid^="btn-view-transactions-"]').getAttribute('href');
    await page.goto(`${BASE_URL}${href ?? ''}`);
    return walletAmount(await page.locator('[data-testid="user-wallet-balance"]').textContent());
}

/** Sets the Live Racing site-wide agent commission rate (%) as ADMIN. */
export async function setLiveRacingCommissionRate(page: Page, rate: string): Promise<void> {
    await loginAs(page, ADMIN.mobile, ADMIN.password);
    await page.goto(`${BASE_URL}/wfbo/live-racing/settings`, { waitUntil: 'domcontentloaded' });
    await page.fill('[data-testid="input-live-racing-agent-commission-rate"]', rate);
    await page.click('[data-testid="btn-save-live-racing-settings"]');
    await expect(page.locator('[data-testid="flash-success"]')).toBeVisible();
}
