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

import { rootPath } from "./config";
import { projectRemoteGcResult } from "./gc-remote";

const webObserveSource = readFileSync(rootPath("scripts/src/gc-remote-web-observe.py"), "utf8");
const runnerSource = readFileSync(rootPath("scripts/src/gc-remote-runner.py"), "utf8");

function runPythonFixture(body: string): Record<string, unknown> {
  const fixture = [
    "import json, os, re, signal, time",
    "PROVIDER_ID = 'NC01'",
    "def safe_int(value, fallback=0):",
    "    try: return int(value)",
    "    except (TypeError, ValueError): return fallback",
    "def config_list(config, key, fallback):",
    "    value = config.get(key, fallback)",
    "    return value if isinstance(value, list) else fallback",
    "def config_int(config, key, fallback, minimum=None, maximum=None):",
    "    value = config.get(key, fallback)",
    "    return value if isinstance(value, int) and not isinstance(value, bool) else fallback",
    "def config_float(config, key, fallback, minimum=None, maximum=None): return float(config.get(key, fallback))",
    "def fmt_bytes(value): return str(value)",
    "def redact_command_preview(value): return str(value)[:200]",
    "def observe_run_record(path, stale_hours): return {}",
    webObserveSource,
    body,
  ].join("\n");
  const result = spawnSync("python3", ["-"], { input: fixture, encoding: "utf8" });
  assert.equal(result.status, 0, result.stderr || result.stdout);
  return JSON.parse(result.stdout.trim()) as Record<string, unknown>;
}

test("memory-pressure candidate fixtures protect active observers and classify only closed stale/orphan trees", () => {
  const result = runPythonFixture(String.raw`
MEMORY_CONFIG = {
  "observeStateRoots": ["/tmp/observe"], "minimumReadableObserveRoots": 1,
  "observeRunDepth": 6, "observeScanLimit": 100, "staleRunMaxAgeHours": 1,
  "processScanLimit": 100, "processStaleMinAgeSeconds": 3600,
  "maxMemoryReclaimCandidates": 20, "terminationGraceSeconds": 1,
  "terminationPollMilliseconds": 50, "observerRootPatterns": ["observer-runner.mjs"],
  "browserRootPatterns": ["observer-runner.mjs", "cliDaemon.mjs"],
  "browserProcessPatterns": ["chromium", "/usr/bin/chromium"]
}
def row(pid, ppid, sid, start, age, command, rss=1024):
    return {"pid": pid, "ppid": ppid, "sid": sid, "startTicks": start, "ageSeconds": age, "rssBytes": rss, "commandLine": command, "commandPreview": command}
processes = {
  100: row(100, 1, 100, 1000, 7200, "node observer-runner.mjs"),
  101: row(101, 100, 100, 1001, 7200, "/usr/bin/chromium --type=browser"),
  200: row(200, 1, 200, 2000, 7200, "node observer-runner.mjs"),
  300: row(300, 1, 300, 3000, 7200, "node cliDaemon.mjs"),
  301: row(301, 300, 300, 3001, 7200, "/usr/bin/chromium --type=browser"),
}
read_proc_process_table = lambda: processes
observer_run_records = lambda: [
  {"pid": 100, "pidAlive": True, "staleLiveSignal": False},
  {"pid": 200, "pidAlive": True, "staleLiveSignal": True},
]
candidates = collect_memory_reclaim_candidates()
print(json.dumps({"kinds": sorted(item["kind"] for item in candidates), "roots": sorted(item["rootPid"] for item in candidates), "diagnostics": memory_reclaim_scan_diagnostics()}))
`);
  assert.deepEqual(result.kinds, ["browser-orphan-process-tree", "stale-web-observer-process-tree"]);
  assert.deepEqual(result.roots, [200, 300]);
  assert.equal((result.diagnostics as Record<string, unknown>).blocked, false);
});

test("memory-pressure process scans fail closed on empty or truncated process tables", () => {
  const result = runPythonFixture(String.raw`
MEMORY_CONFIG = {
  "observeStateRoots": ["/tmp/observe"], "minimumReadableObserveRoots": 1,
  "observeRunDepth": 6, "observeScanLimit": 100, "staleRunMaxAgeHours": 1,
  "processScanLimit": 100, "processStaleMinAgeSeconds": 3600,
  "maxMemoryReclaimCandidates": 20, "terminationGraceSeconds": 1,
  "terminationPollMilliseconds": 50, "observerRootPatterns": ["observer-runner.mjs"],
  "browserRootPatterns": ["cliDaemon.mjs"], "browserProcessPatterns": ["chromium"]
}
observer_run_records = lambda: []
read_proc_process_table = lambda: {}
empty = collect_memory_reclaim_candidates()
empty_diag = memory_reclaim_scan_diagnostics()
def truncated_scan():
    global _PROCESS_SCAN_TRUNCATED
    _PROCESS_SCAN_TRUNCATED = True
    return {1: {"pid": 1, "ppid": 0, "sid": 1, "startTicks": 1, "ageSeconds": 7200, "rssBytes": 1, "commandLine": "node cliDaemon.mjs", "commandPreview": "node cliDaemon.mjs"}}
read_proc_process_table = truncated_scan
truncated = collect_memory_reclaim_candidates()
truncated_diag = memory_reclaim_scan_diagnostics()
print(json.dumps({"emptyCount": len(empty), "emptyBlocked": empty_diag["blocked"], "emptyError": empty_diag["processScanError"], "truncatedCount": len(truncated), "truncatedBlocked": truncated_diag["blocked"], "truncatedFlag": truncated_diag["processScanTruncated"]}))
`);
  assert.equal(result.emptyCount, 0);
  assert.equal(result.emptyBlocked, true);
  assert.equal(result.emptyError, "proc-process-table-empty");
  assert.equal(result.truncatedCount, 0);
  assert.equal(result.truncatedBlocked, true);
  assert.equal(result.truncatedFlag, true);
});

test("post-TERM identity matching tolerates reparenting but never PID startTicks reuse", () => {
  const result = runPythonFixture(String.raw`
identity = {"pid": 41, "ppid": 10, "sid": 10, "startTicks": 1234}
reparented = {41: {"pid": 41, "ppid": 1, "sid": 41, "startTicks": 1234}}
reused = {41: {"pid": 41, "ppid": 10, "sid": 10, "startTicks": 9999}}
print(json.dumps({
  "preTermReparented": matching_live_identity(identity, reparented) is not None,
  "postTermReparented": matching_surviving_identity(identity, reparented) is not None,
  "postTermReused": matching_surviving_identity(identity, reused) is not None,
}))
`);
  assert.equal(result.preTermReparented, false);
  assert.equal(result.postTermReparented, true);
  assert.equal(result.postTermReused, false);
});

test("memory-pressure async runner uses a parent-child barrier and locked worker state", () => {
  assert.match(runnerSource, /os\.pipe\(\)/u);
  assert.match(runnerSource, /os\.read\(read_descriptor/u);
  assert.match(runnerSource, /workerStartTicks/u);
  assert.match(runnerSource, /write_memory_job_state\(/u);
  assert.match(runnerSource, /workerAlive/u);
  assert.match(runnerSource, /worker-identity-unavailable|worker-exited-without-terminal-state/u);
});

test("cgroup reclaim re-resolves workload and systemd identities before execution", () => {
  assert.match(webObserveSource, /_CGROUP_RECLAIM_RUNTIME_INVENTORY = None/u);
  assert.match(webObserveSource, /cgroup reclaim candidate identity changed before execution/u);
  assert.match(webObserveSource, /item\.get\("path"\) == path/u);
  assert.match(webObserveSource, /item\.get\("pod"\).*get\("uid"\).*resolved_uid/u);
  assert.match(webObserveSource, /item\.get\("systemdUnit"\) == resolved_systemd_unit/u);
  assert.match(webObserveSource, /systemd-main-pid-cgroup-mismatch/u);
  assert.match(webObserveSource, /systemd-main-pid-start-ticks-unavailable/u);
});

test("memory-pressure plan includes only YAML allowlisted cgroup reclaim stages with swap reserve", () => {
  const result = runPythonFixture(String.raw`
MEMORY_CONFIG = {
  "observeStateRoots": ["/tmp/observe"], "minimumReadableObserveRoots": 1,
  "observeRunDepth": 6, "observeScanLimit": 100, "staleRunMaxAgeHours": 1,
  "processScanLimit": 100, "processStaleMinAgeSeconds": 3600,
  "maxMemoryReclaimCandidates": 20, "terminationGraceSeconds": 1,
  "terminationPollMilliseconds": 50, "observerRootPatterns": ["observer-runner.mjs"],
  "browserRootPatterns": ["cliDaemon.mjs"], "browserProcessPatterns": ["chromium"],
  "cgroupReclaim": {
    "enabled": True, "targetMemAvailableBytes": 4294967296,
    "minimumSwapFreeBytesAfterReclaim": 402653184,
    "targets": [
      {"id": "file-pages", "systemdUnitSelector": {"unit": "k3s.service"}, "reclaimBytes": 402653184, "swappiness": 0, "priority": 10},
      {"id": "inactive-anon", "systemdUnitSelector": {"unit": "k3s.service"}, "reclaimBytes": 1342177280, "swappiness": 100, "minimumInactiveAnonBytes": 1342177280, "priority": 20},
    ],
  },
  "attribution": {"commandTimeoutSeconds": 20},
}
read_proc_process_table = lambda: {1: {"pid": 1, "ppid": 0, "sid": 1, "startTicks": 1, "ageSeconds": 1, "rssBytes": 1, "commandLine": "init", "commandPreview": "init"}}
observer_run_records = lambda: []
collect_memory_snapshot = lambda: {"ok": True, "memory": {"availableBytes": 3 * 1024 * 1024 * 1024}, "swap": {"freeBytes": 2 * 1024 * 1024 * 1024}}
os.path.realpath = lambda path: path
os.path.isdir = lambda path: True
os.path.islink = lambda path: False
os.path.exists = lambda path: True
os.access = lambda path, mode: True
command = lambda argv, timeout: {"exitCode": 0, "stdout": "LoadState=loaded\nActiveState=active\nSubState=running\nControlGroup=/system.slice/k3s.service\nMainPID=321\n", "stderr": ""}
cgroup_path_for_pid = lambda pid: "/system.slice/k3s.service"
process_start_ticks = lambda pid: 987654
read_cgroup_integer = lambda path, name: 6 * 1024 * 1024 * 1024
read_cgroup_memory_stat = lambda path: {"inactive_anon": 2 * 1024 * 1024 * 1024}
candidates = collect_memory_reclaim_candidates()
memory_reclaim_write_attempts = []
original_open = open
def tracked_open(path, *args, **kwargs):
    if str(path).endswith("/memory.reclaim"):
        memory_reclaim_write_attempts.append(path)
    return original_open(path, *args, **kwargs)
open = tracked_open
MEMORY_CONFIG["cgroupReclaim"]["targets"][0]["priority"] = 11
try:
    execute_cgroup_reclaim_candidate(candidates[0])
    priority_revalidation_error = None
except RuntimeError as error:
    priority_revalidation_error = str(error)
MEMORY_CONFIG["cgroupReclaim"]["targets"][0]["priority"] = 10
process_start_ticks = lambda pid: 987655
try:
    execute_cgroup_reclaim_candidate(candidates[0])
    revalidation_error = None
except RuntimeError as error:
    revalidation_error = str(error)
print(json.dumps({"ids": [item["id"] for item in candidates], "swappiness": [item["swappiness"] for item in candidates], "systemd": [item["resolvedSystemdUnit"] for item in candidates], "priorityRevalidationError": priority_revalidation_error, "memoryReclaimWriteAttempts": memory_reclaim_write_attempts, "revalidationError": revalidation_error, "diagnostics": memory_reclaim_scan_diagnostics()}))
`);
  assert.deepEqual(result.ids, ["cgroup-memory-reclaim:file-pages", "cgroup-memory-reclaim:inactive-anon"]);
  assert.deepEqual(result.swappiness, [0, 100]);
  assert.deepEqual((result.systemd as Array<Record<string, unknown>>).map((item) => item.unit), ["k3s.service", "k3s.service"]);
  assert.deepEqual((result.systemd as Array<Record<string, unknown>>).map((item) => item.startTicks), [987654, 987654]);
  assert.equal(result.priorityRevalidationError, "cgroup reclaim candidate no longer matches YAML: priority");
  assert.deepEqual(result.memoryReclaimWriteAttempts, []);
  assert.equal(result.revalidationError, "cgroup reclaim candidate identity changed before execution");
  assert.equal(((result.diagnostics as Record<string, unknown>).cgroupReclaim as Record<string, unknown>).eligibleCount, 2);
  assert.match(webObserveSource, /minimum SwapFree reserve/u);
  assert.match(webObserveSource, /memory\.reclaim/u);
});

test("default memory-pressure plan projects an actionable YAML-bounded preview below stdout limit", () => {
  const processCandidate = (index: number): Record<string, unknown> => ({
    id: `browser-orphan-process-tree:${1000 + index}:12345`,
    kind: "browser-orphan-process-tree",
    risk: "medium",
    reason: "browser-session-without-observer-record",
    rootPid: 1000 + index,
    sid: 1000 + index,
    rootStartTicks: 12345 + index,
    processCount: 20,
    processIdentities: Array.from({ length: 20 }, (_, member) => ({
      pid: 1000 + index * 100 + member,
      ppid: member === 0 ? 1 : 1000 + index * 100,
      sid: 1000 + index,
      startTicks: 12345 + index * 100 + member,
      rssBytes: 16 * 1024 * 1024,
      commandPreview: `/usr/bin/chromium --fixture-${"x".repeat(160)}`,
    })),
    expectedMemoryRssBytes: 320 * 1024 * 1024,
    expectedMemoryRss: "320 MiB",
    activeObserverIntersection: false,
    treeClosure: "unique-root-ppid-sid-closed",
    action: { op: "term-wait-kill-exact-process-tree", allowlist: "yaml-web-probe-memory-pressure" },
  });
  const cgroupCandidate = (index: number): Record<string, unknown> => ({
    id: `cgroup-memory-reclaim:file-pages-${index}`,
    configId: `file-pages-${index}`,
    kind: "cgroup-memory-reclaim",
    risk: "medium",
    reason: "host-memavailable-below-yaml-target",
    path: "/sys/fs/cgroup/system.slice/k3s.service",
    systemdUnitSelector: { unit: "k3s.service" },
    resolvedSystemdUnit: {
      unit: "k3s.service",
      loadState: "loaded",
      activeState: "active",
      subState: "running",
      controlGroup: "/system.slice/k3s.service",
      mainPid: 321,
      startTicks: 987654,
    },
    reclaimBytes: 256 * 1024 * 1024,
    swappiness: 0,
    targetMemAvailableBytes: 4 * 1024 * 1024 * 1024,
    minimumSwapFreeBytesAfterReclaim: 384 * 1024 * 1024,
    memoryCurrentBytes: 6 * 1024 * 1024 * 1024,
    memorySwapCurrentBytes: 0,
    expectedMemoryRssBytes: 256 * 1024 * 1024,
    expectedMemoryRss: "256 MiB",
    action: { op: "cgroup-v2-memory-reclaim", allowlist: "yaml-stable-cgroup-selector" },
  });
  const candidates = [cgroupCandidate(1), cgroupCandidate(2), ...Array.from({ length: 4 }, (_, index) => processCandidate(index))];
  const payload = {
    ok: true,
    action: "gc remote plan",
    providerId: "NC01",
    scope: "memory-pressure-only",
    dryRun: true,
    mutation: false,
    observedAt: "2026-07-17T20:00:00Z",
    memoryBefore: { ok: true, memory: { availableBytes: 2 * 1024 * 1024 * 1024 }, swap: { freeBytes: 1024 * 1024 * 1024 } },
    summary: { candidateCount: 6, returnedCandidateCount: 6, expectedMemoryRssBytes: 1_610_612_736, byKind: {} },
    candidateScan: {
      ok: true,
      blocked: false,
      processPressure: { processCount: 800, zombieCount: 0, topRss: [] },
      observeRoots: { configuredCount: 2, readableCount: 2, missingCount: 0, minimumReadable: 1 },
      cgroupReclaim: { configuredCount: 2, eligibleCount: 2, protected: [] },
      policySource: "config/unidesk-cli.yaml#gc.remote.targets.NC01.memoryPressure",
    },
    candidates,
    protected: {
      activeObserverIntersection: "excluded",
      processIdentity: "pre-term:pid-ppid-sid-startTicks;post-term:pid-startTicks",
      treeClosure: "unique-root-ppid-sid-closed",
    },
    policy: {
      requiresRunConfirm: true,
      runCommand: "bun scripts/cli.ts gc remote NC01 run --confirm --memory-pressure-only",
    },
  };
  const config = Bun.YAML.parse(readFileSync(rootPath("config/unidesk-cli.yaml"), "utf8")) as Record<string, any>;
  const remoteTarget = config.gc.remote.targets.NC01 as Record<string, unknown>;
  const projected = projectRemoteGcResult(payload, remoteTarget, { action: "plan", memoryPressureOnly: true, full: false }) as Record<string, any>;

  assert.equal(projected.runEligibility.allowed, true);
  assert.equal(projected.runEligibility.allCandidatesEvaluated, true);
  assert.equal(projected.candidatePreview.limit, remoteTarget.memoryPressure.planPreviewLimit);
  assert.equal(projected.candidatePreview.candidates.length, Math.min(candidates.length, remoteTarget.memoryPressure.planPreviewLimit));
  assert.equal(projected.candidatePreview.candidates[2].identityClosure.identitiesComplete, true);
  assert.equal(projected.candidatePreview.candidates[2].processIdentities, undefined);
  assert.equal(projected.policy.runCommand, "bun scripts/cli.ts gc remote NC01 run --confirm --memory-pressure-only");
  assert.equal(projected.valuesRedacted, true);
  const renderedEnvelope = `${JSON.stringify({
    ok: true,
    command: "gc remote NC01 plan --memory-pressure-only",
    data: projected,
  }, null, 2)}\n`;
  assert.ok(Buffer.byteLength(renderedEnvelope, "utf8") < config.output.maxStdoutBytes);
  assert.equal(projectRemoteGcResult(payload, remoteTarget, { action: "plan", memoryPressureOnly: true, full: true }), payload);
});

test("memory-pressure plan projection fails closed when candidate page or YAML preview policy is incomplete", () => {
  const payload = {
    ok: true,
    summary: { candidateCount: 2 },
    candidateScan: { ok: true, blocked: false },
    candidates: [],
  };
  const projected = projectRemoteGcResult(payload, { memoryPressure: {} }, { action: "plan", memoryPressureOnly: true, full: false }) as Record<string, any>;
  assert.equal(projected.runEligibility.allowed, false);
  assert.deepEqual(projected.runEligibility.reasons, [
    "candidate-page-incomplete",
    "yaml-plan-preview-limit-invalid",
  ]);
});
