/**
 * `spectral serve` — relay orchestration entry point.
 *
 * Architecture (post-Batch 2 cutover):
 *  1. Pre-flight: `requireLogin()` — same UX as the main spawn path.
 *  2. Pre-flight: `preflightSqlite()` — fail fast on a broken native module.
 *  3. Open the SQLite store and construct a `SessionStreamManager`.
 *  4. Register this machine with the backend (`ensureMachineRegistered`),
 *     which either reuses a fresh `machine.json` or POSTs to
 *     `/api/machines/register` and persists the issued JWT.
 *  5. Open a single long-lived `RelayClient` to `/agent-connection` carrying
 *     the machine JWT. Reconnects forever with backoff.
 *  6. Until Batch 3 ships, every inbound non-pong frame is acked back as
 *     `{kind:"ack", echo:<frame>}`. This proves the WS is bidirectional
 *     end-to-end and gives operators a smoke check before envelope routing.
 *  7. SIGINT/SIGTERM trigger graceful shutdown: dispose the relay (close 1000),
 *     dispose the manager (tear down agent streams), close the store.
 *
 * What this module is NOT (yet):
 *  - It does NOT translate relay envelopes into REST handler calls or WS
 *    event broadcasts. That dispatcher is Batch 3.
 *  - It does NOT bind any local port. The local HTTP server is gone.
 *
 * Flag surface:
 *  - `--machine-name <name>` — display name used at registration time.
 *  - `--port <n>` / `-p <n>` / `SPECTRAL_PORT` — accepted but warned-and-ignored
 *    so users updating from the pre-relay version don't get a hard error.
 *
 * The exported `runServe()` returns a handle so tests can drive the relay
 * loop in-process without spawning a subprocess. Tests inject
 * `relayUrlOverride` + `fetchImpl` + `webSocketImpl` to point at an
 * in-process fake backend.
 */
import { readFileSync } from "node:fs";
import { homedir, hostname } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { getConfigDir } from "../config.js";
import { NATIVE_EXTENSIONS, buildNativeExtensionsInfo } from "../sdk/coding-agent/core/extensions/native-extensions.js";
import { SettingsManager } from "../sdk/coding-agent/core/settings-manager.js";
import { getAgentDir } from "../sdk/coding-agent/config.js";
import { requireLogin } from "../preflight.js";
import { RelayClient } from "../relay/client.js";
import { detachAllSubscribers, handleAutoResearchFrame, handleCancelTurn, handleClientMessage, handleRestRequest, handleSubscribe, } from "../relay/dispatcher.js";
import { ensureMachineRegistered } from "../relay/registration.js";
import { SessionStreamManager } from "../server/session-stream.js";
import { gracefulShutdown } from "../server/shutdown.js";
import { getLiveMcpState } from "../mcp/state-getter.js";
import { preflightSqlite, SessionStore } from "../server/storage.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
/** Default backend base URL when neither env nor override is set. */
const DEFAULT_BACKEND_URL = "https://api.aexol.ai";
function readPackageVersion() {
    // dist/commands/serve.js  →  ../../package.json
    // src/commands/serve.ts → ../../package.json (tsx / vitest)
    const pkgPath = resolve(__dirname, "..", "..", "package.json");
    const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
    return pkg.version;
}
/**
 * Translate a backend HTTP(S) base URL to the relay WS(S) URL.
 * `http://x:y` → `ws://x:y/agent-connection`
 * `https://x` → `wss://x/agent-connection`
 */
export function deriveRelayUrl(backendUrl) {
    const trimmed = backendUrl.replace(/\/$/, "");
    if (trimmed.startsWith("https://")) {
        return `wss://${trimmed.slice("https://".length)}/agent-connection`;
    }
    if (trimmed.startsWith("http://")) {
        return `ws://${trimmed.slice("http://".length)}/agent-connection`;
    }
    // Already a ws[s] URL — assume the caller knows what they're doing.
    if (trimmed.startsWith("ws://") || trimmed.startsWith("wss://")) {
        return trimmed.endsWith("/agent-connection") ? trimmed : `${trimmed}/agent-connection`;
    }
    throw new Error(`Cannot derive relay URL from "${backendUrl}"`);
}
/**
 * Parse the `serve` subcommand's flag surface. Manual parser, same style as
 * `login`/`logout` (no CLI framework).
 *
 * Accepted:
 *   --machine-name <name>
 *   --machine-name=<name>
 * Deprecated (warn, don't error):
 *   --port <n>  /  -p <n>  /  --port=<n>
 *
 * Anything else throws so users notice typos.
 */
export function parseServeArgs(args) {
    const out = { deprecatedPort: false };
    for (let i = 0; i < args.length; i++) {
        const a = args[i];
        if (a === "--machine-name") {
            const next = args[i + 1];
            if (!next)
                throw new Error(`--machine-name requires a value`);
            out.machineName = next;
            i++;
            continue;
        }
        if (a.startsWith("--machine-name=")) {
            out.machineName = a.slice("--machine-name=".length);
            continue;
        }
        // Deprecated port flag — warn-and-ignore so existing scripts don't crash.
        if (a === "--port" || a === "-p") {
            out.deprecatedPort = true;
            // Swallow the value if present so we don't trip the unknown-arg branch.
            if (args[i + 1] && !args[i + 1].startsWith("-"))
                i++;
            continue;
        }
        if (a.startsWith("--port=")) {
            out.deprecatedPort = true;
            continue;
        }
        throw new Error(`Unknown argument to "serve": ${a}`);
    }
    return out;
}
export async function runServe(opts = {}) {
    const cfg = await requireLogin();
    const cwd = opts.cwd ?? homedir();
    const version = readPackageVersion();
    const installSignalHandlers = opts.installSignalHandlers ?? true;
    const silent = opts.silent ?? false;
    const dbPath = opts.dbPath ?? join(getConfigDir(), "sessions.db");
    // Pre-flight: native sqlite. Fail fast with a friendly hint BEFORE we
    // start opening sockets — keeps the error close to the cause.
    const preflight = preflightSqlite(dbPath);
    if (!preflight.ok) {
        process.stderr.write(`✗ ${preflight.error}\n`);
        process.exit(1);
    }
    const store = new SessionStore(dbPath);
    // Mutable holder for the meta-event publisher. Bound below once we
    // have both `relay` (the send target) and `registration.record.machineId`
    // (the canonical machineId the backend uses to fan out). The manager
    // and dispatcher both close over a thin wrapper that defers to this
    // ref, so a race where the title pipeline fires before the relay is
    // up just no-ops (the deferred lookup sees `undefined`) instead of
    // throwing or losing the rename.
    let publishMetaEventRef;
    const deferredPublishMetaEvent = (event) => {
        publishMetaEventRef?.(event);
    };
    // Resolve URLs. Precedence: explicit option > env > default.
    const backendUrl = opts.backendUrlOverride ?? process.env.SPECTRAL_BACKEND_URL ?? DEFAULT_BACKEND_URL;
    const relayUrl = opts.relayUrlOverride ?? process.env.SPECTRAL_RELAY_URL ?? deriveRelayUrl(backendUrl);
    // Register BEFORE constructing the SessionStreamManager. The manager
    // (and every AgentBridge it spawns) needs the machine JWT to authenticate
    // backend-proxied inference calls, so registration must succeed first.
    // Fail-fast on error — a clear message beats a hang.
    let registration;
    try {
        registration = await ensureMachineRegistered({
            backendUrl,
            apiKey: cfg.teamApiKey,
            userJwt: cfg.userJwt,
            machineNameOverride: opts.machineName,
            version,
            fetchImpl: opts.fetchImpl,
        });
    }
    catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        process.stderr.write(`✗ Could not register machine: ${msg}\n`);
        // Tear down what we've already opened so tests don't see leaks.
        try {
            store.close();
        }
        catch {
            // ignore
        }
        process.exit(1);
    }
    // Stream manager owns per-session spectral processes. Batch 3 will subscribe
    // it to relay frames; for now it's just here so the handles match the
    // shape Batch 3 expects.
    const manager = new SessionStreamManager({
        store,
        cwd,
        backendUrl,
        machineJwt: registration.record.machineJwt,
        bridgeFactory: opts.bridgeFactory,
    });
    if (!silent) {
        const action = registration.reused ? "Reusing" : "Registered";
        process.stdout.write(`${action} machine "${registration.record.machineName}" (${registration.record.machineId})\n`);
        process.stdout.write(`Connecting to relay: ${relayUrl} (cwd: ${cwd}, host: ${hostname()})\n`);
    }
    const relay = new RelayClient({
        relayUrl,
        machineJwt: registration.record.machineJwt,
        backendUrl,
        webSocketImpl: opts.webSocketImpl,
        fetchImpl: opts.fetchImpl,
        logger: silent ? { log: () => { }, warn: () => { }, error: () => { } } : console,
    });
    // Wire the meta publisher now that we have both the relay socket and
    // the canonical machineId. The backend ignores the body's machineId
    // field on `meta_event` (it stamps it from the connection identity),
    // but we still send the right value for local debuggability and to
    // avoid relying on backend behaviour that may evolve.
    publishMetaEventRef = (event) => {
        relay.send({
            kind: "meta_event",
            machineId: registration.record.machineId,
            event,
        });
    };
    // Per-session subscriber registry. The dispatcher attaches at most
    // one subscriber per (sessionId) on the first `client_message`; we
    // hand the same map back to it for every subsequent frame so attach
    // is idempotent. On relay disconnect we detach all of them so the
    // manager doesn't keep an unreachable subscriber pinned, but we
    // KEEP the underlying streams alive — a browser reconnect should
    // resume mid-turn.
    const subscribers = new Map();
    // Envelope dispatcher. We only act on `rest_request` and
    // `client_message`; everything else (welcome, pong, error, etc.) is
    // either handled by RelayClient itself or is informational.
    //
    // Frames arrive typed loosely from `RelayClient` (which is protocol-
    // agnostic — it just forwards JSON). We re-narrow via the `kind`
    // discriminator and cast to the strict envelope types from
    // `@aexol/relay-protocol`. The protocol package's own `parseFrame()`
    // already validated `kind` membership in RelayClient itself; here we
    // additionally trust that the inner shape matches its discriminator
    // (the backend is the only producer and is exercised by tests).
    relay.on("frame", (frame) => {
        if (frame.kind === "rest_request") {
            void handleRestRequest(frame, {
                store,
                manager,
                cwd,
                publishMetaEvent: deferredPublishMetaEvent,
                mcpState: getLiveMcpState() ?? undefined,
            }).then((response) => {
                relay.send(response);
            });
            return;
        }
        if (frame.kind === "client_message") {
            handleClientMessage(frame, {
                manager,
                relay,
                subscribers,
            });
            return;
        }
        if (frame.kind === "subscribe") {
            handleSubscribe(frame, {
                manager,
                relay,
                subscribers,
            });
            return;
        }
        if (frame.kind === "cancel_turn") {
            handleCancelTurn(frame, { manager });
            return;
        }
        if (frame.kind === "auto_research") {
            handleAutoResearchFrame(frame, {
                store,
                manager,
                relay,
                subscribers,
                cwd,
            });
            return;
        }
        if (frame.kind === "config_patch") {
            handleConfigPatch(frame, {
                cwd,
                agentDir: getAgentDir(),
                relay,
                machineId: registration.record.machineId,
            });
            return;
        }
        // Other frames (error, machine_disconnected addressed to us, etc.)
        // are ignored at this layer. Future batches may surface them in
        // structured logs.
    });
    // On every relay close, drop the subscriber registry. The manager
    // keeps streams alive (so `dispose` is the only place that tears
    // them down), but the previous subscribers are pointing at a stale
    // socket — let the next `client_message` attach a fresh one.
    relay.on("close", () => {
        detachAllSubscribers(manager, subscribers);
    });
    // Send native extensions info when the relay connects
    relay.on("welcome", () => {
        if (!silent)
            process.stdout.write("✓ Relay connected\n");
        broadcastNativeExtensionsInfo(cwd, relay, registration.record.machineId);
    });
    if (!silent) {
        relay.on("close", ({ code, reason }) => {
            process.stdout.write(`Relay closed (${code}${reason ? `: ${reason}` : ""}); will reconnect…\n`);
        });
        relay.on("reconnect-scheduled", ({ delayMs, attempt }) => {
            process.stdout.write(`Reconnect attempt ${attempt} in ${delayMs}ms\n`);
        });
        relay.on("error", (err) => {
            const msg = err instanceof Error ? err.message : String(err);
            process.stderr.write(`Relay error: ${msg}\n`);
        });
    }
    relay.connect();
    let closed = false;
    const close = async () => {
        if (closed)
            return;
        closed = true;
        relay.dispose();
        manager.dispose();
        try {
            store.close();
        }
        catch {
            // ignore — best-effort
        }
        if (!silent)
            process.stdout.write("Spectral relay stopped\n");
    };
    if (installSignalHandlers) {
        // Top-level error nets. A daemon must NOT die from a transient
        // unhandled rejection (e.g. a fetch promise that lost its `.catch`
        // when the relay flapped) or a stray throw from a third-party
        // library. Log loudly so we notice in operator logs, but keep the
        // process alive — the relay client owns its own reconnect loop and
        // the manager owns its own per-session error handling.
        process.on("unhandledRejection", (reason) => {
            const msg = reason instanceof Error
                ? `${reason.message}\n${reason.stack ?? ""}`
                : String(reason);
            try {
                process.stderr.write(`[serve] unhandledRejection (kept alive): ${msg}\n`);
            }
            catch {
                // ignore — best-effort
            }
        });
        process.on("uncaughtException", (err) => {
            try {
                process.stderr.write(`[serve] uncaughtException (kept alive): ${err.message}\n${err.stack ?? ""}\n`);
            }
            catch {
                // ignore — best-effort
            }
        });
        // Two-stage signal handling. The first SIGINT/SIGTERM kicks off the
        // graceful sequence (flag the dispatcher, drain in-flight turns up
        // to 5 s, close relay→manager→store, exit 0). A second signal during
        // the grace window forces an immediate exit(1) — operators must always
        // be able to kill the process with Ctrl-C twice.
        //
        // We use `on` (not `once`) so the second signal hits `gracefulShutdown`
        // again, where the entry-counter handles the force path.
        const onSignal = (_sig) => {
            void gracefulShutdown({
                inFlightCount: () => manager.activeTurnCount(),
                closeRelay: () => {
                    // Mirror our own `close()`: dispose the relay (sends close 1000).
                    // We don't await here because RelayClient.dispose is sync and
                    // the shutdown helper expects a void|Promise return.
                    relay.dispose();
                },
                disposeManager: () => {
                    manager.dispose();
                },
                closeStore: () => {
                    store.close();
                },
                logger: silent
                    ? { log: () => { }, warn: () => { }, error: () => { } }
                    : console,
            });
        };
        process.on("SIGINT", onSignal);
        process.on("SIGTERM", onSignal);
    }
    return {
        store,
        manager,
        relay,
        machineId: registration.record.machineId,
        close,
    };
}
// ---------------------------------------------------------------------------
// Native extension settings helpers
// ---------------------------------------------------------------------------
function broadcastNativeExtensionsInfo(cwd, relay, machineId) {
    const settingsManager = SettingsManager.create(cwd, getAgentDir());
    settingsManager.reload().then(() => {
        const settings = settingsManager.getSettings();
        const disabledNative = new Set(settings.disabledNativeExtensions ?? []);
        const extensions = buildNativeExtensionsInfo(disabledNative);
        relay.send({
            kind: "native_extensions_info",
            machineId,
            extensions,
        });
    }).catch((err) => {
        const msg = err instanceof Error ? err.message : String(err);
        process.stderr.write(`[serve] Failed to broadcast native extensions info: ${msg}\n`);
    });
}
async function handleConfigPatch(frame, deps) {
    const settingsManager = SettingsManager.create(deps.cwd, deps.agentDir);
    await settingsManager.reload();
    const current = settingsManager.getSettings();
    const disabledNative = new Set(current.disabledNativeExtensions ?? []);
    for (const patch of frame.patches) {
        if (patch.path === "disabledNativeExtensions" && typeof patch.value === "string") {
            if (patch.op === "add") {
                // Validate: only known native extension ids
                if (!NATIVE_EXTENSIONS.some((e) => e.id === patch.value)) {
                    process.stderr.write(`[serve] Unknown native extension id in config_patch: ${patch.value}\n`);
                    continue;
                }
                disabledNative.add(patch.value);
            }
            else if (patch.op === "remove") {
                disabledNative.delete(patch.value);
            }
        }
    }
    // Persist to settings.json
    settingsManager.setDisabledNativeExtensions([...disabledNative]);
    await settingsManager.flush();
    // Broadcast updated state back to browsers
    const extensions = buildNativeExtensionsInfo(disabledNative);
    deps.relay.send({
        kind: "native_extensions_info",
        machineId: deps.machineId,
        extensions,
    });
}
/**
 * CLI entry point used by `cli.ts`. Surfaces the deprecation warning for
 * `--port`/`SPECTRAL_PORT` once, then defers to `runServe`.
 */
export async function runServeCli(rawArgs) {
    const parsed = parseServeArgs(rawArgs);
    if (parsed.deprecatedPort || process.env.SPECTRAL_PORT) {
        process.stderr.write(`warning: --port / SPECTRAL_PORT are no longer used. ` +
            `spectral serve now connects outbound to the Aexol relay; there is no local port to bind.\n`);
    }
    return runServe({
        machineName: parsed.machineName,
    });
}
