import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "bun:test";

import type { CommandResult } from "./command";
import { rootPath } from "./config";
import { hwlabRuntimeLaneSpecForNode } from "./hwlab-node-lanes";
import { evaluateWebProbeMemoryGuard, runWebProbeMemoryGuard, webProbeBrowserEvidenceEnvAssignments, webProbeGuardedLaunchShell, webProbeMemoryStartGuardConfigRef, webProbeScreenshotFullPage } from "./hwlab-node-web-probe-memory-guard";
import { nodeWebProbeScriptRunnerSource } from "./hwlab-node-web-probe-runner-source";

const spec = hwlabRuntimeLaneSpecForNode("v03", "NC01");
const memoryStartGuard = spec.webProbe?.resourcePolicy?.memoryStartGuard;
assert.ok(memoryStartGuard, "owning YAML must declare the WebProbe memory start guard");
const thresholdBytes = memoryStartGuard.thresholdBytes;

function memoryPayload(availableBytes: number, extra: Record<string, unknown> = {}): Record<string, unknown> {
  return {
    ok: true,
    action: "gc remote memory",
    providerId: "NC01",
    metric: "MemAvailable",
    source: "/proc/meminfo",
    swapExcluded: true,
    memory: { availableBytes, availableHuman: "fixture", ...extra },
    valuesRedacted: true,
  };
}

function exportedFunctionSource(source: string, name: string): string {
  const start = source.indexOf(`export function ${name}(`);
  assert.ok(start >= 0, `missing exported function ${name}`);
  const nextExport = source.indexOf("\nexport function ", start + 1);
  const nextFunction = source.indexOf("\nfunction ", start + 1);
  const next = [nextExport, nextFunction].filter((value) => value >= 0).sort((left, right) => left - right)[0] ?? -1;
  return source.slice(start, next < 0 ? source.length : next);
}

test("WebProbe memory threshold and comparison semantics are read from the single owning YAML policy", () => {
  const policy = spec.webProbe?.resourcePolicy?.memoryStartGuard;
  assert.ok(policy);
  assert.ok(policy.thresholdBytes > 0);
  assert.equal(policy?.metric, "MemAvailable");
  assert.equal(policy?.source, "/proc/meminfo");
  assert.equal(policy?.swapExcluded, true);
  assert.equal(policy?.manualComparator, "less-than-or-equal");
  assert.equal(policy?.sentinelComparator, "less-than");
  assert.match(webProbeMemoryStartGuardConfigRef, /resourcePolicy\.memoryStartGuard$/u);
});

test("browser evidence limits and viewport screenshots are read from owning YAML", () => {
  const policy = spec.webProbe?.resourcePolicy?.browserEvidence;
  assert.deepEqual(policy, {
    screenshotMode: "viewport",
    maxNetworkRequestEntries: 200,
    maxNetworkRequestBytes: 262144,
    maxNetworkFailureEntries: 100,
    maxNetworkFailureBytes: 131072,
  });
  const assignments = webProbeBrowserEvidenceEnvAssignments(spec).join(" ");
  assert.match(assignments, /UNIDESK_WEB_PROBE_SCREENSHOT_MODE='viewport'/u);
  assert.match(assignments, /UNIDESK_WEB_PROBE_MAX_NETWORK_REQUEST_ENTRIES='200'/u);
  assert.match(assignments, /UNIDESK_WEB_PROBE_MAX_NETWORK_FAILURE_BYTES='131072'/u);
});

test("manual start is blocked at the configured threshold and requires controlled GC then MemAvailable recheck", () => {
  const guard = evaluateWebProbeMemoryGuard(spec, "manual-start", memoryPayload(thresholdBytes));
  const memory = guard.memory as Record<string, unknown>;
  const gcNext = guard.gcNext as Record<string, unknown>;

  assert.equal(guard.status, "blocked");
  assert.equal(guard.startBlocked, true);
  assert.equal(guard.decision, "gc-required");
  assert.equal(memory.metric, "MemAvailable");
  assert.equal(memory.source, "/proc/meminfo");
  assert.equal(memory.swapExcluded, true);
  assert.equal(memory.comparator, "less-than-or-equal");
  assert.equal(memory.thresholdMatched, true);
  assert.equal(gcNext.requiredBeforeStart, true);
  assert.match(String(gcNext.planCommand), /gc remote NC01 plan --memory-pressure-only$/u);
  assert.match(String(gcNext.runCommand), /gc remote NC01 run --confirm --memory-pressure-only$/u);
  assert.match(String(gcNext.statusCommandTemplate), /gc remote NC01 status --job-id <job-id>$/u);
  assert.match(String(gcNext.recheckCommand), /gc remote NC01 memory$/u);
  assert.equal(gcNext.recheckMetric, "MemAvailable");
  assert.deepEqual(gcNext.requiredOrder, ["plan", "run", "status-until-terminal", "memory-recheck"]);
  assert.deepEqual(gcNext.startRemainsBlockedUntil, ["gc-job-terminal", "MemAvailable-threshold-cleared"]);
});

test("sentinel allows the configured threshold but skips one byte below without considering swap", () => {
  const exact = evaluateWebProbeMemoryGuard(spec, "sentinel-cadence", memoryPayload(thresholdBytes, { swapFreeBytes: 12 * 1024 ** 3 }));
  const below = evaluateWebProbeMemoryGuard(spec, "sentinel-cadence", memoryPayload(thresholdBytes - 1, { swapFreeBytes: 12 * 1024 ** 3 }));
  const belowMemory = below.memory as Record<string, unknown>;

  assert.equal(exact.status, "allowed");
  assert.equal(exact.startBlocked, false);
  assert.equal((exact.memory as Record<string, unknown>).comparator, "less-than");
  assert.equal(below.status, "skipped");
  assert.equal(below.startBlocked, true);
  assert.equal(below.decision, "skip-observer");
  assert.equal(belowMemory.availableBytes, thresholdBytes - 1);
  assert.equal(belowMemory.swapExcluded, true);
});

test("memory guard rejects a snapshot that is not the /proc/meminfo MemAvailable contract", () => {
  const guard = evaluateWebProbeMemoryGuard(spec, "manual-start", {
    ...memoryPayload(thresholdBytes + 1),
    source: "cgroup-v2-memory.current",
  });
  assert.equal(guard.status, "blocked");
  assert.equal(guard.reason, "memory-snapshot-contract-invalid");
  assert.equal(guard.startBlocked, true);
});

test("memory guard executes only the controlled read-only remote memory entry", () => {
  let observedCommand: string[] = [];
  let observedTimeoutMs = 0;
  const execute = (command: string[], cwd: string, options: { timeoutMs?: number }): CommandResult => {
    observedCommand = command;
    observedTimeoutMs = options.timeoutMs ?? 0;
    return {
      command,
      cwd,
      exitCode: 0,
      stdout: JSON.stringify({ ok: true, command: "gc remote NC01 memory", data: memoryPayload(thresholdBytes + 1) }),
      stderr: "",
      signal: null,
      timedOut: false,
    };
  };
  const guard = runWebProbeMemoryGuard(spec, "manual-start", execute);

  assert.deepEqual(observedCommand, ["bun", "scripts/cli.ts", "gc", "remote", "NC01", "memory"]);
  assert.equal(observedTimeoutMs, 30_000);
  assert.equal(guard.status, "allowed");
});

test("remote memory collector reads MemAvailable directly and excludes swap", () => {
  const source = readFileSync(rootPath("scripts/src/gc-remote-runner.py"), "utf8");
  const start = source.indexOf("def collect_memory_snapshot():");
  const end = source.indexOf("\ndef observe_run_record", start);
  const collector = source.slice(start, end);

  assert.match(collector, /source = "\/proc\/meminfo"/u);
  assert.match(collector, /fields\.get\("MemAvailable"\)/u);
  assert.match(collector, /"swapExcluded": True/u);
  assert.doesNotMatch(collector, /SwapFree|SwapTotal|\["free", "-b"\]/u);
});

test("manual and sentinel gates return before any observer launch path", () => {
  const manualSource = readFileSync(rootPath("scripts/src/hwlab-node/web-probe-observe-actions.ts"), "utf8");
  const manualStart = manualSource.indexOf("export function runNodeWebProbeObserveStart");
  const manualGuard = manualSource.indexOf("runWebProbeMemoryGuard(spec, options.memoryGuardMode)", manualStart);
  const manualReturn = manualSource.indexOf("startBlocked: true", manualGuard);
  const manualJob = manualSource.indexOf("const jobId =", manualStart);
  const manualLaunch = manualSource.indexOf("setsid env", manualStart);
  assert.ok(manualStart >= 0 && manualGuard > manualStart && manualReturn > manualGuard);
  assert.ok(manualReturn < manualJob && manualJob < manualLaunch);

  const sentinelSource = readFileSync(rootPath("scripts/src/hwlab-node-web-sentinel-p5-observe.ts"), "utf8");
  const quickVerify = sentinelSource.indexOf("export function runSentinelQuickVerify");
  const sentinelGuard = sentinelSource.indexOf("runWebProbeMemoryGuard(state.spec, \"sentinel-cadence\")", quickVerify);
  const sentinelSkip = sentinelSource.indexOf("return recordSkippedQuickVerify", sentinelGuard);
  const providerPreflight = sentinelSource.indexOf("runSentinelExactProviderProfilePreflight", quickVerify);
  const observeStart = sentinelSource.indexOf('"web-probe", "observe", "start"', quickVerify);
  assert.ok(quickVerify >= 0 && sentinelGuard > quickVerify && sentinelSkip > sentinelGuard);
  assert.ok(sentinelSkip < providerPreflight && providerPreflight < observeStart);
  assert.match(sentinelSource.slice(observeStart, observeStart + 800), /--sentinel-cadence/u);

  const serviceSource = readFileSync(rootPath("scripts/src/hwlab-node-web-sentinel-service.ts"), "utf8");
  const plannedStart = serviceSource.indexOf('"bun", "scripts/cli.ts", "web-probe", "observe", "start"');
  assert.match(serviceSource.slice(plannedStart, plannedStart + 800), /--sentinel-cadence/u);
});

test("all browser-launch entries use the shared preflight and final launch guard", () => {
  const probeSource = readFileSync(rootPath("scripts/src/hwlab-node/web-probe-observe.ts"), "utf8");
  const scriptSource = readFileSync(rootPath("scripts/src/hwlab-node/web-observe-scripts.ts"), "utf8");
  const sentinelSource = readFileSync(rootPath("scripts/src/hwlab-node-web-sentinel-p5.ts"), "utf8");
  const parserSource = readFileSync(rootPath("scripts/src/hwlab-node/web-probe.ts"), "utf8");

  const run = exportedFunctionSource(probeSource, "runNodeWebProbe");
  const screenshot = exportedFunctionSource(probeSource, "runNodeWebProbeScreenshot");
  const asyncRun = exportedFunctionSource(probeSource, "runNodeWebProbeAsync");
  const script = exportedFunctionSource(scriptSource, "runNodeWebProbeScript");
  const scriptShell = exportedFunctionSource(scriptSource, "nodeWebProbeScriptRemoteShell");
  const dashboard = exportedFunctionSource(sentinelSource, "probeSentinelDashboardBrowser");
  const parser = exportedFunctionSource(parserSource, "parseNodeWebProbeOptions");

  assert.doesNotMatch(probeSource, /runNodeWebProbeMemoryGuard/u);
  assert.match(run, /if \(options\.action === "script"\) return runNodeWebProbeScript/u);
  assert.match(run, /if \(options\.commandTimeoutSeconds > 55\) return runNodeWebProbeAsync/u);
  assert.match(run, /runWebProbeMemoryGuard\(spec, "manual-start"\)[\s\S]+webProbeGuardedLaunchShell\(spec, "manual-start"/u);
  assert.match(screenshot, /runWebProbeMemoryGuard\(spec, "manual-start"\)[\s\S]+webProbeGuardedLaunchShell\(spec, "manual-start", webProbeScreenshotRemoteScript/u);
  assert.match(asyncRun, /runWebProbeMemoryGuard\(spec, "manual-start"\)[\s\S]+webProbeGuardedLaunchShell\(spec, "manual-start"[\s\S]+"structured-output"\)/u);
  assert.match(script, /runWebProbeMemoryGuard\(spec, "manual-start"\)[\s\S]+nodeWebProbeScriptRemoteShell/u);
  assert.match(scriptShell, /webProbeGuardedLaunchShell\(spec, "manual-start"/u);
  assert.match(parser, /if \(actionRaw === "opencode-smoke"\)[\s\S]+action: "script"/u);
  assert.match(dashboard, /runWebProbeMemoryGuard\(state\.spec, "sentinel-cadence"\)[\s\S]+webProbeGuardedLaunchShell\(state\.spec, "sentinel-cadence"/u);
  assert.match(dashboard, /UNIDESK_SENTINEL_DASHBOARD_TRIGGER=.*options\.action === "trigger"/u);
  assert.match(dashboard, /UNIDESK_SENTINEL_DASHBOARD_CAPTURE=.*options\.action === "screenshot"/u);
});

test("read-only and control-plane entries stay outside browser launch guard", () => {
  const dispatchSource = readFileSync(rootPath("scripts/src/hwlab-node/web-probe-observe.ts"), "utf8");
  const dispatchStart = dispatchSource.indexOf("export function runNodeWebProbe(");
  const dispatchEnd = dispatchSource.indexOf("export function runNodeWebProbeScreenshot", dispatchStart);
  const dispatch = dispatchSource.slice(dispatchStart, dispatchEnd);
  const readOnlyReturn = dispatch.indexOf('options.action === "observe" && options.observeAction !== "start"');
  const firstGuard = dispatch.indexOf("runWebProbeMemoryGuard");
  assert.ok(readOnlyReturn >= 0 && firstGuard > readOnlyReturn);
  assert.match(dispatch.slice(0, readOnlyReturn), /options\.action === "sentinel"/u);
  assert.match(dispatch.slice(0, readOnlyReturn), /options\.action === "screenshot"/u);

  const sentinelDispatch = readFileSync(rootPath("scripts/src/hwlab-node-web-sentinel-cicd.ts"), "utf8");
  const sentinel = exportedFunctionSource(sentinelDispatch, "runWebProbeSentinelCommand");
  assert.doesNotMatch(sentinel, /runWebProbeMemoryGuard|webProbeGuardedLaunchShell/u);
  assert.match(sentinel, /options\.kind === "dashboard"/u);
});

test("generated runner bounds network evidence and defaults screenshots to viewport", () => {
  const source = nodeWebProbeScriptRunnerSource();
  assert.match(source, /maxNetworkRequestEntries/u);
  assert.match(source, /maxNetworkRequestBytes/u);
  assert.match(source, /maxNetworkFailureEntries/u);
  assert.match(source, /maxNetworkFailureBytes/u);
  assert.match(source, /apiRequestEvidenceDropped/u);
  assert.match(source, /apiFailureEvidenceDropped/u);
  assert.doesNotMatch(source, /fullPage: true/u);
  assert.equal((source.match(/fullPage: screenshotMode !== "viewport"/gu) ?? []).length, 2);

  const parserSource = exportedFunctionSource(readFileSync(rootPath("scripts/src/hwlab-node/web-probe.ts"), "utf8"), "parseNodeWebProbeOptions");
  assert.match(parserSource, /fullPage: args\.includes\("--full-page"\) && !args\.includes\("--no-full-page"\)/u);
  const screenshotSource = readFileSync(rootPath("scripts/src/hwlab-node/web-probe-observe.ts"), "utf8");
  assert.match(screenshotSource, /webProbeScreenshotFullPage\(spec, options\.fullPage\)/u);
  assert.match(screenshotSource, /UNIDESK_WEB_PROBE_SCREENSHOT_FULL_PAGE === "1"/u);
  assert.doesNotMatch(screenshotSource, /UNIDESK_WEB_PROBE_SCREENSHOT_FULL_PAGE !== "0"/u);
  const sentinelSource = readFileSync(rootPath("scripts/src/hwlab-node-web-sentinel-p5.ts"), "utf8");
  assert.match(sentinelSource, /webProbeScreenshotFullPage\(state\.spec, options\.fullPage\)/u);
  assert.match(sentinelSource, /UNIDESK_SENTINEL_DASHBOARD_FULL_PAGE === "1"/u);
  assert.doesNotMatch(sentinelSource, /UNIDESK_SENTINEL_DASHBOARD_FULL_PAGE !== "0"/u);
  assert.equal(webProbeScreenshotFullPage(spec, false), false);
  assert.equal(webProbeScreenshotFullPage({ ...spec, webProbe: { ...spec.webProbe!, resourcePolicy: { ...spec.webProbe!.resourcePolicy!, browserEvidence: { ...spec.webProbe!.resourcePolicy!.browserEvidence!, screenshotMode: "full-page" } } } }, false), true);
  assert.equal(webProbeScreenshotFullPage(spec, true), true);
});

test("final launch guard rechecks MemAvailable under the configured lock", () => {
  const sync = webProbeGuardedLaunchShell(spec, "manual-start", "node runner.mjs");
  const async = webProbeGuardedLaunchShell(spec, "manual-start", "node helper.mjs start", "structured-output");
  assert.match(sync, /flock -w '15'/u);
  assert.match(sync, /MemAvailable:/u);
  assert.match(sync, new RegExp(`mem_available_bytes.*-le '${thresholdBytes}'`, "u"));
  assert.match(sync, /setsid sh -c/u);
  assert.match(sync, /exec 9>&-/u);
  assert.equal(sync.includes("playwright"), false);
  assert.equal(sync.includes("fs.readlinkSync('/proc/' + name + '/exe')"), true);
  assert.equal(sync.includes("trap 'browser_exit=$?; trap - EXIT TERM INT HUP; cleanup_browser_group"), true);
  assert.equal(sync.includes('kill -TERM -- "-$browser_job_pid"'), true);
  assert.equal(sync.includes('kill -0 -- "-$browser_job_pid"'), true);
  assert.equal(sync.includes('kill -KILL -- "-$browser_job_pid"'), true);
  assert.equal(async.includes("browserCreated === true"), false);
  assert.equal(async.includes("data.workerPid, data.jobPid, data.pid"), true);
  assert.equal(async.includes("[data.browserPid, data.workerPid"), false);
  assert.equal(async.includes("reportedBrowserPid === null || row.pid === reportedBrowserPid"), true);
  assert.equal(async.includes("then flock -u 9; exit 43; fi"), true);
  assert.equal(async.includes("fs.readlinkSync('/proc/' + name + '/exe')"), true);
});
