#!/usr/bin/env node

import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import {
  collectAllResolvedPackagesFromLockfile,
  collectProdResolvedPackagesFromLockfile,
  createBulkAdvisoryPayload,
  fetchBulkAdvisories,
} from "./pre-commit/pnpm-audit-prod.mjs";

const SEVERITY_RANK = {
  info: 0,
  low: 1,
  moderate: 2,
  high: 3,
  critical: 4,
};

function normalizeSeverity(severity) {
  if (typeof severity !== "string") {
    return "info";
  }
  return severity.toLowerCase();
}

function isMalwareAdvisory(advisory) {
  const fields = [advisory?.title, advisory?.overview, advisory?.url].filter(
    (field) => typeof field === "string",
  );
  return fields.some((field) => /\bmalware\b/iu.test(field));
}

function chunkEntries(entries, size) {
  const chunks = [];
  for (let index = 0; index < entries.length; index += size) {
    chunks.push(entries.slice(index, index + size));
  }
  return chunks;
}

async function fetchBulkAdvisoriesForPayload(payload, fetchImpl) {
  const advisoryResults = {};
  for (const payloadChunk of chunkEntries(Object.entries(payload), 400)) {
    const chunkPayload = Object.fromEntries(payloadChunk);
    Object.assign(
      advisoryResults,
      await fetchBulkAdvisories({
        payload: chunkPayload,
        fetchImpl,
      }),
    );
  }
  return advisoryResults;
}

function flattenAdvisories(advisoriesByPackage, graphName) {
  const findings = [];
  for (const [packageName, advisories] of Object.entries(advisoriesByPackage ?? {})) {
    if (!Array.isArray(advisories)) {
      continue;
    }
    for (const advisory of advisories) {
      if (!advisory || typeof advisory !== "object") {
        continue;
      }
      const severity = normalizeSeverity(advisory.severity);
      findings.push({
        graph: graphName,
        packageName,
        id: advisory.id ?? "unknown",
        severity,
        title: advisory.title ?? "Untitled advisory",
        url: advisory.url ?? null,
        vulnerableVersions: advisory.vulnerable_versions ?? null,
        malware: isMalwareAdvisory(advisory),
      });
    }
  }
  return findings;
}

function findingKey(finding) {
  return [
    finding.packageName,
    String(finding.id),
    finding.severity,
    finding.vulnerableVersions ?? "",
  ].join("\0");
}

function dedupeFindings(findings) {
  const byKey = new Map();
  for (const finding of findings) {
    const key = findingKey(finding);
    const existing = byKey.get(key);
    if (!existing) {
      byKey.set(key, finding);
      continue;
    }
    if (existing.graph !== "production" && finding.graph === "production") {
      byKey.set(key, finding);
    }
  }
  return [...byKey.values()];
}

function sortFindings(findings) {
  return findings.toSorted((left, right) => {
    const severityDelta =
      (SEVERITY_RANK[right.severity] ?? -1) - (SEVERITY_RANK[left.severity] ?? -1);
    if (severityDelta !== 0) {
      return severityDelta;
    }
    if (left.graph !== right.graph) {
      return left.graph.localeCompare(right.graph);
    }
    if (left.packageName !== right.packageName) {
      return left.packageName.localeCompare(right.packageName);
    }
    return String(left.id).localeCompare(String(right.id));
  });
}

export function classifyVulnerabilityFindings({ allAdvisories, productionAdvisories }) {
  const allFindings = flattenAdvisories(allAdvisories, "all");
  const productionFindings = flattenAdvisories(productionAdvisories, "production");
  const blockers = [];

  for (const finding of allFindings) {
    if (finding.malware || finding.severity === "critical") {
      blockers.push(finding);
    }
  }
  for (const finding of productionFindings) {
    if (finding.severity === "high" || finding.severity === "critical" || finding.malware) {
      blockers.push(finding);
    }
  }

  return {
    blockers: sortFindings(dedupeFindings(blockers)),
    findings: sortFindings(dedupeFindings([...allFindings, ...productionFindings])),
  };
}

function countPayloadVersions(payload) {
  return Object.values(payload).reduce((sum, versions) => sum + versions.length, 0);
}

export async function runDependencyVulnerabilityGate({
  rootDir = process.cwd(),
  fetchImpl = fetch,
} = {}) {
  const lockfileText = await readFile(path.join(rootDir, "pnpm-lock.yaml"), "utf8");
  const allPayload = createBulkAdvisoryPayload(
    collectAllResolvedPackagesFromLockfile(lockfileText),
  );
  const productionPayload = createBulkAdvisoryPayload(
    collectProdResolvedPackagesFromLockfile(lockfileText),
  );

  const [allAdvisories, productionAdvisories] = await Promise.all([
    fetchBulkAdvisoriesForPayload(allPayload, fetchImpl),
    fetchBulkAdvisoriesForPayload(productionPayload, fetchImpl),
  ]);
  const classified = classifyVulnerabilityFindings({ allAdvisories, productionAdvisories });
  return {
    generatedAt: new Date().toISOString(),
    policy: {
      blocks: [
        "known malware advisories anywhere in the installed graph",
        "critical advisories anywhere in the installed graph",
        "high advisories in the production/runtime graph",
      ],
      reports: [
        "moderate and lower advisories",
        "high advisories outside production/runtime graph",
      ],
      vulnerabilityExceptions: false,
    },
    graphs: {
      all: {
        packages: Object.keys(allPayload).length,
        packageVersions: countPayloadVersions(allPayload),
      },
      production: {
        packages: Object.keys(productionPayload).length,
        packageVersions: countPayloadVersions(productionPayload),
      },
    },
    ...classified,
  };
}

export function renderDependencyVulnerabilityGateMarkdownReport(report) {
  const lines = [
    "# npm Advisory Vulnerability Gate: Resolved Dependency Graph",
    "",
    `Generated: ${report.generatedAt}`,
    "",
    "## Scope",
    "",
    "This gate checks resolved package versions from pnpm-lock.yaml against npm advisory data. It includes transitive dependencies. It blocks known malware anywhere, critical advisories anywhere, and high advisories in the production/runtime graph.",
    "",
    "## Summary",
    "",
    `- All graph packages: ${report.graphs.all.packages}`,
    `- All graph package versions: ${report.graphs.all.packageVersions}`,
    `- Production graph packages: ${report.graphs.production.packages}`,
    `- Production graph package versions: ${report.graphs.production.packageVersions}`,
    `- Hard blockers: ${report.blockers.length}`,
    `- Total findings: ${report.findings.length}`,
    "",
    "## Policy",
    "",
    ...report.policy.blocks.map((block) => `- Block: ${block}`),
    ...report.policy.reports.map((item) => `- Report: ${item}`),
    `- Vulnerability exceptions: ${report.policy.vulnerabilityExceptions ? "allowed" : "not allowed"}`,
    "",
  ];

  if (report.blockers.length > 0) {
    lines.push("## Hard Blockers", "");
    for (const finding of report.blockers) {
      lines.push(
        `- ${finding.severity.toUpperCase()} ${finding.packageName} (${finding.graph}) ` +
          `id=${finding.id} range=${finding.vulnerableVersions ?? "unknown"} ` +
          `${finding.malware ? "[malware] " : ""}${finding.url ?? ""}`,
      );
      lines.push(`  - ${finding.title}`);
    }
    lines.push("");
  }

  if (report.findings.length > 0) {
    lines.push("## Findings", "");
    for (const finding of report.findings) {
      lines.push(
        `- ${finding.severity.toUpperCase()} ${finding.packageName} (${finding.graph}) ` +
          `id=${finding.id} range=${finding.vulnerableVersions ?? "unknown"} ` +
          `${finding.malware ? "[malware] " : ""}${finding.url ?? ""}`,
      );
      lines.push(`  - ${finding.title}`);
    }
    lines.push("");
  }

  if (report.findings.length === 0) {
    lines.push("No advisories found.", "");
  }

  return `${lines.join("\n")}\n`;
}

const renderMarkdownReport = renderDependencyVulnerabilityGateMarkdownReport;

function parseArgs(argv) {
  const options = {
    rootDir: process.cwd(),
    jsonPath: null,
    markdownPath: null,
  };
  for (let index = 0; index < argv.length; index += 1) {
    const arg = argv[index];
    if (arg === "--") {
      continue;
    }
    if (arg === "--root") {
      options.rootDir = argv[++index];
      continue;
    }
    if (arg === "--json") {
      options.jsonPath = argv[++index];
      continue;
    }
    if (arg === "--markdown") {
      options.markdownPath = argv[++index];
      continue;
    }
    throw new Error(`Unsupported argument: ${arg}`);
  }
  return options;
}

async function writeArtifact(filePath, content) {
  if (!filePath) {
    return;
  }
  await mkdir(path.dirname(filePath), { recursive: true });
  await writeFile(filePath, content, "utf8");
}

export async function main(argv = process.argv.slice(2)) {
  const options = parseArgs(argv);
  const report = await runDependencyVulnerabilityGate({ rootDir: options.rootDir });
  await writeArtifact(options.jsonPath, `${JSON.stringify(report, null, 2)}\n`);
  await writeArtifact(options.markdownPath, renderMarkdownReport(report));

  if (report.blockers.length === 0) {
    const packageVersions = Number(report.graphs.all.packageVersions);
    process.stdout.write(
      `PASS npm advisory vulnerability gate: checked ${packageVersions} resolved ` +
        `package versions across the lockfile graph; 0 hard blockers, ` +
        `${report.findings.length} total advisories.\n`,
    );
    return 0;
  }

  process.stderr.write(
    `FAIL npm advisory vulnerability gate: ${report.blockers.length} hard blockers in resolved ` +
      `dependency graph; ${report.findings.length} total advisories.\n`,
  );
  for (const blocker of report.blockers.slice(0, 25)) {
    process.stderr.write(
      `- ${blocker.severity.toUpperCase()} ${blocker.packageName} (${blocker.graph}) ` +
        `id=${blocker.id} title=${blocker.title}\n`,
    );
  }
  return 1;
}

if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
  main().then(
    (exitCode) => {
      process.exitCode = exitCode;
    },
    (error) => {
      process.stderr.write(`${error.stack ?? error.message ?? String(error)}\n`);
      process.exitCode = 1;
    },
  );
}
