/**
 * dev-browser-driver — Wrapper around the upstream `dev-browser --headless` CLI.
 *
 * Responsibilities:
 *  - Spawn `dev-browser --headless` (or `npx dev-browser --headless` if not on PATH)
 *  - Pipe a rendered JS script via stdin (the upstream CLI has no --script flag)
 *  - Capture stdout (single-line JSON result) + stderr (trace) + exit code
 *  - Enforce a wall-clock timeout via AbortController (upstream has no --timeout flag)
 *  - Parse the final JSON object from stdout
 *
 * The script contract: the rendered template MUST emit exactly one
 * `console.log(JSON.stringify({ status, reason, capture }))` on stdout, with
 * any trace output going to console.warn/error. Driver ignores everything but
 * the LAST valid JSON object on stdout (so partial logs don't confuse parsing).
 *
 * Upstream: https://github.com/SawyerHood/dev-browser (MIT, v0.2.7).
 * Pinned in the consumer project's package.json devDependencies.
 */
import { spawn, type ChildProcess } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadTemplate } from '../../../../../../lib/template-loader.js';

export interface DriverTestSpec {
  id: string;
  scenario: 'list' | 'detail' | 'form-submit' | 'edit' | 'delete' | 'permission-negative';
  page: string;
  role: string;
  fixture?: Record<string, unknown>;
  expectations: {
    httpStatus?: number[];
    noConsoleError?: boolean;
    rendersTestId?: string;
    redirectsTo?: string;
  };
}

export interface DriverTestUser {
  role: string;
  email: string;
  password: string;
}

export interface DriverContext {
  /** Absolute URL of the running app's frontend (e.g. http://localhost:5173). */
  frontendUrl: string;
  /** Absolute URL of the running app's API (e.g. http://localhost:5142). */
  apiUrl: string;
  /** Filesystem dir (relative to project) where screenshots are written. */
  captureDir: string;
  /** Wall-clock cap per test (default 60_000 ms). */
  timeoutMs?: number;
  /** Override CWD for the dev-browser spawn (defaults to process.cwd()). */
  cwd?: string;
}

export interface DriverHttpRequest {
  url: string;
  method: string;
  status: number;
}

export interface DriverFailureCapture {
  httpRequests?: DriverHttpRequest[];
  consoleErrors?: string[];
  screenshotRelPath?: string;
  /** Driver-level diagnostic when the script itself crashed. */
  stderrTail?: string;
  exitCode?: number | null;
}

export interface DriverResult {
  status: 'pass' | 'fail';
  reason: string;
  capture: DriverFailureCapture;
  /** Wall-clock duration for the dev-browser invocation (ms). */
  durationMs: number;
}

const DEFAULT_TIMEOUT_MS = 60_000;

/**
 * Run a single UI test via the dev-browser CLI. Returns a typed result.
 *
 * Templates live in ../templates/{scenario}.js.hbs and receive the test spec +
 * user credentials + driver context as Handlebars data.
 */
export async function runDevBrowserTest(
  test: DriverTestSpec,
  user: DriverTestUser,
  ctx: DriverContext,
): Promise<DriverResult> {
  const startedAt = Date.now();
  const timeoutMs = ctx.timeoutMs ?? DEFAULT_TIMEOUT_MS;

  let script: string;
  try {
    // loadTemplate appends `templates/` to the dir hint. The driver lives at
    // run-ui-test/lib/dev-browser-driver.ts, so we walk up one dir to point
    // at run-ui-test/, where templates/ sits.
    const driverDir = path.dirname(fileURLToPath(import.meta.url));
    const cliRoot = path.resolve(driverDir, '..');
    const render = await loadTemplate(`${test.scenario}.js.hbs`, cliRoot);
    script = render({
      test,
      user,
      frontendUrl: ctx.frontendUrl,
      apiUrl: ctx.apiUrl,
      captureDir: ctx.captureDir,
      // Convenience aliases — Handlebars template can reference {{page}} directly.
      testId: test.id,
      page: test.page,
      expectations: test.expectations,
      // Pre-stringified so templates can write `const fixture = {{{fixtureJson}}};`
      // without needing a `json` Handlebars helper.
      fixture: test.fixture ?? {},
      fixtureJson: JSON.stringify(test.fixture ?? {}),
    });
  } catch (err) {
    return {
      status: 'fail',
      reason: `template-error: ${err instanceof Error ? err.message : String(err)}`,
      capture: { exitCode: null },
      durationMs: Date.now() - startedAt,
    };
  }

  return await spawnAndWait(script, ctx.cwd ?? process.cwd(), timeoutMs, startedAt);
}

function spawnAndWait(
  script: string,
  cwd: string,
  timeoutMs: number,
  startedAt: number,
): Promise<DriverResult> {
  return new Promise((resolve) => {
    // Prefer `npx dev-browser` so the consumer project's devDependencies pin
    // wins over any global install (which may diverge from v0.2.7).
    const proc: ChildProcess = spawn('npx', ['--yes', 'dev-browser', '--headless'], {
      cwd,
      stdio: ['pipe', 'pipe', 'pipe'],
      shell: process.platform === 'win32',
    });

    let stdout = '';
    let stderr = '';
    let killed = false;

    const watchdog = setTimeout(() => {
      killed = true;
      try { if (!proc.killed) proc.kill('SIGTERM'); } catch { /* ignore */ }
    }, timeoutMs);

    proc.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString(); });
    proc.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString(); });

    proc.on('error', (err) => {
      clearTimeout(watchdog);
      resolve({
        status: 'fail',
        reason: `driver-error: spawn failed (${err.message})`,
        capture: { stderrTail: tail(stderr, 2000), exitCode: null },
        durationMs: Date.now() - startedAt,
      });
    });

    proc.on('close', (code) => {
      clearTimeout(watchdog);

      if (killed) {
        resolve({
          status: 'fail',
          reason: `driver-error: timeout after ${timeoutMs}ms`,
          capture: { stderrTail: tail(stderr, 2000), exitCode: code },
          durationMs: Date.now() - startedAt,
        });
        return;
      }

      const parsed = extractLastJsonObject(stdout);
      if (!parsed) {
        resolve({
          status: 'fail',
          reason: `driver-error: no JSON result on stdout (exit ${code ?? '?'})`,
          capture: { stderrTail: tail(stderr, 2000), exitCode: code },
          durationMs: Date.now() - startedAt,
        });
        return;
      }

      const status = parsed.status === 'pass' ? 'pass' : 'fail';
      resolve({
        status,
        reason: typeof parsed.reason === 'string' ? parsed.reason : (status === 'pass' ? 'ok' : 'unknown'),
        capture: (parsed.capture as DriverFailureCapture) ?? { exitCode: code },
        durationMs: Date.now() - startedAt,
      });
    });

    // Pipe the script via stdin. dev-browser reads to EOF before executing.
    proc.stdin?.write(script);
    proc.stdin?.end();
  });
}

function tail(text: string, maxBytes: number): string {
  if (text.length <= maxBytes) return text;
  return `…${text.slice(-maxBytes)}`;
}

/**
 * Extract the LAST balanced top-level JSON object from a string. Tolerates
 * preceding logs / ANSI codes / partial fragments. Returns null if no
 * parseable JSON object is found at any depth.
 */
function extractLastJsonObject(text: string): Record<string, unknown> | null {
  // Walk from the end, find the last '}' then walk backwards counting braces
  // (with naive string-skipping) to find the matching '{'. Then JSON.parse.
  // Tries multiple candidates: each '}' from end is a potential close.
  for (let end = text.lastIndexOf('}'); end >= 0; end = text.lastIndexOf('}', end - 1)) {
    let depth = 0;
    let inString = false;
    let escape = false;
    for (let i = end; i >= 0; i--) {
      const ch = text[i];
      if (escape) { escape = false; continue; }
      if (ch === '\\') { escape = true; continue; }
      if (ch === '"') { inString = !inString; continue; }
      if (inString) continue;
      if (ch === '}') depth++;
      else if (ch === '{') {
        depth--;
        if (depth === 0) {
          const candidate = text.slice(i, end + 1);
          try {
            const parsed = JSON.parse(candidate);
            if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
              return parsed as Record<string, unknown>;
            }
          } catch {
            // fall through, try next end position
          }
          break;
        }
      }
    }
  }
  return null;
}
