import { mkdirSync, readdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { DATA_PATH } from '../../data-path';
import { createSidecarConnector } from '../connect';
import { sweepRecordedServe } from './serve-sweep';
import { connectProviderCredential } from './connect-credential';
import { createSessionLogStore } from '../claude/session-log';
import type { SidecarCommand, SidecarEvent } from '../protocol';
import type { RunnerMessage } from './serve-runner';
import { runOpenCodeTurnOnServe, killServeTurn, listRunningServeTurns, stopAllServeTurns } from './serve-runner';
import { API_URL } from '../../officer-url.mjs';

// The OpenCode sidecar (officer-opencode). Same philosophy as officer-claude: a singleton process that
// OWNS its runtime — here, an `opencode serve` — registers with the API server, and answers commands. It
// listens on a random port, reported to the API on connect so it can route there.
//
// The serve runs EVERYTHING: turns (serve-runner.ts), session CRUD and model enumeration. It used to be
// CRUD only, with turns spawned as `opencode run --dir <cwd>` subprocesses — that path was deleted on
// 2026-08-10 once the serve had streaming, mid-turn injection and interrupt working end to end.

const OPENCODE_BIN = process.env.OPENCODE_BIN || join(homedir(), '.opencode', 'bin', 'opencode');
const SERVE_CWD = join(DATA_PATH, 'opencode_server');
const HEALTH_TIMEOUT_MS = 20_000;

/**
 * Kill any orphaned `opencode serve` whose working directory is EXACTLY serveCwd — e.g. one left behind
 * by a previous sidecar that exited uncleanly (SIGKILL/crash), where its SIGTERM shutdown handler never
 * ran. Strictly scoped by resolved cwd: an `opencode serve` running from anywhere else is never touched.
 * Linux-only (reads /proc); a no-op elsewhere.
 */
function sweepStaleServes(serveCwd: string): void {
  let target: string;
  try {
    target = realpathSync(serveCwd);
  } catch {
    return; // dir gone → nothing can be running from it
  }

  let entries: string[];
  try {
    entries = readdirSync('/proc');
  } catch {
    return; // no procfs (non-Linux) → skip
  }

  for (const pid of entries) {
    if (!/^\d+$/.test(pid) || Number(pid) === process.pid) continue;
    try {
      // argv is NUL-separated; require an `opencode … serve` invocation.
      const argv = readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0');
      if (!argv.some((a) => a.includes('opencode')) || !argv.includes('serve')) continue;

      // The scope guard: only sweep serves whose real cwd matches ours.
      const cwd = realpathSync(`/proc/${pid}/cwd`);
      if (cwd !== target) continue;

      process.kill(Number(pid), 'SIGTERM');
      console.log(`[opencode] swept stale serve pid ${pid} (cwd=${cwd})`);
    } catch {
      /* pid vanished mid-scan or /proc entry unreadable — skip */
    }
  }
}

/** Grab an ephemeral free port by briefly binding one and releasing it. */
function getFreePort(): number {
  const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
  const port = probe.port;
  probe.stop(true);
  if (port == null) throw new Error('failed to acquire a free port');
  return port;
}

async function waitHealthy(baseUrl: string, timeoutMs: number): Promise<boolean> {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    try {
      const res = await fetch(`${baseUrl}/api/health`, { signal: AbortSignal.timeout(2000) });
      if (res.ok) return true;
    } catch {
      /* not up yet */
    }
    await new Promise((r) => setTimeout(r, 200));
  }
  return false;
}

// ── Start the OpenCode server (cwd = DATA_PATH/opencode_server) ──

mkdirSync(SERVE_CWD, { recursive: true });
// An AGENTS.md used to be seeded here, telling the agent to read its working directory from the
// "Working directory for this session" line in its system prompt. It was deleted on 2026-08-10 because
// it pointed at nothing: the run path (runner.ts) sends NO system prompt at all, so there was no such
// line to read, and the instruction had been inert since turns moved off the serve.
//
// What it was standing in for, `--dir` does properly. Tested against the installed binary: `--dir`
// anchors the agent's own file operations, not merely the process cwd, and the anchor survives a
// multi-step turn including a write (docs/opencode-phase0-review.md). Nothing replaces this.

// Sweep any orphaned serve from a previous unclean exit so we never end up with two for this directory.
// Two mechanisms, deliberately: the pidfile is portable and precise, the /proc scan is the Linux-only
// backstop for a serve whose pidfile was lost (killed -9 mid-write, or predating the pidfile entirely).
const SERVE_PID_FILE = join(SERVE_CWD, 'serve.pid');
sweepRecordedServe(SERVE_PID_FILE);
sweepStaleServes(SERVE_CWD);

const port = getFreePort();
const baseUrl = `http://127.0.0.1:${port}`;

console.log(`[opencode] starting serve on ${baseUrl} (cwd=${SERVE_CWD})`);
const serve = Bun.spawn([OPENCODE_BIN, 'serve', '--port', String(port), '--hostname', '127.0.0.1'], {
  cwd: SERVE_CWD,
  stdout: 'inherit',
  stderr: 'inherit',
});

// Recorded before the health check, not after: an unhealthy serve is exactly the kind that gets left
// behind, and it still needs sweeping next time.
try {
  writeFileSync(SERVE_PID_FILE, String(serve.pid));
} catch (err) {
  console.error('[opencode] could not record the serve pid; sweeping will fall back to /proc', err);
}

if (!(await waitHealthy(baseUrl, HEALTH_TIMEOUT_MS))) {
  console.error('[opencode] serve failed its health check');
  try {
    serve.kill();
  } catch {
    /* already gone */
  }
  process.exit(1);
}
console.log(`[opencode] serve healthy on port ${port}`);

// The new /api surface keeps credentials separately from auth.json and would otherwise reach free models
// only — silently. Best-effort and not awaited for correctness: turns go through `opencode run`, which
// reads auth.json directly and does not depend on this.
void connectProviderCredential(baseUrl);

// ── Command handlers ──

type ReplyFn = (msg: SidecarEvent) => void;

const SERVE_RUNNER_CONFIG = { baseUrl, fallbackCwd: SERVE_CWD };

function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
  switch (cmd.type) {
    case 'ping':
      reply({ type: 'pong', id: cmd.id });
      break;
    case 'opencode:run-streaming': {
      const { sessionKey, durable = true } = cmd.params;
      // Turn output goes through the session log: translated to TurnMessages and committed to
      // chat_session_events here, in the process that produced it. Officer being down during a turn
      // no longer costs the transcript — the browser replays it from its cursor.
      // Same emit contract either way, which is what makes the switch a switch: the durable commit and
      // the routing fact behave identically whether a subprocess or the serve produced the event.
      const onMessage = (msg: RunnerMessage) => {
        if (msg.type === 'opencode:event') {
          sessionLog.push(sessionKey, msg.event, durable);
          return;
        }
        // opencode:session is a routing fact (which `ses_…` to resume), not transcript — send it live.
        connection.send(msg);
      };

      void runOpenCodeTurnOnServe(cmd.params, SERVE_RUNNER_CONFIG, onMessage);
      reply({ type: 'opencode:spawned', id: cmd.id, sessionKey });
      break;
    }
    case 'opencode:list':
      reply({ type: 'opencode:sessions', id: cmd.id, sessions: listRunningServeTurns() });
      break;

    case 'opencode:kill':
      // An INTERRUPT, not a kill: the turn stops and the session survives, so the conversation can be
      // continued rather than only re-opened.
      void killServeTurn(cmd.sessionKey, SERVE_RUNNER_CONFIG);
      sessionLog.drop(cmd.sessionKey);
      break;
    default:
      reply({
        type: 'error',
        id: (cmd as SidecarCommand).id,
        error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
      });
  }
}

// ── Register with the API server ──

const connection = createSidecarConnector({
  apiUrl: `${API_URL}/api/sidecar/register`,
  name: 'opencode',
  capabilities: ['opencode'],
  onCommand(cmd, reply) {
    handleCommand(cmd as SidecarCommand, reply as ReplyFn);
  },
  onConnected() {
    // Tell the API where our OpenCode HTTP server is listening, so it can route requests there.
    connection.send({ type: 'opencode:server', port });
    console.log(`[opencode] reported server port ${port} to API`);
  },
});

// Translate → commit → deliver, in that order and one at a time per session. Shared with the agent
// sidecar (`claude/session-log.ts`): both harnesses speak ChatEvents, so the translation and the write
// are the same code, and only the wire event type differs.
const sessionLog = createSessionLogStore((d) =>
  connection.send({ type: 'opencode:message', sessionKey: d.sessionId, msg: d.msg, seq: d.seq }),
);

// ── Graceful shutdown ──

// PM2 sends SIGTERM and follows with SIGKILL shortly after, so everything below is on a budget. The
// flush is bounded rather than awaited outright: losing the explanation is bad, hanging the restart is
// worse, and an unbounded await on a wedged Postgres would do exactly that.
const SHUTDOWN_FLUSH_MS = 1_000;

let shuttingDown = false;

async function shutdown(signal: string) {
  if (shuttingDown) return; // SIGINT after SIGTERM must not re-enter and cut the flush short
  shuttingDown = true;

  // Before the connection goes: killing a turn produces an event, and that event's durable write travels
  // over this socket. Tearing it down first would stop every turn silently — the exact outcome this is
  // here to prevent.
  const message = `The OpenCode sidecar restarted (${signal}), so this turn stopped.`;
  const stopped = stopAllServeTurns(message);
  if (stopped > 0) {
    console.log(`[opencode] ${signal} received, stopping ${stopped} in-flight turn(s)...`);
    await Promise.race([sessionLog.flush(), Bun.sleep(SHUTDOWN_FLUSH_MS)]);
  }

  console.log(`[opencode] ${signal} received, stopping serve...`);
  connection.destroy();
  try {
    serve.kill();
  } catch {
    /* already gone */
  }
  // We killed it ourselves, so the record has done its job. Leaving it would make the next start check a
  // pid that is either gone or, worse, reused.
  try {
    unlinkSync(SERVE_PID_FILE);
  } catch {
    /* never written, or already gone */
  }
  process.exit(0);
}

process.on('SIGTERM', () => void shutdown('SIGTERM'));
process.on('SIGINT', () => void shutdown('SIGINT'));
