import { execFileSync, spawn } from "node:child_process";
import { existsSync, readFileSync, writeFileSync, mkdirSync, openSync, } from "node:fs";
import { randomBytes } from "node:crypto";
import { join } from "node:path";
import { homedir } from "node:os";
import Cloudflare from "cloudflare";
let cachedBrand = null;
export function loadBrand() {
    if (cachedBrand)
        return cachedBrand;
    const platformRoot = process.env.PLATFORM_ROOT;
    if (!platformRoot) {
        throw new Error("PLATFORM_ROOT env var is not set — cannot determine brand configuration");
    }
    const brandPath = join(platformRoot, "config", "brand.json");
    if (!existsSync(brandPath)) {
        throw new Error(`brand.json not found at ${brandPath} — PLATFORM_ROOT may be incorrect`);
    }
    const parsed = JSON.parse(readFileSync(brandPath, "utf-8"));
    cachedBrand = {
        configDir: parsed.configDir ?? ".maxy",
        productName: parsed.productName ?? "Maxy",
    };
    return cachedBrand;
}
// ---------------------------------------------------------------------------
// Binary detection (unchanged — cloudflared binary still needed for daemon)
// ---------------------------------------------------------------------------
let cachedBinaryPath = null;
const SEARCH_PATHS = [
    "/usr/local/bin/cloudflared",
    "/usr/bin/cloudflared",
    join(homedir(), ".local/bin/cloudflared"),
];
export function findBinary() {
    if (cachedBinaryPath)
        return cachedBinaryPath;
    // Try `which` first
    try {
        cachedBinaryPath = execFileSync("which", ["cloudflared"], {
            encoding: "utf-8",
            timeout: 5000,
        }).trim();
        return cachedBinaryPath;
    }
    catch {
        // not on PATH
    }
    for (const p of SEARCH_PATHS) {
        if (existsSync(p)) {
            cachedBinaryPath = p;
            return p;
        }
    }
    return null;
}
export function isInstalled() {
    return findBinary() !== null;
}
export function version() {
    const bin = findBinary();
    if (!bin)
        return null;
    try {
        const out = execFileSync(bin, ["--version"], {
            encoding: "utf-8",
            timeout: 5000,
        });
        const match = out.match(/cloudflared version (\S+)/);
        return match?.[1] ?? out.trim();
    }
    catch {
        return null;
    }
}
// ---------------------------------------------------------------------------
// API token storage
// ---------------------------------------------------------------------------
const TOKEN_DIR = join(homedir(), ".cloudflare");
const TOKEN_FILE = join(TOKEN_DIR, "api-token");
export function readToken() {
    try {
        if (!existsSync(TOKEN_FILE))
            return null;
        return JSON.parse(readFileSync(TOKEN_FILE, "utf-8"));
    }
    catch {
        return null;
    }
}
export function writeToken(apiToken, accountId) {
    mkdirSync(TOKEN_DIR, { recursive: true });
    const data = { apiToken, accountId };
    writeFileSync(TOKEN_FILE, JSON.stringify(data), { mode: 0o600 });
}
export function hasToken() {
    return readToken() !== null;
}
// ---------------------------------------------------------------------------
// cert.pem — written by `cloudflared tunnel login`
// ---------------------------------------------------------------------------
const CERT_FILE = join(homedir(), ".cloudflared/cert.pem");
export function hasCert() {
    return existsSync(CERT_FILE);
}
/**
 * Parse the ARGO TUNNEL TOKEN PEM block from cert.pem to extract
 * APIToken and AccountTag. Returns null if cert.pem is missing,
 * the token block is absent, or the format is unexpected.
 */
export function parseCertPem() {
    try {
        if (!existsSync(CERT_FILE))
            return null;
        const pem = readFileSync(CERT_FILE, "utf-8");
        const beginMarker = "-----BEGIN ARGO TUNNEL TOKEN-----";
        const endMarker = "-----END ARGO TUNNEL TOKEN-----";
        const beginIdx = pem.indexOf(beginMarker);
        const endIdx = pem.indexOf(endMarker);
        if (beginIdx === -1 || endIdx === -1)
            return null;
        const b64 = pem
            .slice(beginIdx + beginMarker.length, endIdx)
            .replace(/\s+/g, "");
        const json = Buffer.from(b64, "base64").toString("utf-8");
        const data = JSON.parse(json);
        const apiToken = data.APIToken ?? data.apiToken;
        const accountId = data.AccountTag ?? data.accountTag ?? data.AccountID ?? data.accountID ?? data.accountId;
        if (!apiToken || !accountId)
            return null;
        return { apiToken, accountId };
    }
    catch {
        return null;
    }
}
// ---------------------------------------------------------------------------
// SDK client
// ---------------------------------------------------------------------------
export function getClient() {
    const token = readToken();
    if (!token) {
        throw new Error("No API token configured. Run cf-set-token to set one.");
    }
    return {
        client: new Cloudflare({ apiToken: token.apiToken }),
        accountId: token.accountId,
    };
}
export async function validateToken(apiToken) {
    const client = new Cloudflare({ apiToken });
    try {
        // Try zones first — most accounts have at least one zone
        const zones = await client.zones.list({ per_page: 1 });
        const zone = zones.result?.[0];
        if (zone) {
            return {
                valid: true,
                accountId: zone.account.id ?? null,
                accountName: zone.account.name ?? null,
            };
        }
        // No zones — discover account ID via accounts.list() (works for new accounts)
        const accounts = await client.accounts.list({ per_page: 1 });
        const account = accounts.result?.[0];
        if (account) {
            return {
                valid: true,
                accountId: account.id ?? null,
                accountName: account.name ?? null,
            };
        }
        // Token is valid but no zones and no accounts discoverable
        return {
            valid: true,
            accountId: null,
            accountName: null,
            error: "Token is valid but could not discover account ID — no zones or accounts found.",
        };
    }
    catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        if (msg.includes("Authentication")) {
            return {
                valid: false,
                accountId: null,
                accountName: null,
                error: "Token rejected by Cloudflare — check that it is correct and has not expired.",
            };
        }
        return {
            valid: false,
            accountId: null,
            accountName: null,
            error: msg,
        };
    }
}
export async function validateAuth() {
    const token = readToken();
    if (!token) {
        return { hasToken: false, tokenValid: false };
    }
    try {
        const client = new Cloudflare({ apiToken: token.apiToken });
        await client.zones.list({ per_page: 1 });
        return { hasToken: true, tokenValid: true };
    }
    catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        return { hasToken: true, tokenValid: false, error: msg };
    }
}
export async function listZones() {
    const { client } = getClient();
    const zones = [];
    for await (const zone of client.zones.list()) {
        zones.push({
            id: zone.id,
            name: zone.name,
            status: zone.status ?? "unknown",
            nameservers: zone.name_servers ?? [],
            account: {
                id: zone.account.id ?? "",
                name: zone.account.name ?? "",
            },
        });
    }
    return zones;
}
export async function getZoneId(domain) {
    const { client } = getClient();
    const zones = await client.zones.list({ name: domain });
    const zone = zones.result?.[0];
    if (!zone) {
        const all = await listZones();
        const available = all.map((z) => z.name).join(", ");
        throw new Error(`Zone "${domain}" not found on this account. Available zones: ${available || "none"}`);
    }
    return zone.id;
}
export async function createZone(domain) {
    const { client, accountId } = getClient();
    if (!accountId) {
        throw new Error("No account ID available. Re-run cf-set-token — the token must be re-validated to discover the account ID.");
    }
    // Idempotent — check if zone already exists on this account
    const existing = await client.zones.list({ name: domain });
    const match = existing.result?.[0];
    if (match) {
        return {
            id: match.id,
            name: match.name,
            status: match.status ?? "unknown",
            nameservers: match.name_servers ?? [],
            existing: true,
        };
    }
    const zone = await client.zones.create({
        name: domain,
        type: "full",
        account: { id: accountId },
    });
    return {
        id: zone.id,
        name: zone.name,
        status: zone.status ?? "pending",
        nameservers: zone.name_servers ?? [],
        existing: false,
    };
}
export async function createTunnel(name) {
    const { client, accountId } = getClient();
    // Check for existing tunnel with this name
    const existing = await client.zeroTrust.tunnels.cloudflared.list({
        account_id: accountId,
        name,
        is_deleted: false,
    });
    const match = existing.result?.find((t) => t.name === name);
    if (match?.id) {
        return { tunnelId: match.id, tunnelName: match.name ?? name };
    }
    // Create new tunnel — config_src: "cloudflare" means remotely-managed
    const tunnel = await client.zeroTrust.tunnels.cloudflared.create({
        account_id: accountId,
        name,
        config_src: "cloudflare",
        tunnel_secret: generateTunnelSecret(),
    });
    if (!tunnel.id) {
        throw new Error("Tunnel created but no ID returned from Cloudflare API");
    }
    return { tunnelId: tunnel.id, tunnelName: tunnel.name ?? name };
}
function generateTunnelSecret() {
    return randomBytes(32).toString("base64");
}
export async function configureTunnel(tunnelId, domain, port) {
    const { client, accountId } = getClient();
    await client.zeroTrust.tunnels.cloudflared.configurations.update(tunnelId, {
        account_id: accountId,
        config: {
            ingress: [
                { hostname: `admin.${domain}`, service: `http://localhost:${port}` },
                { hostname: `public.${domain}`, service: `http://localhost:${port}` },
                { hostname: "", service: "http_status:404" },
            ],
        },
    });
}
export async function getTunnelIngress(tunnelId) {
    const { client, accountId } = getClient();
    const config = await client.zeroTrust.tunnels.cloudflared.configurations.get(tunnelId, { account_id: accountId });
    const ingress = config?.config?.ingress;
    if (!Array.isArray(ingress))
        return [];
    return ingress.map((r) => ({
        hostname: r.hostname ?? "",
        service: r.service ?? "",
    }));
}
export async function addHostnameToIngress(tunnelId, hostname, port) {
    const effectivePort = port ?? parseInt(process.env.PLATFORM_PORT ?? "19200", 10);
    const rules = await getTunnelIngress(tunnelId);
    // Idempotent — skip if hostname already has an ingress rule
    if (rules.some((r) => r.hostname === hostname)) {
        return { added: false, alreadyPresent: true };
    }
    // Separate catch-all (hostname empty or missing) from named rules
    const namedRules = rules
        .filter((r) => r.hostname)
        .map((r) => ({ hostname: r.hostname, service: r.service }));
    // Rebuild: existing named rules + new rule + catch-all
    const { client, accountId } = getClient();
    await client.zeroTrust.tunnels.cloudflared.configurations.update(tunnelId, {
        account_id: accountId,
        config: {
            ingress: [
                ...namedRules,
                { hostname, service: `http://localhost:${effectivePort}` },
                { hostname: "", service: "http_status:404" },
            ],
        },
    });
    return { added: true, alreadyPresent: false };
}
// ---------------------------------------------------------------------------
// Ingress port verification — read-modify-write (preserves all hostnames)
//
// Reads current ingress rules, checks if any named rule's service port
// differs from expectedPort, and rewrites all rules with the correct port.
// Preserves all hostnames including aliases — unlike configureTunnel()
// which destructively sets only admin/public.
// ---------------------------------------------------------------------------
function extractPort(serviceUrl) {
    try {
        const url = new URL(serviceUrl);
        return url.port ? parseInt(url.port, 10) : null;
    }
    catch {
        return null;
    }
}
export async function fixIngressPort(tunnelId, expectedPort) {
    const rules = await getTunnelIngress(tunnelId);
    const namedRules = rules.filter((r) => r.hostname);
    if (namedRules.length === 0) {
        return { corrected: false };
    }
    // Check if any named rule has a mismatched port
    let mismatchPort = null;
    for (const rule of namedRules) {
        const port = extractPort(rule.service);
        if (port !== null && port !== expectedPort) {
            mismatchPort = port;
            break;
        }
    }
    if (mismatchPort === null) {
        return { corrected: false };
    }
    // Rewrite all named rules with the correct port, preserving hostnames
    const correctedRules = namedRules.map((r) => ({
        hostname: r.hostname,
        service: `http://localhost:${expectedPort}`,
    }));
    const { client, accountId } = getClient();
    await client.zeroTrust.tunnels.cloudflared.configurations.update(tunnelId, {
        account_id: accountId,
        config: {
            ingress: [
                ...correctedRules,
                { hostname: "", service: "http_status:404" },
            ],
        },
    });
    return { corrected: true, fromPort: mismatchPort };
}
// ---------------------------------------------------------------------------
// Alias domain persistence — ~/{configDir}/alias-domains.json
// ---------------------------------------------------------------------------
function aliasDomainPath() {
    return join(homedir(), loadBrand().configDir, "alias-domains.json");
}
export function loadAliasDomains() {
    try {
        if (!existsSync(aliasDomainPath()))
            return new Set();
        const raw = readFileSync(aliasDomainPath(), "utf-8");
        const parsed = JSON.parse(raw);
        if (!Array.isArray(parsed)) {
            console.error("[alias-domains] malformed alias-domains.json — expected array, treating as empty");
            return new Set();
        }
        return new Set(parsed.filter((h) => typeof h === "string"));
    }
    catch (err) {
        console.error(`[alias-domains] failed to read alias-domains.json: ${err}`);
        return new Set();
    }
}
export function saveAliasDomain(hostname) {
    const existing = loadAliasDomains();
    if (existing.has(hostname))
        return; // idempotent
    existing.add(hostname);
    const dir = join(homedir(), loadBrand().configDir);
    mkdirSync(dir, { recursive: true });
    writeFileSync(aliasDomainPath(), JSON.stringify([...existing], null, 2), "utf-8");
}
export async function getConnectorToken(tunnelId) {
    const { client, accountId } = getClient();
    const response = await client.zeroTrust.tunnels.cloudflared.token.get(tunnelId, { account_id: accountId });
    // Response is the token string directly
    const token = typeof response === "string" ? response : String(response);
    if (!token) {
        throw new Error("Received empty connector token from Cloudflare API");
    }
    return token;
}
// ---------------------------------------------------------------------------
// DNS record management via SDK
// ---------------------------------------------------------------------------
export async function createDnsRecord(zoneId, subdomain, tunnelId) {
    const { client } = getClient();
    const cnameTarget = `${tunnelId}.cfargotunnel.com`;
    // Check if record already exists
    const existing = await client.dns.records.list({
        zone_id: zoneId,
        name: { exact: subdomain },
        type: "CNAME",
    });
    if (existing.result?.length && existing.result.length > 0) {
        const record = existing.result[0];
        if (record.content === cnameTarget) {
            return { created: false, existing: true, updated: false };
        }
        // Stale CNAME pointing to a different tunnel — update it
        await client.dns.records.update(record.id, {
            zone_id: zoneId,
            name: subdomain,
            type: "CNAME",
            content: cnameTarget,
            proxied: true,
            ttl: 1,
        });
        return { created: false, existing: true, updated: true };
    }
    await client.dns.records.create({
        zone_id: zoneId,
        name: subdomain,
        type: "CNAME",
        content: cnameTarget,
        proxied: true,
        ttl: 1, // 1 = automatic
    });
    return { created: true, existing: false, updated: false };
}
// ---------------------------------------------------------------------------
// tunnel login — spawn `cloudflared tunnel login` and capture the auth URL
//
// Uses a log file fd (not pipes) for child stdio — eliminates SIGPIPE.
// Polls the log file for the auth URL instead of streaming from pipes.
// Tracks the login PID in a state file so subsequent calls detect an
// existing process instead of spawning duplicates.
// ---------------------------------------------------------------------------
const LOGIN_STATE_FILE = join(homedir(), ".cloudflared/login.state");
function readLoginState() {
    try {
        if (!existsSync(LOGIN_STATE_FILE))
            return null;
        return JSON.parse(readFileSync(LOGIN_STATE_FILE, "utf-8"));
    }
    catch {
        return null;
    }
}
function writeLoginState(state) {
    mkdirSync(join(homedir(), ".cloudflared"), { recursive: true });
    writeFileSync(LOGIN_STATE_FILE, JSON.stringify(state), "utf-8");
}
function clearLoginState() {
    try {
        if (existsSync(LOGIN_STATE_FILE)) {
            writeFileSync(LOGIN_STATE_FILE, "", "utf-8");
        }
    }
    catch {
        // best-effort
    }
}
/**
 * Returns the existing login state if a login process is still alive
 * and cert.pem hasn't been written yet (authorization in progress).
 */
export function getActiveLogin() {
    const state = readLoginState();
    if (!state || !state.pid)
        return null;
    if (!isProcessAlive(state.pid)) {
        console.error(`[tunnel-login] stale login state — PID ${state.pid} is dead, clearing`);
        clearLoginState();
        return null;
    }
    return state;
}
export function tunnelLogin() {
    const bin = findBinary();
    if (!bin)
        throw new Error("cloudflared is not installed");
    // Check for an existing login process
    const existing = getActiveLogin();
    if (existing) {
        console.error(`[tunnel-login] login process already running (PID ${existing.pid}), returning existing auth URL`);
        return Promise.resolve({ authUrl: existing.authUrl, alreadyRunning: true });
    }
    // Log to ~/{configDir}/logs/ — matches startTunnel() pattern
    const logDir = join(homedir(), loadBrand().configDir, "logs");
    mkdirSync(logDir, { recursive: true });
    const logPath = join(logDir, "cloudflared-login.log");
    const logFd = openSync(logPath, "a");
    console.error(`[tunnel-login] spawning cloudflared tunnel login, log: ${logPath}`);
    const child = spawn(bin, ["tunnel", "login"], {
        stdio: ["ignore", logFd, logFd],
        detached: true,
    });
    child.unref();
    if (!child.pid) {
        throw new Error("Failed to spawn cloudflared tunnel login — no PID returned");
    }
    console.error(`[tunnel-login] spawned PID ${child.pid}`);
    // Poll the log file for the auth URL
    const urlPattern = /https:\/\/dash\.cloudflare\.com[^\s]*/;
    return new Promise((resolve, reject) => {
        let elapsed = 0;
        const interval = 500;
        const timeout = 15000;
        const poller = setInterval(() => {
            elapsed += interval;
            // Check if process died before producing a URL
            if (!isProcessAlive(child.pid)) {
                clearInterval(poller);
                console.error(`[tunnel-login] PID ${child.pid} exited before producing auth URL`);
                clearLoginState();
                try {
                    const output = readFileSync(logPath, "utf-8").slice(-500);
                    reject(new Error(`cloudflared exited before producing an auth URL. Last output: ${output}`));
                }
                catch {
                    reject(new Error("cloudflared exited before producing an auth URL"));
                }
                return;
            }
            // Poll log file for the URL
            try {
                const content = readFileSync(logPath, "utf-8");
                const match = content.match(urlPattern);
                if (match) {
                    clearInterval(poller);
                    const authUrl = match[0];
                    console.error(`[tunnel-login] auth URL extracted from PID ${child.pid}: ${authUrl}`);
                    writeLoginState({
                        pid: child.pid,
                        startedAt: Date.now(),
                        authUrl,
                        logPath,
                    });
                    resolve({ authUrl });
                    return;
                }
            }
            catch {
                // log file may not be flushed yet — retry
            }
            if (elapsed >= timeout) {
                clearInterval(poller);
                console.error(`[tunnel-login] timeout — PID ${child.pid} did not produce auth URL within ${timeout}ms`);
                try {
                    process.kill(child.pid, "SIGTERM");
                }
                catch { /* already gone */ }
                clearLoginState();
                try {
                    const output = readFileSync(logPath, "utf-8").slice(-500);
                    reject(new Error(`cloudflared did not produce an auth URL within ${timeout / 1000} seconds. Output: ${output}`));
                }
                catch {
                    reject(new Error(`cloudflared did not produce an auth URL within ${timeout / 1000} seconds`));
                }
            }
        }, interval);
    });
}
// ---------------------------------------------------------------------------
// Process management — detached, survives MCP server exit
// ---------------------------------------------------------------------------
const STATE_FILE = join(homedir(), ".cloudflared/tunnel.state");
function readState() {
    try {
        if (!existsSync(STATE_FILE))
            return null;
        return JSON.parse(readFileSync(STATE_FILE, "utf-8"));
    }
    catch {
        return null;
    }
}
function writeState(state) {
    mkdirSync(join(homedir(), ".cloudflared"), { recursive: true });
    writeFileSync(STATE_FILE, JSON.stringify(state), "utf-8");
}
function isProcessAlive(pid) {
    if (pid === null || pid <= 0)
        return false;
    try {
        process.kill(pid, 0);
        return true;
    }
    catch {
        return false;
    }
}
export function getPersistedTunnelId() {
    return readState()?.tunnelId ?? null;
}
export function getPersistedDomain() {
    return readState()?.domain ?? null;
}
export async function getStatus(domain) {
    const auth = await validateAuth();
    const state = readState();
    const running = state !== null && isProcessAlive(state.pid);
    const effectiveDomain = domain ?? state?.domain ?? null;
    return {
        installed: isInstalled(),
        version: version(),
        hasCert: hasCert(),
        hasToken: auth.hasToken,
        tokenValid: auth.tokenValid,
        authError: auth.error ?? null,
        running,
        pid: running ? state.pid : null,
        tunnelId: state?.tunnelId ?? null,
        domain: effectiveDomain,
        adminUrl: effectiveDomain ? `https://admin.${effectiveDomain}` : null,
        publicUrl: effectiveDomain ? `https://public.${effectiveDomain}` : null,
        upSince: running ? state.startedAt : null,
        restartCount: 0,
    };
}
export function startTunnel(connectorToken, tunnelId, domain) {
    const bin = findBinary();
    if (!bin)
        throw new Error("cloudflared is not installed");
    // Check if already running via state file
    const state = readState();
    if (state && isProcessAlive(state.pid)) {
        throw new Error(`Tunnel is already running (PID ${state.pid})`);
    }
    // UDP buffer sizes for QUIC stability are persisted via /etc/sysctl.d/99-maxy-quic.conf
    // (written by the installer). No runtime sysctl calls needed.
    // Log to ~/{configDir}/logs/ so crashes are diagnosable
    const logDir = join(homedir(), loadBrand().configDir, "logs");
    mkdirSync(logDir, { recursive: true });
    const logPath = join(logDir, "cloudflared.log");
    const logFd = openSync(logPath, "a");
    // Spawn detached — survives MCP server exit
    const child = spawn(bin, ["tunnel", "run", "--token", connectorToken], {
        stdio: ["ignore", logFd, logFd],
        detached: true,
    });
    child.unref();
    if (!child.pid)
        throw new Error("Failed to spawn tunnel process");
    writeState({
        pid: child.pid,
        connectorToken,
        tunnelId,
        domain,
        startedAt: Date.now(),
    });
}
export function stopTunnel() {
    const state = readState();
    if (!state)
        return;
    const pid = state.pid;
    if (pid !== null && isProcessAlive(pid)) {
        try {
            process.kill(pid, "SIGTERM");
        }
        catch {
            // process may have exited between check and kill
        }
        // Force kill after 5 seconds
        setTimeout(() => {
            if (isProcessAlive(pid)) {
                try {
                    process.kill(pid, "SIGKILL");
                }
                catch {
                    /* already gone */
                }
            }
        }, 5000);
    }
    // Preserve tunnel identity, clear process lifecycle
    writeState({ ...state, pid: null, startedAt: null });
}
//# sourceMappingURL=cloudflared.js.map