/**
 * Codex (OpenAI app-server) harness — Phase 2.
 *
 * One long-lived `codex app-server` subprocess per conversation. We talk to
 * it via JSON-RPC 2.0 over stdio — bidirectional: client → server requests,
 * server → client notifications. Tokens come from `~/.codex/auth.json`
 * (codex reads them itself; we don't pass anything).
 *
 * Lifecycle per live conversation:
 *   spawn → initialize → initialized → thread/start → turn/start (per
 *   user message; turn/steer to inject mid-turn) → turn/completed → idle
 *   → endConversation → turn/interrupt (if needed) → kill subprocess
 *
 * Lifecycle per one-shot query: same as above, but the subprocess is killed
 * as soon as `turn/completed` arrives.
 *
 * Model strings accept either bare ids (`gpt-5.5`) or `<id>:<effort>` where
 * effort is one of low|medium|high|xhigh — the suffix is split off and
 * passed as `effort` on `turn/start`.
 *
 * Notes on parity with Claude harness:
 *   - System prompt → `baseInstructions` on `thread/start`
 *   - Sub-agents → not implemented (Codex has Skills, different model)
 *   - MCP servers → not wired yet (Codex has its own MCP layer)
 *   - Mid-turn input uses `turn/steer` (better than Claude's queue)
 */

import { spawn, type ChildProcessWithoutNullStreams } from 'child_process';
import { createRequire } from 'module';
import readline from 'readline';
import fs from 'fs';
import path from 'path';
import { log } from '../../shared/logger.js';
import { WORKSPACE_DIR } from '../../shared/paths.js';
import type { SavedFile } from '../file-saver.js';
import { getCodexAccessToken } from '../../worker/codex-auth.js';
import { assembleSystemPrompt } from '../../worker/prompts/prompt-assembler.js';
import type { OnAgentMessage, RecentMessage, AgentAttachment, AgentQueryRequest, AgentQueryResult } from './types.js';
export type { RecentMessage, AgentAttachment };

/* ── Constants ─────────────────────────────────────────────────────────── */

const CLIENT_INFO = { name: 'bloby', title: 'Bloby', version: '1' };
const REQUEST_TIMEOUT_MS = 60_000;
const VALID_EFFORTS = new Set(['none', 'minimal', 'low', 'medium', 'high', 'xhigh']);
/**
 * Per-turn IDLE watchdog. `turn/completed` is a NON-guaranteed notification — if
 * the app-server stalls mid-turn without exiting, the RPC `exit` handler never
 * fires and `busy` stays true forever (live: wedges the dashboard + defers
 * backend restarts; one-shot: pins the WhatsApp/scheduler slot since bot:done
 * never arrives). This is an IDLE timeout, reset on every notification for the
 * conversation — a legitimately long turn (deep reasoning, many tool calls)
 * keeps emitting events and is never killed; only true silence trips recovery.
 */
const TURN_WATCHDOG_MS = 5 * 60_000;

/**
 * Resolve the `codex` binary. We don't trust $PATH because Bloby may be
 * installed globally without `@openai/codex` also globally available. Order:
 *   1. BLOBY_CODEX_BIN env override (advanced users / dev)
 *   2. The `bin` entry from the bundled `@openai/codex` npm package
 *      (this is what `npm install bloby-bot` will pull in)
 *   3. Fall back to `codex` on PATH (works when the user installed codex CLI
 *      separately, e.g. via apt/brew).
 */
let cachedCodexBin: string | null = null;
function resolveCodexBin(): string {
  if (cachedCodexBin) return cachedCodexBin;

  if (process.env.BLOBY_CODEX_BIN) {
    cachedCodexBin = process.env.BLOBY_CODEX_BIN;
    return cachedCodexBin;
  }

  try {
    const requireFromHere = createRequire(import.meta.url);
    const pkgPath = requireFromHere.resolve('@openai/codex/package.json');
    const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
    const binEntry = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.codex;
    if (binEntry) {
      const resolved = path.join(path.dirname(pkgPath), binEntry);
      if (fs.existsSync(resolved)) {
        cachedCodexBin = resolved;
        log.info(`[codex] using bundled binary: ${resolved}`);
        return cachedCodexBin;
      }
    }
  } catch {
    // @openai/codex not installed as a dep — fall through.
  }

  cachedCodexBin = 'codex';
  return cachedCodexBin;
}

/* ── Prompt-assembly helpers (duplicated from claude.ts to keep that file
 *    untouched per the project rule) ───────────────────────────────────── */

function readMemoryFile(filename: string): string {
  try {
    const content = fs.readFileSync(path.join(WORKSPACE_DIR, filename), 'utf-8').trim();
    return content || '(empty)';
  } catch {
    return '(empty)';
  }
}

function readMemoryFiles() {
  return {
    myself: readMemoryFile('MYSELF.md'),
    myhuman: readMemoryFile('MYHUMAN.md'),
    memory: readMemoryFile('MEMORY.md'),
    pulse: readMemoryFile('PULSE.json'),
    crons: readMemoryFile('CRONS.json'),
  };
}

function formatConversationHistory(messages: RecentMessage[]): string {
  if (!messages.length) return '';
  return messages.map((m) => `${m.role}: ${m.content}`).join('\n\n');
}

async function assembleBaseInstructions(
  names?: { botName: string; humanName: string },
  recentMessages?: RecentMessage[],
): Promise<string> {
  const memoryFiles = readMemoryFiles();
  const basePrompt = await assembleSystemPrompt(names?.botName, names?.humanName, 'codex');
  let prompt = basePrompt;
  prompt += `\n\n---\n# Your Memory Files\n\n## MYSELF.md\n${memoryFiles.myself}\n\n## MYHUMAN.md\n${memoryFiles.myhuman}\n\n## MEMORY.md\n${memoryFiles.memory}\n\n---\n# Your Config Files\n\n## PULSE.json\n${memoryFiles.pulse}\n\n## CRONS.json\n${memoryFiles.crons}`;

  try {
    const { loadConfig: loadCfg } = await import('../../shared/config.js');
    const cfg = loadCfg();
    const channels = (cfg as any).channels;
    if (channels) {
      prompt += `\n\n---\n# Channel Config\n\`\`\`json\n${JSON.stringify(channels, null, 2)}\n\`\`\``;
    }
  } catch {}

  if (recentMessages?.length) {
    prompt += `\n\n---\n# Recent Conversation\n${formatConversationHistory(recentMessages)}`;
  }

  return prompt;
}

/** Split `gpt-5.5:high` into `{ id: 'gpt-5.5', effort: 'high' }`. */
function parseModelString(model: string): { id: string; effort?: string } {
  const idx = model.lastIndexOf(':');
  if (idx <= 0) return { id: model };
  const candidate = model.slice(idx + 1);
  if (!VALID_EFFORTS.has(candidate)) return { id: model };
  return { id: model.slice(0, idx), effort: candidate };
}

/* ── JSON-RPC client over stdio ────────────────────────────────────────── */

type RpcResult<T = any> = { id: number; result?: T; error?: { code?: number; message: string } };
type RpcNotification = { method: string; params?: any };

interface PendingRequest {
  resolve: (value: any) => void;
  reject: (err: Error) => void;
  timer: NodeJS.Timeout;
}

class CodexRpc {
  private proc: ChildProcessWithoutNullStreams | null = null;
  private pending = new Map<number, PendingRequest>();
  private nextId = 1;
  private notificationHandler: (n: RpcNotification) => void = () => {};
  private closeHandler: (code: number | null) => void = () => {};
  private closed = false;
  private stderrBuf = '';

  start(extraArgs: string[] = []): void {
    this.proc = spawn(resolveCodexBin(), ['app-server', ...extraArgs], { stdio: ['pipe', 'pipe', 'pipe'] });
    const rl = readline.createInterface({ input: this.proc.stdout });
    rl.on('line', (line) => this.onLine(line));

    this.proc.stderr.on('data', (chunk) => {
      this.stderrBuf += chunk.toString();
      // Trim if growing unbounded.
      if (this.stderrBuf.length > 16_000) this.stderrBuf = this.stderrBuf.slice(-8_000);
    });

    this.proc.on('exit', (code) => {
      if (this.closed) return;
      this.closed = true;
      const err = new Error(`codex app-server exited (code=${code}). Stderr tail:\n${this.stderrBuf.trim().slice(-1000)}`);
      for (const p of this.pending.values()) {
        clearTimeout(p.timer);
        p.reject(err);
      }
      this.pending.clear();
      this.closeHandler(code);
    });

    this.proc.on('error', (err) => {
      if (this.closed) return;
      this.closed = true;
      log.warn(`[codex-rpc] spawn error: ${err.message}`);
      for (const p of this.pending.values()) {
        clearTimeout(p.timer);
        p.reject(err);
      }
      this.pending.clear();
      this.closeHandler(null);
    });
  }

  onNotification(handler: (n: RpcNotification) => void): void { this.notificationHandler = handler; }
  onClose(handler: (code: number | null) => void): void { this.closeHandler = handler; }

  private onLine(line: string): void {
    if (!line.trim()) return;
    let msg: any;
    try { msg = JSON.parse(line); } catch {
      log.warn(`[codex-rpc] malformed JSON from server: ${line.slice(0, 200)}`);
      return;
    }

    // Server-initiated REQUEST (has both id AND method) — must reply or the
    // server hangs forever. Approval requests get auto-accepted to match our
    // bypass-permissions posture; anything else gets a method-not-found
    // error reply so we never silently stall.
    if (typeof msg.id === 'number' && typeof msg.method === 'string') {
      this.handleServerRequest(msg);
      return;
    }

    // RESPONSE to a request we sent.
    if (typeof msg.id === 'number') {
      const pending = this.pending.get(msg.id);
      if (!pending) return;
      this.pending.delete(msg.id);
      clearTimeout(pending.timer);
      if (msg.error) pending.reject(new Error(msg.error.message || 'RPC error'));
      else pending.resolve(msg.result);
      return;
    }

    // NOTIFICATION (no id).
    if (typeof msg.method === 'string') {
      this.notificationHandler({ method: msg.method, params: msg.params });
    }
  }

  private handleServerRequest(msg: { id: number; method: string; params?: any }): void {
    // Responses are OBJECTS, not bare strings: CommandExecution/FileChange approval
    // responses are `{ decision }` (CommandExecutionApprovalDecision / FileChangeApprovalDecision),
    // and the legacy v1 aliases take `{ decision }` with the ReviewDecision enum.
    // (None of these fire under approvalPolicy:'never' + danger-full-access, but reply
    // correctly so an edge-case request can't stall the turn with a malformed reply.)
    switch (msg.method) {
      case 'item/commandExecution/requestApproval':
      case 'item/fileChange/requestApproval':
        log.info(`[codex-rpc] auto-accepting ${msg.method}`);
        this.respond(msg.id, { decision: 'acceptForSession' });
        return;
      case 'execCommandApproval':
      case 'applyPatchApproval':
        log.info(`[codex-rpc] auto-accepting (legacy) ${msg.method}`);
        this.respond(msg.id, { decision: 'approved_for_session' });
        return;
      // account/chatgptAuthTokens/refresh is only used by client-managed-token
      // clients; Bloby authenticates via chatgpt OAuth and the app-server refreshes
      // ~/.codex/auth.json itself, so this server-request never fires for us. Decline
      // cleanly (a stale-credential edge would surface as a normal turn error instead).
      default:
        log.warn(`[codex-rpc] unhandled server request ${msg.method} — replying -32601`);
        this.respondError(msg.id, -32601, `Method ${msg.method} not implemented by Bloby client`);
    }
  }

  private respond(id: number, result: any): void {
    if (this.closed || !this.proc) return;
    try { this.proc.stdin.write(JSON.stringify({ id, result }) + '\n'); }
    catch (err: any) { log.warn(`[codex-rpc] respond failed: ${err.message}`); }
  }

  private respondError(id: number, code: number, message: string): void {
    if (this.closed || !this.proc) return;
    try { this.proc.stdin.write(JSON.stringify({ id, error: { code, message } }) + '\n'); }
    catch (err: any) { log.warn(`[codex-rpc] respondError failed: ${err.message}`); }
  }

  request<T = any>(method: string, params?: any, timeoutMs = REQUEST_TIMEOUT_MS): Promise<T> {
    if (this.closed || !this.proc) return Promise.reject(new Error('RPC connection closed'));
    const id = this.nextId++;
    return new Promise<T>((resolve, reject) => {
      const timer = setTimeout(() => {
        this.pending.delete(id);
        reject(new Error(`codex app-server: ${method} timed out after ${timeoutMs}ms`));
      }, timeoutMs);
      this.pending.set(id, { resolve, reject, timer });
      try {
        this.proc!.stdin.write(JSON.stringify({ method, id, params }) + '\n');
      } catch (err: any) {
        this.pending.delete(id);
        clearTimeout(timer);
        reject(err);
      }
    });
  }

  notify(method: string, params?: any): void {
    if (this.closed || !this.proc) return;
    try {
      this.proc.stdin.write(JSON.stringify({ method, params }) + '\n');
    } catch (err: any) {
      log.warn(`[codex-rpc] notify ${method} failed: ${err.message}`);
    }
  }

  close(): void {
    if (this.closed) return;
    this.closed = true;
    for (const p of this.pending.values()) {
      clearTimeout(p.timer);
      p.reject(new Error('RPC connection closed'));
    }
    this.pending.clear();
    const proc = this.proc;
    try { proc?.stdin.end(); } catch {}
    try { proc?.kill('SIGTERM'); } catch {}
    // Escalate to SIGKILL if the app-server ignores SIGTERM (no true leak today
    // since SIGTERM reaps it, but a SIGTERM-ignoring build would otherwise survive).
    if (proc) {
      const t = setTimeout(() => { try { proc.kill('SIGKILL'); } catch {} }, 2000);
      if (typeof t.unref === 'function') t.unref();
      proc.once('exit', () => clearTimeout(t));
    }
    this.proc = null;
  }
}

/* ── Per-conversation state ────────────────────────────────────────────── */

interface CodexConversation {
  id: string;
  rpc: CodexRpc;
  threadId: string;
  effort?: string;
  onMessage: OnAgentMessage;
  /** Currently in-flight turn id (set on `turn/started`, cleared on `turn/completed`). */
  currentTurnId: string | null;
  /** itemId of the agentMessage currently streaming — used to insert a paragraph
   *  break when a turn emits multiple separate agentMessage items. */
  currentMsgItemId: string | null;
  /** Streaming text accumulator for the current turn's agentMessage items. */
  fullText: string;
  /** Tools/items used during the current turn, for the bot:turn-complete payload. */
  usedFileTools: boolean;
  /**
   * Queue of messages submitted via `pushMessage` that arrived while no turn
   * was active *and* we hadn't yet returned from the previous turn — almost
   * always empty, but covers a tight push-during-completed race.
   */
  pendingInputs: string[];
  /** True once the harness has emitted the per-turn completion event. */
  busy: boolean;
  /** True for one-shot queries — the conversation ends after the first turn completes. */
  oneShot: boolean;
  /**
   * Latest context occupancy from `thread/tokenUsage/updated` (codex does NOT
   * report usage on `turn/completed` — Turn has no usage field). Emitted on
   * `bot:turn-complete` so the orchestrator's proactive recycling can fire.
   */
  lastContextTokens: number;
  lastContextWindow: number;
  /** Active per-turn watchdog timer (see TURN_WATCHDOG_MS). */
  turnWatchdog: NodeJS.Timeout | null;
}

const conversations = new Map<string, CodexConversation>();

/* ── Helpers ───────────────────────────────────────────────────────────── */

function buildUserInput(text: string, savedFiles?: SavedFile[]): Array<Record<string, any>> {
  const input: Array<Record<string, any>> = [];

  let promptText = text || '(attached files)';
  if (savedFiles?.length) {
    const lines = savedFiles.map((f) => `- ${f.name} -> ${f.relPath}`);
    promptText += `\n\n[Attached files saved to disk]\n${lines.join('\n')}\nYou can read or reference these files using the paths above (relative to your cwd).`;
  }
  input.push({ type: 'text', text: promptText });

  // Codex understands `localImage` (path on disk) — Bloby's file-saver already
  // wrote attachments to disk, so we just point at the absolute path.
  if (savedFiles?.length) {
    for (const f of savedFiles) {
      if (f.type === 'image') input.push({ type: 'localImage', path: f.absPath });
    }
  }

  return input;
}

function clearTurnWatchdog(conv: CodexConversation): void {
  if (conv.turnWatchdog) {
    clearTimeout(conv.turnWatchdog);
    conv.turnWatchdog = null;
  }
}

/**
 * Arm the per-turn watchdog. On fire, unstick the conversation the same way a
 * real `turn/completed` would (so the dashboard, `anyConversationBusy`, and the
 * channel slot all release), then tear the conversation down — the next message
 * cold-starts a fresh thread.
 */
function armTurnWatchdog(conv: CodexConversation): void {
  clearTurnWatchdog(conv);
  conv.turnWatchdog = setTimeout(() => {
    conv.turnWatchdog = null;
    log.warn(`[codex] turn watchdog fired (${TURN_WATCHDOG_MS}ms) — conv=${conv.id}; unsticking + tearing down`);
    conv.busy = false;
    conv.currentTurnId = null;
    conv.onMessage('bot:error', { conversationId: conv.id, error: 'Codex turn timed out — no response from app-server.' });
    if (conv.oneShot) {
      conv.onMessage('bot:done', { conversationId: conv.id, usedFileTools: conv.usedFileTools });
    } else {
      conv.onMessage('bot:turn-complete', {
        conversationId: conv.id,
        usedFileTools: conv.usedFileTools,
        contextTokens: conv.lastContextTokens || 0,
        contextWindow: conv.lastContextWindow || 0,
        idle: true,
      });
    }
    teardownConversation(conv.id);
  }, TURN_WATCHDOG_MS);
}

async function startTurn(conv: CodexConversation, content: string, savedFiles?: SavedFile[]): Promise<void> {
  const input = buildUserInput(content, savedFiles);
  conv.busy = true;
  conv.fullText = '';
  conv.usedFileTools = false;
  conv.onMessage('bot:typing', { conversationId: conv.id });
  armTurnWatchdog(conv);
  try {
    const params: Record<string, any> = { threadId: conv.threadId, input };
    if (conv.effort) params.effort = conv.effort;
    // turn/start resolves immediately with { turn }; seize the id now so a
    // pushMessage arriving before the turn/started notification can steer
    // instead of starting a second turn.
    const res = await conv.rpc.request<{ turn?: { id?: string } }>('turn/start', params);
    if (res?.turn?.id) conv.currentTurnId = res.turn.id;
  } catch (err: any) {
    clearTurnWatchdog(conv);
    conv.busy = false;
    conv.currentTurnId = null;
    conv.onMessage('bot:error', { conversationId: conv.id, error: `turn/start failed: ${err.message}` });
    // turn/start produced no turn, so no turn/completed will arrive to clear the supervisor's
    // agentQueryActive (set on bot:typing above). Left as-is, that wedges true forever:
    // backend auto-heal is deferred indefinitely and chat is stuck showing "typing". Tear the
    // conversation down so bot:conversation-ended fires (which, unlike bot:turn-complete, does
    // NOT trigger a backend restart) — the next user message cold-starts a fresh thread.
    teardownConversation(conv.id);
  }
}

async function steerOrQueue(conv: CodexConversation, content: string, savedFiles?: SavedFile[]): Promise<void> {
  if (!conv.currentTurnId) {
    // No active turn — start a fresh one.
    await startTurn(conv, content, savedFiles);
    return;
  }
  // Active turn — inject mid-flight.
  const input = buildUserInput(content, savedFiles);
  try {
    const res = await conv.rpc.request<{ turnId?: string }>('turn/steer', {
      threadId: conv.threadId,
      expectedTurnId: conv.currentTurnId,
      input,
    });
    if (res?.turnId) conv.currentTurnId = res.turnId;
    conv.onMessage('bot:typing', { conversationId: conv.id });
  } catch (err: any) {
    // expectedTurnId mismatch most likely means the turn just finished —
    // retry as a fresh turn.
    log.warn(`[codex] turn/steer failed (${err.message}); falling back to turn/start`);
    if (!conv.currentTurnId) await startTurn(conv, content, savedFiles);
    else conv.pendingInputs.push(content);
  }
}

function handleNotification(conv: CodexConversation, n: { method: string; params?: any }): void {
  const p = n.params || {};
  // Any notification for this conv proves the app-server is alive and working —
  // reset the idle watchdog so a long-but-active turn isn't torn down.
  if (conv.turnWatchdog) armTurnWatchdog(conv);
  switch (n.method) {
    case 'turn/started': {
      conv.currentTurnId = p.turn?.id || null;
      conv.fullText = '';
      conv.currentMsgItemId = null;
      conv.usedFileTools = false;
      break;
    }

    case 'item/agentMessage/delta': {
      const delta: string = p.delta || '';
      if (!delta) break;
      // A turn can emit multiple agentMessage items (commentary then final_answer).
      // On a new itemId, insert a paragraph break so they don't run together (mirrors claude.ts).
      if (p.itemId && conv.currentMsgItemId && p.itemId !== conv.currentMsgItemId && conv.fullText && !conv.fullText.endsWith('\n')) {
        conv.fullText += '\n\n';
        conv.onMessage('bot:token', { conversationId: conv.id, token: '\n\n' });
      }
      if (p.itemId) conv.currentMsgItemId = p.itemId;
      conv.fullText += delta;
      conv.onMessage('bot:token', { conversationId: conv.id, token: delta });
      break;
    }

    case 'item/started': {
      const item = p.item || {};
      // Surface tool-like items so the dashboard can show activity.
      switch (item.type) {
        case 'commandExecution':
          conv.onMessage('bot:tool', {
            conversationId: conv.id,
            name: 'shell',
            input: { command: item.command || item.commandLine || '' },
          });
          break;
        case 'mcpToolCall':
          // ThreadItem.mcpToolCall fields are `server` + `tool` (no toolName/name/input).
          conv.onMessage('bot:tool', {
            conversationId: conv.id,
            name: item.tool ? (item.server ? `${item.server}/${item.tool}` : item.tool) : 'mcp_tool',
            input: item.arguments || {},
          });
          break;
        case 'fileChange':
          conv.usedFileTools = true;
          conv.onMessage('bot:tool', {
            conversationId: conv.id,
            name: 'file_change',
            input: { changes: (item.changes || []).map((c: any) => c.path).filter(Boolean) },
          });
          break;
        case 'webSearch':
          conv.onMessage('bot:tool', {
            conversationId: conv.id,
            name: 'web_search',
            input: { query: item.query || '' },
          });
          break;
        case 'collabAgentToolCall':
          // Codex's collaborating sub-agents (rarely enabled) → Bloby's sub-agent UX.
          if (item.tool === 'spawnAgent') {
            conv.onMessage('bot:task-created', {
              conversationId: conv.id,
              taskId: item.id,
              description: item.prompt || 'sub-agent',
              type: 'collab',
            });
          }
          break;
        // userMessage / agentMessage / reasoning — no tool-style event.
      }
      break;
    }

    case 'item/completed': {
      const item = p.item || {};
      if (item.type === 'fileChange') conv.usedFileTools = true;
      // If a final agentMessage arrives without preceding deltas (rare), grab it now.
      if (item.type === 'agentMessage' && !conv.fullText) {
        const text = (item.content || []).map((c: any) => c.text || '').join('') || item.text || '';
        if (text) {
          conv.fullText = text;
          conv.onMessage('bot:token', { conversationId: conv.id, token: text });
        }
      }
      if (item.type === 'collabAgentToolCall' && item.tool === 'spawnAgent') {
        conv.onMessage('bot:task-done', {
          conversationId: conv.id,
          taskId: item.id,
          status: item.status,
          summary: item.prompt || '',
        });
      }
      break;
    }

    case 'thread/tokenUsage/updated': {
      // Codex's only token-usage signal. ThreadTokenUsage = { total, last, modelContextWindow };
      // `last` is the current prompt occupancy (mirrors Claude's input+cacheRead+cacheCreation),
      // the right basis for the recycle compare in supervisor/index.ts (fraction*window, not lifetime).
      const tu = p.tokenUsage || {};
      const last = tu.last || {};
      conv.lastContextTokens = (last.inputTokens || 0) + (last.cachedInputTokens || 0);
      if (typeof tu.modelContextWindow === 'number' && tu.modelContextWindow > 0) {
        conv.lastContextWindow = tu.modelContextWindow;
      }
      break;
    }

    case 'turn/completed': {
      const status: string = p.turn?.status || 'completed';
      const turnError = p.turn?.error;

      clearTurnWatchdog(conv);
      conv.currentTurnId = null;
      conv.busy = false;

      if (status === 'failed') {
        conv.onMessage('bot:error', {
          conversationId: conv.id,
          error: turnError?.message || 'Codex turn failed.',
        });
      } else if (status === 'interrupted') {
        // Interrupted turns carry no final answer — stay silent.
      } else if (conv.fullText) {
        conv.onMessage('bot:response', { conversationId: conv.id, content: conv.fullText });
      }

      if (conv.oneShot) {
        conv.onMessage('bot:done', { conversationId: conv.id, usedFileTools: conv.usedFileTools });
        teardownConversation(conv.id);
      } else {
        // Context-size signal for the orchestrator's proactive session recycling,
        // sourced from the cached `thread/tokenUsage/updated` values above. 0 if codex
        // never sent one this turn → falls back to codex's own in-thread auto-compaction.
        const idle = conv.pendingInputs.length === 0;
        conv.onMessage('bot:turn-complete', {
          conversationId: conv.id,
          usedFileTools: conv.usedFileTools,
          contextTokens: conv.lastContextTokens || 0,
          contextWindow: conv.lastContextWindow || 0,
          idle,
        });

        // Drain any messages that were submitted while we were busy.
        const next = conv.pendingInputs.shift();
        if (next !== undefined) void startTurn(conv, next);
      }
      break;
    }

    case 'error': {
      // ErrorNotification carries willRetry — codex will retry transient errors
      // itself; don't surface those as a hard bot:error before the retry lands.
      if (p.willRetry) {
        log.info(`[codex] transient error (will retry): ${p.error?.message || 'unknown'}`);
        break;
      }
      const errMsg = p.error?.message || 'Codex error notification';
      conv.onMessage('bot:error', { conversationId: conv.id, error: errMsg });
      break;
    }

    case 'mcpServer/startupStatus/updated': {
      // Surface MCP servers (from MCP.json → -c overrides) that fail to start,
      // so a misconfigured server is visible instead of silently absent.
      if (p.status === 'failed' || p.status === 'cancelled') {
        log.warn(`[codex] MCP server "${p.name}" ${p.status}${p.error ? `: ${p.error}` : ''}`);
      }
      break;
    }

    // thread/started, thread/status/changed, remoteControl/status/changed —
    // informational, no-op for the dashboard.
  }
}

function teardownConversation(conversationId: string): void {
  const conv = conversations.get(conversationId);
  if (!conv) return;
  clearTurnWatchdog(conv);
  conversations.delete(conversationId);
  try { conv.rpc.close(); } catch {}
  conv.onMessage('bot:conversation-ended', { conversationId });
}

async function spawnAndInitialize(
  conversationId: string,
  model: string,
  onMessage: OnAgentMessage,
  instructions: string,
  oneShot: boolean,
): Promise<CodexConversation | null> {
  // Pre-flight: confirm we have valid OAuth tokens before spending time spawning.
  const token = await getCodexAccessToken();
  if (!token) {
    onMessage('bot:error', {
      conversationId,
      error: 'Codex credentials not found or expired. Re-authenticate from the dashboard.',
    });
    return null;
  }

  const { id: modelId, effort } = parseModelString(model);
  const rpc = new CodexRpc();
  rpc.start(buildMcpConfigArgs());

  const conv: CodexConversation = {
    id: conversationId,
    rpc,
    threadId: '',
    effort,
    onMessage,
    currentTurnId: null,
    currentMsgItemId: null,
    fullText: '',
    usedFileTools: false,
    pendingInputs: [],
    busy: false,
    oneShot,
    lastContextTokens: 0,
    lastContextWindow: 0,
    turnWatchdog: null,
  };

  rpc.onNotification((n) => handleNotification(conv, n));
  rpc.onClose(() => {
    if (conversations.get(conversationId) === conv) {
      conversations.delete(conversationId);
      onMessage('bot:conversation-ended', { conversationId });
    }
  });

  try {
    log.info(`[codex] init conversation ${conversationId} (model=${modelId}${effort ? `, effort=${effort}` : ''})`);
    await rpc.request('initialize', { clientInfo: CLIENT_INFO });
    rpc.notify('initialized', {});
    // Context auto-compaction is ON by default in the codex app-server: when the
    // thread's token count crosses the model's threshold it compacts history in
    // place (emitting a `contextCompaction` item) and continues — no flag needed
    // here. A manual trigger also exists (`thread/compact/start`) if we ever want
    // to force it from the UI.
    const startResult = await rpc.request<{ thread: { id: string } }>('thread/start', {
      cwd: WORKSPACE_DIR,
      model: modelId,
      // Bloby's persona/workflow prompt rides developerInstructions (ADDITIVE),
      // NOT baseInstructions. baseInstructions fully OVERRIDES codex's native base
      // prompt — which carries the apply_patch FREEFORM spec + shell protocol the
      // model needs to edit files. Leaving baseInstructions unset keeps that native
      // scaffolding; developerInstructions layers Bloby's persona on top of it.
      developerInstructions: instructions,
      personality: 'pragmatic',
      // Bloby's posture matches Claude's bypassPermissions — the bot is
      // running on the user's own machine with their full consent. Skip the
      // approval prompts and give it write access to the workspace + beyond.
      approvalPolicy: 'never',
      sandbox: 'danger-full-access',
    });
    conv.threadId = startResult.thread.id;
    conversations.set(conversationId, conv);
    log.ok(`[codex] thread started ${conv.threadId}`);

    // Prime codex's per-thread skill cache with the workspace skills
    // directory. Without this, codex only sees its system-scope skills and
    // never discovers anything Bloby ships in `workspace/skills/*`. Fire and
    // forget — failure here just means workspace skills won't be auto-routable
    // for this thread, but the agent can still read SKILL.md files directly.
    primeWorkspaceSkills(rpc);

    return conv;
  } catch (err: any) {
    rpc.close();
    onMessage('bot:error', { conversationId, error: `Failed to initialize Codex: ${err.message}` });
    return null;
  }
}

const SKILLS_DIR = path.join(WORKSPACE_DIR, 'skills');
// Codex discovers "repo"-scope skills under `<cwd>/.codex/skills` (verified
// against 0.135.0 — a bare `<cwd>/skills` is NOT scanned, and `skills/list`
// has no extra-root param). Bloby keeps the canonical skills in
// `workspace/skills/<name>`, so we mirror each one into `.codex/skills/<name>`
// as a symlink — single source of truth, discoverable by codex's native router.
// (Each SKILL.md needs YAML frontmatter or codex rejects it — see SKILL_FORMAT_MIGRATION.md.)
const CODEX_SKILLS_ROOT = path.join(WORKSPACE_DIR, '.codex', 'skills');

/** Mirror workspace/skills/<name> → workspace/.codex/skills/<name> as symlinks (idempotent). */
function syncCodexSkillRoot(): void {
  let names: string[] = [];
  try {
    names = fs.readdirSync(SKILLS_DIR, { withFileTypes: true })
      .filter((e) => e.isDirectory() || e.isSymbolicLink())
      .map((e) => e.name);
  } catch {
    return; // no skills dir — nothing to mirror
  }
  try { fs.mkdirSync(CODEX_SKILLS_ROOT, { recursive: true }); } catch {}
  for (const name of names) {
    const target = path.join(SKILLS_DIR, name);
    const link = path.join(CODEX_SKILLS_ROOT, name);
    try {
      const cur = fs.existsSync(link) ? fs.realpathSync(link) : null;
      if (cur === fs.realpathSync(target)) continue; // already correct
      try { fs.rmSync(link, { recursive: true, force: true }); } catch {}
      fs.symlinkSync(target, link, 'dir');
    } catch (err: any) {
      log.warn(`[codex] could not mirror skill "${name}" into .codex/skills: ${err.message}`);
    }
  }
}

function primeWorkspaceSkills(rpc: CodexRpc): void {
  syncCodexSkillRoot();
  rpc.request('skills/list', {
    cwds: [WORKSPACE_DIR],
    forceReload: true,
  }).then((result: any) => {
    const entry = result?.data?.[0];
    const all = entry?.skills ?? [];
    const repo = all.filter((s: any) => s.scope === 'repo');
    const errors = entry?.errors ?? [];
    log.ok(`[codex] skills primed: ${repo.length} workspace (repo), ${all.length - repo.length} user/system${errors.length ? `, ${errors.length} rejected` : ''}`);
    for (const err of errors) log.warn(`[codex] skill load error: ${err.path} — ${err.message}`);
  }).catch((err: any) => {
    log.warn(`[codex] skills/list failed: ${err.message}`);
  });
}

/* ── MCP wiring ────────────────────────────────────────────────────────── */

const MCP_CONFIG_FILE = path.join(WORKSPACE_DIR, 'MCP.json');

/**
 * Load MCP servers from workspace/MCP.json (the same file the Claude harness
 * reads). Accepts the canonical unwrapped map `{ name: { command, args, env } }`,
 * a `{ mcpServers: {...} }` wrapper, or a legacy array of single-key maps.
 * Returns {} when absent/invalid — so this is a no-op until the user populates MCP.json.
 */
function loadMcpServersForCodex(): Record<string, any> {
  try {
    const raw = JSON.parse(fs.readFileSync(MCP_CONFIG_FILE, 'utf-8'));
    let map: any = raw;
    if (raw && typeof raw === 'object' && raw.mcpServers && typeof raw.mcpServers === 'object') map = raw.mcpServers;
    else if (Array.isArray(raw)) map = Object.assign({}, ...raw);
    if (map && typeof map === 'object' && !Array.isArray(map)) return map;
  } catch {}
  return {};
}

/** Serialize a JS value as a TOML literal for a `-c key=value` override. */
function toToml(v: any): string {
  if (Array.isArray(v)) return `[${v.map(toToml).join(',')}]`;
  if (v && typeof v === 'object') return `{${Object.entries(v).map(([k, val]) => `${JSON.stringify(k)}=${toToml(val)}`).join(',')}}`;
  if (typeof v === 'number' || typeof v === 'boolean') return String(v);
  return JSON.stringify(String(v)); // TOML basic string — JSON escaping is compatible
}

/**
 * Translate MCP.json into `codex app-server -c mcp_servers.<name>.<field>=<toml>`
 * spawn flags. Codex sources MCP from its own config layer rather than a per-query
 * param (verified against 0.135.0: a `-c mcp_servers.X.command=...` override shows
 * up in both mcpServerStatus/list and config/read). Only the stdio fields Bloby
 * uses (command/args/env) are translated; names must be TOML-bare-key safe.
 */
function buildMcpConfigArgs(): string[] {
  const servers = loadMcpServersForCodex();
  const args: string[] = [];
  let wired = 0;
  for (const [name, cfg] of Object.entries(servers)) {
    if (!/^[A-Za-z0-9_-]+$/.test(name)) { log.warn(`[codex] skipping MCP server "${name}" — name not TOML-bare-key safe`); continue; }
    const c: any = cfg || {};
    if (!c.command) { log.warn(`[codex] skipping MCP server "${name}" — no command`); continue; }
    args.push('-c', `mcp_servers.${name}.command=${toToml(c.command)}`);
    if (Array.isArray(c.args) && c.args.length) args.push('-c', `mcp_servers.${name}.args=${toToml(c.args)}`);
    if (c.env && typeof c.env === 'object' && Object.keys(c.env).length) args.push('-c', `mcp_servers.${name}.env=${toToml(c.env)}`);
    wired++;
  }
  if (wired) log.info(`[codex] wiring ${wired} MCP server(s) from MCP.json via -c overrides`);
  return args;
}

/* ── Harness implementation ────────────────────────────────────────────── */

export function hasConversation(conversationId: string): boolean {
  return conversations.has(conversationId);
}

export function isConversationBusy(conversationId: string): boolean {
  return conversations.get(conversationId)?.busy ?? false;
}

/** True if ANY live conversation in this harness is mid-turn. Used by the supervisor to defer
 *  backend restarts during channel/Alexa turns (which don't set the dashboard's agentQueryActive). */
/** Codex one-shot queries (startBlobyAgentQuery) reuse the conversations map and set conv.busy via
 *  startTurn, so anyConversationBusy() already covers them — nothing extra to track here. */
export function anyOneShotActive(): boolean {
  return false;
}

export function anyConversationBusy(): boolean {
  for (const c of conversations.values()) if (c.busy) return true;
  return false;
}

export async function startConversation(
  conversationId: string,
  model: string,
  onMessage: OnAgentMessage,
  names?: { botName: string; humanName: string },
  recentMessages?: RecentMessage[],
): Promise<boolean> {
  if (conversations.has(conversationId)) endConversation(conversationId);
  const baseInstructions = await assembleBaseInstructions(names, recentMessages);
  const conv = await spawnAndInitialize(conversationId, model, onMessage, baseInstructions, false);
  return !!conv;
}

export function pushMessage(
  conversationId: string,
  content: string,
  _attachments?: AgentAttachment[],
  savedFiles?: SavedFile[],
): boolean {
  const conv = conversations.get(conversationId);
  if (!conv) {
    log.warn(`[codex] pushMessage: no live conversation ${conversationId}`);
    return false;
  }
  void steerOrQueue(conv, content, savedFiles);
  return true;
}

export function endConversation(conversationId: string): void {
  const conv = conversations.get(conversationId);
  if (!conv) return;
  log.info(`[codex] ending conversation ${conversationId}`);
  if (conv.currentTurnId) {
    void conv.rpc.request('turn/interrupt', {
      threadId: conv.threadId,
      turnId: conv.currentTurnId,
    }).catch(() => {});
  }
  teardownConversation(conversationId);
}

export function endAllConversations(): void {
  for (const id of Array.from(conversations.keys())) endConversation(id);
}

export async function stopSubAgentTask(_conversationId: string, _taskId: string): Promise<void> {
  // Codex doesn't expose Claude-style sub-agent tasks. No-op for now.
}

export async function warmUpForLiveConversation(
  _model: string,
  _names?: { botName: string; humanName: string },
): Promise<void> {
  // No subprocess pre-warming yet — `codex app-server` startup is fast enough
  // (~hundreds of ms). Re-evaluate if it becomes noticeable on the Pi.
}

export async function startBlobyAgentQuery(
  conversationId: string,
  prompt: string,
  model: string,
  onMessage: OnAgentMessage,
  _attachments?: AgentAttachment[],
  savedFiles?: SavedFile[],
  names?: { botName: string; humanName: string },
  recentMessages?: RecentMessage[],
  supportPrompt?: string,
  _maxTurns?: number,
): Promise<void> {
  if (conversations.has(conversationId)) endConversation(conversationId);
  const baseInstructions = supportPrompt
    ? supportPrompt
    : await assembleBaseInstructions(names, recentMessages);
  const conv = await spawnAndInitialize(conversationId, model, onMessage, baseInstructions, true);
  if (!conv) return;
  await startTurn(conv, prompt, savedFiles);
}

export function stopBlobyAgentQuery(conversationId: string): void {
  endConversation(conversationId);
}

// ── Workspace agent endpoint (POST /api/agent/query) ──────────────────────

/**
 * One-shot Codex query that spawns its own short-lived app-server, runs a
 * single turn, returns the accumulated response, and tears down. Mirrors
 * what `agent-api.ts` previously did directly with the Claude SDK.
 *
 * `sessionId` carries a Codex `threadId` — when present we issue a
 * `thread/resume` instead of `thread/start` to preserve server-side context.
 */
export async function runAgentQuery(req: AgentQueryRequest): Promise<AgentQueryResult> {
  const token = await getCodexAccessToken();
  if (!token) {
    return { ok: false, error: 'Codex credentials not found or expired. Re-authenticate via the dashboard.' };
  }

  // Pull the active model + parse effort suffix from the user's config —
  // agent-api callers don't get to pick.
  let model = 'gpt-5.5';
  let effort: string | undefined;
  try {
    const { loadConfig: loadCfg } = await import('../../shared/config.js');
    const cfg = loadCfg();
    if (cfg.ai?.model) {
      const parsed = parseModelString(cfg.ai.model);
      model = parsed.id;
      effort = parsed.effort;
    }
  } catch {}

  const timeout = Math.min(Math.max(req.timeout || 120_000, 5_000), 300_000);

  const rpc = new CodexRpc();
  rpc.start(buildMcpConfigArgs());

  let fullText = '';
  const usedTools = new Set<string>();
  let usedFileTools = false;
  let resolvedThreadId = req.sessionId || '';
  let resolveTurn: (() => void) | null = null;
  let turnError: string | null = null;
  const turnDone = new Promise<void>((r) => { resolveTurn = r; });

  rpc.onNotification((n) => {
    const p = n.params || {};
    switch (n.method) {
      case 'item/agentMessage/delta': {
        if (typeof p.delta === 'string') fullText += p.delta;
        break;
      }
      case 'item/started': {
        const item = p.item || {};
        if (item.type === 'commandExecution') usedTools.add('shell');
        else if (item.type === 'mcpToolCall') usedTools.add(item.tool || 'mcp_tool');
        else if (item.type === 'fileChange') { usedTools.add('file_change'); usedFileTools = true; }
        else if (item.type === 'webSearch') usedTools.add('web_search');
        break;
      }
      case 'item/completed': {
        const item = p.item || {};
        if (item.type === 'fileChange') usedFileTools = true;
        if (item.type === 'agentMessage' && !fullText) {
          const text = (item.content || []).map((c: any) => c.text || '').join('') || item.text || '';
          if (text) fullText = text;
        }
        break;
      }
      case 'turn/completed': {
        const status = p.turn?.status || 'completed';
        if (status === 'failed') {
          turnError = p.turn?.error?.message || 'Codex turn failed.';
        }
        resolveTurn?.();
        break;
      }
      case 'error': {
        if (p.willRetry) break; // transient — codex retries itself
        turnError = p.error?.message || 'Codex error';
        resolveTurn?.();
        break;
      }
    }
  });

  const timeoutHandle = setTimeout(() => {
    if (!turnError) turnError = `Query timed out after ${timeout}ms.`;
    resolveTurn?.();
  }, timeout);

  try {
    log.info(`[codex/agent-api] Query: msg="${req.message.slice(0, 80)}..." model=${model} resume=${req.sessionId || 'none'}`);
    await rpc.request('initialize', { clientInfo: CLIENT_INFO });
    rpc.notify('initialized', {});

    if (req.sessionId) {
      // Resume an existing thread (if codex still has it). Caller must accept
      // failure here — we fall back to a fresh thread.
      try {
        const r = await rpc.request<{ thread: { id: string } }>('thread/resume', { threadId: req.sessionId });
        resolvedThreadId = r.thread.id;
      } catch (err: any) {
        log.warn(`[codex/agent-api] thread/resume failed (${err.message}); starting fresh thread`);
        const r = await rpc.request<{ thread: { id: string } }>('thread/start', {
          cwd: WORKSPACE_DIR,
          model,
          ...(req.systemPrompt ? { developerInstructions: req.systemPrompt } : {}),
          approvalPolicy: 'never',
          sandbox: 'danger-full-access',
        });
        resolvedThreadId = r.thread.id;
      }
    } else {
      const r = await rpc.request<{ thread: { id: string } }>('thread/start', {
        cwd: WORKSPACE_DIR,
        model,
        ...(req.systemPrompt ? { developerInstructions: req.systemPrompt } : {}),
        approvalPolicy: 'never',
        sandbox: 'danger-full-access',
      });
      resolvedThreadId = r.thread.id;
    }

    // Same priming as live conversations — workspace skills won't be visible
    // to codex's router otherwise.
    primeWorkspaceSkills(rpc);

    const turnParams: Record<string, any> = {
      threadId: resolvedThreadId,
      input: [{ type: 'text', text: req.message }],
    };
    if (effort) turnParams.effort = effort;
    await rpc.request('turn/start', turnParams);

    await turnDone;

    if (turnError) {
      return { ok: false, error: turnError, sessionId: resolvedThreadId, toolsUsed: Array.from(usedTools) };
    }

    log.info(`[codex/agent-api] Done: ${fullText.length} chars, tools=[${Array.from(usedTools).join(',')}], thread=${resolvedThreadId}`);
    return { ok: true, response: fullText, sessionId: resolvedThreadId, toolsUsed: Array.from(usedTools), usedFileTools };
  } catch (err: any) {
    return { ok: false, error: err?.message || String(err), sessionId: resolvedThreadId };
  } finally {
    clearTimeout(timeoutHandle);
    rpc.close();
  }
}
