/**
 * 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 {
  ApprovalPreview,
  ClaudeStatus,
  Config,
  Dims,
  EnqueueResult,
  LiveSessionInfo,
  OrphanSessionInfo,
  NotifyService,
  PermissionGate,
  ServerMessage,
  Session,
  SessionManager,
  StatusTelemetry,
  WebSocketLike,
} from '../types.js';
import { WS_OPEN } from '../types.js';
import { serialize } from '../protocol.js';
import { createSession, attachWs, broadcast, kill, writeInput } from './session.js';
import { appendEvent, makeTimelineEvent } from './timeline.js';
import { hasSession, tmuxName, killSession, listSessions, capturePane } from './tmux.js';
import { SESSION_ID_RE } from '../protocol.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.
 * lastOutputAt is no longer list()-omitted: since T-iOS-37 it is serialized
 * into LiveSessionInfo (unread watermark for /live-sessions consumers).
 *
 * `notifyService` is injected (DI, M5) so the manager never imports push-service
 * directly (avoids a manager↔push-service cycle). sweepStuck uses it to push the
 * 'stuck' signal; when it is undefined (VAPID unset / tests) stuck detection still
 * updates status + broadcasts, it just doesn't send a push.
 */
export function createSessionManager(
  cfg: Config,
  notifyService?: NotifyService,
): 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);
      // M3 / AC-B2.3: a late-joining device immediately gets the current
      // telemetry (if any) and status so it doesn't wait for the next
      // hook/statusLine report to refresh. The `pending`/`gate` portion of the
      // status is re-sent by the server layer (it owns pendingApprovals).
      if (existing.telemetry !== null) {
        sendIfOpen(ws, { type: 'telemetry', telemetry: existing.telemetry });
      }
      sendIfOpen(ws, { type: 'status', status: existing.claudeStatus });
      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,
        telemetry: s.telemetry, // B2: latest telemetry for the thumbnail wall
        lastOutputAt: s.lastOutputAt, // T-iOS-37: unread watermark (M3 record)
        queueLength: s.queue.length, // W2: pending inject-queue depth
      }))
      .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/H3: a Claude Code hook reported activity for `sessionId`. Update the
   * status, append a timeline event (A4) when `eventClass` is supplied and the
   * timeline is enabled, and broadcast a `status` frame — carrying `gate` (B4)
   * when the held approval is a 'tool'/'plan' gate, and `preview` (W1) when a
   * bounded command/diff preview of the held tool was derived. No-op for an
   * unknown session.
   */
  function handleHookEvent(
    sessionId: string,
    status: ClaudeStatus,
    detail?: string,
    pending?: boolean,
    gate?: PermissionGate,
    eventClass?: string,
    toolName?: string,
    preview?: ApprovalPreview,
  ): void {
    const session = sessions.get(sessionId);
    if (session === undefined) return;
    session.claudeStatus = status;

    // A4: derive a bounded, sanitized timeline event from the raw hook name.
    // makeTimelineEvent whitelist-gates the event class (unknown → null = drop).
    if (cfg.timelineEnabled && eventClass !== undefined) {
      const ev = makeTimelineEvent(eventClass, toolName, Date.now());
      if (ev !== null) {
        session.timeline = appendEvent(session.timeline, ev, cfg.timelineMax);
      }
    }

    const msg: Extract<ServerMessage, { type: 'status' }> = { type: 'status', status };
    if (detail !== undefined) msg.detail = detail;
    if (pending) msg.pending = true;
    if (gate !== undefined) msg.gate = gate;
    if (preview !== undefined) msg.preview = preview;
    broadcast(session, msg);
  }

  /**
   * B2: store the latest statusLine telemetry for a session and broadcast a
   * `telemetry` frame to every attached client. No-op for an unknown session.
   */
  function handleStatusLine(id: string, telemetry: StatusTelemetry): void {
    const session = sessions.get(id);
    if (session === undefined) return;
    session.telemetry = telemetry;
    broadcast(session, { type: 'telemetry', telemetry });
    maybeAlertBudget(session, telemetry);
  }

  /**
   * W3 quick-wins (b): fire a one-shot cost-budget alert when a session's cost
   * first crosses COST_BUDGET_USD. Mirrors the A5 stuck-latch shape but is NEVER
   * re-armed (cost is monotonic) — so it fires at most once per session. Disabled
   * when the budget is 0/unset or the frame carries no cost.
   *
   * No new ServerMessage variant: the "warning broadcast" is the telemetry frame
   * already sent above (clients derive the warn from costUsd >= costBudgetUsd via
   * GET /config/ui). The one distinct new action on crossing is a 'budget' push.
   */
  function maybeAlertBudget(session: Session, telemetry: StatusTelemetry): void {
    if (cfg.costBudgetUsd <= 0) return; // disabled
    if (session.budgetNotified) return; // already alerted (latch)
    const cost = telemetry.costUsd;
    if (cost === undefined || cost < cfg.costBudgetUsd) return; // no crossing

    session.budgetNotified = true;
    void notifyService?.notify(session, 'budget').catch((err: unknown) => {
      console.error('[manager] budget notification failed', err);
    });
  }

  /**
   * A5 (H4): on the existing reaper tick (no new timer), mark live, non-idle
   * sessions whose output has been silent past STUCK_TTL as 'stuck'. Covers BOTH
   * attached and detached living sessions. Sets the status, broadcasts it (§3.4
   * review #5 — not just a notify), and pushes a single 'stuck' alert per silent
   * round (re-armed by the next pty output in session.ts). Disabled when
   * stuckAlert is off or stuckTtlMs ≤ 0.
   */
  function sweepStuck(now: number): void {
    if (!cfg.stuckAlert || cfg.stuckTtlMs <= 0) return;

    for (const session of sessions.values()) {
      if (session.exitedAt !== null) continue; // already exited
      if (session.claudeStatus === 'idle') continue; // legitimately done
      if (session.stuckNotified) continue; // already alerted this round
      if (now - session.lastOutputAt <= cfg.stuckTtlMs) continue; // still fresh

      session.claudeStatus = 'stuck';
      session.stuckNotified = true;
      broadcast(session, { type: 'status', status: 'stuck' });
      void notifyService?.notify(session, 'stuck').catch((err: unknown) => {
        console.error('[manager] stuck notification failed', err);
      });
    }
  }

  /**
   * 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;
  }

  /**
   * W2: append a verbatim byte string to a session's inject queue.
   *
   * The queue is a bounded FIFO (cfg.queueMaxItems). The HTTP route validates and
   * size-caps `text` before calling; here we only enforce depth + existence.
   * Immutable update: replace the frozen array wholesale, never mutate in place.
   * Broadcasts the new depth so every mirrored device updates its badge.
   */
  function enqueueFollowup(id: string, text: string): EnqueueResult {
    const session = sessions.get(id);
    if (session === undefined) return { ok: false, reason: 'unknown' };
    if (session.exitedAt !== null) return { ok: false, reason: 'exited' };
    if (session.queue.length >= cfg.queueMaxItems) return { ok: false, reason: 'full' };

    session.queue = Object.freeze([...session.queue, text]);
    const length = session.queue.length;
    broadcast(session, { type: 'queue', length });
    return { ok: true, length };
  }

  /**
   * W2: pop the head entry and write it to the PTY (byte-identical to a keystroke;
   * writeInput broadcasts the resulting output to all mirrors). Returns the
   * injected string, or null when there is nothing to drain / the session is
   * unknown or already exited (double-guards L4 — writeInput is itself a no-op
   * after exit). Broadcasts the new depth.
   */
  function drainOne(id: string): string | null {
    const session = sessions.get(id);
    if (session === undefined || session.exitedAt !== null) return null;
    if (session.queue.length === 0) return null;

    const head = session.queue[0] as string;
    session.queue = Object.freeze(session.queue.slice(1));
    writeInput(session, head);
    broadcast(session, { type: 'queue', length: session.queue.length });
    return head;
  }

  /**
   * W2: clear all pending entries (cancel-all escape hatch). Broadcasts depth 0.
   * Returns false for an unknown session.
   */
  function clearQueue(id: string): boolean {
    const session = sessions.get(id);
    if (session === undefined) return false;
    session.queue = Object.freeze([] as string[]);
    broadcast(session, { type: 'queue', length: 0 });
    return true;
  }

  /**
   * 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();
  }

  /* ── A: tmux sessions this server does not track ───────────────────────────
     A `web_*` tmux session that outlived the process which made it is invisible
     to everything above: list() only walks our own table, and so does reapIdle.
     These four functions are the whole of the read/act surface for them. They
     never adopt: no PTY is spawned and nothing is attached, because attaching
     would resize the tmux window to the new client and SIGWINCH whatever is
     running inside it. Joining (which DOES adopt, via handleAttach's re-attach
     path) stays an explicit user action.

     One guard, applied identically everywhere: the id must satisfy
     SESSION_ID_RE and must NOT be in our table. The first is what stops a
     hostile or malformed name reaching `tmux -t`; the second keeps a live
     session's shell from being killed behind its own PTY's back. */

  /** True when `id` is a real, untracked tmux session of ours that we may act on. */
  function isActionableOrphan(id: string): boolean {
    if (!cfg.useTmux) return false;
    if (!SESSION_ID_RE.test(id)) return false;
    if (sessions.has(id)) return false; // tracked ⇒ use get()/killById instead
    return hasSession(tmuxName(id));
  }

  async function listOrphans(): Promise<OrphanSessionInfo[]> {
    if (!cfg.useTmux) return [];
    const all = await listSessions();
    return all
      .filter((t) => !sessions.has(t.id))
      .map((t) => ({
        id: t.id,
        createdAt: t.createdAtMs,
        lastActivityAt: t.lastActivityAtMs,
        attached: t.attached,
        cols: t.cols,
        rows: t.rows,
      }));
  }

  /** The orphan's current screen, or null. Read-only: never attaches. */
  async function captureOrphan(id: string): Promise<string | null> {
    if (!isActionableOrphan(id)) return null;
    return capturePane(tmuxName(id));
  }

  /** End an orphan's shell. False when it is not ours, not there, or is tracked. */
  function killOrphan(id: string): boolean {
    if (!isActionableOrphan(id)) return false;
    killSession(tmuxName(id));
    return true;
  }

  /**
   * Bulk cleanup: kill every UNATTACHED orphan whose last tmux activity predates
   * `cutoffMs`. Returns the ids killed.
   *
   * Attached sessions are skipped on purpose — tmux reporting a client means
   * someone is in there from a real terminal or another server process, and
   * "this server does not track it" is not evidence that it is abandoned.
   */
  async function killOrphansIdleSince(cutoffMs: number): Promise<string[]> {
    const killed: string[] = [];
    for (const o of await listOrphans()) {
      if (o.attached) continue;
      if (o.lastActivityAt >= cutoffMs) continue;
      if (killOrphan(o.id)) killed.push(o.id);
    }
    return killed;
  }

  /** How many orphans `killOrphansIdleSince(cutoffMs)` would kill, without killing
   *  anything. The UI needs this to state a truthful number in its confirmation:
   *  the card grid is capped, so counting cards understates the blast radius. */
  async function countOrphansIdleSince(cutoffMs: number): Promise<number> {
    let n = 0;
    for (const o of await listOrphans()) {
      if (!o.attached && o.lastActivityAt < cutoffMs) n += 1;
    }
    return n;
  }

  return {
    handleAttach,
    get,
    list,
    listOrphans,
    captureOrphan,
    killOrphan,
    killOrphansIdleSince,
    countOrphansIdleSince,
    killById,
    handleHookEvent,
    handleStatusLine,
    sweepStuck,
    reapIdle,
    enqueueFollowup,
    drainOne,
    clearQueue,
    shutdown,
  };
}
