import { spawn } from "node:child_process";
import { randomBytes } from "node:crypto";
import http from "node:http";
import { LOOPBACK_HOST } from "./launcherConstants.js";
/**
 * Argv-safe OS browser opener.
 *
 * The URL is passed as a single argv element to the platform opener and NEVER
 * routed through a shell — a shell would treat `#` (POSIX) or `&` (cmd) in a
 * URL as a metacharacter, and shelling is an injection vector. On Windows we
 * use rundll32's FileProtocolHandler rather than the `start` cmd builtin for
 * exactly that reason. The child is detached + unref'd so the launcher can
 * return immediately.
 */
export function spawnBrowser(url, platform = process.platform) {
    // Test/headless hook: when set to "none", do not launch a real browser but
    // DO fetch the courier URL once, exactly as a browser would — so the launch
    // flow (courier delivery + redirect) is exercised end-to-end without a GUI.
    // Used by the clean-install e2e and CI.
    if (process.env.PATCHTRAIL_LAUNCHER_BROWSER_OPENER === "none") {
        void fetch(url).catch(() => { });
        return { command: "(disabled)", args: [url] };
    }
    const { command, args } = browserOpenCommand(url, platform);
    const child = spawn(command, args, {
        detached: true,
        stdio: "ignore",
        windowsHide: true,
    });
    child.on("error", () => {
        // Swallow opener errors here; the caller decides how to surface a failed
        // open (e.g. print the courier URL). Throwing from a detached spawn handler
        // would be unhandled.
    });
    child.unref();
    return { command, args };
}
/**
 * Pure mapping from a URL to the per-OS opener command + argv. Exposed for
 * testing the argv-safety invariant (URL is always a single, un-shelled arg).
 */
export function browserOpenCommand(url, platform = process.platform) {
    if (platform === "darwin") {
        return { command: "open", args: [url] };
    }
    if (platform === "win32") {
        // rundll32 FileProtocolHandler opens the default browser without a shell.
        return { command: "rundll32", args: ["url.dll,FileProtocolHandler", url] };
    }
    // Linux / other POSIX.
    return { command: "xdg-open", args: [url] };
}
/**
 * Opens the handoff in the browser WITHOUT ever placing the one-time capability
 * fragment in process argv OR on disk.
 *
 * It starts an ephemeral single-use loopback HTTP server, points the browser at
 * a plain `http://127.0.0.1:<port>/<nonce>` URL (no secret — safe in argv), and
 * serves exactly one HTML response that `location.replace`s to the real handoff
 * URL. The capability fragment lives only in that in-memory response body for
 * the moment it is read, then the server closes. A same-scheme http→http
 * navigation to a complete URL string preserves the fragment reliably (unlike
 * the file:// cross-origin gamble).
 */
export async function openHandoffInBrowser(handoffUrl, options = {}) {
    const platform = options.platform ?? process.platform;
    const timeoutMs = options.timeoutMs ?? 60_000;
    const noncePath = `/${randomBytes(16).toString("hex")}`;
    const html = redirectHtml(handoffUrl);
    let resolveServed = () => { };
    // served always RESOLVES — a timeout is a non-delivery, not a failure, so it
    // can never become an unhandled rejection that crashes the launcher after it
    // has already returned.
    const served = new Promise((resolve) => {
        resolveServed = resolve;
    });
    const server = http.createServer((req, res) => {
        // Single-use: only the exact nonce path serves the redirect; anything else
        // (favicon probes, port scanners) gets a bare 404 with no secret.
        if (req.url !== noncePath) {
            res.writeHead(404, { "content-type": "text/plain" });
            res.end("not found");
            return;
        }
        res.writeHead(200, {
            "content-type": "text/html; charset=utf-8",
            "cache-control": "no-store",
            // The fragment never leaves this same-user loopback response.
            "referrer-policy": "no-referrer",
        });
        res.end(html);
        resolveServed({ delivered: true });
    });
    const courierUrl = await new Promise((resolve, reject) => {
        server.once("error", reject);
        server.listen(0, LOOPBACK_HOST, () => {
            const address = server.address();
            if (typeof address !== "object" || address === null) {
                reject(new Error("Courier server did not bind a TCP port."));
                return;
            }
            resolve(`http://${LOOPBACK_HOST}:${address.port}${noncePath}`);
        });
    });
    const timer = setTimeout(() => {
        resolveServed({ delivered: false });
    }, timeoutMs);
    timer.unref?.();
    const close = () => {
        clearTimeout(timer);
        server.close();
    };
    void served.finally(close);
    const opener = options.spawn ?? ((u, p) => void spawnBrowser(u, p));
    opener(courierUrl, platform);
    return { courierUrl, served, close };
}
function redirectHtml(handoffUrl) {
    // The handoff URL (with its #fragment) is embedded as a JSON string literal
    // and applied via location.replace, so the fragment is part of a complete URL
    // string and is preserved across the navigation. JSON.stringify also escapes
    // it safely into the inline script.
    const target = JSON.stringify(handoffUrl);
    return `<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Connecting to PatchTrail…</title>
<meta name="referrer" content="no-referrer"></head>
<body>
<p>Connecting to PatchTrail…</p>
<script>location.replace(${target});</script>
</body>
</html>
`;
}
