// cli/commands/scrub.js — Scrub credentials from Claude Code transcript logs + sidecars
//
// clauth scrub          → scrub active transcript (most recent)
// clauth scrub <file>   → scrub a specific file
// clauth scrub all      → scrub every transcript + tool-result sidecar
// clauth scrub --force  → rescrub even if already marked
//
// Three layers of redaction:
//   1. Built-in regex PATTERNS (known token shapes)
//   2. User-editable patterns from ~/.clauth/scrub-patterns.json (no release needed)
//   3. Vault-value redaction — literal occurrences of THIS machine's actual secret
//      values, pulled best-effort from the local daemon (covers any format).
// Files scanned: .jsonl transcripts AND tool-results/*.txt sidecars (the sidecars
// were previously skipped — a leaked secret could survive a "clean" scrub there).

import fs from "fs";
import path from "path";
import os from "os";
import chalk from "chalk";
import ora from "ora";

const SCRUB_MARKER = "[CLAUTH-SCRUBBED]";
const SCRUB_VERSION = "1.2";
const DAEMON = process.env.CLAUTH_DAEMON_URL || "http://127.0.0.1:52437";
const VAULT_FETCH_DELAY_MS = Number(process.env.CLAUTH_SCRUB_FETCH_DELAY_MS || 2200);

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

// Value-based redaction only considers values that look like real secrets, to avoid
// over-redacting short/benign config values (bucket names, plain URLs, etc.).
const MIN_SECRET_LEN = 16;

// ──────────────────────────────────────────────
// Built-in credential patterns — regex + replacement
// ──────────────────────────────────────────────
const PATTERNS = [
  [/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "[SUPABASE_JWT_REDACTED]"],
  [/vcp_[A-Za-z0-9]{20,80}/g, "[VERCEL_TOKEN_REDACTED]"],
  [/"secret_access_key"\s*:\s*"[a-f0-9]{64}"/g, '"secret_access_key": "[R2_SECRET_REDACTED]"'],
  [/"access_key_id"\s*:\s*"[a-f0-9]{32}"/g, '"access_key_id": "[R2_KEY_REDACTED]"'],
  [/"admin_token"\s*:\s*"[A-Za-z0-9_-]{20,60}"/g, '"admin_token": "[CF_TOKEN_REDACTED]"'],
  [/"account_id"\s*:\s*"[a-f0-9]{32}"/g, '"account_id": "[CF_ACCOUNT_REDACTED]"'],
  // GitHub tokens — ghp_ classic is 36 chars, but accept 30+ to be resilient to format shifts
  [/ghp_[A-Za-z0-9]{30,}/g, "[GITHUB_TOKEN_REDACTED]"],
  [/gho_[A-Za-z0-9]{30,}/g, "[GITHUB_OAUTH_REDACTED]"],
  [/ghs_[A-Za-z0-9]{30,}/g, "[GITHUB_SERVER_REDACTED]"],
  [/github_pat_[A-Za-z0-9_]{40,100}/g, "[GITHUB_PAT_REDACTED]"],
  // AWS access key IDs
  [/AKIA[A-Z0-9]{16}/g, "[AWS_KEY_REDACTED]"],
  // OpenAI / Anthropic style keys
  [/sk-[A-Za-z0-9_-]{20,}/g, "[API_KEY_REDACTED]"],
  [/neo4j\+s?:\/\/[^"\\]+/g, "[NEO4J_CONNSTRING_REDACTED]"],
  [/Bearer [A-Za-z0-9_-]{20,}/g, "Bearer [TOKEN_REDACTED]"],
];

// ──────────────────────────────────────────────
// Layer 2 — user-editable patterns (no clauth release to add a pattern)
// ~/.clauth/scrub-patterns.json: [{ "pattern": "...", "flags": "g", "replacement": "..." }]
// ──────────────────────────────────────────────
export function loadExtraPatterns(filePath) {
  const file = filePath || path.join(os.homedir(), ".clauth", "scrub-patterns.json");
  try {
    if (!fs.existsSync(file)) return [];
    const raw = JSON.parse(fs.readFileSync(file, "utf-8"));
    const list = Array.isArray(raw) ? raw : Array.isArray(raw.patterns) ? raw.patterns : [];
    const out = [];
    for (const entry of list) {
      if (!entry || !entry.pattern) continue;
      try {
        const flags = entry.flags && entry.flags.includes("g") ? entry.flags : (entry.flags || "") + "g";
        out.push([new RegExp(entry.pattern, flags), entry.replacement ?? "[REDACTED]"]);
      } catch { /* skip malformed pattern, never break scrub */ }
    }
    return out;
  } catch {
    return [];
  }
}

// ──────────────────────────────────────────────
// Layer 3 — literal secret values from the vault
// ──────────────────────────────────────────────
export function isSecretLike(value) {
  if (typeof value !== "string") return false;
  const v = value.trim();
  if (v.length < MIN_SECRET_LEN) return false;       // too short → likely benign config
  if (/\s/.test(v)) return false;                     // whitespace → not a token (URLs/sentences)
  if (/^https?:\/\//i.test(v)) return false;          // plain endpoint URL, not a secret
  return true;
}

// Best-effort: pull this machine's actual secret values from the local daemon.
// This is intentionally slow because literal redaction must read real secrets.
// Normal service discovery should use /knowledge and never call this.
// Returns [{ name, value }]. Any failure (daemon down/locked/offline) → [] (regex still runs).
export async function fetchVaultValues(base = DAEMON) {
  try {
    const ctrl = new AbortController();
    const t = setTimeout(() => ctrl.abort(), 4000);
    const listRes = await fetch(`${base}/knowledge`, { signal: ctrl.signal });
    clearTimeout(t);
    if (!listRes.ok) return [];
    const listJson = await listRes.json();
    const names = (listJson.services || []).filter(s => s.has_key !== false).map(s => s.name);
    const out = [];
    for (let i = 0; i < names.length; i++) {
      const name = names[i];
      if (i > 0 && VAULT_FETCH_DELAY_MS > 0) await sleep(VAULT_FETCH_DELAY_MS);
      try {
        const c = new AbortController();
        const tt = setTimeout(() => c.abort(), 4000);
        const r = await fetch(`${base}/v/${encodeURIComponent(name)}`, { signal: c.signal });
        clearTimeout(tt);
        if (!r.ok) continue;
        const value = (await r.text()).trim();
        if (isSecretLike(value)) out.push({ name, value });
      } catch { /* skip this service */ }
    }
    return out;
  } catch {
    return [];
  }
}

function redactLiterals(content, literals) {
  let count = 0;
  for (const { name, value } of literals) {
    if (!value || !content.includes(value)) continue;
    const before = content.length;
    const parts = content.split(value);
    count += parts.length - 1;
    content = parts.join(`[CLAUTH:${name}_REDACTED]`);
    void before;
  }
  return { content, count };
}

// ──────────────────────────────────────────────
// Marker check / stamp
// ──────────────────────────────────────────────
function isAlreadyScrubbed(filePath) {
  try {
    const stat = fs.statSync(filePath);
    const fd = fs.openSync(filePath, "r");
    const bufSize = Math.min(512, stat.size);
    const buf = Buffer.alloc(bufSize);
    fs.readSync(fd, buf, 0, bufSize, Math.max(0, stat.size - bufSize));
    fs.closeSync(fd);
    const lastLine = buf.toString("utf-8").trim().split("\n").pop();
    if (lastLine && lastLine.includes(SCRUB_MARKER)) {
      try { return JSON.parse(lastLine).version === SCRUB_VERSION; } catch { return false; }
    }
    return false;
  } catch { return false; }
}

function stampScrubbed(filePath) {
  const marker = JSON.stringify({
    type: SCRUB_MARKER,
    version: SCRUB_VERSION,
    scrubbed_at: new Date().toISOString(),
  });
  fs.appendFileSync(filePath, "\n" + marker + "\n", "utf-8");
}

// ──────────────────────────────────────────────
// Scrub a single file
// ──────────────────────────────────────────────
export function scrubFile(filePath, opts = {}) {
  const { force = false, patterns = PATTERNS, literals = [] } = opts;
  if (!force && isAlreadyScrubbed(filePath)) return "skipped";

  let content = fs.readFileSync(filePath, "utf-8");
  let total = 0;

  for (const [pattern, replacement] of patterns) {
    pattern.lastIndex = 0;
    const matches = content.match(pattern);
    if (matches) {
      total += matches.length;
      content = content.replace(pattern, replacement);
    }
  }

  if (literals.length) {
    const r = redactLiterals(content, literals);
    content = r.content;
    total += r.count;
  }

  if (total > 0) fs.writeFileSync(filePath, content, "utf-8");
  stampScrubbed(filePath);
  return total;
}

// ──────────────────────────────────────────────
// Find all scrubbable files: .jsonl transcripts AND tool-results/*.txt sidecars
// ──────────────────────────────────────────────
const SCRUB_EXTS = new Set([".jsonl", ".txt"]);

export function findTranscripts(root) {
  const claudeDir = root || path.join(os.homedir(), ".claude", "projects");
  if (!fs.existsSync(claudeDir)) return [];
  const results = [];
  function walk(dir) {
    let entries;
    try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
    for (const entry of entries) {
      const full = path.join(dir, entry.name);
      if (entry.isDirectory()) walk(full);
      else if (SCRUB_EXTS.has(path.extname(entry.name))) results.push(full);
    }
  }
  walk(claudeDir);
  return results;
}

function findMostRecent() {
  const files = findTranscripts();
  if (files.length === 0) return null;
  let newest = files[0];
  let newestMtime = fs.statSync(files[0]).mtimeMs;
  for (const f of files.slice(1)) {
    const mt = fs.statSync(f).mtimeMs;
    if (mt > newestMtime) { newest = f; newestMtime = mt; }
  }
  return newest;
}

// ──────────────────────────────────────────────
// Session scope — the ending session's transcript + its sidecars.
// A SessionEnd hook pipes JSON ({transcript_path, session_id, cwd, ...}) on stdin.
// Sidecars live in the dir named like the transcript minus the .jsonl extension.
// ──────────────────────────────────────────────
export function sessionTargets(hook) {
  const files = [];
  const tp = hook && (hook.transcript_path || hook.transcriptPath);
  if (tp && fs.existsSync(tp)) files.push(tp);
  if (tp) {
    const sidecarDir = tp.replace(/\.jsonl$/i, "");
    if (sidecarDir !== tp && fs.existsSync(sidecarDir)) {
      for (const f of findTranscripts(sidecarDir)) files.push(f);
    }
  }
  return [...new Set(files)];
}

async function readHookStdin() {
  try {
    if (process.stdin.isTTY) return null;
    const chunks = [];
    for await (const chunk of process.stdin) chunks.push(chunk);
    const raw = Buffer.concat(chunks).toString("utf-8").trim();
    return raw ? JSON.parse(raw) : null;
  } catch {
    return null;
  }
}

// ──────────────────────────────────────────────
// Exported runner
// ──────────────────────────────────────────────
export async function runScrub(target, opts = {}) {
  const force = opts.force || false;

  const patterns = [...PATTERNS, ...loadExtraPatterns()];
  const literals = await fetchVaultValues();

  let files;
  if (target === "all") {
    files = findTranscripts();
    if (files.length === 0) { console.log(chalk.yellow("\n  No transcript files found.\n")); return; }
    console.log(chalk.cyan(`\n  Scrubbing ${files.length} file(s) (.jsonl + sidecars)...`));
  } else if (target === "session") {
    // Scrub ONLY the ending session (transcript + its sidecars). For SessionEnd hooks.
    const hook = await readHookStdin();
    files = sessionTargets(hook);
    if (files.length === 0) {
      const recent = findMostRecent();
      if (!recent) { console.log(chalk.yellow("\n  No session transcript found.\n")); return; }
      files = [recent];
    }
    console.log(chalk.gray(`\n  Session scrub: ${files.length} file(s)`));
  } else if (target && target !== "all") {
    const resolved = path.resolve(target);
    if (!fs.existsSync(resolved)) { console.log(chalk.red(`\n  File not found: ${resolved}\n`)); process.exit(1); }
    files = [resolved];
  } else {
    const recent = findMostRecent();
    if (!recent) { console.log(chalk.yellow("\n  No transcript files found.\n")); return; }
    files = [recent];
    console.log(chalk.gray(`\n  Active transcript: ${path.basename(recent)}`));
  }

  const layers = [`${patterns.length} pattern(s)`];
  if (literals.length) layers.push(chalk.green(`${literals.length} live vault value(s)`));
  else layers.push(chalk.gray("vault values: daemon unreachable/locked — regex only"));
  console.log(chalk.gray(`  Redaction: ${layers.join(", ")}\n`));

  const spinner = ora("Scrubbing credentials...").start();
  let grandTotal = 0, skipped = 0, scanned = 0;
  for (const f of files) {
    const result = scrubFile(f, { force, patterns, literals });
    if (result === "skipped") { skipped++; continue; }
    scanned++;
    if (result > 0) {
      spinner.stop();
      console.log(chalk.yellow(`  ${result} redaction(s) in ${path.basename(f)}`));
      spinner.start("Scrubbing credentials...");
      grandTotal += result;
    }
  }
  spinner.stop();

  const parts = [];
  if (scanned) parts.push(`scanned ${scanned}`);
  if (skipped) parts.push(chalk.gray(`skipped ${skipped} (already clean)`));
  if (grandTotal) parts.push(chalk.green(`${grandTotal} credential(s) scrubbed`));
  else if (scanned) parts.push(chalk.green("no credentials found"));
  console.log(`\n  ${files.length} file(s): ${parts.join(", ")}.`);
  if (skipped && !force) console.log(chalk.gray("  (use --force to rescan all files)"));
  console.log();
}

export { PATTERNS, SCRUB_VERSION };
