// plan-chat-service — host-cp sidecar serving two HTTP surfaces on one port:
//
//   POST /v1/chunks         — bearer-auth write API; server-injects actor_id +
//                             actor_type from the bearer principal, validates the
//                             payload, INSERTs into a source Postgres.
//   GET  /v1/shape          — bearer-auth read API; requires (world_id,
//                             session_id) query params; rewrites the upstream
//                             Electric `where` clause server-side so a caller
//                             can never enumerate cross-world chunks (PB2).
//                             Streams the upstream response without buffering
//                             so Electric's `live=true` long-poll round-trips
//                             stay reactive (PB1). Phase B B1 closes both
//                             A2-CP3 audit HIGHs.
//   GET  /livez             — liveness probe (200 on healthy; matches the
//                             agent-memory-service shape so host-cp uses one
//                             probe pattern for both sidecars).
//
// The service is started by `olam plan-chat start` (deferred follow-up task)
// and managed by host-cp's process supervisor in the long run. For now it is
// runnable standalone via `node plan-chat-service.mjs`.
//
// Configuration is environment-driven so a single binary works in laptop
// (the K3 container-spike), in a devbox container, and on a host-cp Mac:
//
//   OLAM_PLAN_CHAT_PORT          (default 3200; moved off 3112 — see
//                                 olam-chunks-subscriber-long-poll Phase A.
//                                 agentmemory iii/node claim 3111-3113.)
//   OLAM_PLAN_CHAT_DATABASE_URL  (default postgres://postgres:spike@localhost:54321/chunks)
//   OLAM_PLAN_CHAT_ELECTRIC_URL  (default http://localhost:30001)
//   OLAM_PLAN_CHAT_SECRET_PATH   (default ~/.olam/plan-chat-secret)

import http from 'node:http';
import { performance } from 'node:perf_hooks';
import { Readable } from 'node:stream';
import { URL } from 'node:url';
import pg from 'pg';
import { ensureSecret, timingSafeEqual, SECRET_PATH } from './plan-chat-secret.mjs';
import { listPlanningSessions } from './planning-sessions.mjs';
import { crystallizePlanningSession } from './crystallize-planning.mjs';

const DEFAULT_PORT = 3200;
const DEFAULT_DB_URL = 'postgres://postgres:spike@localhost:54321/chunks';
const DEFAULT_ELECTRIC_URL = 'http://localhost:30001';

const ACTOR_TYPES = new Set(['agent', 'operator', 'codex', 'system']);
const ROLES = new Set(['user', 'assistant', 'tool', 'system']);
// B3 schema-v2: expanded chunk_type enum mirrors @olam/chunks/src/schema.ts
// CHUNK_TYPES. host-cp duplicates the Set rather than importing because
// this file is plain JS .mjs and the chunks package ships as TS — keeping
// them in sync is the audit:do-schema-parity-style discipline (cross-
// package check queued as a B3 follow-up; for now the parity is by
// inspection + the integration test in __tests__/chunk-type-validator).
const CHUNK_TYPES = new Set([
  'text',
  'tool_use',
  'goal_mode_assumption',
  'dispatch_overflow',
]);

// PB2 — scope-ID shape. world_id + session_id query params are interpolated
// into the upstream Electric `where` clause; the regex IS the SQL-injection
// defence (single-quotes, semicolons, comments can't pass). Same shape as
// @olam/chunks schema accepts and as measure-in-world.mjs validates. Bump
// only if the chunks schema bumps; the two must move together.
const SCOPE_ID_RE = /^[A-Za-z0-9_.-]+$/;

// B5 (plan-chat-context-window-display Phase B): tables allowlisted for
// /v1/shape. Only these tables have server-side where-rewrite support; any
// other table=... param gets a 400. Guards against a client enumerating
// tables the service doesn't own.
const ALLOWED_SHAPE_TABLES = new Set(['chunks', 'message_usage']);

// B6 (plan-chat-context-window-display Phase B): context-window caps per
// model. Mirrors CONTEXT_CAPS from @olam/intelligence/src/llm-router/providers/claude.ts.
// Host-cp doesn't depend on intelligence, so this is a local copy. Keep in
// sync with the canonical table (audit:pricing-coverage checks coverage).
// Default: 200k. 1M only for claude-opus-4-7[1m].
const CONTEXT_CAPS_LOCAL = {
  'claude-sonnet-4-6': 200_000,
  'claude-haiku-4-5-20251001': 200_000,
  'claude-opus-4-7': 200_000,
  'claude-opus-4-7[1m]': 1_000_000,
};
const DEFAULT_CONTEXT_CAP = 200_000;
const THRESHOLD_PCT = 80; // emit dedup'd system chunk when ctx ≥ this %

// Sentinel prefix on threshold system chunks so the dedup query can find them.
// Keep this stable — changing it makes old dedup rows invisible to the guard.
const THRESHOLD_CHUNK_PREFIX = 'Context approaching compaction threshold:';

// PB2 — query params the server OWNS on /v1/shape. All three are stripped
// from what we forward to upstream Electric:
//   - `world_id` + `session_id` are SEEDS for the server-derived `where`
//     clause; Electric doesn't read them natively, so dropping them from
//     the upstream URL is a no-op for Electric semantics. (Per CP3-B1-M2:
//     this drop is intentional — if a future Electric version adopts these
//     as native scope params, split this set into SEEDS vs DROPPED.)
//   - `where` is the actual SQL predicate; we drop the client's value and
//     replace with the server-derived one. Operator can't bypass scope by
//     passing their own.
// Everything else (`table`, `offset`, `handle`, `live`, future Electric
// params) forwards through verbatim.
const SCOPED_QUERY_PARAMS = new Set(['world_id', 'session_id', 'where']);

/**
 * Resolve the bearer principal to (actor_id, actor_type). The minimal-A2
 * implementation treats every authenticated bearer as the same shared
 * principal — refinement to multi-principal mapping lands when host-cp's
 * auth-service integrates (Phase A follow-up, tracked at A6.1 / A4 wiring).
 *
 * Callers MUST never use client-supplied actor_id / actor_type; this
 * function is the single source of authority. T3 mitigation depends on it.
 *
 * IMPORTANT — silent-collapse safeguard (audit finding A2.M3, 2026-05-13):
 * when A4 begins configuring multiple secrets (e.g., agent + operator +
 * codex bearers), this single-principal collapse becomes a foot-gun: every
 * caller's writes are attributed to 'agent', regardless of which secret
 * they authenticated with, and S3 audit-replay attributes operator actions
 * to agent. The check below fails LOUD when more than one secret is
 * configured, so an A4 multi-secret rollout cannot land until principal
 * resolution is wired here. Remove the throw when this function actually
 * inspects the bearer.
 */
/**
 * G1-demo lift (2026-05-16): trust the client-supplied `actor_type` when
 * present. The original A2 design hardcoded ('agent','agent') with T3
 * mitigation explicitly requiring server-injection — but that collapsed
 * the operator + driver + codex roles into one and broke the driver
 * pickup loop (which filters on `actor_type === 'operator'`).
 *
 * Single-operator local mode has no multi-tenant threat, so trusting the
 * client field is safe pragmatic. The original silent-collapse safeguard
 * for OLAM_PLAN_CHAT_SECRETS stays — multi-secret mode still needs A4
 * proper bearer→principal resolution before it can land.
 */
function principalFromBearer(bearer, body) {
  if (process.env.OLAM_PLAN_CHAT_SECRETS) {
    throw new Error(
      'plan-chat-service: OLAM_PLAN_CHAT_SECRETS is set but principalFromBearer ' +
        "still trusts client-supplied actor_type. Wire bearer→principal " +
        'resolution before enabling multi-secret mode (see plan A4 / A6.1).',
    );
  }
  if (typeof bearer !== 'string' || bearer.length === 0) {
    throw new Error('plan-chat-service: principalFromBearer called with empty bearer');
  }
  // Trust client field if supplied (SPA sends 'operator'; SDK adapter omits
  // and inherits 'agent' default; codex-runner sends 'codex').
  const claimed = body && typeof body.actor_type === 'string' && body.actor_type.length > 0
    ? body.actor_type
    : 'agent';
  const claimedId = body && typeof body.actor_id === 'string' && body.actor_id.length > 0
    ? body.actor_id
    : claimed;
  return { actorId: claimedId, actorType: claimed };
}

function readJson(req, limit = 65536) {
  return new Promise((resolve, reject) => {
    const chunks = [];
    let length = 0;
    req.on('data', (chunk) => {
      length += chunk.length;
      if (length > limit) {
        reject(Object.assign(new Error('payload-too-large'), { status: 413 }));
        req.destroy();
        return;
      }
      chunks.push(chunk);
    });
    req.on('end', () => {
      try {
        const body = Buffer.concat(chunks).toString('utf8');
        if (!body) return resolve({});
        resolve(JSON.parse(body));
      } catch (_err) {
        reject(Object.assign(new Error('invalid-json'), { status: 400 }));
      }
    });
    req.on('error', reject);
  });
}

function send(res, status, body) {
  const json = typeof body === 'string' ? body : JSON.stringify(body);
  res.writeHead(status, {
    'content-type': typeof body === 'string' ? 'text/plain; charset=utf-8' : 'application/json',
    'content-length': Buffer.byteLength(json),
  });
  res.end(json);
}

function unauthorized(res) {
  send(res, 401, { error: 'unauthorized' });
}

function badRequest(res, message) {
  send(res, 400, { error: 'bad-request', message });
}

function validateChunkInput(body) {
  if (!body || typeof body !== 'object') return 'body must be an object';
  const required = ['world_id', 'session_id', 'message_id', 'seq', 'role', 'chunk'];
  for (const key of required) {
    if (!(key in body)) return `missing field: ${key}`;
  }
  if (typeof body.world_id !== 'string' || body.world_id.length === 0) return 'world_id must be a non-empty string';
  if (typeof body.session_id !== 'string' || body.session_id.length === 0) return 'session_id must be a non-empty string';
  if (typeof body.message_id !== 'string' || body.message_id.length === 0) return 'message_id must be a non-empty string';
  if (!Number.isInteger(body.seq) || body.seq < 0) return 'seq must be a non-negative integer';
  if (typeof body.chunk !== 'string') return 'chunk must be a string';
  if (!ROLES.has(body.role)) return `role must be one of ${[...ROLES].join(', ')}`;
  // Optional chunk_type field — defaults to 'text' when absent.
  if ('chunk_type' in body && !CHUNK_TYPES.has(body.chunk_type)) {
    return `chunk_type must be one of ${[...CHUNK_TYPES].join(', ')}`;
  }
  // actor_id and actor_type from the client are IGNORED (server-derived).
  return null;
}

/**
 * Build the HTTP request handler. Pure factory — easy to test against a
 * stubbed pool. Production callers pass a real pg.Pool.
 *
 * Phase A A2 (olam-spa-electric-subscription-staleness): when `shapeDebug`
 * is true OR `OLAM_PLAN_CHAT_SHAPE_DEBUG=1` is set in the environment,
 * `handleGetShape` emits two structured log lines per long-poll cycle —
 * one BEFORE the upstream fetch (with the rewritten `where` predicate +
 * forwarded params) and one AFTER (with upstream status + electric-*
 * response headers). Log lines go to `shapeDebugLog` (default
 * `console.error`); tests inject a spy. Flag defaults OFF — production
 * shapes incur a single boolean check per cycle and zero log I/O.
 */
export function createHandler({
  pool,
  bearer,
  electricUrl,
  shapeDebug,
  shapeDebugLog,
  createWorld,
  destroyWorld,
  /** B4 — optional override for tests. When supplied, replaces principalFromBearer
   *  so the test harness can inject a hardcoded server-resolved actor_id and verify
   *  the mismatch guard. Production callers omit this. */
  resolveActor,
}) {
  if (!pool) throw new Error('createHandler: { pool } required');
  if (typeof bearer !== 'string' || bearer.length === 0) {
    throw new Error('createHandler: { bearer } required');
  }
  const electricBase = electricUrl ?? DEFAULT_ELECTRIC_URL;
  const shapeDebugEnabled =
    typeof shapeDebug === 'boolean'
      ? shapeDebug
      : process.env.OLAM_PLAN_CHAT_SHAPE_DEBUG === '1';
  const shapeLog =
    typeof shapeDebugLog === 'function'
      ? shapeDebugLog
      : (msg, details) => console.error(msg, details);

  function checkAuth(req) {
    const header = req.headers.authorization;
    if (typeof header !== 'string' || !header.startsWith('Bearer ')) return false;
    const supplied = header.slice('Bearer '.length).trim();
    return timingSafeEqual(supplied, bearer);
  }

  async function handlePostChunks(req, res) {
    if (!checkAuth(req)) return unauthorized(res);
    let body;
    try {
      body = await readJson(req);
    } catch (err) {
      return send(res, err?.status ?? 400, { error: err?.message ?? 'bad-request' });
    }
    const invalid = validateChunkInput(body);
    if (invalid) return badRequest(res, invalid);
    const principal = resolveActor
      ? resolveActor(bearer, body)
      : principalFromBearer(bearer, body);

    // B4 — mismatch guard: if the client explicitly supplied body.actor_id
    // and it differs from the server-resolved actor_id, reject with 400.
    // In single-secret G1-demo mode this never fires because principalFromBearer
    // echoes back body.actor_id. When Phase A4 lands multi-secret resolution,
    // this guard prevents a caller from spoofing a different actor.
    if (
      typeof body.actor_id === 'string' &&
      body.actor_id.length > 0 &&
      body.actor_id !== principal.actorId
    ) {
      return badRequest(
        res,
        `actor_id mismatch: client supplied '${body.actor_id}' but bearer resolves to '${principal.actorId}'`,
      );
    }

    try {
      await pool.query(
        `INSERT INTO chunks
           (world_id, session_id, message_id, seq, actor_id, actor_type, role, chunk, chunk_type)
         VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
        [
          body.world_id,
          body.session_id,
          body.message_id,
          body.seq,
          principal.actorId,
          principal.actorType,
          body.role,
          body.chunk,
          body.chunk_type ?? 'text',
        ],
      );

      // B4 — capture message_usage when the adapter forwarded token usage.
      // body.usage is optional (only present on assistant turns with SDK usage).
      if (body.usage && typeof body.usage === 'object') {
        const usage = body.usage;
        const model = typeof body.model === 'string' && body.model.length > 0
          ? body.model
          : 'claude-sonnet-4-6';
        await pool.query(
          `INSERT INTO message_usage
             (world_id, session_id, message_id, actor_id, model,
              input_tokens, output_tokens, cache_read_tokens, cache_create_tokens)
           VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
           ON CONFLICT (message_id, actor_id) DO NOTHING`,
          [
            body.world_id,
            body.session_id,
            body.message_id,
            principal.actorId,
            model,
            Number(usage.input_tokens ?? 0),
            Number(usage.output_tokens ?? 0),
            Number(usage.cache_read_input_tokens ?? 0),
            Number(usage.cache_creation_input_tokens ?? 0),
          ],
        );

        // B6 — 80% threshold dedup'd system chunk.
        // After inserting message_usage, check if the per-actor cumulative
        // token count for this session has crossed 80% of the model's context
        // cap. If so, and no prior threshold chunk exists for (session_id,
        // actor_id), emit exactly one dedup'd system chunk.
        //
        // Token math: sum(input_tokens + cache_read_tokens + cache_create_tokens)
        // per (session_id, actor_id), matching Claude Code statusline.
        const contextCap = CONTEXT_CAPS_LOCAL[model] ?? DEFAULT_CONTEXT_CAP;
        const sumResult = await pool.query(
          `SELECT COALESCE(SUM(input_tokens + cache_read_tokens + cache_create_tokens), 0) AS total_used
           FROM message_usage
           WHERE session_id = $1 AND actor_id = $2`,
          [body.session_id, principal.actorId],
        );
        const totalUsed = Number(sumResult.rows[0]?.total_used ?? 0);
        const usedPct = Math.floor((totalUsed / contextCap) * 100);

        if (usedPct >= THRESHOLD_PCT) {
          // Dedup check: scan chunks for an existing threshold-crossing system
          // chunk for this (session_id, actor_id). Prefix-match is sufficient
          // because THRESHOLD_CHUNK_PREFIX is unique to this purpose.
          const dupResult = await pool.query(
            `SELECT 1 FROM chunks
             WHERE session_id = $1 AND actor_id = $2 AND actor_type = 'system'
               AND chunk LIKE $3
             LIMIT 1`,
            [body.session_id, principal.actorId, `${THRESHOLD_CHUNK_PREFIX}%`],
          );
          const alreadyEmitted = (dupResult.rows ?? []).length > 0;

          if (!alreadyEmitted) {
            const personaLabel = principal.actorId;
            const thresholdChunk =
              `${THRESHOLD_CHUNK_PREFIX} ${personaLabel} at ${usedPct}%`;
            // Emit the threshold system chunk. Re-use the same message_id +
            // a seq derived from the current seq + 1 to avoid PK conflict.
            // Use body.seq + 1000 as a high-offset seq so it sorts AFTER the
            // triggering chunk but doesn't collide with normal seq values.
            const thresholdSeq = body.seq + 1000;
            await pool.query(
              `INSERT INTO chunks
                 (world_id, session_id, message_id, seq, actor_id, actor_type, role, chunk, chunk_type)
               VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
               ON CONFLICT (message_id, seq) DO NOTHING`,
              [
                body.world_id,
                body.session_id,
                body.message_id,
                thresholdSeq,
                principal.actorId,
                'system',
                'system',
                thresholdChunk,
                'text',
              ],
            );
          }
        }
      }
    } catch (err) {
      if (err && typeof err === 'object' && 'code' in err && err.code === '23505') {
        return send(res, 409, { error: 'duplicate', message: '(message_id, seq) already exists' });
      }
      return send(res, 500, { error: 'insert-failed', message: String(err?.message ?? err) });
    }
    return send(res, 201, {
      world_id: body.world_id,
      session_id: body.session_id,
      message_id: body.message_id,
      seq: body.seq,
      actor_id: principal.actorId,
      actor_type: principal.actorType,
      chunk_type: body.chunk_type ?? 'text',
    });
  }

  async function handleGetShape(req, res, url) {
    // PB2 — bearer required. Same checkAuth as POST /v1/chunks; rejects with
    // a constant-time comparison via timingSafeEqual.
    if (!checkAuth(req)) return unauthorized(res);

    // B5 — table allowlist. Only chunks + message_usage are supported; any
    // other table= value is rejected with 400. This guards against clients
    // probing arbitrary tables via the shape proxy.
    const tableParam = url.searchParams.get('table');
    if (!tableParam || !ALLOWED_SHAPE_TABLES.has(tableParam)) {
      return badRequest(
        res,
        `table query param must be one of: ${[...ALLOWED_SHAPE_TABLES].join(', ')}`,
      );
    }

    // PB2 — server-derived scope. world_id + session_id query params are
    // mandatory; the regex check is the SQL-injection defence for the
    // upstream `where` interpolation below.
    const worldId = url.searchParams.get('world_id');
    const sessionId = url.searchParams.get('session_id');
    if (!worldId || !SCOPE_ID_RE.test(worldId)) {
      return badRequest(
        res,
        'world_id query param required (alphanumerics + _ - . only)',
      );
    }
    if (!sessionId || !SCOPE_ID_RE.test(sessionId)) {
      return badRequest(
        res,
        'session_id query param required (alphanumerics + _ - . only)',
      );
    }

    // Forward all client query params EXCEPT the scoped set. Client-supplied
    // `where` is silently dropped — server-derived `where` always wins.
    // Operators inspecting traffic see the rewrite in the upstream URL; we
    // don't 400 on `where=` presence because legitimate Electric clients may
    // pass it via library defaults.
    const upstream = new URL(electricBase);
    upstream.pathname = '/v1/shape';
    for (const [key, value] of url.searchParams) {
      if (SCOPED_QUERY_PARAMS.has(key)) continue;
      upstream.searchParams.set(key, value);
    }
    // PB2 — server-derived `where` clause; safe because SCOPE_ID_RE has
    // already rejected single-quotes, semicolons, and SQL meta-characters.
    //
    // Predicate order: session_id FIRST. Electric SQL caches shapes by the
    // exact predicate string; flipping the original (world_id, session_id)
    // order forces a fresh shape and avoids a known cache-staleness bug
    // where an empty-result shape persists after new rows land.
    upstream.searchParams.set(
      'where',
      `session_id='${sessionId}' AND world_id='${worldId}'`,
    );

    // Phase A A2 — log BEFORE upstream fetch. Includes the rewritten
    // `where` predicate so an operator can correlate client-supplied
    // offset/handle with the server-derived scope. Cheap when off: one
    // boolean check.
    if (shapeDebugEnabled) {
      shapeLog('[plan-chat-service:shape] → upstream', {
        upstream: upstream.pathname + upstream.search,
        offset: url.searchParams.get('offset'),
        handle: url.searchParams.get('handle'),
        live: url.searchParams.get('live'),
        where: upstream.searchParams.get('where'),
      });
    }

    let upstreamRes;
    const upstreamStartedAt = shapeDebugEnabled ? performance.now() : 0;
    try {
      upstreamRes = await fetch(upstream, {
        method: 'GET',
        headers: { accept: 'application/json' },
      });
    } catch (err) {
      if (shapeDebugEnabled) {
        shapeLog('[plan-chat-service:shape] ✖ upstream-error', {
          upstream: upstream.pathname + upstream.search,
          err: String(err?.message ?? err),
        });
      }
      return send(res, 502, {
        error: 'shape-upstream-unreachable',
        message: String(err?.message ?? err),
      });
    }

    // Phase A A2 — log AFTER upstream returns headers but BEFORE
    // `res.writeHead` fires (Seam: must not touch the body stream;
    // logging post-pipe risks hanging the response on a sync exception
    // in the logger). Captures the electric-* response headers to
    // disambiguate offset-cursor staleness from handle eviction.
    if (shapeDebugEnabled) {
      const elapsedMs = Math.round(performance.now() - upstreamStartedAt);
      shapeLog(
        `[plan-chat-service:shape] ← upstream ${upstreamRes.status} ${elapsedMs}ms`,
        {
          resHandle: upstreamRes.headers.get('electric-handle'),
          resOffset: upstreamRes.headers.get('electric-offset'),
          resUpToDate: upstreamRes.headers.get('electric-up-to-date'),
          resSchema: upstreamRes.headers.get('electric-schema'),
        },
      );
    }

    // Forward every upstream `electric-*` header BEFORE the stream starts.
    // Once `res.writeHead` fires, headers are locked.
    //
    // Phase B B4 (olam-spa-electric-subscription-staleness) — prefix-match
    // the entire `electric-*` namespace upstream owns instead of a static
    // four-header whitelist. ElectricSQL 1.6.3 added `electric-cursor` to
    // the live-mode response contract; the old whitelist dropped it; the
    // SPA's TanStack Electric collection raised `MissingHeadersError` and
    // stopped processing the long-poll stream. Mirror-all-electric closes
    // the class — any future header upstream adds inside this namespace
    // flows through with zero proxy changes. See plan
    // `~/.claude/plans/olam-spa-electric-subscription-staleness.md` § Phase B
    // and Phase A A3 evidence at
    // `docs/screenshots/olam-spa-electric-subscription-staleness/a3-evidence-2026-05-18/`.
    for (const [header, value] of upstreamRes.headers) {
      if (header.toLowerCase().startsWith('electric-')) {
        res.setHeader(header, value);
      }
    }
    const upstreamContentType = upstreamRes.headers.get('content-type');
    if (upstreamContentType) res.setHeader('content-type', upstreamContentType);

    res.writeHead(upstreamRes.status);

    // PB1 — stream the body without buffering. Electric SQL long-poll
    // responses (`live=true`) can sit open for tens of seconds before the
    // first row arrives; the old `fetch().text()` blocked the response until
    // upstream closed, which broke reactive UX. `Readable.fromWeb` wraps
    // upstream's web ReadableStream as a Node Readable; `.pipe(res)` ends
    // the response automatically when the upstream stream closes.
    //
    // CP3-B1-H1 fix: attach an `error` listener BEFORE piping. Without one,
    // a mid-stream upstream error (Electric SQL restart, network blip mid
    // long-poll, upstream socket reset after headers) becomes an
    // uncaughtException and kills the host-cp process — DoS class. On
    // upstream error we destroy the response so the client sees a
    // truncated body (the only honest signal once headers are sent) and
    // the process stays up. Mirrors the established pattern in
    // packages/host-cp/src/proxy.mjs:205.
    if (upstreamRes.body) {
      const src = Readable.fromWeb(upstreamRes.body);
      src.on('error', (err) => {
        try {
          res.destroy(err);
        } catch {
          /* response already destroyed; fine */
        }
      });
      src.pipe(res);
    } else {
      // Defensive — no body (e.g. HEAD-shaped empty response). Close out.
      res.end();
    }
  }

  async function handleGetPlanningSessions(req, res, url) {
    if (!checkAuth(req)) return unauthorized(res);
    // Derive actorId from the bearer principal (no body on GET).
    const principal = principalFromBearer(bearer, null);
    const limitParam = url.searchParams.get('limit');
    const limit = limitParam ? Math.min(parseInt(limitParam, 10) || 50, 200) : 50;
    try {
      const sessions = await listPlanningSessions({ pool, actorId: principal.actorId, limit });
      return send(res, 200, { sessions });
    } catch (err) {
      return send(res, 500, { error: 'list-failed', message: String(err?.message ?? err) });
    }
  }

  async function handlePostCrystallize(req, res) {
    if (!checkAuth(req)) return unauthorized(res);
    let body;
    try {
      body = await readJson(req);
    } catch (err) {
      return send(res, err?.status ?? 400, { error: err?.message ?? 'bad-request' });
    }

    // Validate required fields: session_id + plan.title + plan.phases
    if (!body || typeof body.session_id !== 'string' || body.session_id.length === 0) {
      return badRequest(res, 'session_id required');
    }
    if (!body.plan || typeof body.plan.title !== 'string' || body.plan.title.length === 0) {
      return badRequest(res, 'plan.title required');
    }
    if (!Array.isArray(body.plan.phases)) {
      return badRequest(res, 'plan.phases must be an array');
    }

    // createWorld + destroyWorld callbacks must be wired at handler creation
    // time (production: WorldManager closures; tests: stubs). If they are not
    // provided, surface a clear 501 rather than crashing.
    if (typeof createWorld !== 'function' || typeof destroyWorld !== 'function') {
      return send(res, 501, {
        ok: false,
        error: 'crystallize-not-wired',
        status: 'failed',
      });
    }

    try {
      const result = await crystallizePlanningSession({
        pool,
        sessionId: body.session_id,
        planTitle: body.plan.title,
        planPhases: body.plan.phases,
        createWorld,
        destroyWorld,
      });
      return send(res, 200, {
        ok: true,
        created_world_id: result.worldId,
        world_url: result.worldId ? `/world/${result.worldId}` : null,
        status: result.status,
      });
    } catch (err) {
      return send(res, 500, {
        ok: false,
        error: String(err?.message ?? err),
        status: 'failed',
      });
    }
  }

  return async function handler(req, res) {
    const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
    if (req.method === 'GET' && url.pathname === '/livez') return send(res, 200, { ok: true });
    if (req.method === 'POST' && url.pathname === '/v1/chunks') return handlePostChunks(req, res);
    if (req.method === 'GET' && url.pathname === '/v1/shape') return handleGetShape(req, res, url);
    if (req.method === 'GET' && url.pathname === '/v1/planning-sessions') return handleGetPlanningSessions(req, res, url);
    if (req.method === 'POST' && url.pathname === '/v1/crystallize') return handlePostCrystallize(req, res);
    return send(res, 404, { error: 'not-found' });
  };
}

/**
 * Boot the service. Returns the http.Server instance plus a stop() helper.
 * Callers pass the resolved bearer + pool + electric URL; defaults live in
 * the standalone launcher below.
 */
export async function startService(opts = {}) {
  const port = opts.port ?? Number(process.env.OLAM_PLAN_CHAT_PORT ?? DEFAULT_PORT);
  const databaseUrl =
    opts.databaseUrl ?? process.env.OLAM_PLAN_CHAT_DATABASE_URL ?? DEFAULT_DB_URL;
  const electricUrl =
    opts.electricUrl ?? process.env.OLAM_PLAN_CHAT_ELECTRIC_URL ?? DEFAULT_ELECTRIC_URL;
  // Layered defense: SECRET_PATH (imported above) is itself env-aware at
  // module load. Reading OLAM_PLAN_CHAT_SECRET_PATH here too lets explicit
  // opts.secretPath callers and env-changes-since-import still win. Do not
  // collapse either layer — direct callers in server.mjs (readPlanChatSecret
  // with no arg) rely on the module-const path being env-aware.
  const secretPath = opts.secretPath ?? process.env.OLAM_PLAN_CHAT_SECRET_PATH ?? SECRET_PATH;
  const bearer = opts.bearer ?? ensureSecret(secretPath);

  const pool = opts.pool ?? new pg.Pool({ connectionString: databaseUrl, max: 8 });
  const handler = createHandler({
    pool,
    bearer,
    electricUrl,
    shapeDebug: opts.shapeDebug,
    shapeDebugLog: opts.shapeDebugLog,
    resolveActor: opts.resolveActor,
  });
  const server = http.createServer((req, res) => {
    handler(req, res).catch((err) => {
      try {
        send(res, 500, { error: 'handler-crash', message: String(err?.message ?? err) });
      } catch { /* response already started */ }
    });
  });

  await new Promise((resolve, reject) => {
    server.once('error', (err) => {
      if (err && err.code === 'EADDRINUSE') {
        // A3 — port-collision diagnostic. Prevents silent half-start when the
        // target port is held by another process. agentmemory's `iii` has
        // historically claimed 3111-3113 (motivated the 3112 → 3200 move in
        // olam-chunks-subscriber-long-poll Phase A); flag it so the next
        // collision is debuggable in one log line, not three rounds of grep.
        // eslint-disable-next-line no-console
        console.error(`[plan-chat-service] EADDRINUSE on :${port} — port already in use.`);
        // eslint-disable-next-line no-console
        console.error(`[plan-chat-service]   Check:  lsof -i:${port}`);
        // eslint-disable-next-line no-console
        console.error(`[plan-chat-service]   Note:   agentmemory's iii claims 3111-3113 (motivated 3112 → 3200 move).`);
        // eslint-disable-next-line no-console
        console.error(`[plan-chat-service]   Override: OLAM_PLAN_CHAT_PORT=<free-port>`);
      }
      reject(err);
    });
    server.listen(port, () => resolve(undefined));
  });

  async function stop() {
    await new Promise((resolve) => server.close(() => resolve(undefined)));
    if (!opts.pool) await pool.end().catch(() => undefined);
  }

  return { server, stop, port, bearer };
}

// Standalone launcher (allows `node plan-chat-service.mjs` for ad-hoc runs +
// the deferred `olam plan-chat start` lifecycle command).
if (import.meta.url === `file://${process.argv[1]}`) {
  startService()
    .then(({ port }) => {
      // eslint-disable-next-line no-console
      console.log(`[plan-chat-service] listening on :${port}`);
    })
    .catch((err) => {
      // eslint-disable-next-line no-console
      console.error('[plan-chat-service] failed to start:', err);
      process.exit(1);
    });
}
