// SPEC: PJ2026-01060508 Web哨兵 draft-2026-07-18-p19-external-form-workflow.
// Responsibility: Persistent, redacted browser runner for declared external form workflows.

export function webProbeExternalFormRunnerSource(): string {
  return String.raw`#!/usr/bin/env node
import { createHash } from "node:crypto";
import { readFileSync, readdirSync } from "node:fs";
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import { createServer } from "node:http";
import path from "node:path";
import { pathToFileURL } from "node:url";

const stateDir = path.resolve(requiredEnv("UNIDESK_EXTERNAL_FORM_STATE_DIR"));
const workflowPath = path.join(stateDir, "workflow.json");
const secretPath = path.join(stateDir, "secrets.json");
const statusPath = path.join(stateDir, "status.json");
const launchReadyPath = path.join(stateDir, "launch-ready.json");
const startedAt = new Date().toISOString();
const workflow = JSON.parse(await readFile(workflowPath, "utf8"));
const secrets = JSON.parse(await readFile(secretPath, "utf8"));
await rm(secretPath, { force: true });
const secretValues = Object.values(secrets).filter((value) => typeof value === "string" && value.length > 0);
const completed = [];
const network = { responses: 0, failed: 0, status4xx: 0, status5xx: 0 };
const browserSignals = { consoleErrors: 0, pageErrors: 0, lastPageErrorHash: null };
const observedPages = new WeakSet();
let inspection = null;
let browser;
let context;
let page;
let browserPid = null;
let fixtureServer = null;
let effectiveOrigin = workflow.origin;
let stopping = false;
let currentAction = null;

process.on("SIGTERM", () => { stopping = true; });
process.on("SIGINT", () => { stopping = true; });

try {
  await mkdir(path.join(stateDir, "artifacts"), { recursive: true, mode: 0o700 });
  await writeFile(path.join(stateDir, "pid"), String(process.pid) + "\n", { mode: 0o600 });
  await writeStatus("starting");
  if (typeof workflow.fixtureHtml === "string") {
    fixtureServer = await startFixtureServer(workflow.fixtureHtml);
    effectiveOrigin = "http://127.0.0.1:" + fixtureServer.address().port;
  }
  const launcher = await import(pathToFileURL(path.resolve(requiredEnv("UNIDESK_EXTERNAL_FORM_LAUNCHER_PATH"))).href);
  const { chromium } = await launcher.importPlaywright();
  const proxy = browserProxy();
  browser = await launcher.launchChromium(chromium, {
    env: browserProcessEnvWithoutProxy(),
    ...(proxy === null ? { args: ["--no-proxy-server"] } : { proxy }),
  });
  browserPid = await waitForBrowserPid();
  context = await browser.newContext({ viewport: workflow.viewport, ...(proxy === null ? {} : { proxy }) });
  page = await context.newPage();
  attachPageSignals(page);
  await writeStatus("running", { browserPid });
  await writeJsonAtomic(launchReadyPath, {
    ok: true,
    status: "ready",
    workflowId: workflow.workflowId,
    pid: process.pid,
    browserPid,
    phase: "running",
    observedAt: new Date().toISOString(),
    valuesRedacted: true,
  });

  for (let index = 0; index < workflow.actions.length; index += 1) {
    if (stopping) throw stableError("workflow-stopped", "workflow was stopped");
    const action = workflow.actions[index];
    currentAction = { index, id: action.id, type: action.type };
    await writeStatus("running");
    await executeAction(action);
    completed.push(action.id);
  }
  currentAction = null;
  await writeStatus("completed", { completedAt: new Date().toISOString() });
} catch (error) {
  const code = error && typeof error === "object" && typeof error.code === "string" ? error.code : "external-form-runner-failed";
  await writeJsonAtomic(launchReadyPath, {
    ok: false,
    status: "failed",
    workflowId: workflow.workflowId,
    pid: process.pid,
    browserPid,
    reason: code,
    observedAt: new Date().toISOString(),
    valuesRedacted: true,
  }).catch(() => undefined);
  await writeStatus(code === "workflow-stopped" ? "stopped" : "failed", {
    error: { code, messageHash: shaText(redact(error instanceof Error ? error.message : String(error))) },
    completedAt: new Date().toISOString(),
  }).catch(() => undefined);
  process.exitCode = code === "workflow-stopped" ? 0 : 2;
} finally {
  await context?.close().catch(() => undefined);
  await browser?.close().catch(() => undefined);
  await closeFixtureServer();
  await rm(path.join(stateDir, "uploads"), { recursive: true, force: true }).catch(() => undefined);
  for (const key of Object.keys(secrets)) secrets[key] = "";
}

async function executeAction(action) {
  if (action.type === "goto") {
    const target = new URL(action.url, effectiveOrigin).toString();
    assertAllowedUrl(target);
    await page.goto(target, { waitUntil: action.waitUntil || "domcontentloaded", timeout: workflow.timeoutMs });
    return;
  }
  if (action.type === "fill") {
    await page.locator(action.selector).first().fill(inputValue(action), { timeout: workflow.timeoutMs });
    return;
  }
  if (action.type === "typeText") {
    await page.locator(action.selector).first().pressSequentially(inputValue(action), { timeout: workflow.timeoutMs });
    return;
  }
  if (action.type === "select") {
    const option = action.label ? { label: action.label } : inputValue(action);
    await page.locator(action.selector).first().selectOption(option, { timeout: workflow.timeoutMs });
    return;
  }
  if (action.type === "check") {
    const locator = page.locator(action.selector).first();
    if (action.checked === false) await locator.uncheck({ timeout: workflow.timeoutMs });
    else await locator.check({ timeout: workflow.timeoutMs });
    return;
  }
  if (action.type === "upload") {
    await page.locator(action.selector).first().setInputFiles(action.path, { timeout: workflow.timeoutMs });
    return;
  }
  if (action.type === "click" || action.type === "submit") {
    const locator = page.locator(action.selector).first();
    if (action.type === "click" && await isSubmitControl(locator)) {
      throw stableError("external-form-submit-action-required", "submit controls require an explicit submit action");
    }
    await locator.click({ timeout: workflow.timeoutMs });
    return;
  }
  if (action.type === "press") {
    await page.locator(action.selector).first().press(action.key, { timeout: workflow.timeoutMs });
    return;
  }
  if (action.type === "waitFor") {
    await page.locator(action.selector).first().waitFor({ state: action.state || "visible", timeout: action.timeoutMs || workflow.timeoutMs });
    return;
  }
  if (action.type === "switchPage") {
    const previousPage = page;
    const deadline = Date.now() + (action.timeoutMs || workflow.timeoutMs);
    let nextPage = null;
    while (Date.now() < deadline) {
      const candidates = context.pages().filter((candidate) => candidate !== previousPage && !candidate.isClosed());
      if (candidates.length > 0) {
        nextPage = candidates[candidates.length - 1];
        break;
      }
      await new Promise((resolve) => setTimeout(resolve, Math.min(workflow.pollMs, Math.max(1, deadline - Date.now()))));
    }
    if (nextPage === null) throw stableError("external-form-new-page-not-found", "no new browser page appeared before timeout");
    page = nextPage;
    attachPageSignals(page);
    await page.waitForLoadState("domcontentloaded", { timeout: Math.max(1, deadline - Date.now()) }).catch(() => undefined);
    assertAllowedUrl(page.url());
    return;
  }
  if (action.type === "screenshot") {
    await captureScreenshot(action.id);
    return;
  }
  if (action.type === "inspect") {
    inspection = await inspectControls(action);
    await writeStatus("running");
    return;
  }
  if (action.type === "pause") {
    const screenshot = await captureScreenshot("pause-" + action.id);
    await writeStatus("paused", { pause: { id: action.id, reason: action.reason, screenshot } });
    const continuation = await waitForContinuation(action.id);
    if (action.selector) await page.locator(action.selector).first().fill(continuation, { timeout: workflow.timeoutMs });
    await writeStatus("running", { pause: null });
    return;
  }
  throw stableError("external-form-action-unsupported", "unsupported action type");
}

function isSubmitControl(locator) {
  return locator.evaluate((element) => {
    const tag = element.tagName.toLowerCase();
    if (tag === "input") return ["submit", "image"].includes(String(element.getAttribute("type") || "text").toLowerCase());
    if (tag === "button") return String(element.getAttribute("type") || "submit").toLowerCase() === "submit";
    return false;
  });
}

async function waitForContinuation(pauseId) {
  const file = path.join(stateDir, "continuations", pauseId + ".json");
  while (!stopping) {
    try {
      const payload = JSON.parse(await readFile(file, "utf8"));
      await rm(file, { force: true });
      if (payload && typeof payload.value === "string" && payload.value.length > 0) return payload.value;
    } catch {}
    await sleep(workflow.pollMs);
  }
  throw stableError("workflow-stopped", "workflow was stopped while paused");
}

function inputValue(action) {
  if (typeof action.valueRef === "string") {
    const value = secrets[action.valueRef];
    if (typeof value !== "string" || value.length === 0) throw stableError("external-form-input-missing", "declared input is missing");
    return value;
  }
  if (typeof action.value === "string") return action.value;
  throw stableError("external-form-input-missing", "action has no declared value");
}

async function captureScreenshot(name) {
  const file = path.join(stateDir, "artifacts", safeName(name) + ".png");
  await page.screenshot({ path: file, fullPage: false });
  const bytes = await readFile(file);
  return { path: file, bytes: bytes.byteLength, sha256: "sha256:" + createHash("sha256").update(bytes).digest("hex"), valuesRedacted: true };
}

async function inspectControls(action) {
  const selector = action.selector || "input, button, a, select, textarea";
  const controls = await page.locator(selector).evaluateAll((elements) => elements.slice(0, 40).map((element) => {
    const tag = element.tagName.toLowerCase();
    const className = element.getAttribute("class") || "";
    const visibleText = tag === "button" || tag === "a" || tag === "option" || tag === "li"
      || element.getAttribute("role") === "option" || className.includes("rcmpaginatedselect")
      ? String(element.innerText || "")
      : "";
    return {
      tag,
      id: element.getAttribute("id") || "",
      name: element.getAttribute("name") || "",
      type: element.getAttribute("type") || "",
      placeholder: element.getAttribute("placeholder") || "",
      text: visibleText,
      className,
      onclick: element.getAttribute("onclick") || "",
    };
  }));
  return {
    actionId: action.id,
    selector,
    count: controls.length,
    truncated: await page.locator(selector).count() > controls.length,
    controls: controls.map((control) => Object.fromEntries(
      Object.entries(control).map(([key, value]) => [key, redact(String(value)).replace(/\s+/g, " ").trim().slice(0, 160)])
    )),
    valuesRedacted: true,
  };
}

async function writeStatus(phase, extra = {}) {
  const finalUrl = page && !page.isClosed() ? safeUrl(page.url()) : null;
  const payload = {
    ok: phase !== "failed",
    workflowId: workflow.workflowId,
    phase,
    pid: process.pid,
    browserPid,
    startedAt,
    updatedAt: new Date().toISOString(),
    currentAction,
    progress: { completed: completed.length, total: workflow.actions.length, completedIds: completed.slice(-20) },
    finalUrl,
    network,
    browserSignals,
    inspection,
    pause: null,
    valuesRedacted: true,
    ...extra,
  };
  await writeJsonAtomic(statusPath, payload);
}

async function writeJsonAtomic(file, payload) {
  const temp = file + ".tmp";
  await writeFile(temp, JSON.stringify(payload, null, 2) + "\n", { mode: 0o600 });
  await rename(temp, file);
}

function attachPageSignals(targetPage) {
  if (observedPages.has(targetPage)) return;
  observedPages.add(targetPage);
  targetPage.on("response", (response) => {
    network.responses += 1;
    if (response.status() >= 500) network.status5xx += 1;
    else if (response.status() >= 400) network.status4xx += 1;
  });
  targetPage.on("requestfailed", () => { network.failed += 1; });
  targetPage.on("console", (entry) => { if (entry.type() === "error") browserSignals.consoleErrors += 1; });
  targetPage.on("pageerror", (error) => {
    browserSignals.pageErrors += 1;
    browserSignals.lastPageErrorHash = shaText(error instanceof Error ? error.message : String(error));
  });
}

function assertAllowedUrl(raw) {
  const url = new URL(raw);
  if (!workflow.allowedHosts.includes(url.hostname)) throw stableError("external-form-origin-denied", "navigation host is not declared");
}

function browserProxy() {
  if (workflow.browserProxyMode === "direct") return null;
  const target = new URL(effectiveOrigin);
  const noProxy = process.env.NO_PROXY || process.env.no_proxy || "";
  if (noProxy.split(",").map((item) => item.trim()).filter(Boolean).some((item) => target.hostname === item || target.hostname.endsWith("." + item.replace(/^\./, "")))) return null;
  const raw = target.protocol === "https:"
    ? process.env.HTTPS_PROXY || process.env.https_proxy || process.env.ALL_PROXY || process.env.all_proxy || ""
    : process.env.HTTP_PROXY || process.env.http_proxy || process.env.ALL_PROXY || process.env.all_proxy || "";
  return raw ? { server: raw } : null;
}

function startFixtureServer(html) {
  return new Promise((resolve, reject) => {
    const server = createServer((request, response) => {
      if (request.url === "/" || request.url === "/form") {
        response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
        response.end(html);
        return;
      }
      response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
      response.end("not found");
    });
    server.once("error", reject);
    server.listen(0, "127.0.0.1", () => resolve(server));
  });
}

function closeFixtureServer() {
  if (fixtureServer === null) return Promise.resolve();
  return new Promise((resolve) => fixtureServer.close(() => resolve()));
}

function browserProcessEnvWithoutProxy() {
  const blocked = new Set(["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy"]);
  return Object.fromEntries(Object.entries(process.env).filter(([key, value]) => !blocked.has(key) && value !== undefined));
}

async function waitForBrowserPid() {
  const deadline = Date.now() + Math.min(5000, workflow.timeoutMs);
  while (Date.now() <= deadline) {
    const rows = [];
    for (const name of readdirSync("/proc")) {
      if (!/^\d+$/.test(name)) continue;
      try {
        const raw = readFileSync("/proc/" + name + "/stat", "utf8");
        const close = raw.lastIndexOf(")");
        const fields = raw.slice(close + 2).trim().split(/\s+/);
        const command = readFileSync("/proc/" + name + "/cmdline", "utf8").replace(/\0/g, " ");
        rows.push({ pid: Number(name), ppid: Number(fields[1]), command });
      } catch {}
    }
    const descendants = new Set([process.pid]);
    let changed = true;
    while (changed) {
      changed = false;
      for (const row of rows) {
        if (!descendants.has(row.pid) && descendants.has(row.ppid)) {
          descendants.add(row.pid);
          changed = true;
        }
      }
    }
    const match = rows.find((row) => row.pid !== process.pid && descendants.has(row.pid) && /(?:^|[ /])(chrome|chromium)(?:[ /]|$)/i.test(row.command));
    if (match) return match.pid;
    await sleep(workflow.pollMs);
  }
  throw stableError("external-form-browser-pid-unconfirmed", "browser process was not confirmed");
}

function safeUrl(raw) {
  try {
    const url = new URL(raw);
    return url.origin + url.pathname;
  } catch {
    return null;
  }
}

function redact(raw) {
  let value = String(raw);
  for (const secret of secretValues) value = value.split(secret).join("<redacted>");
  return value;
}

function shaText(value) {
  return "sha256:" + createHash("sha256").update(String(value)).digest("hex");
}

function safeName(value) {
  return String(value).replace(/[^A-Za-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "artifact";
}

function stableError(code, message) {
  const error = new Error(message);
  error.code = code;
  return error;
}

function requiredEnv(name) {
  const value = process.env[name];
  if (!value) throw new Error("missing " + name);
  return value;
}

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}
`;
}
