import axios from "axios";
import { spawn as nodeSpawn } from "node:child_process";
import path from "node:path";
// Default logger silences `debug` so the startup self-update check
// doesn't flood stdout with transient network-failure stack traces.
// The check is invoked from `runTestsHandler` in src/cli.ts (the
// default `doc-detective` flow); `install …` subcommands deliberately
// bypass it so explicit maintenance actions don't trigger surprise
// upgrades. When the check IS invoked, the CLI wires up a real logger
// that maps to `core/utils.ts:log()` with the user's configured
// logLevel; this in-module fallback is only for direct test or
// programmatic use that calls `checkForUpdate` / `selfUpdate` without
// passing `deps.logger`.
const RUNTIME_DEBUG = process.env.DOC_DETECTIVE_RUNTIME_DEBUG === "1";
const defaultLogger = (msg, level = "info") => {
    if (level === "debug" && !RUNTIME_DEBUG)
        return;
    if (level === "error")
        console.error(msg);
    else
        console.log(msg);
};
const REGISTRY_URL = "https://registry.npmjs.org/doc-detective";
const REGISTRY_TIMEOUT_MS = 3_000;
/**
 * Compare two semver strings (lenient — handles `X.Y.Z`; prerelease tags
 * are compared using semver semantics: dot-split identifiers, numeric
 * vs alphanumeric ranking, shorter set wins on a tie — see
 * `comparePrerelease()` below). Returns:
 *   <0  if a < b
 *   0   if a === b
 *   >0  if a > b
 *
 * We avoid pulling in the full `semver` dep for this single use site.
 */
export function compareVersions(a, b) {
    const [coreA, preA = ""] = a.split("-");
    const [coreB, preB = ""] = b.split("-");
    const partsA = coreA.split(".").map((n) => parseInt(n, 10) || 0);
    const partsB = coreB.split(".").map((n) => parseInt(n, 10) || 0);
    const len = Math.max(partsA.length, partsB.length);
    for (let i = 0; i < len; i++) {
        const x = partsA[i] ?? 0;
        const y = partsB[i] ?? 0;
        if (x !== y)
            return x - y;
    }
    // A release version is "greater than" any prerelease of the same core.
    if (preA && !preB)
        return -1;
    if (!preA && preB)
        return 1;
    return comparePrerelease(preA, preB);
}
/**
 * Compare two prerelease tags using semver semantics (npm-compatible):
 * dot-split each into identifiers, compare element-wise. Numeric
 * identifiers compare numerically; alphanumerics compare lexically;
 * numerics rank lower than alphanumerics; a shorter set wins when all
 * shared identifiers match.
 *
 * Plain string ordering would mis-rank `next.10` vs `next.2`
 * (lexical: "10" < "2") — that's the bug this replaces.
 */
function comparePrerelease(a, b) {
    if (a === b)
        return 0;
    const partsA = a.split(".");
    const partsB = b.split(".");
    const len = Math.min(partsA.length, partsB.length);
    for (let i = 0; i < len; i++) {
        const cmp = comparePrereleaseId(partsA[i], partsB[i]);
        if (cmp !== 0)
            return cmp;
    }
    // All shared identifiers match — the shorter prerelease ranks lower
    // (e.g., `4.5.0-next` < `4.5.0-next.1`).
    return partsA.length - partsB.length;
}
function comparePrereleaseId(a, b) {
    const numA = /^\d+$/.test(a) ? Number(a) : null;
    const numB = /^\d+$/.test(b) ? Number(b) : null;
    if (numA !== null && numB !== null) {
        return numA - numB;
    }
    if (numA !== null)
        return -1; // numeric < alphanumeric per semver
    if (numB !== null)
        return 1;
    if (a < b)
        return -1;
    if (a > b)
        return 1;
    return 0;
}
/**
 * Hit the npm registry for the dist-tag.latest of `doc-detective`. Bounded
 * by a 3 s timeout. Any failure — network, parse, semver, anything —
 * yields { newer: false } and a debug-level log; we never let a registry
 * hiccup block the run.
 */
export async function checkForUpdate(currentVersion, deps = {}) {
    const logger = deps.logger ?? defaultLogger;
    const http = deps.http ?? {
        get: (url, opts) => axios.get(url, opts),
    };
    try {
        const res = await http.get(REGISTRY_URL, { timeout: REGISTRY_TIMEOUT_MS });
        const latest = res?.data?.["dist-tags"]?.latest;
        if (typeof latest !== "string" || latest.length === 0) {
            logger("registry response missing dist-tags.latest; skipping self-update", "debug");
            return { latest: null, newer: false };
        }
        return { latest, newer: compareVersions(latest, currentVersion) > 0 };
    }
    catch (err) {
        logger(`self-update check skipped: ${String(err)}`, "debug");
        return { latest: null, newer: false };
    }
}
/**
 * Decide whether the running shim is installed as a global npm bin, a local
 * project dep, or being invoked via `npx`. Used to choose the right update
 * command — global gets `npm install -g`, npx re-execs through `npx -y`,
 * and local just gets an info-level "update available" line because
 * mutating the user's package.json is out of scope for self-update.
 */
export function detectInstallMode() {
    const entry = process.argv[1] ?? "";
    const normalized = entry.split(path.sep).join("/").toLowerCase();
    if (normalized.includes("/_npx/"))
        return "npx";
    if (process.env.npm_execpath && process.env.npm_execpath.toLowerCase().includes("npx"))
        return "npx";
    // npm sets npm_config_global=true for `npm install -g …` lifecycle scripts,
    // but at run-time we look at the binary path. A typical global install on
    // POSIX lives under `/usr/local/lib/node_modules/doc-detective/...` or the
    // npm prefix; on Windows under `%AppData%/npm/node_modules/...`.
    if (normalized.includes("/lib/node_modules/doc-detective/"))
        return "global";
    if (normalized.includes("/appdata/roaming/npm/"))
        return "global";
    if (normalized.includes("/.npm-global/"))
        return "global";
    if (normalized.includes("/node_modules/doc-detective/"))
        return "local";
    return "unknown";
}
/**
 * Perform a self-update for the running shim, scoped by install mode.
 *   - global: spawn `npm install -g doc-detective@<latest>`, then re-exec.
 *   - npx:    re-exec via `npx -y doc-detective@latest <…orig argv>`.
 *   - local:  log an info-level "update available" line, return updated:false.
 *   - unknown: like local — never mutate state we don't understand.
 *
 * In the re-exec paths, the child inherits the parent's stdio and is
 * spawned with DOC_DETECTIVE_SKIP_AUTO_UPDATE=1 to prevent infinite update
 * loops if the new version's check fires again.
 */
export async function selfUpdate(latestVersion, mode, deps = {}) {
    const logger = deps.logger ?? defaultLogger;
    const spawner = deps.spawn ?? nodeSpawn;
    if (mode === "local" || mode === "unknown") {
        logger(`Update available: doc-detective@${latestVersion}. Run \`npm i doc-detective@latest\` in this project to upgrade.`, "info");
        return { updated: false, reexec: false };
    }
    // `latestVersion` comes from the npm registry response (dist-tags.latest)
    // and is interpolated into the args of an `npm.cmd` / `npx.cmd` spawn
    // that uses `shell: true` on Windows (Node 18+ refuses .cmd without
    // shell:true). A compromised or malformed registry value could
    // therefore reach cmd.exe parsing. Constrain the value to a strict
    // semver-ish character class (digits, dots, hyphens, and ASCII
    // alphanumerics for prerelease identifiers) before using it. Anything
    // outside this set bails out of the self-update — the user keeps
    // running the current version and a clear error is logged.
    if (!/^[A-Za-z0-9.\-+]+$/.test(latestVersion)) {
        logger(`Refusing to self-update: registry-reported version ${JSON.stringify(latestVersion)} contains characters outside the semver charset. Skipping.`, "error");
        return { updated: false, reexec: false };
    }
    const childEnv = { ...process.env, DOC_DETECTIVE_SKIP_AUTO_UPDATE: "1" };
    const npmExe = process.platform === "win32" ? "npm.cmd" : "npm";
    const npxExe = process.platform === "win32" ? "npx.cmd" : "npx";
    if (mode === "global") {
        logger(`Self-updating doc-detective to ${latestVersion} (global)`, "info");
        const installExit = await runChild(spawner, npmExe, ["install", "-g", `doc-detective@${latestVersion}`], { env: childEnv, shell: process.platform === "win32", stdio: "inherit" }, logger);
        if (installExit !== 0) {
            // The install failed (network blip, permission error, registry 5xx).
            // Re-execing the same entrypoint now would silently run the OLD
            // binary again with no actionable signal. Surface the failure
            // and keep running the current version so the user at least sees
            // their original command complete.
            logger(`npm install -g doc-detective@${latestVersion} exited with code ${installExit}; skipping re-exec and continuing with the current version.`, "error");
            return { updated: false, reexec: false };
        }
        // Re-exec the same entry with the original argv tail.
        const exitCode = await runChild(spawner, process.execPath, [process.argv[1], ...process.argv.slice(2)], { env: childEnv, stdio: "inherit" }, logger);
        process.exit(exitCode);
        // process.exit is `never`-typed in node's typings, but if it's stubbed
        // (programmatic / test use), execution would otherwise fall through to
        // the npx branch and double-spawn. Explicit return keeps the contract
        // coherent. `as never` matches the declared `Promise<SelfUpdateResult>`
        // without compromising the runtime no-op.
        return { updated: true, reexec: true };
    }
    // npx
    logger(`Re-launching via npx -y doc-detective@${latestVersion}`, "info");
    const exitCode = await runChild(spawner, npxExe, ["-y", `doc-detective@${latestVersion}`, ...process.argv.slice(2)], { env: childEnv, shell: process.platform === "win32", stdio: "inherit" }, logger);
    process.exit(exitCode);
    // Same fallthrough-guard as the global branch above.
    return { updated: true, reexec: true };
}
function runChild(spawner, cmd, args, opts, logger) {
    return new Promise((resolve, reject) => {
        const child = spawner(cmd, args, opts);
        child.on("error", (err) => {
            logger(`spawn ${cmd} failed: ${err.message}`, "error");
            reject(err);
        });
        child.on("close", (code) => resolve(code ?? 1));
    });
}
//# sourceMappingURL=selfUpdate.js.map