/**
 * src/session/session.ts (T12) — single PTY session: lifecycle + ws plumbing.
 *
 * Owns one node-pty process and its scrollback. Wires:
 *   - pty.onData → ring buffer + (forward to attachedWs as `output`),
 *   - pty.onExit → stamp exitedAt/exitCode → (notify attachedWs with `exit`)
 *                  → call the injected onExit so the manager can drop it (L2).
 *
 * Cross-validated fixes embedded here (ARCHITECTURE §3.4 / §4.4):
 *   - M4: spawn failure THROWS — createSession does not swallow it; server.ts
 *         catches and serializes a single `exit(-1, reason)` for that connection.
 *   - M5: every ws.send is guarded by `ws.readyState === WS_OPEN`.
 *   - L1: detach-then-exit keeps the session; with no attachedWs the `exit` is
 *         not delivered now — it is re-sent on the next attach (manager's job).
 *   - L4: writeInput/resize are silently ignored once the PTY has exited
 *         (calling write/resize on a dead PTY throws).
 *
 * The session never holds a reference to the manager; the exit event bubbles
 * across that layer purely through the injected `onExit` callback.
 *
 * Imports src/types.ts (frozen, read-only) and createRingBuffer (T6).
 * node-pty's `spawn` is imported here and mocked in tests so no real shell runs.
 */

import { randomUUID } from 'node:crypto';
import { spawn } from 'node-pty';

import type {
  Config,
  Dims,
  IPty,
  Session,
  SessionMeta,
  ServerMessage,
  TimelineEvent,
  WebSocketLike,
} from '../types.js';
import { WS_OPEN } from '../types.js';
import { serialize } from '../protocol.js';
import { createRingBuffer } from './ring-buffer.js';
import { tmuxName, killSession } from './tmux.js';
import { withUtf8Locale } from './locale.js';

/** Send a server message to `ws` only if it is OPEN (M5). Never throws on a
 *  closed socket; forwarding to a dead ws is simply a no-op. */
function sendIfOpen(ws: WebSocketLike | null, msg: ServerMessage): void {
  if (ws !== null && ws.readyState === WS_OPEN) {
    ws.send(serialize(msg));
  }
}

/** Broadcast a server message to every attached client (multi-device sharing). */
export function broadcast(session: Session, msg: ServerMessage): void {
  for (const ws of session.clients) sendIfOpen(ws, msg);
}

/** Resize the PTY if the dims actually changed and it's still alive (L4). */
function applyDims(session: Session, cols: number, rows: number): void {
  if (session.exitedAt !== null) return;
  if (session.pty.cols === cols && session.pty.rows === rows) return;
  session.pty.resize(cols, rows);
}

/**
 * Spawn a PTY and wire its data/exit streams. Throws on spawn failure (M4).
 *
 * @param onExit injected by the manager — invoked after exitedAt is set so the
 *               session can be removed from the table (L2).
 */
export function createSession(
  cfg: Config,
  dims: Dims,
  now: number,
  onExit: (session: Session) => void,
  // H1: pass an existing id to RE-ATTACH to a surviving tmux session (e.g. after
  // a server restart). Default = a fresh id for a brand-new session.
  id: string = randomUUID(),
  // M6: spawn directory for a new session ("new tab here"); defaults to homeDir.
  cwd?: string,
): Session {
  // The id is injected into the shell env so Claude Code hooks know which tab an
  // event belongs to ($WEBTERM_SESSION) and where to POST ($WEBTERM_HOOK_URL, H2).
  const tName = cfg.useTmux ? tmuxName(id) : null;

  // Spawn the shell as a LOGIN shell (POSIX only) so it loads the full profile
  // chain (/etc/zprofile, ~/.zprofile, ~/.zshrc, …) and rebuilds PATH — exactly
  // like Terminal.app / iTerm. Without `-l`, a shell spawned by a GUI-launched
  // app (Finder / login-item) or a long-lived tmux keepalive server inherits a
  // minimal PATH, so nvm-managed `node` (only on the login PATH) is missing and
  // Claude Code hooks fail with "node: command not found". PowerShell (Windows)
  // has no `-l`; skip it there (Windows also forces tmux off — server-config.ts).
  const loginArgs = process.platform === 'win32' ? [] : ['-l'];

  // H1: under tmux, the node-pty process is a tmux CLIENT; `new-session -A`
  // attaches to web_<id> if it exists (restart survival) or creates it. The
  // shell-command (shellPath + login flag) is the tmux session's root process.
  const file = tName !== null ? 'tmux' : cfg.shellPath;
  const args =
    tName !== null
      ? ['new-session', '-A', '-s', tName, cfg.shellPath, ...loginArgs]
      : loginArgs;

  // M4: let a spawn failure (e.g. missing shell / tmux) propagate synchronously.
  const pty: IPty = spawn(file, args, {
    name: 'xterm-256color',
    cols: dims.cols,
    rows: dims.rows,
    cwd: cwd ?? cfg.homeDir,
    // A Finder/launchd-launched server has NO LANG/LC_* — which puts tmux in its
    // non-UTF-8 mode and turns every CJK/wide character into `_` before the bytes
    // ever reach xterm. withUtf8Locale() fills one in (and only then).
    env: {
      ...withUtf8Locale(process.env),
      WEBTERM_SESSION: id,
      WEBTERM_HOOK_URL: `http://127.0.0.1:${cfg.port}/hook`,
      // B2: statusLine scripts POST telemetry here; WEBTERM_NTFY_* vars forwarded
      // via ...process.env spread above (H3 — token set in env, not here).
      WEBTERM_STATUSLINE_URL: `http://127.0.0.1:${cfg.port}/hook/status`,
    },
  });

  const meta: SessionMeta = {
    id,
    createdAt: now,
    shellPath: cfg.shellPath,
  };

  const session: Session = {
    meta,
    buffer: createRingBuffer(cfg.scrollbackBytes),
    clients: new Set(),
    detachedAt: null,
    lastOutputAt: now,
    exitedAt: null,
    exitCode: null,
    claudeStatus: 'unknown',
    cwd: cwd ?? null,
    tmuxName: tName,
    pty,
    // v0.7 Walk-away Workbench fields (T-spawn-env):
    timeline: Object.freeze([] as TimelineEvent[]),
    stuckNotified: false,   // A5: re-armed to false by each pty output
    budgetNotified: false,   // W3(b): cost-budget one-shot latch, never re-armed
    telemetry: null,         // B2: updated by manager.handleStatusLine
    // W2: inject follow-up queue — empty at spawn; replaced wholesale by manager.
    queue: Object.freeze([] as string[]),
  };

  // onData: persist to scrollback, refresh liveness, broadcast to all clients.
  pty.onData((chunk) => {
    session.buffer.append(chunk);
    session.lastOutputAt = Date.now();
    // A5 §3.4: re-arm the stuck flag on every output so each silent round can
    // alert at most once. If we were stuck, recover to 'working' and broadcast
    // so clients clear the stuck badge immediately — this is the ONLY reliable
    // place to do this (same as lastOutputAt; T-manager never sees raw output).
    session.stuckNotified = false;
    if (session.claudeStatus === 'stuck') {
      session.claudeStatus = 'working';
      broadcast(session, { type: 'status', status: 'working' });
    }
    broadcast(session, { type: 'output', data: chunk });
  });

  // onExit: record exit, notify every client (L1 if none), then bubble up.
  pty.onExit(({ exitCode }) => {
    session.exitedAt = Date.now();
    session.exitCode = exitCode;
    broadcast(session, { type: 'exit', code: exitCode });
    onExit(session);
  });

  return session;
}

/**
 * Add `ws` as a client (multi-device sharing): clear the detached stamp, then
 * replay the scrollback to THIS client only so it sees the current screen.
 * No kicking — devices share the session (invariant #5 relaxed for v0.4).
 *
 * Attaching does NOT change the PTY size — the device actively using the
 * session keeps its size. A client only sets the size once it sends a `resize`
 * (the frontend does so when its pane is visible / regains focus).
 */
export function attachWs(session: Session, ws: WebSocketLike): void {
  session.clients.add(ws);
  session.detachedAt = null;

  // Replay the buffered scrollback so the joining client sees the last screen.
  sendIfOpen(ws, { type: 'output', data: session.buffer.snapshot() });
}

/**
 * Remove one client. NEVER kills the PTY (vibe-coding core). Only when the LAST
 * client leaves do we stamp detachedAt (starts the idle-reclaim clock); while
 * other clients remain, re-derive the shared size (a leaver may let it grow).
 */
export function detachWs(session: Session, ws: WebSocketLike, now: number): void {
  session.clients.delete(ws);
  // A client leaving does NOT resize the PTY — whatever device is still actively
  // viewing keeps its size. Start the idle clock only when the last client goes.
  if (session.clients.size === 0) {
    session.detachedAt = now;
  }
}

/** Forward input to the PTY. No-op once the PTY has exited (L4). */
export function writeInput(session: Session, data: string): void {
  if (session.exitedAt !== null) return;
  session.pty.write(data);
}

/**
 * Latest-writer-wins: the device that most recently fit/focused drives the PTY
 * size, so whichever device you are actively using is full-screen. (A shared
 * PTY can only be one size; min-sizing letterboxed the bigger screen.) The
 * frontend re-sends dims when a pane is shown or its window regains focus, so
 * switching devices reclaims full size. No-op after exit (L4) / when unchanged.
 *
 * The `ws` argument identifies the requesting client; the size is applied
 * directly to the PTY (no per-client dims map — latest writer wins).
 */
export function setClientDims(
  session: Session,
  _ws: WebSocketLike,
  cols: number,
  rows: number,
): void {
  if (session.exitedAt !== null) return;
  applyDims(session, cols, rows);
}

/**
 * Kill the session (idle reclaim). Under tmux this ends the actual shell via
 * `tmux kill-session` (H1); killing only the client pty would leave the tmux
 * session running. Always also kills the client pty.
 */
export function kill(session: Session): void {
  if (session.tmuxName !== null) killSession(session.tmuxName);
  session.pty.kill();
}
