import { existsSync, mkdirSync, rmSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { chromium, firefox, webkit, type Browser, type BrowserContext, type BrowserType, type Page } from "playwright";
import { repoRoot } from "./config";
import { emitError, emitJson } from "./output";

type BrowserName = "chromium" | "firefox" | "webkit";
type WaitUntil = "load" | "domcontentloaded" | "networkidle" | "commit";
type PlaywrightCommand = "help" | "open" | "screenshot" | "eval" | "session-list" | "session-delete";

interface ParsedOptions {
  command: PlaywrightCommand | string;
  commandArgs: string[];
  browserName: BrowserName;
  dryRun: boolean;
  headless: boolean;
  sessionId: string;
  timeoutMs: number;
  waitUntil: WaitUntil;
  screenshotPath: string | null;
  selector: string | null;
  fullPage: boolean;
}

const stateRoot = join(repoRoot, ".state", "playwright-cli");
const defaultScreenshotPath = join(stateRoot, "screenshots", "latest.png");
const supportedCommands = ["open", "screenshot", "eval", "session-list", "session-delete"];
const unsupportedInteractiveCommands = new Set([
  "click",
  "dblclick",
  "fill",
  "type",
  "press",
  "hover",
  "drag",
  "select",
  "check",
  "uncheck",
  "snapshot",
  "tab-list",
  "tab-new",
  "tab-close",
  "tab-select",
  "close",
  "config",
]);

function usage(): Record<string, unknown> {
  return {
    command: "playwright-cli",
    output: "json",
    usage: [
      "bun scripts/playwright-cli.ts open <url> [--session id] [--screenshot path] [--headed|--headless]",
      "bun scripts/playwright-cli.ts screenshot <url> [path] [--session id] [--selector css] [--full-page]",
      "bun scripts/playwright-cli.ts eval <url> <javascript-expression> [--session id]",
      "bun scripts/playwright-cli.ts session-list",
      "bun scripts/playwright-cli.ts session-delete [sessionId]",
    ],
    defaults: {
      browser: "chromium",
      headless: true,
      session: "default",
      waitUntil: "domcontentloaded",
      timeoutMs: 30_000,
      stateRoot,
    },
    behavior: [
      "This is a repo-owned short-run wrapper for commander browser checks, not the external agent-skill passthrough.",
      "It runs headless by default, so screenshots/open checks work on hosts without an X server.",
      "Named sessions persist Playwright storageState JSON between invocations; no long-running browser daemon or element-ref click session is kept.",
      "Use --headed only when a display is available; on headless hosts, run headed checks with xvfb-run -a bun scripts/playwright-cli.ts ... --headed. The xvfb fallback requires both xvfb-run and xauth.",
    ],
    unsupported: {
      reason: "persistent interactive session commands require a browser daemon that this wrapper does not provide",
      commands: Array.from(unsupportedInteractiveCommands).sort(),
    },
    externalSkillGap: {
      observed: "The external playwright skill at ~/.agents/skills/playwright currently behaves like npx playwright passthrough while its SKILL.md may advertise richer session commands.",
      sourceOfTruth: "Track repo-owned commander checks here; update the external skill source separately if the skill itself must expose daemon/session commands.",
    },
  };
}

function parseArgs(argv: string[]): ParsedOptions {
  let browserName: BrowserName = "chromium";
  let dryRun = false;
  let headless = true;
  let sessionId = "default";
  let timeoutMs = 30_000;
  let waitUntil: WaitUntil = "domcontentloaded";
  let screenshotPath: string | null = null;
  let selector: string | null = null;
  let fullPage = false;
  const positional: string[] = [];

  for (let index = 0; index < argv.length; index += 1) {
    const arg = argv[index] ?? "";
    const next = argv[index + 1];
    if (arg === "--help" || arg === "-h" || arg === "help") {
      positional.push("help");
    } else if (arg === "--dry-run") {
      dryRun = true;
    } else if (arg === "--headless") {
      headless = true;
    } else if (arg === "--headed") {
      headless = false;
    } else if (arg === "--full-page" || arg === "--fullPage") {
      fullPage = true;
    } else if (arg.startsWith("--session=")) {
      sessionId = nonEmptyValue("--session", arg.slice("--session=".length));
    } else if (arg === "--session") {
      sessionId = nonEmptyValue("--session", next);
      index += 1;
    } else if (arg.startsWith("--browser=")) {
      browserName = parseBrowser(arg.slice("--browser=".length));
    } else if (arg === "--browser") {
      browserName = parseBrowser(next);
      index += 1;
    } else if (arg.startsWith("--timeout-ms=")) {
      timeoutMs = parsePositiveInteger("--timeout-ms", arg.slice("--timeout-ms=".length));
    } else if (arg === "--timeout-ms") {
      timeoutMs = parsePositiveInteger("--timeout-ms", next);
      index += 1;
    } else if (arg.startsWith("--wait-until=")) {
      waitUntil = parseWaitUntil(arg.slice("--wait-until=".length));
    } else if (arg === "--wait-until") {
      waitUntil = parseWaitUntil(next);
      index += 1;
    } else if (arg.startsWith("--screenshot=") || arg.startsWith("--filename=")) {
      const value = arg.includes("--screenshot=") ? arg.slice("--screenshot=".length) : arg.slice("--filename=".length);
      screenshotPath = resolvePath(nonEmptyValue("--screenshot", value));
    } else if (arg === "--screenshot" || arg === "--filename") {
      screenshotPath = resolvePath(nonEmptyValue(arg, next));
      index += 1;
    } else if (arg.startsWith("--selector=")) {
      selector = nonEmptyValue("--selector", arg.slice("--selector=".length));
    } else if (arg === "--selector") {
      selector = nonEmptyValue("--selector", next);
      index += 1;
    } else {
      positional.push(arg);
    }
  }

  const [command = "help", ...commandArgs] = positional;
  return { command, commandArgs, browserName, dryRun, headless, sessionId, timeoutMs, waitUntil, screenshotPath, selector, fullPage };
}

function parseBrowser(value: string | undefined): BrowserName {
  const browser = nonEmptyValue("--browser", value);
  if (browser === "chromium" || browser === "chrome") return "chromium";
  if (browser === "firefox") return "firefox";
  if (browser === "webkit") return "webkit";
  throw new Error(`--browser must be chromium, firefox, or webkit; received ${browser}`);
}

function parseWaitUntil(value: string | undefined): WaitUntil {
  const waitUntil = nonEmptyValue("--wait-until", value);
  if (waitUntil === "load" || waitUntil === "domcontentloaded" || waitUntil === "networkidle" || waitUntil === "commit") return waitUntil;
  throw new Error(`--wait-until must be load, domcontentloaded, networkidle, or commit; received ${waitUntil}`);
}

function parsePositiveInteger(name: string, value: string | undefined): number {
  const parsed = Number(nonEmptyValue(name, value));
  if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${name} must be a positive integer`);
  return parsed;
}

function nonEmptyValue(name: string, value: string | undefined): string {
  if (value === undefined || value.length === 0) throw new Error(`${name} requires a non-empty value`);
  return value;
}

function resolvePath(value: string): string {
  return resolve(repoRoot, value);
}

function safeSessionId(value: string): string {
  if (!/^[A-Za-z0-9._-]{1,80}$/u.test(value)) {
    throw new Error("--session must contain only letters, numbers, dot, underscore, or dash, max 80 characters");
  }
  return value;
}

function sessionsDir(): string {
  return join(stateRoot, "sessions");
}

function sessionFile(sessionId: string): string {
  return join(sessionsDir(), `${safeSessionId(sessionId)}.storage.json`);
}

function browserType(name: BrowserName): BrowserType {
  if (name === "firefox") return firefox;
  if (name === "webkit") return webkit;
  return chromium;
}

function redactOptions(options: ParsedOptions): Record<string, unknown> {
  return {
    command: options.command,
    commandArgs: options.commandArgs,
    browser: options.browserName,
    headless: options.headless,
    sessionId: options.sessionId,
    timeoutMs: options.timeoutMs,
    waitUntil: options.waitUntil,
    screenshotPath: options.screenshotPath,
    selector: options.selector,
    fullPage: options.fullPage,
    sessionStatePath: sessionFile(options.sessionId),
  };
}

async function createContext(options: ParsedOptions): Promise<{ browser: Browser; context: BrowserContext; close: () => Promise<void> }> {
  let browser: Browser;
  try {
    browser = await browserType(options.browserName).launch({ headless: options.headless });
  } catch (error) {
    if (!options.headless && isDisplayFailure(error)) {
      throw new Error(`headed browser launch failed because no display is available; rerun with --headless or use xvfb-run -a bun scripts/playwright-cli.ts ${process.argv.slice(2).join(" ")}`);
    }
    throw error;
  }
  const storageState = sessionFile(options.sessionId);
  const context = await browser.newContext(existsSync(storageState) ? { storageState } : {});
  return {
    browser,
    context,
    close: async () => {
      await context.close();
      await browser.close();
    },
  };
}

function isDisplayFailure(error: unknown): boolean {
  const message = error instanceof Error ? error.message : String(error);
  return /Target page, context or browser has been closed|Missing X server|no display|DISPLAY|xvfb-run|Looks like you launched a headed browser/u.test(message);
}

async function gotoPage(page: Page, url: string, options: ParsedOptions): Promise<number | null> {
  const response = await page.goto(url, { timeout: options.timeoutMs, waitUntil: options.waitUntil });
  return response?.status() ?? null;
}

async function runOpen(options: ParsedOptions): Promise<Record<string, unknown>> {
  const url = nonEmptyValue("open url", options.commandArgs[0]);
  const sessionStatePath = sessionFile(options.sessionId);
  const screenshotPath = options.screenshotPath;
  const plan = {
    ok: true,
    action: "open",
    url,
    browser: options.browserName,
    headless: options.headless,
    sessionId: options.sessionId,
    sessionStatePath,
    timeoutMs: options.timeoutMs,
    waitUntil: options.waitUntil,
    screenshotPath,
  };
  if (options.dryRun) return { ...plan, dryRun: true, mutation: false };

  mkdirSync(sessionsDir(), { recursive: true });
  const { context, close } = await createContext(options);
  try {
    const page = await context.newPage();
    const status = await gotoPage(page, url, options);
    const title = await page.title();
    if (screenshotPath !== null) {
      mkdirSync(dirname(screenshotPath), { recursive: true });
      await page.screenshot({ path: screenshotPath, fullPage: options.fullPage });
    }
    await context.storageState({ path: sessionStatePath });
    return {
      ...plan,
      dryRun: false,
      mutation: true,
      status,
      finalUrl: page.url(),
      title,
      sessionSaved: true,
    };
  } finally {
    await close();
  }
}

async function runScreenshot(options: ParsedOptions): Promise<Record<string, unknown>> {
  const url = nonEmptyValue("screenshot url", options.commandArgs[0]);
  const positionalPath = options.commandArgs[1] === undefined ? null : resolvePath(options.commandArgs[1]);
  const screenshotPath = options.screenshotPath ?? positionalPath ?? defaultScreenshotPath;
  const sessionStatePath = sessionFile(options.sessionId);
  const plan = {
    ok: true,
    action: "screenshot",
    url,
    browser: options.browserName,
    headless: options.headless,
    sessionId: options.sessionId,
    sessionStatePath,
    timeoutMs: options.timeoutMs,
    waitUntil: options.waitUntil,
    screenshotPath,
    selector: options.selector,
    fullPage: options.fullPage,
  };
  if (options.dryRun) return { ...plan, dryRun: true, mutation: false };

  mkdirSync(sessionsDir(), { recursive: true });
  mkdirSync(dirname(screenshotPath), { recursive: true });
  const { context, close } = await createContext(options);
  try {
    const page = await context.newPage();
    const status = await gotoPage(page, url, options);
    const title = await page.title();
    if (options.selector !== null) {
      await page.locator(options.selector).screenshot({ path: screenshotPath, timeout: options.timeoutMs });
    } else {
      await page.screenshot({ path: screenshotPath, fullPage: options.fullPage });
    }
    await context.storageState({ path: sessionStatePath });
    return {
      ...plan,
      dryRun: false,
      mutation: true,
      status,
      finalUrl: page.url(),
      title,
      sessionSaved: true,
    };
  } finally {
    await close();
  }
}

async function runEval(options: ParsedOptions): Promise<Record<string, unknown>> {
  const url = nonEmptyValue("eval url", options.commandArgs[0]);
  const expression = nonEmptyValue("eval expression", options.commandArgs[1]);
  const sessionStatePath = sessionFile(options.sessionId);
  const plan = {
    ok: true,
    action: "eval",
    url,
    browser: options.browserName,
    headless: options.headless,
    sessionId: options.sessionId,
    sessionStatePath,
    timeoutMs: options.timeoutMs,
    waitUntil: options.waitUntil,
    expressionPreview: expression.length > 200 ? `${expression.slice(0, 200)}...` : expression,
  };
  if (options.dryRun) return { ...plan, dryRun: true, mutation: false };

  mkdirSync(sessionsDir(), { recursive: true });
  const { context, close } = await createContext(options);
  try {
    const page = await context.newPage();
    const status = await gotoPage(page, url, options);
    const value = await page.evaluate((source) => {
      return Function(`"use strict"; return (${source});`)();
    }, expression);
    await context.storageState({ path: sessionStatePath });
    return {
      ...plan,
      dryRun: false,
      mutation: true,
      status,
      finalUrl: page.url(),
      value,
      sessionSaved: true,
    };
  } finally {
    await close();
  }
}

function runSessionList(): Record<string, unknown> {
  if (!existsSync(sessionsDir())) return { ok: true, sessions: [], stateRoot };
  const glob = new Bun.Glob("*.storage.json");
  const sessions = Array.from(glob.scanSync({ cwd: sessionsDir() })).map((file) => file.replace(/\.storage\.json$/u, ""));
  return { ok: true, sessions: sessions.sort(), stateRoot };
}

function runSessionDelete(options: ParsedOptions): Record<string, unknown> {
  const target = options.commandArgs[0] ?? options.sessionId;
  const file = sessionFile(target);
  const existed = existsSync(file);
  if (options.dryRun) return { ok: true, dryRun: true, mutation: false, sessionId: target, path: file, existed };
  if (existed) rmSync(file);
  return { ok: true, dryRun: false, mutation: existed, sessionId: target, path: file, deleted: existed };
}

function unsupportedCommand(command: string, options: ParsedOptions): Record<string, unknown> {
  return {
    ok: false,
    error: "unsupported-command",
    command,
    supportedCommands,
    reason: unsupportedInteractiveCommands.has(command)
      ? "This repo-owned wrapper is short-run/headless and does not keep a live page with element refs between invocations."
      : "Unknown Playwright wrapper command.",
    actualBehavior: "Use open/screenshot/eval for bounded commander checks, or call npx playwright directly when you intentionally need upstream Playwright CLI behavior.",
    next: [
      "bun scripts/playwright-cli.ts screenshot <url> /tmp/page.png --session <id>",
      "bun scripts/playwright-cli.ts open <url> --session <id> --screenshot /tmp/page.png",
      "xvfb-run -a bun scripts/playwright-cli.ts open <url> --headed --screenshot /tmp/page.png",
    ],
    launchPlan: redactOptions(options),
  };
}

function displayUnavailableError(error: unknown): Record<string, unknown> {
  const message = error instanceof Error ? error.message : String(error);
  return {
    ok: false,
    error: "display-unavailable",
    reason: "A headed browser requires an X server/display, but this host does not expose one.",
    message,
    next: [
      "rerun the same command without --headed",
      `xvfb-run -a bun scripts/playwright-cli.ts ${process.argv.slice(2).join(" ")}`,
    ],
  };
}

export async function runPlaywrightCli(argv: string[]): Promise<void> {
  const options = parseArgs(argv);
  const commandName = `playwright-cli ${argv.join(" ")}`.trim();
  if (options.command === "help" || options.command === "--help" || options.command === "-h") {
    emitJson(commandName, usage());
    return;
  }
  if (options.command === "open") {
    emitJson(commandName, await runOpen(options));
    return;
  }
  if (options.command === "screenshot") {
    emitJson(commandName, await runScreenshot(options));
    return;
  }
  if (options.command === "eval") {
    emitJson(commandName, await runEval(options));
    return;
  }
  if (options.command === "session-list") {
    emitJson(commandName, runSessionList());
    return;
  }
  if (options.command === "session-delete") {
    emitJson(commandName, runSessionDelete(options));
    return;
  }
  emitJson(commandName, unsupportedCommand(options.command, options), false);
  process.exitCode = 1;
}

export function handlePlaywrightCliError(error: unknown): void {
  if (isDisplayFailure(error)) {
    emitJson("playwright-cli", displayUnavailableError(error), false);
    process.exitCode = 1;
    return;
  }
  emitError("playwright-cli", error);
  process.exitCode = 1;
}
