import { spawn, spawnSync, execFileSync } from "node:child_process";
import { logger } from "../logger.js";
import { resolveTunnelId, ensureIngressRule, tunnelHasActiveConnector, } from "./cloudflared-config.js";
/**
 * Build a spawn env inheriting process.env and optionally injecting
 * TUNNEL_ORIGIN_CERT. Returning undefined when no cert is provided is
 * intentional: it lets callers omit the `env` key from spawn() options
 * entirely, keeping byte-identical behaviour with the pre-isolation code.
 */
function cloudflaredEnv(originCert) {
    if (!originCert)
        return undefined;
    return { ...process.env, TUNNEL_ORIGIN_CERT: originCert };
}
const activeTunnelPids = new Set();
const HIDE_WINDOWS_CONSOLE = true;
process.on("exit", () => {
    for (const pid of activeTunnelPids) {
        try {
            process.kill(pid, "SIGTERM");
        }
        catch { /* already dead */ }
    }
});
function trackProcess(proc) {
    const pid = proc.pid;
    if (pid !== undefined) {
        activeTunnelPids.add(pid);
        proc.once("close", () => { activeTunnelPids.delete(pid); });
    }
}
function escapeRegExp(value) {
    return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
 * Discover legacy `cloudflared tunnel run <name>` processes currently
 * running on this host. The match is intentionally narrow:
 *   - only `cloudflared` command lines
 *   - only lines containing `tunnel run`
 *   - only the exact tunnel name as a standalone arg
 *
 * Exposed for tests. Callers in production should prefer
 * `stopLegacyNamedTunnelProcesses()` below, which wraps this with a
 * terminate-and-confirm cycle.
 */
export function findLegacyNamedTunnelPids(tunnelName) {
    const trimmedName = tunnelName.trim();
    if (!trimmedName)
        return [];
    if (process.platform === "win32") {
        // TODO: add a Windows tasklist/wmic variant if we ever need this path.
        return [];
    }
    const nameArgRe = new RegExp(`(?:^|\\s)${escapeRegExp(trimmedName)}(?:\\s|$)`);
    let psOutput = "";
    try {
        psOutput = execFileSync("ps", ["-axo", "pid=,command="], {
            encoding: "utf-8",
            timeout: 10_000,
            windowsHide: HIDE_WINDOWS_CONSOLE,
        });
    }
    catch {
        return [];
    }
    const pids = [];
    for (const line of psOutput.split("\n")) {
        const trimmed = line.trim();
        if (!trimmed)
            continue;
        const match = /^(\d+)\s+(.*)$/.exec(trimmed);
        if (!match)
            continue;
        const pid = Number.parseInt(match[1], 10);
        const command = match[2];
        if (!Number.isFinite(pid) || pid <= 0 || pid === process.pid)
            continue;
        if (!/\bcloudflared\b/.test(command))
            continue;
        if (!/\btunnel\s+run\b/.test(command))
            continue;
        if (!nameArgRe.test(command))
            continue;
        pids.push(pid);
    }
    return pids;
}
function isProcessAlive(pid) {
    try {
        // Signal 0 checks for existence without sending a signal.
        process.kill(pid, 0);
        return true;
    }
    catch (err) {
        const code = err.code;
        // EPERM means the process exists but we don't have rights to signal
        // it — treat that as "still alive but out of our reach" so we don't
        // wrongly report it as terminated.
        if (code === "EPERM")
            return true;
        return false;
    }
}
/**
 * Migration handoff for a pre-existing legacy `cloudflared tunnel run
 * <name>` process. Sends SIGTERM, polls for exit, escalates to SIGKILL if
 * the process does not exit within `gracefulTimeoutMs`, and only resolves
 * once every targeted PID is confirmed gone (or the force-kill window has
 * elapsed). This prevents the profile-managed connector the caller is
 * about to start from racing an inherited legacy connector for the same
 * tunnel UUID.
 */
export async function stopLegacyNamedTunnelProcesses(tunnelName, opts = {}) {
    const gracefulTimeoutMs = opts.gracefulTimeoutMs ?? 8_000;
    const pollIntervalMs = opts.pollIntervalMs ?? 100;
    const forceKillTimeoutMs = opts.forceKillTimeoutMs ?? 2_000;
    const pids = findLegacyNamedTunnelPids(tunnelName);
    if (pids.length === 0) {
        return { stopped: [], survived: [] };
    }
    const remaining = new Set();
    for (const pid of pids) {
        try {
            process.kill(pid, "SIGTERM");
            remaining.add(pid);
        }
        catch {
            // Already gone between the ps snapshot and the kill — treat as stopped.
        }
    }
    async function waitUntilGone(budgetMs) {
        const deadline = Date.now() + budgetMs;
        while (remaining.size > 0 && Date.now() < deadline) {
            for (const pid of [...remaining]) {
                if (!isProcessAlive(pid))
                    remaining.delete(pid);
            }
            if (remaining.size === 0)
                break;
            await sleep(pollIntervalMs);
        }
    }
    await waitUntilGone(gracefulTimeoutMs);
    if (remaining.size > 0) {
        for (const pid of [...remaining]) {
            try {
                process.kill(pid, "SIGKILL");
            }
            catch {
                // If SIGKILL fails (EPERM / already gone), the recheck below handles it.
            }
        }
        await waitUntilGone(forceKillTimeoutMs);
    }
    const stopped = pids.filter((pid) => !remaining.has(pid));
    return { stopped, survived: [...remaining] };
}
function sleep(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
}
const HEALTH_CHECK_ATTEMPTS = 5;
const HEALTH_CHECK_INTERVAL_MS = 10_000;
async function healthCheck(tunnelUrl) {
    const healthUrl = `${tunnelUrl}/obs/health`;
    for (let i = 0; i < HEALTH_CHECK_ATTEMPTS; i++) {
        try {
            const ac = new AbortController();
            const t = setTimeout(() => { ac.abort(); }, 10_000);
            const res = await fetch(healthUrl, { signal: ac.signal });
            clearTimeout(t);
            if (res.ok) {
                const body = (await res.json());
                if (body.ok === true)
                    return true;
            }
        }
        catch {
            // network error or abort — try again
        }
        if (i < HEALTH_CHECK_ATTEMPTS - 1)
            await sleep(HEALTH_CHECK_INTERVAL_MS);
    }
    return false;
}
function spawnCloudflared(port, protocol) {
    const args = ["tunnel", "--url", `http://localhost:${port}`];
    if (protocol === "http2") {
        args.push("--protocol", "http2");
    }
    // Keep helper tunnel processes from creating a flashing console window on Windows.
    const proc = spawn("cloudflared", args, {
        stdio: ["ignore", "pipe", "pipe"],
        windowsHide: HIDE_WINDOWS_CONSOLE,
    });
    trackProcess(proc);
    const urlPromise = new Promise((resolve, reject) => {
        const timeout = setTimeout(() => {
            reject(new Error(`cloudflared did not provide a URL within 30s (protocol: ${protocol})`));
        }, 30_000);
        const urlRegex = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
        let lastStderr = "";
        function onData(data) {
            const text = data.toString();
            const m = urlRegex.exec(text);
            if (m) {
                clearTimeout(timeout);
                proc.stdout.removeListener("data", onData);
                proc.stderr.removeListener("data", onData);
                resolve(m[0]);
            }
        }
        proc.stdout.on("data", onData);
        proc.stderr.on("data", (data) => {
            const text = data.toString();
            lastStderr = text.trimEnd().split("\n").pop() ?? lastStderr;
            onData(data);
        });
        proc.on("error", (err) => {
            clearTimeout(timeout);
            reject(err);
        });
        proc.on("close", (code) => {
            clearTimeout(timeout);
            const detail = lastStderr ? `: ${lastStderr}` : "";
            reject(new Error(`cloudflared exited with code ${code} before providing a URL${detail}`));
        });
    });
    return { proc, urlPromise };
}
async function stopProcess(proc) {
    if (proc.exitCode !== null)
        return;
    return new Promise((resolve) => {
        proc.once("close", () => { resolve(); });
        try {
            proc.kill("SIGTERM");
        }
        catch {
            // On Windows, SIGTERM may not work — force kill via taskkill
            if (process.platform === "win32" && proc.pid) {
                try {
                    spawn("taskkill", ["/PID", String(proc.pid), "/F"], {
                        stdio: "ignore",
                        windowsHide: HIDE_WINDOWS_CONSOLE,
                    });
                }
                catch { /* best effort */ }
            }
        }
        setTimeout(() => {
            try {
                proc.kill("SIGKILL");
            }
            catch { /* already dead */ }
        }, 3_000);
    });
}
const SPAWN_RETRIES = 3;
const SPAWN_RETRY_DELAY_MS = 5_000;
/**
 * Cloudflare's published IPv4 ranges (stable set maintained at
 * https://www.cloudflare.com/ips-v4). When the hostname's CNAME target is a
 * Cloudflare-proxied record (orange cloud), Cloudflare's public resolver
 * hides the CNAME and returns a flattened A record pointing to one of these
 * ranges. Used as a fallback signal that the tunnel DNS is actually healthy
 * even though a bare CNAME lookup comes back empty.
 */
const CLOUDFLARE_IPV4_CIDRS = [
    "173.245.48.0/20",
    "103.21.244.0/22",
    "103.22.200.0/22",
    "103.31.4.0/22",
    "141.101.64.0/18",
    "108.162.192.0/18",
    "190.93.240.0/20",
    "188.114.96.0/20",
    "197.234.240.0/22",
    "198.41.128.0/17",
    "162.158.0.0/15",
    "104.16.0.0/13",
    "104.24.0.0/14",
    "172.64.0.0/13",
    "131.0.72.0/22",
];
function ipv4ToUint32(ip) {
    const parts = ip.split(".");
    if (parts.length !== 4)
        return null;
    let n = 0;
    for (const p of parts) {
        const octet = Number(p);
        if (!Number.isInteger(octet) || octet < 0 || octet > 255)
            return null;
        n = (n * 256 + octet) >>> 0;
    }
    return n;
}
export function isCloudflareIPv4(ip) {
    const addr = ipv4ToUint32(ip);
    if (addr === null)
        return false;
    for (const cidr of CLOUDFLARE_IPV4_CIDRS) {
        const [base, bitsStr] = cidr.split("/");
        const baseAddr = ipv4ToUint32(base);
        const bits = Number(bitsStr);
        if (baseAddr === null || !Number.isInteger(bits) || bits < 0 || bits > 32)
            continue;
        // bits=0 → mask 0; bits=32 → mask 0xffffffff. Avoid the JS shift-by-32 quirk.
        const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
        if ((addr & mask) === (baseAddr & mask))
            return true;
    }
    return false;
}
/**
 * One-shot DoH lookup against Cloudflare's public resolver to verify that a
 * named tunnel's hostname is wired up correctly.
 *
 * Logic:
 *   1. Query CNAME. If answers include `<tunnelId>.cfargotunnel.com`, great
 *      (proxied=off / grey cloud) — return "ok".
 *   2. If a CNAME answer exists but points elsewhere, return "wrong".
 *   3. If no CNAME is returned, the record may still be healthy: a proxied
 *      (orange cloud) CNAME is hidden by Cloudflare's public resolver and
 *      flattened into A records pointing at Cloudflare edge IPs. Re-query
 *      type=A and return "ok" if every answer is within Cloudflare's ranges.
 *   4. Otherwise "missing".
 *
 * Per-request timeout is short (3s) so the outer poll loop stays responsive.
 */
async function cnameLookupOnce(hostname, expected) {
    try {
        const cnameRes = await fetch(`https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(hostname)}&type=CNAME`, {
            headers: { accept: "application/dns-json" },
            signal: AbortSignal.timeout(3000),
        });
        if (!cnameRes.ok)
            return "unknown";
        const cnameBody = (await cnameRes.json().catch(() => null));
        if (!cnameBody)
            return "unknown";
        // RFC 8484 status 3 = NXDOMAIN, 0 = NOERROR.
        const cnames = [];
        for (const a of cnameBody.Answer ?? []) {
            // RFC 8484 type 5 = CNAME; skip other record types (A/AAAA/etc.).
            if (a.type === 5 && typeof a.data === "string") {
                cnames.push(a.data.toLowerCase().replace(/\.$/, ""));
            }
        }
        if (cnames.length > 0)
            return cnames.includes(expected) ? "ok" : "wrong";
        // No CNAME answer. Could be genuinely missing, or a proxied CNAME that
        // Cloudflare has flattened. Fall back to an A-record probe and treat
        // Cloudflare-owned IPs as healthy.
        const aRes = await fetch(`https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(hostname)}&type=A`, {
            headers: { accept: "application/dns-json" },
            signal: AbortSignal.timeout(3000),
        });
        if (!aRes.ok)
            return "missing";
        const aBody = (await aRes.json().catch(() => null));
        const aAnswers = (aBody?.Answer ?? []).filter((a) => a.type === 1 && typeof a.data === "string");
        if (aAnswers.length === 0)
            return "missing";
        const allCloudflare = aAnswers.every((a) => isCloudflareIPv4(a.data));
        return allCloudflare ? "ok" : "wrong";
    }
    catch {
        return "unknown";
    }
}
/**
 * Query Cloudflare's DoH endpoint to confirm that `hostname` resolves to a
 * CNAME pointing at `<tunnelId>.cfargotunnel.com`. Returns one of:
 *   "ok"       — correct CNAME present
 *   "missing"  — no CNAME (public DNS hasn't been updated at all)
 *   "wrong"    — record exists but points elsewhere (e.g. cloudflared added
 *                the hostname to the wrong zone, leaving an unrelated record
 *                on the one we actually query)
 *   "unknown"  — DoH request failed; don't treat as fatal.
 *
 * Short polling loop: freshly-created CNAMEs often need ~5-20s before
 * Cloudflare's public resolver sees them, so returning "missing" after a
 * single 5s request generates a lot of false alarms. We poll up to
 * `maxWaitMs` (default 15s) with a 2s cadence, short-circuiting on the
 * first success. Terminal statuses ("ok" and "wrong") return immediately.
 * Total wall-clock cost on the happy path is typically <3s; the worst case
 * is bounded by `maxWaitMs` + one in-flight request (~3s).
 */
async function lookupTunnelCname(hostname, tunnelId, maxWaitMs = 15_000) {
    const expected = `${tunnelId}.cfargotunnel.com`.toLowerCase();
    const deadline = Date.now() + maxWaitMs;
    let lastStatus = "unknown";
    while (Date.now() < deadline) {
        const status = await cnameLookupOnce(hostname, expected);
        lastStatus = status;
        // "ok" is what we want; "wrong" is a hard misconfiguration (retrying
        // won't fix it). Either way, stop polling.
        if (status === "ok" || status === "wrong")
            return status;
        // "missing" / "unknown" → wait and try again, unless we've run out.
        if (Date.now() + 2_000 >= deadline)
            break;
        await sleep(2_000);
    }
    return lastStatus;
}
/**
 * Verify the DNS CNAME for a named tunnel exists.  If the initial
 * `cloudflared tunnel route dns` fails with error code 1003 (conflicting
 * record), retry with `--overwrite-dns` to replace the stale record.
 *
 * Follow-up: after cloudflared reports success, do an out-of-band DoH lookup
 * to confirm the CNAME actually exists on the public DNS. This guards
 * against cases where cloudflared's API call silently targeted the wrong
 * zone (seen when cert.pem.zoneID and the hostname's parent domain drift
 * apart — cloudflared then writes a CNAME to the cert's zone rather than
 * failing, and the hostname never resolves publicly).
 */
function legacyDnsMutationMode() {
    const raw = process.env.VISIONCLAW_LEGACY_CLOUDFLARED_DNS_MUTATION?.trim().toLowerCase();
    if (raw === "allow" || raw === "1" || raw === "true")
        return "allow";
    if (raw === "disable" || raw === "0" || raw === "false")
        return "disable";
    return "warn";
}
async function ensureDnsRouteExists(tunnelName, tunnelHostname, opts) {
    const mode = legacyDnsMutationMode();
    if (mode === "disable") {
        logger.warn(`Legacy cloudflared DNS mutation is disabled; skipping DNS route mutation for ${tunnelHostname}`);
        return;
    }
    if (mode === "warn") {
        logger.warn(`Legacy cloudflared DNS mutation would run for ${tunnelHostname}; set VISIONCLAW_LEGACY_CLOUDFLARED_DNS_MUTATION=disable to block it.`);
    }
    const env = cloudflaredEnv(opts?.tunnelOriginCert);
    const spawnBase = {
        encoding: "utf-8",
        timeout: 30_000,
        windowsHide: true,
    };
    const result = spawnSync("cloudflared", ["tunnel", "route", "dns", tunnelName, tunnelHostname], {
        ...spawnBase,
        ...(env ? { env } : {}),
    });
    const combined = `${result.stdout}\n${result.stderr}`;
    let cloudflaredOk = false;
    if (result.status === 0 || /already exists/i.test(combined)) {
        logger.system(`DNS route verified for ${tunnelHostname}`);
        cloudflaredOk = true;
    }
    else if (/code: 1003|CNAME record with that host/i.test(combined)) {
        logger.warn(`Conflicting DNS record for ${tunnelHostname} — retrying with --overwrite-dns…`);
        const retry = spawnSync("cloudflared", ["tunnel", "route", "dns", "--overwrite-dns", tunnelName, tunnelHostname], { ...spawnBase, ...(env ? { env } : {}) });
        if (retry.status === 0) {
            logger.system(`DNS route overwritten successfully for ${tunnelHostname}`);
            cloudflaredOk = true;
        }
        else {
            logger.warn(`Failed to overwrite DNS route: ${retry.stderr.trim() || retry.stdout.trim()}`);
        }
    }
    else {
        logger.warn(`DNS route check for ${tunnelHostname}: exit=${result.status} ${combined.trim().slice(0, 200)}`);
    }
    if (!cloudflaredOk)
        return;
    // Secondary verification: ask public DNS whether the CNAME actually exists
    // and points at our tunnel. We need the tunnelId for the comparison; if we
    // can't resolve it locally (unusual), skip the check rather than guess.
    const isoOpts = opts?.tunnelOriginCert
        ? { originCert: opts.tunnelOriginCert, configPath: opts.tunnelConfigPath }
        : undefined;
    const tunnelId = resolveTunnelId(tunnelName, isoOpts);
    if (!tunnelId)
        return;
    const dnsResult = await lookupTunnelCname(tunnelHostname, tunnelId);
    switch (dnsResult) {
        case "ok":
            // Nothing to say — the happy path is common.
            return;
        case "missing":
            logger.warn(`DNS check: ${tunnelHostname} is not yet resolvable on public DNS. ` +
                `Cloudflare propagation can take a minute, but if this persists the CNAME ` +
                `may have been created in the wrong zone (cert.pem zoneID / domain mismatch).`);
            return;
        case "wrong":
            logger.warn(`DNS check: ${tunnelHostname} resolves to a different target than ` +
                `${tunnelId}.cfargotunnel.com. An unrelated CNAME already exists on this ` +
                `hostname and cloudflared's update likely went to the wrong zone.`);
            return;
        case "unknown":
            // DoH unreachable — silent to avoid crying wolf in offline dev setups.
            return;
    }
}
async function startCloudflaredNamed(port, tunnelName, tunnelHostname, opts) {
    const url = `https://${tunnelHostname}`;
    // Two operational modes:
    //   1. Runtime-credentials mode — `opts.tunnelId` was set by the agent
    //      after materialising a heartbeat-delivered tunnel credential.
    //      No `cert.pem` is available (nor needed); we skip every
    //      `cloudflared` subcommand that requires it (`tunnel info`,
    //      `tunnel route dns`) and drive config.yml with the known UUID.
    //   2. Legacy mode — tunnelId is resolved via `cloudflared tunnel info`
    //      using the local `cert.pem`. DNS routing and the active-connector
    //      probe run as before. This preserves byte-for-byte behaviour for
    //      agents that haven't onboarded onto runtime credential delivery.
    const runtimeTunnelId = typeof opts?.tunnelId === "string" && opts.tunnelId.length > 0
        ? opts.tunnelId
        : undefined;
    const runtimeMode = runtimeTunnelId !== undefined;
    // Ensure config.yml has our ingress entry before starting.
    // Creates the file if missing, merges if it exists, no-op if already correct.
    const isoOpts = opts?.tunnelOriginCert
        ? { originCert: opts.tunnelOriginCert, configPath: opts.tunnelConfigPath }
        : undefined;
    let tunnelId;
    if (runtimeMode) {
        tunnelId = runtimeTunnelId;
        // Runtime mode: config.yml sits in the profile tunnel dir and we own
        // the credentials-file path. Merge the ingress for our obs port in
        // place — same helper, but scoped to the profile config. The runtime-
        // mode guard above ensures `opts` was non-null when it set `tunnelId`,
        // but TS can't correlate the two through the intermediate variable so
        // we narrow again here.
        const configPath = opts?.tunnelConfigPath;
        if (tunnelId && configPath) {
            try {
                // A profile config may legitimately carry additional ingress rules
                // for non-obs hostnames (user-configured routes to other local
                // services on different ports). Only touch the canonical obs
                // hostname here — leave every other rule's service verbatim.
                ensureIngressRule(tunnelId, tunnelHostname, `http://localhost:${port}`, { configPath });
            }
            catch (err) {
                logger.warn(`Failed to ensure ingress in profile config.yml: ${err instanceof Error ? err.message : String(err)}`);
            }
        }
    }
    else {
        tunnelId = resolveTunnelId(tunnelName, isoOpts);
        if (tunnelId) {
            try {
                ensureIngressRule(tunnelId, tunnelHostname, `http://localhost:${port}`, isoOpts);
            }
            catch (err) {
                logger.warn(`Failed to ensure ingress in config.yml: ${err instanceof Error ? err.message : String(err)}`);
            }
            await ensureDnsRouteExists(tunnelName, tunnelHostname, opts);
        }
    }
    // If an existing cloudflared process is already serving this tunnel (e.g.
    // a manually-managed connector or a previous VisionClaw instance that
    // survived our restart), spawning a second connector makes Cloudflare's
    // edge flap between origins and surfaces as ERR_CONNECTION_CLOSED / 530
    // in browsers. Detect that case and reuse the existing connector.
    //
    // We only do this when we can resolve the tunnel ID — a cert-less or
    // misconfigured environment falls through to the old spawn-and-hope path
    // so we don't silently skip tunnel startup in setups the probe can't
    // inspect. When `tunnelHasActiveConnector` returns `undefined` (probe
    // itself failed) we also prefer to spawn, treating "unknown" as "safer to
    // try" since the caller will see the spawn's own failure modes anyway.
    //
    // Runtime-credentials mode skips this probe entirely — `tunnel info` also
    // requires cert.pem, and a profile-managed cloudflared process is the only
    // connector we expect to see for the tunnel anyway.
    if (!runtimeMode && tunnelId && tunnelHasActiveConnector(tunnelName, isoOpts) === true) {
        logger.system(`Reusing existing cloudflared connector for tunnel "${tunnelName}" ` +
            `(tunnelId=${tunnelId}) — assuming ingress already routes ` +
            `${tunnelHostname} → http://localhost:${port}.`);
        logger.system(`Verifying ${url}…`);
        const ok = await healthCheck(url);
        if (ok) {
            logger.system(`Existing tunnel healthy: ${url}`);
        }
        else {
            // Don't fail outright — the other cloudflared process may take a few
            // seconds to register our updated ingress with the edge, or DNS may
            // not be fully propagated yet. Surface a warning so operators can
            // correlate with ingress/DNS logs.
            logger.warn(`Existing tunnel health check failed for ${url}. ` +
                `Verify the other cloudflared process routes ${tunnelHostname} ` +
                `to http://localhost:${port} and that public DNS resolves the hostname.`);
        }
        // Reusing someone else's connector — stop() must not kill it.
        return { url, stop: () => Promise.resolve() };
    }
    const env = cloudflaredEnv(opts?.tunnelOriginCert);
    for (let attempt = 1; attempt <= SPAWN_RETRIES; attempt++) {
        logger.system(`Starting named cloudflared tunnel "${tunnelName}" (attempt ${attempt}/${SPAWN_RETRIES})…`);
        // Use default config.yml if we ensured it, otherwise fall back to --url.
        // When an isolated config path is provided, pass it via --config so
        // cloudflared reads our per-profile ingress instead of `~/.cloudflared/config.yml`.
        const args = ["tunnel"];
        if (tunnelId && opts?.tunnelConfigPath) {
            args.push("--config", opts.tunnelConfigPath);
        }
        if (tunnelId) {
            args.push("run", tunnelName);
        }
        else {
            args.push("run", "--url", `http://localhost:${port}`, tunnelName);
        }
        const proc = spawn("cloudflared", args, {
            stdio: ["ignore", "pipe", "pipe"],
            windowsHide: HIDE_WINDOWS_CONSOLE,
            ...(env ? { env } : {}),
        });
        trackProcess(proc);
        const stderrChunks = [];
        const ready = await new Promise((resolve) => {
            const timeout = setTimeout(() => { resolve(true); }, 15_000);
            let exited = false;
            proc.on("close", (code) => {
                exited = true;
                clearTimeout(timeout);
                const stderrTail = stderrChunks.join("").trimEnd().split("\n").slice(-3).join(" | ");
                logger.warn(`cloudflared named tunnel exited early with code ${code}: ${stderrTail}`);
                resolve(false);
            });
            proc.on("error", (err) => {
                exited = true;
                clearTimeout(timeout);
                logger.warn(`cloudflared named tunnel spawn error: ${err.message}`);
                resolve(false);
            });
            proc.stderr.on("data", (data) => {
                const text = data.toString();
                stderrChunks.push(text);
                if (!exited && /registered tunnel connection|Registered tunnel connection|Connection.*registered/i.test(text)) {
                    clearTimeout(timeout);
                    resolve(true);
                }
            });
        });
        if (!ready) {
            await stopProcess(proc);
            if (attempt < SPAWN_RETRIES) {
                logger.system(`Retrying in ${SPAWN_RETRY_DELAY_MS / 1000}s…`);
                await sleep(SPAWN_RETRY_DELAY_MS);
                continue;
            }
            throw new Error(`cloudflared named tunnel "${tunnelName}" failed after ${SPAWN_RETRIES} attempts`);
        }
        logger.system(`Named tunnel "${tunnelName}" started — waiting 20s for Cloudflare edge to route hostname…`);
        await sleep(20_000);
        logger.system(`Verifying ${url}…`);
        const ok = await healthCheck(url);
        if (ok) {
            logger.system(`Named tunnel healthy: ${url}`);
        }
        else {
            logger.warn(`Named tunnel health check failed for ${url}. ` +
                `The URL may become reachable in 1–2 minutes.`);
        }
        return { url, stop: () => stopProcess(proc) };
    }
    throw new Error(`cloudflared named tunnel "${tunnelName}" failed after all retry attempts`);
}
async function startCloudflared(port) {
    // Retry the spawn itself — Cloudflare's quick-tunnel API can fail transiently
    // (e.g. "failed to parse quick Tunnel ID: invalid UUID length: 0").
    for (let attempt = 1; attempt <= SPAWN_RETRIES; attempt++) {
        logger.system(`Starting cloudflared tunnel (protocol: auto, attempt ${attempt}/${SPAWN_RETRIES})…`);
        const spawn1 = spawnCloudflared(port, "auto");
        let url1;
        try {
            url1 = await spawn1.urlPromise;
        }
        catch (err) {
            const msg = err instanceof Error ? err.message : String(err);
            logger.warn(`cloudflared (auto) attempt ${attempt} failed: ${msg}`);
            await stopProcess(spawn1.proc);
            if (attempt < SPAWN_RETRIES) {
                logger.system(`Retrying in ${SPAWN_RETRY_DELAY_MS / 1000}s…`);
                await sleep(SPAWN_RETRY_DELAY_MS);
                continue;
            }
            throw err;
        }
        logger.system(`cloudflared URL (auto): ${url1} — verifying reachability…`);
        const ok1 = await healthCheck(url1);
        if (ok1) {
            logger.system(`Tunnel healthy: ${url1}`);
            return {
                url: url1,
                stop: () => stopProcess(spawn1.proc),
            };
        }
        // Health check failed — NAT likely blocks UDP/QUIC. Fall back to http2 (TCP+TLS).
        logger.warn(`Tunnel health check failed for ${url1} — retrying with --protocol http2 (TCP)`);
        await stopProcess(spawn1.proc);
        const spawn2 = spawnCloudflared(port, "http2");
        let url2;
        try {
            url2 = await spawn2.urlPromise;
        }
        catch (err) {
            const msg = err instanceof Error ? err.message : String(err);
            logger.warn(`cloudflared (http2) attempt ${attempt} failed: ${msg}`);
            await stopProcess(spawn2.proc);
            if (attempt < SPAWN_RETRIES) {
                logger.system(`Retrying in ${SPAWN_RETRY_DELAY_MS / 1000}s…`);
                await sleep(SPAWN_RETRY_DELAY_MS);
                continue;
            }
            throw err;
        }
        logger.system(`cloudflared URL (http2): ${url2} — verifying reachability…`);
        const ok2 = await healthCheck(url2);
        if (ok2) {
            logger.system(`Tunnel healthy (http2): ${url2}`);
        }
        else {
            logger.warn(`Tunnel health check still failing for ${url2}. The URL may become reachable after DNS propagation.`);
        }
        return {
            url: url2,
            stop: () => stopProcess(spawn2.proc),
        };
    }
    throw new Error("cloudflared failed after all retry attempts");
}
async function startNgrok(port) {
    logger.system("Starting ngrok tunnel…");
    const proc = spawn("ngrok", ["http", String(port), "--log=stdout", "--log-format=json"], {
        stdio: ["ignore", "pipe", "ignore"],
        windowsHide: HIDE_WINDOWS_CONSOLE,
    });
    trackProcess(proc);
    const url = await new Promise((resolve, reject) => {
        const timeout = setTimeout(() => {
            reject(new Error("ngrok did not provide a URL within 30s"));
        }, 30_000);
        function onStdout(data) {
            for (const line of data.toString().split("\n")) {
                try {
                    const obj = JSON.parse(line);
                    if (typeof obj.url === "string" && obj.url.startsWith("https://")) {
                        clearTimeout(timeout);
                        proc.stdout.removeListener("data", onStdout);
                        resolve(obj.url);
                        return;
                    }
                }
                catch {
                    // not JSON
                }
            }
        }
        proc.stdout.on("data", onStdout);
        proc.on("error", (err) => {
            clearTimeout(timeout);
            reject(err);
        });
        proc.on("close", (code) => {
            clearTimeout(timeout);
            reject(new Error(`ngrok exited with code ${code} before providing a URL`));
        });
    });
    logger.system(`ngrok URL: ${url} — verifying reachability…`);
    const ok = await healthCheck(url);
    if (ok) {
        logger.system(`Tunnel healthy: ${url}`);
    }
    else {
        logger.warn(`ngrok tunnel health check failed for ${url}. It may still become reachable.`);
    }
    return { url, stop: () => stopProcess(proc) };
}
export async function startTunnel(port, provider, options) {
    if (provider === "cloudflared") {
        if (options?.tunnelName && options.tunnelHostname) {
            return startCloudflaredNamed(port, options.tunnelName, options.tunnelHostname, options);
        }
        return startCloudflared(port);
    }
    return startNgrok(port);
}
//# sourceMappingURL=tunnel.js.map