import { existsSync } from 'node:fs';
import type { Subprocess } from 'bun';
import type { ChatEvent, MessageCost } from '../../api/chat/types';
import type { OpenCodeRunParams } from '../protocol';

// Drives one chat turn by spawning `opencode run … --format json` and mapping its newline-delimited
// JSON events to the shared ChatEvent contract. This is the reliable path: `--dir <cwd>` hard-anchors
// every tool to the chat's working directory, and `run` (unlike serve + POST /message) reports tool
// completion faithfully and exits when the turn is done — no wedged "running" tools. `--dangerously-
// skip-permissions` auto-approves so tools never block on an approval we can't answer over this channel.
//
// A watchdog guards against a genuinely hung child (an interactive prompt, a `sleep`, a network stall):
// an inactivity timer (reset on every stdout chunk) and an absolute per-turn ceiling both kill the
// process and emit a clean `error`, so the UI never sits at "Working…" forever.

const INACTIVITY_MS = 120_000; // no stdout for this long → assume wedged, kill
const HARD_CAP_MS = 10 * 60_000; // absolute per-turn ceiling

export type RunnerConfig = {
  bin: string; // absolute path to the opencode binary
  fallbackCwd: string; // used when params.cwd is missing/nonexistent
};

// What a turn reports to the sidecar it runs in. `opencode:event` is deliberately not a wire event any
// more: the sidecar translates each one into a TurnMessage and commits it before officer sees anything,
// so the durable record does not depend on officer being up (see index.ts).
export type RunnerMessage =
  | { type: 'opencode:event'; sessionKey: string; event: ChatEvent }
  | { type: 'opencode:session'; sessionKey: string; sessionId: string };

type Emit = (msg: RunnerMessage) => void;

type RunHandle = { proc: Subprocess; killedByUser: boolean };

// One turn per sessionKey; a new turn supersedes any stale process for that key.
const running = new Map<string, RunHandle>();

// Shape of `opencode run --format json` events (verified live against 1.17.9).
type RunPart = {
  type?: string;
  text?: string;
  tool?: string;
  callID?: string;
  state?: { status?: string; input?: unknown; output?: unknown; error?: unknown };
  tokens?: { input?: number; output?: number };
  cost?: number;
};
type RunEvent = { type?: string; sessionID?: string; part?: RunPart };

export function runOpenCodeTurn(params: OpenCodeRunParams, config: RunnerConfig, emit: Emit): void {
  const { sessionKey } = params;

  // Supersede any lingering turn for this session.
  const stale = running.get(sessionKey);
  if (stale) {
    try {
      stale.proc.kill();
    } catch {
      /* already gone */
    }
    running.delete(sessionKey);
  }

  const args = ['run', '--format', 'json', '--dangerously-skip-permissions'];
  if (params.cwd) args.push('--dir', params.cwd);
  if (params.model) args.push('--model', params.model);
  if (params.resumeSessionId) args.push('--session', params.resumeSessionId);
  args.push(params.prompt);

  const cwd = params.cwd && existsSync(params.cwd) ? params.cwd : config.fallbackCwd;

  const proc = Bun.spawn([config.bin, ...args], {
    cwd,
    stdin: 'ignore', // == /dev/null: `run` hangs waiting on stdin otherwise
    stdout: 'pipe',
    stderr: 'pipe',
  });

  const handle: RunHandle = { proc, killedByUser: false };
  running.set(sessionKey, handle);

  let done = false;
  let reportedSession = false;
  let cost: MessageCost = { inputTokens: 0, outputTokens: 0, totalUSD: 0 };
  let stderrTail = '';

  const emitEvent = (event: ChatEvent) => emit({ type: 'opencode:event', sessionKey, event });

  let inactivityTimer: ReturnType<typeof setTimeout> | undefined;
  const finish = (event: ChatEvent) => {
    if (done) return;
    done = true;
    clearTimeout(hardTimer);
    if (inactivityTimer) clearTimeout(inactivityTimer);
    running.delete(sessionKey);
    emitEvent(event);
  };

  // ── Watchdogs ──
  const hardTimer = setTimeout(() => {
    try {
      proc.kill();
    } catch {
      /* already gone */
    }
    finish({ type: 'error', message: `OpenCode turn exceeded ${HARD_CAP_MS / 1000}s and was stopped` });
  }, HARD_CAP_MS);

  const bumpInactivity = () => {
    if (done) return;
    if (inactivityTimer) clearTimeout(inactivityTimer);
    inactivityTimer = setTimeout(() => {
      try {
        proc.kill();
      } catch {
        /* already gone */
      }
      finish({
        type: 'error',
        message: `OpenCode turn stalled (no output for ${INACTIVITY_MS / 1000}s) and was stopped`,
      });
    }, INACTIVITY_MS);
  };
  bumpInactivity();

  // ── Capture a tail of stderr for error reporting ──
  void (async () => {
    const dec = new TextDecoder();
    try {
      for await (const chunk of proc.stderr as unknown as AsyncIterable<Uint8Array>) {
        stderrTail = (stderrTail + dec.decode(chunk, { stream: true })).slice(-2000);
      }
    } catch {
      /* stream closed */
    }
  })();

  // ── Parse stdout: newline-delimited JSON events ──
  void (async () => {
    const dec = new TextDecoder();
    let buf = '';
    try {
      for await (const chunk of proc.stdout as unknown as AsyncIterable<Uint8Array>) {
        bumpInactivity();
        buf += dec.decode(chunk, { stream: true });
        let nl: number;
        while ((nl = buf.indexOf('\n')) >= 0) {
          const line = buf.slice(0, nl).trim();
          buf = buf.slice(nl + 1);
          if (line) handleLine(line);
        }
      }
      const last = buf.trim();
      if (last) handleLine(last);
    } catch {
      /* stream closed / process killed */
    }
  })();

  function handleLine(line: string): void {
    let evt: RunEvent;
    try {
      evt = JSON.parse(line) as RunEvent;
    } catch {
      return; // non-JSON log line
    }

    // Report the OpenCode session id once, so the API can resume it (`--session`) next turn.
    if (!reportedSession && evt.sessionID) {
      reportedSession = true;
      emit({ type: 'opencode:session', sessionKey, sessionId: evt.sessionID });
    }

    switch (evt.type) {
      case 'text': {
        // `run` emits complete text parts (not token deltas) — forward each as final text.
        const text = evt.part?.text;
        if (typeof text === 'string' && text.length > 0) emitEvent({ type: 'text', text });
        return;
      }
      case 'tool_use': {
        // In `run`, the tool part arrives already resolved (status + output). Emit start then result.
        const part = evt.part;
        if (!part || part.type !== 'tool' || !part.callID) return;
        const st = part.state ?? {};
        emitEvent({
          type: 'tool:start',
          toolCallId: part.callID,
          toolName: part.tool ?? 'tool',
          toolInput: (st.input as Record<string, unknown>) ?? {},
        });
        const isError = st.status === 'error';
        emitEvent({
          type: 'tool:result',
          toolCallId: part.callID,
          output: String((isError ? st.error : st.output) ?? ''),
          isError,
        });
        return;
      }
      case 'step_finish': {
        // Accumulate per-step tokens/cost into the turn's MessageCost.
        const part = evt.part;
        const t = part?.tokens;
        cost = {
          inputTokens: cost.inputTokens + (t?.input ?? 0),
          outputTokens: cost.outputTokens + (t?.output ?? 0),
          totalUSD: cost.totalUSD + (typeof part?.cost === 'number' ? part.cost : 0),
        };
        return;
      }
      default:
        return; // step_start etc. — nothing to forward
    }
  }

  // ── Completion: process exit is the authoritative turn-done signal ──
  void proc.exited.then((code) => {
    if (done) return;
    if (handle.killedByUser) {
      finish({ type: 'stopped' });
      return;
    }
    if (code === 0) {
      finish({ type: 'result', cost });
      return;
    }
    const tail = stderrTail.trim();
    finish({
      type: 'error',
      message: tail ? `OpenCode exited (${code}): ${tail.slice(-500)}` : `OpenCode exited with code ${code}`,
    });
  });
}

export function killOpenCodeTurn(sessionKey: string): void {
  const handle = running.get(sessionKey);
  if (!handle) return;
  handle.killedByUser = true;
  try {
    handle.proc.kill();
  } catch {
    /* already gone */
  }
  // proc.exited fires → finish({ type: 'stopped' }).
}
