// SPEC: PJ2026-01040111 长程观测 draft-2026-06-20-p0-passive-web-probe-observer.
// Responsibility: web-probe observe collect action and child JSON recovery.
import { createHash } from "node:crypto";
import type { CommandResult } from "../command";
import { resolveCliChildJsonCommandResult } from "../cli-child-json-recovery";
import { nodeWebObserveCollectViewNodeScript } from "../hwlab-node-web-observe-collect";
import { withWebObserveCollectRendered } from "../hwlab-node-web-observe-render";
import { buildWebObserveWrapperForObserveOptions } from "../hwlab-node-web-observe-wrapper";
import type { HwlabRuntimeLaneSpec } from "../hwlab-node-lanes";
import type { RenderedCliResult } from "../output";
import type { NodeWebProbeObserveOptions } from "./entry";
import { runTransWorkspaceStdinScript } from "./public-exposure";
import { compactCommandResult, compactCommandResultWithStdoutTail } from "./utils";
import { webObserveCommandLabel, webObserveIdFromOptions, nodeWebObserveResolveStateDirShell } from "./web-observe-render";
import { nodeWebObserveCollectNodeScript } from "./web-observe-scripts";
import { compactObserveCollectForRaw } from "./web-observe-collect-compact";

export function runNodeWebProbeObserveCollect(options: NodeWebProbeObserveOptions, spec: HwlabRuntimeLaneSpec): Record<string, unknown> | RenderedCliResult {
  const collectScript = options.collectView === "files"
    ? nodeWebObserveCollectNodeScript(options.maxFiles, options.collectFile, options.collectFinding, options.collectGrep)
    : nodeWebObserveCollectViewNodeScript({
      maxFiles: options.maxFiles,
      view: options.collectView,
      traceId: options.collectTraceId,
      sampleSeq: options.collectSampleSeq,
      timestamp: options.collectTimestamp,
      turn: options.collectTurn,
      commandId: options.collectCommandId,
      windowMs: options.collectWindowMs,
    });
  const script = [
    "set -eu",
    nodeWebObserveResolveStateDirShell(options),
    collectScript,
  ].join("\n");
  const result = runTransWorkspaceStdinScript(options.node, spec.workspace, script, options.commandTimeoutSeconds);
  const payload = buildNodeWebProbeObserveCollectPayload(options, spec, result);
  return options.raw ? payload : withWebObserveCollectRendered(payload);
}

export function buildNodeWebProbeObserveCollectPayload(
  options: NodeWebProbeObserveOptions,
  spec: Pick<HwlabRuntimeLaneSpec, "workspace">,
  result: CommandResult,
): Record<string, unknown> {
  const stdoutResolution = resolveCliChildJsonCommandResult({
    result,
    requestedStdoutType: "web-probe-observe-collect",
    acceptParsed: (value) => isWebObserveCollectJsonContract(value, options.collectFile),
  });
  const collect = normalizeWebObserveCollectResult(stdoutResolution.parsed, options.collectFile, result.stdout, options.stateDir);
  const compactRaw = options.raw && options.compactRaw;
  const degradedReason = collect === null
    ? collectDegradedReason(stdoutResolution.diagnostics)
    : null;
  const collectStatus = collect === null ? null : stringOrNull(collect.status);
  return {
    ok: result.exitCode === 0 && collect !== null && collect.ok !== false,
    status: result.exitCode === 0 && collect !== null
      ? collect.ok === false
        ? collectStatus ?? "blocked"
        : collectStatus ?? "collected"
      : "blocked",
    command: webObserveCommandLabel("collect", options),
    id: webObserveIdFromOptions(options),
    node: options.node,
    lane: options.lane,
    workspace: spec.workspace,
    view: options.collectView,
    requestedFile: options.collectFile,
    requestedGrep: options.collectGrep,
    requestedCommandId: options.collectCommandId,
    requestedWindowMs: options.collectWindowMs,
    degradedReason,
    stdoutRecovery: stdoutResolution.diagnostics,
    collect: compactRaw ? compactObserveCollectForRaw(collect) : collect,
    wrapper: compactRaw
      ? { mode: "wrapper-only", action: "collect", node: options.node, lane: options.lane, id: webObserveIdFromOptions(options), stateDir: options.stateDir, valuesRedacted: true }
      : buildWebObserveWrapperForObserveOptions("collect", options, spec.workspace),
    result: compactRaw
      ? { exitCode: result.exitCode, timedOut: result.timedOut, stdoutBytes: Buffer.byteLength(result.stdout), stderrBytes: Buffer.byteLength(result.stderr), stdoutRecovery: stdoutResolution.diagnostics }
      : collect === null
        ? { ...compactCommandResultWithStdoutTail(result), stdoutRecovery: stdoutResolution.diagnostics }
        : compactCommandResult(result),
    valuesRedacted: true,
  };
}

export function isWebObserveCollectJsonContract(value: Record<string, unknown>, requestedFile: string | null = null): boolean {
  const command = stringOrNull(value.command);
  const view = stringOrNull(value.view);
  const mode = stringOrNull(value.mode);
  const stateDir = stringOrNull(value.stateDir);
  if (commandArtifactBucket(value, requestedFile) !== null) return true;
  if (value.ok === false) {
    return command === "web-probe-observe collect"
      || view !== null
      || stringOrNull(value.reason) !== null
      || stringOrNull(value.error) !== null;
  }
  return command === "web-probe-observe collect"
    && (view !== null || mode === "file" || mode === "list")
    && stateDir !== null;
}

function normalizeWebObserveCollectResult(
  value: Record<string, unknown> | null,
  requestedFile: string | null,
  stdout: string,
  stateDir: string | null,
): Record<string, unknown> | null {
  if (value === null) return null;
  const bucket = commandArtifactBucket(value, requestedFile);
  if (bucket === null || requestedFile === null) return value;
  const jsonContent = compactCommandArtifact(value);
  return {
    ok: true,
    command: "web-probe-observe collect",
    stateDir,
    mode: "file",
    requestedFile,
    resolvedFile: requestedFile,
    fallbackReason: "terminal-command-artifact-stdout-normalized",
    file: {
      relative: requestedFile,
      requestedRelative: requestedFile,
      resolvedRelative: requestedFile,
      fallbackReason: "terminal-command-artifact-stdout-normalized",
      byteCount: Buffer.byteLength(stdout),
      sha256: `sha256:${createHash("sha256").update(stdout).digest("hex")}`,
      truncated: false,
      contentTruncated: false,
      jsonlTail: null,
      jsonSummary: {
        topLevelKeys: Object.keys(value).slice(0, 24),
        commandArtifactBucket: bucket,
        artifactOk: value.ok,
      },
      jsonContent,
      grep: null,
      lineCount: stdout.split(/\r?\n/u).filter(Boolean).length,
    },
    artifactOutcome: {
      ok: value.ok,
      commandId: value.commandId,
      type: value.type,
      bucket,
      valuesRedacted: true,
    },
    valuesRedacted: true,
  };
}

function commandArtifactBucket(value: Record<string, unknown>, requestedFile: string | null): "done" | "failed" | "abandoned" | null {
  if (requestedFile === null) return null;
  const match = requestedFile.match(/^commands\/(done|failed|abandoned)\/([A-Za-z0-9_.-]+)\.json$/u);
  if (match === null || stringOrNull(value.commandId) !== match[2] || stringOrNull(value.type) === null) return null;
  if (match[1] === "done") {
    return value.ok === true && stringOrNull(value.completedAt) !== null && isRecord(value.result) ? "done" : null;
  }
  if (match[1] === "failed") {
    return value.ok === false && stringOrNull(value.failedAt) !== null && isRecord(value.error) ? "failed" : null;
  }
  return value.ok === false && stringOrNull(value.abandonedAt) !== null && stringOrNull(value.reason) !== null ? "abandoned" : null;
}

function compactCommandArtifact(value: Record<string, unknown>): Record<string, unknown> {
  return {
    ok: value.ok,
    commandId: value.commandId,
    type: value.type,
    completedAt: value.completedAt ?? null,
    failedAt: value.failedAt ?? null,
    abandonedAt: value.abandonedAt ?? null,
    reason: boundedRedactedValue(value.reason),
    result: boundedRedactedValue(value.result),
    error: boundedRedactedValue(value.error),
    failureSample: boundedRedactedValue(value.failureSample),
    valuesRedacted: true,
  };
}

function boundedRedactedValue(value: unknown, depth = 0): unknown {
  if (value === null || value === undefined || typeof value === "number" || typeof value === "boolean") return value ?? null;
  if (typeof value === "string") return value.slice(0, 360);
  if (depth >= 4) return Array.isArray(value)
    ? { arrayLength: value.length }
    : isRecord(value)
      ? { keys: Object.keys(value).slice(0, 20) }
      : String(value).slice(0, 180);
  if (Array.isArray(value)) {
    const items = value.slice(0, 12).map((item) => boundedRedactedValue(item, depth + 1));
    return value.length > 12 ? [...items, { omitted: value.length - 12 }] : items;
  }
  if (!isRecord(value)) return String(value).slice(0, 180);
  const output: Record<string, unknown> = {};
  const keys = Object.keys(value).slice(0, 40);
  for (const key of keys) {
    output[key] = /token|secret|password|passwd|authorization|cookie|api[-_]?key|session/iu.test(key)
      ? "[redacted]"
      : boundedRedactedValue(value[key], depth + 1);
  }
  if (Object.keys(value).length > keys.length) output.__omittedKeys = Object.keys(value).length - keys.length;
  return output;
}

function collectDegradedReason(diagnostics: Record<string, unknown>): string {
  const fallbackReason = stringOrNull(diagnostics.fallbackReason);
  if (fallbackReason === "stdout-json-contract-invalid") return "collect-stdout-json-contract-invalid";
  if (fallbackReason === "stdout-trans-truncated" || fallbackReason === "stdout-ssh-truncated") return "collect-stdout-dump-unavailable";
  if (fallbackReason === "stdout-dump-wrapper-unreadable") return "collect-stdout-dump-unreadable";
  if (fallbackReason === "stdout-empty") return "collect-stdout-empty";
  return "collect-stdout-json-unavailable";
}

function stringOrNull(value: unknown): string | null {
  return typeof value === "string" && value.length > 0 ? value : null;
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return value !== null && typeof value === "object" && !Array.isArray(value);
}
