/**
 * src/session/manager.ts (T13) — session table: create / attach / reap / shutdown.
 *
 * Cross-validated fixes implemented here (ARCHITECTURE §3.5):
 *  - M3: reapIdle uses now - max(detachedAt, lastOutputAt) > idleTtlMs (not just
 *        detachedAt), so a detached session that still has recent output is not reaped.
 *  - M4: createSession throwing (spawn failure) is NOT swallowed — it propagates to
 *        server.ts, which serialises a {type:'exit',code:-1,reason} and closes only
 *        that connection.
 *  - L1: when a client reconnects with a sessionId and that session's PTY has already
 *        exited (exitedAt!=null), the manager replays the ring buffer then immediately
 *        resends the exit message, then removes the session from the table.
 *  - L2: the onExit callback injected into createSession removes the session from the
 *        table ONLY when the ws was attached at exit time; if the session was detached
 *        at exit time, we keep it in the table so the L1 reconnect path can replay.
 *
 * Invariants (ARCHITECTURE §8):
 *  - #2: WS close → detach ONE client, never kill PTY (reap only when the last leaves).
 *  - #5 (relaxed v0.4): a session may have MANY concurrent clients (multi-device
 *        mirror sharing); a new attach JOINS instead of kicking. Output/exit/status
 *        broadcast to all; the PTY size is latest-writer-wins (the most recently
 *        focused/fitted device drives the shared size).
 *  - #6: every ws.send is guarded by readyState === WS_OPEN (handled inside session.ts).
 *
 * Coding style: immutable-update preference, no console.log, errors explicit.
 */

import type {
  ClaudeStatus,
  Config,
  Dims,
  LiveSessionInfo,
  Session,
  SessionManager,
  WebSocketLike,
} from '../types.js';
import { WS_OPEN } from '../types.js';
import { serialize } from '../protocol.js';
import { createSession, attachWs, broadcast, kill } from './session.js';
import { hasSession, tmuxName } from './tmux.js';

/**
 * Send a serialised ServerMessage to `ws` only when it is OPEN (M5).
 * Mirrors the guard inside session.ts so the manager can also send
 * control messages (e.g. the replayed exit on L1).
 */
function sendIfOpen(ws: WebSocketLike | null, msg: Parameters<typeof serialize>[0]): void {
  if (ws !== null && ws.readyState === WS_OPEN) {
    ws.send(serialize(msg));
  }
}

/**
 * Create the session table.
 *
 * The Map is the only mutable state; all updates produce a new Map snapshot
 * (add, delete) rather than in-place modification of the table structure.
 * Individual Session objects do carry mutable fields (attachedWs, detachedAt,
 * lastOutputAt, exitedAt, exitCode) by design — those are runtime handles.
 */
export function createSessionManager(cfg: Config): SessionManager {
  let sessions: Map<string, Session> = new Map();

  /**
   * The onExit callback injected into every createSession call.
   *
   * L2: when the PTY exits while a ws is attached (the common case), the
   * session is removed from the table here — the ws already received the
   * `exit` frame from inside session.ts (M5-guarded).
   *
   * L1: when the PTY exits while detached (no attachedWs), we KEEP the
   * session in the table so the next reconnect can replay the buffer and
   * re-send the exit message before removing the session.
   */
  function onSessionExit(session: Session): void {
    if (session.clients.size > 0) {
      // ≥1 client was attached at exit time — they got the `exit` frame, safe to drop.
      sessions = new Map(sessions);
      sessions.delete(session.meta.id);
    }
    // Otherwise: detached-then-exit (L1) — leave in table for reconnect replay.
  }

  /**
   * DoS guard: refuse to spawn a brand-new session once the table is at the
   * configured cap. Throwing here propagates to server.ts (M4 path), which
   * sends exit(-1, reason) and closes the offending connection.
   */
  function assertUnderSessionCap(): void {
    if (sessions.size >= cfg.maxSessions) {
      throw new Error(`session limit reached (max ${cfg.maxSessions})`);
    }
  }

  function handleAttach(
    ws: WebSocketLike,
    sessionId: string | null,
    dims: Dims,
    now: number,
    cwd?: string,
  ): Session {
    // ── Case 1: null → always create a new session ──────────────────────────
    if (sessionId === null) {
      // DoS guard: cap concurrent sessions. Throwing reuses the M4 path —
      // server.ts catches it and sends exit(-1, reason) + closes this connection.
      assertUnderSessionCap();
      // M4: createSession may throw (spawn failure). Do NOT catch here.
      // M6: cwd (if given) is the spawn directory for "new tab here".
      const session = createSession(cfg, dims, now, onSessionExit, undefined, cwd);
      attachWs(session, ws);
      sessions = new Map(sessions).set(session.meta.id, session);
      return session;
    }

    const existing = sessions.get(sessionId);

    // ── Case 2: hit a live session → JOIN it (multi-device sharing) ───────────
    if (existing !== undefined && existing.exitedAt === null) {
      attachWs(existing, ws);
      return existing;
    }

    // ── Case 3: hit an already-exited session (L1) ───────────────────────────
    if (existing !== undefined && existing.exitedAt !== null) {
      // Replay the ring buffer so the reconnecting client sees the last screen.
      sendIfOpen(ws, { type: 'output', data: existing.buffer.snapshot() });
      // Immediately re-deliver the exit message (it was not delivered while detached).
      sendIfOpen(ws, { type: 'exit', code: existing.exitCode ?? -1 });
      // Remove from table — session lifecycle is complete.
      sessions = new Map(sessions);
      sessions.delete(sessionId);
      return existing;
    }

    // ── Case 3.5 (H1): not in our table, but a tmux session survived a restart ─
    // Re-attach with the SAME id so `tmux new-session -A -s web_<id>` reconnects
    // to the still-running shell instead of spawning a fresh one.
    if (cfg.useTmux && hasSession(tmuxName(sessionId))) {
      const revived = createSession(cfg, dims, now, onSessionExit, sessionId);
      attachWs(revived, ws);
      sessions = new Map(sessions).set(revived.meta.id, revived);
      return revived;
    }

    // ── Case 4: session not found → create a new one ─────────────────────────
    // DoS guard (see Case 1): cap concurrent sessions before spawning.
    assertUnderSessionCap();
    // M4: createSession may throw. Do NOT catch here.
    const session = createSession(cfg, dims, now, onSessionExit);
    attachWs(session, ws);
    sessions = new Map(sessions).set(session.meta.id, session);
    return session;
  }

  function get(id: string): Session | undefined {
    return sessions.get(id);
  }

  /** Live sessions for multi-device discovery (newest first). */
  function list(): LiveSessionInfo[] {
    return [...sessions.values()]
      .map((s) => ({
        id: s.meta.id,
        createdAt: s.meta.createdAt,
        clientCount: s.clients.size,
        status: s.claudeStatus,
        exited: s.exitedAt !== null,
        cwd: s.cwd,
        cols: s.pty.cols,
        rows: s.pty.rows,
      }))
      .sort((a, b) => b.createdAt - a.createdAt);
  }

  /**
   * Kill a session by id (manage page): close every attached client, kill the
   * PTY (and tmux session, H1), and drop it from the table. Returns whether a
   * session was found.
   */
  function killById(id: string): boolean {
    const session = sessions.get(id);
    if (session === undefined) return false;
    for (const ws of session.clients) {
      if (ws.readyState === WS_OPEN) ws.close();
    }
    kill(session);
    sessions = new Map(sessions);
    sessions.delete(id);
    return true;
  }

  /**
   * H2: a Claude Code hook reported activity for `sessionId`. Record it and push
   * a `status` frame to the attached ws (no-op if the session is gone/detached).
   */
  function handleHookEvent(
    sessionId: string,
    status: ClaudeStatus,
    detail?: string,
    pending?: boolean,
  ): void {
    const session = sessions.get(sessionId);
    if (session === undefined) return;
    session.claudeStatus = status;
    const msg: { type: 'status'; status: ClaudeStatus; detail?: string; pending?: boolean } = {
      type: 'status',
      status,
    };
    if (detail !== undefined) msg.detail = detail;
    if (pending) msg.pending = true;
    broadcast(session, msg);
  }

  /**
   * Reclaim detached sessions whose liveness window has expired (M3).
   *
   * Reclaim condition: session is detached (detachedAt != null) AND
   *   now - max(detachedAt, lastOutputAt) > idleTtlMs
   *
   * Using max() means: if output arrived after detach (PTY still running a
   * background task), the expiry clock restarts from that later timestamp.
   * This approximates "is there a live foreground job?" without needing
   * cross-platform PTY process-group queries (M3 rationale).
   *
   * Returns the count of reaped sessions.
   */
  function reapIdle(now: number): number {
    let count = 0;
    const next = new Map(sessions);

    for (const [id, session] of next) {
      if (session.detachedAt === null) continue; // still attached or never detached

      const livenessCursor = Math.max(session.detachedAt, session.lastOutputAt);
      if (now - livenessCursor > cfg.idleTtlMs) {
        kill(session);
        next.delete(id);
        count += 1;
      }
    }

    sessions = next;
    return count;
  }

  /**
   * Called on SIGINT/SIGTERM/close. For non-tmux sessions, kill the PTY. For
   * tmux sessions (H1), kill only the client pty — the tmux server keeps the
   * shell alive so it survives the restart and can be re-attached.
   */
  function shutdown(): void {
    for (const session of sessions.values()) {
      if (session.tmuxName !== null) {
        session.pty.kill(); // detach client; tmux keeps the shell
      } else {
        kill(session);
      }
    }
    sessions = new Map();
  }

  return { handleAttach, get, list, killById, handleHookEvent, reapIdle, shutdown };
}
