/**
 * Legacy cloudflared tunnel adoption.
 *
 * Phase 1 of the "runtime credentials" rollout requires every agent to
 * operate against the profile-managed tunnel directory
 * (`~/.visionclaw/profiles/<profile>/tunnel/`) rather than the shared
 * user-level `~/.cloudflared/` directory. Before the onboarding service
 * can safely issue tunnel `credentialBundle` deliveries, every existing agent must
 * first be migrated so the two worlds don't overlap.
 *
 * This module performs a one-shot, file-level adoption:
 *   1. Read `~/.cloudflared/config.yml` to discover the tunnel UUID,
 *      credentials-file path, and ingress hostname used today.
 *   2. Copy the `{UUID}.json` credentials into the profile tunnel dir.
 *   3. Write a fresh profile `config.yml` that points at the copied
 *      credentials and preserves the existing hostname.
 *
 * Crucially, this module never touches Cloudflare's control plane
 * (no `cloudflared tunnel create`, no `tunnel route dns`). It also never
 * deletes the legacy `~/.cloudflared` files — they stay in place as a
 * safety net for repair / rollback until a later phase cleans them up.
 *
 * Idempotency:
 *   - If `probeLocalTunnelCredential()` already returns a valid snapshot,
 *     adoption is skipped (profile dir is the source of truth).
 *   - If any precondition fails, adoption is skipped — we never half-write
 *     the profile dir.
 */
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { spawnSync } from "node:child_process";
import { getProfileTunnelDir, getProfileTunnelConfigPath, getProfileTunnelCredentialsPath, } from "../config/index.js";
import { logger } from "../logger.js";
import { parseCloudflaredConfigYaml, writeProfileTunnelConfig, } from "./cloudflared-config.js";
import { probeLocalTunnelCredential } from "../agent/runtime-credentials.js";
/** Default location of the legacy cloudflared state on disk. */
function defaultLegacyDir() {
    return path.join(os.homedir(), ".cloudflared");
}
const DEV_TUNNEL_HOST_RE = /\.visionclawobs\.dev$/i;
const OPS_CLAW_MINI_HOST_RE = /^claw-mini-[a-z0-9-]+-ssh\.visionclawobs\.com$/i;
const OPS_CLAW_MINI_NAME_RE = /^claw-mini-[a-z0-9-]+$/i;
function isHistoricalWrongDevHostname(hostname) {
    return DEV_TUNNEL_HOST_RE.test(hostname.trim());
}
function hasClawMiniOpsIngress(parsed) {
    return parsed.ingress.some((rule) => OPS_CLAW_MINI_HOST_RE.test(rule.hostname.trim()));
}
function isClawMiniTunnelName(name) {
    return OPS_CLAW_MINI_NAME_RE.test(name.trim());
}
function serializeCloudflaredConfig(config) {
    const lines = [
        `tunnel: ${config.tunnel}`,
        `credentials-file: ${config.credentialsFile}`,
        "ingress:",
    ];
    for (const rule of config.ingress) {
        if (rule.hostname.trim() !== "") {
            lines.push(`  - hostname: ${rule.hostname}`);
            lines.push(`    service: ${rule.service}`);
        }
        else {
            lines.push(`  - service: ${rule.service}`);
        }
    }
    return lines.join("\n") + "\n";
}
/**
 * Normalize the ingress section of a legacy `~/.cloudflared/config.yml` in
 * place so the about-to-be-archived file on disk is a faithful, deduplicated
 * snapshot of the hostnames the agent actually served. Two transformations
 * are applied, in order:
 *
 *   1. Drop every rule whose hostname ends in `.visionclawobs.dev` — those
 *      are historical DNS mistakes that were later superseded by the
 *      `.visionclawobs.com` canonical hostname.
 *   2. Collapse consecutive / repeated named hostnames (case-insensitive) to
 *      the first occurrence. This defends against a legacy config where an
 *      earlier in-place fix appended the `.com` rule alongside an existing
 *      duplicate instead of replacing it.
 *
 * Catch-all rules (no hostname, e.g. `service: http_status:404`) are
 * intentionally left untouched — they're always semantically equivalent in
 * our codebase and rewriting them would be out of scope for the user-
 * observed hostname duplication concern.
 *
 * Exported so unit tests can exercise the pure file-level transform without
 * depending on the archive/zip side effect (zip on some test environments
 * is not installed, and on success the directory is removed before tests
 * could read the normalized bytes back).
 */
export function normalizeLegacyIngress(legacyConfigPath) {
    if (!fs.existsSync(legacyConfigPath)) {
        return { prunedCount: 0, dedupedCount: 0 };
    }
    const configText = fs.readFileSync(legacyConfigPath, "utf-8");
    const parsed = parseCloudflaredConfigYaml(configText);
    const seenHostnames = new Set();
    let prunedCount = 0;
    let dedupedCount = 0;
    const normalizedIngress = [];
    for (const rule of parsed.ingress) {
        const trimmed = rule.hostname.trim();
        if (trimmed === "") {
            normalizedIngress.push(rule);
            continue;
        }
        if (isHistoricalWrongDevHostname(rule.hostname)) {
            prunedCount++;
            continue;
        }
        const key = trimmed.toLowerCase();
        if (seenHostnames.has(key)) {
            dedupedCount++;
            continue;
        }
        seenHostnames.add(key);
        normalizedIngress.push(rule);
    }
    if (prunedCount === 0 && dedupedCount === 0) {
        return { prunedCount: 0, dedupedCount: 0 };
    }
    fs.writeFileSync(legacyConfigPath, serializeCloudflaredConfig({
        tunnel: parsed.tunnel,
        credentialsFile: parsed.credentialsFile,
        ingress: normalizedIngress,
    }), "utf-8");
    return { prunedCount, dedupedCount };
}
export function archiveLegacyCloudflaredDir(legacyDir) {
    if (!fs.existsSync(legacyDir)) {
        return {
            status: "skipped",
            reason: "missing",
            prunedDevIngressCount: 0,
            dedupedIngressCount: 0,
        };
    }
    let prunedDevIngressCount = 0;
    let dedupedIngressCount = 0;
    try {
        const normalize = normalizeLegacyIngress(path.join(legacyDir, "config.yml"));
        prunedDevIngressCount = normalize.prunedCount;
        dedupedIngressCount = normalize.dedupedCount;
    }
    catch (err) {
        return {
            status: "failed",
            reason: `failed to normalize legacy ingress rules: ${err instanceof Error ? err.message : String(err)}`,
            prunedDevIngressCount,
            dedupedIngressCount,
        };
    }
    const stamp = new Date().toISOString().replace(/[:.]/g, "-");
    const archivePath = `${legacyDir}.pre-migration-${stamp}.zip`;
    const zipResult = spawnSync("zip", ["-rq", archivePath, path.basename(legacyDir)], {
        cwd: path.dirname(legacyDir),
        encoding: "utf-8",
        timeout: 120_000,
        windowsHide: true,
    });
    if (zipResult.status === 0) {
        try {
            fs.rmSync(legacyDir, { recursive: true, force: true });
            return {
                status: "archived",
                archivePath,
                prunedDevIngressCount,
                dedupedIngressCount,
            };
        }
        catch (err) {
            return {
                status: "failed",
                reason: `failed to remove legacy directory after zip: ${err instanceof Error ? err.message : String(err)}`,
                prunedDevIngressCount,
                dedupedIngressCount,
            };
        }
    }
    try {
        const fallbackDir = `${legacyDir}.pre-migration-${stamp}`;
        fs.renameSync(legacyDir, fallbackDir);
        return {
            status: "renamed",
            backupDir: fallbackDir,
            prunedDevIngressCount,
            dedupedIngressCount,
        };
    }
    catch (err) {
        return {
            status: "failed",
            reason: `failed to archive legacy directory: ${err instanceof Error ? err.message : String(err)}`,
            prunedDevIngressCount,
            dedupedIngressCount,
        };
    }
}
/**
 * Run legacy → profile adoption if the preconditions are met.
 *
 * Returns a structured outcome so callers can log what happened. Never
 * throws: any unexpected failure degrades to a `skipped` outcome with the
 * narrowest reason we can determine.
 */
export function adoptLegacyTunnelIfNeeded(config, opts = {}) {
    // 1. Profile dir already has a materialized tunnel → nothing to do.
    //    This guarantees adoption runs at most once per profile across
    //    restarts, and also short-circuits after server-issued runtime
    //    credentials take over in a later phase.
    const existing = probeLocalTunnelCredential();
    if (existing) {
        return { status: "skipped", reason: "already-profile-managed" };
    }
    // 2. Only cloudflared tunnels can be adopted. `off` and any future
    //    non-cloudflared transport is out of scope for this migration.
    if (config.obs.tunnel !== "cloudflared") {
        return { status: "skipped", reason: "tunnel-not-cloudflared" };
    }
    const tunnelName = (config.obs.tunnelName ?? "").trim();
    if (!tunnelName) {
        return { status: "skipped", reason: "tunnel-name-missing" };
    }
    // 3. Parse the legacy config.yml. Note: we never assume its contents are
    //    self-consistent — an agent may have been repaired manually and the
    //    config.yml can reference a tunnel that no longer exists on disk.
    const legacyDir = opts.legacyCloudflaredDir ?? defaultLegacyDir();
    const legacyConfigPath = path.join(legacyDir, "config.yml");
    if (!fs.existsSync(legacyConfigPath)) {
        return { status: "skipped", reason: "legacy-config-missing" };
    }
    let legacyConfigText;
    try {
        legacyConfigText = fs.readFileSync(legacyConfigPath, "utf-8");
    }
    catch {
        return { status: "skipped", reason: "legacy-config-unreadable" };
    }
    let parsed;
    try {
        parsed = parseCloudflaredConfigYaml(legacyConfigText);
    }
    catch {
        return { status: "skipped", reason: "legacy-config-invalid" };
    }
    // Some newer machines carry an operations tunnel in ~/.cloudflared with
    // names/hostnames like `claw-mini-*` / `claw-mini-*-ssh.visionclawobs.com`.
    // Those are not agent tunnels and must be left completely untouched:
    // no profile adoption, no archival/removal of ~/.cloudflared.
    if (hasClawMiniOpsIngress(parsed) ||
        isClawMiniTunnelName((config.obs.tunnelName ?? "").trim())) {
        return { status: "skipped", reason: "ops-claw-mini-tunnel-present" };
    }
    const tunnelId = parsed.tunnel.trim();
    const credsPathRaw = parsed.credentialsFile.trim();
    if (!tunnelId || !credsPathRaw) {
        return { status: "skipped", reason: "legacy-config-invalid" };
    }
    // 4. Preserve every non-historical named ingress rule verbatim — both
    //    the hostname AND its `service` target. Legacy agents routinely run
    //    multiple hostnames through one tunnel to different local ports
    //    (e.g. `jessica.visionclawobs.com` → obs on :3101 and
    //    `hailong-polymarket.visionclawobs.com` → a user service on :8888).
    //    We still ignore `*.visionclawobs.dev` records (historical wrong
    //    domain) and de-duplicate hostnames case-insensitively, keeping
    //    the first occurrence's service.
    const seenHostnames = new Set();
    const preservedRules = [];
    for (const rule of parsed.ingress) {
        const hostname = rule.hostname.trim();
        if (hostname === "" || isHistoricalWrongDevHostname(hostname))
            continue;
        const key = hostname.toLowerCase();
        if (seenHostnames.has(key))
            continue;
        seenHostnames.add(key);
        preservedRules.push({ hostname, service: rule.service });
    }
    const fallbackHostname = (config.obs.tunnelHostname ?? "").trim();
    if (preservedRules.length === 0 && fallbackHostname !== "") {
        // No usable ingress in the legacy config — fall back to the
        // VisionClaw-configured canonical obs hostname. The `service`
        // placeholder is harmless: the obs server overwrites it at startup
        // via ensureIngressRule() once it knows the port.
        preservedRules.push({
            hostname: fallbackHostname,
            service: "http_status:404",
        });
    }
    if (preservedRules.length === 0) {
        return { status: "skipped", reason: "hostname-missing" };
    }
    const namedHostnames = preservedRules.map((r) => r.hostname);
    const hostname = namedHostnames[0] ?? "";
    // 5. Copy the credentials JSON into the profile tunnel dir. We keep the
    //    bytes byte-identical to the legacy file so cloudflared's internal
    //    signature over the payload remains valid.
    //
    //    `credentials-file` in the legacy config is sometimes absolute,
    //    sometimes relative to the cloudflared dir — normalize.
    const legacyCredsPath = path.isAbsolute(credsPathRaw)
        ? credsPathRaw
        : path.join(legacyDir, credsPathRaw);
    if (!fs.existsSync(legacyCredsPath)) {
        return { status: "skipped", reason: "legacy-credentials-missing" };
    }
    let credsBlob;
    try {
        credsBlob = fs.readFileSync(legacyCredsPath);
    }
    catch {
        return { status: "skipped", reason: "legacy-credentials-unreadable" };
    }
    // 5b. Require the credentials JSON to agree with the tunnel UUID from the
    //     legacy config.yml before we materialise anything into the profile dir.
    //     Legacy hosts can drift into a bad state where config.yml points at
    //     one tunnel while credentials-file contains another tunnel's blob.
    //     If we adopted that blindly, obs runtime would prefer the profile
    //     tunnel path and bypass the legacy cert.pem-based validation flow.
    let credentialsTunnelId;
    try {
        const parsedCredentials = JSON.parse(credsBlob.toString("utf-8"));
        const rawTunnelId = typeof parsedCredentials.TunnelID === "string"
            ? parsedCredentials.TunnelID
            : typeof parsedCredentials.tunnelId === "string"
                ? parsedCredentials.tunnelId
                : "";
        credentialsTunnelId = rawTunnelId.trim();
    }
    catch {
        return { status: "skipped", reason: "legacy-credentials-invalid" };
    }
    if (!credentialsTunnelId) {
        return { status: "skipped", reason: "legacy-credentials-invalid" };
    }
    if (credentialsTunnelId !== tunnelId) {
        return { status: "skipped", reason: "legacy-credentials-mismatch" };
    }
    const profileTunnelDir = getProfileTunnelDir();
    const profileCredsPath = getProfileTunnelCredentialsPath(tunnelId);
    const profileConfigPath = getProfileTunnelConfigPath();
    try {
        fs.mkdirSync(profileTunnelDir, { recursive: true });
        if (process.platform !== "win32") {
            try {
                fs.chmodSync(profileTunnelDir, 0o700);
            }
            catch {
                // best-effort; some filesystems reject chmod
            }
        }
        fs.writeFileSync(profileCredsPath, credsBlob);
        if (process.platform !== "win32") {
            try {
                fs.chmodSync(profileCredsPath, 0o600);
            }
            catch {
                // best-effort; parent dir at 0o700 still limits exposure
            }
        }
        // 6. Always rewrite the profile config.yml through the canonical
        //    writer rather than copying the legacy file verbatim. This keeps
        //    the schema stable and matches what the runtime-credential path
        //    produces when credentials are issued by the server later.
        writeProfileTunnelConfig({
            configPath: profileConfigPath,
            tunnelId,
            credentialsFile: profileCredsPath,
            rules: preservedRules,
        });
    }
    catch (err) {
        logger.warn(`Legacy tunnel adoption: failed to write profile files: ${err instanceof Error ? err.message : String(err)}`);
        return { status: "skipped", reason: "write-failed" };
    }
    const rulesSummary = preservedRules.length > 1
        ? ` (preserved ${preservedRules.length} ingress rules: ${preservedRules
            .map((r) => `${r.hostname} → ${r.service}`)
            .join(", ")})`
        : ` (hostname: ${hostname} → ${preservedRules[0]?.service ?? ""})`;
    logger.system(`Legacy tunnel adoption: adopted tunnel "${tunnelName}" (${tunnelId}) from ${legacyConfigPath} into ${profileTunnelDir}${rulesSummary}`);
    return {
        status: "adopted",
        tunnelId,
        tunnelName,
        hostname,
        hostnames: namedHostnames,
        rules: preservedRules,
    };
}
/**
 * Human-readable mapping for skip reasons. Kept separate so the adoption
 * result can be logged without string-shaping at every call site.
 */
export function describeAdoptionOutcome(outcome) {
    if (outcome.status === "adopted") {
        return `adopted legacy tunnel "${outcome.tunnelName}" (${outcome.tunnelId})`;
    }
    switch (outcome.reason) {
        case "already-profile-managed":
            return "profile tunnel dir already has credentials — nothing to migrate";
        case "tunnel-not-cloudflared":
            return "obs.tunnel is not 'cloudflared' — skipping adoption";
        case "tunnel-name-missing":
            return "config.obs.tunnelName missing — skipping adoption";
        case "ops-claw-mini-tunnel-present":
            return "ops claw-mini tunnel record present in ~/.cloudflared — leaving legacy state untouched";
        case "legacy-config-missing":
            return "no ~/.cloudflared/config.yml to adopt";
        case "legacy-config-unreadable":
            return "failed to read ~/.cloudflared/config.yml";
        case "legacy-config-invalid":
            return "~/.cloudflared/config.yml is missing tunnel/credentials-file entries";
        case "legacy-credentials-missing":
            return "legacy credentials-file does not exist on disk";
        case "legacy-credentials-unreadable":
            return "failed to read legacy credentials-file";
        case "legacy-credentials-invalid":
            return "legacy credentials-file is not valid tunnel JSON";
        case "legacy-credentials-mismatch":
            return "legacy credentials-file TunnelID does not match config.yml tunnel";
        case "hostname-missing":
            return "no hostname in legacy config or config.obs.tunnelHostname";
        case "write-failed":
            return "failed to write profile tunnel files";
    }
}
//# sourceMappingURL=legacy-tunnel-adoption.js.map