/**
 * Библиотека агентных проверок (puppeteer-core + системный Chromium) поверх
 * моста window.__agent (EngineAgent). Единственное место с браузерным
 * бойлерплейтом: смоуки и сценарии строятся поверх openGame()/AgentClient.
 * Контент приложения (маяк загрузки и т.п.) передаётся параметрами.
 */
import puppeteer from 'puppeteer-core';
import { spawn } from 'node:child_process';
import { setTimeout as sleep } from 'node:timers/promises';

/** Корень монорепо (тулза живёт в packages/engine/tools — 3 уровня). */
const ROOT = new URL('../../..', import.meta.url);

export const CHROMIUM = '/usr/bin/chromium';

/** Запустить браузер с нужными headless-флагами (см. грабли autoplay-policy). */
export async function launchBrowser() {
    return puppeteer.launch({
        executablePath: CHROMIUM,
        headless: true,
        args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-angle=swiftshader',
               '--autoplay-policy=no-user-gesture-required', '--window-size=960,540']
    });
}

/**
 * Поднять dev-сервер vite (spawn + ожидание HTTP 200). Возвращает {url, stop}.
 * cwd — каталог приложения с его vite-конфигом и index.html; по умолчанию
 * корень монорепо (где vite есть, но конфига приложения нет — приложение
 * должно передавать свой каталог, иначе получит 404 на всё).
 */
export async function startDevServer({ port = 5199, cwd = ROOT.pathname } = {}) {
    // detached: true — процесс в своей группе: stop() убивает и детей npx-обёртки
    // (голый kill обёртки оставляет зомби-вите, который держит порт).
    const proc = spawn('npx', ['vite', '--port', String(port), '--strictPort'], {
        cwd,
        stdio: ['ignore', 'pipe', 'pipe'],
        detached: true
    });
    const stop = () => {
        try { process.kill(-proc.pid, 'SIGTERM'); } catch { proc.kill('SIGTERM'); }
    };
    // strictPort при занятом порте завершает наш vite сразу, но fetch продолжил
    // бы отвечать чужим сервером — фиксируем выход и падаем.
    let exited = null;
    proc.on('exit', (code, signal) => { exited = { code, signal }; });
    const url = `http://localhost:${port}/`;
    const deadline = Date.now() + 30000;
    while (Date.now() < deadline) {
        if (exited !== null) {
            throw new Error(`vite (cwd ${cwd}) завершился сразу (код ${exited.code}) — порт ${port} занят чужим сервером?`);
        }
        try {
            const res = await fetch(url);
            if (res.ok) return { url, stop };
        } catch { /* ещё не поднялся */ }
        await sleep(250);
    }
    stop();
    throw new Error(`dev-сервер на :${port} не поднялся за 30 с (cwd ${cwd})`);
}

/**
 * Открыть приложение и дождаться готовности агента.
 * newGame=true — выполнить agent.newGame() и дождаться конца fade-перехода.
 * beacon — консольная строка-маяк готовности; без него ждать сразу networkidle2.
 * Возвращает {browser, page, agent}.
 */
export async function openGame({ url = 'http://localhost:5199/', newGame = true, beacon = null } = {}) {
    const browser = await launchBrowser();
    const page = await browser.newPage();
    await page.setViewport({ width: 960, height: 540 });
    const consoleLogs = [];
    page.on('console', (m) => consoleLogs.push(m.text()));
    page.on('pageerror', (e) => consoleLogs.push(`[pageerror] ${e.message}`));

    const booted = beacon
        ? new Promise((resolve, reject) => {
            const t = setTimeout(() => reject(new Error(`маяк «${beacon}» не пришёл за 30 с`)), 30000);
            page.on('console', (m) => { if (m.text().includes(beacon)) { clearTimeout(t); resolve(); } });
        })
        : Promise.resolve();
    await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 });
    await booted;
    // Дождаться конца fade-перехода Boot->Menu: begin() молча отбрасывает
    // команды, пришедшие во время перехода.
    await sleep(1500);

    const agent = new AgentClient(page);
    if (newGame) {
        await agent.newGame();
        // fade-переход в локацию ещё идёт: сцена игнорирует клики до его конца.
        await agent.waitFor('!s.transitioning', { timeoutTicks: 300 });
    }
    return { browser, page, agent, consoleLogs };
}

/**
 * Тонкий клиент поверх window.__agent: каждое поле — прокси-вызов page.evaluate.
 * snapshot()/invariants() возвращают готовые объекты; waitFor — строка-pred.
 */
export class AgentClient {
    constructor(page) { this.page = page; }

    async call(method, ...args) {
        return this.page.evaluate((m, a) => {
            const api = window.__agent;
            if (!api) return { __agentError: 'window.__agent не зарегистрирован (DEV-сборка?)' };
            return api[m](...a);
        }, method, args);
    }

    snapshot() { return this.call('snapshot'); }
    invariants() { return this.call('invariants'); }
    step(n = 1, opts) { return this.call('step', n, opts); }
    waitFor(pred, opts) { return this.call('waitFor', pred, opts); }
    tapVirtual(x, y) { return this.call('tapVirtual', x, y); }
    press(action, holdTicks) { return this.call('press', action, holdTicks); }
    key(code) { return this.call('key', code); }
    command(name, args) { return this.call('command', name, args); }
    walkTo(x, y, opts) { return this.call('walkTo', x, y, opts); }
    approach(x, y, opts) { return this.call('approach', x, y, opts); }
    runDialogue(timeoutTicks) { return this.call('runDialogue', timeoutTicks); }
    pickChoiceByText(text, timeoutTicks) { return this.call('pickChoiceByText', text, timeoutTicks); }
    newGame() { return this.call('newGame'); }
    currentArea() { return this.call('currentArea'); }

    async screenshot(path) { return this.page.screenshot({ path }); }
}

/**
 * Каркас сценариев: run() ловит исключения, finish() печатает JSON и ставит код.
 * Использование: const c = new Checks('имя'); await c.run('шаг', async () => ...); c.finish();
 */
export class Checks {
    constructor(name) {
        this.name = name;
        this.results = [];
        this.started = Date.now();
    }
    /** Добавить проверку; исключение/ложь → неуспех, прогон продолжается. */
    async run(checkName, fn) {
        const t0 = Date.now();
        try {
            const details = await fn();
            this.results.push({ name: checkName, ok: details !== false, ms: Date.now() - t0,
                                details: details === true ? null : details ?? null });
        } catch (err) {
            this.results.push({ name: checkName, ok: false, ms: Date.now() - t0, details: String(err?.message ?? err) });
        }
        return this;
    }
    /** Ассерт-хелпер: неистинное условие кидает исключение с сообщением. */
    expect(cond, msg, details) {
        if (!cond) throw new Error(msg + (details !== undefined ? `: ${JSON.stringify(details)}` : ''));
        return details;
    }
    /** Напечатать JSON-отчёт и вернуть его (код выхода — через report.ok). */
    finish({ pretty = false } = {}) {
        const report = {
            name: this.name,
            ok: this.results.every((r) => r.ok),
            ms: Date.now() - this.started,
            results: this.results
        };
        console.log(pretty ? JSON.stringify(report, null, 2) : JSON.stringify(report));
        return report;
    }
}

/** Разбор общих флагов CLI: --pretty/--json/--port/--only/--skip. */
export function parseArgs(argv = process.argv.slice(2)) {
    const out = { _: [] };
    for (let i = 0; i < argv.length; i++) {
        const a = argv[i];
        if (a === '--pretty' || a === '--json') out.pretty = a === '--pretty';
        else if (a === '--port') out.port = Number(argv[++i]);
        else if (a === '--only') out.only = (argv[++i] ?? '').split(',').filter(Boolean);
        else if (a === '--skip') out.skip = (argv[++i] ?? '').split(',').filter(Boolean);
        else if (a === '--new-game') out.newGame = true;
        else if (a === '--out') out.out = argv[++i];
        else if (a === '--steps') out.steps = Number(argv[++i]);
        else out._.push(a);
    }
    return out;
}