/**
 * Phase F-2-D (D1+D2+D3): `olam host-cp <subcommand>` — operator-facing
 * lifecycle for the host CP container.
 *
 * Subcommands:
 *   start  — generate token, audit port 19000, docker compose up, write PID
 *   stop   — docker compose down, remove PID + token
 *   status — diagnostic probe (with optional --json flag)
 *
 * Wraps the compose stack at `packages/host-cp/compose.yaml`. Token
 * lives at `~/.olam/host-cp.token` (chmod 600). PID file at
 * `~/.olam/host-cp.pid`. Both are operator-state files, not repo state.
 */
import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
import Dockerode from 'dockerode';
import { auditPortsForZombies, PortHeldByZombieError } from '@olam/adapters';
import { printError, printSuccess, printInfo, printHeader, printWarning } from '../output.js';
import { resolveDockerHostOptions } from '../docker-host.js';
import { probeComposePlugin } from '../lib/health-probes.js';
import { readCliVersion } from '../cli-version.js';
// ── Constants ─────────────────────────────────────────────────────
/** Default host CP port. Matches packages/host-cp/compose.yaml. */
export const HOST_CP_PORT = 19000;
/** Path to the compose stack. Checked in order:
 *  1. Bundled-package path — used when installed via `npm install -g @pleri/olam-cli`.
 *  2. Monorepo cwd — used in source-mode dev (`pnpm dev:olam`).
 *  3. Monorepo parent cwd — fallback for nested cwd inside the repo.
 */
export function findComposeFile() {
    const candidates = [
        // Bundled path: dist/index.js lives at <pkg>/dist/; host-cp/ is a sibling of dist/
        path.resolve(path.dirname(new URL(import.meta.url).pathname), '../host-cp/compose.yaml'),
        // Source-mode: cwd is monorepo root
        path.resolve(process.cwd(), 'packages/host-cp/compose.yaml'),
        // Source-mode: cwd is one level inside the monorepo
        path.resolve(process.cwd(), '../packages/host-cp/compose.yaml'),
    ];
    for (const c of candidates) {
        if (fs.existsSync(c))
            return c;
    }
    // Last resort: let docker compose surface the error.
    return path.resolve(process.cwd(), 'packages/host-cp/compose.yaml');
}
/** Operator-state directory: `~/.olam/`. Honors OLAM_HOME for tests. */
function olamHome() {
    return process.env.OLAM_HOME ?? path.join(os.homedir(), '.olam');
}
function tokenPath() {
    return path.join(olamHome(), 'host-cp.token');
}
function pidPath() {
    return path.join(olamHome(), 'host-cp.pid');
}
/**
 * Path to the operator-state shared auth-service secret.
 *
 * Created by `olam auth up` when the auth-service container first
 * starts. Both the auth-service container and host-cp container must
 * use the same secret value as their `X-Olam-Secret` so host-cp's
 * `/credentials/*` calls authenticate correctly.
 *
 * Honors OLAM_HOME for tests (matches `olamHome()`).
 */
export function authSecretPath() {
    return path.join(olamHome(), 'auth-secret');
}
/**
 * Read the shared auth-service secret from `~/.olam/auth-secret`.
 *
 * Returns null when the file is missing (auth-service was never started
 * or the file was deleted). Returns null instead of throwing because
 * `host-cp start` should still bring host-cp up — but it then logs a
 * loud warning so the operator notices that credential surfaces will
 * 401 until they run `olam auth up` to regenerate the secret.
 *
 * Empty/whitespace files are treated as "not set" (null) — a 0-byte
 * file would otherwise round-trip to compose as an empty env var,
 * which is exactly the silent-failure mode this whole helper exists
 * to prevent.
 */
export function readAuthSecret() {
    const filePath = authSecretPath();
    if (!fs.existsSync(filePath))
        return null;
    const raw = fs.readFileSync(filePath, 'utf-8').trim();
    return raw.length > 0 ? raw : null;
}
/** Path to `~/.olam/r2-credentials.json`. Honors OLAM_HOME for tests. */
export function r2CredentialsPath() {
    return path.join(olamHome(), 'r2-credentials.json');
}
/**
 * Read R2 credentials from `~/.olam/r2-credentials.json`.
 *
 * Returns null when the file is missing — callers should print a clear
 * "operator must configure" error rather than throwing. Returns null for
 * malformed JSON so callers can surface a useful message instead of a
 * raw parse stack trace.
 */
export function readR2Credentials() {
    const filePath = r2CredentialsPath();
    if (!fs.existsSync(filePath))
        return null;
    const raw = fs.readFileSync(filePath, 'utf-8').trim();
    if (raw.length === 0)
        return null;
    try {
        const parsed = JSON.parse(raw);
        if (typeof parsed !== 'object' || parsed === null)
            return null;
        const creds = parsed;
        if (typeof creds.account_id !== 'string' ||
            typeof creds.bucket !== 'string' ||
            typeof creds.access_key_id !== 'string' ||
            typeof creds.secret_access_key !== 'string' ||
            typeof creds.public_url_base !== 'string') {
            return null;
        }
        return {
            account_id: creds.account_id,
            bucket: creds.bucket,
            access_key_id: creds.access_key_id,
            secret_access_key: creds.secret_access_key,
            public_url_base: creds.public_url_base,
        };
    }
    catch {
        return null;
    }
}
// ── Token + PID file helpers (pure; no docker calls) ──────────────
/**
 * Generate a fresh 32-byte hex token and write it to `tokenPath()` with
 * mode 0600. Creates the parent directory if missing. Idempotent: if a
 * token already exists, this overwrites it (each `start` regenerates).
 */
export function writeToken() {
    const token = crypto.randomBytes(32).toString('hex');
    const filePath = tokenPath();
    fs.mkdirSync(path.dirname(filePath), { recursive: true });
    fs.writeFileSync(filePath, token, { mode: 0o600 });
    return token;
}
export function readToken() {
    const filePath = tokenPath();
    if (!fs.existsSync(filePath))
        return null;
    return fs.readFileSync(filePath, 'utf-8').trim();
}
export function removeToken() {
    const filePath = tokenPath();
    if (!fs.existsSync(filePath))
        return false;
    fs.unlinkSync(filePath);
    return true;
}
export function writePid(pid) {
    const filePath = pidPath();
    fs.mkdirSync(path.dirname(filePath), { recursive: true });
    fs.writeFileSync(filePath, String(pid), { mode: 0o644 });
}
export function readPid() {
    const filePath = pidPath();
    if (!fs.existsSync(filePath))
        return null;
    const raw = fs.readFileSync(filePath, 'utf-8').trim();
    const n = parseInt(raw, 10);
    return Number.isFinite(n) ? n : null;
}
export function removePid() {
    const filePath = pidPath();
    if (!fs.existsSync(filePath))
        return false;
    fs.unlinkSync(filePath);
    return true;
}
/**
 * Find the host-cp container by name. Returns null if absent.
 */
export async function findHostCpContainer() {
    const docker = new Dockerode(resolveDockerHostOptions());
    const containers = await docker.listContainers({ all: true });
    for (const c of containers) {
        const names = (c.Names ?? []).map((n) => n.replace(/^\//, ''));
        if (names.includes('olam-host-cp')) {
            return {
                id: c.Id.slice(0, 12),
                name: 'olam-host-cp',
                state: c.State,
                status: c.Status,
            };
        }
    }
    return null;
}
/**
 * Phase host-cp-reliability: Probe for a running host CP, supporting
 * both bare-node (process on host) and container deployments.
 *
 * Strategy:
 *   1. HTTP probe: try http://127.0.0.1:19000/api/bootstrap. If the
 *      token file exists and the endpoint returns 200, host-cp is running.
 *      Mode is 'bare' if no docker container found, 'container' otherwise.
 *   2. Docker fallback: if HTTP probe fails, look for the docker container.
 *      If found+running, probe its port.
 *
 * Returns null if both fail, along with probe diagnostics in the
 * returned ProbeFailure for fail-loud error messages.
 *
 * @returns {Promise<{ url: string; mode: 'bare' | 'container' } | null>}
 *   Result on success, null on failure.
 */
export async function probeHostCp() {
    const candidateUrl = `http://127.0.0.1:${HOST_CP_PORT}`;
    // Step 1: HTTP probe (works for both bare-node and container, since
    // compose.yaml binds 127.0.0.1:19000. The /api/bootstrap endpoint is
    // unauthed and always returns 200 if host-cp is up.)
    let httpOk = false;
    try {
        const res = await fetch(`${candidateUrl}/api/bootstrap`, {
            signal: AbortSignal.timeout(2000),
        });
        httpOk = res.ok;
    }
    catch {
        httpOk = false;
    }
    if (httpOk) {
        // Determine mode: if docker container exists, it's container mode.
        let mode = 'bare';
        try {
            const container = await findHostCpContainer();
            if (container && container.state === 'running') {
                mode = 'container';
            }
        }
        catch {
            // Docker not available — must be bare mode.
        }
        return { url: candidateUrl, mode };
    }
    // Step 2: Docker container fallback. Handles the case where host-cp is
    // a container on a non-default port.
    try {
        const container = await findHostCpContainer();
        if (container && container.state === 'running') {
            // Probe the same 127.0.0.1:19000 (compose always binds there).
            try {
                const res = await fetch(`${candidateUrl}/api/bootstrap`, {
                    signal: AbortSignal.timeout(2000),
                });
                if (res.ok) {
                    return { url: candidateUrl, mode: 'container' };
                }
            }
            catch {
                // Container found but not responding on 19000 — fall through to null.
            }
        }
    }
    catch {
        // Docker not available — can't check containers.
    }
    return null;
}
/**
 * Gather probe failure diagnostics for fail-loud error messages.
 * Returns structured info about what was tried and why it failed.
 */
export async function gatherProbeFailureDiagnostics() {
    let bootstrapStatus = 'no response';
    try {
        const res = await fetch(`http://127.0.0.1:${HOST_CP_PORT}/api/bootstrap`, {
            signal: AbortSignal.timeout(2000),
        });
        bootstrapStatus = `HTTP ${res.status}`;
    }
    catch (err) {
        bootstrapStatus = err instanceof Error ? err.message : 'connection refused';
    }
    let containerStatus = 'not found';
    try {
        const container = await findHostCpContainer();
        if (!container) {
            containerStatus = 'not found';
        }
        else {
            containerStatus = `found (state: ${container.state})`;
        }
    }
    catch {
        containerStatus = 'docker not available';
    }
    return { bootstrapStatus, containerStatus };
}
/**
 * Probe `http://127.0.0.1:19000/health`. Returns the parsed JSON or
 * null on connection error / non-2xx.
 */
async function probeHealth() {
    try {
        const res = await fetch(`http://127.0.0.1:${HOST_CP_PORT}/health`, {
            signal: AbortSignal.timeout(2000),
        });
        if (!res.ok)
            return null;
        return (await res.json());
    }
    catch {
        return null;
    }
}
// ── docker compose wrapper ────────────────────────────────────────
/**
 * The compose service name that `olam host-cp start` pre-pulls before
 * `compose up`. Named as a constant so tests can assert this is NEVER
 * the host-cp service itself (which was already digest-pinned by
 * `olam bootstrap`; re-pulling it would bypass the digest-verification
 * step and potentially fetch a different image from the registry).
 *
 * Regression guard: if this is changed to a bare compose pull (no
 * service specified), `docker compose pull` would try to pull ALL
 * services — including `olam-host-cp`, which before PR #302 was a bare
 * image name that Docker resolved via Docker Hub → "pull access denied
 * for olam-host-cp, repository does not exist".
 */
export const SIDECAR_PULL_SERVICE = 'docker-socket-proxy';
export function runCompose(args, composeFile, extraEnv = {}) {
    // compose.yaml interpolates `${OLAM_AUTH_SECRET:-}` from the shell
    // env. When the operator's shell didn't export it, the container
    // came up with an empty secret and every host-cp → auth-service
    // call 401'd silently — the SPA showed "0 credentials" even though
    // the vault was intact. We now thread a merged env explicitly so
    // `olam host-cp start` is self-sufficient and doesn't depend on
    // shell state.
    const result = spawnSync('docker', ['compose', '-f', composeFile, ...args], {
        encoding: 'utf-8',
        stdio: ['ignore', 'pipe', 'pipe'],
        env: { ...process.env, ...extraEnv },
    });
    return {
        ok: result.status === 0,
        stdout: result.stdout ?? '',
        stderr: result.stderr ?? '',
    };
}
/**
 * Build the extra env vars to pass to `docker compose up`. Pure helper
 * extracted so the env-merge contract is unit-testable without spawning
 * docker. Returns an object with only the keys we set; absent values
 * are omitted so process.env defaults can win when the secret file is
 * missing (and the boot warning surfaces the gap).
 *
 * Exported for tests.
 */
export function buildComposeEnv(authSecret, ghToken) {
    const env = {};
    if (authSecret !== null && authSecret.length > 0) {
        env.OLAM_AUTH_SECRET = authSecret;
    }
    if (ghToken != null && ghToken.length > 0) {
        env.GH_TOKEN = ghToken;
    }
    // Propagate the operator's CLI version into the host-cp container so
    // /api/version/status can surface it (the dashboard's TopNav reads
    // it). Best-effort: if `cli-version.js` isn't reachable (test
    // environment without a built bundle), we skip — the container
    // falls back to whatever's baked in its image.
    const cliVersion = readCliVersion();
    if (cliVersion && cliVersion !== '0.0.0-unknown') {
        env.OLAM_CLI_VERSION = cliVersion;
    }
    return env;
}
/**
 * Capture the operator's GitHub token by running `gh auth token` on the
 * host. Returns the token string on success, null when gh is not installed
 * or the operator is not authenticated.
 *
 * Never throws — callers that need a token should check for null and warn,
 * but must not block host-cp from starting.
 *
 * Exported for tests.
 */
export function captureGhToken() {
    try {
        const result = spawnSync('gh', ['auth', 'token'], {
            encoding: 'utf-8',
            stdio: ['ignore', 'pipe', 'pipe'],
        });
        if (result.status === 0) {
            const token = (result.stdout ?? '').trim();
            return token.length > 0 ? token : null;
        }
        return null;
    }
    catch {
        return null;
    }
}
// ── start ─────────────────────────────────────────────────────────
/** Exported for `olam begin` (D1) to delegate start logic without CLI re-parse. */
export async function startHostCp(opts) {
    return handleStart(opts);
}
async function handleStart(opts) {
    // 0. B2 — surface the compose-plugin error EARLY (not buried in step 4a's
    //    "docker compose pull docker-socket-proxy failed after 3 attempts" with
    //    a cryptic "unknown shorthand flag" stderr). An npm-only operator who
    //    installed `docker` via `brew install docker` does NOT get the compose
    //    v2 plugin by default; the actual fix is `brew install docker-compose`.
    //    Probing first means the operator sees that one-line remedy instead of
    //    chasing a retry-loop error.
    {
        const composeProbe = await probeComposePlugin();
        if (!composeProbe.ok) {
            printError(composeProbe.message);
            printError(composeProbe.remedy);
            process.exitCode = 1;
            return;
        }
    }
    // 1. Idempotency check: if container already running, surface URL + exit.
    const existing = await findHostCpContainer();
    if (existing && existing.state === 'running') {
        const health = await probeHealth();
        if (health) {
            printSuccess(`Host CP already running at http://127.0.0.1:${HOST_CP_PORT}`);
            printInfo('Container', existing.id);
            printInfo('Uptime', String(health['uptime_seconds'] ?? 'unknown') + 's');
            return;
        }
        // Container exists but health endpoint isn't responding — possibly
        // mid-boot. Tell the operator + don't restart.
        printWarning('Host CP container running but /health not responding. Wait a few seconds and retry, or stop+start.');
        return;
    }
    // 2. Port-zombie audit. If a stopped container holds :19000, fail
    //    fast with the clean error from PortHeldByZombieError.
    try {
        const docker = new Dockerode(resolveDockerHostOptions());
        await auditPortsForZombies(docker, [HOST_CP_PORT]);
    }
    catch (err) {
        if (err instanceof PortHeldByZombieError) {
            printError(`Port ${HOST_CP_PORT} held by zombie container "${err.containerName}" (state: ${err.state}).`);
            printError(`Run: docker rm ${err.containerName}`);
            process.exitCode = 1;
            return;
        }
        throw err;
    }
    // 3. Generate fresh token. Each `start` regenerates so rotated tokens
    //    don't linger; old SPA sessions are invalidated.
    const token = writeToken();
    // 4. docker compose up -d.
    const composeFile = findComposeFile();
    if (!fs.existsSync(composeFile)) {
        printError(`compose.yaml not found at ${composeFile}. Run from the olam project root.`);
        removeToken();
        process.exitCode = 1;
        return;
    }
    // Auto-load the shared auth-service secret from ~/.olam/auth-secret
    // so the operator's shell doesn't need to export OLAM_AUTH_SECRET.
    // Missing/empty file → warn now (NOT fatal — host-cp still boots, the
    // SPA still serves; the operator just sees an empty credentials list
    // until they run `olam auth up` to regenerate the secret).
    const authSecret = readAuthSecret();
    if (authSecret === null) {
        printWarning(`${authSecretPath()} not found or empty. host-cp will boot, but ` +
            'credential surfaces (auth fleet, hotswap) will fail with 401 until ' +
            'you run `olam auth up` to (re)generate the shared secret.');
    }
    // Capture GH_TOKEN from the host's gh CLI so host-cp's pr-merge-poller
    // and /api/prs endpoint can authenticate against the GitHub API.
    // Missing token is non-fatal — host-cp boots fine, just without PR badges.
    const ghToken = captureGhToken();
    if (ghToken === null) {
        printWarning('GitHub CLI not authenticated; PR badges will not appear in the inbox. ' +
            'Run `gh auth login` then `olam host-cp restart`.');
    }
    // 4a. Pre-pull the docker-socket-proxy sidecar with bounded retry.
    //     compose.yaml's host-cp service is image-only (no `build:`) and
    //     its `:latest` tag was already aliased from a digest-pinned ref
    //     by `olam bootstrap` — no registry round-trip needed for that
    //     image. The remaining service that DOES need a pull is the
    //     third-party `tecnativa/docker-socket-proxy:0.3.0`. Pulling it
    //     by name (rather than `compose pull` of all services) gives us
    //     the retry/backoff loop without overwriting the digest-pinned
    //     host-cp tag with whatever `:latest` happens to be on the
    //     registry right now.
    const composeEnv = buildComposeEnv(authSecret, ghToken);
    const PULL_BACKOFF_MS = [0, 1000, 3000];
    let pullOk = false;
    let lastPullStderr = '';
    for (let attempt = 0; attempt < PULL_BACKOFF_MS.length; attempt += 1) {
        if (PULL_BACKOFF_MS[attempt] > 0) {
            await new Promise((r) => setTimeout(r, PULL_BACKOFF_MS[attempt]));
        }
        const pullResult = runCompose(['pull', '--quiet', SIDECAR_PULL_SERVICE], composeFile, composeEnv);
        if (pullResult.ok) {
            pullOk = true;
            break;
        }
        lastPullStderr = pullResult.stderr;
    }
    if (!pullOk) {
        printError('docker compose pull docker-socket-proxy failed after 3 attempts');
        process.stderr.write(lastPullStderr);
        removeToken();
        process.exitCode = 1;
        return;
    }
    // No --build: compose.yaml is image-only (no `build:` block) so the
    // operator's pre-pulled `ghcr.io/pleri/olam-host-cp:latest` (aliased
    // from the digest by bootstrap) is used directly. `--build` would
    // require the Dockerfile + build context, which fresh-install
    // operators don't ship. Local-dev contributors who ARE working on
    // host-cp source use `compose.dev.yaml` as an override; that path
    // does not run through this code.
    const result = runCompose(['up', '-d'], composeFile, composeEnv);
    if (!result.ok) {
        printError('docker compose up failed');
        process.stderr.write(result.stderr);
        removeToken();
        process.exitCode = 1;
        return;
    }
    // 5. Wait briefly for /health to come up (max 10s).
    const deadline = Date.now() + 10_000;
    let healthy = false;
    while (Date.now() < deadline) {
        const h = await probeHealth();
        if (h) {
            healthy = true;
            break;
        }
        await new Promise((r) => setTimeout(r, 500));
    }
    if (!healthy) {
        printWarning('Host CP started but /health did not respond within 10s. Check `docker compose logs host-cp`.');
    }
    // 6. Resolve container PID for the marker file.
    const container = await findHostCpContainer();
    if (container) {
        // We can't easily get the host-side PID without docker inspect; the
        // file primarily marks "started by olam host-cp start" presence, not
        // a true OS PID. Use 1 as a sentinel; D2 doesn't depend on the value.
        writePid(1);
    }
    // 7. Surface the URL + token.
    printSuccess(`Host CP running at http://127.0.0.1:${HOST_CP_PORT}`);
    if (opts.showToken) {
        printInfo('Token', token);
    }
    else {
        printInfo('Token', `(written to ${tokenPath()}; pass --show-token to print)`);
    }
    printInfo('Open', `http://127.0.0.1:${HOST_CP_PORT}`);
}
// ── stop ──────────────────────────────────────────────────────────
/** Exported for `olam stop` (D2) to delegate stop logic without CLI re-parse. */
export async function stopHostCp() {
    return handleStop();
}
async function handleStop() {
    const composeFile = findComposeFile();
    if (!fs.existsSync(composeFile)) {
        printWarning(`compose.yaml not found at ${composeFile}. Cleaning up token + PID anyway.`);
        removeToken();
        removePid();
        return;
    }
    const existing = await findHostCpContainer();
    if (!existing) {
        printInfo('Host CP', 'not running');
        removeToken();
        removePid();
        return;
    }
    const result = runCompose(['down'], composeFile);
    if (!result.ok) {
        printError('docker compose down failed');
        process.stderr.write(result.stderr);
        process.exitCode = 1;
        return;
    }
    removeToken();
    removePid();
    printSuccess('Host CP stopped');
}
async function buildStatusReport() {
    const container = await findHostCpContainer();
    const health = await probeHealth();
    const tokenFile = tokenPath();
    const tokenPresent = fs.existsSync(tokenFile);
    let tokenModeOk = false;
    if (tokenPresent) {
        const mode = fs.statSync(tokenFile).mode & 0o777;
        tokenModeOk = mode === 0o600;
    }
    const pidPresent = fs.existsSync(pidPath());
    let stack;
    if (!container) {
        stack = 'not_started';
    }
    else if (container.state === 'running' && health) {
        stack = 'running';
    }
    else if (container.state === 'running') {
        stack = 'partial';
    }
    else {
        stack = 'stopped';
    }
    return {
        stack,
        container,
        health,
        token_present: tokenPresent,
        token_mode_ok: tokenModeOk,
        pid_present: pidPresent,
        url: `http://127.0.0.1:${HOST_CP_PORT}`,
    };
}
async function handleStatus(opts) {
    const report = await buildStatusReport();
    if (opts.json) {
        process.stdout.write(JSON.stringify(report, null, 2) + '\n');
        process.exitCode = report.stack === 'running' ? 0 : 1;
        return;
    }
    printHeader('Host CP Status');
    printInfo('Stack', report.stack);
    printInfo('URL', report.url);
    if (report.container) {
        printInfo('Container', `${report.container.id} (${report.container.state})`);
        printInfo('Status line', report.container.status);
    }
    else {
        printInfo('Container', 'not found (run `olam host-cp start`)');
    }
    if (report.health) {
        printInfo('Health', 'ok');
        printInfo('Uptime', String(report.health['uptime_seconds'] ?? 'unknown') + 's');
        const cache = report.health['cache'];
        if (cache) {
            printInfo('Cached worlds', String(cache.worlds?.length ?? 0));
            printInfo('Cache TTL', `${cache.ttl_sec ?? 'unknown'}s`);
        }
        const sse = report.health['sse'];
        if (sse) {
            printInfo('SSE active', `${sse.active ?? 0} / ${sse.cap ?? 0}`);
        }
    }
    else {
        printInfo('Health', 'not responding');
    }
    printInfo('Token file', report.token_present ? (report.token_mode_ok ? 'present (mode 600)' : 'present (BAD MODE — should be 600)') : 'absent');
    printInfo('PID file', report.pid_present ? 'present' : 'absent');
    process.exitCode = report.stack === 'running' ? 0 : 1;
}
// ── Register ──────────────────────────────────────────────────────
export function registerHostCp(program) {
    const hostCp = program
        .command('host-cp')
        .description('Manage the Olam host control plane container');
    hostCp
        .command('start')
        .description('Start the host CP container (token regenerated each call)')
        .option('--show-token', 'Print the generated token to stdout (default: hide)')
        .action(async (opts) => {
        await handleStart({ showToken: opts.showToken === true });
    });
    hostCp
        .command('stop')
        .description('Stop the host CP container + remove token + PID files')
        .action(async () => {
        await handleStop();
    });
    hostCp
        .command('status')
        .description('Show host CP container + health diagnostics')
        .option('--json', 'Output as JSON (machine-parseable; sets exit code)')
        .action(async (opts) => {
        await handleStatus({ json: opts.json === true });
    });
    hostCp
        .command('register')
        .description('Register a world with the running host CP so it appears in the unified UI')
        .requiredOption('--world <id>', 'World id (the docker container suffix, e.g. gold-arc-1454)')
        .option('--port <port>', 'Override per-world CP port; default: discovered from `olam list`')
        .action(async (opts) => {
        await handleRegister({ world: opts.world, port: opts.port });
    });
    hostCp
        .command('deregister')
        .description('Remove a world from the host CP registry (does NOT destroy the world)')
        .requiredOption('--world <id>', 'World id to remove')
        .action(async (opts) => {
        await handleDeregister({ world: opts.world });
    });
}
// ── Register / Deregister handlers ────────────────────────────────
async function discoverWorldPort(worldId) {
    try {
        const { loadContext } = await import('../context.js');
        const { ctx } = await loadContext();
        if (!ctx)
            return null;
        const world = await ctx.worldManager.getWorld(worldId);
        if (!world)
            return null;
        // Per-world CP base port = 19080. Per-world host port = base + offset.
        // Matches the docker provider's port allocation in
        // packages/adapters/src/docker/container.ts (search for 19080).
        return 19080 + world.portOffset;
    }
    catch {
        return null;
    }
}
async function readHostCpToken() {
    const tp = tokenPath();
    if (!fs.existsSync(tp))
        return null;
    return fs.readFileSync(tp, 'utf-8').trim();
}
/**
 * Phase D6 (olam-dogfood-vision): open a Host CP URL in the operator's
 * default browser.
 *
 * D-phase audit follow-up (HIGH-1): the helper now lives in
 * `@olam/core/src/util/open-url.ts` so both this CLI and the MCP
 * server can import it statically. Earlier shape duplicated the
 * helper here and required @olam/mcp-server to dynamic-import the
 * compiled CLI dist — fragile under stale-build conditions. Static
 * import via @olam/core (already a declared dep on both packages)
 * removes the cross-package dynamic-import + as-never-cast pattern.
 *
 * Re-exported here so existing callers don't need to update their
 * imports.
 */
export { openUrl as openHostCpUrl } from '@olam/core/src/util/open-url.js';
/**
 * Phase C8 (olam-dogfood-vision): generic host-cp proxy caller for
 * `/api/world/<id>/*` routes. Used by `olam lanes` subcommands to
 * reach the per-world CP through host-cp's auth-injecting proxy
 * (host-cp injects X-Olam-Secret server-side; the operator only
 * sees the Bearer token).
 *
 * Returns the parsed JSON body on success, or {ok:false, error} on
 * any failure (token missing, network error, non-2xx response).
 *
 * @param method
 * @param worldId
 * @param path  the per-world CP path including leading slash, e.g.
 *              '/lanes', '/lanes/foo', '/lanes/foo/dispatch'
 * @param body  optional JSON body for POST/DELETE
 */
export async function callHostCpProxy(method, worldId, path, body) {
    const token = await readHostCpToken();
    if (!token)
        return { ok: false, status: 0, error: 'no token (host CP not started)' };
    const url = `http://127.0.0.1:${HOST_CP_PORT}/api/world/${encodeURIComponent(worldId)}${path}`;
    try {
        const headers = {
            Authorization: `Bearer ${token}`,
        };
        if (body !== undefined)
            headers['Content-Type'] = 'application/json';
        const res = await fetch(url, {
            method,
            headers,
            ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
        });
        if (!res.ok) {
            const text = await res.text().catch(() => '');
            // Try to parse the error body as JSON so callers can surface
            // structured errors (e.g., 409 conflict with currentTask).
            let errMsg = text || `HTTP ${res.status}`;
            try {
                const parsed = JSON.parse(text);
                if (parsed && typeof parsed === 'object' && 'error' in parsed) {
                    errMsg = String(parsed.error);
                }
            }
            catch { /* keep raw text */ }
            return { ok: false, status: res.status, error: errMsg };
        }
        const data = await res.json().catch(() => null);
        return { ok: true, status: res.status, data };
    }
    catch (err) {
        return {
            ok: false,
            status: 0,
            error: err instanceof Error ? err.message : String(err),
        };
    }
}
/**
 * POST/DELETE the host-cp admin registry endpoint. Exported so other
 * commands (create, destroy) can auto-register without re-implementing
 * the fetch dance.
 */
export async function callHostCpRegistry(method, body) {
    const token = await readHostCpToken();
    if (!token)
        return { ok: false, status: 0, error: 'no token (host CP not started)' };
    const url = method === 'DELETE'
        ? `http://127.0.0.1:${HOST_CP_PORT}/api/admin/registry/${encodeURIComponent(body.id)}`
        : `http://127.0.0.1:${HOST_CP_PORT}/api/admin/registry`;
    try {
        const res = await fetch(url, {
            method,
            headers: {
                Authorization: `Bearer ${token}`,
                ...(method === 'POST' ? { 'Content-Type': 'application/json' } : {}),
            },
            ...(method === 'POST' ? { body: JSON.stringify(body) } : {}),
        });
        if (!res.ok) {
            const text = await res.text().catch(() => '');
            return { ok: false, status: res.status, error: text || `HTTP ${res.status}` };
        }
        return { ok: true, status: res.status };
    }
    catch (err) {
        return {
            ok: false,
            status: 0,
            error: err instanceof Error ? err.message : String(err),
        };
    }
}
async function handleRegister(opts) {
    printHeader('Register world with host CP');
    let port = null;
    if (opts.port) {
        port = parseInt(opts.port, 10);
        if (!Number.isFinite(port) || port <= 0) {
            printError(`Invalid --port value: ${opts.port}`);
            process.exitCode = 1;
            return;
        }
    }
    else {
        port = await discoverWorldPort(opts.world);
        if (port === null) {
            printError(`Could not discover port for world ${opts.world}. Pass --port explicitly or check that the world exists in \`olam list\`.`);
            process.exitCode = 1;
            return;
        }
    }
    const result = await callHostCpRegistry('POST', { id: opts.world, port });
    if (!result.ok) {
        printError(`Register failed: ${result.error}`);
        if (result.status === 0) {
            printInfo('Hint', 'Is host CP running? `olam host-cp status`');
        }
        process.exitCode = 1;
        return;
    }
    printSuccess(`Registered ${opts.world} → :${port}`);
    printInfo('UI', `http://127.0.0.1:${HOST_CP_PORT}/world/${encodeURIComponent(opts.world)}`);
}
async function handleDeregister(opts) {
    printHeader('Deregister world from host CP');
    const result = await callHostCpRegistry('DELETE', { id: opts.world });
    if (!result.ok) {
        printError(`Deregister failed: ${result.error}`);
        if (result.status === 0) {
            printInfo('Hint', 'Is host CP running? `olam host-cp status`');
        }
        process.exitCode = 1;
        return;
    }
    printSuccess(`Deregistered ${opts.world}`);
}
//# sourceMappingURL=host-cp.js.map