import { randomUUID } from "node:crypto";
import { existsSync, readFileSync, statSync } from "node:fs";
import { resolve } from "node:path";
import { createDefaultCoreDeps } from "../runtime/deps.js";
const GIT_TIMEOUT_MS = 30_000;
const GIT_MAX_CAPTURE_BYTES = 2 * 1024 * 1024;
const MAX_UNTRACKED_TEXT_BYTES = 512 * 1024;
const WORKING_TREE_SCOPE_LABEL = "current working tree";
export class DiffReviewSnapshot {
    id;
    repoRoot;
    cwd;
    diffArgs;
    patch;
    files;
    scopeLabel;
    patchByPath;
    constructor(args) {
        this.id = args.id ?? `tau-diff-${randomUUID()}`;
        this.repoRoot = args.repoRoot;
        this.cwd = args.cwd;
        this.diffArgs = [...args.diffArgs];
        this.patch = args.patch;
        this.files = args.files.map((file) => ({ ...file }));
        this.scopeLabel = args.scopeLabel;
        this.patchByPath = new Map(args.patchByPath);
    }
    getFilePatch(path) {
        if (path.length === 0) {
            return undefined;
        }
        return this.patchByPath.get(path);
    }
    toDiffCommand() {
        return this.scopeLabel;
    }
}
export function formatDiffReviewScope(diffArgs) {
    return diffArgs.length > 0 ? `git diff ${diffArgs.join(" ")}` : WORKING_TREE_SCOPE_LABEL;
}
export async function captureDiffReviewSnapshot(options) {
    const deps = createDiffSnapshotDeps(options.deps);
    const cwd = resolve(options.cwd);
    const signal = options.signal;
    const repoRoot = await resolveGitRepoRoot(cwd, deps, signal);
    const source = options.source;
    const captured = source.kind === "patch_files"
        ? await capturePatchFilesSnapshot(cwd, source.patchFiles, deps, signal)
        : source.diffArgs.length > 0
            ? await captureExplicitDiffSnapshot(cwd, source.diffArgs, deps, signal)
            : await captureWorkingTreeSnapshot(repoRoot, deps, signal);
    const diffArgs = source.kind === "git_diff" ? [...source.diffArgs] : [];
    return new DiffReviewSnapshot({
        repoRoot,
        cwd,
        diffArgs,
        patch: captured.patch,
        files: captured.files,
        patchByPath: captured.patchByPath,
        scopeLabel: source.kind === "patch_files" ? source.scopeLabel : formatDiffReviewScope(source.diffArgs),
    });
}
function throwIfSnapshotAborted(signal) {
    if (signal?.aborted) {
        throw new Error("diff review start aborted");
    }
}
function createDiffSnapshotDeps(deps) {
    const defaults = createDefaultCoreDeps();
    return {
        spawn: deps?.spawn ?? defaults.spawn,
        env: deps?.env ?? defaults.env,
        fs: deps?.fs ?? defaults.fs,
    };
}
async function resolveGitRepoRoot(cwd, deps, signal) {
    const output = await runGitCommand(cwd, ["rev-parse", "--show-toplevel"], deps, {
        invalidRepoMessage: "diff review requires a git repository",
    }, signal);
    const repoRoot = output.trim();
    if (!repoRoot) {
        throw new Error("failed to resolve git repository root");
    }
    return resolve(repoRoot);
}
async function capturePatchFilesSnapshot(cwd, patchFiles, deps, signal) {
    const sections = [];
    let totalBytes = 0;
    for (const patchFile of patchFiles) {
        throwIfSnapshotAborted(signal);
        const patchPath = resolve(cwd, patchFile);
        const patchBytes = statSync(patchPath).size;
        if (totalBytes + patchBytes > GIT_MAX_CAPTURE_BYTES) {
            throw new Error(`patch files exceeded ${GIT_MAX_CAPTURE_BYTES} bytes while capturing diff review snapshot`);
        }
        const patch = deps.fs.readFile(patchPath);
        totalBytes += Buffer.byteLength(patch, "utf-8");
        if (totalBytes > GIT_MAX_CAPTURE_BYTES) {
            throw new Error(`patch files exceeded ${GIT_MAX_CAPTURE_BYTES} bytes while capturing diff review snapshot`);
        }
        const patchSections = splitPatchSections(patch);
        if (patchSections.length === 0 && patch.trim().length > 0) {
            throw new Error(`patch file ${patchFile} does not contain git unified diff sections`);
        }
        sections.push(...patchSections);
    }
    const files = sections.map((section) => parsePatchSectionFile(section));
    const patchByPath = pairFilePatches(files, sections);
    return {
        patch: joinPatchSections(sections),
        files: dedupeFilesByPath(files),
        patchByPath,
    };
}
async function captureExplicitDiffSnapshot(cwd, diffArgs, deps, signal) {
    const patch = await runGitCommand(cwd, ["diff", ...diffArgs], deps, {}, signal);
    const files = parseNameStatusOutput(await runGitCommand(cwd, ["diff", "--name-status", "-z", ...diffArgs], deps, {}, signal));
    const patchSections = splitPatchSections(await runGitCommand(cwd, ["diff", ...withForcedPatch(diffArgs)], deps, {}, signal));
    return {
        patch,
        files,
        patchByPath: pairFilePatches(files, patchSections),
    };
}
async function captureWorkingTreeSnapshot(repoRoot, deps, signal) {
    const hasHead = await gitRefExists(repoRoot, "HEAD", deps, signal);
    const tracked = hasHead
        ? await captureTrackedWorkingTreeSnapshot(repoRoot, deps, signal)
        : await captureTrackedWorkingTreeSnapshotWithoutHead(repoRoot, deps, signal);
    const untracked = await captureUntrackedFilePatches(repoRoot, deps, signal);
    const patchByPath = new Map(tracked.patchByPath);
    for (const [path, patch] of untracked.patchByPath) {
        patchByPath.set(path, patch);
    }
    return sortCapturedSnapshotDataByPath({
        patch: joinPatchSections([tracked.patch, ...untracked.patches]),
        files: dedupeFilesByPath([...tracked.files, ...untracked.files]),
        patchByPath,
    });
}
async function captureTrackedWorkingTreeSnapshot(repoRoot, deps, signal) {
    const patch = await runGitCommand(repoRoot, ["diff", "HEAD"], deps, {}, signal);
    const files = parseNameStatusOutput(await runGitCommand(repoRoot, ["diff", "--name-status", "-z", "HEAD"], deps, {}, signal));
    const patchSections = splitPatchSections(await runGitCommand(repoRoot, ["diff", "HEAD", "--patch"], deps, {}, signal));
    return {
        patch,
        files,
        patchByPath: pairFilePatches(files, patchSections),
    };
}
async function captureTrackedWorkingTreeSnapshotWithoutHead(repoRoot, deps, signal) {
    const unstagedPatch = await runGitCommand(repoRoot, ["diff"], deps, {}, signal);
    const stagedPatch = await runGitCommand(repoRoot, ["diff", "--cached", "--root"], deps, {}, signal);
    const unstagedFiles = parseNameStatusOutput(await runGitCommand(repoRoot, ["diff", "--name-status", "-z"], deps, {}, signal));
    const stagedFiles = parseNameStatusOutput(await runGitCommand(repoRoot, ["diff", "--cached", "--name-status", "-z", "--root"], deps, {}, signal));
    const patch = joinPatchSections([unstagedPatch, stagedPatch]);
    const files = dedupeFilesByPath([...unstagedFiles, ...stagedFiles]);
    return {
        patch,
        files,
        patchByPath: pairFilePatches(files, splitPatchSections(patch)),
    };
}
async function captureUntrackedFilePatches(repoRoot, deps, signal) {
    const paths = parseNullSeparatedPaths(await runGitCommand(repoRoot, ["ls-files", "--others", "--exclude-standard", "-z"], deps, {}, signal));
    const files = [];
    const patches = [];
    const patchByPath = new Map();
    for (const path of paths) {
        const filePath = resolve(repoRoot, path);
        if (!existsSync(filePath)) {
            continue;
        }
        const content = readFileSync(filePath);
        if (content.length > MAX_UNTRACKED_TEXT_BYTES || content.includes(0)) {
            continue;
        }
        const patch = createUntrackedFilePatch(path, content.toString("utf-8"));
        files.push({
            path,
            status: "added",
            newPath: path,
        });
        patches.push(patch);
        patchByPath.set(path, patch);
    }
    return {
        files,
        patches,
        patchByPath,
    };
}
function createUntrackedFilePatch(path, content) {
    const lines = splitPatchContentLines(content);
    const patch = [
        `diff --git ${formatPatchPath(`a/${path}`)} ${formatPatchPath(`b/${path}`)}`,
        "new file mode 100644",
        "--- /dev/null",
        `+++ ${formatPatchPath(`b/${path}`)}`,
    ];
    if (lines.length === 0) {
        return patch.join("\n");
    }
    patch.push(`@@ -0,0 +1,${lines.length} @@`);
    patch.push(...lines.map((line) => `+${line}`));
    if (!content.endsWith("\n")) {
        patch.push("\\ No newline at end of file");
    }
    return patch.join("\n");
}
function splitPatchContentLines(content) {
    if (content.length === 0) {
        return [];
    }
    const normalized = content.replaceAll("\r\n", "\n");
    const withoutTrailingNewline = normalized.endsWith("\n") ? normalized.slice(0, -1) : normalized;
    if (withoutTrailingNewline.length === 0) {
        return [];
    }
    return withoutTrailingNewline.split("\n");
}
function formatPatchPath(path) {
    return pathNeedsQuoting(path) ? JSON.stringify(path) : path;
}
function pathNeedsQuoting(path) {
    for (let index = 0; index < path.length; index += 1) {
        const code = path.charCodeAt(index);
        if (code <= 0x20 || code === 0x22 || code === 0x5c || code === 0x7f) {
            return true;
        }
    }
    return false;
}
function joinPatchSections(sections) {
    return sections.filter((section) => section.trim().length > 0).join("\n");
}
function sortCapturedSnapshotDataByPath(data) {
    const files = [...data.files].sort((left, right) => left.path.localeCompare(right.path));
    const orderedPatches = [];
    const patchByPath = new Map();
    for (const file of files) {
        const patch = data.patchByPath.get(file.path) ??
            (file.oldPath ? data.patchByPath.get(file.oldPath) : undefined) ??
            (file.newPath ? data.patchByPath.get(file.newPath) : undefined);
        if (!patch) {
            continue;
        }
        orderedPatches.push(patch);
        patchByPath.set(file.path, patch);
        if (file.oldPath) {
            patchByPath.set(file.oldPath, patch);
        }
        if (file.newPath) {
            patchByPath.set(file.newPath, patch);
        }
    }
    return {
        patch: joinPatchSections(orderedPatches),
        files,
        patchByPath,
    };
}
function dedupeFilesByPath(files) {
    const byPath = new Map();
    for (const file of files) {
        if (!byPath.has(file.path)) {
            byPath.set(file.path, file);
        }
    }
    return [...byPath.values()];
}
function parseNullSeparatedPaths(output) {
    if (!output) {
        return [];
    }
    const tokens = output.split("\0");
    if (tokens[tokens.length - 1] === "") {
        tokens.pop();
    }
    return tokens.filter((value) => value.length > 0);
}
async function gitRefExists(cwd, ref, deps, signal) {
    const result = await deps.spawn("git", ["rev-parse", "--verify", ref], {
        cwd,
        env: buildGitEnv(deps),
        timeoutMs: GIT_TIMEOUT_MS,
        signal,
        captureOutput: "combined",
        maxCaptureBytes: GIT_MAX_CAPTURE_BYTES,
        maxCaptureMode: "ignore",
    });
    if (result.captureLimitExceeded) {
        throw new Error(`git rev-parse --verify ${ref} output exceeded ${GIT_MAX_CAPTURE_BYTES} bytes while capturing diff review snapshot`);
    }
    if (result.exitCode === 0) {
        return true;
    }
    if (result.aborted) {
        throw new Error("diff review start aborted");
    }
    const message = (result.output ?? "").trim();
    if (/not a git repository/i.test(message)) {
        throw new Error("diff review requires a git repository");
    }
    return false;
}
async function runGitCommand(cwd, args, deps, options = {}, signal) {
    const result = await deps.spawn("git", args, {
        cwd,
        env: buildGitEnv(deps),
        timeoutMs: GIT_TIMEOUT_MS,
        signal,
        captureOutput: "combined",
        maxCaptureBytes: GIT_MAX_CAPTURE_BYTES,
        maxCaptureMode: "ignore",
    });
    if (result.captureLimitExceeded) {
        throw new Error(`git ${args.join(" ")} output exceeded ${GIT_MAX_CAPTURE_BYTES} bytes while capturing diff review snapshot`);
    }
    if (result.exitCode === 0) {
        return result.output ?? "";
    }
    if (result.aborted) {
        throw new Error("diff review start aborted");
    }
    const message = (result.output ?? "").trim();
    if (options.invalidRepoMessage && /not a git repository/i.test(message)) {
        throw new Error(options.invalidRepoMessage);
    }
    throw new Error(message || `git ${args.join(" ")} failed`);
}
function buildGitEnv(deps) {
    return {
        ...deps.env.env(),
        GIT_TERMINAL_PROMPT: "0",
        GIT_EDITOR: "true",
        GIT_SEQUENCE_EDITOR: "true",
        GIT_PAGER: "cat",
        GIT_ASKPASS: "true",
        GIT_SSH_COMMAND: "ssh -o BatchMode=yes",
    };
}
function withForcedPatch(diffArgs) {
    const separatorIndex = diffArgs.indexOf("--");
    if (separatorIndex < 0) {
        return [...diffArgs, "--patch"];
    }
    return [...diffArgs.slice(0, separatorIndex), "--patch", ...diffArgs.slice(separatorIndex)];
}
function parseNameStatusOutput(output) {
    if (!output) {
        return [];
    }
    const tokens = output.split("\0");
    if (tokens[tokens.length - 1] === "") {
        tokens.pop();
    }
    const files = [];
    let index = 0;
    while (index < tokens.length) {
        const statusToken = tokens[index++]?.trim();
        if (!statusToken) {
            continue;
        }
        const statusCode = statusToken[0] ?? "";
        if (statusCode === "R" || statusCode === "C") {
            const oldPath = tokens[index++];
            const newPath = tokens[index++];
            if (!oldPath || !newPath) {
                break;
            }
            files.push({
                path: newPath,
                status: mapNameStatus(statusCode),
                oldPath,
                newPath,
            });
            continue;
        }
        const path = tokens[index++];
        if (!path) {
            break;
        }
        files.push({
            path,
            status: mapNameStatus(statusCode),
            ...(statusCode === "D" ? { oldPath: path } : { newPath: path }),
        });
    }
    return files;
}
function mapNameStatus(statusCode) {
    switch (statusCode) {
        case "A":
            return "added";
        case "M":
            return "modified";
        case "D":
            return "deleted";
        case "R":
            return "renamed";
        case "C":
            return "copied";
        case "T":
            return "type-changed";
        case "U":
            return "unmerged";
        default:
            return "unknown";
    }
}
function splitPatchSections(patch) {
    if (!patch.trim()) {
        return [];
    }
    const matches = [...patch.matchAll(/^diff --git .*$/gm)];
    if (matches.length === 0) {
        return [];
    }
    const sections = [];
    for (let index = 0; index < matches.length; index += 1) {
        const start = matches[index]?.index;
        if (start === undefined) {
            continue;
        }
        const end = matches[index + 1]?.index ?? patch.length;
        sections.push(patch.slice(start, end));
    }
    return sections;
}
function pairFilePatches(files, patchSections) {
    const byPath = new Map();
    if (files.length === 0 || patchSections.length === 0) {
        return byPath;
    }
    if (files.length === patchSections.length) {
        for (let index = 0; index < files.length; index += 1) {
            const file = files[index];
            const patch = patchSections[index];
            if (!file || patch === undefined) {
                continue;
            }
            addFilePatchByPath(byPath, file, patch);
        }
        return byPath;
    }
    for (const patch of patchSections) {
        const paths = extractPatchPaths(patch);
        if (!paths) {
            continue;
        }
        const file = files.find((entry) => {
            if (entry.path === paths.newPath || entry.path === paths.oldPath) {
                return true;
            }
            return entry.oldPath === paths.oldPath || entry.newPath === paths.newPath;
        });
        if (!file) {
            continue;
        }
        addFilePatchByPath(byPath, file, patch);
    }
    return byPath;
}
function addFilePatchByPath(byPath, file, patch) {
    const keys = new Set([file.path, file.oldPath, file.newPath].filter((path) => Boolean(path)));
    for (const key of keys) {
        const existing = byPath.get(key);
        byPath.set(key, existing ? joinPatchSections([existing, patch]) : patch);
    }
}
function parsePatchSectionFile(patch) {
    const paths = extractPatchPaths(patch);
    if (!paths) {
        throw new Error("patch section is missing a valid diff --git header");
    }
    const lines = patch.split(/\r?\n/);
    const isAdded = lines.some((line) => line === "--- /dev/null");
    const isDeleted = lines.some((line) => line === "+++ /dev/null");
    const isRenamed = lines.some((line) => line.startsWith("rename from "));
    const isCopied = lines.some((line) => line.startsWith("copy from "));
    if (isAdded) {
        return { path: paths.newPath, status: "added", newPath: paths.newPath };
    }
    if (isDeleted) {
        return { path: paths.oldPath, status: "deleted", oldPath: paths.oldPath };
    }
    if (isRenamed) {
        return {
            path: paths.newPath,
            status: "renamed",
            oldPath: paths.oldPath,
            newPath: paths.newPath,
        };
    }
    if (isCopied) {
        return {
            path: paths.newPath,
            status: "copied",
            oldPath: paths.oldPath,
            newPath: paths.newPath,
        };
    }
    return {
        path: paths.newPath,
        status: "modified",
        oldPath: paths.oldPath,
        newPath: paths.newPath,
    };
}
function extractPatchPaths(patch) {
    const firstLine = patch.split(/\r?\n/, 1)[0]?.trim();
    if (!firstLine?.startsWith("diff --git ")) {
        return undefined;
    }
    const raw = firstLine.slice("diff --git ".length);
    const match = /^a\/(.+) b\/(.+)$/.exec(raw);
    if (match) {
        return {
            oldPath: match[1] ?? "",
            newPath: match[2] ?? "",
        };
    }
    const oldPath = readGitDiffHeaderPath(raw, 0, "a/");
    if (!oldPath) {
        return undefined;
    }
    let index = oldPath.nextIndex;
    while (index < raw.length && raw[index] === " ") {
        index += 1;
    }
    const newPath = readGitDiffHeaderPath(raw, index, "b/");
    if (!newPath || newPath.nextIndex !== raw.length) {
        return undefined;
    }
    return {
        oldPath: oldPath.path,
        newPath: newPath.path,
    };
}
function readGitDiffHeaderPath(input, startIndex, prefix) {
    if (input[startIndex] !== '"') {
        return undefined;
    }
    let path = "";
    let index = startIndex + 1;
    while (index < input.length) {
        const char = input[index];
        if (char === '"') {
            if (!path.startsWith(prefix)) {
                return undefined;
            }
            return {
                path: path.slice(prefix.length),
                nextIndex: index + 1,
            };
        }
        if (char !== "\\") {
            path += char;
            index += 1;
            continue;
        }
        const next = input[index + 1];
        if (next === undefined) {
            return undefined;
        }
        const octal = input.slice(index + 1, index + 4);
        if (/^[0-7]{3}$/.test(octal)) {
            path += String.fromCharCode(Number.parseInt(octal, 8));
            index += 4;
            continue;
        }
        path += decodeGitQuotedEscape(next);
        index += 2;
    }
    return undefined;
}
function decodeGitQuotedEscape(char) {
    switch (char) {
        case "a":
            return "\u0007";
        case "b":
            return "\b";
        case "f":
            return "\f";
        case "n":
            return "\n";
        case "r":
            return "\r";
        case "t":
            return "\t";
        case "v":
            return "\v";
        case "\\":
            return "\\";
        case '"':
            return '"';
        default:
            return char;
    }
}
//# sourceMappingURL=snapshot.js.map