import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { spawnSync } from "node:child_process";
const DEFAULT_CLOUDFLARED_DIR = path.join(os.homedir(), ".cloudflared");
const DEFAULT_CONFIG_PATH = path.join(DEFAULT_CLOUDFLARED_DIR, "config.yml");
function spawnEnv(opts) {
    // Preserve byte-identical spawn behaviour when no isolation is requested:
    // returning undefined means spawn() receives no `env` key and keeps its
    // default process.env inheritance.
    if (!opts?.originCert)
        return undefined;
    return { ...process.env, TUNNEL_ORIGIN_CERT: opts.originCert };
}
/**
 * Normalize an agent name into a valid Cloudflare tunnel name.
 * Must match the DNS label syntax: lowercase letters, digits and hyphens only,
 * no leading/trailing hyphens, no consecutive hyphens. Falls back to
 * "visionclaw" when the input sanitizes to an empty string.
 */
export function sanitizeTunnelName(agentName) {
    return (agentName
        .toLowerCase()
        .replace(/[^a-z0-9-]/g, "")
        .replace(/-+/g, "-")
        .replace(/^-|-$/g, "") || "visionclaw");
}
/**
 * Resolve the UUID for a named tunnel via `cloudflared tunnel info`.
 * Returns undefined if the tunnel doesn't exist or the command fails.
 */
export function resolveTunnelId(tunnelName, opts) {
    const env = spawnEnv(opts);
    const result = spawnSync("cloudflared", ["tunnel", "info", tunnelName], {
        encoding: "utf-8",
        timeout: 30_000,
        windowsHide: true,
        ...(env ? { env } : {}),
    });
    const combined = `${result.stdout}\n${result.stderr}`;
    const match = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i.exec(combined);
    return match?.[1];
}
/**
 * Ask cloudflared whether a named tunnel already has at least one active
 * connector registered with Cloudflare. Used to avoid spawning a second
 * connector for the same tunnel (which makes the edge flap between origins
 * and surfaces as ERR_CONNECTION_CLOSED / 530 in browsers).
 *
 * Implemented by parsing `cloudflared tunnel info <name>` rather than the
 * GraphQL API so it works with nothing more than the cert.pem we already
 * have. The output has two UUID-bearing sections:
 *
 *     NAME:     audreyblake
 *     ID:       <tunnel-uuid>                     ← Skip
 *     CREATED:  ...
 *
 *     CONNECTOR ID                         CREATED              ...
 *     <connector-uuid>                     <timestamp>          ...
 *
 * We detect the CONNECTOR table header and look for any UUID on a subsequent
 * line. When the tunnel has no connectors, the table body is empty.
 *
 * Returns:
 *   - `true` when at least one connector UUID is listed in the table
 *   - `false` when the table is empty (tunnel exists but nobody's connected)
 *   - `undefined` if `cloudflared tunnel info` failed entirely (so callers
 *      can decide whether to be optimistic or pessimistic rather than
 *      mistaking a transient failure for "no connectors").
 */
export function tunnelHasActiveConnector(tunnelName, opts) {
    const env = spawnEnv(opts);
    const result = spawnSync("cloudflared", ["tunnel", "info", tunnelName], {
        encoding: "utf-8",
        timeout: 30_000,
        windowsHide: true,
        ...(env ? { env } : {}),
    });
    if (result.error)
        return undefined;
    if (typeof result.status === "number" && result.status !== 0)
        return undefined;
    return parseHasActiveConnector(result.stdout);
}
/**
 * Pure helper split out from `tunnelHasActiveConnector` so it can be unit
 * tested with fixtures of real `cloudflared tunnel info` output without
 * spawning the binary.
 */
export function parseHasActiveConnector(stdout) {
    const lines = stdout.split("\n");
    const uuidRe = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i;
    const headerIdx = lines.findIndex((l) => /^\s*CONNECTOR ID\b/i.test(l));
    if (headerIdx === -1)
        return false;
    for (let i = headerIdx + 1; i < lines.length; i++) {
        if (uuidRe.test(lines[i]))
            return true;
    }
    return false;
}
/**
 * Parse a simple cloudflared config YAML.
 * Only supports the subset we write: tunnel, credentials-file, and ingress list.
 *
 * Exposed for the legacy-tunnel-adoption flow, which reads the user's
 * pre-existing `~/.cloudflared/config.yml` to materialise the same
 * fields into the agent profile's private tunnel dir.
 */
export function parseCloudflaredConfigYaml(content) {
    return parseConfig(content);
}
function parseConfig(content) {
    let tunnel = "";
    let credentialsFile = "";
    const ingress = [];
    const lines = content.split("\n");
    let i = 0;
    while (i < lines.length) {
        const line = lines[i].trimEnd();
        if (line.startsWith("tunnel:")) {
            tunnel = line.slice("tunnel:".length).trim();
        }
        else if (line.startsWith("credentials-file:")) {
            credentialsFile = line.slice("credentials-file:".length).trim();
        }
        else if (line.trim() === "ingress:") {
            i++;
            while (i < lines.length) {
                const ingressLine = lines[i];
                if (!ingressLine.startsWith("  ") && !ingressLine.startsWith("\t") && ingressLine.trim() !== "") {
                    break;
                }
                const trimmed = ingressLine.trim();
                if (trimmed.startsWith("- hostname:")) {
                    const hostname = trimmed.slice("- hostname:".length).trim();
                    i++;
                    const serviceLine = lines[i]?.trim() ?? "";
                    const service = serviceLine.startsWith("service:") ? serviceLine.slice("service:".length).trim() : "";
                    ingress.push({ hostname, service });
                }
                else if (trimmed.startsWith("- service:")) {
                    const service = trimmed.slice("- service:".length).trim();
                    ingress.push({ hostname: "", service });
                }
                else {
                    i++;
                    continue;
                }
                i++;
                continue;
            }
            continue;
        }
        i++;
    }
    return { tunnel, credentialsFile, ingress };
}
function serializeConfig(config) {
    const lines = [
        `tunnel: ${config.tunnel}`,
        `credentials-file: ${config.credentialsFile}`,
        "ingress:",
    ];
    for (const rule of config.ingress) {
        if (rule.hostname) {
            lines.push(`  - hostname: ${rule.hostname}`);
            lines.push(`    service: ${rule.service}`);
        }
        else {
            lines.push(`  - service: ${rule.service}`);
        }
    }
    return lines.join("\n") + "\n";
}
/**
 * Add or update an ingress rule for a hostname.
 * The catch-all rule (no hostname, typically `http_status:404`) is always kept last.
 * If a rule for the hostname already exists, its service is updated in place.
 */
export function upsertIngressRule(config, hostname, service) {
    const catchAll = config.ingress.find((r) => !r.hostname);
    const named = config.ingress.filter((r) => r.hostname);
    const existing = named.find((r) => r.hostname === hostname);
    if (existing) {
        existing.service = service;
    }
    else {
        named.push({ hostname, service });
    }
    return {
        ...config,
        ingress: [...named, catchAll ?? { hostname: "", service: "http_status:404" }],
    };
}
/**
 * Write a fresh cloudflared config.yml for a profile-managed tunnel. Unlike
 * `ensureIngressRule` this always rewrites the file with the exact tunnel
 * UUID and credentials-file path — callers use it right after materializing
 * a runtime-delivered credential blob, where any pre-existing config.yml in
 * the profile dir is assumed to be stale.
 *
 * `rules` carries the full list of named ingress rules to write, each with
 * its own `service` target. This matters for legacy migration: one tunnel
 * may serve several hostnames (e.g. `jessica.visionclawobs.com` →
 * `http://localhost:3101` for obs, and `hailong-polymarket.visionclawobs.com`
 * → `http://localhost:8888` for an unrelated local service). Collapsing all
 * of those onto a single service would silently break the non-obs routes.
 *
 * Rules are de-duplicated by hostname case-insensitively, keeping the first
 * occurrence. Blank hostnames are dropped. The catch-all
 * `http_status:404` rule is always appended last.
 */
export function writeProfileTunnelConfig(opts) {
    const dir = path.dirname(opts.configPath);
    fs.mkdirSync(dir, { recursive: true });
    let config = {
        tunnel: opts.tunnelId,
        credentialsFile: opts.credentialsFile,
        ingress: [{ hostname: "", service: "http_status:404" }],
    };
    const seenHostnames = new Set();
    for (const rule of opts.rules) {
        const hostname = rule.hostname.trim();
        if (hostname === "")
            continue;
        const key = hostname.toLowerCase();
        if (seenHostnames.has(key))
            continue;
        seenHostnames.add(key);
        config = upsertIngressRule(config, hostname, rule.service);
    }
    fs.writeFileSync(opts.configPath, serializeConfig(config), "utf-8");
}
/**
 * Ensure the target cloudflared `config.yml` exists and contains an ingress
 * entry for the given hostname. Creates the file if missing, merges the entry
 * if it exists, and does nothing if it already points to the correct service.
 *
 * When `opts.configPath` is provided, reads/writes that file (and mkdirs its
 * parent). Otherwise writes to `~/.cloudflared/config.yml`, matching legacy
 * production behaviour byte-for-byte.
 */
export function ensureIngressRule(tunnelId, hostname, service, opts) {
    const effectiveConfigPath = opts?.configPath ?? DEFAULT_CONFIG_PATH;
    const effectiveDir = path.dirname(effectiveConfigPath);
    fs.mkdirSync(effectiveDir, { recursive: true });
    const expectedCredentialsFile = opts?.credentialsFile ?? path.join(effectiveDir, `${tunnelId}.json`);
    let config;
    if (fs.existsSync(effectiveConfigPath)) {
        config = parseConfig(fs.readFileSync(effectiveConfigPath, "utf-8"));
        // If a tunnel with the same name was deleted and recreated, it will have a
        // new UUID. Always rewrite the tunnel UUID and credentials file to match
        // the currently resolved tunnel rather than preserving stale config.yml data.
        config.tunnel = tunnelId;
        config.credentialsFile = expectedCredentialsFile;
    }
    else {
        config = {
            tunnel: tunnelId,
            credentialsFile: expectedCredentialsFile,
            ingress: [{ hostname: "", service: "http_status:404" }],
        };
    }
    const existing = config.ingress.find((r) => r.hostname === hostname);
    if (existing?.service === service &&
        config.tunnel === tunnelId &&
        config.credentialsFile === expectedCredentialsFile) {
        return;
    }
    config = upsertIngressRule(config, hostname, service);
    fs.writeFileSync(effectiveConfigPath, serializeConfig(config), "utf-8");
}
//# sourceMappingURL=cloudflared-config.js.map