/**
 * pipeline/cache.ts
 *
 * Content-aware caching for pipeline steps. Each step's cache key is derived
 * from a hash of its inputs (config files, context files, task files, etc.).
 * When inputs haven't changed between runs, cached outputs are reused.
 *
 * Cache storage: `results/cache/` directory with one JSON manifest per step.
 * Each manifest maps a content hash to the step's output metadata.
 *
 * Cache invalidation triggers:
 *   - Content change: any input file's content changes → hash changes → miss
 *   - Config change: config/models, config/sources, tasks/*.yaml changes → miss
 *   - Manual bypass: --no-cache flag skips all cache lookups
 *   - Cache clear: delete results/cache/ to start fresh
 */
import { createHash } from "crypto";
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync, } from "fs";
import { join, resolve } from "path";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Resolve first existing config file (matches loadConfigFile priority chain) */
function resolveConfig(rootDir, name) {
    const r = (f) => resolve(rootDir, f);
    for (const ext of [".ts", ".js", ".yaml", ".yml", ".json"]) {
        const p = r(`config/${name}${ext}`);
        if (existsSync(p))
            return p;
    }
    return undefined;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CACHE_DIR_NAME = "cache";
const CACHE_VERSION = 1;
// ---------------------------------------------------------------------------
// Hashing
// ---------------------------------------------------------------------------
/** Create an empty CacheStats object */
export function createCacheStats() {
    return {
        hits: 0,
        misses: 0,
        skipped: 0,
        steps: {},
        total: 0,
    };
}
/** Format cache stats as a human-readable summary line */
export function formatCacheStats(stats) {
    const { hits, misses, skipped, total } = stats;
    const evaluated = misses;
    const parts = [];
    if (hits > 0)
        parts.push(`${hits} cached`);
    if (evaluated > 0)
        parts.push(`${evaluated} evaluated`);
    if (skipped > 0)
        parts.push(`${skipped} skipped`);
    return `${parts.join(", ")} (${total} total steps)`;
}
// ---------------------------------------------------------------------------
// Cache store
// ---------------------------------------------------------------------------
/**
 * Collect all file paths that serve as inputs for a given pipeline step.
 * This is the core of cache key computation — if any of these files change,
 * the step must re-execute.
 */
export function getStepInputPaths(rootDir, step) {
    const r = (rel) => resolve(rootDir, rel);
    switch (step) {
        case "calculate-scores": {
            // Inputs: eval results file + scoring logic (the script itself)
            return [
                r("results/latest/eval-results.json"),
                r("results/latest/eval-results-agentic.json"),
                r("results/latest/eval-results-observed.json"),
            ].filter((p) => existsSync(p));
        }
        case "eval":
        case "eval-baseline":
        case "eval-agentic":
        case "eval-observed": {
            // Per-mode cache keys: eval-baseline and eval-agentic include only
            // their own config + expanded YAML so changes to one mode's inputs
            // don't invalidate the other's cache entry.
            //
            // The legacy "eval" key includes all configs for backward compat.
            const isBaseline = step === "eval-baseline" || step === "eval";
            const isAgentic = step === "eval-agentic" || step === "eval";
            const isObserved = step === "eval-observed" || step === "eval";
            const paths = [];
            const modelsPath = resolveConfig(rootDir, "models");
            if (modelsPath)
                paths.push(modelsPath);
            // Config files — only the relevant ones for this mode
            if (isBaseline) {
                paths.push(r("promptfooconfig.yaml"));
                paths.push(r("tasks/.expanded.yaml"));
            }
            if (isAgentic) {
                paths.push(r("promptfooconfig.agentic.yaml"));
                paths.push(r("tasks/.expanded.agentic.yaml"));
            }
            if (isObserved) {
                paths.push(r("promptfooconfig.observed.yaml"));
            }
            // Context files (shared across modes)
            const contextsDir = r("contexts");
            if (existsSync(contextsDir)) {
                const contextFiles = readdirSync(contextsDir)
                    .filter((f) => f.endsWith(".md"))
                    .map((f) => join(contextsDir, f));
                paths.push(...contextFiles);
                // Canonical contexts
                const canonicalDir = join(contextsDir, "canonical");
                if (existsSync(canonicalDir)) {
                    const canonicalFiles = readdirSync(canonicalDir)
                        .filter((f) => f.endsWith(".md"))
                        .map((f) => join(canonicalDir, f));
                    paths.push(...canonicalFiles);
                }
            }
            // Task files (contain assertions and test definitions).
            // Task files live in tasks/{mode}/ subdirectories (e.g., tasks/literacy/)
            const tasksDir = r("tasks");
            if (existsSync(tasksDir)) {
                for (const entry of readdirSync(tasksDir)) {
                    const subDir = join(tasksDir, entry);
                    if (statSync(subDir).isDirectory()) {
                        const taskFiles = readdirSync(subDir)
                            .filter((f) => /\.(task\.ts|task\.js)$/.test(f))
                            .map((f) => join(subDir, f));
                        paths.push(...taskFiles);
                    }
                }
            }
            // Reference solutions (used by grader assertions)
            const refDir = r("canonical/reference-solutions");
            if (existsSync(refDir)) {
                const refFiles = readdirSync(refDir)
                    .map((f) => join(refDir, f))
                    .filter((f) => statSync(f).isFile());
                paths.push(...refFiles);
            }
            return paths;
        }
        case "fetch-docs": {
            // Inputs: config sources + models, task files
            const paths = [];
            const sourcesPath = resolveConfig(rootDir, "sources");
            const modelsPath2 = resolveConfig(rootDir, "models");
            if (sourcesPath)
                paths.push(sourcesPath);
            if (modelsPath2)
                paths.push(modelsPath2);
            // Include all task files (they define feature areas)
            // Task files live in tasks/{mode}/ subdirectories (e.g., tasks/literacy/)
            const tasksDir = r("tasks");
            if (existsSync(tasksDir)) {
                for (const entry of readdirSync(tasksDir)) {
                    const subDir = join(tasksDir, entry);
                    if (statSync(subDir).isDirectory()) {
                        const taskFiles = readdirSync(subDir)
                            .filter((f) => /\.(task\.ts|task\.js)$/.test(f))
                            .map((f) => join(subDir, f));
                        paths.push(...taskFiles);
                    }
                }
            }
            return paths;
        }
        case "generate-configs": {
            // Inputs: config models + sources, all task files
            const paths = [];
            const modelsPath3 = resolveConfig(rootDir, "models");
            const sourcesPath2 = resolveConfig(rootDir, "sources");
            if (modelsPath3)
                paths.push(modelsPath3);
            if (sourcesPath2)
                paths.push(sourcesPath2);
            const tasksDir = r("tasks");
            if (existsSync(tasksDir)) {
                for (const entry of readdirSync(tasksDir)) {
                    const subDir = join(tasksDir, entry);
                    if (statSync(subDir).isDirectory()) {
                        const taskFiles = readdirSync(subDir)
                            .filter((f) => /\.(task\.ts|task\.js)$/.test(f))
                            .map((f) => join(subDir, f));
                        paths.push(...taskFiles);
                    }
                }
            }
            return paths;
        }
        case "report": {
            // Inputs: score summary
            return [r("results/latest/score-summary.json")];
        }
        default:
            return [];
    }
}
/**
 * Compute a SHA-256 hash of the concatenated content of multiple files.
 * Files are sorted by path for deterministic ordering. Missing files
 * contribute a sentinel value so the hash changes if a file is deleted.
 *
 * Optional `context` strings are included in the hash so that non-file
 * state (e.g., filter flags, environment variables) can also participate
 * in cache key computation.
 */
export function hashFiles(paths, context) {
    const hash = createHash("sha256");
    // Include cache version so format changes invalidate everything
    hash.update(`cache-version:${CACHE_VERSION}\n`);
    // Include non-file context (filter flags, env vars, etc.)
    if (context && context.length > 0) {
        for (const c of context) {
            hash.update(`context:${c}\n`);
        }
    }
    const sorted = [...paths].sort();
    for (const filePath of sorted) {
        hash.update(`path:${filePath}\n`);
        if (existsSync(filePath)) {
            const content = readFileSync(filePath);
            hash.update(content);
        }
        else {
            hash.update("__missing__\n");
        }
        hash.update("\n---\n");
    }
    return hash.digest("hex");
}
/**
 * Look up the cache for a pipeline step.
 *
 * Computes the current input hash and compares it against the stored manifest.
 * Returns a hit if the hashes match AND all expected output files still exist.
 *
 * Optional `context` strings are included in the hash so that non-file
 * state (e.g., area/task filter flags) participates in cache key computation.
 */
export function lookupCache(rootDir, step, context) {
    const inputPaths = getStepInputPaths(rootDir, step);
    const currentHash = hashFiles(inputPaths, context);
    const manifest = readManifest(rootDir, step);
    if (!manifest?.entry) {
        return { currentHash, hit: false };
    }
    // Hash must match
    if (manifest.entry.inputHash !== currentHash) {
        return { currentHash, hit: false };
    }
    // All output files must still exist (guard against manual deletion)
    const outputsExist = manifest.entry.outputPaths.every((p) => existsSync(resolve(rootDir, p)));
    if (!outputsExist) {
        return { currentHash, hit: false };
    }
    return { entry: manifest.entry, hit: true };
}
/**
 * Read the cache manifest for a step.
 * Returns null if no manifest exists or it's corrupt.
 */
export function readManifest(rootDir, step) {
    const manifestPath = getManifestPath(rootDir, step);
    if (!existsSync(manifestPath))
        return null;
    try {
        const raw = readFileSync(manifestPath, "utf-8");
        const parsed = JSON.parse(raw);
        if (parsed.step !== step)
            return null;
        return parsed;
    }
    catch {
        return null;
    }
}
// ---------------------------------------------------------------------------
// Cache operations
// ---------------------------------------------------------------------------
/**
 * Record a cache entry after a successful step execution.
 */
export function recordCache(rootDir, step, inputHash, summary, durationMs, outputPaths) {
    const entry = {
        durationMs,
        inputHash,
        outputPaths,
        summary,
        timestamp: new Date().toISOString(),
    };
    writeManifest(rootDir, step, entry);
}
/**
 * Write a cache manifest for a step.
 * Creates the cache directory if it doesn't exist.
 */
export function writeManifest(rootDir, step, entry) {
    const cacheDir = getCacheDir(rootDir);
    if (!existsSync(cacheDir)) {
        mkdirSync(cacheDir, { recursive: true });
    }
    const manifest = { entry, step };
    const manifestPath = getManifestPath(rootDir, step);
    writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
}
// ---------------------------------------------------------------------------
// Cache stats
// ---------------------------------------------------------------------------
/** Get the cache directory path */
function getCacheDir(rootDir) {
    return resolve(rootDir, "results", CACHE_DIR_NAME);
}
/** Get the manifest file path for a step */
function getManifestPath(rootDir, step) {
    return join(getCacheDir(rootDir), `${step}.json`);
}
