/**
 * Cloudflare tunnel setup — deterministic orchestrator.
 *
 * the tunnel setup flow (login → create → enable → verify) has a
 * deterministic shape. Every branch is driven by a structured signal —
 * either `tunnel-status`'s `unhealthyReason` enum, or the operator's
 * literal input (domain name, subdomain names, "done").
 * the flow was driven by the LLM reading SKILL.md prose and picking the
 * next `tunnel-*` tool; that allowed a dozen+ recurrences where the LLM
 * forked, dispatched Playwright to the Cloudflare dashboard, or fabricated
 * a `cp cert.pem` workaround. None of those deviations are reachable here
 * because the LLM no longer chooses — this module does.
 *
 * Contract:
 *   - `runOrchestrator(input)` is the single entry point. One call advances
 *     one deterministic step. The LLM's only job is to pass the operator's
 *     literal input through (domain, "done", etc.) and relay the structured
 *     result verbatim.
 *   - Every transition emits a `[cloudflare:setup-run:phase-transition]` log
 *     line. Every internal sub-tool call emits
 *     `[cloudflare:setup-run:subtool-call]`. Every terminal state emits
 *     `[cloudflare:setup-run:terminal]`. These give a single grep path
 *     (`[cloudflare:setup-run:`) to the full transition chain post-hoc.
 *   - State is persisted at `~/{configDir}/cloudflared/setup.state.json`
 *     alongside `tunnel.state` and `login.state`. On corruption or absence
 *     the orchestrator reconciles from live `tunnel-status` + operator
 *     input (no "state file not found" crash).
 *   - The tool-surface gate in `platform/ui/app/lib/tool-surface-filter.ts`
 *     reads this state file every turn and removes the raw `tunnel-*`,
 *     Playwright, Bash, Write, Edit tools from the LLM's menu whenever
 *     `phase !== 'idle' && phase !== 'healthy'`. No LLM regression can
 *     re-enter the loop because the tools that enabled it are literally
 *     absent from the menu.
 */
import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import * as cloudflared from "./cloudflared.js";
import { deviceUrlBlock } from "../../../../../lib/device-url/dist/index.js";
import { requirePortEnv } from "../../../../../lib/require-port-env/dist/index.js";
import { hostname as osHostname } from "node:os";
function initialState() {
    return {
        phase: "idle",
        domain: null,
        adminSubdomain: null,
        publicSubdomain: null,
        tunnelId: null,
        phaseEnteredAt: new Date().toISOString(),
        unhealthyReason: null,
    };
}
// ---------------------------------------------------------------------------
// State file I/O
// ---------------------------------------------------------------------------
/**
 * The orchestrator state file path. Consumers OUTSIDE this module (e.g. the
 * tool-surface filter in `platform/ui/app/lib/tool-surface-filter.ts`) also
 * need this path to read the phase. Exported so the two sides cannot drift.
 *
 * Brand-aware via loadBrand(); falls back to `.maxy` when PLATFORM_ROOT is
 * unset. The fallback matters only for the tool-surface filter running
 * outside the cloudflare MCP server's process — the MCP server always has
 * PLATFORM_ROOT set by the claude-agent spawn.
 */
export function setupStatePath(configDirOverride) {
    const configDir = configDirOverride ?? (() => {
        try {
            return cloudflared.loadBrand().configDir;
        }
        catch {
            return ".maxy";
        }
    })();
    return join(homedir(), configDir, "cloudflared", "setup.state.json");
}
function readState() {
    const path = setupStatePath();
    if (!existsSync(path))
        return initialState();
    try {
        const parsed = JSON.parse(readFileSync(path, "utf-8"));
        // Loose validation — if the file is malformed, treat as idle and rely
        // on `tunnel-status` reconciliation to map back to the right phase.
        // A "state file not found" crash would be a silent failure mode the
        // operator cannot recover from; mapping to idle is loud-failure (log)
        // but non-fatal.
        if (typeof parsed?.phase !== "string") {
            console.error(`[cloudflare:setup-run:state-malformed] path=${path} — resetting to idle`);
            return initialState();
        }
        return {
            phase: parsed.phase,
            domain: parsed.domain ?? null,
            adminSubdomain: parsed.adminSubdomain ?? null,
            publicSubdomain: parsed.publicSubdomain ?? null,
            tunnelId: parsed.tunnelId ?? null,
            phaseEnteredAt: parsed.phaseEnteredAt ?? new Date().toISOString(),
            unhealthyReason: parsed.unhealthyReason ?? null,
        };
    }
    catch (err) {
        console.error(`[cloudflare:setup-run:state-read-error] path=${path} err=${err instanceof Error ? err.message : String(err)}`);
        return initialState();
    }
}
function writeState(state) {
    const path = setupStatePath();
    mkdirSync(join(homedir(), cloudflared.loadBrand().configDir, "cloudflared"), { recursive: true });
    writeFileSync(path, JSON.stringify(state, null, 2), { mode: 0o600 });
}
function deleteState() {
    const path = setupStatePath();
    try {
        unlinkSync(path);
    }
    catch (err) {
        const code = err.code;
        if (code !== "ENOENT") {
            console.error(`[cloudflare:setup-run:state-delete-error] path=${path} err=${err}`);
        }
    }
}
function transition(state, next, reason) {
    if (state.phase !== next) {
        console.error(`[cloudflare:setup-run:phase-transition] from=${state.phase} to=${next} reason=${reason}`);
    }
    const updated = {
        ...state,
        phase: next,
        phaseEnteredAt: new Date().toISOString(),
    };
    writeState(updated);
    return updated;
}
// ---------------------------------------------------------------------------
// Read-only status
//
// Exposed so the tool-surface filter (outside this MCP server) can read
// orchestrator phase without advancing it, and so the companion
// `cloudflare-setup-status` tool can surface it to the admin agent.
// ---------------------------------------------------------------------------
export function readOrchestratorState() {
    return readState();
}
/**
 * Whether the tool-surface filter should treat this device as "setup in
 * progress". Matches the gate condition named in the task file: the filter
 * lifts whenever phase is idle or terminal (healthy / unhealthy).
 */
export function isSetupActive(state) {
    return state.phase !== "idle" && state.phase !== "healthy" && state.phase !== "unhealthy";
}
function subtoolLog(name, phase) {
    console.error(`[cloudflare:setup-run:subtool-call] subtool=${name} phase=${phase}`);
}
function terminalLog(outcome, reason) {
    console.error(`[cloudflare:setup-run:terminal] outcome=${outcome} reason=${reason}`);
}
function operatorInputLog(phase, repr) {
    console.error(`[cloudflare:setup-run:operator-input] phase=${phase} input=${repr}`);
}
// ---------------------------------------------------------------------------
// Subdomain validation
//
// The operator types subdomain labels directly. Reject anything that would
// break `cloudflared tunnel route dns` before shelling out. Labels: letters,
// digits, hyphens; 1-63 chars; no leading/trailing hyphen.
// ---------------------------------------------------------------------------
const SUBDOMAIN_PATTERN = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/i;
function validateSubdomain(label, name) {
    if (label.length === 0)
        return `${name} is empty`;
    if (label.includes("."))
        return `${name} must be a single label (got "${label}"). Use just the prefix, e.g. "admin" — not "admin.example.com".`;
    if (!SUBDOMAIN_PATTERN.test(label))
        return `${name} "${label}" has invalid characters. Use letters, digits, and hyphens only.`;
    return null;
}
// ---------------------------------------------------------------------------
// Card builder — the `maxy-device-url` block wrapped in intent prose
// ---------------------------------------------------------------------------
function signInCard(authUrl, accountHint) {
    const block = deviceUrlBlock({
        url: authUrl,
        intent: "Sign in to Cloudflare",
        hostname: osHostname(),
    });
    return (`Cloudflare sign-in started on this device.\n\n` +
        `${block}\n\n` +
        `Click the button to open the sign-in page on this device's browser. ` +
        `Pick the account that owns ${accountHint} — the account name is in the ` +
        `top-left of the Cloudflare page, next to the little orange cloud — then ` +
        `click Authorize. Tell me when you have finished.`);
}
function accountSwitchCard(hostname) {
    return (`That Cloudflare account does not own ${hostname}.\n\n` +
        `Open Cloudflare in your browser. Look at the name in the top-left. ` +
        `If it is not the account that owns ${hostname}, switch to the correct ` +
        `account using the top-left dropdown. Once the correct account is showing, ` +
        `tell me and I will start the sign-in again.`);
}
// ---------------------------------------------------------------------------
// The orchestrator entry point
//
// Every call:
//   (1) Reads persisted state.
//   (2) Probes live tunnel-status.
//   (3) Reconciles — if status shows we're further along than persisted,
//       advance persisted to match.
//   (4) Handles current phase.
//
// Step (3) is what makes re-entry and cold-start correct. An operator who
// deletes the state file mid-flow gets reconciled to the right phase on
// the next call because tunnel-status is the ground truth. Fresh-install
// with no state and no cert starts at `idle` → `awaiting-domain`. Fresh-
// install with no state but cert already bound (migration from a prior
// setup) maps to `awaiting-hostnames`.
// ---------------------------------------------------------------------------
export async function runOrchestrator(input) {
    let state = readState();
    console.error(`[cloudflare:setup-run:enter] phase=${state.phase} domain=${state.domain ?? "none"}`);
    // Log operator input once, terse — helpful post-hoc when diagnosing how
    // the state transitioned. Never log secrets; domain is public by design.
    const inputRepr = JSON.stringify({
        domain: input.domain ?? null,
        adminSubdomain: input.adminSubdomain ?? null,
        publicSubdomain: input.publicSubdomain ?? null,
        confirmed: input.confirmed ?? false,
    });
    operatorInputLog(state.phase, inputRepr);
    // ---------------------------------------------------------------------
    // Reconcile persisted phase against live tunnel-status.
    //
    // The reconciliation rule: tunnel-status is always right. If status
    // says we're healthy, we are healthy (regardless of persisted phase).
    // If status says we have a cert but persisted says we still need one,
    // we skip ahead to `awaiting-hostnames`. If persisted domain is null
    // but input provides one, adopt it.
    // ---------------------------------------------------------------------
    if (input.domain) {
        const domainTrimmed = input.domain.trim().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
        if (domainTrimmed.length === 0) {
            return { text: "Invalid domain — please give me the bare domain, e.g. maxy.bot.", phase: state.phase, healthy: false };
        }
        if (state.domain !== domainTrimmed) {
            state = { ...state, domain: domainTrimmed };
            writeState(state);
        }
    }
    if (!state.domain && state.phase === "idle") {
        state = transition(state, "awaiting-domain", "initial-entry-no-domain");
        return {
            text: "What domain do you want to use for the tunnel? For example: maxy.bot.",
            phase: state.phase,
            healthy: false,
        };
    }
    // Probe live tunnel-status. When `state.domain` is null we pass undefined
    // and let cloudflared use persisted state.
    subtoolLog("tunnel-status", state.phase);
    const status = await cloudflared.getStatus(state.domain ?? undefined);
    // Terminal healthy short-circuit — same answer from any phase.
    if (status.healthy) {
        // A second call after initial healthy convergence is a no-op. Log once
        // as terminal to mark the flow closed.
        if (state.phase !== "healthy") {
            state = transition(state, "healthy", "status-healthy");
            terminalLog("healthy", "all-probes-ok");
        }
        else {
            console.error(`[cloudflare:setup-run:no-op] reason=already-healthy`);
        }
        const urls = status.probes.map((p) => `  https://${p.hostname}`).join("\n");
        return {
            text: `Tunnel is healthy.\n\n${urls}`,
            phase: state.phase,
            healthy: true,
        };
    }
    // --- From here: phase-specific advance logic -------------------------
    // If we have a cert and it matches the binding, we are past the login
    // phase. Don't re-spawn login.
    const authedAndReady = status.hasCert && status.bound;
    // Determine next action from (persisted phase, status, input).
    switch (state.phase) {
        case "awaiting-domain": {
            // We have a domain now (input.domain was applied above). Advance.
            if (authedAndReady) {
                state = transition(state, "awaiting-hostnames", "cert-already-bound");
            }
            else {
                state = transition(state, "awaiting-signin", "no-cert-spawning-login");
            }
            return continueFromPhase(state, input, status);
        }
        case "idle": {
            // Cold start with domain already supplied (rare — the check above
            // only transitions to awaiting-domain when there's no domain).
            if (authedAndReady) {
                state = transition(state, "awaiting-hostnames", "cert-already-bound");
            }
            else {
                state = transition(state, "awaiting-signin", "cold-start-needs-login");
            }
            return continueFromPhase(state, input, status);
        }
        case "awaiting-signin": {
            if (authedAndReady) {
                state = transition(state, "awaiting-hostnames", "sign-in-complete");
                return continueFromPhase(state, input, status);
            }
            return continueFromPhase(state, input, status);
        }
        case "awaiting-account-switch": {
            // Operator confirms they have switched accounts. Re-login with force.
            if (input.confirmed) {
                subtoolLog("tunnel-login-force", state.phase);
                cloudflared.resetAuth();
                const res = await cloudflared.tunnelLogin();
                state = transition(state, "awaiting-signin", "account-switch-relogin");
                return {
                    text: signInCard(res.authUrl, state.domain ?? "your domain"),
                    phase: state.phase,
                    healthy: false,
                };
            }
            return {
                text: accountSwitchCard(state.domain ?? "your domain"),
                phase: state.phase,
                healthy: false,
            };
        }
        case "awaiting-hostnames": {
            return continueFromPhase(state, input, status);
        }
        case "enabling": {
            // Re-entry after crash in the middle of create/enable. Probe told
            // us we're not healthy; fall through to re-run enable.
            return continueFromPhase(state, input, status);
        }
        case "healthy": {
            // Unreachable — the healthy short-circuit above returns before here.
            // Keep for exhaustiveness.
            return { text: "Tunnel is healthy.", phase: state.phase, healthy: true };
        }
        case "unhealthy": {
            // Re-invoking after an unhealthy terminal re-probes and either
            // converges (returns healthy above) or re-emits the same reason.
            const reason = status.unhealthyReason ?? "unknown";
            return {
                text: `Tunnel is not healthy. Reason: ${reason}. Re-running setup from the current state — ask me again in a minute.`,
                phase: state.phase,
                healthy: false,
            };
        }
    }
}
/**
 * Handle a phase when it is the natural next step (or a fall-through from
 * a reconciliation). Kept separate from `runOrchestrator`'s switch so the
 * reconciliation cases above can transition and then delegate here without
 * nested switching.
 */
async function continueFromPhase(state, input, status) {
    switch (state.phase) {
        case "awaiting-signin": {
            subtoolLog("tunnel-login", state.phase);
            const res = await cloudflared.tunnelLogin();
            return {
                text: signInCard(res.authUrl, state.domain ?? "your domain"),
                phase: state.phase,
                healthy: false,
            };
        }
        case "awaiting-hostnames": {
            // Need admin subdomain from the operator.
            if (!input.adminSubdomain) {
                return {
                    text: `What subdomain should the admin interface use? ` +
                        `For example "admin" → admin.${state.domain}. ` +
                        `Optionally, what subdomain for the public chat? ` +
                        `For example "public" → public.${state.domain}. ` +
                        `Leave blank to skip the public chat.`,
                    phase: state.phase,
                    healthy: false,
                };
            }
            const adminErr = validateSubdomain(input.adminSubdomain, "admin subdomain");
            if (adminErr)
                return { text: adminErr, phase: state.phase, healthy: false };
            let publicSubdomain = null;
            if (input.publicSubdomain) {
                const pubErr = validateSubdomain(input.publicSubdomain, "public subdomain");
                if (pubErr)
                    return { text: pubErr, phase: state.phase, healthy: false };
                publicSubdomain = input.publicSubdomain;
            }
            if (publicSubdomain && publicSubdomain === input.adminSubdomain) {
                return {
                    text: `Admin and public subdomains must differ — both given as "${publicSubdomain}".`,
                    phase: state.phase,
                    healthy: false,
                };
            }
            // Persist the choices so re-entry survives mid-call crashes.
            state = { ...state, adminSubdomain: input.adminSubdomain, publicSubdomain };
            writeState(state);
            // Transition to enabling and run create + enable + verify.
            state = transition(state, "enabling", "hostnames-received");
            try {
                // tunnel-create
                subtoolLog("tunnel-create", state.phase);
                const tunnelName = (state.domain ?? "tunnel").replace(/\./g, "-");
                const tunnel = cloudflared.createTunnelCli(tunnelName);
                const adminHostname = `${input.adminSubdomain}.${state.domain}`;
                const publicHostname = publicSubdomain ? `${publicSubdomain}.${state.domain}` : null;
                const hostnames = publicHostname ? [adminHostname, publicHostname] : [adminHostname];
                const platformPort = requirePortEnv("PLATFORM_PORT", { tag: "cloudflare:tunnel-create" });
                const configPath = cloudflared.writeLocalConfig(tunnel.tunnelId, tunnel.credentialsPath, hostnames, platformPort);
                cloudflared.saveTunnelIdentity({
                    tunnelId: tunnel.tunnelId,
                    tunnelName: tunnel.tunnelName,
                    domain: state.domain ?? "",
                    configPath,
                    credentialsPath: tunnel.credentialsPath,
                    adminHostname,
                    publicHostname,
                });
                state = { ...state, tunnelId: tunnel.tunnelId };
                writeState(state);
                // Route DNS for each hostname.
                subtoolLog("tunnel-route-dns", state.phase);
                for (const h of hostnames) {
                    await cloudflared.routeDnsCli(tunnel.tunnelId, h);
                }
                // tunnel-enable
                subtoolLog("tunnel-enable", state.phase);
                cloudflared.startTunnel({
                    tunnelId: tunnel.tunnelId,
                    tunnelName: tunnel.tunnelName,
                    domain: state.domain ?? "",
                    configPath,
                    credentialsPath: tunnel.credentialsPath,
                });
                // Poll `tunnel-status` until healthy or a hard reason surfaces.
                // Same retry budget as the standalone tunnel-enable tool's verify
                // loop: 6 attempts × 5s = 30s.
                for (let attempt = 0; attempt < 6; attempt++) {
                    if (attempt > 0)
                        await new Promise((r) => setTimeout(r, 5000));
                    const probe = await cloudflared.getStatus(state.domain ?? undefined);
                    if (probe.healthy) {
                        state = transition(state, "healthy", "create-enable-verified");
                        terminalLog("healthy", "create-enable-verified");
                        const urls = probe.probes.map((p) => `  https://${p.hostname}`).join("\n");
                        return { text: `Tunnel is healthy.\n\n${urls}`, phase: state.phase, healthy: true };
                    }
                    if (probe.unhealthyReason === "bound-account-does-not-own-hostname") {
                        state = transition(state, "awaiting-account-switch", "post-enable-wrong-account");
                        return {
                            text: accountSwitchCard(state.domain ?? "your domain"),
                            phase: state.phase,
                            healthy: false,
                        };
                    }
                }
                // Couldn't verify within retry budget — emit a diagnostic unhealthy
                // terminal. Operator can re-invoke cloudflare-setup-run later; if
                // the edge caught up by then it will converge to healthy.
                const final = await cloudflared.getStatus(state.domain ?? undefined);
                const reason = final.unhealthyReason ?? "edge-not-yet-reachable";
                state = { ...state, unhealthyReason: reason };
                state = transition(state, "unhealthy", `verify-failed-${reason}`);
                terminalLog("unhealthy", reason);
                return {
                    text: `Tunnel created and started but the end-to-end probe has not converged yet ` +
                        `(reason: ${reason}). DNS propagation can take 1-5 minutes. Ask me to set up ` +
                        `the tunnel again in a minute to re-probe.`,
                    phase: state.phase,
                    healthy: false,
                };
            }
            catch (err) {
                // tunnel-create shelled refusal — detect the wrong-account class
                // and route to awaiting-account-switch. Other errors bubble up as
                // unhealthy with the message.
                if (err instanceof cloudflared.CloudflareRefusalError) {
                    const reason = err.refusal.reason;
                    if (reason === "post-flight-fqdn-mismatch" ||
                        reason === "hostname-zone-not-routable" ||
                        reason === "account-drift" ||
                        reason === "unbound-device") {
                        state = transition(state, "awaiting-account-switch", `refusal-${reason}`);
                        return {
                            text: accountSwitchCard(state.domain ?? "your domain"),
                            phase: state.phase,
                            healthy: false,
                        };
                    }
                }
                const msg = err instanceof Error ? err.message : String(err);
                state = { ...state, unhealthyReason: "create-or-enable-failed" };
                state = transition(state, "unhealthy", "create-or-enable-threw");
                terminalLog("unhealthy", "create-or-enable-threw");
                return {
                    text: `Tunnel setup failed: ${msg}`,
                    phase: state.phase,
                    healthy: false,
                };
            }
        }
        case "enabling": {
            // Re-enter mid-enable after a crash. Delegate back to
            // awaiting-hostnames with the persisted subdomains as "input" so
            // the create/enable/verify block runs again. Idempotent: tunnel
            // create reuses an existing tunnel by name; DNS route is overwritten.
            if (state.adminSubdomain) {
                const recoveredInput = {
                    adminSubdomain: state.adminSubdomain,
                    publicSubdomain: state.publicSubdomain ?? undefined,
                };
                const recoveredState = transition(state, "awaiting-hostnames", "re-entry-after-enabling-crash");
                return continueFromPhase(recoveredState, recoveredInput, status);
            }
            // Somehow hit enabling without a stored adminSubdomain — reset to
            // awaiting-hostnames and ask again.
            state = transition(state, "awaiting-hostnames", "re-entry-missing-subdomains");
            return continueFromPhase(state, input, status);
        }
        case "idle":
        case "awaiting-domain":
        case "awaiting-account-switch":
        case "healthy":
        case "unhealthy":
            // Unreachable in this helper — the top-level switch handles these.
            return {
                text: `Unexpected phase: ${state.phase}. Ask me again.`,
                phase: state.phase,
                healthy: false,
            };
    }
}
// ---------------------------------------------------------------------------
// Reset — exposed for tests and for the hypothetical "start over" admin flow
// ---------------------------------------------------------------------------
export function resetOrchestrator() {
    console.error(`[cloudflare:setup-run:reset]`);
    deleteState();
}
//# sourceMappingURL=setup-orchestrator.js.map