import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { readFileSync, statSync } from "node:fs";
import path from "node:path";
import { runValidationOutcomeToEvidenceValidation, } from "./RunValidationOutcome.js";
const changedFileChanges = new Set([
    "modified",
    "added",
    "removed",
]);
const validationStatuses = new Set([
    "passed",
    "failed",
    "skipped",
]);
const warningSeverities = new Set([
    "info",
    "warning",
]);
const maxPatchBytes = 192 * 1024;
export class RunEvidenceService {
    localStore;
    validationHarvester;
    constructor(localStore, validationHarvester) {
        this.localStore = localStore;
        this.validationHarvester = validationHarvester;
    }
    capture(input) {
        const run = this.localStore.findRun(input.runId);
        if (!run) {
            throw new Error("Run evidence is invalid.");
        }
        const changedFiles = input.changedFiles.map(sanitizeChangedFile);
        const validations = input.validations.map(sanitizeValidation);
        const warnings = input.warnings.map(sanitizeWarning);
        const upsertInput = {
            runId: run.id,
            changedFiles,
            validations,
            warnings,
        };
        if (input.patchFiles) {
            upsertInput.patchFiles = input.patchFiles;
        }
        return this.localStore.upsertRunEvidence(upsertInput);
    }
    captureBackendSubmission(input) {
        const run = this.localStore.findRun(input.runId);
        if (!run) {
            throw new Error("Run evidence is invalid.");
        }
        const workspace = this.localStore.findWorkspace(run.workspaceId);
        if (!workspace || workspace.rootPath.length === 0) {
            throw new Error("Run evidence is invalid.");
        }
        if (!run.sourceWorkItemId ||
            !run.sourceRepositoryId ||
            !run.sourcePreparationId ||
            !run.sourceRunIntentId ||
            !run.branchPlanId ||
            !run.branchName ||
            !run.baseBranch) {
            throw new Error("Run evidence is invalid.");
        }
        const gitEvidence = captureGitEvidence(workspace.rootPath);
        const validationEvidence = this.resolveValidationEvidence(run.id, input);
        const evidence = this.capture({
            runId: run.id,
            changedFiles: gitEvidence.changedFiles,
            validations: validationEvidence.validations,
            warnings: validationEvidence.warnings,
            patchFiles: gitEvidence.patchFiles,
        });
        return {
            workspaceId: run.workspaceId,
            preparationId: run.sourcePreparationId,
            runIntentId: run.sourceRunIntentId,
            bridgeRunId: run.id,
            changedFiles: evidence.changedFiles,
            validations: evidence.validations,
            warnings: evidence.warnings,
            patchFiles: gitEvidence.patchFiles,
        };
    }
    resolveValidationEvidence(runId, input) {
        if (input.validations) {
            return { validations: input.validations, warnings: input.warnings };
        }
        const outcomes = this.validationHarvester?.harvest(runId) ?? [];
        const validations = outcomes
            .map(runValidationOutcomeToEvidenceValidation)
            .filter((validation) => Boolean(validation));
        return {
            validations,
            warnings: appendValidationOutcomeWarnings(input.warnings, outcomes),
        };
    }
}
function appendValidationOutcomeWarnings(warnings, outcomes) {
    const labels = new Set(warnings.map((warning) => warning.label));
    const result = [...warnings];
    const add = (label) => {
        if (labels.has(label))
            return;
        labels.add(label);
        result.push({ label, severity: "warning" });
    };
    if (outcomes.some((outcome) => outcome.verdict === "missing-evidence")) {
        add("runtime-validation-missing-evidence");
    }
    if (outcomes.some((outcome) => outcome.verdict === "timed-out")) {
        add("runtime-validation-timed-out");
    }
    return result;
}
function sanitizeChangedFile(file) {
    if (!isSafeRelativePath(file.path) || !changedFileChanges.has(file.change)) {
        throw new Error("Run evidence is invalid.");
    }
    return {
        path: file.path.trim(),
        change: file.change,
        added: Math.max(0, Math.trunc(file.added)),
        removed: Math.max(0, Math.trunc(file.removed)),
    };
}
function sanitizeValidation(validation) {
    if (!isSafeLabel(validation.label) ||
        !validationStatuses.has(validation.status)) {
        throw new Error("Run evidence is invalid.");
    }
    return {
        label: validation.label.trim(),
        status: validation.status,
        ...(typeof validation.exitCode === "number"
            ? { exitCode: Math.trunc(validation.exitCode) }
            : {}),
    };
}
function sanitizeWarning(warning) {
    if (!isSafeLabel(warning.label) || !warningSeverities.has(warning.severity)) {
        throw new Error("Run evidence is invalid.");
    }
    return {
        label: warning.label.trim(),
        severity: warning.severity,
    };
}
function isSafeLabel(value) {
    const label = value.trim();
    return (label.length > 0 &&
        label.length <= 80 &&
        /^[A-Za-z0-9][A-Za-z0-9:_-]*$/u.test(label));
}
function isSafeRelativePath(value) {
    const path = value.trim();
    return (path.length > 0 &&
        path.length <= 160 &&
        !path.startsWith("/") &&
        !path.includes("\\") &&
        !path.includes("..") &&
        !path.includes("~") &&
        /^[A-Za-z0-9][A-Za-z0-9._/-]*$/u.test(path));
}
function captureGitEvidence(rootPath) {
    const statusLines = runGit(rootPath, [
        "status",
        "--porcelain=v1",
        "--untracked-files=all",
    ])
        .split("\n")
        .map((line) => line.trimEnd())
        .filter(Boolean);
    const changedFiles = [];
    const patchFiles = [];
    for (const line of statusLines) {
        const parsed = parseStatusLine(line);
        if (!parsed)
            continue;
        const stats = readNumstat(rootPath, parsed.path, parsed.change);
        const changedFile = sanitizeChangedFile({
            path: parsed.path,
            change: parsed.change,
            added: stats.added,
            removed: stats.removed,
        });
        changedFiles.push(changedFile);
        patchFiles.push(capturePatchFile(rootPath, changedFile));
    }
    return { changedFiles, patchFiles };
}
function parseStatusLine(line) {
    if (line.length < 4)
        return undefined;
    const status = line.slice(0, 2);
    const rawPath = line.slice(3).replace(/^"|"$/gu, "");
    const pathPart = rawPath.includes(" -> ")
        ? (rawPath.split(" -> ").at(-1) ?? rawPath)
        : rawPath;
    if (!isSafeRelativePath(pathPart))
        return undefined;
    if (status.includes("D"))
        return { path: pathPart, change: "removed" };
    if (status.includes("A") || status.includes("?"))
        return { path: pathPart, change: "added" };
    return { path: pathPart, change: "modified" };
}
function readNumstat(rootPath, relativePath, change) {
    if (change === "added") {
        const content = readSafeFile(rootPath, relativePath);
        return { added: content.toString("utf8").split("\n").length, removed: 0 };
    }
    const output = runGit(rootPath, [
        "diff",
        "--numstat",
        "HEAD",
        "--",
        relativePath,
    ]);
    const first = output.split("\n").find(Boolean);
    if (!first)
        return { added: 0, removed: change === "removed" ? 1 : 0 };
    const [added, removed] = first.split(/\s+/u);
    return {
        added: Number.parseInt(added ?? "0", 10) || 0,
        removed: Number.parseInt(removed ?? "0", 10) || 0,
    };
}
function capturePatchFile(rootPath, file) {
    if (file.change === "removed") {
        return {
            path: file.path,
            change: file.change,
            contentBase64: null,
            contentSha256: null,
        };
    }
    const content = readSafeFile(rootPath, file.path);
    return {
        path: file.path,
        change: file.change,
        contentBase64: content.toString("base64"),
        contentSha256: createHash("sha256").update(content).digest("hex"),
    };
}
function readSafeFile(rootPath, relativePath) {
    const absolute = path.resolve(rootPath, relativePath);
    const resolvedRoot = path.resolve(rootPath);
    if (!absolute.startsWith(`${resolvedRoot}${path.sep}`)) {
        throw new Error("Run evidence is invalid.");
    }
    const stat = statSync(absolute);
    if (!stat.isFile() || stat.size > maxPatchBytes) {
        throw new Error("Run evidence is invalid.");
    }
    const content = readFileSync(absolute);
    if (content.includes(0) || looksSecret(content)) {
        throw new Error("Run evidence is invalid.");
    }
    return content;
}
function runGit(rootPath, args) {
    return execFileSync("git", ["-C", rootPath, ...args], {
        encoding: "utf8",
        maxBuffer: 512 * 1024,
        stdio: ["ignore", "pipe", "ignore"],
    });
}
function looksSecret(content) {
    const text = content.toString("utf8");
    return (text.includes("BEGIN PRIVATE KEY") ||
        text.includes("ghp_") ||
        text.includes("github_pat_") ||
        text.includes("sk-"));
}
