import type { ServerWebSocket } from 'bun';
import type {
  SidecarCommand,
  SidecarEvent,
  ClaudeState,
  ClaudeSpawnParams,
  ClaudeSpawnStreamingParams,
  ClaudeCodeResult,
  LiveClaudeSession,
  LiveOpenCodeSession,
  OpenCodeRunParams,
  VncStartParams,
} from './sidecar/protocol';
import type { SidecarRegistration } from './sidecar/registration-protocol';
import type { TurnMessage } from './api/chat/types';

// ── Types ──

type RegisteredSidecar = {
  id: string;
  name: string;
  capabilities: string[];
  ws: ServerWebSocket<any>;
};

type PendingRequest = {
  /** Which sidecar this request was sent to, so a disconnect only fails that sidecar's own work. */
  sidecarId: string;
  resolve: (value: any) => void;
  reject: (error: Error) => void;
  timer: Timer;
};

type EventHandler = (event: SidecarEvent) => void;

// ── State ──

const sidecars = new Map<string, RegisteredSidecar>();
const pending = new Map<string, PendingRequest>();
const eventHandlers = new Map<string, Set<EventHandler>>();
let cachedState: ClaudeState | null = null;
let idCounter = 0;

function nextId(): string {
  return `sr_${Date.now()}_${++idCounter}`;
}

// ── Registration ──

export function registerSidecar(ws: ServerWebSocket<any>, registration: SidecarRegistration): string {
  // If a sidecar with the same name is already registered, unregister it first
  for (const [id, sc] of sidecars) {
    if (sc.name === registration.name) {
      console.log(`[registry] replacing existing sidecar "${registration.name}" (id=${id})`);
      unregisterSidecar(id);
      break;
    }
  }

  const id = `sc_${registration.name}_${Date.now()}`;
  sidecars.set(id, {
    id,
    name: registration.name,
    capabilities: registration.capabilities,
    ws,
  });
  console.log(
    `[registry] registered sidecar "${registration.name}" (id=${id}, capabilities=[${registration.capabilities.join(', ')}])`,
  );

  // A registration socket lives and dies with its process, so an agent showing up here is an agent that
  // has just started — and whatever it was running before is gone. Announced rather than inferred from
  // the *disconnect*, which is the wrong signal entirely: every `pm2 restart officer` drops these sockets
  // while the sidecars, and their turns, carry on perfectly well.
  if (registration.capabilities.includes('claude')) {
    for (const handler of claudeRestartHandlers) {
      try {
        handler();
      } catch (err) {
        console.error('[registry] claude-restart handler failed', err);
      }
    }
  }
  return id;
}

const claudeRestartHandlers = new Set<() => void>();

/** Notified when the agent sidecar registers — i.e. when a new agent process has come up. */
export function onClaudeSidecarStarted(handler: () => void): () => void {
  claudeRestartHandlers.add(handler);
  return () => claudeRestartHandlers.delete(handler);
}

export function unregisterSidecar(id: string): void {
  const sc = sidecars.get(id);
  if (!sc) return;
  sidecars.delete(id);
  console.log(`[registry] unregistered sidecar "${sc.name}" (id=${id})`);

  // Reject the pending requests belonging to THIS sidecar, and only those. Until the pending entries
  // carried a sidecarId this loop rejected the whole map, so restarting any one sidecar failed in-flight
  // work on every other — `pm2 restart officer-music` could kill a running agent turn with the message
  // `Sidecar "music" disconnected`, which is the sort of failure nobody traces back to its cause.
  for (const [reqId, req] of pending) {
    if (req.sidecarId !== id) continue;
    clearTimeout(req.timer);
    req.reject(new Error(`Sidecar "${sc.name}" disconnected`));
    pending.delete(reqId);
  }

  // Clear cached state if the claude sidecar disconnects
  if (sc.capabilities.includes('proxy')) {
    cachedState = null;
  }
}

export function handleSidecarMessage(id: string, msg: SidecarEvent): void {
  // Check if this is a response to a pending request
  if ('id' in msg && msg.id && pending.has(msg.id)) {
    const req = pending.get(msg.id)!;
    pending.delete(msg.id);
    clearTimeout(req.timer);
    req.resolve(msg);
    return;
  }

  // Otherwise dispatch as event
  dispatchEvent(msg);
}

// ── Lookup ──

function findSidecarByCapability(cap: string): RegisteredSidecar | undefined {
  for (const sc of sidecars.values()) {
    if (sc.capabilities.includes(cap)) return sc;
  }
  return undefined;
}

// ── Event dispatch ──

function dispatchEvent(msg: SidecarEvent) {
  const handlers = eventHandlers.get(msg.type);
  if (handlers) {
    for (const handler of handlers) {
      try {
        handler(msg);
      } catch {
        /* ignore */
      }
    }
  }
}

export function on(eventType: string, handler: EventHandler): () => void {
  if (!eventHandlers.has(eventType)) {
    eventHandlers.set(eventType, new Set());
  }
  eventHandlers.get(eventType)!.add(handler);
  return () => {
    eventHandlers.get(eventType)?.delete(handler);
  };
}

// ── Command sending ──

const DEFAULT_TIMEOUT_MS = 30_000;
const LONG_TIMEOUT_MS = 6 * 60 * 1000;

function sendCommand(cap: string, cmd: SidecarCommand, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<any> {
  return new Promise((resolve, reject) => {
    const sc = findSidecarByCapability(cap);
    if (!sc) {
      reject(new Error(`No sidecar with capability "${cap}" is connected`));
      return;
    }

    const timer = setTimeout(() => {
      pending.delete((cmd as any).id);
      reject(new Error(`Sidecar command ${cmd.type} timed out`));
    }, timeoutMs);

    pending.set((cmd as any).id, { sidecarId: sc.id, resolve, reject, timer });
    sc.ws.send(JSON.stringify(cmd));
  });
}

function sendFire(cap: string, cmd: SidecarCommand): void {
  const sc = findSidecarByCapability(cap);
  if (sc) {
    sc.ws.send(JSON.stringify(cmd));
  }
}

function sendCommandToSidecar(
  sc: RegisteredSidecar,
  cmd: SidecarCommand,
  timeoutMs = DEFAULT_TIMEOUT_MS,
): Promise<any> {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => {
      pending.delete((cmd as any).id);
      reject(new Error(`Sidecar command ${cmd.type} timed out`));
    }, timeoutMs);

    pending.set((cmd as any).id, { sidecarId: sc.id, resolve, reject, timer });
    sc.ws.send(JSON.stringify(cmd));
  });
}

function sendFireToSidecar(sc: RegisteredSidecar, cmd: SidecarCommand): void {
  sc.ws.send(JSON.stringify(cmd));
}

// ── Waiting for a sidecar to appear ──

// Officer no longer spawns any sidecar; PM2 owns every one of them. The only thing left to handle is
// startup order — PM2 brings `officer` and its peers up together, so the first request after a boot can
// arrive a beat before the sidecar has finished dialling in. Wait briefly rather than failing the
// request. (This replaces ~77 lines of spawn-and-poll: `ensureClaudeSidecar`,
// `spawnAndWaitForRegistration`, and the per-email `claudeProcs`/`claudeSpawnWaiters` maps.)
const CAPABILITY_WAIT_MS = 15_000;
const CAPABILITY_POLL_MS = 100;

async function waitForCapability(cap: string, timeoutMs = CAPABILITY_WAIT_MS): Promise<RegisteredSidecar> {
  const existing = findSidecarByCapability(cap);
  if (existing) return existing;

  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    await Bun.sleep(CAPABILITY_POLL_MS);
    const sc = findSidecarByCapability(cap);
    if (sc) return sc;
  }
  throw new Error(`No sidecar with capability "${cap}" registered within ${timeoutMs}ms`);
}

// ── Public API ──

export function isConnected(): boolean {
  return findSidecarByCapability('proxy') !== undefined;
}

export function getCachedState(): ClaudeState | null {
  return cachedState;
}

export async function syncState(): Promise<ClaudeState> {
  const res = await sendCommand('proxy', { type: 'state:sync', id: nextId() });
  if (res.type === 'state:sync') {
    cachedState = res.state;
    return res.state;
  }
  throw new Error('Unexpected response');
}

export async function getProxySecret(): Promise<string> {
  if (cachedState?.proxySecret) return cachedState.proxySecret;
  const res = await sendCommand('proxy', { type: 'proxy:secret', id: nextId() });
  if (res.type === 'proxy:secret') return res.secret;
  throw new Error('Failed to get proxy secret');
}

export function getProxySecretSync(): string {
  return cachedState?.proxySecret ?? '';
}

// ── Claude Code (the `officer-agent` sidecar, capability 'claude') ──

// Single-user platform, so there is exactly one agent sidecar and it is found by capability like every
// other one. The `email` on the params is still passed through to the sidecar — it needs it to resolve
// paths — but officer no longer uses it to *locate* anything.

export async function spawnClaude(params: ClaudeSpawnParams): Promise<ClaudeCodeResult> {
  const sc = await waitForCapability('claude');
  const res = await sendCommandToSidecar(sc, { type: 'claude:spawn', id: nextId(), params }, LONG_TIMEOUT_MS);
  if (res.type === 'claude:result') return res.result;
  if (res.type === 'claude:error') throw new Error(res.error);
  throw new Error('Unexpected response');
}

export async function spawnClaudeStreaming(params: ClaudeSpawnStreamingParams): Promise<void> {
  const sc = await waitForCapability('claude');
  const res = await sendCommandToSidecar(sc, { type: 'claude:spawn-streaming', id: nextId(), params });
  if (res.type === 'claude:spawned') return;
  if (res.type === 'claude:error') throw new Error(res.error);
  throw new Error('Unexpected response');
}

export function killClaude(sessionKey: string, userId: number): void {
  sendFire('claude', { type: 'claude:kill', id: nextId(), sessionKey, userId });
}

// Interrupt the current turn but keep the persistent session warm (the "stop" button).
export function interruptClaude(sessionKey: string, userId: number): void {
  sendFire('claude', { type: 'claude:interrupt', id: nextId(), sessionKey, userId });
}

/**
 * Is the agent still running a turn for this session?
 *
 * Asked of the sidecar because it is the only party that can answer: officer's own memory of a turn dies
 * with `pm2 restart officer` while the turn itself carries on, so "we don't remember it" is not evidence
 * of anything. Used on reconnect to tell those two apart — a turn that outlived a restart from one whose
 * process is gone and is never going to produce another token.
 *
 * Fails *toward alive*: no answer means we don't know, and wrongly telling someone their turn died while
 * it is still typing is worse than leaving a spinner up a little longer. Only a registered agent that
 * says "no", or no agent at all, counts as dead.
 */
/**
 * Every session the agent is holding IN MEMORY right now — distinct from `listClaudeSessions` in
 * `api/chat/claude-sessions`, which lists conversations from transcripts on disk. Those are the history;
 * these are the ones with a process behind them.
 *
 * Officer's own session records live in memory and die with `pm2 restart officer`, while the agent — a
 * PM2 peer, not a child — keeps running and keeps committing to chat_session_events. Until this existed
 * a surviving session was invisible: `adoptOrphanedSession` only fires when a browser reconnects to one
 * *by id*, so nothing could answer "what is still running".
 *
 * Fails *toward empty*, unlike `isClaudeGenerating` which fails toward alive. The asymmetry is
 * deliberate: there, not knowing means leaving a spinner up; here, not knowing would mean inventing
 * sessions, and an enumeration that reports things that may not exist is worse than a short one.
 */
export async function listLiveClaudeSessions(userId: number): Promise<LiveClaudeSession[]> {
  const sc = findSidecarByCapability('claude');
  if (!sc) return [];
  try {
    const res = await sendCommandToSidecar(sc, { type: 'claude:list', id: nextId(), userId });
    return res.type === 'claude:sessions' ? res.sessions : [];
  } catch {
    return [];
  }
}

/**
 * The OpenCode turns running right now, from the sidecar's own map.
 *
 * Fails toward EMPTY, matching `listLiveClaudeSessions` and for the same reason: an enumeration that
 * invents sessions is worse than a short one. A sidecar that is down or does not understand the verb
 * (an older build) simply contributes nothing to the Live panel rather than breaking it.
 */
export async function listLiveOpenCodeSessions(): Promise<LiveOpenCodeSession[]> {
  const sc = findSidecarByCapability('opencode');
  if (!sc) return [];
  try {
    const res = await sendCommandToSidecar(sc, { type: 'opencode:list', id: nextId() });
    return res.type === 'opencode:sessions' ? res.sessions : [];
  } catch {
    return [];
  }
}

export async function isClaudeGenerating(sessionKey: string, userId: number): Promise<boolean> {
  const sc = findSidecarByCapability('claude');
  if (!sc) return false;
  try {
    const res = await sendCommandToSidecar(sc, { type: 'claude:is-generating', id: nextId(), sessionKey, userId });
    return res.type === 'claude:generating' ? res.generating : true;
  } catch {
    return true;
  }
}

/**
 * Officer's session key for a Claude transcript uuid, or null if the agent has never seen it.
 *
 * The browser only ever has the uuid after a refresh — it is what the URL carries — and officer's own
 * key is not derivable from it. The agent's on-disk map is the single record that relates them, so this
 * is the hinge the whole reattach path turns on.
 *
 * Fails toward null: no agent, no answer, or a timeout all mean "cannot re-bind", and the caller falls
 * back to today's behaviour of leaving the socket unattached rather than binding it to a guess.
 */
export async function findClaudeSessionKey(claudeSessionId: string, userId: number): Promise<string | null> {
  const sc = findSidecarByCapability('claude');
  if (!sc) return null;
  try {
    const res = await sendCommandToSidecar(sc, { type: 'claude:find-session', id: nextId(), claudeSessionId, userId });
    return res.type === 'claude:session-key' ? res.sessionKey : null;
  } catch {
    return null;
  }
}

export function clearClaudeSession(sessionKey: string, userId: number): void {
  sendFire('claude', { type: 'claude:clear-session', id: nextId(), sessionKey, userId });
}

// Turn output arrives finished and already durable: the agent translated it and committed it to
// chat_session_events, and `seq` is its cursor id there. Officer relays it — it no longer builds or
// persists chat messages for this harness.
export function onClaudeMessage(handler: (sessionKey: string, msg: TurnMessage, seq?: number) => void): () => void {
  return on('claude:message', (ev) => {
    if (ev.type !== 'claude:message') return;
    const msg = ev as SidecarEvent & { type: 'claude:message' };
    handler(msg.sessionKey, msg.msg, msg.seq);
  });
}

// ── OpenCode Code (single sidecar, capability 'opencode') ──

export async function spawnOpenCodeStreaming(params: OpenCodeRunParams): Promise<void> {
  const res = await sendCommand('opencode', { type: 'opencode:run-streaming', id: nextId(), params });
  if (res.type === 'opencode:spawned') return;
  if (res.type === 'opencode:error') throw new Error(res.error);
  throw new Error('Unexpected response');
}

export function killOpenCode(sessionKey: string): void {
  sendFire('opencode', { type: 'opencode:kill', id: nextId(), sessionKey });
}

export function onOpenCodeMessage(handler: (sessionKey: string, msg: TurnMessage, seq?: number) => void): () => void {
  return on('opencode:message', (ev) => {
    if (ev.type !== 'opencode:message') return;
    const msg = ev as SidecarEvent & { type: 'opencode:message' };
    handler(msg.sessionKey, msg.msg, msg.seq);
  });
}

export function onOpenCodeSession(handler: (sessionKey: string, sessionId: string) => void): () => void {
  return on('opencode:session', (msg) => {
    if (msg.type === 'opencode:session') {
      handler(
        (msg as SidecarEvent & { type: 'opencode:session' }).sessionKey,
        (msg as SidecarEvent & { type: 'opencode:session' }).sessionId,
      );
    }
  });
}

// ── Terminal (PTY sidecar) ──
//
// Nothing here any more. The pty sidecar serves its own listener; `/api/terminal/*` is a proxy and
// `/api/terminal/ws` a byte relay, both keyed off the `pty:server` port like every other HTTP sidecar.

// ── VNC ──

export async function startVnc(params: VncStartParams): Promise<{ port: number; display: number }> {
  const res = await sendCommand('vnc', { type: 'vnc:start', id: nextId(), params });
  if (res.type === 'vnc:started') return { port: res.port, display: res.display };
  if (res.type === 'vnc:error') throw new Error(res.error);
  throw new Error('Unexpected response');
}

// Provision the VNC password without starting a server. The desktop UI needs it before it can open
// the WebSocket that would start one, so asking vnc:start here would be circular.
export async function ensureVncPassword(email: string): Promise<string> {
  const res = await sendCommand('vnc', { type: 'vnc:ensure-password', id: nextId(), email });
  if (res.type === 'vnc:password') return res.password;
  if (res.type === 'vnc:error') throw new Error(res.error);
  throw new Error('Unexpected response from VNC sidecar');
}

export function stopVnc(email: string): void {
  sendFire('vnc', { type: 'vnc:stop', id: nextId(), email });
}

export function isVncConnected(): boolean {
  return findSidecarByCapability('vnc') !== undefined;
}
