#!/usr/bin/env node
/**
 * A11y-Driven Smoke Test Runner
 *
 * Enumerates interactive elements from the a11y tree,
 * performs random actions within the site, and detects crashes.
 *
 * Usage:
 *   vlmkit inspect smoke <url-or-file>
 *   vlmkit inspect smoke --url https://example.com --max-actions 20
 *   vlmkit inspect smoke --file fixtures/css-challenge/page.html --seed 42
 */
import { mkdir, readFile } from "node:fs/promises";
import { isCliEntry } from "@mizchi/vlmkit-core/plugin/cli-entry.ts";
import { type Page, type Browser } from "playwright";
import { launchBrowser } from "@mizchi/vlmkit-core/browser-launch.ts";
import type {
  SmokeTestRequest, SmokeTestResponse, SmokeAction, SmokeError,
  A11ySnapshot, A11yNodeCompact,
} from "./smoke-types.ts";

import { getArg, getIntArg, getRawArgs } from "@mizchi/vlmkit-core/cli-args.ts";
import { DIM, RESET, GREEN, RED, YELLOW, CYAN, BOLD } from "@mizchi/vlmkit-core/terminal-colors.ts";

// ---- Config ----

const URL_ARG = getArg("url", "");
const rawArgs = getRawArgs();
const FILE_ARG = getArg("file", rawArgs[0] && !rawArgs[0].startsWith("--") ? rawArgs[0] : "");
const MAX_ACTIONS = getIntArg("max-actions", 30, { min: 1 });
const SEED = getIntArg("seed", Date.now());
const MODE = getArg("mode", "random") as "random" | "reasoning";
const RECORD_VIDEO_DIR = getArg("record-video", "");
const VIEWPORT = { width: 1280, height: 900 };

// ---- Random ----

function seededRandom(seed: number): () => number {
  let s = seed;
  return () => { s = (s * 1664525 + 1013904223) & 0x7fffffff; return s / 0x7fffffff; };
}

// ---- A11y tree → action candidates ----

interface ActionCandidate {
  role: string;
  name: string;
  selector: string;
  action: SmokeAction["action"];
  value?: string;
}

const ROLE_ACTION_MAP: Record<string, SmokeAction["action"]> = {
  button: "click",
  link: "click",
  tab: "click",
  menuitem: "click",
  checkbox: "check",
  switch: "check",
  radio: "click",
  textbox: "type",
  searchbox: "type",
  combobox: "click",
};

/** Minimal ARIA yaml → tree parser */
function parseAriaYaml(yaml: string): any {
  if (!yaml) return null;
  const root: any = { role: "document", name: "", children: [] };
  const stack: any[] = [root];
  for (const line of yaml.split("\n")) {
    if (!line.trim()) continue;
    const indent = line.search(/\S/);
    const match = line.trim().match(/^-\s+(\w+)\s*(?:"([^"]*)")?/);
    if (!match) continue;
    const [, role, name] = match;
    const node = { role, name: name ?? "", children: [] };
    const depth = Math.floor(indent / 2) + 1;
    while (stack.length > depth) stack.pop();
    stack[stack.length - 1].children.push(node);
    stack.push(node);
  }
  return root;
}

const SAMPLE_INPUTS = [
  "hello", "test@example.com", "12345", "日本語テスト", "",
  "a".repeat(100), "<script>alert(1)</script>", "   ", "null", "undefined",
];

async function discoverActions(page: Page): Promise<ActionCandidate[]> {
  const candidates: ActionCandidate[] = [];

  // Get a11y snapshot
  const snapshot = await page.locator(":root").ariaSnapshot().then((yaml: string) => parseAriaYaml(yaml)).catch(() => null);
  if (!snapshot) return candidates;

  function walk(node: any, path: string) {
    const role = node.role ?? "";
    const name = node.name ?? "";
    const action = ROLE_ACTION_MAP[role];

    if (action) {
      // For links, only allow same-origin
      if (role === "link" && name) {
        // We'll check origin at click time via page routing
        candidates.push({
          role, name,
          selector: `[role="${role}"]`,
          action,
        });
      } else if (role === "textbox" || role === "searchbox") {
        candidates.push({
          role, name,
          selector: name ? `[aria-label="${name}"]` : `${role}`,
          action: "type",
        });
      } else if (name) {
        candidates.push({
          role, name,
          selector: `[role="${role}"]`,
          action,
        });
      }
    }

    for (const child of node.children ?? []) {
      walk(child, `${path} > ${role}`);
    }
  }

  walk(snapshot, "root");
  return candidates;
}

function buildA11ySnapshot(page: Page, step: number): Promise<A11ySnapshot | null> {
  return page.locator(":root").ariaSnapshot().then((yaml: string) => parseAriaYaml(yaml))
    .then((snap) => {
      if (!snap) return null;
      let interactiveCount = 0;
      let landmarkCount = 0;
      const INTERACTIVE = new Set(Object.keys(ROLE_ACTION_MAP));
      const LANDMARKS = new Set(["banner", "main", "navigation", "contentinfo", "complementary", "form", "region", "search"]);

      function walk(node: any): A11yNodeCompact {
        if (INTERACTIVE.has(node.role)) interactiveCount++;
        if (LANDMARKS.has(node.role)) landmarkCount++;
        return {
          role: node.role ?? "",
          name: node.name ?? "",
          children: (node.children ?? []).map(walk),
        };
      }

      const tree = walk(snap);
      return { step, tree, interactiveCount, landmarkCount, issues: [] };
    })
    .catch(() => null);
}

function countA11yNodes(node: A11yNodeCompact | undefined): number {
  if (!node) return 0;
  return 1 + (node.children ?? []).reduce((sum, child) => sum + countA11yNodes(child), 0);
}

function collectA11ySnapshotConsistencyIssues(
  baseline: A11ySnapshot,
  current: A11ySnapshot,
): string[] {
  const issues: string[] = [];
  const baselineNodeCount = countA11yNodes(baseline.tree);
  const currentNodeCount = countA11yNodes(current.tree);

  if (baselineNodeCount > 1 && currentNodeCount <= 1) {
    issues.push(`a11y tree became empty after step ${current.step}`);
  }
  if (baseline.interactiveCount > 0 && current.interactiveCount === 0) {
    issues.push(
      `interactive targets disappeared after step ${current.step} (${baseline.interactiveCount} -> 0)`,
    );
  }
  if (baseline.landmarkCount > 0 && current.landmarkCount === 0) {
    issues.push(
      `landmarks disappeared after step ${current.step} (${baseline.landmarkCount} -> 0)`,
    );
  }

  return issues;
}

export function evaluateA11ySnapshotConsistency(
  snapshots: readonly A11ySnapshot[],
): SmokeError[] {
  if (snapshots.length < 2) return [];
  const baseline = snapshots[0];
  if (!baseline) return [];

  const errors: SmokeError[] = [];
  for (const snapshot of snapshots.slice(1)) {
    const issues = collectA11ySnapshotConsistencyIssues(baseline, snapshot);
    if (issues.length === 0) continue;
    errors.push({
      step: snapshot.step,
      type: "a11y-regression",
      message: issues.join("; "),
    });
  }
  return errors;
}

export function annotateA11ySnapshotConsistency(
  snapshots: A11ySnapshot[],
): SmokeError[] {
  const errors = evaluateA11ySnapshotConsistency(snapshots);
  for (const error of errors) {
    const snapshot = snapshots.find((item) => item.step === error.step);
    if (!snapshot) continue;
    for (const issue of error.message.split("; ")) {
      if (!snapshot.issues.includes(issue)) snapshot.issues.push(issue);
    }
  }
  return errors;
}

// ---- Runner ----

export async function runSmokeTest(
  request: SmokeTestRequest,
): Promise<SmokeTestResponse> {
  const rand = seededRandom(request.seed ?? Date.now());
  const maxActions = request.maxActions ?? 30;
  const actions: SmokeAction[] = [];
  const errors: SmokeError[] = [];
  const snapshots: A11ySnapshot[] = [];
  const startTime = Date.now();

  let browser: Browser;
  try {
    // Routed through the choke point so the `message` below carries the diagnosis
    // (which Playwright browser is missing, and the install command for the
    // *resolved* installation) instead of a raw stack. This runner is called from
    // the HTTP API, where there is no `handleCliError` to prettify anything.
    browser = await launchBrowser();
  } catch (e) {
    return {
      status: "error",
      actions: [],
      errors: [{ step: 0, type: "crash", message: String(e) }],
      meta: { totalActions: 0, totalErrors: 1, elapsedMs: 0, seed: request.seed, mode: request.mode },
    };
  }

  // recordVideo only works when configured on the context (not directly on a Page).
  const recordingDir = request.recordVideo?.dir;
  if (recordingDir) {
    await mkdir(recordingDir, { recursive: true });
  }
  const context = await browser.newContext({
    viewport: VIEWPORT,
    recordVideo: recordingDir
      ? {
          dir: recordingDir,
          size: {
            width: request.recordVideo?.widthHint ?? VIEWPORT.width,
            height: request.recordVideo?.heightHint ?? VIEWPORT.height,
          },
        }
      : undefined,
  });
  const page = await context.newPage();

  // Block external navigation
  if (request.blockExternalNavigation !== false) {
    const origin = request.target.url
      ? new URL(request.target.url).origin
      : "null"; // data URL origin

    await page.route("**/*", (route) => {
      const url = route.request().url();
      try {
        const reqOrigin = new URL(url).origin;
        if (reqOrigin !== origin && reqOrigin !== "null" && !url.startsWith("data:")) {
          route.abort("blockedbyclient");
          return;
        }
      } catch { /* allow */ }
      route.continue();
    });
  }

  // Collect console errors
  page.on("console", (msg) => {
    if (msg.type() === "error") {
      errors.push({
        step: actions.length,
        type: "console-error",
        message: msg.text(),
      });
    }
  });

  page.on("pageerror", (err) => {
    errors.push({
      step: actions.length,
      type: "uncaught-exception",
      message: err.message,
      stack: err.stack,
    });
  });

  // Load page
  try {
    if (request.target.url) {
      // Security: only allow http/https URLs
      if (!request.target.url.startsWith("http://") && !request.target.url.startsWith("https://")) {
        throw new Error(`Unsupported URL scheme: ${request.target.url}. Only http:// and https:// are allowed.`);
      }
      await page.goto(request.target.url, { waitUntil: "networkidle", timeout: 30000 });
    } else if (request.target.html) {
      await page.setContent(request.target.html, { waitUntil: "networkidle" });
    }
  } catch (e) {
    errors.push({ step: 0, type: "crash", message: `Failed to load: ${e}` });
    await browser.close();
    return {
      status: "crash", actions, errors,
      meta: { totalActions: 0, totalErrors: errors.length, elapsedMs: Date.now() - startTime, seed: request.seed, mode: request.mode },
    };
  }

  // Initial snapshot
  const initialSnap = await buildA11ySnapshot(page, 0);
  if (initialSnap) snapshots.push(initialSnap);

  // LLM reasoning mode: pre-generate action plan
  let plannedActions: ActionCandidate[] | null = null;
  if (request.mode === "reasoning") {
    const { createLLMProvider } = await import("@mizchi/vlmkit-ai/llm-client.ts");
    const llm = createLLMProvider({ throwIfMissing: false });
    if (llm && initialSnap) {
      try {
        const candidates = await discoverActions(page);
        const candidateList = candidates.map((c) => `- ${c.action} ${c.role}: "${c.name}"`).join("\n");
        const prompt = `You are testing a web page by interacting with it like a real user.

Available interactive elements:
${candidateList}

Generate a realistic user interaction sequence (${maxActions} steps).
Each step should be a natural user action (e.g., fill a form, navigate, toggle settings).

Respond with one action per line in this EXACT format:
ACTION: <click|type|check|uncheck> ROLE: <role> NAME: <name> VALUE: <optional value for type>

Example:
ACTION: type ROLE: textbox NAME: Email VALUE: test@example.com
ACTION: click ROLE: button NAME: Submit`;

        const response = await llm.complete(prompt);
        plannedActions = [];
        for (const line of response.split("\n")) {
          const m = line.match(/ACTION:\s*(\w+)\s+ROLE:\s*(\w+)\s+NAME:\s*(.+?)(?:\s+VALUE:\s*(.+))?$/);
          if (m) {
            plannedActions.push({
              role: m[2],
              name: m[3].trim(),
              selector: "",
              action: m[1] as SmokeAction["action"],
              value: m[4]?.trim(),
            });
          }
        }
      } catch { /* fallback to random */ }
    }
  }

  // Action loop
  for (let step = 0; step < maxActions; step++) {
    let candidate: ActionCandidate;

    if (plannedActions && step < plannedActions.length) {
      // LLM reasoning mode: follow the plan
      candidate = plannedActions[step];
    } else {
      // Random mode: discover and pick
      const candidates = await discoverActions(page);
      if (candidates.length === 0) break;
      candidate = candidates[Math.floor(rand() * candidates.length)];
    }
    const actionStart = Date.now();
    let result: SmokeAction["result"] = "ok";

    try {
      // Find element by accessible name + role
      const locator = page.getByRole(candidate.role as any, { name: candidate.name }).first();
      const isVisible = await locator.isVisible({ timeout: 1000 }).catch(() => false);
      if (!isVisible) {
        result = "timeout";
      } else if (await locator.isDisabled({ timeout: 500 }).catch(() => false)) {
        result = "timeout"; // skip disabled elements
      } else {
        switch (candidate.action) {
          case "click":
            await locator.click({ timeout: 3000 });
            break;
          case "type": {
            const value = SAMPLE_INPUTS[Math.floor(rand() * SAMPLE_INPUTS.length)];
            candidate.value = value;
            await locator.fill(value).catch(() => locator.click());
            break;
          }
          case "check":
            await locator.check({ timeout: 2000 }).catch(() => locator.click());
            break;
          case "uncheck":
            await locator.uncheck({ timeout: 2000 }).catch(() => locator.click());
            break;
          case "hover":
            await locator.hover({ timeout: 1000 });
            break;
          default:
            await locator.click({ timeout: 3000 });
        }

        // Wait for page to settle
        await page.waitForTimeout(200);
      }
    } catch (e) {
      const msg = String(e);
      if (msg.includes("navigation")) {
        result = "navigation";
      } else if (msg.includes("timeout") || msg.includes("Timeout")) {
        result = "timeout";
      } else {
        result = "error";
        errors.push({ step, type: "uncaught-exception", message: msg });
      }
    }

    actions.push({
      step,
      target: { role: candidate.role, name: candidate.name },
      action: candidate.action,
      value: candidate.value,
      result,
      elapsedMs: Date.now() - actionStart,
    });

    // Periodic a11y snapshot
    if (step % 5 === 0 || result === "error") {
      const snap = await buildA11ySnapshot(page, step + 1);
      if (snap) snapshots.push(snap);
    }

    // Check if page crashed
    try {
      await page.title();
    } catch {
      errors.push({ step, type: "crash", message: "Page became unresponsive" });
      break;
    }
  }

  // Final snapshot
  const finalSnap = await buildA11ySnapshot(page, actions.length);
  if (finalSnap) {
    const lastIndex = snapshots.length - 1;
    if (snapshots[lastIndex]?.step === finalSnap.step) {
      snapshots[lastIndex] = finalSnap;
    } else {
      snapshots.push(finalSnap);
    }
  }

  errors.push(...annotateA11ySnapshotConsistency(snapshots));

  // Capture the recording path before closing the context (close finalizes the WebM).
  let videoPath: string | undefined;
  if (recordingDir) {
    try {
      const v = page.video();
      if (v) {
        await page.close();
        await context.close();
        videoPath = await v.path();
      }
    } catch {
      // ignore — best-effort recording
    }
  }

  await browser.close();

  const hasCrash = errors.some((e) => e.type === "crash");
  const status = hasCrash ? "crash" : errors.length > 0 ? "error" : "pass";

  return {
    status,
    actions,
    errors,
    snapshots,
    meta: {
      totalActions: actions.length,
      totalErrors: errors.length,
      elapsedMs: Date.now() - startTime,
      seed: request.seed,
      mode: request.mode,
      videoPath,
    },
  };
}

// ---- CLI ----

async function main() {
  // Neither `--help` nor `-h` is a URL or a file, so both landed in this branch and took its
  // exit 1 with them.
  const askedForHelp = process.argv.slice(2).some((a) => a === "--help" || a === "-h");
  if (askedForHelp || (!URL_ARG && !FILE_ARG)) {
    // The command, not the module path. `node src/smoke-runner.ts` has not
    // been a way to run this since the dispatcher took over, so printing it
    // hands the reader an instruction that cannot be pasted.
    console.log("Usage: vlmkit inspect smoke <file-or-url>");
    console.log("       vlmkit inspect smoke --url https://example.com --max-actions 20");
    console.log("       vlmkit inspect smoke --file page.html --seed 42");
    process.exit(askedForHelp ? 0 : 1);
  }

  const target: SmokeTestRequest["target"] = {};
  if (URL_ARG) {
    target.url = URL_ARG;
    target.label = URL_ARG;
  } else {
    target.html = await readFile(FILE_ARG, "utf-8");
    target.label = FILE_ARG;
  }

  console.log();
  console.log(`${BOLD}${CYAN}A11y-Driven Smoke Test${RESET}`);
  console.log(`  ${DIM}Target: ${target.label}${RESET}`);
  console.log(`  ${DIM}Max actions: ${MAX_ACTIONS} | Seed: ${SEED}${RESET}`);
  console.log();

  const result = await runSmokeTest({
    target,
    mode: MODE,
    maxActions: MAX_ACTIONS,
    seed: SEED,
    blockExternalNavigation: true,
    recordVideo: RECORD_VIDEO_DIR ? { dir: RECORD_VIDEO_DIR } : undefined,
  });

  // Print actions
  for (const action of result.actions) {
    const icon = action.result === "ok" ? `${GREEN}✓${RESET}`
      : action.result === "navigation" ? `${CYAN}→${RESET}`
      : action.result === "timeout" ? `${YELLOW}⏱${RESET}`
      : `${RED}✗${RESET}`;
    const valueStr = action.value ? ` "${action.value}"` : "";
    console.log(`  ${icon} [${String(action.step + 1).padStart(2)}] ${action.action} ${action.target.role}:${action.target.name}${valueStr} ${DIM}${action.elapsedMs}ms${RESET}`);
  }

  // Print errors
  if (result.errors.length > 0) {
    console.log();
    console.log(`  ${RED}${BOLD}Errors (${result.errors.length}):${RESET}`);
    for (const err of result.errors) {
      console.log(`    ${RED}[step ${err.step}] ${err.type}: ${err.message.slice(0, 120)}${RESET}`);
    }
  }

  // Summary
  console.log();
  console.log(`  ${BOLD}Result: ${result.status === "pass" ? GREEN : RED}${result.status.toUpperCase()}${RESET}`);
  console.log(`  ${DIM}Actions: ${result.meta.totalActions} | Errors: ${result.meta.totalErrors} | Time: ${result.meta.elapsedMs}ms${RESET}`);
  if (result.meta.videoPath) {
    console.log(`  ${DIM}Video: ${GREEN}${result.meta.videoPath}${RESET}`);
  }
  console.log();

  process.exit(result.status === "pass" ? 0 : 1);
}

// Run CLI only when executed directly
if (isCliEntry(import.meta.url, "smoke-runner")) {
  main().catch((e) => { console.error(e); process.exit(1); });
}
