/**
 * Runs a single orchestrator agent by spawning the `pi` CLI in RPC mode and
 * translating its JSONL event stream into a slim, persisted + streamable log.
 *
 * Responsibilities:
 *   - spawn `pi --mode rpc` in the run's worktree (or the repo root)
 *   - send the initial prompt, plus later steer / follow-up / abort commands
 *   - parse stdout JSONL (commands-responses vs. agent events)
 *   - emit a compact event stream (`SlimEvent`) to subscribers AND persist it
 *   - finalize the run status (completed / failed / stopped) on exit
 *
 * The agent uses pi's built-in coding tools directly; kanban/docs mutations
 * happen via the server's own HTTP endpoints (the agent curls them), so no
 * SQLite writes cross the process boundary.
 */

import { type ChildProcess, spawn } from 'node:child_process';
import type { RunStatus } from '../types.js';
import { appendRunEvent } from './events.js';
import { PI_BIN, PI_MODEL, PI_TOOLS } from './config.js';

/** Compact, UI-friendly event derived from pi's richer event stream. */
export type SlimEvent =
  | { type: 'status'; status: RunStatus }
  | { type: 'text'; text: string }
  | { type: 'tool_start'; tool: string; preview: string }
  | { type: 'tool_end'; tool: string; ok: boolean; preview: string }
  | { type: 'log'; level: 'info' | 'warn' | 'error'; text: string }
  | { type: 'done'; summary: string }
  | { type: 'error'; message: string };

/** A slim event after it has been persisted: it now carries its sequence
 * number and timestamp so SSE consumers can dedup and order reliably. */
export type PersistedEvent = SlimEvent & { seq: number; createdAt: string };

type Listener = (event: PersistedEvent) => void;

export interface RunnerOptions {
  runId: string;
  /** Working directory for the agent (a worktree path, or the repo root). */
  cwd: string;
  /** Initial prompt. */
  prompt: string;
  /**
   * If set, resume (append to) this pi session JSONL file instead of starting a
   * fresh conversation. Used by refinement runs to continue a prior run's chat.
   */
  resumeSession?: string;
  /** Called once when the run settles (status is final). */
  onSettled: (status: RunStatus, summary: string | null) => void;
  /**
   * Called with the persisted session JSONL path once pi reports it (fresh runs
   * only — resumed runs already know their session file). Lets the manager
   * record it so later refinements can resume this run's chat.
   */
  onSessionResolved?: (sessionFile: string) => void;
}

export class Runner {
  readonly runId: string;
  private readonly cwd: string;
  private readonly prompt: string;
  private readonly resumeSession: string | undefined;
  private readonly onSettled: (status: RunStatus, summary: string | null) => void;
  private readonly onSessionResolved: ((sessionFile: string) => void) | undefined;

  private proc: ChildProcess | null = null;
  private listeners = new Set<Listener>();
  private buffer = ''; // stdin line buffer
  private textBuffer = ''; // accumulating assistant text for the current message
  private lastAssistantText = '';
  private stopping = false;
  private settled = false;
  /** Pending steer/follow-up messages pi hasn't processed yet. The run only
   * settles when the agent finishes a turn with an empty queue. */
  private pending = 0;
  /** Watchdog that force-settles a run if the agent stalls with a queued msg. */
  private watchdog: ReturnType<typeof setTimeout> | null = null;
  /** Liveness watchdog: if pi emits nothing for this long, it's hung (stalled
   * LLM call, wedged tool, provider drop) — settle as failed so the run doesn't
   * sit at 'running' forever. Reset on every line pi sends. */
  private idleTimer: ReturnType<typeof setTimeout> | null = null;
  private static readonly IDLE_TIMEOUT_MS = 10 * 60 * 1000;

  constructor(opts: RunnerOptions) {
    this.runId = opts.runId;
    this.cwd = opts.cwd;
    this.prompt = opts.prompt;
    this.resumeSession = opts.resumeSession;
    this.onSettled = opts.onSettled;
    this.onSessionResolved = opts.onSessionResolved;
  }

  /** Subscribe to live slim events. Returns an unsubscribe function. */
  subscribe(fn: Listener): () => void {
    this.listeners.add(fn);
    return () => this.listeners.delete(fn);
  }
  /** Begin the run: spawn pi and send the prompt. */
  start(): void {
    const args = ['--mode', 'rpc', '--approve', '-n', `kanban:${this.runId.slice(0, 8)}`, '--tools', PI_TOOLS];
    // Refinement runs resume (append to) a prior run's session file; fresh runs
    // persist a new session (no --no-session) so they can be refined later.
    if (this.resumeSession) args.push('--session', this.resumeSession);
    if (PI_MODEL) args.push('--model', PI_MODEL);

    this.proc = spawn(PI_BIN, args, {
      cwd: this.cwd,
      stdio: ['pipe', 'pipe', 'pipe'],
      env: {
        ...process.env,
        // Keep the subprocess quiet and deterministic.
        PI_SKIP_VERSION_CHECK: '1',
        NO_COLOR: '1',
        CLICOLOR: '0',
      },
    });

    this.proc.stdout?.setEncoding('utf8');
    this.proc.stderr?.setEncoding('utf8');

    this.proc.stdout?.on('data', (chunk: string) => this.onStdout(chunk));
    this.proc.stderr?.on('data', (chunk: string) => this.onStderr(chunk));
    this.proc.on('error', (err) => this.fail(`failed to spawn pi: ${err.message}`));
    this.proc.on('exit', (code, signal) => this.onExit(code, signal));

    // Capture the persisted session file path up front (fresh runs) so the
    // manager can record it for later refinement. Resumed runs already know
    // their session. Sent before the prompt so it resolves immediately.
    if (!this.resumeSession && this.onSessionResolved) {
      this.send({ type: 'get_state', id: 'init-state' });
    }
    this.send({ type: 'prompt', message: this.prompt });
    this.emit({ type: 'log', level: 'info', text: `Started pi (cwd ${this.cwd})${this.resumeSession ? ' [resumed session]' : ''}` });
    this.emit({ type: 'status', status: 'running' });
    this.armIdle();
  }

  /** Queue a steering or follow-up message mid-run. */
  message(text: string, mode: 'steer' | 'followUp'): void {
    const cmd =
      mode === 'followUp' ? { type: 'follow_up', message: text } : { type: 'steer', message: text };
    this.send(cmd);
    // Optimistically mark one more turn pending so an imminent agent_end for the
    // current turn doesn't settle the run before the queued message is served.
    this.pending += 1;
    this.clearWatchdog();
    this.emit({ type: 'log', level: 'info', text: `${mode === 'followUp' ? 'Follow-up' : 'Steer'} queued: ${text}` });
  }

  /** Abort the run. Status becomes 'stopped' once the process exits. */
  stop(): void {
    if (!this.proc || this.stopping) return;
    this.stopping = true;
    this.emit({ type: 'log', level: 'warn', text: 'Stopping run…' });
    try {
      this.send({ type: 'abort' });
    } catch {
      /* stdin may already be closed */
    }
    // Escalating kill backstop if pi doesn't exit promptly after abort:
    // SIGTERM first, then SIGKILL so a truly wedged process can still be reaped.
    setTimeout(() => {
      if (this.proc && !this.settled) {
        try {
          this.proc.kill('SIGTERM');
        } catch {
          /* ignore */
        }
      }
    }, 15_000);
    // SIGKILL shortly after if SIGTERM didn't do it.
    setTimeout(() => {
      if (this.proc && !this.settled) {
        try {
          this.proc.kill('SIGKILL');
        } catch {
          /* ignore */
        }
      }
    }, 20_000);
  }

  // --- stdin / stdout handling --------------------------------------------

  private send(cmd: Record<string, unknown>): void {
    this.proc?.stdin?.write(`${JSON.stringify(cmd)}\n`);
  }

  private onStdout(chunk: string): void {
    this.buffer += chunk;
    let idx: number;
    while ((idx = this.buffer.indexOf('\n')) >= 0) {
      const line = this.buffer.slice(0, idx).replace(/\r$/, '');
      this.buffer = this.buffer.slice(idx + 1);
      if (line.trim()) this.handleLine(line);
    }
  }

  private onStderr(chunk: string): void {
    const text = chunk.trim();
    if (text) this.emit({ type: 'log', level: 'warn', text: `[pi stderr] ${text.slice(0, 500)}` });
  }

  private handleLine(line: string): void {
    if (!line.startsWith('{')) return; // ignore banners / non-JSON
    let msg: Record<string, unknown>;
    try {
      msg = JSON.parse(line);
    } catch {
      return;
    }
    // Any valid JSON line from pi means it's alive — push the liveness watchdog.
    this.armIdle();
    switch (msg.type) {
      case 'response':
        // Capture the persisted session file from the init get_state call so the
        // manager can record it for later refinement runs.
        if (
          msg.id === 'init-state' &&
          msg.success === true &&
          this.onSessionResolved
        ) {
          const sf = (msg.data as { sessionFile?: string } | undefined)?.sessionFile;
          if (typeof sf === 'string' && sf) this.onSessionResolved(sf);
        }
        // Surface command failures.
        if (msg.success === false) {
          this.emit({ type: 'log', level: 'error', text: `command ${String(msg.command ?? '?')} rejected` });
        }
        break;
      case 'agent_start':
        // A new turn is starting (initial prompt or a queued steer/follow-up).
        this.clearWatchdog();
        break;
      case 'agent_end':
        this.lastAssistantText = extractAssistantText(msg.messages) || this.lastAssistantText;
        this.maybeComplete();
        break;
      case 'message_update': {
        const ev = msg.assistantMessageEvent as { type?: string; delta?: string } | undefined;
        if (ev?.type === 'text_delta' && typeof ev.delta === 'string') this.textBuffer += ev.delta;
        break;
      }
      case 'message_end': {
        const text = extractAssistantText(msg.message) || this.textBuffer.trim();
        if (text) {
          this.lastAssistantText = text;
          this.emit({ type: 'text', text });
        }
        this.textBuffer = '';
        break;
      }
      case 'tool_execution_start':
        this.emit({
          type: 'tool_start',
          tool: String(msg.toolName ?? 'tool'),
          preview: previewArgs(msg.args),
        });
        break;
      case 'tool_execution_end':
        this.emit({
          type: 'tool_end',
          tool: String(msg.toolName ?? 'tool'),
          ok: msg.isError !== true,
          preview: previewResult(msg.result),
        });
        break;
      case 'queue_update': {
        // Reconcile our pending count with pi's authoritative queue state.
        const q = msg as { steering?: unknown[]; followUp?: unknown[] };
        this.pending = (q.steering?.length ?? 0) + (q.followUp?.length ?? 0);
        break;
      }
      default:
        // Other event types (compaction, retry, …) are intentionally dropped.
        break;
    }
  }

  private onExit(code: number | null, signal: NodeJS.Signals | null): void {
    // Backstop: if pi exits before we settled (crash, abort, or unexpected
    // close), finalize now. Normal completion is driven by `maybeComplete()`.
    if (this.settled) return;
    this.clearWatchdog();
    this.clearIdle();
    const crashed = code !== null && code !== 0;
    let status: RunStatus;
    if (this.stopping) status = 'stopped';
    else if (crashed || signal) status = 'failed';
    else status = 'completed';

    this.settle(status, this.lastAssistantText || null);
  }

  /**
   * Decide whether the run is finished after an `agent_end`. RPC-mode pi does
   * not exit on its own after answering; it waits for more commands. So we treat
   * the run as complete when the agent finishes a turn with nothing queued, and
   * close pi's stdin so it exits cleanly. A queued steer/follow-up keeps the run
   * alive for another turn (guarded by a watchdog in case pi stalls).
   */
  private maybeComplete(): void {
    if (this.settled) return;
    // An abort was requested: honor it as 'stopped' rather than 'completed'
    // (pi still emits agent_end after aborting the current turn).
    if (this.stopping) {
      this.clearWatchdog();
      this.settle('stopped', this.lastAssistantText || null);
      return;
    }
    if (this.pending > 0) {
      this.armWatchdog();
      return;
    }
    this.clearWatchdog();
    this.settle('completed', this.lastAssistantText || null);
  }

  private armWatchdog(): void {
    this.clearWatchdog();
    this.watchdog = setTimeout(() => {
      if (!this.settled) {
        this.emit({ type: 'log', level: 'warn', text: 'Agent stalled with a queued message; finalizing.' });
        this.settle('completed', this.lastAssistantText || null);
      }
    }, 90_000);
  }

  private clearWatchdog(): void {
    if (this.watchdog) {
      clearTimeout(this.watchdog);
      this.watchdog = null;
    }
  }

  /** Arm (or refresh) the liveness watchdog: settle as failed if pi goes silent
   *  for IDLE_TIMEOUT_MS. Call `armIdle()` on every sign of life from pi. */
  private armIdle(): void {
    if (this.settled) return;
    this.clearIdle();
    this.idleTimer = setTimeout(() => {
      if (!this.settled) {
        this.emit({
          type: 'error',
          message: `Agent went idle for ${Math.round(Runner.IDLE_TIMEOUT_MS / 1000)}s with no output — treating as hung.`,
        });
        this.settle('failed', this.lastAssistantText || null);
      }
    }, Runner.IDLE_TIMEOUT_MS);
  }

  private clearIdle(): void {
    if (this.idleTimer) {
      clearTimeout(this.idleTimer);
      this.idleTimer = null;
    }
  }

  private fail(message: string): void {
    if (this.settled) return;
    this.emit({ type: 'error', message });
    this.settle('failed', this.lastAssistantText || null);
  }

  private settle(status: RunStatus, summary: string | null): void {
    if (this.settled) return;
    this.settled = true;
    this.clearWatchdog();
    this.clearIdle();
    this.emit({ type: 'status', status });
    if (status === 'completed') this.emit({ type: 'done', summary: summary ?? '' });
    if (status === 'failed' && summary) this.emit({ type: 'text', text: summary });
    try {
      this.proc?.stdin?.end();
    } catch {
      /* ignore */
    }
    this.onSettled(status, summary);
  }

  // --- persistence + fan-out ----------------------------------------------

  private emit(event: SlimEvent): void {
    const { type, ...data } = event;
    const { seq, createdAt } = appendRunEvent(this.runId, type, data);
    const enriched: PersistedEvent = { ...event, seq, createdAt };
    for (const fn of this.listeners) {
      try {
        fn(enriched);
      } catch {
        /* a bad listener must not break the run */
      }
    }
  }
}

// --- helpers ---------------------------------------------------------------

/** Extract concatenated assistant text from a message-like object or from the
 * `messages` array of an `agent_end` event (last assistant message wins). */
function extractAssistantText(source: unknown): string {
  if (Array.isArray(source)) {
    for (let i = source.length - 1; i >= 0; i--) {
      const m = source[i] as { role?: string };
      if (m && m.role === 'assistant') {
        const text = textFromMessage(m);
        if (text) return text;
      }
    }
    return '';
  }
  return textFromMessage(source);
}

function textFromMessage(message: unknown): string {
  if (!message || typeof message !== 'object') return '';
  const content = (message as { content?: unknown }).content;
  if (!Array.isArray(content)) return '';
  return content
    .filter((b): b is { type: string; text: string } => typeof b === 'object' && b !== null && (b as { type?: string }).type === 'text' && typeof (b as { text?: unknown }).text === 'string')
    .map((b) => b.text)
    .join('\n')
    .trim();
}

/** Short, readable preview of a tool call's arguments. */
function previewArgs(args: unknown): string {
  if (!args || typeof args !== 'object') return '';
  const a = args as Record<string, unknown>;
  if (typeof a.command === 'string') return truncate(a.command);
  const path = a.path ?? a.file_path;
  if (typeof path === 'string') return truncate(String(path));
  return truncate(JSON.stringify(a));
}

/** Short, readable preview of a tool result. */
function previewResult(result: unknown): string {
  if (!result || typeof result !== 'object') return '';
  const content = (result as { content?: unknown }).content;
  if (!Array.isArray(content)) return '';
  const text = content
    .map((b) =>
      b && typeof b === 'object' && (b as { type?: string }).type === 'text'
        ? String((b as { text?: unknown }).text ?? '')
        : '',
    )
    .join('\n')
    .trim();
  return truncate(text);
}

function truncate(s: string, max = 280): string {
  const oneLine = s.replace(/\s+/g, ' ').trim();
  return oneLine.length > max ? `${oneLine.slice(0, max)}…` : oneLine;
}
