// Cockpit subscription hook.
//
// Connects to /sessions/{id}/cockpit/ws, receives CockpitBroadcastFrame
// JSON, and reduces them into a CockpitState. On `lagged` notices the
// hook hits the snapshot endpoint to recover any missed frames before
// resuming live broadcast. Errors from sendPrompt / resolveApproval /
// cancelPrompt are surfaced via state.lastError so the user gets a
// dismissible banner instead of a silently-lost action.

import { useCallback, useEffect, useReducer, useRef, useState } from "react";
import {
  applyEvent,
  emptyCockpitState,
  isTurnActive,
  normaliseTurnCounters,
  setActivityLimit,
  type ApprovalDecision,
  type CockpitFrame,
  type CockpitState,
  type QueuedPrompt,
} from "../lib/cockpitTypes";
import { useCockpitPrefs } from "../lib/cockpitPrefs";
import { getOrCreateDeviceBindingSecret } from "../lib/deviceBinding";
import { getToken } from "../lib/token";

export type Action =
  | { kind: "frame"; frame: CockpitFrame }
  | { kind: "frames"; frames: CockpitFrame[] }
  | { kind: "lagged"; skipped: number }
  | { kind: "user_prompt"; text: string }
  | { kind: "error"; message: string }
  | { kind: "clear_error" }
  | { kind: "lagged_resolved" }
  | { kind: "reset" }
  | { kind: "hydrate"; state: CockpitState }
  | { kind: "enqueue_prompt"; text: string }
  | { kind: "dequeue_prompt"; id: string }
  | { kind: "dequeue_prompts_by_id"; ids: string[] }
  | { kind: "edit_queued_prompt"; id: string; text: string }
  | { kind: "clear_queue" }
  | { kind: "dismiss_primer" }
  | { kind: "dismiss_rejected_prompt"; id: string }
  | { kind: "dismiss_mode_switch_failed" };

// LRU-capped module cache keyed by cockpit session id. Mirrors the
// per-session CockpitState into `localStorage` under
// `aoe:cockpit-state:v1:<id>` so a full page reload (mobile OS evicts
// the tab, user pulls down a Cloudflare re-auth, PWA cold start)
// hydrates the reducer from the last-known state and only fetches
// the seq-delta from the server instead of replaying the entire
// transcript through the typewriter. See #1132.
//
// Versioned key prefix lets us invalidate stored entries when the
// reducer schema changes meaningfully (bump to `v2:`); a TTL sweep
// on first mount prunes entries older than STATE_TTL_MS so abandoned
// sessions don't squat on the per-origin quota forever.
//
// The cap prevents long-running dashboards from accumulating state
// for every cockpit session ever opened (Map.set with an existing
// key is a no-op for ordering, so we delete-then-set to refresh the
// LRU position). `clearCockpitCache(id?)` is exported so the
// session-delete handler and logout flow can drop stale entries
// instead of waiting for them to age out.
const STATE_CACHE_CAP = 32;
const STORAGE_KEY_PREFIX = "aoe:cockpit-state:v1:";
const STATE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const stateCache = new Map<string, CockpitState>();

interface PersistedEntry {
  savedAt: number;
  state: CockpitState;
}

function storageKey(sessionId: string): string {
  return STORAGE_KEY_PREFIX + sessionId;
}

function persistState(sessionId: string, state: CockpitState): void {
  if (typeof window === "undefined") return;
  try {
    const entry: PersistedEntry = { savedAt: Date.now(), state };
    window.localStorage.setItem(storageKey(sessionId), JSON.stringify(entry));
  } catch {
    // Quota exceeded, private mode, or storage disabled. The in-memory
    // cache still works; the next reload will fall back to the full
    // replay path. Drop quietly so a single oversized session can't
    // surface as a banner.
  }
}

function loadPersistedState(sessionId: string): CockpitState | undefined {
  if (typeof window === "undefined") return undefined;
  try {
    const raw = window.localStorage.getItem(storageKey(sessionId));
    if (!raw) return undefined;
    const parsed = JSON.parse(raw) as PersistedEntry | null;
    if (
      !parsed ||
      typeof parsed.savedAt !== "number" ||
      typeof parsed.state !== "object" ||
      parsed.state === null
    ) {
      return undefined;
    }
    if (Date.now() - parsed.savedAt > STATE_TTL_MS) {
      window.localStorage.removeItem(storageKey(sessionId));
      return undefined;
    }
    const state = parsed.state as Partial<CockpitState>;
    if (
      typeof state.lastSeq !== "number" ||
      !Array.isArray(state.activity) ||
      !Array.isArray(state.queuedPrompts)
    ) {
      window.localStorage.removeItem(storageKey(sessionId));
      return undefined;
    }
    // Backfill the seq-counter pair introduced by #1170 for entries
    // persisted before the schema change; see `normaliseTurnCounters`
    // for the rules.
    return normaliseTurnCounters(state as CockpitState);
  } catch {
    return undefined;
  }
}

function dropPersistedState(sessionId: string): void {
  if (typeof window === "undefined") return;
  try {
    window.localStorage.removeItem(storageKey(sessionId));
  } catch {
    // ignore
  }
}

function dropAllPersistedState(): void {
  if (typeof window === "undefined") return;
  try {
    const toRemove: string[] = [];
    for (let i = 0; i < window.localStorage.length; i++) {
      const k = window.localStorage.key(i);
      if (k && k.startsWith(STORAGE_KEY_PREFIX)) toRemove.push(k);
    }
    for (const k of toRemove) window.localStorage.removeItem(k);
  } catch {
    // ignore
  }
}

let sweptStorage = false;
function sweepExpiredStorage(): void {
  if (sweptStorage) return;
  sweptStorage = true;
  if (typeof window === "undefined") return;
  try {
    const toRemove: string[] = [];
    const now = Date.now();
    for (let i = 0; i < window.localStorage.length; i++) {
      const k = window.localStorage.key(i);
      if (!k || !k.startsWith(STORAGE_KEY_PREFIX)) continue;
      const raw = window.localStorage.getItem(k);
      if (!raw) {
        toRemove.push(k);
        continue;
      }
      try {
        const parsed = JSON.parse(raw) as PersistedEntry | null;
        if (
          !parsed ||
          typeof parsed.savedAt !== "number" ||
          now - parsed.savedAt > STATE_TTL_MS
        ) {
          toRemove.push(k);
        }
      } catch {
        toRemove.push(k);
      }
    }
    for (const k of toRemove) window.localStorage.removeItem(k);
  } catch {
    // ignore
  }
}

function cacheGet(sessionId: string): CockpitState | undefined {
  const value = stateCache.get(sessionId);
  if (value !== undefined) {
    // Touch the LRU position by re-inserting at the back of the Map's
    // insertion order.
    stateCache.delete(sessionId);
    stateCache.set(sessionId, value);
    return value;
  }
  const persisted = loadPersistedState(sessionId);
  if (persisted !== undefined) {
    stateCache.set(sessionId, persisted);
    while (stateCache.size > STATE_CACHE_CAP) {
      const oldest = stateCache.keys().next().value;
      if (oldest === undefined) break;
      stateCache.delete(oldest);
    }
    return persisted;
  }
  return undefined;
}

function cacheSet(sessionId: string, value: CockpitState): void {
  stateCache.delete(sessionId);
  stateCache.set(sessionId, value);
  while (stateCache.size > STATE_CACHE_CAP) {
    const oldest = stateCache.keys().next().value;
    if (oldest === undefined) break;
    stateCache.delete(oldest);
  }
  persistState(sessionId, value);
}

/** Drop a session's cached state (or the entire cache when called
 *  with no argument). Call from the session-delete handler so the
 *  next session created with the same id doesn't briefly show the
 *  prior transcript on remount. */
/** How far back of a seq overlap `fetchReplay` requests on every call.
 *  Catches events that landed in the broadcast tail without being
 *  applied by the reducer (e.g. WS connect drain races against the
 *  REST replay call). The reducer's `frame.seq <= state.lastSeq`
 *  dedupe makes the overlap idempotent. See #1100. */
const REPLAY_OVERLAP = 50;

export function clearCockpitCache(sessionId?: string): void {
  if (sessionId === undefined) {
    stateCache.clear();
    dropAllPersistedState();
  } else {
    stateCache.delete(sessionId);
    dropPersistedState(sessionId);
  }
}

function initialState(sessionId: string | null): CockpitState {
  if (!sessionId) return emptyCockpitState();
  return cacheGet(sessionId) ?? emptyCockpitState();
}

export function cockpitHookReducer(
  state: CockpitState,
  action: Action,
): CockpitState {
  return reducer(state, action);
}

/** Build the single combined prompt fired when
 *  `cockpit.queue_drain_mode = combined` and the agent transitions to
 *  idle with a non-empty queue. Joins every queued entry's text with a
 *  blank line so the agent sees them as one batch follow-up. Extracted
 *  for testability; consumed by the drain effect below. See #1031. */
export function combineQueuedPrompts(
  queue: ReadonlyArray<QueuedPrompt>,
): string {
  return queue.map((q) => q.text).join("\n\n");
}

function reducer(state: CockpitState, action: Action): CockpitState {
  if (action.kind === "frame") {
    return applyEvent(state, action.frame);
  }
  if (action.kind === "frames") {
    return action.frames.reduce(applyEvent, state);
  }
  if (action.kind === "lagged") {
    return { ...state, lagged: true };
  }
  if (action.kind === "lagged_resolved") {
    return { ...state, lagged: false };
  }
  if (action.kind === "error") {
    return { ...state, lastError: action.message };
  }
  if (action.kind === "clear_error") {
    return { ...state, lastError: null };
  }
  if (action.kind === "hydrate") {
    return action.state;
  }
  if (action.kind === "user_prompt") {
    // Bump `pendingUserPromptSeq` rather than touching `turnActive`
    // directly. `turnActive` derives from `pendingUserPromptSeq >
    // lastStoppedSeq`; the derived alias is recomputed here so any
    // existing `state.turnActive` reads stay consistent without a
    // selector hop. Without this the late `Stopped` from the prior
    // turn could clobber the spinner mid follow-up. See #1170.
    const pendingUserPromptSeq = state.pendingUserPromptSeq + 1;
    return {
      ...state,
      activity: state.activity.concat({
        id: `user-${Date.now()}-${state.activity.length}`,
        kind: "user_prompt",
        text: action.text,
        at: new Date().toISOString(),
      }),
      assistantMessage: "",
      // A fresh prompt clears stale errors: the user has indicated
      // they're trying again, so don't keep nagging them.
      startupError: null,
      lastError: null,
      pendingUserPromptSeq,
      turnActive: isTurnActive({
        pendingUserPromptSeq,
        lastStoppedSeq: state.lastStoppedSeq,
      }),
    };
  }
  if (action.kind === "enqueue_prompt") {
    const entry: QueuedPrompt = {
      id: `q-${Date.now()}-${state.queuedPrompts.length}`,
      text: action.text,
      queuedAt: new Date().toISOString(),
    };
    return { ...state, queuedPrompts: state.queuedPrompts.concat(entry) };
  }
  if (action.kind === "dequeue_prompt") {
    return {
      ...state,
      queuedPrompts: state.queuedPrompts.filter((q) => q.id !== action.id),
    };
  }
  if (action.kind === "dequeue_prompts_by_id") {
    if (action.ids.length === 0) return state;
    const drop = new Set(action.ids);
    return {
      ...state,
      queuedPrompts: state.queuedPrompts.filter((q) => !drop.has(q.id)),
    };
  }
  if (action.kind === "edit_queued_prompt") {
    return {
      ...state,
      queuedPrompts: state.queuedPrompts.map((q) =>
        q.id === action.id ? { ...q, text: action.text } : q,
      ),
    };
  }
  if (action.kind === "clear_queue") {
    return { ...state, queuedPrompts: [] };
  }
  if (action.kind === "dismiss_primer") {
    // Clear the offer entirely so it doesn't re-render on session
    // re-mount. A subsequent SessionContextReset re-seeds the field
    // with a new `resetSeq`, which the banner reads as a fresh
    // incident and shows again. See #1110.
    return { ...state, contextPrimerAvailable: null };
  }
  if (action.kind === "dismiss_rejected_prompt") {
    return {
      ...state,
      rejectedPrompts: state.rejectedPrompts.filter(
        (r) => r.id !== action.id,
      ),
    };
  }
  if (action.kind === "dismiss_mode_switch_failed") {
    return { ...state, modeSwitchFailed: null };
  }
  return emptyCockpitState();
}

export type ConnectionStatus =
  | "connecting"
  | "open"
  | "closed"
  | "error";

/** Reconnect backoff: 1s, 2s, 4s, 8s, 16s, 30s, 30s (cap). Seven
 *  attempts cover the common mobile-background / Cloudflare-idle /
 *  WiFi-flap recovery shapes without flooding the daemon when the
 *  backend is genuinely down. After the cap, the UI surfaces a manual
 *  "Tap to retry" affordance via `manualReconnect`. Mirrors the
 *  retry envelope already used by `useTerminal` (#1009 / #1107). */
const COCKPIT_MAX_RETRIES = 7;
const COCKPIT_RETRY_BASE_MS = 1000;
const COCKPIT_RETRY_CAP_MS = 30000;
export function cockpitRetryDelayMs(attempt: number): number {
  return Math.min(
    COCKPIT_RETRY_CAP_MS,
    COCKPIT_RETRY_BASE_MS * 2 ** Math.max(0, attempt - 1),
  );
}
export const COCKPIT_MAX_RETRIES_EXPORT = COCKPIT_MAX_RETRIES;

export function useCockpit(
  sessionId: string | null,
  /** Live cockpit worker lifecycle from `SessionResponse.cockpit_worker_state`.
   *  When not `"running"`, the drain effect parks queued prompts so they
   *  don't dispatch into a worker that isn't online yet. Defaults to
   *  `"running"` so non-cockpit / pre-#1088 call sites keep working. */
  workerState: "absent" | "resuming" | "running" = "running",
) {
  // Sweep stale persisted state entries on first hook mount in this
  // module's lifetime. Idempotent (guarded by `sweptStorage`) so the
  // cost is one full localStorage scan per page load.
  sweepExpiredStorage();
  const [state, dispatch] = useReducer(reducer, sessionId, initialState);
  const [status, setStatus] = useState<ConnectionStatus>("connecting");
  // Mirror the worker state into a ref so the drain effect always sees
  // the latest value without re-running on every poll.
  const workerStateRef = useRef(workerState);
  useEffect(() => {
    workerStateRef.current = workerState;
  }, [workerState]);
  // Drain mode is sourced from the daemon's resolved `[cockpit]` config
  // and republished via `CockpitPrefsProvider` (App.tsx). Held in a ref
  // so the drain effect's pop logic always sees the latest value
  // without re-running the effect on every toggle. See #1031.
  const { queueDrainMode, replayEvents } = useCockpitPrefs();
  const drainModeRef = useRef(queueDrainMode);
  useEffect(() => {
    drainModeRef.current = queueDrainMode;
  }, [queueDrainMode]);
  // Mirror the server-side retention cap onto the reducer's
  // in-memory activity buffer. Without this, a frontend-only 200-row
  // cap clipped the rendered transcript regardless of what the user
  // set on the server side (#1111). 0 means unlimited.
  useEffect(() => {
    setActivityLimit(replayEvents);
  }, [replayEvents]);
  // Mirror status into a ref so sendPrompt's stable callback can short
  // circuit when the WS is closed without re-creating the callback on
  // every status flip (which would invalidate downstream memoised
  // handlers).
  const statusRef = useRef<ConnectionStatus>("connecting");
  useEffect(() => {
    statusRef.current = status;
  }, [status]);

  // Mirror every state change into the module-level cache so that on
  // remount (e.g. user navigates back to the cockpit tab) we hydrate
  // from the last-known state instead of staring at an empty chat
  // until the WS connection completes.
  useEffect(() => {
    if (sessionId) cacheSet(sessionId, state);
  }, [sessionId, state]);
  const wsRef = useRef<WebSocket | null>(null);
  // Auto-reconnect machinery (#1130). retryCountRef is the persistent
  // attempt counter across `onclose` -> scheduled `connect()` cycles;
  // retryTimerRef holds the pending setTimeout so manualReconnect can
  // cancel a backed-off retry without leaking it. countdownTimerRef
  // drives the per-second `retryCountdown` decrement that the banner
  // renders. connectRef is the stable indirection so listeners
  // installed outside the connection effect (visibilitychange, online,
  // pageshow) can dial without re-creating the listeners.
  //
  // dialGenRef is a monotonic generation counter. Every connect() call
  // bumps it; each in-flight IIFE captures its generation at entry and
  // bails (or no-ops in its WS handlers) once the current generation
  // moves past it. Without this, a visibilitychange / manualReconnect
  // that fires while a prior IIFE is mid-`await fetchReplay` allocates
  // a second WS, and the orphaned first WS's onclose still nulls
  // wsRef.current and schedules a retry on top of a healthy socket.
  const retryCountRef = useRef(0);
  const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const countdownTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
  const connectRef = useRef<(() => void) | null>(null);
  const dialGenRef = useRef(0);
  const [reconnecting, setReconnecting] = useState(false);
  const [retryCount, setRetryCount] = useState(0);
  const [retryCountdown, setRetryCountdown] = useState(0);
  // Track lastSeq in a ref so the snapshot fetcher always sees the
  // latest value without re-running the effect when it changes.
  // The ref is updated inside an effect (not during render) to keep
  // the react-hooks linter happy; fetchReplay only ever runs from
  // an event handler or another effect, so the one-tick lag is fine.
  const lastSeqRef = useRef(0);
  useEffect(() => {
    lastSeqRef.current = state.lastSeq;
  }, [state.lastSeq]);
  // Flips true the first time the WS opens for this session and
  // resets on session change. Lets the SystemNotices banner copy
  // distinguish "first connect, worker still spawning" from
  // "reconnecting after a real drop". The prior wording was misleading
  // on brand-new sessions; see #1106.
  const [hasEverOpened, setHasEverOpened] = useState(false);
  useEffect(() => {
    setHasEverOpened(false);
  }, [sessionId]);

  // Timestamp (ms) of the most recent applied frame. Read by the
  // "Force end turn" escape hatch in WorkingSpinner: when `turnActive`
  // is true and `Date.now() - lastActivity` exceeds the configured
  // threshold, the spinner offers the button. Kept as a ref (not
  // reducer state) so updating it on every frame doesn't trigger a
  // rerender; the spinner polls the ref on its own 1s timer. See
  // #1100 (C).
  // Initialised to 0; bumped to a real timestamp on first applied
  // frame or first user submit. Date.now() at render time would trip
  // react-hooks/purity (renders must be deterministic), and the zero
  // sentinel does the right thing on first read since
  // `Date.now() - 0` is enormous and the spinner only checks against
  // it while `turnActive` is true (false on a freshly-mounted hook).
  const lastActivityRef = useRef<number>(0);

  const fetchReplay = useCallback(
    async (sid: string) => {
      try {
        // Defensive overlap: re-fetch from `lastSeq - REPLAY_OVERLAP`
        // instead of `lastSeq` so events that landed in the broadcast
        // tail without being applied (WS-vs-replay race, broadcast lag
        // window, etc.) get a second chance. The reducer's
        // `frame.seq <= state.lastSeq` dedupe drops the overlap, so
        // this is idempotent. See #1100.
        const since = Math.max(0, lastSeqRef.current - REPLAY_OVERLAP);
        const res = await fetch(
          `/api/sessions/${encodeURIComponent(sid)}/cockpit/replay?since=${since}`,
          { credentials: "same-origin" },
        );
        if (!res.ok) return;
        const data = (await res.json()) as {
          frames: CockpitFrame[];
          lost: boolean;
          highest_seq: number;
        };
        // Detect a server-side seq reset: the supervisor's per-session
        // counter has been forgotten (cockpit_disable → cockpit_enable,
        // or session delete+recreate with the same id), so the new
        // conversation is starting fresh from seq=1. Without this reset
        // the client-side dedupe would drop the new events because
        // `frame.seq <= state.lastSeq` is true.
        if (data.highest_seq < since) {
          dispatch({ kind: "reset" });
        }
        if (data.lost) {
          // The buffer doesn't go back far enough; surface this via
          // the existing `lagged` flag (the UI shows a "history
          // truncated" notice) and let the user reload if they want
          // the full transcript back.
          dispatch({ kind: "lagged", skipped: data.highest_seq });
          return;
        }
        if (data.frames.length > 0) {
          dispatch({ kind: "frames", frames: data.frames });
        }
        dispatch({ kind: "lagged_resolved" });
      } catch {
        // Network failure: leave the lagged flag set so the user
        // sees something is wrong rather than silently dropping
        // frames.
      }
    },
    [],
  );

  useEffect(() => {
    if (!sessionId) {
      statusRef.current = "closed";
      setStatus("closed");
      return;
    }
    // Hydrate the reducer from the per-session cache rather than
    // resetting to empty. fetchReplay will then top up anything that
    // happened on the server while this component was unmounted using
    // the cached lastSeq as the `since` cursor.
    dispatch({
      kind: "hydrate",
      state: cacheGet(sessionId) ?? emptyCockpitState(),
    });
    statusRef.current = "connecting";
    setStatus("connecting");
    retryCountRef.current = 0;
    setReconnecting(false);
    setRetryCount(0);
    setRetryCountdown(0);

    // Set up cancellation so the cleanup function can stop a pending
    // open if the effect re-runs (sessionId change) before the WS dial
    // completed. Without this, a fast session-switch could leak a WS
    // that fires onmessage into a now-stale reducer.
    let cancelled = false;

    const clearRetryTimers = () => {
      if (retryTimerRef.current) {
        clearTimeout(retryTimerRef.current);
        retryTimerRef.current = null;
      }
      if (countdownTimerRef.current) {
        clearInterval(countdownTimerRef.current);
        countdownTimerRef.current = null;
      }
    };

    const scheduleReconnect = () => {
      if (cancelled) return;
      if (retryCountRef.current >= COCKPIT_MAX_RETRIES) {
        setReconnecting(false);
        setRetryCount(retryCountRef.current);
        setRetryCountdown(0);
        return;
      }
      retryCountRef.current += 1;
      const attempt = retryCountRef.current;
      const delayMs = cockpitRetryDelayMs(attempt);
      let countdown = Math.ceil(delayMs / 1000);
      setReconnecting(true);
      setRetryCount(attempt);
      setRetryCountdown(countdown);
      clearRetryTimers();
      countdownTimerRef.current = setInterval(() => {
        countdown -= 1;
        if (countdown > 0) setRetryCountdown(countdown);
      }, 1000);
      retryTimerRef.current = setTimeout(() => {
        if (countdownTimerRef.current) {
          clearInterval(countdownTimerRef.current);
          countdownTimerRef.current = null;
        }
        connectRef.current?.();
      }, delayMs);
    };

    const connect = () => {
      if (cancelled) return;
      // Cancel any pending scheduled retry; a fresh dial supersedes it.
      clearRetryTimers();
      // Close any prior socket synchronously so its handlers fire (and
      // get filtered out by the generation check below) before the new
      // dial starts. Without this, the orphan's `onclose` can land
      // after the new WS opens and re-arm scheduleReconnect on top of
      // a healthy connection.
      if (wsRef.current) {
        try {
          wsRef.current.close();
        } catch {
          // ignore
        }
        wsRef.current = null;
      }
      dialGenRef.current += 1;
      const myGen = dialGenRef.current;
      const isCurrentDial = () => !cancelled && dialGenRef.current === myGen;
      statusRef.current = "connecting";
      setStatus("connecting");
      void (async () => {
        // Order: replay first, then open WS. Today the server's WS
        // on-connect drain and the REST replay endpoint read the same
        // disk store; awaiting the replay before the dial gives the
        // reducer a known-correct `lastSeq` so the WS subscribes from a
        // settled cursor instead of racing two delivery paths. Without
        // this, an event landing during the dial window could be
        // delivered by both paths in different orders, and the dedupe
        // would drop later applies, which is exactly the "Stopped never
        // reaches the reducer" failure mode in #1100.
        await fetchReplay(sessionId);
        if (!isCurrentDial()) return;

        const token = getToken();
        const protocol =
          window.location.protocol === "https:" ? "wss" : "ws";
        // Pass `?since=<lastSeq>` so the server's on-connect drain only
        // resends events newer than what we already have. Without this,
        // a long-running session resends its full transcript on every
        // reconnect (page refresh / mobile flap), which can be tens of
        // MB at the retention cap.
        const since = lastSeqRef.current;
        const url = `${protocol}://${window.location.host}/sessions/${encodeURIComponent(sessionId)}/cockpit/ws?since=${since}`;

        // Subprotocols carry both factors on a WS upgrade:
        //   - `aoe-auth` is the legacy signalling protocol the server
        //     expects to see.
        //   - the bare `<token>` is the first-factor auth token
        //     (kept for backward compatibility with PWA tabs that
        //     loaded before the prefixed format landed).
        //   - `aoe-device.<binding-secret>` is the device-binding
        //     second factor introduced in #1131. The middleware
        //     enforces this when passphrase login is configured.
        let bindingSecret: string | null = null;
        try {
          bindingSecret = getOrCreateDeviceBindingSecret();
        } catch {
          // Storage / crypto unavailable; the server will reject this
          // upgrade with 401 and the login page will surface the cause.
        }
        const protocols: string[] = ["aoe-auth"];
        if (token) protocols.push(token);
        if (bindingSecret) protocols.push(`aoe-device.${bindingSecret}`);
        const ws = new WebSocket(url, protocols);
        wsRef.current = ws;

        // Set the ref synchronously alongside setState so sendPrompt's
        // gate (which reads the ref) doesn't race the next render.
        // Without this, a click landing in the same event-loop tick as
        // `onclose` could see statusRef.current === "open" and dispatch
        // an optimistic prompt against a closed socket.
        //
        // Every handler additionally checks `isCurrentDial()`: an
        // orphaned WS from a superseded connect() must not flip status,
        // null wsRef.current, or schedule a retry on top of the new
        // healthy socket.
        ws.onopen = () => {
          if (!isCurrentDial()) {
            try {
              ws.close();
            } catch {
              // ignore
            }
            return;
          }
          statusRef.current = "open";
          setStatus("open");
          setHasEverOpened(true);
          // A live socket is the right moment to reset the retry
          // envelope: a future close from here is a genuinely new
          // failure, not a continuation of the prior backoff chain.
          retryCountRef.current = 0;
          setReconnecting(false);
          setRetryCount(0);
          setRetryCountdown(0);
        };
        ws.onerror = () => {
          if (!isCurrentDial()) return;
          statusRef.current = "error";
          setStatus("error");
        };
        ws.onclose = () => {
          if (!isCurrentDial()) return;
          statusRef.current = "closed";
          setStatus("closed");
          wsRef.current = null;
          scheduleReconnect();
        };
        ws.onmessage = (ev) => {
          if (!isCurrentDial()) return;
          try {
            const data = JSON.parse(ev.data) as
              | CockpitFrame
              | { kind: "lagged"; skipped?: number };
            if (
              typeof data === "object" &&
              data !== null &&
              "kind" in data &&
              (data as { kind?: unknown }).kind === "lagged"
            ) {
              const skipped =
                ((data as unknown) as { skipped?: number }).skipped ?? 0;
              dispatch({ kind: "lagged", skipped });
              // Try to recover via the snapshot endpoint.
              fetchReplay(sessionId);
              return;
            }
            if (
              typeof data === "object" &&
              data !== null &&
              "session_id" in data &&
              "event" in data
            ) {
              // Every incoming live frame is an "activity" tick for the
              // force-end-turn watchdog: as long as the agent is
              // streaming, the spinner stays "honest" and the escape
              // hatch doesn't appear. See WorkingSpinner in CockpitView.
              lastActivityRef.current = Date.now();
              dispatch({ kind: "frame", frame: data as CockpitFrame });
            }
          } catch {
            // Ignore malformed frames; the server should never send them.
          }
        };
      })();
    };
    connectRef.current = connect;

    // Trigger an immediate reconnect when the tab returns to the
    // foreground / the OS rejoins the network / bfcache restores the
    // page. Mobile Chrome / Safari close idle WSs in the background
    // (~30-60s), and Cloudflare's tunnel kills them at 100s; the
    // standard recovery signal is the visibility event firing on
    // foreground. iOS Safari batches these so `pageshow` is the
    // backup. See #1130.
    const tryAutoReconnect = () => {
      const ws = wsRef.current;
      const ready = ws?.readyState;
      if (
        ready === WebSocket.OPEN ||
        ready === WebSocket.CONNECTING
      ) {
        return;
      }
      retryCountRef.current = 0;
      setRetryCount(0);
      setRetryCountdown(0);
      clearRetryTimers();
      connectRef.current?.();
    };
    const onVisibility = () => {
      if (document.visibilityState === "visible") tryAutoReconnect();
    };
    const onOnline = () => tryAutoReconnect();
    const onPageShow = () => tryAutoReconnect();
    document.addEventListener("visibilitychange", onVisibility);
    window.addEventListener("online", onOnline);
    window.addEventListener("pageshow", onPageShow);

    connect();

    return () => {
      cancelled = true;
      // Bump the generation so any in-flight IIFE / pending WS handlers
      // from this effect's lifetime see themselves as stale.
      dialGenRef.current += 1;
      clearRetryTimers();
      const ws = wsRef.current;
      if (ws) {
        try {
          ws.close();
        } catch {
          // ignore
        }
      }
      wsRef.current = null;
      connectRef.current = null;
      document.removeEventListener("visibilitychange", onVisibility);
      window.removeEventListener("online", onOnline);
      window.removeEventListener("pageshow", onPageShow);
    };
  }, [sessionId, fetchReplay]);

  const resolveApproval = useCallback(
    async (nonce: string, decision: ApprovalDecision) => {
      if (!sessionId) return;
      try {
        const res = await fetch(
          `/api/sessions/${encodeURIComponent(sessionId)}/cockpit/approvals/${encodeURIComponent(nonce)}`,
          {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ decision }),
          },
        );
        if (!res.ok) {
          const detail = await safeText(res);
          dispatch({
            kind: "error",
            message: `Could not resolve approval (${res.status}). ${detail}`.trim(),
          });
        } else {
          dispatch({ kind: "clear_error" });
        }
      } catch (e) {
        dispatch({
          kind: "error",
          message: `Network error resolving approval: ${describeError(e)}`,
        });
      }
    },
    [sessionId],
  );

  // Dispatch a prompt immediately, no queueing. Internal helper used by
  // both sendPrompt (when the turn is idle) and the drain effect below
  // (when popping the head of queuedPrompts on Stopped). Returns true
  // when the POST succeeded so the drain effect knows whether to retire
  // the items it just sent; false on any disconnect / non-OK response /
  // network error so the queue stays intact for the next turn-end retry.
  const dispatchPromptNow = useCallback(
    async (text: string): Promise<boolean> => {
      if (!sessionId) return false;
      if (statusRef.current !== "open") {
        dispatch({
          kind: "error",
          message: "Cockpit disconnected; message not sent. Reconnect to retry.",
        });
        return false;
      }
      // Optimistically echo the user's message; the agent reply
      // streams back as session/update events on the WS. If the POST
      // fails we'll surface a banner and the user can retry; the
      // optimistic row stays so they see what they tried to send.
      dispatch({ kind: "user_prompt", text });
      // Submit counts as activity so the force-end-turn watchdog
      // doesn't surface the escape hatch immediately on a fresh prompt
      // (the agent's first chunk can be a few seconds out).
      lastActivityRef.current = Date.now();
      try {
        const res = await fetch(
          `/api/sessions/${encodeURIComponent(sessionId)}/cockpit/prompt`,
          {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ text }),
          },
        );
        if (!res.ok) {
          const detail = await safeText(res);
          dispatch({
            kind: "error",
            message: `Could not send prompt (${res.status}). ${detail}`.trim(),
          });
          return false;
        }
        return true;
      } catch (e) {
        dispatch({
          kind: "error",
          message: `Network error sending prompt: ${describeError(e)}`,
        });
        return false;
      }
    },
    [sessionId],
  );

  // Public sendPrompt. When the agent is already working we enqueue
  // client-side; the drain effect dispatches the head once the current
  // turn ends. When idle we dispatch immediately. Background: #1031.
  const sendPrompt = useCallback(
    async (text: string) => {
      if (!sessionId) return;
      // The reducer sees the same `turnActive` the UI does. statusRef
      // covers the WS-closed case separately so disconnected users
      // still get the existing error banner even when typing into a
      // running session.
      if (statusRef.current !== "open") {
        dispatch({
          kind: "error",
          message: "Cockpit disconnected; message not sent. Reconnect to retry.",
        });
        return;
      }
      if (state.turnActive) {
        dispatch({ kind: "enqueue_prompt", text });
        return;
      }
      // Worker still resuming (e.g. cold-start parallel reconciler
      // hasn't reached this session yet). Queue locally rather than
      // POSTing into a worker that isn't online; the drain effect
      // fires once `workerState` transitions to `"running"`. See #1088.
      if (workerStateRef.current !== "running") {
        dispatch({ kind: "enqueue_prompt", text });
        return;
      }
      await dispatchPromptNow(text);
    },
    [sessionId, state.turnActive, dispatchPromptNow],
  );

  // Drain effect: when the agent transitions to idle and the queue is
  // non-empty, dispatch follow-ups per `cockpit.queue_drain_mode`:
  //   - combined (default): join every queued entry with `\n\n` and
  //     fire one prompt; the agent's single response covers the batch.
  //   - serial: pop the head only; the next Stopped re-runs this effect
  //     and fires the following entry. One response per entry.
  // Guarded by `drainingRef` so a re-render between the dequeue
  // dispatch and the next state tick doesn't fire the same head twice.
  // Skipped while a worker-stopped / restarting banner is showing; a
  // fresh `AcpSessionAssigned` (which clears both flags) re-runs this
  // effect and drains then. See #1031.
  const drainingRef = useRef(false);
  useEffect(() => {
    if (drainingRef.current) return;
    if (!sessionId) return;
    if (state.turnActive) return;
    if (state.workerStopped || state.workerRestarting) return;
    // Worker still mid-resume from a daemon cold start (or it never
    // came online). Park queued prompts so they don't POST into a
    // worker that's not online yet; the next REST poll flips
    // workerState to "running" and re-runs this effect. See #1088.
    if (workerStateRef.current !== "running") return;
    // Reconnect race: connect() awaits fetchReplay BEFORE opening the
    // WS, and replay can dispatch a Stopped frame that flips turnActive
    // off. If we drain here while the WS is still in "connecting",
    // dispatchPromptNow will bail (statusRef !== "open"), surface an
    // error banner, and (under the prior optimistic-clear ordering)
    // permanently drop the queued items. Park the drain until status
    // flips to "open"; the `status` value is in the dep array below
    // so this effect re-runs on transition. See #1144.
    if (statusRef.current !== "open") return;
    if (state.queuedPrompts.length === 0) return;
    drainingRef.current = true;
    if (drainModeRef.current === "combined") {
      // Snapshot the ids we're about to send. The user can enqueue MORE
      // prompts during the await; on success we only clear the items in
      // the snapshot so newly-typed entries survive into the next turn.
      // On failure (POST non-OK / network blip / WS dropped mid-send)
      // we leave the queue untouched so the next Stopped retries.
      const snapshot = state.queuedPrompts.slice();
      const combined = combineQueuedPrompts(snapshot);
      const sentIds = snapshot.map((q) => q.id);
      void dispatchPromptNow(combined)
        .then((ok) => {
          if (ok) dispatch({ kind: "dequeue_prompts_by_id", ids: sentIds });
        })
        .finally(() => {
          drainingRef.current = false;
        });
    } else {
      const head = state.queuedPrompts[0]!;
      const headId = head.id;
      void dispatchPromptNow(head.text)
        .then((ok) => {
          if (ok) dispatch({ kind: "dequeue_prompt", id: headId });
        })
        .finally(() => {
          drainingRef.current = false;
        });
    }
  }, [
    sessionId,
    status,
    workerState,
    state.turnActive,
    state.workerStopped,
    state.workerRestarting,
    state.queuedPrompts,
    dispatchPromptNow,
  ]);

  const removeQueuedPrompt = useCallback((id: string) => {
    dispatch({ kind: "dequeue_prompt", id });
  }, []);

  const editQueuedPrompt = useCallback((id: string, text: string) => {
    dispatch({ kind: "edit_queued_prompt", id, text });
  }, []);

  const clearQueue = useCallback(() => {
    dispatch({ kind: "clear_queue" });
  }, []);

  const dismissPrimer = useCallback(() => {
    dispatch({ kind: "dismiss_primer" });
  }, []);

  const dismissRejectedPrompt = useCallback((id: string) => {
    dispatch({ kind: "dismiss_rejected_prompt", id });
  }, []);

  const dismissModeSwitchFailed = useCallback(() => {
    dispatch({ kind: "dismiss_mode_switch_failed" });
  }, []);

  // Cancels the in-flight agent turn (ACP session/cancel). Must only
  // fire on an explicit user gesture against a dedicated cancel/stop
  // affordance; never bind this to the Escape key. Claude Code CLI
  // hijacks Escape for cancel and accidental presses lose work the
  // user did not mean to abort; the cockpit deliberately keeps Escape
  // for closing local UI surfaces (palette, dialogs, popovers) only.
  // If a future Escape binding is added, route it through
  // useKeyboardShortcuts.onEscape's local-UI dismissal, not here.
  const cancelPrompt = useCallback(async () => {
    if (!sessionId) return;
    try {
      const res = await fetch(
        `/api/sessions/${encodeURIComponent(sessionId)}/cockpit/cancel`,
        { method: "POST" },
      );
      if (!res.ok) {
        const detail = await safeText(res);
        dispatch({
          kind: "error",
          message: `Could not cancel (${res.status}). ${detail}`.trim(),
        });
      }
    } catch (e) {
      dispatch({
        kind: "error",
        message: `Network error cancelling: ${describeError(e)}`,
      });
    }
  }, [sessionId]);

  // Escape hatch for the "spinner stuck" failure mode (#1100). Locally
  // dispatches a synthetic Stopped so the reducer flips `turnActive`
  // off immediately, then POSTs to the daemon to publish a Stopped
  // server-side and best-effort cancel any in-flight turn. The local
  // dispatch is optimistic; the server echo (which lands as a real
  // frame on the WS) is idempotent against the reducer.
  const forceEndTurn = useCallback(async () => {
    if (!sessionId) return;
    dispatch({
      kind: "frame",
      frame: {
        session_id: sessionId,
        seq: lastSeqRef.current + 1,
        event: { Stopped: { reason: "user_forced" } },
      },
    });
    lastActivityRef.current = Date.now();
    try {
      const res = await fetch(
        `/api/sessions/${encodeURIComponent(sessionId)}/cockpit/force_end_turn`,
        { method: "POST" },
      );
      if (!res.ok) {
        const detail = await safeText(res);
        dispatch({
          kind: "error",
          message:
            `Could not force end turn (${res.status}). ${detail}`.trim(),
        });
      }
    } catch (e) {
      dispatch({
        kind: "error",
        message: `Network error forcing end turn: ${describeError(e)}`,
      });
    }
  }, [sessionId]);

  const dismissError = useCallback(() => {
    dispatch({ kind: "clear_error" });
  }, []);

  // Public manual-reconnect affordance. Surfaces in the SystemNotices
  // banner once the auto-retry envelope is exhausted; resets the
  // backoff counter and dials a fresh WS immediately. Idempotent
  // against a live socket (the reconnect path checks readyState).
  const manualReconnect = useCallback(() => {
    if (retryTimerRef.current) {
      clearTimeout(retryTimerRef.current);
      retryTimerRef.current = null;
    }
    if (countdownTimerRef.current) {
      clearInterval(countdownTimerRef.current);
      countdownTimerRef.current = null;
    }
    retryCountRef.current = 0;
    setRetryCount(0);
    setRetryCountdown(0);
    setReconnecting(false);
    connectRef.current?.();
  }, []);

  return {
    state,
    status,
    /** True between an `onclose` and the next successful dial / failure
     *  to exhaust the retry envelope. Drives the banner's "Reconnecting
     *  (N/MAX) in Xs" copy. See #1130. */
    reconnecting,
    /** Current attempt number; 0 while the live socket is healthy,
     *  1..MAX while backing off. */
    retryCount,
    /** Seconds remaining before the next scheduled retry fires. The
     *  banner reads this on each render to animate the countdown. */
    retryCountdown,
    /** Maximum retries before falling back to the manual reconnect
     *  affordance. Exposed so the banner can render "N/MAX" without
     *  re-importing the constant. */
    maxRetries: COCKPIT_MAX_RETRIES,
    /** User-triggered reconnect. Resets the retry counter and dials a
     *  fresh socket immediately. */
    manualReconnect,
    /** True once the WS has reached `onopen` at least once for the
     *  current session. Lets banner copy distinguish "first dial
     *  while the worker spawns" (no prior connection to recover) from
     *  "we lost a live connection and are retrying" (cached
     *  transcript and the recovery framing are honest). See #1106. */
    hasEverOpened,
    resolveApproval,
    sendPrompt,
    cancelPrompt,
    forceEndTurn,
    /** Timestamp (ms) of the most recent applied frame. The
     *  WorkingSpinner reads this on a 1s timer to decide whether to
     *  surface the "Force end turn" button. Exposed as a ref so the
     *  hook doesn't rerender every frame just to update a watchdog
     *  clock. See #1100 (C). */
    lastActivityRef,
    dismissError,
    dismissPrimer,
    removeQueuedPrompt,
    editQueuedPrompt,
    clearQueue,
    dismissRejectedPrompt,
    dismissModeSwitchFailed,
  };
}

async function safeText(res: Response): Promise<string> {
  try {
    return (await res.text()).slice(0, 200);
  } catch {
    return "";
  }
}

function describeError(e: unknown): string {
  if (e instanceof Error) return e.message;
  return String(e);
}
