#!/usr/bin/env node
/**
 * Verified scripted browser flow (key-free half of the "verified
 * agent"; docs/design/mcp-and-agent-expansion.md Part B).
 *
 * A flow is a list of steps: each step performs a browser action and
 * then asserts a deterministic post-condition on the live DOM. The run
 * FAILS the first time a post-condition is unmet — the difference from
 * every LLM-per-step browser agent is that "it did something" is not
 * success; "the asserted state actually holds" is. No LLM: the flow is
 * given (a planner may emit it later, but the verification engine is
 * fully deterministic).
 *
 * Assertion vocabulary (post-condition language):
 *   - attr    : an attribute of `selector` equals a value (aria-expanded=true)
 *   - visible : `selector` is displayed with non-zero box
 *   - hidden  : `selector` is absent or not displayed
 *   - focused : `selector` holds (or contains) document.activeElement
 *   - text    : `selector`'s textContent contains a substring
 *   - count   : number of `selector` matches equals a value
 *
 * CLI:
 *   vlmkit verify flow <html-or-url> --flow <flow.json> [--json]
 */
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import type { Page } from "playwright";
import { handleCliError, UsageError } from "@mizchi/vlmkit-core/cli-error.ts";
import { withAuthState } from "@mizchi/vlmkit-core/auth-state.ts";
import { describeRedirect } from "@mizchi/vlmkit-core/navigation-redirect.ts";
import { type PageLoadOptions, applyHar, navigationOptions } from "@mizchi/vlmkit-core/page-load.ts";
import { settlePage } from "@mizchi/vlmkit-core/page-open.ts";
import { appendRunLedger } from "@mizchi/vlmkit-core/run-ledger.ts";
import { BOLD, CYAN, DIM, GREEN, RED, RESET } from "@mizchi/vlmkit-core/terminal-colors.ts";
import type { RuleView } from "@mizchi/vlmkit-core/plugin/contract.ts";
import { ruleTier } from "@mizchi/vlmkit-core/plugin/rule-tier.ts";
import { withBrowser } from "@mizchi/vlmkit-core/browser-launch.ts";

export type FlowAction =
  /**
   * `force` skips Playwright's actionability wait. Needed to assert that a
   * genuinely disabled control DOES NOTHING when clicked: Playwright refuses
   * to click `aria-disabled="true"` elements, so without force the honest
   * implementation times out — and the S19 run showed an agent "fixing"
   * that by making aria-disabled transient (set for 50ms, then removed),
   * which passes the assert while lying to assistive tech. Force lets the
   * flow author click through the disabled state instead.
   */
  | { action: "click"; selector: string; force?: boolean }
  | { action: "press"; selector?: string; key: string }
  | { action: "fill"; selector: string; value: string }
  | { action: "type"; selector: string; text: string }
  | { action: "focus"; selector: string }
  | { action: "hover"; selector: string }
  | { action: "wait"; ms: number };

export type FlowAssert =
  | { assert: "attr"; selector: string; name: string; equals: string | null }
  | { assert: "visible"; selector: string }
  | { assert: "hidden"; selector: string }
  | { assert: "focused"; selector: string }
  | { assert: "text"; selector: string; contains: string }
  | { assert: "count"; selector: string; equals: number };

export interface FlowStep {
  /** Optional human label for the step. */
  label?: string;
  do: FlowAction;
  /** Post-conditions checked after the action; all must hold. */
  expect?: FlowAssert[];
}

export interface Flow {
  viewport?: { width: number; height: number };
  steps: FlowStep[];
}

export interface StepResult {
  index: number;
  label: string;
  action: string;
  actionError?: string;
  assertions: { assert: FlowAssert; passed: boolean; actual: string }[];
  passed: boolean;
}

export interface FlowVerifyReport {
  source: string;
  steps: StepResult[];
  passed: number;
  total: number;
  done: boolean;
  /**
   * Set when the URL redirected — almost always a login wall. A flow driven
   * against a sign-in page fails on "element not found" for every step, which
   * reads as a broken flow rather than a missing session.
   */
  redirected?: string;
}

export const FLOW_ACTIONS = ["click", "press", "fill", "type", "focus", "hover", "wait"] as const;
export const FLOW_ASSERTS = ["attr", "visible", "hidden", "focused", "text", "count"] as const;

/**
 * Reject a malformed flow before opening a browser.
 *
 * Measured 2026-08-02, both directions were wrong:
 *
 *   {"assert":"visble"}  ->  FAIL (unknown assert)   — a flow-file typo read as
 *                                                      a page defect
 *   {"action":"clik"}    ->  done: true              — a flow that performed no
 *                                                      action at all passed
 *
 * The second is the worse one: `runAction`'s switch had no default, so an
 * unrecognised action fell through silently and the step's post-conditions (none
 * given) all held. A green verdict from a flow that did nothing.
 *
 * `--allow` already refuses an unknown finding kind for exactly this reason. A
 * gate that cannot tell "your page is wrong" from "your flow file is wrong" is
 * worse than no gate.
 */
export function validateFlow(flow: Flow): void {
  if (!Array.isArray(flow.steps) || flow.steps.length === 0) {
    throw new UsageError(`flow has no steps. Expected { "steps": [ { "do": <action>, "expect": [<assert>...] } ] }.`);
  }
  flow.steps.forEach((step, i) => {
    const where = `step ${i}${step.label ? ` ("${step.label}")` : ""}`;
    const action = (step.do as { action?: string } | undefined)?.action;
    if (!action) throw new UsageError(`${where} has no "do.action". Valid actions: ${FLOW_ACTIONS.join(", ")}.`);
    if (!FLOW_ACTIONS.includes(action as (typeof FLOW_ACTIONS)[number])) {
      throw new UsageError(`${where}: unknown action "${action}". Valid actions: ${FLOW_ACTIONS.join(", ")}.`);
    }
    (step.expect ?? []).forEach((spec, j) => {
      const name = (spec as { assert?: string } | undefined)?.assert;
      if (!name) {
        throw new UsageError(`${where}, expect[${j}] has no "assert". Valid asserts: ${FLOW_ASSERTS.join(", ")}.`);
      }
      if (!FLOW_ASSERTS.includes(name as (typeof FLOW_ASSERTS)[number])) {
        throw new UsageError(
          `${where}, expect[${j}]: unknown assert "${name}". Valid asserts: ${FLOW_ASSERTS.join(", ")}.`
          + ` This is a flow-file error, not a page defect — it used to be reported as a failing post-condition.`,
        );
      }
    });
  });
}

function describeAction(a: FlowAction): string {
  switch (a.action) {
    case "click": return `click ${a.selector}${a.force ? " (force)" : ""}`;
    case "press": return `press ${a.key}${a.selector ? ` on ${a.selector}` : ""}`;
    case "fill": return `fill ${a.selector}`;
    case "type": return `type into ${a.selector}`;
    case "focus": return `focus ${a.selector}`;
    case "hover": return `hover ${a.selector}`;
    case "wait": return `wait ${a.ms}ms`;
  }
}

async function runAction(page: Page, a: FlowAction): Promise<void> {
  switch (a.action) {
    case "click": await page.click(a.selector, { timeout: 5000, ...(a.force ? { force: true } : {}) }); return;
    case "press":
      if (a.selector) await page.press(a.selector, a.key, { timeout: 5000 });
      else await page.keyboard.press(a.key);
      return;
    case "fill": await page.fill(a.selector, a.value, { timeout: 5000 }); return;
    case "type": await page.type(a.selector, a.text, { timeout: 5000 }); return;
    case "focus": await page.focus(a.selector, { timeout: 5000 }); return;
    case "hover": await page.hover(a.selector, { timeout: 5000 }); return;
    case "wait": await page.waitForTimeout(a.ms); return;
  }
}

/** Evaluate one assertion in-page; returns [passed, actual]. */
function evalAssertion(spec: FlowAssert): (s: FlowAssert) => [boolean, string] {
  // Returned as a real function so page.evaluate(fn, spec) passes the
  // argument (a string body would be treated as an expression and never
  // receive spec). `spec` is threaded for closure-free serialization.
  void spec;
  return (s: FlowAssert): [boolean, string] => {
    const q = (sel: string) => document.querySelector(sel);
    const visible = (el: Element | null): boolean => {
      if (!el) return false;
      const st = getComputedStyle(el);
      const r = el.getBoundingClientRect();
      return st.display !== "none" && st.visibility !== "hidden" && r.width > 0 && r.height > 0;
    };
    switch (s.assert) {
      case "attr": {
        const el = q(s.selector);
        const actual = el ? el.getAttribute(s.name) : "(no element)";
        return [el != null && actual === s.equals, String(actual)];
      }
      case "visible": { const el = q(s.selector); return [visible(el), visible(el) ? "visible" : "hidden/absent"]; }
      case "hidden": { const el = q(s.selector); return [!visible(el), visible(el) ? "visible" : "hidden/absent"]; }
      case "focused": {
        const el = q(s.selector); const active = document.activeElement;
        const ok = !!el && (el === active || el.contains(active));
        return [ok, active ? (active.id ? "#" + active.id : active.tagName.toLowerCase()) : "(none)"];
      }
      case "text": {
        const el = q(s.selector); const t = el ? (el.textContent || "").replace(/\s+/g, " ").trim() : "";
        return [t.includes(s.contains), t.slice(0, 80)];
      }
      case "count": {
        const n = document.querySelectorAll(s.selector).length;
        return [n === s.equals, String(n)];
      }
      default: return [false, "unknown assert"];
    }
  };
}

export interface FlowVerifyOptions extends PageLoadOptions {
  /**
   * Playwright storage-state file so gates can measure pages behind a
   * login. Falls back to VLMKIT_STORAGE_STATE. See auth-state.ts.
   */
  storageState?: string;
  source: string;
  flow: Flow;
}

export async function runFlowVerify(options: FlowVerifyOptions): Promise<FlowVerifyReport> {
  validateFlow(options.flow);
  const steps: StepResult[] = [];
  let redirected: string | undefined;
  await withBrowser(async (browser) => {
    const page = await browser.newPage(withAuthState({ viewport: options.flow.viewport ?? { width: 1280, height: 800 } }, options.storageState));
    const url = /^(https?|file):\/\//.test(options.source) ? options.source : pathToFileURL(resolve(options.source)).href;
    await applyHar(page, options.har);
    // `load` stays this gate's default milestone — the settle below already
    // bounds a network-idle wait, which is why a flow survives a page that
    // never reaches idle. `--wait-until` can still lower it.
    await page.goto(url, navigationOptions(options, "load"));
    // Assertions read the DOM through `page.evaluate`, which — unlike a
    // Playwright action — does not auto-wait. Without this settle a flow
    // pointed at a client-rendered view asserts against the placeholder:
    // measured 2026-08-02, `count .card expected 2, measured 0` on a page where
    // `check layout` measured 2 at the same instant. The failure read as a
    // markup defect, and the only workaround was for the flow author to guess
    // at an explicit `{"action":"wait"}` first step.
    await settlePage(page);
    redirected = /^https?:\/\//.test(options.source)
      ? describeRedirect(options.source, page.url()) ?? undefined
      : undefined;

    for (let i = 0; i < options.flow.steps.length; i++) {
      const step = options.flow.steps[i]!;
      const res: StepResult = {
        index: i,
        label: step.label ?? describeAction(step.do),
        action: describeAction(step.do),
        assertions: [],
        passed: true,
      };
      try {
        await runAction(page, step.do);
      } catch (e) {
        res.actionError = String(e).split("\n")[0]!.slice(0, 120);
        res.passed = false;
      }
      if (!res.actionError) {
        for (const spec of step.expect ?? []) {
          const [passed, actual] = await page.evaluate(evalAssertion(spec), spec) as [boolean, string];
          res.assertions.push({ assert: spec, passed, actual });
          if (!passed) res.passed = false;
        }
      }
      steps.push(res);
      if (!res.passed) break; // stop at the first unmet post-condition
    }
  });
  const passed = steps.filter((s) => s.passed).length;
  // Not `done` on a redirect: the flow ran against a page the caller did not
  // ask for, so passing steps would be a claim about the sign-in screen.
  const done = steps.length === options.flow.steps.length && steps.every((s) => s.passed) && !redirected;
  appendRunLedger({
    tool: "verify-flow",
    source: options.source,
    headline: { done, passed, total: options.flow.steps.length, ...(redirected ? { redirected: true } : {}) },
  });
  return {
    source: options.source,
    steps,
    passed,
    total: options.flow.steps.length,
    done,
    ...(redirected ? { redirected } : {}),
  };
}

export function formatFlowReport(report: FlowVerifyReport, rules?: RuleView): string {
  const lines: string[] = [];
  const off = (rule: string) => ruleTier(rules, rule, "suspect") === "off";
  const stepFailedOff = off("step-failed");
  lines.push(`${BOLD}${CYAN}vlmkit verify flow${RESET}`);
  lines.push(`${DIM}source: ${report.source}${RESET}`);
  if (report.redirected && !off("redirected")) {
    lines.push(`${RED}x ${report.redirected}${RESET}`);
    lines.push(`${DIM}  Every step below ran against that page.${RESET}`);
  } else if (report.redirected) {
    lines.push(`${DIM}- ${report.redirected} — NOT reported (redirected off)${RESET}`);
  }
  lines.push("");
  // `passed/total` are the measurement and stay; the word is what the settings leave of it.
  const failedLive = !stepFailedOff && report.steps.some((s) => !s.passed);
  const redirectLive = report.redirected !== undefined && !off("redirected");
  const done = report.done || !(failedLive || redirectLive);
  lines.push(`verdict: ${done ? `${GREEN}DONE${RESET}` : `${RED}FAILED${RESET}`} (${report.passed}/${report.total} steps)`
    + (done && !report.done ? ` ${DIM}(step-failed off — the failing steps below are measured, not reported)${RESET}` : ""));
  lines.push("");
  for (const s of report.steps) {
    const mark = s.passed ? `${GREEN}✓${RESET}` : stepFailedOff ? `${DIM}-${RESET}` : `${RED}✗${RESET}`;
    lines.push(`${mark} step ${s.index + 1}: ${s.label}`);
    if (s.actionError) lines.push(`    ${RED}action failed:${RESET} ${s.actionError}`);
    for (const a of s.assertions) {
      const m = a.passed ? `${GREEN}✓${RESET}` : `${RED}✗${RESET}`;
      const spec = a.assert;
      const want = spec.assert === "attr"
        ? `${spec.name}=${spec.equals}`
        : spec.assert === "text"
        ? `text~"${spec.contains}"`
        : spec.assert === "count"
        ? `count=${spec.equals}`
        : spec.assert;
      lines.push(`    ${m} ${"selector" in spec ? spec.selector : ""} ${want} ${a.passed ? "" : `${RED}(got: ${a.actual})${RESET}`}`);
    }
  }
  return lines.join("\n");
}

/**
 * CLI entry removed: this module is measurement code now, not a command.
 * `verify flow` is declared in `../gates/verify.gate.ts` and driven by the core runner
 * (`@mizchi/vlmkit-core/plugin/runner.ts`), which owns argument parsing,
 * `--json`, `--advisory`, the run ledger and the exit code.
 */
