import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "bun:test";

import { hwlabRuntimeLaneSpecForNode } from "./hwlab-node-lanes";
import { nodeWebObserveRunnerSource } from "./hwlab-node-web-observe-runner-source";
import {
  classifyQuickVerifyCleanupStep,
  createQuickVerifyObserverLifecycle,
  quickVerifyCleanupFindings,
  quickVerifyUnhandledExceptionSummary,
  readAnalysisSummaryFromWorkspace,
  sentinelChildStartMemorySkip,
  sentinelServiceProxyInvocation,
  stopQuickVerifyObserverAfterPartialStart,
} from "./hwlab-node-web-sentinel-p5-observe";
import { createWebProbeSentinelService } from "./hwlab-node-web-sentinel-service";

test("sentinel service proxy executes its script through trans standard input", () => {
  assert.deepEqual(sentinelServiceProxyInvocation("NC01:k3s", "echo sentinel-service-proxy"), {
    argv: ["trans", "NC01:k3s", "sh"],
    input: "echo sentinel-service-proxy",
  });
});

test("workspace analysis fallback maps report generatedAt to reportUpdatedAt", () => {
  const directory = mkdtempSync(join(tmpdir(), "unidesk-workspace-analysis-"));
  try {
    const stateDir = ".state/web-observe/webobs-fixture-20260712T000000Z";
    mkdirSync(join(directory, stateDir, "analysis"), { recursive: true });
    writeFileSync(join(directory, stateDir, "analysis", "report.json"), JSON.stringify({ generatedAt: "2026-07-12T00:00:00.000Z", findings: [] }));
    const summary = readAnalysisSummaryFromWorkspace({} as never, stateDir, 5, (script) => {
      const result = spawnSync("sh", { cwd: directory, input: script, encoding: "utf8" });
      return {
        command: ["sh"],
        cwd: directory,
        exitCode: result.status,
        stdout: result.stdout ?? "",
        stderr: result.stderr ?? "",
        signal: result.signal,
        timedOut: false,
      };
    });
    assert.equal(summary.ok, true, JSON.stringify(summary));
    assert.equal(summary.reportUpdatedAt, "2026-07-12T00:00:00.000Z", JSON.stringify(summary));
  } finally {
    rmSync(directory, { recursive: true, force: true });
  }
});

test("recorded sentinel run is available through the latest report index", () => {
  const stateRoot = mkdtempSync(join(tmpdir(), "unidesk-sentinel-index-"));
  const service = createWebProbeSentinelService({
    spec: hwlabRuntimeLaneSpecForNode("v03", "NC01"),
    sentinelId: "nc01-web-probe-sentinel",
    stateRootOverride: stateRoot,
    schedulerEnabled: false,
  });
  try {
    const scenarioId = String(service.config.scenarios[0]?.id);
    const recorded = service.recordRun({
      runId: "sentinel-run-index-test",
      scenarioId,
      status: "succeeded",
      reportJsonSha256: "sha256:index-test",
      summary: { source: "test", valuesRedacted: true },
      views: { summary: { renderedText: "indexed summary" } },
      findingCount: 0,
      artifactCount: 0,
      updatedAt: "2026-07-13T00:00:00.000Z",
      valuesRedacted: true,
    });
    const latest = service.report("summary", null);
    assert.equal(recorded.ok, true);
    assert.equal(latest.ok, true);
    assert.equal((latest.run as Record<string, unknown>).id, "sentinel-run-index-test");
  } finally {
    service.close();
    rmSync(stateRoot, { recursive: true, force: true });
  }
});

test("latest report selects the newest stored run without a report SHA", () => {
  const stateRoot = mkdtempSync(join(tmpdir(), "unidesk-sentinel-index-"));
  const service = createWebProbeSentinelService({
    spec: hwlabRuntimeLaneSpecForNode("v03", "NC01"),
    sentinelId: "nc01-web-probe-sentinel",
    stateRootOverride: stateRoot,
    schedulerEnabled: false,
  });
  try {
    const scenarioId = String(service.config.scenarios[0]?.id);
    service.recordRun({
      runId: "sentinel-run-old-artifact",
      scenarioId,
      status: "succeeded",
      reportJsonSha256: "sha256:old-artifact",
      summary: { source: "test", valuesRedacted: true },
      views: { summary: { renderedText: "old indexed summary" } },
      findingCount: 0,
      artifactCount: 0,
      updatedAt: "2026-07-13T00:00:00.000Z",
      valuesRedacted: true,
    });
    service.recordRun({
      runId: "sentinel-run-new-stored-finding",
      scenarioId,
      status: "blocked",
      summary: { source: "test", valuesRedacted: true },
      views: { summary: { renderedText: "new stored summary" } },
      findings: [{ id: "quick-verify-observer-start-failed", severity: "error", summary: "observer start failed" }],
      findingCount: 1,
      artifactCount: 0,
      updatedAt: "2026-07-13T00:00:01.000Z",
      valuesRedacted: true,
    });

    const latest = service.report("summary", null);
    assert.equal(latest.ok, true);
    assert.equal((latest.run as Record<string, unknown>).id, "sentinel-run-new-stored-finding");
    assert.equal((latest.run as Record<string, unknown>).reportJsonSha256, null);
    assert.match(String(latest.renderedText), /run=sentinel-run-new-stored-finding/u);
    assert.match(String(latest.renderedText), /页面观察未启动/u);
  } finally {
    service.close();
    rmSync(stateRoot, { recursive: true, force: true });
  }
});
function forceStopStep(observer: Record<string, unknown>): Record<string, unknown> {
  return {
    phase: "observe-stop-after-terminal",
    ok: false,
    payload: {
      ok: false,
      status: "blocked",
      forceReason: "graceful-stop-not-consumed",
      observer: {
        command: "web-probe-observe force-stop",
        ...observer,
      },
      valuesRedacted: true,
    },
    valuesRedacted: true,
  };
}

test("quick verify cleanup downgrades abandoned graceful stop when force stop confirms not alive", () => {
  const step = forceStopStep({
    aliveBefore: true,
    aliveAfter: false,
    termSent: true,
    killSent: true,
    pendingAbandoned: 1,
    processingAbandoned: 0,
  });

  const cleanup = classifyQuickVerifyCleanupStep(step);
  assert.equal(cleanup.cleanupClass, "bounded-force-stop");
  assert.equal(cleanup.gracefulStopOk, false);
  assert.equal(cleanup.forceStopOk, true);
  assert.equal(cleanup.aliveAfter, false);
  assert.equal(cleanup.pendingAbandoned, 1);
  const findings = quickVerifyCleanupFindings(step);
  assert.equal(findings.length, 1);
  assert.equal(findings[0]?.id, "observer-graceful-stop-timeout-force-stop-succeeded");
  assert.equal(findings[0]?.severity, "warning");
});

test("quick verify cleanup reads force-stop evidence from top-level CLI payload", () => {
  const step = {
    phase: "observe-stop-after-terminal",
    ok: false,
    payload: {
      ok: true,
      command: "web-probe-observe force-stop",
      forced: true,
      reason: "graceful-stop-not-consumed",
      aliveBefore: true,
      aliveAfter: false,
      termSent: true,
      killSent: true,
      pendingAbandoned: 1,
      processingAbandoned: 0,
      valuesRedacted: true,
    },
    valuesRedacted: true,
  };

  const cleanup = classifyQuickVerifyCleanupStep(step);
  const findings = quickVerifyCleanupFindings(step);
  assert.equal(cleanup.cleanupClass, "bounded-force-stop");
  assert.equal(cleanup.forceStopOk, true);
  assert.equal(cleanup.aliveAfter, false);
  assert.deepEqual(cleanup.evidenceSources, ["payload"]);
  assert.equal(findings[0]?.id, "observer-graceful-stop-timeout-force-stop-succeeded");
});

test("quick verify cleanup emits process-alive code when force stop leaves observer alive", () => {
  const step = forceStopStep({
    aliveBefore: true,
    aliveAfter: true,
    termSent: true,
    killSent: false,
    pendingAbandoned: 0,
    processingAbandoned: 1,
  });

  const cleanup = classifyQuickVerifyCleanupStep(step);
  const findings = quickVerifyCleanupFindings(step);
  assert.equal(cleanup.cleanupClass, "observer-stop-failed");
  assert.equal(cleanup.forceStopOk, false);
  assert.equal(cleanup.aliveAfter, true);
  assert.equal(findings.length, 1);
  assert.equal(findings[0]?.id, "observer-force-stop-left-process-alive");
  assert.match(String(findings[0]?.evidenceSummary), /aliveAfter=true/u);
});

test("quick verify cleanup emits evidence-gap code when stopped state cannot be confirmed", () => {
  const step = forceStopStep({
    aliveBefore: true,
    termSent: true,
    killSent: true,
    pendingAbandoned: 1,
    processingAbandoned: 0,
  });

  const cleanup = classifyQuickVerifyCleanupStep(step);
  const findings = quickVerifyCleanupFindings(step);
  assert.equal(cleanup.cleanupClass, "observer-stop-failed");
  assert.equal(cleanup.aliveAfter, null);
  assert.deepEqual(cleanup.missingEvidenceFields, ["aliveAfter"]);
  assert.equal(findings.length, 1);
  assert.equal(findings[0]?.id, "observer-cleanup-evidence-missing");
  assert.match(String(findings[0]?.evidenceSummary), /missing=aliveAfter/u);
});

test("quick verify observer lifecycle stops once on success, failure, and exception fallback", () => {
  const cases = [
    { name: "success", initialPhase: "observe-stop-after-terminal", expectedPhase: "observe-stop-after-terminal" },
    { name: "failure", initialPhase: "observe-stop-after-failure", expectedPhase: "observe-stop-after-failure" },
    { name: "exception-fallback", initialPhase: null, expectedPhase: "observe-stop-after-finally" },
  ] as const;

  for (const scenario of cases) {
    const calls: { observerId: string; phase: string }[] = [];
    const lifecycle = createQuickVerifyObserverLifecycle("webobs-fixture", (observerId, phase) => {
      calls.push({ observerId, phase });
      return { phase, ok: true, observerId };
    });
    if (scenario.initialPhase !== null) lifecycle.stop(scenario.initialPhase);
    const cleanupStep = lifecycle.stop("observe-stop-after-finally");

    assert.deepEqual(calls, [{ observerId: "webobs-fixture", phase: scenario.expectedPhase }], scenario.name);
    assert.equal(cleanupStep.phase, scenario.expectedPhase, scenario.name);
    assert.strictEqual(cleanupStep, lifecycle.cleanupStep, scenario.name);
  }
});

test("quick verify observer lifecycle retries a failed stop again from the bounded finally fallback", () => {
  let calls = 0;
  const lifecycle = createQuickVerifyObserverLifecycle("webobs-stop-throws", () => {
    calls += 1;
    throw new Error("sensitive-stop-error");
  });

  const first = lifecycle.stop("observe-stop-after-failure");
  const fallback = lifecycle.stop("observe-stop-after-finally");

  assert.equal(calls, 4);
  assert.notStrictEqual(first, fallback);
  assert.equal(first.failure, "observer-stop-unhandled-exception");
  assert.equal(Array.isArray(fallback.stopAttempts), true);
  assert.equal((fallback.stopAttempts as unknown[]).length, 4);
  assert.equal(JSON.stringify(first).includes("sensitive-stop-error"), false);
});

test("quick verify observer lifecycle retries a failed force-stop and caches the successful fallback", () => {
  let calls = 0;
  const lifecycle = createQuickVerifyObserverLifecycle("webobs-stop-retry", (_observerId, phase) => {
    calls += 1;
    return { phase, ok: calls === 2, observerId: "webobs-stop-retry" };
  });
  const first = lifecycle.stop("observe-stop-after-failure");
  const cached = lifecycle.stop("observe-stop-after-finally");
  assert.equal(calls, 2);
  assert.equal(first.ok, true);
  assert.strictEqual(first, cached);
  assert.equal(Array.isArray(first.stopAttempts), true);
});

test("quick verify cleans a partial start only when start output contains an observer id", () => {
  const calls: { observerId: string; phase: string }[] = [];
  const stopObserver = (observerId: string, phase: string) => {
    calls.push({ observerId, phase });
    return { phase, ok: true, observerId };
  };

  assert.equal(stopQuickVerifyObserverAfterPartialStart(null, stopObserver), null);
  const cleanupStep = stopQuickVerifyObserverAfterPartialStart("webobs-partial", stopObserver);

  assert.deepEqual(calls, [{ observerId: "webobs-partial", phase: "observe-stop-after-start-failure" }]);
  assert.equal(cleanupStep?.ok, true);
});

test("quick verify unexpected exception evidence is bounded and redacted", () => {
  const secret = "sensitive-upstream-message";
  const summary = quickVerifyUnhandledExceptionSummary(new Error(secret));
  const serialized = JSON.stringify(summary);

  assert.equal(summary.errorName, "Error");
  assert.match(String(summary.errorMessageSha256), /^[a-f0-9]{64}$/u);
  assert.equal(summary.errorMessageTruncated, false);
  assert.equal(serialized.includes(secret), false);
});

test("sentinel preserves a child launch-time memory race as skipped instead of observe-start-failed", () => {
  const skip = sentinelChildStartMemorySkip({
    ok: true,
    command: "web-probe observe start",
    data: {
      ok: true,
      status: "skipped-wait-next-round",
      observerCreated: false,
      memoryGuard: {
        mode: "sentinel-cadence",
        status: "skipped",
        memory: {
          metric: "MemAvailable",
          source: "/proc/meminfo",
          swapExcluded: true,
          availableBytes: 4 * 1024 ** 3 - 1,
          thresholdBytes: 4 * 1024 ** 3,
          comparator: "less-than",
        },
      },
    },
  });

  assert.equal(skip?.status, "skipped-wait-next-round");
  assert.equal(skip?.decision, "skip-observer");
  assert.equal(skip?.observerCreated, false);
  assert.equal((skip?.memoryGuard as Record<string, unknown>).mode, "sentinel-cadence");
  assert.equal(sentinelChildStartMemorySkip({ data: { status: "blocked", observerCreated: false } }), null);
});

test("web observe runner records finite lifecycle policy and closes the browser", () => {
  const source = nodeWebObserveRunnerSource();

  assert.match(source, /sampling:\s*\{[^}]*maxSamples,[^}]*maxRunMs,[^}]*maxRunSeconds:/u);
  assert.match(source, /observerLifecycle:\s*\{[^}]*maxSamples,[^}]*maxRunMs,[^}]*maxRunSeconds:/u);
  assert.match(source, /if \(maxRunMs > 0 && Date\.now\(\) - startedAtMs >= maxRunMs\)/u);
  assert.match(source, /const maxScreenshots = requiredBoundedInteger\(process\.env\.UNIDESK_WEB_OBSERVE_MAX_SCREENSHOTS/u);
  assert.match(source, /const maxPerformanceEntries = requiredBoundedInteger\(process\.env\.UNIDESK_WEB_OBSERVE_MAX_PERFORMANCE_ENTRIES/u);
  assert.match(source, /const maxRealtimeEventBodyBytes = requiredBoundedInteger\(process\.env\.UNIDESK_WEB_OBSERVE_MAX_REALTIME_EVENT_BODY_BYTES/u);
  assert.match(source, /while \(browserProcessHistory\.length > maxBrowserProcessHistoryEntries\)/u);
  assert.match(source, /while \(browserFreezeSignalHistory\.length > maxBrowserFreezeSignalHistoryEntries\)/u);
  assert.match(source, /if \(screenshotCount >= maxScreenshots\)/u);
  assert.match(source, /const maxBytes = responseBodyMaxBytes/u);
  assert.match(source, /function responseBodyReadContract\(response, maxBytes = responseBodyMaxBytes\)/u);
  assert.match(source, /contentEncoding[\s\S]*?reason: "encoded-body-size-unbounded"/u);
  assert.match(source, /!Number\.isFinite\(contentLength\)[\s\S]*?reason: "content-length-unavailable"/u);
  assert.match(source, /bodyReadSkipped: body\.error\?\.reason \|\| "bounded-response-read-failed"/u);
  assert.match(source, /\.slice\(0, maxProcessScanEntries\)/u);
  assert.match(source, /if \(items\.length > maxEntries\)[\s\S]*?items\.splice/u);
  assert.match(source, /process\.once\(signal,[\s\S]*?closeBrowserResources\("signal:" \+ signal\)/u);
  assert.match(source, /finally \{[\s\S]*?await closeBrowserResources/u);
  assert.match(source, /closingObserverPage\.close\(\)[\s\S]*?closingPage\.close\(\)[\s\S]*?closingContext\.close\(\)[\s\S]*?closingBrowser\.close\(\)/u);

  const directory = mkdtempSync(join(tmpdir(), "web-observe-runner-check-"));
  try {
    const runner = join(directory, "observer-runner.mjs");
    writeFileSync(runner, source);
    const syntax = spawnSync("node", ["--check", runner], { encoding: "utf8" });
    assert.equal(syntax.status, 0, syntax.stderr);
  } finally {
    rmSync(directory, { recursive: true, force: true });
  }
});
