// node --test cli/commands/scrub.test.js
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";

import {
  PATTERNS,
  findTranscripts,
  scrubFile,
  isSecretLike,
  loadExtraPatterns,
  sessionTargets,
} from "./scrub.js";

const GH_TOKEN = "ghp_0123456789abcdefABCDEF0123456789abcd"; // ghp_ + 36 chars
const VAULT_VALUE = "SuperSecretVaultValue_abc123XYZ";        // secret-like literal
const CUSTOM_SECRET = "MYCORP-TOKEN-998877";                  // only an editable pattern catches it

function seedProjects() {
  const root = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-scrub-"));
  const sess = path.join(root, "proj", "session-uuid");
  const sidecarDir = path.join(sess, "tool-results");
  fs.mkdirSync(sidecarDir, { recursive: true });

  const jsonl = path.join(root, "proj", "session-uuid.jsonl");
  const txt = path.join(sidecarDir, "toolu_abc.txt");
  const body = `token=${GH_TOKEN} value=${VAULT_VALUE} custom=${CUSTOM_SECRET}\n`;
  fs.writeFileSync(jsonl, `{"x":"${body.trim()}"}\n`, "utf-8");
  fs.writeFileSync(txt, body, "utf-8"); // the sidecar that the old scrubber skipped
  return { root, jsonl, txt };
}

test("findTranscripts discovers .jsonl AND tool-results/*.txt sidecars", () => {
  const { root, jsonl, txt } = seedProjects();
  const found = findTranscripts(root);
  assert.ok(found.includes(jsonl), "should find the .jsonl transcript");
  assert.ok(found.includes(txt), "should find the .txt sidecar (the previously-skipped leak path)");
});

test("scrubFile redacts the github token in BOTH the jsonl and the sidecar", () => {
  const { jsonl, txt } = seedProjects();
  for (const f of [jsonl, txt]) {
    const n = scrubFile(f, { force: true, patterns: PATTERNS, literals: [] });
    assert.ok(n >= 1, `expected >=1 redaction in ${path.basename(f)}`);
    const after = fs.readFileSync(f, "utf-8");
    assert.ok(!after.includes(GH_TOKEN), "github token must be gone");
    assert.ok(after.includes("[GITHUB_TOKEN_REDACTED]"), "redaction marker present");
    assert.ok(after.includes("[CLAUTH-SCRUBBED]"), "file stamped as scrubbed");
  }
});

test("vault-value (literal) redaction removes an arbitrary secret regardless of format", () => {
  const { txt } = seedProjects();
  const n = scrubFile(txt, { force: true, patterns: [], literals: [{ name: "test-svc", value: VAULT_VALUE }] });
  assert.equal(n, 1, "exactly one literal occurrence redacted");
  const after = fs.readFileSync(txt, "utf-8");
  assert.ok(!after.includes(VAULT_VALUE), "vault value must be gone");
  assert.ok(after.includes("[CLAUTH:test-svc_REDACTED]"), "labelled vault redaction present");
});

test("editable patterns file catches a custom secret with no clauth release", () => {
  const cfg = path.join(os.tmpdir(), `clauth-scrub-patterns-${Date.now()}.json`);
  fs.writeFileSync(cfg, JSON.stringify([{ pattern: "MYCORP-TOKEN-\\d+", replacement: "[MYCORP_REDACTED]" }]), "utf-8");
  const extra = loadExtraPatterns(cfg);
  assert.equal(extra.length, 1, "one extra pattern loaded");

  const { txt } = seedProjects();
  const n = scrubFile(txt, { force: true, patterns: extra, literals: [] });
  assert.ok(n >= 1, "custom pattern matched");
  const after = fs.readFileSync(txt, "utf-8");
  assert.ok(!after.includes(CUSTOM_SECRET), "custom secret gone");
  assert.ok(after.includes("[MYCORP_REDACTED]"), "custom replacement applied");
  fs.rmSync(cfg, { force: true });
});

test("isSecretLike: accepts real tokens, rejects short/url/whitespace values", () => {
  assert.ok(isSecretLike(GH_TOKEN), "long token is secret-like");
  assert.ok(isSecretLike(VAULT_VALUE), "31-char no-space value is secret-like");
  assert.ok(!isSecretLike("short"), "short value rejected");
  assert.ok(!isSecretLike("https://research.regendevcorp.com/mcp"), "plain URL rejected");
  assert.ok(!isSecretLike("has spaces in it value"), "whitespace value rejected");
  assert.ok(!isSecretLike(""), "empty rejected");
});

test("sessionTargets returns ONLY the ending session's transcript + its sidecars", () => {
  const root = fs.mkdtempSync(path.join(os.tmpdir(), "clauth-sess-"));
  const proj = path.join(root, "C--proj");
  const sid = "11112222-3333-4444-5555-666677778888";
  fs.mkdirSync(path.join(proj, sid, "tool-results"), { recursive: true });
  const transcript = path.join(proj, `${sid}.jsonl`);
  const sidecar = path.join(proj, sid, "tool-results", "toolu_x.txt");
  const otherSession = path.join(proj, "99990000-aaaa-bbbb-cccc-ddddeeeeffff.jsonl");
  fs.writeFileSync(transcript, "{}\n");
  fs.writeFileSync(sidecar, "tool output\n");
  fs.writeFileSync(otherSession, "{}\n"); // a DIFFERENT session — must NOT be included

  const targets = sessionTargets({ transcript_path: transcript, session_id: sid });
  assert.ok(targets.includes(transcript), "includes the session transcript");
  assert.ok(targets.includes(sidecar), "includes the session's sidecar");
  assert.ok(!targets.includes(otherSession), "does NOT include other sessions (session-only)");
  assert.equal(targets.length, 2, "exactly the 2 session files");

  assert.deepEqual(sessionTargets(null), [], "no hook input → no targets (caller falls back)");
  assert.deepEqual(sessionTargets({ transcript_path: path.join(root, "nope.jsonl") }), [], "missing file → none");
});

test("loadExtraPatterns tolerates a missing/malformed file", () => {
  assert.deepEqual(loadExtraPatterns(path.join(os.tmpdir(), "does-not-exist-xyz.json")), []);
  const bad = path.join(os.tmpdir(), `clauth-bad-${Date.now()}.json`);
  fs.writeFileSync(bad, "{ not json", "utf-8");
  assert.deepEqual(loadExtraPatterns(bad), [], "malformed json → empty, never throws");
  fs.rmSync(bad, { force: true });
});
