#!/usr/bin/env npx tsx
/**
 * cve-collect.ts
 *
 * Orchestrator that pulls AI / agent / LLM / MCP CVEs from every
 * authoritative source we trust and emits a single summary report.
 *
 * Sources (each is a standalone script — this orchestrator just shells
 * out to them so a maintainer can run any source individually):
 *
 *   1. CISA KEV         scripts/sync-cisa-kev.ts   -> proposals/cisa-kev/
 *   2. AVID-DB          scripts/sync-avid-db.ts    -> proposals/avid/
 *   3. GHSA             scripts/sync-ghsa.ts        -> proposals/ghsa/
 *   4. NVD AI-filter    scripts/sync-nvd-ai.ts      -> proposals/nvd/
 *   5. Pliny corpus     scripts/sync-pliny.ts        -> proposals/pliny/
 *   (planned)
 *   6. Huntr.dev        scripts/sync-huntr.ts       -> proposals/huntr/
 *
 * Output:
 *   - Writes per-source proposal files (delegated to each sync script).
 *   - Emits a JSON summary to data/cve-collector/last-run.json with
 *     run date, per-source counts, total CVEs tracked, and the
 *     detection-ready vs tracking-only split.
 *   - Prints a human-readable summary table on stdout.
 *
 * Idempotency:
 *   Each sync script already de-dupes against rules/ and proposals/
 *   (see buildKnownIndex / buildCveIndex). Running this orchestrator
 *   daily is safe — only NEW CVEs produce new proposal files.
 *
 * Usage:
 *   npx tsx scripts/cve-collect.ts              # dry-run (no proposals written)
 *   npx tsx scripts/cve-collect.ts --write      # write proposals to disk
 *   npx tsx scripts/cve-collect.ts --source ghsa --write
 *
 * Exit codes:
 *   0 success
 *   1 fatal (one or more sources failed unrecoverably)
 *   2 partial (some sources rate-limited; recoverable on next run)
 */

import {
  existsSync,
  mkdirSync,
  readFileSync,
  readdirSync,
  writeFileSync,
  statSync,
} from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import yaml from "js-yaml";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const REPO_ROOT = resolve(__dirname, "..");
const SCRIPTS_DIR = resolve(REPO_ROOT, "scripts");
const PROPOSALS_BASE = resolve(REPO_ROOT, "proposals");
const RULES_DIR = resolve(REPO_ROOT, "rules");
const DATA_OUT = resolve(REPO_ROOT, "data/cve-collector");

const args = process.argv.slice(2);
const flag = (n: string) => args.includes(n);
const opt = (n: string): string | undefined => {
  const i = args.indexOf(n);
  return i >= 0 ? args[i + 1] : undefined;
};

const WRITE = flag("--write");
const FORCE = flag("--force");
const SOURCE_FILTER = opt("--source");
const VERBOSE = flag("--verbose");

interface SourceDef {
  id: string;
  script: string;
  proposalsDir: string;
  summaryMarker: string; // tag emitted by each script on its last line
  description: string;
}

const SOURCES: SourceDef[] = [
  {
    id: "cisa-kev",
    script: "sync-cisa-kev.ts",
    proposalsDir: "cisa-kev",
    summaryMarker: "::cisa-kev-summary::",
    description: "CISA Known Exploited Vulnerabilities (AI-filtered)",
  },
  {
    id: "avid-db",
    script: "sync-avid-db.ts",
    proposalsDir: "avid",
    summaryMarker: "::avid-summary::",
    description: "AI Vulnerability Database (avidml/avid-db)",
  },
  {
    id: "ghsa",
    script: "sync-ghsa.ts",
    proposalsDir: "ghsa",
    summaryMarker: "::ghsa-summary::",
    description: "GitHub Security Advisories (AI package allowlist)",
  },
  {
    id: "nvd",
    script: "sync-nvd-ai.ts",
    proposalsDir: "nvd",
    summaryMarker: "::nvd-summary::",
    description: "NVD CVE feed (AI/agent/LLM keyword-filtered)",
  },
  {
    id: "pliny",
    script: "sync-pliny.ts",
    proposalsDir: "pliny",
    summaryMarker: "::pliny-summary::",
    description: "Pliny L1B3RT4S community jailbreak corpus (MIT)",
  },
  {
    // sync-advbench.ts processes 3 sub-corpora (AdvBench, HarmBench,
    // JailbreakBench) in one pass; the proposalsDir counted below is the
    // parent advbench/ folder, but the script writes into 3 sibling
    // dirs (advbench/, harmbench/, jailbreakbench/) so the orchestrator
    // reads combined totals via the summary marker.
    id: "advbench",
    script: "sync-advbench.ts",
    proposalsDir: "advbench",
    summaryMarker: "::advbench-summary::",
    description:
      "Academic adversarial-prompt benchmarks (AdvBench + HarmBench + JailbreakBench)",
  },
];

interface SourceResult {
  id: string;
  exit_code: number;
  duration_ms: number;
  rate_limited: boolean;
  parsed_summary: Record<string, unknown> | null;
  proposals_on_disk: number;
}

function runSource(s: SourceDef): SourceResult {
  const scriptPath = join(SCRIPTS_DIR, s.script);
  if (!existsSync(scriptPath)) {
    console.error(
      `source ${s.id}: script not found at ${scriptPath} — skipping`,
    );
    return {
      id: s.id,
      exit_code: -1,
      duration_ms: 0,
      rate_limited: false,
      parsed_summary: null,
      proposals_on_disk: countProposals(s.proposalsDir),
    };
  }

  const argv = ["tsx", scriptPath];
  if (WRITE) argv.push("--write");
  if (FORCE) argv.push("--force");
  if (VERBOSE) argv.push("--verbose");

  console.error(`\n=== running ${s.id} (${s.description}) ===`);
  const t0 = Date.now();
  const proc = spawnSync("npx", argv, {
    cwd: REPO_ROOT,
    encoding: "utf-8",
    env: { ...process.env },
    stdio: ["ignore", "pipe", "pipe"],
  });
  const duration = Date.now() - t0;

  if (proc.stderr) process.stderr.write(proc.stderr);
  const stdout = proc.stdout ?? "";

  let parsed: Record<string, unknown> | null = null;
  for (const line of stdout.split("\n")) {
    if (line.startsWith(s.summaryMarker)) {
      const payload = line.slice(s.summaryMarker.length);
      try {
        parsed = JSON.parse(payload) as Record<string, unknown>;
      } catch {
        parsed = null;
      }
    }
  }

  return {
    id: s.id,
    exit_code: proc.status ?? -1,
    duration_ms: duration,
    rate_limited: proc.status === 2,
    parsed_summary: parsed,
    proposals_on_disk: countProposals(s.proposalsDir),
  };
}

function countProposals(subdir: string): number {
  const dir = join(PROPOSALS_BASE, subdir);
  if (!existsSync(dir)) return 0;
  let n = 0;
  for (const e of readdirSync(dir)) {
    const f = join(dir, e);
    try {
      const st = statSync(f);
      if (st.isFile() && (e.endsWith(".yaml") || e.endsWith(".yml"))) n += 1;
    } catch {
      continue;
    }
  }
  return n;
}

function walkYaml(dir: string): string[] {
  if (!existsSync(dir)) return [];
  const out: string[] = [];
  for (const e of readdirSync(dir)) {
    const f = join(dir, e);
    let st;
    try {
      st = statSync(f);
    } catch {
      continue;
    }
    if (st.isDirectory()) out.push(...walkYaml(f));
    else if (st.isFile() && (e.endsWith(".yaml") || e.endsWith(".yml")))
      out.push(f);
  }
  return out;
}

interface CveCoverageStats {
  total_cves_tracked: number;
  cves_with_active_rule: number;
  proposals_total: number;
  proposals_detection_ready: number;
  proposals_tracking_only: number;
}

function computeCoverageStats(): CveCoverageStats {
  const stats: CveCoverageStats = {
    total_cves_tracked: 0,
    cves_with_active_rule: 0,
    proposals_total: 0,
    proposals_detection_ready: 0,
    proposals_tracking_only: 0,
  };

  const trackedCves = new Set<string>();
  const activeCves = new Set<string>();

  for (const f of walkYaml(RULES_DIR)) {
    let doc: unknown;
    try {
      doc = yaml.load(readFileSync(f, "utf-8"));
    } catch {
      continue;
    }
    const refs = (doc as { references?: { cve?: string[] } })?.references;
    if (refs?.cve) {
      for (const c of refs.cve) {
        if (typeof c === "string") {
          trackedCves.add(c);
          activeCves.add(c);
        }
      }
    }
  }

  for (const f of walkYaml(PROPOSALS_BASE)) {
    stats.proposals_total += 1;
    let doc: unknown;
    try {
      doc = yaml.load(readFileSync(f, "utf-8"));
    } catch {
      continue;
    }
    const refs = (doc as { references?: { cve?: string[] } })?.references;
    if (refs?.cve) {
      for (const c of refs.cve) {
        if (typeof c === "string") trackedCves.add(c);
      }
    }
    const triage = (doc as { _triage?: { detection_ready?: boolean } })
      ?._triage;
    if (triage?.detection_ready) stats.proposals_detection_ready += 1;
    else stats.proposals_tracking_only += 1;
  }

  stats.total_cves_tracked = trackedCves.size;
  stats.cves_with_active_rule = activeCves.size;
  return stats;
}

function main(): void {
  if (!existsSync(DATA_OUT)) mkdirSync(DATA_OUT, { recursive: true });

  const wanted = SOURCE_FILTER
    ? SOURCES.filter((s) => s.id === SOURCE_FILTER)
    : SOURCES;
  if (wanted.length === 0) {
    console.error(
      `unknown source "${SOURCE_FILTER}" — known: ${SOURCES.map((s) => s.id).join(", ")}`,
    );
    process.exit(1);
  }

  const results: SourceResult[] = [];
  for (const s of wanted) results.push(runSource(s));

  const coverage = computeCoverageStats();

  const report = {
    run_date: new Date().toISOString(),
    mode: WRITE ? "write" : "dry-run",
    sources: results,
    coverage,
  };

  const outFile = join(DATA_OUT, "last-run.json");
  writeFileSync(outFile, JSON.stringify(report, null, 2), "utf-8");

  console.log("\n=== CVE Collector Summary ===");
  console.log(`Run date: ${report.run_date}`);
  console.log(`Mode: ${report.mode}`);
  console.log("\nPer-source results:");
  for (const r of results) {
    const status = r.rate_limited
      ? "RATE-LIMITED"
      : r.exit_code === 0
        ? "OK"
        : `FAIL(${r.exit_code})`;
    const summary = r.parsed_summary ?? {};
    console.log(
      `  ${r.id.padEnd(12)} ${status.padEnd(14)} ` +
        `${String(r.duration_ms).padStart(5)}ms  ` +
        `${r.proposals_on_disk} proposals on disk  ` +
        `(this run: ${JSON.stringify(summary)})`,
    );
  }

  console.log("\nCoverage stats:");
  console.log(`  Total AI CVEs tracked: ${coverage.total_cves_tracked}`);
  console.log(
    `  CVEs with active rule (in rules/): ${coverage.cves_with_active_rule}`,
  );
  console.log(`  Proposals total: ${coverage.proposals_total}`);
  console.log(
    `  Proposals detection-ready: ${coverage.proposals_detection_ready}`,
  );
  console.log(`  Proposals tracking-only: ${coverage.proposals_tracking_only}`);
  console.log(`\nReport written to ${outFile.replace(REPO_ROOT + "/", "")}`);

  const anyFatal = results.some((r) => r.exit_code !== 0 && !r.rate_limited);
  const anyRateLimited = results.some((r) => r.rate_limited);
  if (anyFatal) process.exit(1);
  if (anyRateLimited) process.exit(2);
}

main();
