// agent-runtime-trigger — Phase B B7 (minimum-demo cut) host-side launch hook.
//
// When the SPA opens the plan-tab for a (worldId, sessionId), it POSTs
// here; host-cp idempotently spawns the agent-stream-launch supervisor
// inside the world's devbox container via `docker exec`. The supervisor
// (PID 1 within the spawned exec session) then fork-spawns driver +
// codex runners that long-poll host-cp's /v1/shape.
//
// Demo-cut simplifications (per minimum-demo decision; full B7 in follow-up):
//   - In-memory idempotency map keyed by `(worldId, sessionId)`. Restart of
//     host-cp loses state; second call after restart re-issues docker exec,
//     which the supervisor's idempotency check (B6-full's flock + PID-file)
//     would catch. B6-minimum has no such check → restart of host-cp +
//     re-trigger may spawn two supervisors. Acceptable for single-operator
//     local demo; full B7 + B6-full close this.
//   - Uses shared-secret bearer (from `~/.olam/plan-chat-secret` per the
//     existing plan-chat-service contract). JWT scope-claim migration is B9.
//   - No conversation_id ↔ (worldId, sessionId) join-table (A1.4
//     §migration-schema open question). For demo, the supervisor is
//     keyed by (worldId, sessionId) directly; codex's APPROVE chunks
//     write under (worldId, sessionId) — `conversation_id` plumbing
//     deferred until lookouts (B3) need it.
//   - No host-cp restart cleanup of dead supervisor entries (the in-memory
//     map only tracks live spawns; container crash + re-trigger DOES
//     re-spawn).
//
// Source: docs/design/olam-plan-chat-agent-runtime.md `lifecycle` +
// `bake-in-seam` sections, minimum-demo cut.

import { spawnSync, spawn } from 'node:child_process';

const SPAWN_TIMEOUT_MS = 10_000;

// Default container-side path for the supervisor binary.
// In source-mode (OLAM_DEV=1): the operator's built host dist is bind-mounted
// read-only at /opt/olam/agent-stream/dist (Phase B1, olam-world-bundle-freshness).
// The mount overlays the image-baked dist, so this path always resolves to the
// freshest available binary — no docker cp required.
// In install-mode / cloud: the image-baked dist (devbox.runtime.glibc.Dockerfile
// lines 263-287 bake step) is the fallback; the path is the same.
const DEFAULT_SUPERVISOR_PATH = '/opt/olam/agent-stream/dist/agent-stream-launch.js';

/**
 * @typedef {object} TriggerArgs
 * @property {string} worldId
 * @property {string} sessionId
 * @property {string} hostCpUrl — URL the container reaches host-cp at
 *   (e.g. `http://host.docker.internal:3112`)
 * @property {string} bearer — shared-secret token (read from
 *   `~/.olam/plan-chat-secret` server-side; never passed in from SPA)
 * @property {string} [dockerHost='docker-cli'] — `'docker-cli'` for bare-node
 *   mode; `tcp://...` for container mode (docker-socket-proxy)
 * @property {string} [supervisorPath] — override for tests
 * @property {(cmd: string, args: string[], opts?: object) => any} [spawnSyncImpl]
 *   — injectable for tests; defaults to node:child_process spawnSync
 * @property {(cmd: string, args: string[], opts?: object) => any} [spawnImpl]
 *   — injectable for tests; defaults to node:child_process spawn (detached)
 */

/**
 * Internal state: which `(worldId, sessionId)` pairs we've already
 * spawned. Survives only within a single host-cp process instance.
 *
 * @type {Map<string, {spawnedAt: number, pid?: number}>}
 */
const liveSpawns = new Map();

/** @param {string} worldId @param {string} sessionId */
function key(worldId, sessionId) {
  return `${worldId}::${sessionId}`;
}

/**
 * Idempotently spawn the agent-stream supervisor inside the world's container.
 *
 * Returns `{status: 'spawned' | 'already-running', container, pid?}`.
 * Throws on docker-CLI failure or container-not-running.
 *
 * @param {TriggerArgs} args
 */
export async function triggerAgentRuntime(args) {
  const {
    worldId,
    sessionId,
    hostCpUrl,
    bearer,
    dockerHost = 'docker-cli',
    supervisorPath = DEFAULT_SUPERVISOR_PATH,
    spawnSyncImpl = spawnSync,
    spawnImpl = spawn,
  } = args;

  if (!worldId || !sessionId || !hostCpUrl || !bearer) {
    throw new Error(
      'triggerAgentRuntime: worldId, sessionId, hostCpUrl, bearer all required',
    );
  }

  const k = key(worldId, sessionId);
  if (liveSpawns.has(k)) {
    const entry = liveSpawns.get(k);
    return {
      status: 'already-running',
      container: `olam-${worldId}-devbox`,
      spawnedAt: entry.spawnedAt,
      pid: entry.pid,
    };
  }

  const containerName = `olam-${worldId}-devbox`;

  // Bare-node mode: shell out to docker exec --detach (or background
  // via & in a wrapper command). Detached so the SPA's HTTP request
  // returns promptly; the supervisor lives until SIGTERM.
  if (dockerHost === 'docker-cli') {
    // First, verify the container exists and is running. `docker inspect`
    // returns exit 1 if the container is not found; exit 0 with stdout
    // containing the state if found.
    const inspect = spawnSyncImpl(
      'docker',
      ['inspect', '--format', '{{.State.Running}}', containerName],
      { encoding: 'utf-8', timeout: SPAWN_TIMEOUT_MS },
    );
    if (inspect.error) {
      throw new Error(
        `docker inspect ${containerName} failed: ${inspect.error.message}`,
      );
    }
    if (inspect.status !== 0) {
      throw new Error(
        `docker inspect ${containerName} exit ${inspect.status}: ${(inspect.stderr || '').trim()}`,
      );
    }
    if ((inspect.stdout || '').trim() !== 'true') {
      throw new Error(
        `container ${containerName} is not running (state: ${(inspect.stdout || '').trim()})`,
      );
    }

    // Use docker exec --detach to spawn the supervisor in the background.
    // -e flags inject the runtime env; the supervisor binary path is the
    // last positional argument.
    const env = {
      HOST_CP_URL: hostCpUrl,
      HOST_CP_BEARER: bearer,
      WORLD_ID: worldId,
      SESSION_ID: sessionId,
    };
    const execArgs = ['exec', '--detach'];
    for (const [k_, v] of Object.entries(env)) {
      execArgs.push('-e', `${k_}=${v}`);
    }
    execArgs.push(containerName, 'node', supervisorPath);

    const detached = spawnImpl('docker', execArgs, {
      stdio: 'ignore',
      detached: true,
    });
    detached.unref?.();

    liveSpawns.set(k, { spawnedAt: Date.now(), pid: detached.pid });

    return {
      status: 'spawned',
      container: containerName,
      pid: detached.pid,
    };
  }

  // Container mode (docker-socket-proxy on tcp://<host>:<port>).
  // Two-step Docker API exec: POST /containers/<name>/exec creates an
  // exec instance, then POST /exec/<id>/start with Detach=true runs it
  // in the background. Matches the pattern in container-secret-fetcher.mjs.
  if (dockerHost.startsWith('tcp://')) {
    const apiBase = dockerHost.replace(/^tcp:\/\//, 'http://');

    // Step 0: verify the container is running.
    const inspectRes = await fetch(
      `${apiBase}/containers/${encodeURIComponent(containerName)}/json`,
    );
    if (!inspectRes.ok) {
      throw new Error(
        `socket-proxy GET /containers/${containerName}/json: ${inspectRes.status} ${inspectRes.statusText}`,
      );
    }
    const inspect = await inspectRes.json();
    if (!inspect?.State?.Running) {
      throw new Error(
        `container ${containerName} is not running (state: ${JSON.stringify(inspect?.State)})`,
      );
    }

    // Step 1: create exec instance with env injection.
    const createRes = await fetch(
      `${apiBase}/containers/${encodeURIComponent(containerName)}/exec`,
      {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          Cmd: ['node', supervisorPath],
          Env: [
            `HOST_CP_URL=${hostCpUrl}`,
            `HOST_CP_BEARER=${bearer}`,
            `WORLD_ID=${worldId}`,
            `SESSION_ID=${sessionId}`,
          ],
          AttachStdout: false,
          AttachStderr: false,
          Tty: false,
        }),
      },
    );
    if (!createRes.ok) {
      const errBody = await createRes.text().catch(() => '<no body>');
      throw new Error(
        `socket-proxy POST /containers/${containerName}/exec: ${createRes.status} — ${errBody}`,
      );
    }
    const { Id: execId } = await createRes.json();

    // Step 2: start exec in detached mode.
    const startRes = await fetch(`${apiBase}/exec/${execId}/start`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ Detach: true, Tty: false }),
    });
    if (!startRes.ok && startRes.status !== 200) {
      const errBody = await startRes.text().catch(() => '<no body>');
      throw new Error(
        `socket-proxy POST /exec/${execId}/start: ${startRes.status} — ${errBody}`,
      );
    }

    liveSpawns.set(k, { spawnedAt: Date.now(), execId });

    return {
      status: 'spawned',
      container: containerName,
      execId,
    };
  }

  throw new Error(
    `triggerAgentRuntime: unsupported dockerHost mode '${dockerHost}'`,
  );
}

/**
 * Test-only: clear the in-memory live-spawns map.
 * Production code should NEVER call this — it would let a duplicate
 * supervisor spawn.
 */
export function _clearLiveSpawnsForTests() {
  liveSpawns.clear();
}

/**
 * Inspect-only: read the current live-spawns map (for observability).
 *
 * @returns {ReadonlyMap<string, {spawnedAt: number, pid?: number}>}
 */
export function getLiveSpawns() {
  return new Map(liveSpawns);
}
