#!/usr/bin/env node
import { spawn } from "node:child_process";
import fs from "node:fs";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";

import { importPlaywright, launchChromium } from "./src/browser-launcher.mjs";
import { ensureNotRepoReportsPath, tempReportPath } from "./src/report-paths.mjs";

const scriptPath = fileURLToPath(import.meta.url);
const repoRoot = path.resolve(path.dirname(scriptPath), "..");
const stateRoot = path.join(repoRoot, ".state", "web-live-dom-probe");

if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
  try {
    const args = parseProbeArgs(process.argv.slice(2));
    const result = args.command === "start"
      ? await startProbe(args)
      : args.command === "status"
        ? await probeStatus(args)
        : args.command === "help"
          ? helpPayload()
          : await runProbe(args);
    process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
    process.exitCode = ["pass", "running", "usage"].includes(result.status) ? 0 : 2;
  } catch (error) {
    process.stderr.write(`[web-live-dom-probe] ${error instanceof Error ? error.stack : String(error)}\n`);
    process.stdout.write(`${JSON.stringify({ ok: false, command: "web-live-dom-probe", status: "blocked", error: error instanceof Error ? error.message : String(error) }, null, 2)}\n`);
    process.exitCode = 2;
  }
}

export function parseProbeArgs(argv) {
  const command = argv[0] && !argv[0].startsWith("--") ? argv[0] : "run";
  const startIndex = command === "run" ? (argv[0] === "run" ? 1 : 0) : 1;
  const args = {
    command,
    url: process.env.HWLAB_WEB_PROBE_URL ?? process.env.HWLAB_WEB_URL ?? "http://74.48.78.17:19666/",
    user: process.env.HWLAB_WEB_USER ?? "admin",
    pass: process.env.HWLAB_WEB_PASS ?? "hwlab2026",
    viewport: process.env.HWLAB_WEB_PROBE_VIEWPORT ?? "1440x900",
    message: process.env.HWLAB_WEB_PROBE_MESSAGE ?? "",
    freshSession: false,
    cancelRunning: true,
    waitAfterSubmitMs: 1500,
    timeoutMs: 30000,
    projectId: process.env.HWLAB_WEB_PROBE_PROJECT_ID ?? "prj_hwpod_workbench",
    conversationId: process.env.HWLAB_WEB_PROBE_CONVERSATION_ID ?? "",
    waitMessagesMs: 2500,
    waitAgentTerminalMs: Number(process.env.HWLAB_WEB_PROBE_WAIT_AGENT_TERMINAL_MS ?? "0"),
    reportPath: tempReportPath("web-live-dom-probe.json"),
    screenshotPath: tempReportPath("web-live-dom-probe.png"),
    jobId: ""
  };
  if (command === "help" || argv.includes("--help") || argv.includes("-h")) return { ...args, command: "help" };
  for (let index = startIndex; index < argv.length; index += 1) {
    const arg = argv[index];
    const readValue = (name) => {
      const inline = arg.match(new RegExp(`^${name}=(.+)$`, "u"));
      if (inline) return inline[1];
      const value = argv[index + 1];
      if (!value || value.startsWith("--")) throw new Error(`${name} requires a value`);
      index += 1;
      return value;
    };
    if (arg === "--url" || arg.startsWith("--url=")) args.url = readValue("--url");
    else if (arg === "--user" || arg.startsWith("--user=")) args.user = readValue("--user");
    else if (arg === "--pass" || arg.startsWith("--pass=")) args.pass = readValue("--pass");
    else if (arg === "--viewport" || arg.startsWith("--viewport=")) args.viewport = readValue("--viewport");
    else if (arg === "--message" || arg.startsWith("--message=")) args.message = readValue("--message");
    else if (arg === "--project-id" || arg.startsWith("--project-id=")) args.projectId = readValue("--project-id");
    else if (arg === "--conversation-id" || arg.startsWith("--conversation-id=")) args.conversationId = readValue("--conversation-id");
    else if (arg === "--report" || arg.startsWith("--report=")) args.reportPath = readValue("--report");
    else if (arg === "--screenshot" || arg.startsWith("--screenshot=")) args.screenshotPath = readValue("--screenshot");
    else if (arg === "--job-id" || arg.startsWith("--job-id=")) args.jobId = readValue("--job-id");
    else if (arg === "--wait-after-submit-ms" || arg.startsWith("--wait-after-submit-ms=")) args.waitAfterSubmitMs = positiveInteger(readValue("--wait-after-submit-ms"), "--wait-after-submit-ms");
    else if (arg === "--wait-messages-ms" || arg.startsWith("--wait-messages-ms=")) args.waitMessagesMs = positiveInteger(readValue("--wait-messages-ms"), "--wait-messages-ms");
    else if (arg === "--wait-agent-terminal-ms" || arg.startsWith("--wait-agent-terminal-ms=")) args.waitAgentTerminalMs = positiveInteger(readValue("--wait-agent-terminal-ms"), "--wait-agent-terminal-ms");
    else if (arg === "--timeout-ms" || arg.startsWith("--timeout-ms=")) args.timeoutMs = positiveInteger(readValue("--timeout-ms"), "--timeout-ms");
    else if (arg === "--fresh-session") args.freshSession = true;
    else if (arg === "--no-cancel-running") args.cancelRunning = false;
    else if (command === "status" && !args.jobId) args.jobId = arg;
    else throw new Error(`unknown web-live-dom-probe argument: ${arg}`);
  }
  return args;
}

export async function runProbe(args) {
  const startedAt = new Date().toISOString();
  const reportPath = ensureNotRepoReportsPath(repoRoot, path.resolve(repoRoot, args.reportPath), "--report");
  const screenshotPath = ensureNotRepoReportsPath(repoRoot, path.resolve(repoRoot, args.screenshotPath), "--screenshot");
  const { width, height } = parseViewport(args.viewport);
  const actions = [];
  let browser;
  let result;
  try {
    const { chromium } = await importPlaywright();
    browser = await launchChromium(chromium);
    const context = await browser.newContext({ viewport: { width, height } });
    const page = await context.newPage();
    await page.goto(args.url, { waitUntil: "domcontentloaded", timeout: args.timeoutMs });
    await waitForAuthBootstrap(page, args, actions);
    await maybeLogin(page, args, actions);
    await page.locator("#workspace").waitFor({ state: "visible", timeout: args.timeoutMs });
    await page.locator("#command-input").waitFor({ state: "visible", timeout: args.timeoutMs });
    if (args.conversationId) await selectConversationInBrowserSession(page, args, actions);
    if (args.freshSession) await createFreshSession(page, args, actions);
    if (args.message) await submitPrompt(page, args, actions);
    if (args.waitAgentTerminalMs) await waitForAgentTerminal(page, args, actions);
    await waitForMessageCards(page, args, actions);
    await page.screenshot({ path: screenshotPath, fullPage: true });
    const dom = await collectDomEvidence(page);
    const promptValidation = validatePromptFinalResponse(dom, args);
    result = {
      ok: true,
      status: dom.requiredSelectors.workspace && dom.requiredSelectors.commandInput && dom.requiredSelectors.conversationList && promptValidation.ok ? "pass" : "blocked",
      command: "web-live-dom-probe run",
      generatedAt: new Date().toISOString(),
      startedAt,
      url: args.url,
      finalUrl: page.url(),
      viewport: { width, height },
      browser: browser.hwlabLaunchPlan ?? null,
      actions,
      dom,
      promptValidation,
      artifacts: { reportPath, screenshotPath },
      safety: {
        repoOwnedLauncher: true,
        directChromiumLaunch: false,
      projectId: args.projectId,
      conversationSelected: Boolean(args.conversationId),
        freshSessionRequested: args.freshSession,
        promptSubmitted: Boolean(args.message),
        cancelRunningRequested: args.cancelRunning
      }
    };
  } catch (error) {
    result = {
      ok: false,
      status: "blocked",
      command: "web-live-dom-probe run",
      generatedAt: new Date().toISOString(),
      startedAt,
      url: args.url,
      browser: browser?.hwlabLaunchPlan ?? error?.hwlabBrowserLaunch ?? null,
      actions,
      error: error instanceof Error ? error.message : String(error),
      artifacts: { reportPath, screenshotPath }
    };
  } finally {
    if (browser) await browser.close().catch(() => {});
  }
  await mkdir(path.dirname(reportPath), { recursive: true });
  await writeFile(reportPath, `${JSON.stringify(result, null, 2)}\n`, "utf8");
  return result;
}

function validatePromptFinalResponse(dom, args) {
  if (!args.message) return { ok: true, applicable: false, reason: "no-prompt-submitted" };
  const messages = Array.isArray(dom.messages) ? dom.messages : [];
  const latestAgent = [...messages].reverse().find((message) => message.role === "agent");
  const finalResponse = latestAgent?.finalResponse ?? null;
  const markdown = finalResponse?.markdown ?? null;
  const failures = [];
  if (!latestAgent) failures.push("agent-message-missing");
  if (latestAgent?.status !== "completed") failures.push("agent-not-completed");
  if (finalResponse?.present !== true) failures.push("final-response-missing");
  if (!finalResponse || Number(finalResponse.textChars ?? 0) <= 0) failures.push("final-response-empty");
  if (Number(markdown?.headings ?? 0) <= 0) failures.push("markdown-heading-missing");
  if (Number(markdown?.lists ?? 0) <= 0 || Number(markdown?.listItems ?? 0) <= 0) failures.push("markdown-list-missing");
  if (Number(markdown?.codeBlocks ?? 0) <= 0) failures.push("markdown-code-block-missing");
  return {
    ok: failures.length === 0,
    applicable: true,
    failures,
    finalResponse: finalResponse ? {
      textChars: finalResponse.textChars ?? null,
      textPreview: finalResponse.textPreview ?? null,
      markdown: finalResponse.markdown ?? null
    } : null
  };
}

async function startProbe(args) {
  await mkdir(stateRoot, { recursive: true });
  const jobId = args.jobId || `web-live-dom-probe-${Date.now()}-${process.pid}`;
  const reportPath = path.join(stateRoot, `${jobId}.result.json`);
  const screenshotPath = path.join(stateRoot, `${jobId}.png`);
  const stdoutPath = path.join(stateRoot, `${jobId}.stdout.log`);
  const stderrPath = path.join(stateRoot, `${jobId}.stderr.log`);
  const metaPath = path.join(stateRoot, `${jobId}.json`);
  const childArgs = [
    scriptPath,
    "run",
    "--url", args.url,
    "--user", args.user,
    "--pass", args.pass,
    "--viewport", args.viewport,
    "--project-id", args.projectId,
    "--report", reportPath,
    "--screenshot", screenshotPath,
    "--timeout-ms", String(args.timeoutMs),
    "--wait-after-submit-ms", String(args.waitAfterSubmitMs),
    "--wait-agent-terminal-ms", String(args.waitAgentTerminalMs)
  ];
  if (args.freshSession) childArgs.push("--fresh-session");
  if (!args.cancelRunning) childArgs.push("--no-cancel-running");
  if (args.conversationId) childArgs.push("--conversation-id", args.conversationId);
  if (args.message) childArgs.push("--message", args.message);
  const stdout = fs.openSync(stdoutPath, "a");
  const stderr = fs.openSync(stderrPath, "a");
  const child = spawn(process.execPath, childArgs, {
    cwd: repoRoot,
    detached: true,
    stdio: ["ignore", stdout, stderr]
  });
  child.unref();
  const meta = {
    ok: true,
    status: "running",
    command: "web-live-dom-probe start",
    jobId,
    pid: child.pid,
    startedAt: new Date().toISOString(),
    url: args.url,
    projectId: args.projectId,
    conversationId: args.conversationId || null,
    reportPath,
    screenshotPath,
    stdoutPath,
    stderrPath,
    statusCommand: `node scripts/web-live-dom-probe.mjs status ${jobId}`
  };
  await writeFile(metaPath, `${JSON.stringify(meta, null, 2)}\n`, "utf8");
  return meta;
}

async function probeStatus(args) {
  if (!args.jobId) throw new Error("status requires a job id");
  const metaPath = path.join(stateRoot, `${args.jobId}.json`);
  const meta = JSON.parse(await readFile(metaPath, "utf8"));
  const report = fs.existsSync(meta.reportPath) ? JSON.parse(await readFile(meta.reportPath, "utf8")) : null;
  const running = !report && typeof meta.pid === "number" && processAlive(meta.pid);
  return {
    ok: report ? report.ok === true : running,
    status: report ? report.status : running ? "running" : "blocked",
    command: "web-live-dom-probe status",
    jobId: args.jobId,
    pid: meta.pid,
    startedAt: meta.startedAt,
    reportPath: meta.reportPath,
    screenshotPath: meta.screenshotPath,
    stdoutPath: meta.stdoutPath,
    stderrPath: meta.stderrPath,
    running,
    reportSummary: report ? { status: report.status, url: report.url, finalUrl: report.finalUrl, browser: report.browser, dom: report.dom } : null,
    stdoutTail: await readTail(meta.stdoutPath),
    stderrTail: await readTail(meta.stderrPath)
  };
}

async function maybeLogin(page, args, actions) {
  const username = page.locator("#login-username");
  if (!(await username.isVisible({ timeout: Math.min(args.timeoutMs, 12000) }).catch(() => false))) {
    actions.push({ action: "login", skipped: "login-form-not-visible", authState: await page.locator("body").getAttribute("data-auth-state").catch(() => null) });
    return;
  }
  await username.fill(args.user);
  await page.locator("#login-password").fill(args.pass);
  await page.locator("#login-submit").click();
  actions.push({ action: "login", user: args.user });
  await page.locator("#command-input").waitFor({ state: "visible", timeout: args.timeoutMs });
  const finalUrl = new URL(page.url());
  if (finalUrl.searchParams.has("username") || finalUrl.searchParams.has("password")) {
    throw new Error("login credentials leaked into URL query");
  }
}

async function waitForAuthBootstrap(page, args, actions) {
  const timeout = Math.min(args.timeoutMs, 12000);
  const settled = await page.waitForFunction(() => {
    const authState = document.body.getAttribute("data-auth-state");
    const loginVisible = Boolean(document.querySelector("#login-username"));
    const workspaceVisible = Boolean(document.querySelector("#workspace"));
    return authState !== "checking" || loginVisible || workspaceVisible;
  }, null, { timeout }).catch(() => null);
  actions.push({
    action: "auth-bootstrap",
    settled: Boolean(settled),
    authState: await page.locator("body").getAttribute("data-auth-state").catch(() => null)
  });
}

async function maybeClick(page, selector, actions, action) {
  const locator = page.locator(selector).first();
  if (await locator.isVisible({ timeout: 3000 }).catch(() => false)) {
    await locator.click();
    actions.push({ action, selector });
    await page.waitForTimeout(1000);
    return true;
  }
  actions.push({ action, selector, skipped: "not-visible" });
  return false;
}

async function createFreshSession(page, args, actions) {
  const selector = "#session-create";
  const locator = page.locator(selector).first();
  if (!(await locator.isVisible({ timeout: 3000 }).catch(() => false))) {
    actions.push({ action: "fresh-session", selector, skipped: "not-visible" });
    return false;
  }
  const shellReady = await waitForSessionShellReady(page, args, actions);
  const before = await collectSessionState(page);
  await locator.click();
  const timeoutMs = Math.min(args.timeoutMs, 30000);
  const settled = await page.waitForFunction((initial) => {
    const statusText = document.querySelector("#session-status")?.textContent?.trim() ?? null;
    const changed = Boolean(statusText && statusText !== initial.statusText && !/加载中|等待 workspace/u.test(statusText));
    const messageCount = document.querySelectorAll(".message-card").length;
    const emptyVisible = Boolean(document.querySelector("[data-conversation-empty]"));
    const input = document.querySelector("#command-input");
    const send = document.querySelector("#command-send");
    return changed && messageCount === 0 && emptyVisible && input && send && !input.disabled && !send.disabled;
  }, before, { timeout: timeoutMs }).catch(() => null);
  const after = await collectSessionState(page);
  actions.push({ action: "fresh-session", selector, settled: Boolean(settled), timeoutMs, shellReady, before, after });
  if (!settled) throw new Error(`fresh session did not settle: ${JSON.stringify({ before, after, timeoutMs })}`);
  return true;
}

async function waitForSessionShellReady(page, args, actions) {
  const timeoutMs = Math.min(args.timeoutMs, 30000);
  const before = await collectSessionState(page);
  const ready = await page.waitForFunction(() => {
    const statusText = document.querySelector("#session-status")?.textContent?.trim() ?? "";
    const workspace = document.querySelector("#workspace");
    const create = document.querySelector("#session-create");
    return Boolean(workspace && create && !create.disabled && statusText && !/加载中/u.test(statusText));
  }, null, { timeout: timeoutMs }).catch(() => null);
  const after = await collectSessionState(page);
  const result = { action: "session-shell-ready", ready: Boolean(ready), timeoutMs, before, after };
  actions.push(result);
  if (!ready) throw new Error(`session shell did not finish initial loading: ${JSON.stringify(result)}`);
  return after;
}

async function collectSessionState(page) {
  return page.evaluate(() => {
    const activeTab = document.querySelector(".session-tab[aria-selected='true']");
    return {
      statusText: document.querySelector("#session-status")?.textContent?.trim() ?? null,
      activeTabTitle: activeTab?.getAttribute("title") ?? null,
      tabCount: document.querySelectorAll(".session-tab").length,
      messageCount: document.querySelectorAll(".message-card").length,
      emptyVisible: Boolean(document.querySelector("[data-conversation-empty]")),
      command: {
        inputDisabled: Boolean(document.querySelector("#command-input")?.disabled),
        sendDisabled: Boolean(document.querySelector("#command-send")?.disabled),
        sendText: document.querySelector("#command-send")?.textContent?.trim() ?? null,
        formTitle: document.querySelector("#command-form")?.getAttribute("title") ?? null
      }
    };
  });
}

async function submitPrompt(page, args, actions) {
  const readyBefore = await waitForCommandReady(page, args, actions);
  await page.locator("#command-input").fill(args.message);
  await page.waitForFunction((expected) => document.querySelector("#command-input")?.value === expected, args.message, { timeout: Math.min(args.timeoutMs, 5000) });
  const afterFill = await collectCommandState(page);
  await page.locator("#command-send").click();
  const afterClick = await collectCommandState(page);
  actions.push({ action: "submit-prompt", chars: args.message.length, readyBefore, afterFill, afterClick });
  await page.waitForTimeout(args.waitAfterSubmitMs);
  if (args.cancelRunning) await maybeClick(page, ".message-action-cancel", actions, "cancel-running-message");
}

async function waitForCommandReady(page, args, actions) {
  const timeoutMs = Math.min(args.timeoutMs, 30000);
  const before = await collectCommandState(page);
  const ready = await page.waitForFunction(() => {
    const input = document.querySelector("#command-input");
    const send = document.querySelector("#command-send");
    return Boolean(input && send && !input.disabled && !send.disabled);
  }, null, { timeout: timeoutMs }).catch(() => null);
  const after = await collectCommandState(page);
  const result = { action: "command-ready", ready: Boolean(ready), timeoutMs, before, after };
  actions.push(result);
  if (!ready) throw new Error(`command bar is not ready for submit: ${JSON.stringify(result)}`);
  return after;
}

async function collectCommandState(page) {
  return page.evaluate(() => {
    const input = document.querySelector("#command-input");
    const send = document.querySelector("#command-send");
    const form = document.querySelector("#command-form");
    return {
      inputVisible: Boolean(input),
      inputDisabled: input ? Boolean(input.disabled) : null,
      inputLength: input && typeof input.value === "string" ? input.value.length : null,
      sendVisible: Boolean(send),
      sendDisabled: send ? Boolean(send.disabled) : null,
      sendText: send?.textContent?.trim() ?? null,
      formTitle: form?.getAttribute("title") ?? null,
      messageCount: document.querySelectorAll(".message-card").length,
      authState: document.body.getAttribute("data-auth-state")
    };
  });
}

async function selectConversationInBrowserSession(page, args, actions) {
  const result = await page.evaluate(async ({ projectId, conversationId }) => {
    const summarizeAuthSession = (body) => ({
      authenticated: body?.authenticated ?? null,
      userId: body?.user?.id ?? body?.actor?.id ?? null,
      username: body?.user?.username ?? body?.actor?.username ?? null,
      role: body?.user?.role ?? body?.actor?.role ?? null,
      status: body?.user?.status ?? body?.actor?.status ?? null
    });
    const summarizeWorkspace = (workspace) => workspace ? {
      workspaceId: workspace.workspaceId ?? null,
      ownerUserId: workspace.ownerUserId ?? null,
      projectId: workspace.projectId ?? null,
      selectedConversationId: workspace.selectedConversationId ?? null,
      selectedAgentSessionId: workspace.selectedAgentSessionId ?? null,
      selectedConversationStatus: workspace.selectedConversation?.status ?? null,
      selectedMessagesCount: workspace.selectedConversation?.messages?.length ?? workspace.selectedConversation?.messagesCount ?? null,
      revision: workspace.revision ?? null
    } : null;
    const sessionResponse = await fetch("/auth/session", { credentials: "same-origin" });
    const sessionBody = await sessionResponse.json().catch(() => null);
    const workspaceResponse = await fetch(`/v1/workbench/workspace?projectId=${encodeURIComponent(projectId)}`, { credentials: "same-origin" });
    const workspaceBody = await workspaceResponse.json().catch(() => null);
    const workspaceId = workspaceBody?.workspace?.workspaceId;
    if (!workspaceId) return {
      ok: false,
      phase: "workspace",
      status: workspaceResponse.status,
      workspaceId: null,
      authSession: summarizeAuthSession(sessionBody),
      workspace: summarizeWorkspace(workspaceBody?.workspace),
      bodyStatus: workspaceBody?.status ?? null,
      error: workspaceBody?.error ?? null
    };
    const selectResponse = await fetch(`/v1/workbench/workspace/${encodeURIComponent(workspaceId)}/select-conversation`, {
      method: "POST",
      credentials: "same-origin",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ projectId, conversationId, updatedByClient: "web-live-dom-probe" })
    });
    const selectBody = await selectResponse.json().catch(() => null);
    return {
      ok: selectResponse.ok,
      phase: "select-conversation",
      status: selectResponse.status,
      workspaceId,
      authSession: summarizeAuthSession(sessionBody),
      workspace: summarizeWorkspace(workspaceBody?.workspace),
      responseStatus: selectBody?.status ?? null,
      error: selectBody?.error ?? null,
      conversationId: selectBody?.workspace?.selectedConversationId ?? conversationId,
      selectedMessageCount: selectBody?.workspace?.selectedConversation?.messages?.length ?? selectBody?.workspace?.selectedConversation?.messagesCount ?? null,
      sessionStatus: selectBody?.workspace?.selectedConversation?.status ?? selectBody?.workspace?.sessionStatus ?? null
    };
  }, { projectId: args.projectId, conversationId: args.conversationId });
  actions.push({ action: "select-conversation", ...result });
  if (!result.ok) throw new Error(`browser session select-conversation failed: ${JSON.stringify(result)}`);
  await page.reload({ waitUntil: "domcontentloaded", timeout: args.timeoutMs });
  await waitForAuthBootstrap(page, args, actions);
  await page.locator("#workspace").waitFor({ state: "visible", timeout: args.timeoutMs });
  await page.locator("#command-input").waitFor({ state: "visible", timeout: args.timeoutMs });
}

async function waitForMessageCards(page, args, actions) {
  if (!args.waitMessagesMs) return;
  const before = await page.locator(".message-card").count().catch(() => 0);
  const found = await page.waitForFunction(() => document.querySelectorAll(".message-card").length > 0, null, { timeout: args.waitMessagesMs }).catch(() => null);
  const after = await page.locator(".message-card").count().catch(() => 0);
  actions.push({ action: "wait-message-cards", before, after, found: Boolean(found), timeoutMs: args.waitMessagesMs });
}

async function waitForAgentTerminal(page, args, actions) {
  const timeoutMs = Math.min(args.waitAgentTerminalMs, args.timeoutMs);
  const before = await collectLatestAgentMessageState(page);
  const terminal = await page.waitForFunction(() => {
    const cards = [...document.querySelectorAll(".message-card")];
    const latestAgent = [...cards].reverse().find((card) => card.getAttribute("data-message-role") === "agent" || card.classList.contains("message-agent"));
    if (!latestAgent) return false;
    const status = latestAgent.getAttribute("data-message-status") ?? "";
    const finalResponse = latestAgent.querySelector(".message-final-response");
    const text = finalResponse?.textContent?.trim() ?? "";
    return status && status !== "running" && text.length > 0;
  }, null, { timeout: timeoutMs }).catch(() => null);
  const after = await collectLatestAgentMessageState(page);
  actions.push({ action: "wait-agent-terminal", terminal: Boolean(terminal), timeoutMs, before, after });
  if (!terminal) throw new Error(`agent final response did not reach terminal DOM state: ${JSON.stringify({ before, after, timeoutMs })}`);
}

async function collectLatestAgentMessageState(page) {
  return page.evaluate(() => {
    const cards = [...document.querySelectorAll(".message-card")];
    const latestAgent = [...cards].reverse().find((card) => card.getAttribute("data-message-role") === "agent" || card.classList.contains("message-agent"));
    const finalResponse = latestAgent?.querySelector(".message-final-response");
    return {
      messageCount: cards.length,
      status: latestAgent?.getAttribute("data-message-status") ?? null,
      textChars: finalResponse?.textContent?.trim().length ?? 0,
      textPreview: (finalResponse?.textContent ?? "").replace(/\s+/gu, " ").trim().slice(0, 240)
    };
  });
}

async function collectDomEvidence(page) {
  return page.evaluate(() => {
    const workspace = document.querySelector("#workspace");
    const list = document.querySelector("#conversation-list");
    const cards = [...document.querySelectorAll(".message-card")];
    const scroll = workspace ? {
      scrollTop: Math.round(workspace.scrollTop),
      scrollHeight: Math.round(workspace.scrollHeight),
      clientHeight: Math.round(workspace.clientHeight),
      overflowY: getComputedStyle(workspace).overflowY,
      followState: workspace.getAttribute("data-scroll-follow")
    } : null;
    return {
      title: document.title,
      authState: document.body.getAttribute("data-auth-state"),
      requiredSelectors: {
        workspace: Boolean(workspace),
        commandInput: Boolean(document.querySelector("#command-input")),
        commandSend: Boolean(document.querySelector("#command-send")),
        conversationList: Boolean(list),
        sessionCreate: Boolean(document.querySelector("#session-create"))
      },
      messageCount: cards.length,
      messageRoles: cards.map((card) => card.getAttribute("data-message-role") ?? [...card.classList].find((item) => item === "message-user" || item === "message-agent" || item === "message-system") ?? "unknown"),
      messages: cards.map((card) => {
        const trace = card.querySelector(".message-trace");
        const traceEvents = card.querySelector(".message-trace-events");
        const finalResponse = card.querySelector(".message-final-response");
        const traceStyle = traceEvents ? getComputedStyle(traceEvents) : null;
        const finalStyle = finalResponse ? getComputedStyle(finalResponse) : null;
        const traceRect = traceEvents?.getBoundingClientRect();
        const finalRect = finalResponse?.getBoundingClientRect();
        const finalBody = finalResponse?.querySelector(".message-body");
        return {
          status: card.getAttribute("data-message-status") ?? null,
          role: card.getAttribute("data-message-role") ?? [...card.classList].find((item) => item === "message-user" || item === "message-agent" || item === "message-system")?.replace(/^message-/, "") ?? null,
          className: card.className,
          trace: trace ? {
            present: true,
            open: trace instanceof HTMLDetailsElement ? trace.open : null,
            status: trace.getAttribute("data-trace-status"),
            mode: trace.getAttribute("data-trace-mode"),
            maxHeight: traceStyle?.maxHeight ?? null,
            overflowY: traceStyle?.overflowY ?? null,
            height: traceRect ? Math.round(traceRect.height) : null,
            scrollHeight: traceEvents ? Math.round(traceEvents.scrollHeight) : null
          } : { present: false },
          finalResponse: finalResponse ? {
            present: true,
            maxHeight: finalStyle?.maxHeight ?? null,
            overflowY: finalStyle?.overflowY ?? null,
            height: finalRect ? Math.round(finalRect.height) : null,
            scrollHeight: Math.round(finalResponse.scrollHeight),
            textChars: finalResponse.textContent?.trim().length ?? 0,
            textPreview: (finalResponse.textContent ?? "").replace(/\s+/gu, " ").trim().slice(0, 500),
            markdown: finalBody ? {
              paragraphs: finalBody.querySelectorAll("p").length,
              headings: finalBody.querySelectorAll("h1,h2,h3,h4,h5,h6").length,
              lists: finalBody.querySelectorAll("ul,ol").length,
              listItems: finalBody.querySelectorAll("li").length,
              codeBlocks: finalBody.querySelectorAll("pre code").length,
              inlineCode: finalBody.querySelectorAll("p code,li code").length,
              tables: finalBody.querySelectorAll("table").length
            } : null
          } : { present: false }
        };
      }),
      scroll,
      textPreview: (document.body.textContent ?? "").replace(/\s+/gu, " ").trim().slice(0, 800)
    };
  });
}

function parseViewport(value) {
  const [width, height] = String(value).split("x").map(Number);
  if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) throw new Error(`invalid --viewport: ${value}`);
  return { width, height };
}

function positiveInteger(value, label) {
  const parsed = Number(value);
  if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${label} must be a positive integer`);
  return parsed;
}

function processAlive(pid) {
  try {
    process.kill(pid, 0);
    return true;
  } catch {
    return false;
  }
}

async function readTail(file, maxChars = 4000) {
  if (!file || !fs.existsSync(file)) return "";
  const text = await readFile(file, "utf8");
  return text.length > maxChars ? text.slice(-maxChars) : text;
}

function helpPayload() {
  return {
    ok: true,
    status: "usage",
    command: "web-live-dom-probe",
    usage: [
      "node scripts/web-live-dom-probe.mjs run --url http://74.48.78.17:19666/ --fresh-session --report /tmp/hwlab-dev-gate/web-live-dom-probe.json",
      "node scripts/web-live-dom-probe.mjs start --url http://74.48.78.17:19666/ --fresh-session --message '...' --no-cancel-running --wait-agent-terminal-ms 180000",
      "node scripts/web-live-dom-probe.mjs run --url http://74.48.78.17:19666/#/workspace --conversation-id cnv_...",
      "node scripts/web-live-dom-probe.mjs start --url http://74.48.78.17:19666/ --fresh-session",
      "node scripts/web-live-dom-probe.mjs status <jobId>"
    ],
    output: "JSON with browser launch plan, DOM selectors, report/screenshot paths, and short-poll status metadata."
  };
}
