import { useCallback, useEffect, useRef, useState, type DragEvent } from "react";
import { toast } from 'sonner';
import { api } from '@/api/client';
import type {
  ArenaState,
  ClosedPaneEntry,
  OrchestratorState,
  WorkspacePane,
  WorkspaceState,
  WorkspaceTabKind,
} from '@/api/types';
import { setActivePaneInfo, clearActivePane } from '@/hooks/useActivePane';
import { sessionEvents } from '@/hooks/sessionEvents';

import {
  activePaneChatId,
  appendClosed,
  arenaPane,
  chatNameForPaneKind,
  chatPane,
  emptyPane,
  filterTabs,
  generateId,
  generateTermTabId,
  htmlArtifactPane,
  LEGACY_STORAGE_KEY,
  markdownArtifactPane,
  nonSettingsCount,
  normalizePanes,
  orchestratorPane,
  paneTabKinds,
  persistablePanes,
  readLegacyPanes,
  rebuildPane,
  SAVE_DEBOUNCE_MS,
  settingsPane,
  toWorkspaceState,
} from './workspace-pane-ops.js';

export { activePaneChatId };
export const MAX_PANES = 5;

export interface UseWorkspacePanesResult {
  panes: WorkspacePane[];
  // v2.6.x: stable session-scoped tab number per chat id (Batch 3a). Keyed by
  // chat.id, NEVER by tab position.
  tabNumbers: Record<string, number>;
  activePaneIdx: number;
  setActivePaneIdx: React.Dispatch<React.SetStateAction<number>>;
  activePaneIdxRef: React.MutableRefObject<number>;
  openChatInPane: (paneIdx: number, chatId: string) => void;
  switchTab: (paneIdx: number, tabIdx: number) => void;
  removeTab: (paneIdx: number, chatId: string) => void;
  closeOtherTabs: (paneIdx: number, keepChatId: string) => void;
  closeTabsToRight: (paneIdx: number, pivotChatId: string) => void;
  closeAllTabs: (paneIdx: number) => void;
  showLandingPage: (paneIdx: number) => void;
  // Session-history view: which pane (by id) should render its landing in the
  // history list instead of the new-chat hero. Shared so the mobile header
  // button and the desktop pane-header menu drive the same controlled view.
  historyPaneId: string | null;
  openSessionHistory: (paneIdx: number) => void;
  closeSessionHistory: () => void;
  // v1.10.3: returns the new pane's id (or null if the operation was a no-op:
  // max panes reached). Callers can use the
  // id to update mobile URL state so the URL-sync effect doesn't fight the
  // freshly-set activePaneIdx.
  addSplitPane: (kind: 'chat' | 'terminal' | 'coder') => string | null;
  /** Mixed tabs: add a tab of any kind to a pane (the "+" menu). */
  createTab: (paneIdx: number, kind: WorkspaceTabKind) => Promise<void>;
  /** Open an orchestrator run pane (or focus an existing one for the same run_id). */
  addOrchestratorPane: (state: OrchestratorState) => string | null;
  /** Open an arena battle pane (or focus an existing one for the same battle_id). */
  addArenaPane: (state: ArenaState) => string | null;
  /** Back-compat alias for createTab(paneIdx, 'coder'). */
  createCoderTab: (paneIdx: number) => Promise<void>;
  // Open-on-first-click, close-on-second-click. Singleton — settings panes
  // don't count toward MAX_PANES. Closing the only remaining pane (edge case)
  // falls back to an empty pane to preserve the "always one pane" invariant.
  toggleSettingsPane: () => string | null;
  removePane: (idx: number) => void;
  reopenPane: () => void;
  hasClosedPanes: boolean;
  removeChatFromPanes: (chatId: string) => void;
  initializeFirstChatIfEmpty: (chatId: string) => void;
  validatePanes: (validChatIds: Set<string>) => void;
  /** True while a coder/terminal pane is waiting for its scoped chat row. */
  isPaneChatPending: (paneId: string) => boolean;
  handlePaneDragStart: (idx: number) => (e: DragEvent<HTMLDivElement>) => void;
  handlePaneDragOver: (idx: number) => (e: DragEvent<HTMLDivElement>) => void;
  handlePaneDragLeave: () => void;
  handlePaneDrop: (targetIdx: number) => (e: DragEvent<HTMLDivElement>) => void;
  handlePaneDragEnd: () => void;
  dragOverIdx: number | null;
  draggingIdxRef: React.MutableRefObject<number | null>;
}

export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
  const [panes, setPanes] = useState<WorkspacePane[]>(() => [emptyPane()]);
  const [activePaneIdx, setActivePaneIdx] = useState(0);
  // v2.6.x envelope state. Persisted alongside `panes` in the WorkspaceState
  // envelope. `tabNumbers` is the stable session-scoped tab number per chat id;
  // `nextTabNumber` only ever increments; `closedPaneStack` is the reopen LIFO.
  const [tabNumbers, setTabNumbers] = useState<Record<string, number>>({});
  const [nextTabNumber, setNextTabNumber] = useState(1);
  const [closedPaneStack, setClosedPaneStack] = useState<ClosedPaneEntry[]>([]);
  const draggingIdxRef = useRef<number | null>(null);
  const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
  const [historyPaneId, setHistoryPaneId] = useState<string | null>(null);
  // v1.12.1: skip PATCH while hydrating from the server. Without this, the
  // initial [emptyPane()] would be saved over the server's real state before
  // the GET resolves.
  const hydratedRef = useRef(false);
  // Tracks the last value broadcast by another device (or this one's own
  // round-trip). If a PATCH would echo this exact payload, we skip the call.
  const lastRemoteJsonRef = useRef<string>('[]');
  const pendingPaneChatRef = useRef<Set<string>>(new Set());
  const [pendingPaneChatIds, setPendingPaneChatIds] = useState<Set<string>>(() => new Set());

  const markPaneChatPending = useCallback((paneId: string, pending: boolean) => {
    setPendingPaneChatIds((prev) => {
      const next = new Set(prev);
      if (pending) next.add(paneId);
      else next.delete(paneId);
      pendingPaneChatRef.current = next;
      return next;
    });
  }, []);

  // Fire-and-forget kill of terminal-tab tmux sessions (keyed by tab id). The
  // endpoint is idempotent (404 on a missing session) so a StrictMode
  // double-invoke of a setPanes updater that calls this is harmless.
  const killTerms = useCallback(
    (ids: string[]) => {
      for (const id of ids) api.terminals.kill(sessionId, id).catch(() => { /* non-fatal */ });
    },
    [sessionId],
  );

  const attachTabToPane = useCallback(
    (paneId: string, tabId: string, kind: WorkspaceTabKind) => {
      setPanes((prev) =>
        prev.map((p) => (p.id === paneId ? rebuildPane(p, [tabId], [kind], 0) : p)),
      );
    },
    [],
  );

  const seedPaneChat = useCallback(
    async (paneId: string, kind: 'coder' | 'terminal') => {
      if (pendingPaneChatRef.current.has(paneId)) return;
      markPaneChatPending(paneId, true);
      try {
        if (kind === 'terminal') {
          // Terminal tabs have no chats row — a generated id keys the tmux session.
          attachTabToPane(paneId, generateTermTabId(), 'terminal');
        } else {
          const chat = await api.chats.create(sessionId, { name: chatNameForPaneKind(kind) });
          attachTabToPane(paneId, chat.id, kind);
        }
      } catch (err) {
        toast.error(err instanceof Error ? err.message : 'Failed to create pane chat');
      } finally {
        markPaneChatPending(paneId, false);
      }
    },
    [sessionId, attachTabToPane, markPaneChatPending],
  );

  // Mixed tabs: add a new tab of ANY kind to a pane (the "+" menu). chat/coder
  // tabs create a fresh chats row; terminal tabs get a generated id (its own
  // tmux session). The new tab is appended and focused, and pane.kind tracks it
  // (rebuildPane). The "split into a new pane" action stays addSplitPane.
  const createTab = useCallback(
    async (paneIdx: number, kind: WorkspaceTabKind) => {
      const paneId = panes[paneIdx]?.id;
      if (!paneId) return;
      const appendTab = (tabId: string) =>
        setPanes((prev) => {
          const idx = prev.findIndex((p) => p.id === paneId);
          if (idx < 0) return prev;
          const pane = prev[idx]!;
          const next = [...prev];
          next[idx] = rebuildPane(
            pane,
            [...pane.chatIds, tabId],
            [...paneTabKinds(pane), kind],
            pane.chatIds.length,
          );
          return next;
        });
      if (kind === 'terminal') {
        appendTab(generateTermTabId());
        setActivePaneIdx(paneIdx);
        return;
      }
      markPaneChatPending(paneId, true);
      try {
        const chat = await api.chats.create(
          sessionId,
          kind === 'coder' ? { name: chatNameForPaneKind('coder') } : undefined,
        );
        appendTab(chat.id);
        setActivePaneIdx(paneIdx);
      } catch (err) {
        toast.error(err instanceof Error ? err.message : 'Failed to create tab');
      } finally {
        markPaneChatPending(paneId, false);
      }
    },
    [sessionId, panes, markPaneChatPending],
  );

  // Back-compat wrapper: the desktop coder pane "+" used to call this directly.
  const createCoderTab = useCallback(
    (paneIdx: number) => createTab(paneIdx, 'coder'),
    [createTab],
  );

  const seedEmptyScopedPanes = useCallback(
    (paneList: WorkspacePane[]) => {
      for (const pane of paneList) {
        if (pane.kind !== 'coder' && pane.kind !== 'terminal') continue;
        if ((pane.chatIds?.length ?? 0) > 0 || pane.chatId) continue;
        void seedPaneChat(pane.id, pane.kind);
      }
    },
    [seedPaneChat],
  );

  // v1.12.1: hydrate from server on mount, then subscribe to remote updates.
  useEffect(() => {
    hydratedRef.current = false;
    let cancelled = false;
    void (async () => {
      try {
        const session = await api.sessions.get(sessionId);
        if (cancelled) return;
        let env = toWorkspaceState(session.workspace_panes);
        let initial: WorkspacePane[] = normalizePanes(env.panes);
        // One-time migration: if server is empty but legacy localStorage has
        // a layout, seed the server (as an envelope) and delete the local key.
        if (initial.length === 0) {
          const legacy = readLegacyPanes(sessionId);
          if (legacy && legacy.length > 0) {
            try {
              const seedState: WorkspaceState = {
                panes: persistablePanes(legacy),
                tabNumbers: {},
                nextTabNumber: 1,
                closedPaneStack: [],
              };
              const updated = await api.sessions.updateWorkspacePanes(sessionId, seedState);
              if (cancelled) return;
              env = toWorkspaceState(updated.workspace_panes);
              initial = normalizePanes(env.panes);
              localStorage.removeItem(`${LEGACY_STORAGE_KEY}.${sessionId}`);
            } catch {
              env = { ...env, panes: legacy };
              initial = normalizePanes(legacy);
            }
          }
        }
        const next = initial.length > 0 ? initial : [emptyPane()];
        lastRemoteJsonRef.current = JSON.stringify({
          panes: persistablePanes(next),
          tabNumbers: env.tabNumbers,
          nextTabNumber: env.nextTabNumber,
          closedPaneStack: env.closedPaneStack,
        });
        setPanes(next);
        setTabNumbers(env.tabNumbers);
        setNextTabNumber(env.nextTabNumber);
        setClosedPaneStack(env.closedPaneStack);
        setActivePaneIdx(0);
        seedEmptyScopedPanes(next);
      } finally {
        if (!cancelled) hydratedRef.current = true;
      }
    })();
    return () => { cancelled = true; };
  }, [sessionId, seedEmptyScopedPanes]);

  // v1.12.1: live cross-device sync. Replace local state when another device
  // (or our own write echo) lands a session_workspace_updated frame.
  useEffect(() => {
    return sessionEvents.subscribe((ev) => {
      if (ev.type !== 'session_workspace_updated') return;
      if (ev.session_id !== sessionId) return;
      const env = toWorkspaceState(ev.workspace_panes);
      const incoming = normalizePanes(env.panes);
      // Echo-dedup on the FULL envelope so tabNumber / stack-only changes are
      // not mistaken for our own write echo.
      const json = JSON.stringify({
        panes: persistablePanes(incoming),
        tabNumbers: env.tabNumbers,
        nextTabNumber: env.nextTabNumber,
        closedPaneStack: env.closedPaneStack,
      });
      if (json === lastRemoteJsonRef.current) return;
      lastRemoteJsonRef.current = json;
      const nextPanes = incoming.length > 0 ? incoming : [emptyPane()];
      setPanes(nextPanes);
      setTabNumbers(env.tabNumbers);
      setNextTabNumber(env.nextTabNumber);
      setClosedPaneStack(env.closedPaneStack);
      setActivePaneIdx((prev) => Math.min(prev, Math.max(0, incoming.length - 1)));
      seedEmptyScopedPanes(nextPanes);
    });
  }, [sessionId, seedEmptyScopedPanes]);

  // v1.14.x-html-artifact-panes: ActionRow's "Open in pane" emits one of
  // these per click. If a pane already exists for the same message_id, focus
  // it instead of stacking a duplicate. Otherwise append (capped at MAX_PANES;
  // settings panes don't count, matching addSplitPane's rule).
  useEffect(() => {
    return sessionEvents.subscribe((ev) => {
      if (
        ev.type !== 'open_markdown_artifact_pane' &&
        ev.type !== 'open_html_artifact_pane'
      ) {
        return;
      }
      setPanes((prev) => {
        const targetKind: WorkspacePane['kind'] =
          ev.type === 'open_html_artifact_pane' ? 'html_artifact' : 'markdown_artifact';
        const messageId = ev.state.message_id;
        const existingIdx = prev.findIndex((p) =>
          p.kind === 'markdown_artifact'
            ? p.markdown_artifact_state?.message_id === messageId
            : p.kind === 'html_artifact'
              ? p.html_artifact_state?.message_id === messageId
              : false,
        );
        if (existingIdx >= 0) {
          setActivePaneIdx(existingIdx);
          return prev;
        }
        if (nonSettingsCount(prev) >= MAX_PANES) {
          toast.error(`Maximum ${MAX_PANES} panes`);
          return prev;
        }
        const newPane =
          ev.type === 'open_html_artifact_pane'
            ? htmlArtifactPane(ev.state)
            : markdownArtifactPane(ev.state);
        // Defensive: assert kind matches for the discriminated union.
        if (newPane.kind !== targetKind) return prev;
        const next = [...prev, newPane];
        setActivePaneIdx(next.length - 1);
        return next;
      });
    });
  }, []);

  // v1.12.1: debounced PATCH on every change. Settings panes are stripped
  // before saving (ephemeral per v1.9).
  useEffect(() => {
    if (!hydratedRef.current) return;
    // v2.6.x: persist the full WorkspaceState envelope. The dedup ref compares
    // the whole envelope so tabNumber / reopen-stack changes also persist.
    const envelope: WorkspaceState = {
      panes: persistablePanes(panes),
      tabNumbers,
      nextTabNumber,
      closedPaneStack,
    };
    const json = JSON.stringify(envelope);
    if (json === lastRemoteJsonRef.current) return;
    const timer = setTimeout(() => {
      lastRemoteJsonRef.current = json;
      api.sessions.updateWorkspacePanes(sessionId, envelope).catch(() => {
        // Non-fatal: next change retries. Persistent failures surface via
        // the network layer's existing reconnect toast.
      });
    }, SAVE_DEBOUNCE_MS);
    return () => clearTimeout(timer);
  }, [sessionId, panes, tabNumbers, nextTabNumber, closedPaneStack]);

  // v2.6.x (Batch 3a): maintain stable, session-scoped tab numbers. Collect the
  // chat ids that appear in CHAT- or CODER-kind panes in deterministic order
  // (pane index, then tab index). Assign numbers to any without one (global per
  // session, only ever increasing, never reused) and prune entries whose chat
  // is no longer in any tab-hosting pane. Guarded against render loops: only
  // setState when something actually changed.
  useEffect(() => {
    const liveChatIds: string[] = [];
    const liveSet = new Set<string>();
    for (const pane of panes) {
      if (pane.kind !== 'chat' && pane.kind !== 'coder') continue;
      for (const id of pane.chatIds) {
        if (!liveSet.has(id)) {
          liveSet.add(id);
          liveChatIds.push(id);
        }
      }
    }

    // Assign: walk live ids in deterministic order, handing out numbers.
    let counter = nextTabNumber;
    const additions: Record<string, number> = {};
    for (const id of liveChatIds) {
      if (tabNumbers[id] === undefined && additions[id] === undefined) {
        additions[id] = counter;
        counter += 1;
      }
    }

    // Prune: retire numbers for chats no longer in any chat-kind pane.
    const removals: string[] = [];
    for (const id of Object.keys(tabNumbers)) {
      if (!liveSet.has(id)) removals.push(id);
    }

    const hasAdditions = Object.keys(additions).length > 0;
    const hasRemovals = removals.length > 0;
    if (!hasAdditions && !hasRemovals) return;

    setTabNumbers((prev) => {
      const next: Record<string, number> = {};
      for (const [id, n] of Object.entries(prev)) {
        if (!removals.includes(id)) next[id] = n;
      }
      Object.assign(next, additions);
      return next;
    });
    if (hasAdditions) setNextTabNumber(counter);
  }, [panes, tabNumbers, nextTabNumber]);

  useEffect(() => {
    const active = panes[activePaneIdx];
    if (!active) {
      clearActivePane();
      return;
    }
    setActivePaneInfo({
      sessionId,
      paneId: active.id,
      kind: active.kind,
      activeFile: null,
    });
  }, [sessionId, panes, activePaneIdx]);

  useEffect(() => {
    return () => {
      clearActivePane();
    };
  }, []);

  const activePaneIdxRef = useRef(activePaneIdx);
  activePaneIdxRef.current = activePaneIdx;

  const openChatInPane = useCallback((paneIdx: number, chatId: string) => {
    setPanes((prev) => {
      const next = [...prev];
      const pane = next[paneIdx]!;
      const existing = pane.chatIds.indexOf(chatId);
      if (existing >= 0) {
        next[paneIdx] = rebuildPane(pane, pane.chatIds, paneTabKinds(pane), existing);
      } else {
        // Opening a stored conversation appends a chat tab (mixed tabs).
        next[paneIdx] = rebuildPane(
          pane,
          [...pane.chatIds, chatId],
          [...paneTabKinds(pane), 'chat'],
          pane.chatIds.length,
        );
      }
      return next;
    });
    setActivePaneIdx(paneIdx);
  }, []);

  // Open a whole chat in its own fresh pane (focused). Detaches the chat from
  // any pane currently showing it so it lives in exactly one pane (preserves
  // the one-chat-per-pane model), dropping a source pane left with no tabs. For
  // fork the chat isn't in any pane yet, so the detach is a no-op (pure append).
  const openChatInNewPane = useCallback((chatId: string) => {
    setPanes((prev) => {
      const detached = prev.flatMap((p) => {
        if (!p.chatIds.includes(chatId)) return [p];
        const nextIds = p.chatIds.filter((id) => id !== chatId);
        if (nextIds.length === 0) return [];
        const ai = Math.min(p.activeChatIdx, nextIds.length - 1);
        return [{ ...p, kind: 'chat' as const, chatId: nextIds[ai], chatIds: nextIds, activeChatIdx: ai }];
      });
      if (nonSettingsCount(detached) >= MAX_PANES) {
        toast.error(`Maximum ${MAX_PANES} panes`);
        return prev;
      }
      const next = [...detached, chatPane(chatId)];
      setActivePaneIdx(next.length - 1);
      return next;
    });
  }, []);

  // ChatTabBar's "Open in new pane" + MessageBubble.fork() emit this.
  useEffect(() => {
    return sessionEvents.subscribe((ev) => {
      if (ev.type !== 'open_chat_in_new_pane') return;
      openChatInNewPane(ev.chat_id);
    });
  }, [openChatInNewPane]);

  const switchTab = useCallback((paneIdx: number, tabIdx: number) => {
    setPanes((prev) => {
      const next = [...prev];
      const pane = next[paneIdx]!;
      if (tabIdx < 0 || tabIdx >= pane.chatIds.length) return prev;
      next[paneIdx] = rebuildPane(pane, pane.chatIds, paneTabKinds(pane), tabIdx);
      return next;
    });
  }, []);

  const removeTab = useCallback((paneIdx: number, chatId: string) => {
    setPanes((prev) => {
      const next = [...prev];
      const pane = next[paneIdx]!;
      if (!pane.chatIds.includes(chatId)) return prev;
      const { ids, kinds, removedTermIds } = filterTabs(pane, (id) => id !== chatId);
      killTerms(removedTermIds);
      if (ids.length === 0 && next.length > 1) {
        // Last tab closed and other panes exist — remove the whole pane
        // instead of leaving an orphaned empty panel.
        setClosedPaneStack((stack) => appendClosed(stack, pane));
        const spliced = next.filter((_, i) => i !== paneIdx);
        setActivePaneIdx((ai) => Math.min(ai, spliced.length - 1));
        return spliced;
      }
      next[paneIdx] = rebuildPane(pane, ids, kinds, Math.min(pane.activeChatIdx, ids.length - 1));
      return next;
    });
  }, [killTerms]);

  // Keep only the right-clicked tab open in this pane.
  const closeOtherTabs = useCallback((paneIdx: number, keepChatId: string) => {
    setPanes((prev) => {
      const next = [...prev];
      const pane = next[paneIdx]!;
      const keepIdx = pane.chatIds.indexOf(keepChatId);
      if (keepIdx < 0) return prev;
      const { ids, kinds, removedTermIds } = filterTabs(pane, (id) => id === keepChatId);
      killTerms(removedTermIds);
      next[paneIdx] = rebuildPane(pane, ids, kinds, 0);
      return next;
    });
  }, [killTerms]);

  // Close every tab to the right of the right-clicked one.
  const closeTabsToRight = useCallback((paneIdx: number, pivotChatId: string) => {
    setPanes((prev) => {
      const next = [...prev];
      const pane = next[paneIdx]!;
      const pivotIdx = pane.chatIds.indexOf(pivotChatId);
      if (pivotIdx < 0 || pivotIdx === pane.chatIds.length - 1) return prev;
      const { ids, kinds, removedTermIds } = filterTabs(pane, (_id, i) => i <= pivotIdx);
      killTerms(removedTermIds);
      next[paneIdx] = rebuildPane(pane, ids, kinds, Math.min(pane.activeChatIdx, ids.length - 1));
      return next;
    });
  }, [killTerms]);

  // Close every tab in this pane; land on landing page.
  const closeAllTabs = useCallback((paneIdx: number) => {
    setPanes((prev) => {
      const next = [...prev];
      const pane = next[paneIdx]!;
      const { removedTermIds } = filterTabs(pane, () => false);
      killTerms(removedTermIds);
      next[paneIdx] = rebuildPane(pane, [], [], -1);
      return next;
    });
  }, [killTerms]);

  const showLandingPage = useCallback((paneIdx: number) => {
    setPanes((prev) => {
      const pane = prev[paneIdx];
      if (!pane) return prev;
      const next = [...prev];
      // Drop the pane's tabs and show the landing page. Terminal tabs are
      // ephemeral — kill their tmux sessions (keyed by tab id) on close.
      const { removedTermIds } = filterTabs(pane, () => false);
      if (removedTermIds.length > 0) killTerms(removedTermIds);
      next[paneIdx] = rebuildPane(pane, [], [], -1);
      return next;
    });
  }, [killTerms]);

  // Reveal the session-history list. Mirrors the desktop "Show history" action:
  // convert the pane to its landing (showLandingPage) and flag it so the landing
  // opens on the history list rather than the new-chat hero.
  const openSessionHistory = useCallback((paneIdx: number) => {
    const id = panes[paneIdx]?.id ?? null;
    showLandingPage(paneIdx);
    setHistoryPaneId(id);
  }, [panes, showLandingPage]);

  const closeSessionHistory = useCallback(() => setHistoryPaneId(null), []);

  const addSplitPane = useCallback((kind: 'chat' | 'terminal' | 'coder'): string | null => {
    // Generate the id outside the updater so we can return it deterministically.
    // setPanes's updater can be invoked twice in strict mode; using a fixed id
    // ensures both invocations agree and the returned id matches what landed.
    const newPaneId = generateId();
    let success = false;
    setPanes((prev) => {
      // v1.9: settings panes are excluded from the MAX cap (decision c).
      if (nonSettingsCount(prev) >= MAX_PANES) {
        toast.error(`Maximum ${MAX_PANES} panes`);
        return prev;
      }
      const newPane =
        kind === 'terminal'
          ? { id: newPaneId, kind: 'terminal' as const, chatIds: [] as string[], tabKinds: [], activeChatIdx: -1 }
          : kind === 'coder'
            ? { id: newPaneId, kind: 'coder' as const, chatIds: [] as string[], tabKinds: [], activeChatIdx: -1 }
            : emptyPane(newPaneId);
      const next = [...prev, newPane];
      setActivePaneIdx(next.length - 1);
      success = true;
      if (kind === 'terminal' || kind === 'coder') {
        queueMicrotask(() => void seedPaneChat(newPaneId, kind));
      }
      return next;
    });
    return success ? newPaneId : null;
  }, [seedPaneChat]);

  const addOrchestratorPane = useCallback((state: OrchestratorState): string | null => {
    let openedId: string | null = null;
    setPanes((prev) => {
      // Dedup: focus an existing pane for the same run.
      const existingIdx = prev.findIndex(
        (p) => p.kind === 'orchestrator' && p.orchestrator_state?.run_id === state.run_id,
      );
      if (existingIdx >= 0) {
        setActivePaneIdx(existingIdx);
        openedId = prev[existingIdx]!.id;
        return prev;
      }
      if (nonSettingsCount(prev) >= MAX_PANES) {
        toast.error(`Maximum ${MAX_PANES} panes`);
        return prev;
      }
      const newPane = orchestratorPane(state);
      openedId = newPane.id;
      const next = [...prev, newPane];
      setActivePaneIdx(next.length - 1);
      return next;
    });
    return openedId;
  }, []);

  // Orchestrator pane: open via sessionEvents (fired by ChatInput slash/button).
  useEffect(() => {
    return sessionEvents.subscribe((ev) => {
      if (ev.type !== 'open_orchestrator_pane') return;
      addOrchestratorPane(ev.state);
    });
  }, [addOrchestratorPane]);

  const addArenaPane = useCallback((state: ArenaState): string | null => {
    let openedId: string | null = null;
    setPanes((prev) => {
      const existingIdx = prev.findIndex(
        (p) => p.kind === 'arena' && p.arena_state?.battle_id === state.battle_id,
      );
      if (existingIdx >= 0) {
        setActivePaneIdx(existingIdx);
        openedId = prev[existingIdx]!.id;
        return prev;
      }
      if (nonSettingsCount(prev) >= MAX_PANES) {
        toast.error(`Maximum ${MAX_PANES} panes`);
        return prev;
      }
      const newPane = arenaPane(state);
      openedId = newPane.id;
      const next = [...prev, newPane];
      setActivePaneIdx(next.length - 1);
      return next;
    });
    return openedId;
  }, []);

  // Arena pane: open via sessionEvents (fired by the launcher).
  useEffect(() => {
    return sessionEvents.subscribe((ev) => {
      if (ev.type !== 'open_arena_pane') return;
      addArenaPane(ev.state);
    });
  }, [addArenaPane]);

  // Returns the new settings pane id when one is OPENED (so mobile callers can
  // push ?pane= atomically — see addPaneAndSwitch), or null when it was closed.
  // Id generated outside the updater so a strict-mode double-invoke agrees.
  const toggleSettingsPane = useCallback((): string | null => {
    const newPaneId = generateId();
    let openedId: string | null = null;
    setPanes((prev) => {
      const existingIdx = prev.findIndex((p) => p.kind === 'settings');
      if (existingIdx < 0) {
        const next = [...prev, settingsPane(newPaneId)];
        setActivePaneIdx(next.length - 1);
        openedId = newPaneId;
        return next;
      }
      openedId = null;
      if (prev.length <= 1) {
        setActivePaneIdx(0);
        return [emptyPane()];
      }
      const next = prev.filter((_, i) => i !== existingIdx);
      setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
      return next;
    });
    return openedId;
  }, []);

  const removePane = useCallback((idx: number) => {
    setPanes((prev) => {
      if (prev.length <= 1) {
        // Settings is the only kind that can be the last pane and still need
        // closing (X / Esc / sidebar toggle). Fall back to empty. One-pane
        // edge: no relocation — there is no other pane.
        if (prev[idx]?.kind === 'settings') {
          setActivePaneIdx(0);
          return [emptyPane()];
        }
        return prev;
      }
      // v1.10.8c: with per-pane tmux sessions, an unkilled session leaks until
      // the next `tmux kill-server`. Fire-and-forget /kill on terminal removal.
      // The endpoint is idempotent (404 on missing session) so a strict-mode
      // double-invoke of the updater is safe.
      const removed = prev[idx];
      // Push the original pane (with its chatIds intact) to the reopen stack.
      if (removed) setClosedPaneStack((stack) => appendClosed(stack, removed));

      // v2.6.x (Batch 1) + mixed tabs: relocate a closing CHAT-active pane's
      // tabs (any kind) to the oldest remaining pane that can host tabs, so
      // conversations aren't lost on close. Terminal/coder-active panes close
      // exactly as before (no relocation).
      let working = prev;
      let relocated = false;
      if (removed && removed.kind === 'chat' && removed.chatIds.length > 0) {
        let targetIdx = -1;
        for (let i = 0; i < prev.length; i += 1) {
          if (i === idx) continue;
          const p = prev[i]!;
          if (p.kind === 'chat' || p.kind === 'empty') {
            targetIdx = i;
            break;
          }
        }
        if (targetIdx >= 0) {
          relocated = true;
          working = prev.map((p, i) => {
            if (i !== targetIdx) return p;
            const mergedIds = [...p.chatIds, ...removed.chatIds];
            const mergedKinds = [...paneTabKinds(p), ...paneTabKinds(removed)];
            // Preserve the target's existing focus — append, don't force-focus
            // the moved tabs. Clamp only when the target had no active tab.
            const ai = p.activeChatIdx >= 0 ? p.activeChatIdx : 0;
            return rebuildPane(p, mergedIds, mergedKinds, ai);
          });
        }
      }

      // Kill the tmux sessions of any terminal tabs that are NOT relocated
      // (keyed by tab id, not pane id, since mixed panes hold many terminals).
      if (removed && !relocated) {
        killTerms(filterTabs(removed, () => false).removedTermIds);
      }

      const next = working.filter((_, i) => i !== idx);
      setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
      return next;
    });
  }, [killTerms]);

  const hasClosedPanes = closedPaneStack.length > 0;

  const reopenPane = useCallback(() => {
    // Read the top entry from the current render's stack (not inside the
    // updater) so a StrictMode double-invoke can't pop two entries. The pop
    // setState is idempotent: filtering by reference removes exactly this entry.
    const e = closedPaneStack[closedPaneStack.length - 1];
    if (!e) return;
    setClosedPaneStack((stack) => (stack[stack.length - 1] === e ? stack.slice(0, -1) : stack));
    setPanes((prev) => {
      // v2.6.x (Batch 4): reversible reopen. The closed tabs may have been
      // relocated into another pane on close (Batch 1). Strip e.chatIds from
      // every existing pane first so reopening never duplicates a tab —
      // whether or not it was relocated (a no-op strip when it wasn't). Mirror
      // removeTab's emptiness handling: a chat pane emptied by the strip is
      // dropped when other panes remain, else turned empty.
      const stripped: WorkspacePane[] = [];
      for (const p of prev) {
        const { ids, kinds } = filterTabs(p, (id) => !e.chatIds.includes(id));
        if (ids.length === p.chatIds.length) {
          stripped.push(p);
          continue;
        }
        if (ids.length === 0 && p.kind === 'chat') {
          // Drop the now-empty chat pane (the restored pane plus possibly others
          // remain). rebuildPane would leave an empty landing — we'd rather drop.
          continue;
        }
        stripped.push(rebuildPane(p, ids, kinds, Math.min(p.activeChatIdx, ids.length - 1)));
      }
      const restoredKinds: WorkspaceTabKind[] =
        e.tabKinds && e.tabKinds.length === e.chatIds.length
          ? e.tabKinds
          : e.chatIds.map(() => (e.kind === 'coder' ? 'coder' : e.kind === 'terminal' ? 'terminal' : 'chat'));
      const restored: WorkspacePane = rebuildPane(
        { id: generateId(), kind: e.kind, chatIds: [], activeChatIdx: -1 },
        e.chatIds,
        restoredKinds,
        e.activeChatIdx,
      );
      const next = [...stripped, restored];
      setActivePaneIdx(next.length - 1);
      return next;
    });
  }, [closedPaneStack]);

  const initializeFirstChatIfEmpty = useCallback((chatId: string) => {
    setPanes((prev) => {
      if (prev.length === 1 && prev[0]!.kind === 'empty') {
        return [chatPane(chatId)];
      }
      return prev;
    });
  }, []);

  const validatePanes = useCallback((validChatIds: Set<string>) => {
    setPanes((prev) => {
      const cleaned = prev.map((pane) => {
        if (pane.chatIds.length === 0) return pane;
        const kinds = paneTabKinds(pane);
        // Prune chat/coder tabs whose chats row was deleted. Terminal tabs have
        // no chats row, so they're always kept.
        const { ids, kinds: nextKinds } = filterTabs(
          pane,
          (id, i) => kinds[i] === 'terminal' || validChatIds.has(id),
        );
        if (ids.length === pane.chatIds.length) return pane;
        return rebuildPane(pane, ids, nextKinds, Math.min(pane.activeChatIdx, ids.length - 1));
      });
      const unchanged = cleaned.every((p, i) => p === prev[i]);
      return unchanged ? prev : cleaned;
    });
  }, []);

  const isPaneChatPending = useCallback(
    (paneId: string) => pendingPaneChatIds.has(paneId),
    [pendingPaneChatIds],
  );

  const removeChatFromPanes = useCallback((chatId: string) => {
    setPanes((prev) => prev.map((p) => {
      if (!p.chatIds.includes(chatId)) return p;
      const { ids, kinds } = filterTabs(p, (id) => id !== chatId);
      return rebuildPane(p, ids, kinds, Math.min(p.activeChatIdx, ids.length - 1));
    }));
  }, []);

  const handlePaneDragStart = useCallback(
    (idx: number) => (e: DragEvent<HTMLDivElement>) => {
      draggingIdxRef.current = idx;
      e.dataTransfer.effectAllowed = 'move';
      e.dataTransfer.setData('text/plain', String(idx));
    },
    []
  );

  const handlePaneDragOver = useCallback(
    (idx: number) => (e: DragEvent<HTMLDivElement>) => {
      if (draggingIdxRef.current === null) return;
      e.preventDefault();
      e.dataTransfer.dropEffect = 'move';
      if (dragOverIdx !== idx) setDragOverIdx(idx);
    },
    [dragOverIdx]
  );

  const handlePaneDragLeave = useCallback(() => {
    setDragOverIdx(null);
  }, []);

  const handlePaneDrop = useCallback(
    (targetIdx: number) => (e: DragEvent<HTMLDivElement>) => {
      e.preventDefault();
      const fromIdx = draggingIdxRef.current;
      draggingIdxRef.current = null;
      setDragOverIdx(null);
      if (fromIdx === null || fromIdx === targetIdx) return;
      setPanes((prev) => {
        const next = [...prev];
        const [moved] = next.splice(fromIdx, 1);
        if (!moved) return prev;
        next.splice(targetIdx, 0, moved);
        // Keep active selection on the same logical pane (the one being dragged).
        setActivePaneIdx(targetIdx);
        return next;
      });
    },
    []
  );

  const handlePaneDragEnd = useCallback(() => {
    draggingIdxRef.current = null;
    setDragOverIdx(null);
  }, []);

  return {
    panes,
    tabNumbers,
    activePaneIdx,
    setActivePaneIdx,
    activePaneIdxRef,
    openChatInPane,
    switchTab,
    removeTab,
    closeOtherTabs,
    closeTabsToRight,
    closeAllTabs,
    showLandingPage,
    historyPaneId,
    openSessionHistory,
    closeSessionHistory,
    addSplitPane,
    createTab,
    addOrchestratorPane,
    addArenaPane,
    createCoderTab,
    toggleSettingsPane,
    removePane,
    reopenPane,
    hasClosedPanes,
    removeChatFromPanes,
    initializeFirstChatIfEmpty,
    validatePanes,
    isPaneChatPending,
    handlePaneDragStart,
    handlePaneDragOver,
    handlePaneDragLeave,
    handlePaneDrop,
    handlePaneDragEnd,
    dragOverIdx,
    draggingIdxRef,
  };
}
