import { execFile } from "node:child_process";
import { existsSync, readdirSync } from "node:fs";
import { rm } from "node:fs/promises";
const DEFAULT_TIMEOUT_MS = 120_000;
export class RepoCloneManager {
    #git;
    #timeoutMs;
    constructor(options = {}) {
        this.#git = options.gitRunner ?? createExecFileGitRunner();
        this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
    }
    async clone(input) {
        const timeoutMs = input.timeoutMs ?? this.#timeoutMs;
        if (directoryHasEntries(input.targetDir)) {
            return { ok: false, errorCode: "clone-target-occupied" };
        }
        const { args, env } = buildCloneInvocation(input);
        let run;
        try {
            run = await this.#git({ args, env, timeoutMs });
        }
        catch (error) {
            await this.#cleanup(input.targetDir);
            return {
                ok: false,
                errorCode: isTimeout(error) ? "clone-timeout" : "clone-failed",
            };
        }
        if (run.code !== 0) {
            await this.#cleanup(input.targetDir);
            return { ok: false, errorCode: classifyCloneFailure(run.stderr) };
        }
        const defaultBranch = input.defaultBranch ??
            (await this.#headBranch(input.targetDir, timeoutMs));
        return {
            ok: true,
            ...(defaultBranch ? { defaultBranch } : {}),
        };
    }
    async locate(input) {
        const timeoutMs = input.timeoutMs ?? this.#timeoutMs;
        let originRun;
        try {
            originRun = await this.#git({
                args: ["-C", input.localPath, "remote", "get-url", "origin"],
                timeoutMs,
            });
        }
        catch {
            return { matched: false, reasonCode: "locate-failed" };
        }
        if (originRun.code !== 0) {
            return { matched: false, reasonCode: "locate-not-a-git-repo" };
        }
        const actual = normalizeRemoteIdentity(originRun.stdout.trim());
        const expected = normalizeRemoteIdentity(input.expectedRemoteUrl);
        if (!actual || actual !== expected) {
            return { matched: false, reasonCode: "locate-remote-mismatch" };
        }
        const defaultBranch = await this.#headBranch(input.localPath, timeoutMs);
        return {
            matched: true,
            ...(defaultBranch ? { defaultBranch } : {}),
        };
    }
    async #headBranch(dir, timeoutMs) {
        try {
            const run = await this.#git({
                args: ["-C", dir, "rev-parse", "--abbrev-ref", "HEAD"],
                timeoutMs,
            });
            if (run.code !== 0)
                return undefined;
            const branch = run.stdout.trim();
            return branch && branch !== "HEAD" ? branch : undefined;
        }
        catch {
            return undefined;
        }
    }
    async #cleanup(dir) {
        try {
            await rm(dir, { recursive: true, force: true });
        }
        catch {
            // Best-effort: a residual partial clone is itself never surfaced.
        }
    }
}
/**
 * Build the git argv + env for a clone. For an authenticated https remote, the
 * token is injected via a one-shot credential helper that reads it from an env
 * var — so it never appears in argv (visible in `ps`) nor in the URL.
 */
function buildCloneInvocation(input) {
    const env = {};
    const configArgs = [];
    const useToken = typeof input.token === "string" &&
        input.token.length > 0 &&
        input.remoteUrl.startsWith("https://");
    if (useToken) {
        env.PATCHTRAIL_FORGE_TOKEN = input.token;
        // A credential helper that supplies a fixed username + the env token. `git`
        // invokes `sh -c '<helper>' -- get`, so the helper prints the password from
        // the environment. The token is therefore never in argv or the URL.
        const helper = '!f() { printf "username=x-access-token\\npassword=%s\\n" "$PATCHTRAIL_FORGE_TOKEN"; }; f';
        configArgs.push("-c", `credential.helper=${helper}`);
        configArgs.push("-c", "credential.useHttpPath=false");
    }
    const args = [
        ...configArgs,
        "clone",
        ...(input.defaultBranch ? ["--branch", input.defaultBranch] : []),
        "--",
        input.remoteUrl,
        input.targetDir,
    ];
    return { args, env };
}
function classifyCloneFailure(stderr) {
    const text = stderr.toLowerCase();
    if (text.includes("authentication failed") ||
        text.includes("could not read username") ||
        text.includes("invalid username or password") ||
        text.includes("403")) {
        return "clone-auth-failed";
    }
    if (text.includes("not found") ||
        text.includes("does not exist") ||
        text.includes("repository not found") ||
        text.includes("could not read from remote")) {
        return "clone-not-found";
    }
    return "clone-failed";
}
/**
 * Canonicalize a git remote URL to `host/owner/repo` (lowercased, `.git` and
 * any embedded userinfo stripped) so two equivalent spellings of the same
 * remote compare equal. Returns "" when the input cannot be parsed.
 */
export function normalizeRemoteIdentity(remoteUrl) {
    const raw = remoteUrl.trim();
    if (!raw)
        return "";
    let host = "";
    let path = "";
    let scheme = "";
    // scp-like syntax: git@github.com:owner/repo(.git)
    const scp = /^[^/@]+@([^:/]+):(.+)$/u.exec(raw);
    if (scp && !raw.includes("://")) {
        host = scp[1] ?? "";
        path = scp[2] ?? "";
    }
    else {
        try {
            const url = new URL(raw);
            scheme = url.protocol;
            host = url.hostname;
            path = url.pathname;
        }
        catch {
            return "";
        }
    }
    const cleanedPath = path.replace(/\.git$/iu, "").replace(/\/+$/u, "");
    // file:// remotes (used in hermetic tests + some local setups) have no host;
    // identify them by their absolute path so two spellings of the same bare
    // repo compare equal.
    if (!host) {
        if (scheme === "file:" && cleanedPath) {
            return `file:${cleanedPath}`.toLowerCase();
        }
        return "";
    }
    const trimmedPath = cleanedPath.replace(/^\/+/u, "");
    if (!trimmedPath)
        return "";
    return `${host}/${trimmedPath}`.toLowerCase();
}
function directoryHasEntries(dir) {
    if (!existsSync(dir))
        return false;
    try {
        return readdirSync(dir).length > 0;
    }
    catch {
        return false;
    }
}
function isTimeout(error) {
    return (error !== null &&
        typeof error === "object" &&
        "killed" in error &&
        error.killed === true);
}
function createExecFileGitRunner() {
    return ({ args, cwd, env, timeoutMs }) => new Promise((resolve, reject) => {
        // A minimal, hermetic git environment: ignore the user's global/system
        // git config and never prompt for a terminal credential.
        const childEnv = {
            PATH: process.env.PATH,
            HOME: process.env.HOME,
            GIT_CONFIG_GLOBAL: "/dev/null",
            GIT_CONFIG_SYSTEM: "/dev/null",
            GIT_TERMINAL_PROMPT: "0",
            GIT_ASKPASS: "",
            ...(env ?? {}),
        };
        execFile("git", args, {
            cwd,
            env: childEnv,
            timeout: timeoutMs ?? DEFAULT_TIMEOUT_MS,
            maxBuffer: 16 * 1024 * 1024,
            windowsHide: true,
        }, (error, stdout, stderr) => {
            if (error && typeof error.code !== "number") {
                // Spawn-level failure (git missing) or timeout kill.
                reject(error);
                return;
            }
            resolve({
                code: typeof error?.code === "number" ? error.code : 0,
                stdout: stdout.toString(),
                stderr: stderr.toString(),
            });
        });
    });
}
