// plan-orchestrator.mjs — Phase 2: multi-persona conversation coordinator.
//
// Architecture:
//  - AgentRegistry holds one pi AgentSession per (conversationId, personaId).
//  - HandoffEngine forks the session tree when the active persona changes.
//  - All persona turns share one session.jsonl per conversation.
//  - SSE sinks are an in-process Set<ServerResponse> per conversationId.
//
// Credentials:
//  - Uses the Olam auth-service vault (same as the rest of host-cp).
//  - No ANTHROPIC_API_KEY required; tokens fetched on demand via auth-service.

import path from 'node:path';
import os from 'node:os';
import fs from 'node:fs';
import { randomUUID } from 'node:crypto';
import Database from 'better-sqlite3';
import { SessionManager } from '@mariozechner/pi-coding-agent';
import { PERSONAS, DEFAULT_PERSONA_ID, getPersona } from './plan/personas.mjs';
import { AgentRegistry } from './plan/agent-registry.mjs';
import { HandoffEngine } from './plan/handoff-engine.mjs';
import { RopeEngine } from './plan/rope-engine.mjs';
import { loadAuthorityConfig } from './plan/authority-config.mjs';
import { isPathVaultUrl, ensurePathVaultProxy } from './plan/path-vault-proxy.mjs';
// Phase D (olam-config-store-unification): config.json reader (dep-free, copied
// from packages/core/src/cloud-state/read-config-value.mjs — host-cp has no
// @olam/core dep). Container-aware /data path resolution lives in config-reader.mjs.
import { readConfigString, olamConfigDir } from './config-reader.mjs';

// ── Cloud path-vault fallback ───────────────────────────────────────────────
//
// When the local auth-service vault has no Claude credential, the plan agent can
// instead reach Claude through the operator's cloud path-vault URL. Resolution
// mirrors server.mjs readAnthropicBaseUrl() (kept independent so this module has
// no server.mjs dependency):
//   1. OLAM_ANTHROPIC_BASE_URL env var
//   2. ~/.olam/anthropic-base-url file
//   3. ANTHROPIC_BASE_URL env var
//
// Only PATH-FORMAT vault URLs (https://host/auth/<sub>/<secret>) are usable as a
// fallback — they self-authenticate, so no live token is required.

/** Placeholder api-key handed to the agent runtime in path-vault mode. The
 *  path prefix is the real credential; the proxy strips this header. */
const PATH_VAULT_PLACEHOLDER_KEY = 'path-vault-proxy';

/** @returns {string} the configured Anthropic base URL, or '' if none. */
function readAnthropicBaseUrlForFallback() {
  const fromOlamEnv = process.env['OLAM_ANTHROPIC_BASE_URL'];
  if (fromOlamEnv && fromOlamEnv.length > 0) return fromOlamEnv.trim();
  // Phase D: config.json leg sits BETWEEN the two env legs (mirrors
  // server.mjs readAnthropicBaseUrl + resolver.ts getAnthropicBaseUrl).
  const fromConfig = readConfigString('cloud.urls.anthropic-base-url');
  if (fromConfig !== null) return fromConfig;
  try {
    // Legacy fallback under the container-aware config dir (/data or ~/.olam).
    const file = path.join(olamConfigDir(), 'anthropic-base-url');
    const content = fs.readFileSync(file, 'utf-8').trim();
    if (content.length > 0) return content;
  } catch {
    // file absent — fall through
  }
  const fromShellEnv = process.env['ANTHROPIC_BASE_URL'];
  if (fromShellEnv && fromShellEnv.length > 0) return fromShellEnv.trim();
  return '';
}

// ── Paths ─────────────────────────────────────────────────────────────────────
//
// Inside the Docker container, os.homedir() → /root, but compose.yaml mounts
// ${HOME}/.olam → /data. Without env overrides, plan.db would be written to
// /root/.olam/plan.db (container ephemeral layer) and lost on every
// `docker compose up --force-recreate` (i.e. every `olam upgrade`).
//
// OLAM_PLAN_DB_PATH and OLAM_PLAN_DIR are set to /data/plan.db and /data/plan
// in compose.yaml so all writes land in the bind-mounted host directory.
// On bare-host installs (no container) neither env var is set and the paths
// fall back to the original ~/.olam locations — no behaviour change.
//
// Paths are resolved at construction time (not module load) so tests can pass
// explicit paths via constructor opts without any module re-import tricks.

/** @returns {string} */
function defaultPlanDbPath() {
  return process.env.OLAM_PLAN_DB_PATH ?? path.join(os.homedir(), '.olam', 'plan.db');
}

/** @returns {string} */
function defaultPlanDir() {
  return process.env.OLAM_PLAN_DIR ?? path.join(os.homedir(), '.olam', 'plan');
}

// ── Helpers ───────────────────────────────────────────────────────────────────

function initSessionFile(sessionFile, sessionId) {
  const header = {
    type: 'session',
    version: 3,
    id: sessionId,
    timestamp: new Date().toISOString(),
    cwd: os.homedir(),
  };
  fs.writeFileSync(sessionFile, JSON.stringify(header) + '\n');
}

/**
 * Derive a short title from the first user message content.
 * Truncates at a word boundary to at most maxLen characters.
 * @param {string} content
 * @param {number} [maxLen=40]
 * @returns {string}
 */
export function deriveTitle(content, maxLen = 40) {
  const trimmed = content.trim().replace(/\s+/g, ' ');
  if (!trimmed) return '(empty)';
  if (trimmed.length <= maxLen) return trimmed;
  const cut = trimmed.slice(0, maxLen);
  const lastSpace = cut.lastIndexOf(' ');
  return (lastSpace > 0 ? cut.slice(0, lastSpace) : cut) + '…';
}

// ── PlanOrchestrator ──────────────────────────────────────────────────────────

export class PlanOrchestrator {
  #db;
  #planDir;
  #authServiceUrl;
  #authServiceSecret;
  #registry;
  #handoffEngine;
  #ropeEngine;

  /** Tracks the active persona per conversationId: Map<conversationId, personaId> */
  #activePersona = new Map();

  /** @type {Map<string, Set<import('node:http').ServerResponse>>} */
  #sinks = new Map();

  /**
   * Ring buffer of in-flight SSE events per conversationId.
   * Populated while a turn is active; cleared after all persona turn_complete events.
   * Used by drainReplayBuffer to replay missed events on reconnect.
   * @type {Map<string, Array<{event: string, data: object}>>}
   */
  #activeTurns = new Map();

  /**
   * Number of persona turn_complete events still pending per conversationId.
   * Replay buffer is only cleared when this reaches 0.
   * @type {Map<string, number>}
   */
  #pendingPersonaCount = new Map();

  /**
   * Mutable current-chunk refs per conversationId.
   * ChunkEmitter updates these; read_sidebar tool reads them.
   * @type {Map<string, { current: string|null }>}
   */
  #currentChunkRefs = new Map();

  /**
   * @param {{
   *   authServiceUrl: string,
   *   authServiceSecret: string,
   *   planDbPath?: string,
   *   planDirPath?: string,
   * }} opts
   *
   * planDbPath / planDirPath default to OLAM_PLAN_DB_PATH / OLAM_PLAN_DIR env vars,
   * falling back to ~/.olam/plan.db and ~/.olam/plan. Pass explicitly in tests to
   * avoid touching real home-dir paths.
   */
  constructor({ authServiceUrl, authServiceSecret, planDbPath, planDirPath } = {}) {
    this.#authServiceUrl = authServiceUrl;
    this.#authServiceSecret = authServiceSecret;

    const legacyDbPath = path.join(os.homedir(), '.olam', 'plan.db');
    // Track whether the caller injected an explicit DB path (used to skip the
    // legacy-path migration below — tests inject tmpDir paths and must not
    // inherit the operator's real plan.db).
    const planDbPathInjected = planDbPath !== undefined;
    const resolvedDbPath = planDbPath ?? defaultPlanDbPath();
    this.#planDir = planDirPath ?? defaultPlanDir();

    this.#registry = new AgentRegistry({ authServiceUrl, authServiceSecret });
    this.#handoffEngine = new HandoffEngine(this.#registry);

    fs.mkdirSync(path.dirname(resolvedDbPath), { recursive: true });

    // One-time migration: if the resolved DB path differs from the legacy default and
    // the target doesn't exist yet, copy any existing DB from the old location.
    // This preserves conversations on a hot-restart after deploying the compose.yaml fix.
    // On full container recreate the legacy path is already gone — this is a no-op.
    //
    // Skip when the caller injected an explicit planDbPath — that's the unit-
    // test shape (each test owns a tmpDir db). Pre-fix history: tests on a host
    // with a populated `~/.olam/plan.db` got every `listConversations()` query
    // polluted by real operator data because the migration eagerly copied the
    // legacy file into the test's tmpDir.
    if (
      !planDbPathInjected &&
      resolvedDbPath !== legacyDbPath &&
      !fs.existsSync(resolvedDbPath) &&
      fs.existsSync(legacyDbPath)
    ) {
      try {
        fs.copyFileSync(legacyDbPath, resolvedDbPath);
        console.info('[plan] Migrated plan.db from legacy path to', resolvedDbPath);
      } catch (err) {
        console.warn('[plan] plan.db migration failed (non-fatal):', err.message);
      }
    }

    this.#db = new Database(resolvedDbPath);
    this.#db.exec(`
      CREATE TABLE IF NOT EXISTS plan_conversations (
        id TEXT PRIMARY KEY,
        title TEXT,
        persona TEXT NOT NULL DEFAULT 'brainstorm',
        created_at INTEGER NOT NULL,
        last_turn_at INTEGER
      );
      CREATE TABLE IF NOT EXISTS plan_turns (
        id TEXT PRIMARY KEY,
        conversation_id TEXT NOT NULL REFERENCES plan_conversations(id),
        role TEXT NOT NULL,
        content TEXT NOT NULL DEFAULT '',
        persona TEXT,
        from_persona TEXT,
        to_persona TEXT,
        mode TEXT,
        fork_node_id TEXT,
        created_at INTEGER NOT NULL
      );
      CREATE INDEX IF NOT EXISTS plan_turns_conv_idx
        ON plan_turns(conversation_id, created_at);

      -- Phase 4B: lookout agent registry per conversation
      CREATE TABLE IF NOT EXISTS plan_lookout_agents (
        conversation_id TEXT NOT NULL,
        persona_id TEXT NOT NULL,
        muted INTEGER NOT NULL DEFAULT 0,
        mode TEXT NOT NULL DEFAULT 'observe',
        created_at INTEGER NOT NULL,
        PRIMARY KEY (conversation_id, persona_id)
      );

      -- Phase 4B: sidebar signals from lookout agents
      CREATE TABLE IF NOT EXISTS plan_sidebar_signals (
        id TEXT PRIMARY KEY,
        conversation_id TEXT NOT NULL,
        agent_id TEXT NOT NULL,
        urgency TEXT NOT NULL DEFAULT 'p2',
        reason TEXT NOT NULL DEFAULT '',
        content TEXT NOT NULL DEFAULT '',
        chunk_id TEXT NOT NULL,
        created_at INTEGER NOT NULL,
        status TEXT NOT NULL DEFAULT 'active',
        tension_subject TEXT,
        parent_signal_id TEXT
      );
      CREATE INDEX IF NOT EXISTS plan_sidebar_conv_idx
        ON plan_sidebar_signals(conversation_id, created_at);
      CREATE INDEX IF NOT EXISTS plan_sidebar_chunk_idx
        ON plan_sidebar_signals(chunk_id);
    `);

    // Migration guard: add pinned column if the table predates this feature.
    const planConvCols = this.#db.prepare(`PRAGMA table_info(plan_conversations)`).all();
    if (!planConvCols.some(c => c.name === 'pinned')) {
      this.#db.exec(`ALTER TABLE plan_conversations ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0`);
    }

    const authorityConfig = loadAuthorityConfig();
    this.#ropeEngine = new RopeEngine({
      registry: this.#registry,
      db: this.#db,
      broadcast: (cId, evt, data) => this.#broadcast(cId, evt, data),
      authorityConfig,
    });
  }

  // ── Auth-service credential fetching ──────────────────────────────────────

  /**
   * Fetch a Claude credential token for an about-to-run turn.
   *
   * Returns a real vault token when the local vault has one, OR a placeholder
   * token in cloud path-vault fallback mode. As a SIDE EFFECT it points the
   * AgentRegistry at the right Anthropic base URL (localhost proxy in path-vault
   * mode, cleared otherwise) BEFORE any runtime is built — every persona /
   * rope / handoff runtime resolves its model base URL from the registry.
   *
   * Used by all turn-dispatch call sites (dispatch, rope-engine, handoff-engine)
   * via `fetchToken: () => this.#fetchToken()`, so the fallback applies uniformly
   * without changing those call sites.
   *
   * @returns {Promise<string>}
   */
  async #fetchToken() {
    const cred = await this.#resolveCredential();
    // setAnthropicBaseUrl points runtimes at the localhost path-vault proxy in
    // fallback mode (else clears it). Guard for registries that predate the
    // method or are test doubles — the path-vault override is best-effort.
    if (typeof this.#registry.setAnthropicBaseUrl === 'function') {
      this.#registry.setAnthropicBaseUrl(cred.mode === 'path-vault' ? cred.baseUrl : null);
    }
    return cred.token;
  }

  /**
   * Resolve a credential for the plan agent, preferring the local auth-service
   * vault and falling back to the operator's cloud path-vault URL when the local
   * vault is empty.
   *
   * @typedef {{ mode: 'vault', token: string }
   *   | { mode: 'path-vault', token: string, baseUrl: string }} CredentialResolution
   *
   * @returns {Promise<CredentialResolution>}
   */
  async #resolveCredential() {
    // 1. Prefer the local vault. When it has a credential, behavior is unchanged.
    //    Call the registry directly (NOT #fetchToken) — #fetchToken delegates
    //    back here, so going through it would recurse.
    try {
      const token = await this.#registry.fetchToken('claude');
      return { mode: 'vault', token };
    } catch (err) {
      // Only fall back on a missing credential — surface real auth-service errors
      // (timeouts, 5xx) so they don't get masked by the path-vault path.
      if (err?.code && err.code !== 'NO_CREDENTIAL') throw err;
    }

    // 2. Fall back to the cloud path-vault URL, if configured + path-format.
    const baseUrl = readAnthropicBaseUrlForFallback();
    if (!isPathVaultUrl(baseUrl)) {
      // No usable fallback — re-raise the original NO_CREDENTIAL shape so callers
      // (hasCredential / dispatch) behave exactly as before.
      const e = new Error('no active claude credential in vault');
      e.code = 'NO_CREDENTIAL';
      throw e;
    }

    const localBaseUrl = await ensurePathVaultProxy(baseUrl);
    return { mode: 'path-vault', token: PATH_VAULT_PLACEHOLDER_KEY, baseUrl: localBaseUrl };
  }

  /**
   * Lightweight check — returns true when a credential is reachable, either from
   * the local vault OR the cloud path-vault fallback.
   * @returns {Promise<boolean>}
   */
  async hasCredential() {
    try {
      await this.#resolveCredential();
      return true;
    } catch {
      return false;
    }
  }

  // ── Conversation management ───────────────────────────────────────────────

  /**
   * @param {{ title?: string }} [opts]
   * @returns {{ id: string, title: string|null, persona: string, created_at: number }}
   */
  createConversation({ title } = {}) {
    const id = randomUUID();
    const created_at = Date.now();

    const sessionDir = path.join(this.#planDir, id);
    fs.mkdirSync(sessionDir, { recursive: true });
    initSessionFile(path.join(sessionDir, 'session.jsonl'), id);

    this.#db
      .prepare(
        `INSERT INTO plan_conversations (id, title, persona, created_at)
         VALUES (?, ?, ?, ?)`,
      )
      .run(id, title ?? null, DEFAULT_PERSONA_ID, created_at);

    this.#activePersona.set(id, DEFAULT_PERSONA_ID);

    return { id, title: title ?? null, persona: DEFAULT_PERSONA_ID, created_at };
  }

  /** @returns {Array<{id, title, pinned, created_at, last_turn_at, persona, snippet}>} */
  listConversations() {
    return this.#db
      .prepare(
        `SELECT
           c.id, c.title, c.pinned, c.created_at, c.last_turn_at, c.persona,
           (SELECT pt.content FROM plan_turns pt
            WHERE pt.conversation_id = c.id
            ORDER BY pt.created_at DESC LIMIT 1) AS snippet
         FROM plan_conversations c
         ORDER BY c.pinned DESC, COALESCE(c.last_turn_at, c.created_at) DESC, c.rowid DESC`,
      )
      .all();
  }

  /**
   * Patch a conversation's title and/or pinned state.
   * @param {string} id
   * @param {{ title?: string, pinned?: boolean }} updates
   * @returns {object|null} Updated row, or null if not found.
   */
  patchConversation(id, updates) {
    const parts = [];
    const values = [];
    if (updates.title !== undefined) {
      parts.push('title = ?');
      values.push(updates.title || null);
    }
    if (updates.pinned !== undefined) {
      parts.push('pinned = ?');
      values.push(updates.pinned ? 1 : 0);
    }
    if (parts.length === 0) return null;
    values.push(id);
    const changed = this.#db
      .prepare(`UPDATE plan_conversations SET ${parts.join(', ')} WHERE id = ?`)
      .run(...values);
    if (changed.changes === 0) return null;
    return this.#db
      .prepare(`SELECT id, title, pinned, created_at, last_turn_at, persona FROM plan_conversations WHERE id = ?`)
      .get(id) ?? null;
  }

  /**
   * Delete a conversation and all its associated data.
   * @param {string} id
   * @returns {boolean} true if deleted, false if not found.
   */
  deleteConversation(id) {
    const exists = this.#db
      .prepare(`SELECT 1 FROM plan_conversations WHERE id = ?`)
      .get(id);
    if (!exists) return false;

    this.#db.prepare(`DELETE FROM plan_turns WHERE conversation_id = ?`).run(id);
    this.#db.prepare(`DELETE FROM plan_lookout_agents WHERE conversation_id = ?`).run(id);
    this.#db.prepare(`DELETE FROM plan_sidebar_signals WHERE conversation_id = ?`).run(id);
    this.#db.prepare(`DELETE FROM plan_conversations WHERE id = ?`).run(id);

    this.#activePersona.delete(id);
    this.#sinks.delete(id);
    this.#activeTurns.delete(id);
    this.#currentChunkRefs.delete(id);

    const sessionDir = path.join(this.#planDir, id);
    try { fs.rmSync(sessionDir, { recursive: true }); } catch { /* ok if missing */ }

    return true;
  }

  /**
   * @param {string} id
   * @returns {{ id, title, persona, created_at, last_turn_at, tree } | null}
   */
  getConversation(id) {
    const row = this.#db
      .prepare(
        `SELECT id, title, persona, created_at, last_turn_at
         FROM plan_conversations WHERE id = ?`,
      )
      .get(id);

    if (!row) return null;

    const sessionFile = path.join(this.#planDir, id, 'session.jsonl');
    let tree = [];
    try {
      const mgr = SessionManager.open(sessionFile, path.join(this.#planDir, id));
      tree = mgr.getTree();
    } catch {
      // Session file missing or corrupt — return empty tree.
    }

    return { ...row, tree };
  }

  // ── Active persona management ─────────────────────────────────────────────

  /**
   * @param {string} conversationId
   * @returns {string} Active persona ID.
   */
  getActivePersona(conversationId) {
    if (this.#activePersona.has(conversationId)) {
      return this.#activePersona.get(conversationId);
    }
    const row = this.#db
      .prepare(`SELECT persona FROM plan_conversations WHERE id = ?`)
      .get(conversationId);
    const personaId = row?.persona ?? DEFAULT_PERSONA_ID;
    this.#activePersona.set(conversationId, personaId);
    return personaId;
  }

  /**
   * Set the active default persona for a conversation (does NOT trigger a handoff).
   * @param {string} conversationId
   * @param {string} personaId
   */
  setActivePersona(conversationId, personaId) {
    this.#activePersona.set(conversationId, personaId);
    this.#db
      .prepare(`UPDATE plan_conversations SET persona = ? WHERE id = ?`)
      .run(personaId, conversationId);
  }

  // ── SSE broadcast ─────────────────────────────────────────────────────────

  #broadcast(conversationId, eventName, data) {
    // Buffer event while a turn is active for reconnect replay.
    const buf = this.#activeTurns.get(conversationId);
    if (buf) {
      buf.push({ event: eventName, data });
    }

    const sinks = this.#sinks.get(conversationId);
    if (!sinks || sinks.size === 0) return;
    const chunk = `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`;
    for (const res of sinks) {
      try { res.write(chunk); } catch { /* client disconnected */ }
    }

    // Clear buffer only when all pending personas have completed.
    if (eventName === 'turn_complete') {
      const pending = (this.#pendingPersonaCount.get(conversationId) ?? 1) - 1;
      if (pending <= 0) {
        this.#activeTurns.delete(conversationId);
        this.#pendingPersonaCount.delete(conversationId);
      } else {
        this.#pendingPersonaCount.set(conversationId, pending);
      }
    }
  }

  // ── Lookout agent management ──────────────────────────────────────────────

  /**
   * Invite a persona as a lookout for a conversation.
   * @param {string} conversationId
   * @param {string} personaId
   * @returns {{ persona_id: string, state: string, muted: boolean, mode: string }}
   */
  inviteLookout(conversationId, personaId) {
    const now = Date.now();
    this.#db
      .prepare(`INSERT OR IGNORE INTO plan_lookout_agents (conversation_id, persona_id, muted, mode, created_at) VALUES (?, ?, 0, 'observe', ?)`)
      .run(conversationId, personaId, now);
    const agent = { persona_id: personaId, state: 'listening', muted: false, mode: 'observe' };
    this.#broadcast(conversationId, 'agent_state', { persona_id: personaId, state: 'listening' });
    return agent;
  }

  /**
   * Update muted status (or mode) for a lookout agent.
   * @param {string} conversationId
   * @param {string} personaId
   * @param {{ muted?: boolean, mode?: string }} updates
   * @returns {{ persona_id: string, state: string, muted: boolean, mode: string } | null}
   */
  updateLookout(conversationId, personaId, { muted, mode } = {}) {
    const row = this.#db
      .prepare(`SELECT * FROM plan_lookout_agents WHERE conversation_id = ? AND persona_id = ?`)
      .get(conversationId, personaId);
    if (!row) return null;

    const newMuted = muted !== undefined ? (muted ? 1 : 0) : row.muted;
    const newMode = mode ?? row.mode;
    this.#db
      .prepare(`UPDATE plan_lookout_agents SET muted = ?, mode = ? WHERE conversation_id = ? AND persona_id = ?`)
      .run(newMuted, newMode, conversationId, personaId);

    const newState = newMuted ? 'idle' : 'listening';
    this.#broadcast(conversationId, 'agent_state', { persona_id: personaId, state: newState });
    return { persona_id: personaId, state: newState, muted: !!newMuted, mode: newMode };
  }

  /**
   * Remove a lookout agent.
   * @param {string} conversationId
   * @param {string} personaId
   */
  uninviteLookout(conversationId, personaId) {
    this.#db
      .prepare(`DELETE FROM plan_lookout_agents WHERE conversation_id = ? AND persona_id = ?`)
      .run(conversationId, personaId);
  }

  /**
   * List active lookout agents for a conversation.
   * @param {string} conversationId
   * @returns {Array<{ persona_id: string, state: string, muted: boolean, mode: string }>}
   */
  listLookoutAgents(conversationId) {
    const rows = this.#db
      .prepare(`SELECT persona_id, muted, mode FROM plan_lookout_agents WHERE conversation_id = ?`)
      .all(conversationId);
    return rows.map((r) => ({
      persona_id: r.persona_id,
      state: r.muted ? 'idle' : 'listening',
      muted: !!r.muted,
      mode: r.mode,
    }));
  }

  // ── Sidebar signal management ─────────────────────────────────────────────

  /**
   * Dismiss a sidebar signal.
   * @param {string} conversationId
   * @param {string} signalId
   * @returns {boolean}
   */
  dismissSignal(conversationId, signalId) {
    const info = this.#db
      .prepare(`UPDATE plan_sidebar_signals SET status = 'dismissed' WHERE id = ? AND conversation_id = ?`)
      .run(signalId, conversationId);
    return info.changes > 0;
  }

  /**
   * Mark a sidebar signal as used (for next turn context).
   * @param {string} conversationId
   * @param {string} signalId
   * @returns {boolean}
   */
  useSignal(conversationId, signalId) {
    const info = this.#db
      .prepare(`UPDATE plan_sidebar_signals SET status = 'used' WHERE id = ? AND conversation_id = ?`)
      .run(signalId, conversationId);
    return info.changes > 0;
  }

  /**
   * List sidebar signals for a conversation (optionally filtered by chunk_id).
   * @param {string} conversationId
   * @param {string} [chunkId]
   * @returns {Array<object>}
   */
  listSignals(conversationId, chunkId) {
    if (chunkId) {
      return this.#db
        .prepare(`SELECT * FROM plan_sidebar_signals WHERE conversation_id = ? AND chunk_id = ? ORDER BY created_at ASC`)
        .all(conversationId, chunkId);
    }
    return this.#db
      .prepare(`SELECT * FROM plan_sidebar_signals WHERE conversation_id = ? ORDER BY created_at ASC`)
      .all(conversationId);
  }

  // ── Lookout analysis ──────────────────────────────────────────────────────

  /**
   * Persona-specific heuristics for lookout analysis.
   * Returns { shouldComment: boolean, urgency, content, reason, tension_subject? }
   * or null if no comment warranted.
   *
   * @param {string} personaId
   * @param {string} content — chunk content to analyze
   * @returns {{ urgency: string, content: string, reason: string, tension_subject?: string } | null}
   */
  #analyzeChunkHeuristic(personaId, content) {
    const lower = content.toLowerCase();

    if (personaId === 'scout') {
      // Scout: flag unsubstantiated claims and factual assertions
      const claimPatterns = [
        /\b(research shows|studies (show|indicate|suggest)|data (shows|indicates|suggests))\b/i,
        /\b\d+(\.\d+)?\s*%\b/,
        /\b(always|never|all|every|none|no one)\b/i,
        /\b(proven|definitive|certain|guaranteed|undeniable)\b/i,
        /\b(industry standard|best practice|widely accepted)\b/i,
      ];
      const matched = claimPatterns.find((p) => p.test(content));
      if (matched) {
        return {
          urgency: 'p2',
          reason: 'Factual claim without cited source',
          content: 'This response contains claims that should be verified with evidence. What data or sources back this up?',
        };
      }
      // Scout spark: look for unexplored data angles
      if (lower.includes('option') || lower.includes('approach') || lower.includes('strategy')) {
        if (Math.random() < 0.3) {
          return {
            urgency: 'spark',
            reason: 'Potential evidence gap',
            content: '_What metrics or signals would tell us which option is actually better here?_',
          };
        }
      }
    }

    if (personaId === 'pm') {
      // PM: flag scope ambiguity and missing requirements
      const scopePatterns = [
        /\b(could|might|maybe|perhaps|possibly|potentially)\b/i,
        /\b(later|eventually|someday|future)\b/i,
        /\b(depends on|unclear|tbd|to be determined)\b/i,
      ];
      const matched = scopePatterns.find((p) => p.test(content));
      if (matched) {
        return {
          urgency: 'p1',
          reason: 'Scope ambiguity detected',
          content: 'Scope boundary needs clarification. What specifically is in vs. out for this iteration?',
        };
      }
      // PM: flag missing success criteria
      if ((lower.includes('implement') || lower.includes('build') || lower.includes('create')) && !lower.includes('success') && !lower.includes('metric') && !lower.includes('goal')) {
        if (Math.random() < 0.4) {
          return {
            urgency: 'p2',
            reason: 'Missing acceptance criteria',
            content: 'What does done look like here? Define the measurable success criteria before building.',
          };
        }
      }
    }

    if (personaId === 'brainstorm') {
      // Brainstorm: flag premature convergence on a single option
      const convergencePatterns = [
        /\b(the (best|right|correct|only) (way|approach|solution|option))\b/i,
        /\b(we should|we must|we need to|the answer is)\b/i,
        /\b(obviously|clearly|simply|just)\b/i,
      ];
      const matched = convergencePatterns.find((p) => p.test(content));
      if (matched) {
        return {
          urgency: 'spark',
          reason: 'Early convergence on one path',
          content: '_Before narrowing: what\'s the alternative that explicitly rejects this approach? What would it look like?_',
        };
      }
    }

    return null;
  }

  /**
   * Run lookout analysis for all active lookout agents after a turn completes.
   * Emits sidebar_entry SSE events for any signals generated.
   *
   * @param {string} conversationId
   * @param {string} chunkId — the turn ID used as chunk reference
   * @param {string} chunkContent — the assistant's response text
   * @param {string} chunkPersona — which persona produced the chunk
   */
  async #runLookoutAnalysis(conversationId, chunkId, chunkContent, chunkPersona) {
    const lookouts = this.#db
      .prepare(`SELECT persona_id, muted FROM plan_lookout_agents WHERE conversation_id = ? AND muted = 0`)
      .all(conversationId);

    for (const lookout of lookouts) {
      const { persona_id: personaId } = lookout;

      // Skip if this is the persona that produced the chunk
      if (personaId === chunkPersona) continue;

      // Emit thinking state
      this.#broadcast(conversationId, 'agent_state', { persona_id: personaId, state: 'thinking' });

      // Small async gap to let the SSE event reach the client before analysis
      await new Promise((resolve) => setTimeout(resolve, 300 + Math.random() * 700));

      try {
        const analysis = this.#analyzeChunkHeuristic(personaId, chunkContent);

        if (analysis) {
          const signalId = randomUUID();
          const now = Date.now();
          this.#db
            .prepare(
              `INSERT INTO plan_sidebar_signals (id, conversation_id, agent_id, urgency, reason, content, chunk_id, created_at, status, tension_subject)
               VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?)`,
            )
            .run(signalId, conversationId, personaId, analysis.urgency, analysis.reason, analysis.content, chunkId, now, analysis.tension_subject ?? null);

          const signal = {
            id: signalId,
            agent_id: personaId,
            urgency: analysis.urgency,
            reason: analysis.reason,
            content: analysis.content,
            chunk_id: chunkId,
            created_at: now,
            status: 'active',
            tension_subject: analysis.tension_subject ?? null,
            parent_signal_id: null,
          };

          this.#broadcast(
            conversationId,
            analysis.urgency === 'p0' ? 'interrupt' : 'sidebar_entry',
            { signal },
          );
        }
      } catch (err) {
        console.error(`[plan] lookout analysis error ${conversationId}/${personaId}:`, err.message);
      }

      // Return to listening state
      this.#broadcast(conversationId, 'agent_state', { persona_id: personaId, state: 'listening' });
    }
  }

  // ── Persona subscription setup ────────────────────────────────────────────

  /**
   * Wire pi event listeners for a session so tokens + turn_complete events are
   * forwarded to SSE clients.
   *
   * @param {string} conversationId
   * @param {string} personaId
   * @param {import('@mariozechner/pi-coding-agent').AgentSession} session
   */
  #wireSessionEvents(conversationId, personaId, session) {
    session.subscribe((event) => {
      if (event.type === 'message_update') {
        const ae = event.assistantMessageEvent;
        if (ae.type === 'text_delta') {
          this.#broadcast(conversationId, 'token', { delta: ae.delta, persona: personaId });
        }
      } else if (event.type === 'agent_end') {
        const msgs = event.messages;
        const last = msgs[msgs.length - 1];

        let persistedText = '';
        let turnId = last?.id ?? randomUUID();

        // Persist the assistant turn so history loads correctly.
        if (last) {
          const text = (last.content ?? [])
            .filter((c) => c.type === 'text')
            .map((c) => c.text ?? '')
            .join('');
          if (text) {
            persistedText = text;
            const now = Date.now();
            this.#db
              .prepare(
                `INSERT OR IGNORE INTO plan_turns
                 (id, conversation_id, role, content, persona, created_at)
                 VALUES (?, ?, 'assistant', ?, ?, ?)`,
              )
              .run(turnId, conversationId, text, personaId, now);
          }
        }

        this.#broadcast(conversationId, 'turn_complete', {
          turnId,
          persona: personaId,
          finishReason: last?.stopReason ?? 'end_turn',
        });
        this.#db
          .prepare(`UPDATE plan_conversations SET last_turn_at = ? WHERE id = ?`)
          .run(Date.now(), conversationId);

        // Trigger lookout analysis asynchronously — does not block the turn.
        if (persistedText) {
          this.#runLookoutAnalysis(conversationId, turnId, persistedText, personaId)
            .catch((err) => console.error('[plan] lookout run error:', err.message));
        }
      }
    });
  }

  // ── Public API ────────────────────────────────────────────────────────────

  /**
   * Submit a user turn to one or more personas in parallel.
   * When mentionedPersonas contains 2+ IDs, each receives its own AgentSession
   * and streams tokens with per-persona attribution via SSE `persona` field.
   * Returns immediately; tokens stream over SSE.
   *
   * @param {{
   *   conversationId: string,
   *   content: string,
   *   personaOverride?: string,
   *   mentionedPersonas?: string[],
   * }} params
   * @returns {Promise<{ turnId: string, persona: string }>}
   */
  async submitTurn({ conversationId, content, personaOverride, mentionedPersonas }) {
    const row = this.#db
      .prepare(`SELECT id, title FROM plan_conversations WHERE id = ?`)
      .get(conversationId);

    if (!row) {
      const err = new Error('conversation not found');
      err.code = 'NOT_FOUND';
      throw err;
    }

    const now = Date.now();

    // Determine which personas will receive this turn.
    // Multi-persona: user @-mentioned 2+ personas explicitly.
    // Single-persona: use explicit override or the conversation's active persona.
    const personasToDispatch = (mentionedPersonas?.length ?? 0) > 1
      ? mentionedPersonas
      : [personaOverride ?? this.getActivePersona(conversationId)];

    // Open (or reset) the replay buffer; track how many turn_complete events are expected.
    this.#activeTurns.set(conversationId, []);
    this.#pendingPersonaCount.set(conversationId, personasToDispatch.length);

    // Set title from first user message if still null.
    if (row.title === null) {
      this.#db
        .prepare(`UPDATE plan_conversations SET title = ? WHERE id = ?`)
        .run(deriveTitle(content), conversationId);
    }

    // Persist the user turn once (regardless of how many personas respond).
    this.#db
      .prepare(
        `INSERT INTO plan_turns (id, conversation_id, role, content, created_at)
         VALUES (?, ?, 'user', ?, ?)`,
      )
      .run(randomUUID(), conversationId, content, now);

    const isSinglePersona = personasToDispatch.length === 1;

    // Dispatch to each persona. For multi-persona turns, skip rope enrichment —
    // the user explicitly chose all participants, so no auto-delegation is needed.
    await Promise.all(personasToDispatch.map(async (pId) => {
      const onStubCall = (event) => {
        this.#broadcast(conversationId, 'tool_stub_call', { persona: pId, ...event });
      };

      // Refresh credential before each turn. MUST precede getAgent(): in cloud
      // path-vault fallback mode #fetchToken points the registry at the localhost
      // proxy base URL, and getAgent() bakes that base URL into the runtime's
      // model when it first builds the (cached) runtime.
      const token = await this.#fetchToken();

      const { session, authStorage } = await this.#registry.getAgent(conversationId, pId, { onStubCall });

      // Wire events on first use (idempotent because pi de-duplicates subscribers).
      this.#wireSessionEvents(conversationId, pId, session);

      authStorage.setRuntimeApiKey('anthropic', token);

      let promptContent = content;
      if (isSinglePersona) {
        // Pre-turn autoRope enrichment (Phase D): run any persona's autoRope rules
        // before the caller's session sees the content. Keeps pm_gathering_context
        // backward-compat; rope_start/rope_complete are emitted by RopeEngine.
        promptContent = await this.#ropeEngine.autoDelegateIfNeeded({
          conversationId,
          callerPersonaId: pId,
          content,
          fetchToken: () => this.#fetchToken(),
        });
      }

      session.prompt(promptContent).catch((err) => {
        console.error(`[plan] prompt error ${conversationId}/${pId}:`, err.message);
        this.#broadcast(conversationId, 'error', {
          message: err.message,
          code: err.code ?? 'PROMPT_ERROR',
        });
      });
    }));

    const turnId = randomUUID();
    return { turnId, persona: personasToDispatch[0] };
  }

  /**
   * Execute a handoff, switching the default active persona.
   *
   * @param {{
   *   conversationId: string,
   *   toPersona: string,
   *   mode?: 'full' | 'distilled' | 'quoted',
   *   selectedTurnIds?: string[],
   * }} params
   * @returns {Promise<{ handoffId: string, forkNodeId: string | null, seededTurnCount: number }>}
   */
  async handoff({ conversationId, toPersona, mode = 'full', selectedTurnIds = [] }) {
    const row = this.#db
      .prepare(`SELECT id FROM plan_conversations WHERE id = ?`)
      .get(conversationId);
    if (!row) {
      const err = new Error('conversation not found');
      err.code = 'NOT_FOUND';
      throw err;
    }

    const fromPersona = this.getActivePersona(conversationId);
    const onStubCall = (event) => {
      this.#broadcast(conversationId, 'tool_stub_call', { persona: toPersona, ...event });
    };

    const result = await this.#handoffEngine.handoff({
      conversationId,
      fromPersona,
      toPersona,
      mode,
      selectedTurnIds,
      fetchToken: () => this.#fetchToken(),
      onStubCall,
    });

    // Update the active persona for this conversation.
    this.setActivePersona(conversationId, toPersona);

    // Persist handoff marker so history replay can reconstruct it.
    this.#db
      .prepare(
        `INSERT OR IGNORE INTO plan_turns
         (id, conversation_id, role, content, from_persona, to_persona, mode, fork_node_id, created_at)
         VALUES (?, ?, 'handoff', '', ?, ?, ?, ?, ?)`,
      )
      .run(result.handoffId, conversationId, fromPersona, toPersona, mode, result.forkNodeId ?? null, Date.now());

    // Broadcast the handoff event to SSE clients.
    this.#broadcast(conversationId, 'handoff', {
      handoffId: result.handoffId,
      fromPersona,
      toPersona,
      mode,
      forkNodeId: result.forkNodeId,
    });

    // Wire events for the new persona's session.
    try {
      const { session } = await this.#registry.getAgent(conversationId, toPersona, { onStubCall });
      this.#wireSessionEvents(conversationId, toPersona, session);
    } catch {
      // Best-effort — events will be wired on first turn if this fails.
    }

    return result;
  }

  /**
   * Replay buffered in-flight SSE events to a reconnecting client.
   * Call this before addEventSink so the client gets events it missed.
   * No-op if no turn is active.
   *
   * @param {string} conversationId
   * @param {import('node:http').ServerResponse} res
   */
  drainReplayBuffer(conversationId, res) {
    const buf = this.#activeTurns.get(conversationId);
    if (!buf || buf.length === 0) return;
    for (const { event, data } of buf) {
      try {
        res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
      } catch { /* client closed before drain completed */ }
    }
  }

  /**
   * Register an SSE sink for a conversation. Returns a cleanup function.
   * @param {string} conversationId
   * @param {import('node:http').ServerResponse} res
   * @returns {() => void}
   */
  addEventSink(conversationId, res) {
    if (!this.#sinks.has(conversationId)) {
      this.#sinks.set(conversationId, new Set());
    }
    this.#sinks.get(conversationId).add(res);
    return () => {
      const s = this.#sinks.get(conversationId);
      if (s) s.delete(res);
    };
  }

  /**
   * Return the ordered turn list for a conversation (for history replay).
   * Each turn is one of:
   *   { role:'user'|'assistant', content, persona?, created_at }
   *   { role:'handoff', from_persona, to_persona, mode, fork_node_id, created_at }
   * @param {string} conversationId
   * @returns {Array<object>}
   */
  getTurns(conversationId) {
    return this.#db
      .prepare(
        `SELECT id, role, content, persona, from_persona, to_persona, mode, fork_node_id, created_at
         FROM plan_turns
         WHERE conversation_id = ?
         ORDER BY created_at ASC`,
      )
      .all(conversationId);
  }

  /** Expose persona list for the /api/plan/personas endpoint. */
  listPersonas() {
    return PERSONAS.map((p) => ({
      id: p.id,
      displayName: p.displayName,
      model: p.model,
      toolNames: p.toolNames,
      systemPromptPreview: p.systemPrompt.length > 120
        ? p.systemPrompt.slice(0, 117) + '...'
        : p.systemPrompt,
    }));
  }
}
