/**
 * Incremental Scanner Cache
 *
 * Caches scanner results to enable incremental scanning. Only changed files
 * are re-scanned, dramatically improving performance on subsequent runs.
 *
 * Cache structure:
 * ```
 * .vaspera/cache/scanners/{scanner}/{hash}.json
 * ```
 *
 * Cache key: sha256(filePath + fileHash + scannerVersion + ruleHash)
 *
 * @module scanners/cache
 */
import { createHash } from "crypto";
import { readFile, writeFile, readdir, mkdir, unlink, stat } from "fs/promises";
import { join } from "path";
import { logger } from "../logger.js";
const DEFAULT_OPTIONS = {
    maxEntriesPerScanner: 10000,
    maxAgeMs: 7 * 24 * 60 * 60 * 1000, // 7 days
    cacheDir: ".vaspera/cache/scanners",
};
/**
 * Scanner cache manager
 *
 * Provides LRU cache with configurable limits per scanner type.
 */
export class ScannerCache {
    cacheDir;
    maxEntriesPerScanner;
    maxAgeMs;
    projectPath;
    constructor(projectPath, options = {}) {
        this.projectPath = projectPath;
        const opts = { ...DEFAULT_OPTIONS, ...options };
        this.cacheDir = join(projectPath, opts.cacheDir);
        this.maxEntriesPerScanner = opts.maxEntriesPerScanner;
        this.maxAgeMs = opts.maxAgeMs;
    }
    /**
     * Generate a cache key for a file + scanner + version + rules combination
     */
    generateCacheKey(filePath, fileHash, scanner, scannerVersion, ruleHash) {
        const input = `${filePath}:${fileHash}:${scanner}:${scannerVersion}:${ruleHash}`;
        return createHash("sha256").update(input).digest("hex");
    }
    /**
     * Get the cache file path for a given key
     */
    getCachePath(scanner, key) {
        // Use first 2 chars as subdirectory for better filesystem performance
        const subdir = key.slice(0, 2);
        return join(this.cacheDir, scanner, subdir, `${key}.json`);
    }
    /**
     * Ensure cache directory exists
     */
    async ensureCacheDir(scanner, key) {
        const subdir = key.slice(0, 2);
        const dir = join(this.cacheDir, scanner, subdir);
        await mkdir(dir, { recursive: true });
    }
    /**
     * Look up cached results for a file
     */
    async lookup(filePath, fileHash, scanner, scannerVersion, ruleHash) {
        const key = this.generateCacheKey(filePath, fileHash, scanner, scannerVersion, ruleHash);
        const cachePath = this.getCachePath(scanner, key);
        try {
            const content = await readFile(cachePath, "utf-8");
            const entry = JSON.parse(content);
            // Validate entry version
            if (entry.version !== 1) {
                return { hit: false, missReason: "version_mismatch" };
            }
            // Check scanner version
            if (entry.scannerVersion !== scannerVersion) {
                return { hit: false, missReason: "version_mismatch" };
            }
            // Check rule hash
            if (entry.ruleHash !== ruleHash) {
                return { hit: false, missReason: "rule_mismatch" };
            }
            // Check file hash
            if (entry.fileHash !== fileHash) {
                return { hit: false, missReason: "file_changed" };
            }
            // Check age
            const age = Date.now() - new Date(entry.cachedAt).getTime();
            if (age > this.maxAgeMs) {
                return { hit: false, missReason: "expired" };
            }
            logger.debug("scanner_cache.hit", {
                scanner,
                filePath,
                findingsCount: entry.findings.length,
            });
            return { hit: true, entry };
        }
        catch {
            return { hit: false, missReason: "not_found" };
        }
    }
    /**
     * Store scanner results in cache
     */
    async store(filePath, fileHash, scanner, scannerVersion, ruleHash, findings, scanDuration) {
        const key = this.generateCacheKey(filePath, fileHash, scanner, scannerVersion, ruleHash);
        const entry = {
            version: 1,
            scanner,
            scannerVersion,
            ruleHash,
            filePath,
            fileHash,
            findings,
            cachedAt: new Date().toISOString(),
            scanDuration,
        };
        try {
            await this.ensureCacheDir(scanner, key);
            const cachePath = this.getCachePath(scanner, key);
            await writeFile(cachePath, JSON.stringify(entry), "utf-8");
            logger.debug("scanner_cache.stored", {
                scanner,
                filePath,
                findingsCount: findings.length,
            });
        }
        catch (error) {
            logger.warn("scanner_cache.store_error", {
                scanner,
                filePath,
                error: String(error),
            });
        }
    }
    /**
     * Invalidate cache for a specific file
     */
    async invalidate(filePath, scanner) {
        let invalidated = 0;
        const scanners = scanner
            ? [scanner]
            : ["semgrep", "npm-audit", "gitleaks", "tsc", "eslint", "bandit", "gosec", "brakeman", "trivy"];
        for (const s of scanners) {
            const scannerDir = join(this.cacheDir, s);
            try {
                const subdirs = await readdir(scannerDir);
                for (const subdir of subdirs) {
                    const subdirPath = join(scannerDir, subdir);
                    const files = await readdir(subdirPath);
                    for (const file of files) {
                        try {
                            const content = await readFile(join(subdirPath, file), "utf-8");
                            const entry = JSON.parse(content);
                            if (entry.filePath === filePath) {
                                await unlink(join(subdirPath, file));
                                invalidated++;
                            }
                        }
                        catch {
                            // Ignore individual file errors
                        }
                    }
                }
            }
            catch {
                // Scanner directory doesn't exist
            }
        }
        logger.debug("scanner_cache.invalidated", {
            filePath,
            scanner,
            count: invalidated,
        });
        return invalidated;
    }
    /**
     * Run LRU eviction to stay within limits
     */
    async evict() {
        let evicted = 0;
        const scanners = [
            "semgrep", "npm-audit", "gitleaks", "tsc", "eslint",
            "bandit", "gosec", "brakeman", "trivy",
        ];
        for (const scanner of scanners) {
            const scannerDir = join(this.cacheDir, scanner);
            const entries = [];
            try {
                const subdirs = await readdir(scannerDir);
                for (const subdir of subdirs) {
                    const subdirPath = join(scannerDir, subdir);
                    try {
                        const files = await readdir(subdirPath);
                        for (const file of files) {
                            const filePath = join(subdirPath, file);
                            try {
                                const stats = await stat(filePath);
                                entries.push({ path: filePath, mtime: stats.mtimeMs });
                            }
                            catch {
                                // Ignore stat errors
                            }
                        }
                    }
                    catch {
                        // Ignore readdir errors
                    }
                }
            }
            catch {
                // Scanner directory doesn't exist
                continue;
            }
            // Sort by mtime (oldest first)
            entries.sort((a, b) => a.mtime - b.mtime);
            // Evict oldest entries if over limit
            const toEvict = entries.length - this.maxEntriesPerScanner;
            if (toEvict > 0) {
                for (let i = 0; i < toEvict; i++) {
                    try {
                        await unlink(entries[i].path);
                        evicted++;
                    }
                    catch {
                        // Ignore unlink errors
                    }
                }
            }
            // Also evict expired entries
            const now = Date.now();
            for (const entry of entries.slice(toEvict > 0 ? toEvict : 0)) {
                if (now - entry.mtime > this.maxAgeMs) {
                    try {
                        await unlink(entry.path);
                        evicted++;
                    }
                    catch {
                        // Ignore unlink errors
                    }
                }
            }
        }
        if (evicted > 0) {
            logger.info("scanner_cache.evicted", { count: evicted });
        }
        return evicted;
    }
    /**
     * Get cache statistics
     */
    async getStats() {
        const stats = {
            totalEntries: 0,
            totalSizeBytes: 0,
            byScanner: {
                semgrep: 0,
                "npm-audit": 0,
                gitleaks: 0,
                tsc: 0,
                eslint: 0,
                bandit: 0,
                gosec: 0,
                brakeman: 0,
                trivy: 0,
                "binary-analysis": 0,
                "memory-safety": 0,
                "race-condition": 0,
                healthcare: 0,
                plugin: 0,
                logic: 0,
                dast: 0,
                zap: 0,
                nuclei: 0,
                terraform: 0,
                tfsec: 0,
                checkov: 0,
                openapi: 0,
                spectral: 0,
                rust: 0,
                "cargo-audit": 0,
                clippy: 0,
                detection: 0,
            },
        };
        let oldestTime = Infinity;
        let newestTime = 0;
        const scanners = Object.keys(stats.byScanner);
        for (const scanner of scanners) {
            const scannerDir = join(this.cacheDir, scanner);
            try {
                const subdirs = await readdir(scannerDir);
                for (const subdir of subdirs) {
                    const subdirPath = join(scannerDir, subdir);
                    try {
                        const files = await readdir(subdirPath);
                        for (const file of files) {
                            try {
                                const fileStat = await stat(join(subdirPath, file));
                                stats.totalEntries++;
                                stats.byScanner[scanner]++;
                                stats.totalSizeBytes += fileStat.size;
                                if (fileStat.mtimeMs < oldestTime) {
                                    oldestTime = fileStat.mtimeMs;
                                    stats.oldestEntry = new Date(fileStat.mtimeMs).toISOString();
                                }
                                if (fileStat.mtimeMs > newestTime) {
                                    newestTime = fileStat.mtimeMs;
                                    stats.newestEntry = new Date(fileStat.mtimeMs).toISOString();
                                }
                            }
                            catch {
                                // Ignore stat errors
                            }
                        }
                    }
                    catch {
                        // Ignore readdir errors
                    }
                }
            }
            catch {
                // Scanner directory doesn't exist
            }
        }
        return stats;
    }
    /**
     * Clear all cache entries
     */
    async clear() {
        let cleared = 0;
        const scanners = [
            "semgrep", "npm-audit", "gitleaks", "tsc", "eslint",
            "bandit", "gosec", "brakeman", "trivy",
        ];
        for (const scanner of scanners) {
            const scannerDir = join(this.cacheDir, scanner);
            try {
                const subdirs = await readdir(scannerDir);
                for (const subdir of subdirs) {
                    const subdirPath = join(scannerDir, subdir);
                    try {
                        const files = await readdir(subdirPath);
                        for (const file of files) {
                            try {
                                await unlink(join(subdirPath, file));
                                cleared++;
                            }
                            catch {
                                // Ignore unlink errors
                            }
                        }
                    }
                    catch {
                        // Ignore readdir errors
                    }
                }
            }
            catch {
                // Scanner directory doesn't exist
            }
        }
        logger.info("scanner_cache.cleared", { count: cleared });
        return cleared;
    }
}
/**
 * Compute hash of file contents
 */
export async function hashFileContents(filePath) {
    const content = await readFile(filePath);
    return createHash("sha256").update(content).digest("hex");
}
/**
 * Compute hash of scanner rules/config
 *
 * This is used to invalidate cache when rules change.
 */
export function hashRules(rules, configFile) {
    const input = JSON.stringify({ rules: rules?.sort() || [], configFile });
    return createHash("sha256").update(input).digest("hex").slice(0, 16);
}
/**
 * Run scanner with caching
 *
 * Wraps a scanner function to use the cache. Only scans files that:
 * 1. Are not in cache
 * 2. Have changed since last scan
 * 3. Were scanned with different scanner version or rules
 */
export async function runScannerWithCache(cache, scanner, scannerVersion, ruleHash, files, scanFn) {
    const startTime = Date.now();
    const cachedFindings = [];
    const filesToScan = [];
    let cacheHits = 0;
    let cacheMisses = 0;
    let savedTimeMs = 0;
    // Check cache for each file
    for (const file of files) {
        const lookup = await cache.lookup(file.path, file.hash, scanner, scannerVersion, ruleHash);
        if (lookup.hit && lookup.entry) {
            cacheHits++;
            cachedFindings.push(...lookup.entry.findings);
            savedTimeMs += lookup.entry.scanDuration;
        }
        else {
            cacheMisses++;
            filesToScan.push(file.path);
        }
    }
    // Run scanner only on uncached files
    let scanResult;
    if (filesToScan.length > 0) {
        scanResult = await scanFn(filesToScan);
        // Store new results in cache (group findings by file)
        const findingsByFile = new Map();
        for (const finding of scanResult.findings) {
            const existing = findingsByFile.get(finding.file) || [];
            existing.push(finding);
            findingsByFile.set(finding.file, existing);
        }
        const scanDurationPerFile = scanResult.duration / Math.max(filesToScan.length, 1);
        for (const file of files) {
            if (filesToScan.includes(file.path)) {
                const fileFindings = findingsByFile.get(file.path) || [];
                await cache.store(file.path, file.hash, scanner, scannerVersion, ruleHash, fileFindings, scanDurationPerFile);
            }
        }
    }
    else {
        // All files were cached, return empty scanner result
        scanResult = {
            scanner,
            findings: [],
            duration: 0,
            success: true,
            version: scannerVersion,
            filesScanned: 0,
        };
    }
    // Combine cached findings with new findings
    const combinedFindings = [...cachedFindings, ...scanResult.findings];
    const result = {
        ...scanResult,
        findings: combinedFindings,
        filesScanned: files.length,
    };
    const totalTime = Date.now() - startTime;
    logger.info("scanner_cache.run_complete", {
        scanner,
        cacheHits,
        cacheMisses,
        savedTimeMs,
        actualTimeMs: totalTime,
        totalFindings: combinedFindings.length,
    });
    return {
        result,
        cacheHits,
        cacheMisses,
        savedTimeMs,
    };
}
/**
 * Create a default scanner cache instance
 */
export function createScannerCache(projectPath, options) {
    return new ScannerCache(projectPath, options);
}
//# sourceMappingURL=cache.js.map