/**
 * dependency_audit — composite tool that runs 4 dependency health checks
 * in parallel: vulnerabilities, licenses, freshness, and lockfile integrity.
 * Wraps existing CLI tools (npm/pnpm/yarn/bun audit + outdated).
 */
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { readFile, stat } from "node:fs/promises";
import { join } from "node:path";
import { getCodeIndex } from "./index-tools.js";
const execFileAsync = promisify(execFile);
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CHECK_TIMEOUT_MS = 30_000;
const MAX_BUFFER = 64 * 1024 * 1024; // 64MB — npm audit can be large
const TOP_N = 20;
const SEVERITY_RANK = {
    low: 0,
    moderate: 1,
    high: 2,
    critical: 3,
};
// GPL, AGPL, LGPL, SSPL, EUPL, OSL family → copyleft/problematic for many orgs
const PROBLEMATIC_LICENSE_PATTERNS = [
    /^GPL(-|$)/i,
    /^AGPL(-|$)/i,
    /^LGPL(-|$)/i,
    /^SSPL(-|$)/i,
    /^EUPL(-|$)/i,
    /^OSL(-|$)/i,
    /^CC-BY-NC/i,
    /copyleft/i,
];
function withTimeout(promise, ms) {
    return Promise.race([
        promise,
        new Promise((resolve) => setTimeout(() => resolve({ status: "timeout" }), ms)),
    ]);
}
function isTimeout(v) {
    return v != null
        && typeof v === "object"
        && "status" in v
        && v.status === "timeout";
}
// ---------------------------------------------------------------------------
// Package manager detection
// ---------------------------------------------------------------------------
async function fileExists(path) {
    try {
        await stat(path);
        return true;
    }
    catch {
        return false;
    }
}
async function detectPackageManager(workspace) {
    if (await fileExists(join(workspace, "pnpm-lock.yaml")))
        return "pnpm";
    if (await fileExists(join(workspace, "yarn.lock")))
        return "yarn";
    if (await fileExists(join(workspace, "bun.lockb")))
        return "bun";
    if (await fileExists(join(workspace, "bun.lock")))
        return "bun";
    if (await fileExists(join(workspace, "package-lock.json")))
        return "npm";
    return "unknown";
}
function lockfileName(pm) {
    switch (pm) {
        case "npm": return "package-lock.json";
        case "pnpm": return "pnpm-lock.yaml";
        case "yarn": return "yarn.lock";
        case "bun": return "bun.lockb";
        default: return null;
    }
}
function emptyVulnAggregate() {
    return {
        total: 0,
        by_severity: { critical: 0, high: 0, moderate: 0, low: 0 },
        findings: [],
    };
}
function normalizeSeverity(raw) {
    if (typeof raw !== "string")
        return null;
    const s = raw.toLowerCase();
    if (s === "critical" || s === "high" || s === "moderate" || s === "low") {
        return s;
    }
    // npm sometimes emits "info" — treat as low
    if (s === "info")
        return "low";
    return null;
}
function parseNpmAudit(stdout, minRank) {
    const agg = emptyVulnAggregate();
    let parsed;
    try {
        parsed = JSON.parse(stdout);
    }
    catch {
        return agg;
    }
    if (!parsed || typeof parsed !== "object")
        return agg;
    const vulns = parsed.vulnerabilities;
    if (!vulns || typeof vulns !== "object")
        return agg;
    for (const [pkg, data] of Object.entries(vulns)) {
        if (!data || typeof data !== "object")
            continue;
        const entry = data;
        const severity = normalizeSeverity(entry.severity);
        if (!severity)
            continue;
        if (SEVERITY_RANK[severity] < minRank)
            continue;
        // `via` can be an array of strings or objects (when transitive)
        const viaRaw = Array.isArray(entry.via) ? entry.via : [];
        const via = viaRaw.map((v) => {
            if (typeof v === "string")
                return v;
            if (v && typeof v === "object" && "name" in v && typeof v.name === "string") {
                return v.name;
            }
            return "";
        }).filter((s) => s.length > 0);
        // fixAvailable may be boolean or an object
        const fixAvailable = entry.fixAvailable === true
            || (entry.fixAvailable !== undefined && entry.fixAvailable !== false
                && typeof entry.fixAvailable === "object" && entry.fixAvailable !== null);
        agg.total++;
        agg.by_severity[severity]++;
        const finding = {
            package: pkg,
            severity,
            via,
            fix_available: fixAvailable,
        };
        if (typeof entry.url === "string" && entry.url.length > 0) {
            finding.advisory_url = entry.url;
        }
        agg.findings.push(finding);
    }
    // Sort by severity (critical first), then by package name
    agg.findings.sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity]
        || a.package.localeCompare(b.package));
    agg.findings = agg.findings.slice(0, TOP_N);
    return agg;
}
async function runAudit(pm, workspace, minRank) {
    if (pm === "unknown") {
        throw new Error("cannot run audit: unknown package manager");
    }
    // All supported package managers accept `audit --json`
    const args = ["audit", "--json"];
    // execFile with non-zero exit is treated as error, but npm audit exits
    // non-zero when vulnerabilities are found. We still want the stdout.
    try {
        const { stdout } = await execFileAsync(pm, args, {
            cwd: workspace,
            maxBuffer: MAX_BUFFER,
            timeout: CHECK_TIMEOUT_MS,
            // Don't inherit the parent's TTY; force non-interactive output
            env: { ...process.env, CI: "1", NO_COLOR: "1" },
        });
        return parseNpmAudit(stdout, minRank);
    }
    catch (err) {
        // Non-zero exit is normal if vulns exist; stdout may still hold the JSON
        if (err && typeof err === "object" && "stdout" in err) {
            const stdout = err.stdout;
            if (typeof stdout === "string" && stdout.trim().length > 0) {
                return parseNpmAudit(stdout, minRank);
            }
        }
        throw err;
    }
}
function isProblematicLicense(license) {
    return PROBLEMATIC_LICENSE_PATTERNS.some((pat) => pat.test(license));
}
function normalizeLicenseField(raw) {
    if (typeof raw === "string")
        return raw;
    // SPDX object form: { type: "MIT" }
    if (raw && typeof raw === "object") {
        const obj = raw;
        if (typeof obj.type === "string")
            return obj.type;
    }
    return "UNKNOWN";
}
async function scanLicenses(workspace, indexFiles) {
    const agg = {
        total: 0,
        problematic: [],
        distribution: {},
    };
    // Look for node_modules/<pkg>/package.json entries in the index.
    // Scoped packages: node_modules/@scope/pkg/package.json (depth 3)
    // Regular:        node_modules/pkg/package.json (depth 2)
    const pkgManifests = indexFiles.filter((f) => {
        if (!f.path.includes("node_modules/"))
            return false;
        if (!f.path.endsWith("/package.json"))
            return false;
        // Strip everything up to and including "node_modules/"
        const idx = f.path.lastIndexOf("node_modules/");
        const tail = f.path.slice(idx + "node_modules/".length);
        const parts = tail.split("/");
        // Regular: ["pkg", "package.json"] (2 parts)
        // Scoped:  ["@scope", "pkg", "package.json"] (3 parts)
        if (parts.length === 2)
            return true;
        if (parts.length === 3 && parts[0].startsWith("@"))
            return true;
        return false;
    });
    for (const file of pkgManifests) {
        try {
            const absPath = join(workspace, file.path);
            const source = await readFile(absPath, "utf-8");
            const json = JSON.parse(source);
            const pkgName = typeof json.name === "string" ? json.name : file.path;
            // Prefer `license` (SPDX), fall back to legacy `licenses` array
            let license = "UNKNOWN";
            if (json.license !== undefined) {
                license = normalizeLicenseField(json.license);
            }
            else if (Array.isArray(json.licenses) && json.licenses.length > 0) {
                license = normalizeLicenseField(json.licenses[0]);
            }
            agg.total++;
            agg.distribution[license] = (agg.distribution[license] ?? 0) + 1;
            if (isProblematicLicense(license)) {
                agg.problematic.push({
                    package: pkgName,
                    license,
                    is_problematic: true,
                });
            }
        }
        catch {
            // Skip unreadable / malformed manifests
        }
    }
    return agg;
}
function parseMajor(version) {
    const cleaned = version.replace(/^[\^~>=<v\s]+/, "");
    const first = cleaned.split(".")[0];
    if (!first)
        return null;
    const num = parseInt(first, 10);
    return Number.isNaN(num) ? null : num;
}
function parseOutdated(stdout) {
    const agg = { outdated_count: 0, major_gaps: [] };
    if (!stdout.trim())
        return agg;
    let parsed;
    try {
        parsed = JSON.parse(stdout);
    }
    catch {
        return agg;
    }
    if (!parsed || typeof parsed !== "object")
        return agg;
    const entries = [];
    for (const [pkg, data] of Object.entries(parsed)) {
        if (!data || typeof data !== "object")
            continue;
        const entry = data;
        const current = typeof entry.current === "string" ? entry.current : "";
        const latest = typeof entry.latest === "string" ? entry.latest : "";
        if (!current || !latest)
            continue;
        const currMajor = parseMajor(current);
        const latestMajor = parseMajor(latest);
        const gap = currMajor !== null && latestMajor !== null
            ? Math.max(0, latestMajor - currMajor)
            : 0;
        entries.push({
            package: pkg,
            current,
            latest,
            major_gap: gap,
        });
    }
    agg.outdated_count = entries.length;
    entries.sort((a, b) => b.major_gap - a.major_gap || a.package.localeCompare(b.package));
    agg.major_gaps = entries.slice(0, TOP_N);
    return agg;
}
async function runOutdated(pm, workspace) {
    if (pm === "unknown") {
        throw new Error("cannot run outdated: unknown package manager");
    }
    // npm/pnpm/yarn/bun all accept `outdated --json`. Yarn Classic emits NDJSON
    // (different schema); we only support the modern JSON form here.
    const args = ["outdated", "--json"];
    try {
        const { stdout } = await execFileAsync(pm, args, {
            cwd: workspace,
            maxBuffer: MAX_BUFFER,
            timeout: CHECK_TIMEOUT_MS,
            env: { ...process.env, CI: "1", NO_COLOR: "1" },
        });
        return parseOutdated(stdout);
    }
    catch (err) {
        // `npm outdated` exits 1 when outdated packages exist, but stdout still has JSON
        if (err && typeof err === "object" && "stdout" in err) {
            const stdout = err.stdout;
            if (typeof stdout === "string" && stdout.trim().length > 0) {
                return parseOutdated(stdout);
            }
        }
        throw err;
    }
}
function cleanVersionRange(range) {
    return range.replace(/^[\^~>=<v\s]+/, "").trim();
}
function versionSatisfiesManifest(manifestRange, installed) {
    // Lightweight check: caret/tilde compatibility on the major.
    const cleaned = cleanVersionRange(manifestRange);
    const manifestMajor = parseMajor(cleaned);
    const installedMajor = parseMajor(installed);
    if (manifestMajor === null || installedMajor === null)
        return true;
    // Accept exact equality of major version as "in range" for drift detection
    return manifestMajor === installedMajor;
}
async function checkLockfile(workspace, pm) {
    const agg = { present: false, issues: [] };
    const lockName = lockfileName(pm);
    if (!lockName) {
        agg.issues.push({
            type: "missing",
            message: "No supported lockfile found",
        });
        return agg;
    }
    const lockPath = join(workspace, lockName);
    if (!(await fileExists(lockPath))) {
        agg.issues.push({
            type: "missing",
            message: `Lockfile ${lockName} is missing`,
        });
        return agg;
    }
    agg.present = true;
    // Only npm (package-lock.json v2+) is parsed in detail here; others just
    // report presence and skip deep drift/duplicate analysis.
    if (pm !== "npm")
        return agg;
    let manifest = {};
    try {
        const manifestSrc = await readFile(join(workspace, "package.json"), "utf-8");
        manifest = JSON.parse(manifestSrc);
    }
    catch {
        agg.issues.push({
            type: "missing",
            message: "package.json could not be read",
        });
        return agg;
    }
    let lock = {};
    try {
        const lockSrc = await readFile(lockPath, "utf-8");
        lock = JSON.parse(lockSrc);
    }
    catch {
        agg.issues.push({
            type: "missing",
            message: `Lockfile ${lockName} could not be parsed`,
        });
        return agg;
    }
    // Declared deps from manifest
    const declared = {};
    for (const field of ["dependencies", "devDependencies", "optionalDependencies"]) {
        const bag = manifest[field];
        if (bag && typeof bag === "object") {
            for (const [name, range] of Object.entries(bag)) {
                if (typeof range === "string")
                    declared[name] = range;
            }
        }
    }
    // Walk installed versions from `packages` (v2+). Root entry has key "" —
    // every other key starts with "node_modules/".
    const installedVersions = new Map();
    const packages = lock.packages ?? {};
    for (const [key, data] of Object.entries(packages)) {
        if (!key.startsWith("node_modules/"))
            continue;
        const lastIdx = key.lastIndexOf("node_modules/");
        const tail = key.slice(lastIdx + "node_modules/".length);
        // Scoped names include a slash
        const parts = tail.split("/");
        let name;
        if (parts.length >= 2 && parts[0].startsWith("@")) {
            name = `${parts[0]}/${parts[1]}`;
        }
        else {
            name = parts[0];
        }
        if (!name)
            continue;
        const version = data && typeof data === "object" && "version" in data
            && typeof data.version === "string"
            ? data.version
            : null;
        if (!version)
            continue;
        if (!installedVersions.has(name))
            installedVersions.set(name, new Set());
        installedVersions.get(name).add(version);
    }
    // Detect drift: manifest says ^1.x but installed is 0.x
    for (const [name, range] of Object.entries(declared)) {
        const versions = installedVersions.get(name);
        if (!versions || versions.size === 0) {
            agg.issues.push({
                type: "missing",
                package: name,
                message: `${name} declared in package.json but not present in lockfile`,
            });
            continue;
        }
        for (const installed of versions) {
            if (!versionSatisfiesManifest(range, installed)) {
                agg.issues.push({
                    type: "drift",
                    package: name,
                    message: `${name}: manifest range ${range} does not match installed ${installed}`,
                });
            }
        }
    }
    // Detect duplicates: same package name appearing with multiple versions in the tree
    for (const [name, versions] of installedVersions) {
        if (versions.size > 1) {
            agg.issues.push({
                type: "duplicate",
                package: name,
                message: `${name} installed with ${versions.size} different versions: ${[...versions].join(", ")}`,
            });
        }
    }
    return agg;
}
// ---------------------------------------------------------------------------
// Main composite tool
// ---------------------------------------------------------------------------
export async function dependencyAudit(repo, options) {
    const startMs = Date.now();
    const index = await getCodeIndex(repo);
    if (!index) {
        throw new Error(`Repository "${repo}" not found. Index it first with index_folder.`);
    }
    const workspace = options?.workspace_path ?? index.root;
    const minRank = SEVERITY_RANK[options?.min_severity ?? "low"];
    const skipLicenses = options?.skip_licenses === true;
    const pm = await detectPackageManager(workspace);
    const errors = [];
    // Fan out 4 checks in parallel with per-check timeout
    const [vulnRes, licenseRes, freshRes, lockRes] = await Promise.allSettled([
        pm === "unknown"
            ? Promise.resolve(emptyVulnAggregate())
            : withTimeout(runAudit(pm, workspace, minRank), CHECK_TIMEOUT_MS),
        skipLicenses
            ? Promise.resolve({ total: 0, problematic: [], distribution: {} })
            : withTimeout(scanLicenses(workspace, index.files), CHECK_TIMEOUT_MS),
        pm === "unknown"
            ? Promise.resolve({ outdated_count: 0, major_gaps: [] })
            : withTimeout(runOutdated(pm, workspace), CHECK_TIMEOUT_MS),
        withTimeout(checkLockfile(workspace, pm), CHECK_TIMEOUT_MS),
    ]);
    // Extract results — any failure becomes an error[] entry, never crashes the audit
    let vulnerabilities = emptyVulnAggregate();
    if (vulnRes.status === "fulfilled") {
        if (isTimeout(vulnRes.value)) {
            errors.push("vulnerabilities: check timed out");
        }
        else {
            vulnerabilities = vulnRes.value;
        }
    }
    else {
        errors.push(`vulnerabilities: ${vulnRes.reason instanceof Error ? vulnRes.reason.message : String(vulnRes.reason)}`);
    }
    let licenses = { total: 0, problematic: [], distribution: {} };
    if (licenseRes.status === "fulfilled") {
        if (isTimeout(licenseRes.value)) {
            errors.push("licenses: check timed out");
        }
        else {
            licenses = licenseRes.value;
        }
    }
    else {
        errors.push(`licenses: ${licenseRes.reason instanceof Error ? licenseRes.reason.message : String(licenseRes.reason)}`);
    }
    let freshness = { outdated_count: 0, major_gaps: [] };
    if (freshRes.status === "fulfilled") {
        if (isTimeout(freshRes.value)) {
            errors.push("freshness: check timed out");
        }
        else {
            freshness = freshRes.value;
        }
    }
    else {
        errors.push(`freshness: ${freshRes.reason instanceof Error ? freshRes.reason.message : String(freshRes.reason)}`);
    }
    let lockfile = { present: false, issues: [] };
    if (lockRes.status === "fulfilled") {
        if (isTimeout(lockRes.value)) {
            errors.push("lockfile: check timed out");
        }
        else {
            lockfile = lockRes.value;
        }
    }
    else {
        errors.push(`lockfile: ${lockRes.reason instanceof Error ? lockRes.reason.message : String(lockRes.reason)}`);
    }
    if (pm === "unknown") {
        errors.push("package_manager: could not detect from lockfile");
    }
    return {
        workspace,
        package_manager: pm,
        vulnerabilities,
        licenses,
        freshness,
        lockfile,
        duration_ms: Date.now() - startMs,
        errors,
    };
}
//# sourceMappingURL=dependency-audit-tools.js.map