import { mkdirSync, writeFileSync, existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { DATA_PATH } from '../../data-path';
import { createSidecarConnector } from '../connect';
import type { SidecarCommand, SidecarEvent } from '../protocol';
import { runOpenCodeTurn, killOpenCodeTurn } from './runner';

// 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.
// The serve's working directory is DATA_PATH/opencode_server, which holds an AGENTS.md OpenCode loads
// as standing instructions (the per-chat system prompt just supplies the target cwd). It listens on a
// random port, reported to the API on connect so it can route there.

const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
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;

// Standing instructions OpenCode loads from the project root (SERVE_CWD/AGENTS.md). Tells the agent to
// take its working directory from the "Working directory for this session" line in the system prompt.
const AGENTS_MD = `# Officer OpenCode Agent

## Working Directory Rules

**CRITICAL**: Always use absolute paths for all file operations.

At the start of each session, read the "Working directory for this session" value from your system prompt. Use that path as the base for all file tool calls (read, write, edit, glob, grep). Never use relative paths or assume the current directory.

Example:
- ✅ \`read /absolute/path/to/project/file.ts\`
- ❌ \`read ./project/file.ts\`
- ❌ \`read file.ts\`
`;

/**
 * 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-sidecar) ──

mkdirSync(SERVE_CWD, { recursive: true });
// Seed the project AGENTS.md before starting the serve (if absent — don't clobber local edits).
const agentsPath = join(SERVE_CWD, 'AGENTS.md');
if (!existsSync(agentsPath)) writeFileSync(agentsPath, AGENTS_MD);

// Sweep any orphaned serve from a previous unclean exit (scoped strictly to SERVE_CWD) so we never
// end up with two serves for this directory.
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',
});

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}`);

// ── Command handlers ──

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

const RUNNER_CONFIG = { bin: OPENCODE_BIN, fallbackCwd: SERVE_CWD };

function handleCommand(cmd: SidecarCommand, reply: ReplyFn, send: SendFn) {
  switch (cmd.type) {
    case 'ping':
      reply({ type: 'pong', id: cmd.id });
      break;
    case 'opencode:run-streaming':
      // Fire the turn; events stream back via `send` (opencode:event / opencode:session / terminal).
      runOpenCodeTurn(cmd.params, RUNNER_CONFIG, send);
      reply({ type: 'opencode:spawned', id: cmd.id, sessionKey: cmd.params.sessionKey });
      break;
    case 'opencode:kill':
      killOpenCodeTurn(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) {
    // Streaming turn events use a stable send (always the current ws), not the per-command reply.
    handleCommand(cmd as SidecarCommand, reply as ReplyFn, (msg) => connection.send(msg));
  },
  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`);
  },
});

// ── Graceful shutdown ──

function shutdown(signal: string) {
  console.log(`[opencode] ${signal} received, stopping serve...`);
  connection.destroy();
  try {
    serve.kill();
  } catch {
    /* already gone */
  }
  process.exit(0);
}

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