import { useCallback, useEffect, useRef, useState } from 'react';
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { SearchAddon } from '@xterm/addon-search';
import { WebLinksAddon } from '@xterm/addon-web-links';
import '@xterm/xterm/css/xterm.css';
import { ChevronDown, ChevronUp, Maximize2, RefreshCw, X } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import {
  chatInputsRegistry,
  sendToChat,
  sendToTerminal,
  terminalsRegistry,
  type ChatInputRegistration,
} from '@/lib/events';
import { cn } from '@/lib/utils';

// Architecture invariants (do not regress without reading the comments
// next to each):
//
//   - Resize is in-band on the WebSocket as a JSON text frame. The HTTP
//     /resize endpoint had a race with PTY-map registration; WS frames
//     don't.
//   - Server `init` text frame triggers term.clear() so a stale buffer
//     can't leak through the capture-pane replay.
//   - DOM renderer only (no @xterm/addon-webgl). The WebGL atlas bakes
//     glyphs via Canvas2D at load time and locks against whatever font
//     is registered then; @fontsource JBM Variable subsets don't cover
//     U+2500-25FF, so the atlas baked against the system fallback and
//     opencode/claude banners rendered garbled. DOM renderer uses real
//     text spans, so the browser's text rasterizer handles unicode
//     fallback per-character — boolab pattern, just works.
//   - Refit on document.fonts.ready (post-open) so xterm's cell metrics
//     don't bake against the system fallback. Boolab pattern: rAF inside
//     fonts.ready, plus a 150ms safety-net setTimeout.

interface Props {
  sessionId: string;
  paneId: string;
  label: string;
  active?: boolean;
}

// Terminal background matches the pane container's `bg-[#0b0f14]` so any
// sub-cell rounding remainder is invisible. Update both if the theme changes.
const TERM_BG = '#0b0f14';

const XTERM_THEME = {
  background: TERM_BG,
  foreground: '#d6deeb',
  cursor: '#82aaff',
  selectionBackground: '#1d3b53',
  black: '#011627',
  red: '#ef5350',
  green: '#22da6e',
  yellow: '#c5e478',
  blue: '#82aaff',
  magenta: '#c792ea',
  cyan: '#7fdbca',
  white: '#d6deeb',
  brightBlack: '#575656',
  brightRed: '#ef5350',
  brightGreen: '#22da6e',
  brightYellow: '#ffeb95',
  brightBlue: '#82aaff',
  brightMagenta: '#c792ea',
  brightCyan: '#7fdbca',
  brightWhite: '#ffffff',
};

type ConnState = 'connecting' | 'open' | 'reconnecting' | 'disconnected';

const MAX_RECONNECT_ATTEMPTS = 5;
const RECONNECT_DELAYS_MS = [500, 1000, 2000, 4000, 8000];

const LONG_PRESS_MS = 500;
const LONG_PRESS_TOLERANCE_PX = 10;

// xterm 5 ships no public dimensions API — `_core._renderService.dimensions`
// is internal but stable across the package versions we ship.
function cellSize(term: Terminal, container: HTMLElement): { w: number; h: number } {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const dims = (term as any)._core?._renderService?.dimensions?.css?.cell;
  if (dims && dims.width > 0 && dims.height > 0) {
    return { w: dims.width, h: dims.height };
  }
  const rect = container.getBoundingClientRect();
  return { w: rect.width / Math.max(term.cols, 1), h: rect.height / Math.max(term.rows, 1) };
}

function pointToCell(
  term: Terminal,
  container: HTMLElement,
  clientX: number,
  clientY: number,
): { col: number; bufferRow: number } {
  const rect = container.getBoundingClientRect();
  const { w, h } = cellSize(term, container);
  const localX = Math.max(0, clientX - rect.left);
  const localY = Math.max(0, clientY - rect.top);
  const col = Math.min(term.cols - 1, Math.floor(localX / Math.max(w, 1)));
  const screenRow = Math.min(term.rows - 1, Math.floor(localY / Math.max(h, 1)));
  const bufferRow = term.buffer.active.viewportY + screenRow;
  return { col, bufferRow };
}

const WORD_RE = /[\w.~$\-/]+/g;
function wordRangeAt(line: string, col: number): { start: number; end: number } | null {
  for (const m of line.matchAll(WORD_RE)) {
    const start = m.index ?? 0;
    const end = start + m[0].length;
    if (col >= start && col < end) return { start, end };
    if (start > col) return null;
  }
  return null;
}

export function TerminalPane({ sessionId, paneId, label, active = false }: Props) {
  const containerRef = useRef<HTMLDivElement | null>(null);
  const termRef = useRef<Terminal | null>(null);
  const searchRef = useRef<SearchAddon | null>(null);
  const wsRef = useRef<WebSocket | null>(null);
  const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const reconnectAttemptRef = useRef(0);
  const closedByUserRef = useRef(false);
  // Last size pushed through term.onResize. Mirrors xterm's authoritative
  // cols/rows; consulted on every WS open so the resize frame carries the
  // currently-measured size, not a stale lastSize from before the latest fit.
  const lastSizeRef = useRef<{ cols: number; rows: number }>({ cols: 80, rows: 24 });
  const reconnectFnRef = useRef<() => void>(() => {});

  const [connState, setConnState] = useState<ConnState>('connecting');
  const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);
  const [searchOpen, setSearchOpen] = useState(false);
  const [chatInputs, setChatInputs] = useState<ChatInputRegistration[]>([]);
  const setMenuRef = useRef(setMenu);
  setMenuRef.current = setMenu;
  const setSearchOpenRef = useRef(setSearchOpen);
  setSearchOpenRef.current = setSearchOpen;
  const setChatInputsRef = useRef(setChatInputs);
  setChatInputsRef.current = setChatInputs;

  // v1.10.8d: sticky Ctrl for the on-screen hotkey bar (boolab pattern).
  // ctrlArmedRef is read synchronously inside term.onData (per keystroke);
  // ctrlArmed state mirror exists so the Ctrl button can highlight in React.
  // Auto-disarms after 5s so a stray tap doesn't quietly mangle the next
  // keystroke minutes later.
  const [ctrlArmed, setCtrlArmed] = useState(false);
  const ctrlArmedRef = useRef(false);
  const ctrlTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const setCtrlArmedSync = useCallback((armed: boolean) => {
    ctrlArmedRef.current = armed;
    setCtrlArmed(armed);
    if (ctrlTimerRef.current) {
      clearTimeout(ctrlTimerRef.current);
      ctrlTimerRef.current = null;
    }
    if (armed) {
      ctrlTimerRef.current = setTimeout(() => {
        ctrlArmedRef.current = false;
        setCtrlArmed(false);
        ctrlTimerRef.current = null;
      }, 5000);
    }
  }, []);
  const armCtrl = useCallback(() => {
    setCtrlArmedSync(!ctrlArmedRef.current);
  }, [setCtrlArmedSync]);
  // sendInput: write to the WS as a binary frame (server-side discriminator
  // routes binary to PTY, text to JSON control). Used by the hotkey bar.
  const sendInput = useCallback((text: string) => {
    if (!text) return;
    const ws = wsRef.current;
    if (!ws || ws.readyState !== WebSocket.OPEN) return;
    try {
      ws.send(new TextEncoder().encode(text));
    } catch {
      /* ignore */
    }
  }, []);
  // fitFull lives inside the main useEffect (closure over `disposed`/`ctr`);
  // expose via a ref so the hotkey bar's fit button and the visualViewport
  // resize handler can reach it.
  const fitFullRef = useRef<() => void>(() => {});
  const triggerFit = useCallback(() => {
    fitFullRef.current();
  }, []);

  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;
    const ctr: HTMLDivElement = container;

    let disposed = false;
    closedByUserRef.current = false;
    reconnectAttemptRef.current = 0;

    // On mobile viewports drop to 11px so opencode/claude get enough cols.
    const fontSize = typeof window !== 'undefined' && window.innerWidth < 768 ? 11 : 13;
    const term = new Terminal({
      convertEol: true,
      cursorBlink: true,
      allowProposedApi: true,
      fontFamily:
        "'JetBrains Mono Variable', 'JetBrains Mono', 'Fira Code', Menlo, monospace",
      fontSize,
      // Locked cell metrics so DOM-renderer rows line up: integer-multiple
      // line height and zero letter spacing keep glyph spans cell-aligned.
      // globals.css enforces the same with !important so an inherited rule
      // can't fight us.
      lineHeight: 1.0,
      letterSpacing: 0,
      scrollback: 10_000,
      fastScrollModifier: 'shift',
      altClickMovesCursor: false,
      theme: XTERM_THEME,
    });
    termRef.current = term;
    const fit = new FitAddon();
    const search = new SearchAddon();
    searchRef.current = search;
    term.loadAddon(fit);
    term.loadAddon(search);
    term.loadAddon(new WebLinksAddon());
    term.open(ctr);

    // Bypass FitAddon's proposeDimensions(), which subtracts the native
    // scrollbar's reserved width even after CSS hides it (boolab fix). We
    // compute cols/rows directly from host.clientWidth/Height ÷ cell metrics.
    // Falls through (no-op) if cell metrics aren't measured yet — a later
    // refit (fonts.ready / setTimeout / ResizeObserver) will catch it.
    //
    // v1.10.8d: row count uses the VISIBLE height (clipped against the
    // visualViewport) rather than raw clientHeight. h-dvh on the root only
    // shrinks for the URL bar — not the iOS keyboard — so without the clip
    // we allocate rows for the area hidden behind the keyboard, and bash's
    // cursor (pushed down by MOTD on SSH login) ends up below the fold.
    const fitFull = (): void => {
      if (disposed) return;
      const t = termRef.current;
      if (!t) return;
      if (!t.element || !(t.element as HTMLElement).offsetParent) return;
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      const core: any = (t as any)._core;
      const cellW: number | undefined = core?._renderService?.dimensions?.css?.cell?.width;
      const cellH: number | undefined = core?._renderService?.dimensions?.css?.cell?.height;
      if (!cellW || !cellH || cellW <= 0 || cellH <= 0) return;
      let visibleHeight = ctr.clientHeight;
      const vp2 = typeof window !== 'undefined' ? window.visualViewport : null;
      if (vp2) {
        const rect = ctr.getBoundingClientRect();
        // visualViewport.height is the layout viewport minus the keyboard.
        // rect.bottom is in layout-viewport coordinates. clip to whichever
        // is smaller. offsetTop/pageTop adjustments aren't needed at zoom=1.
        const visibleBottom = Math.min(rect.bottom, vp2.height);
        const clipped = Math.max(0, visibleBottom - rect.top);
        if (clipped > 0) visibleHeight = clipped;
      }
      const cols = Math.max(2, Math.floor(ctr.clientWidth / cellW));
      const rows = Math.max(1, Math.floor(visibleHeight / cellH));
      if (cols !== t.cols || rows !== t.rows) {
        try {
          t.resize(cols, rows);
        } catch {
          /* ignore */
        }
      }
    };
    fitFullRef.current = fitFull;

    // boolab pattern: term.onResize fires synchronously inside term.resize()
    // and on init. We always update lastSizeRef so a fresh WS connect (or
    // reconnect) carries the current measured size; we send a resize frame
    // when the WS is open. No gating by an "is-started" flag — the WS itself
    // is the gate (it ignores writes when not open).
    term.onResize(({ cols, rows }) => {
      lastSizeRef.current = { cols, rows };
      if (cols < 2 || rows < 1) return;
      const ws = wsRef.current;
      if (!ws || ws.readyState !== WebSocket.OPEN) return;
      try {
        ws.send(JSON.stringify({ type: 'resize', cols, rows }));
      } catch {
        /* ignore */
      }
    });

    // Keystrokes go out as a BINARY frame so the server can disambiguate them
    // from JSON control frames. TextEncoder is in every modern browser.
    //
    // Sticky Ctrl: when armed (via the hotkey bar), apply Ctrl to the next
    // single ASCII printable (0x40-0x7e — '@', A-Z, [, \, ], ^, _, `, a-z,
    // {, |, }, ~) and disarm. Multi-byte sequences (arrows, Esc, F-keys) pass
    // through untouched and disarm so a stuck arm doesn't poison them.
    term.onData((data) => {
      const ws = wsRef.current;
      if (!ws || ws.readyState !== WebSocket.OPEN) return;
      let out = data;
      if (ctrlArmedRef.current) {
        if (data.length === 1) {
          const c = data.charCodeAt(0);
          if (c >= 0x40 && c <= 0x7e) {
            out = String.fromCharCode(c & 0x1f);
          }
        }
        setCtrlArmedSync(false);
      }
      try {
        ws.send(new TextEncoder().encode(out));
      } catch {
        /* ignore */
      }
    });

    function buildWsUrl(): string {
      const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
      const { cols, rows } = lastSizeRef.current;
      return `${proto}//${window.location.host}/ws/term/sessions/${sessionId}/panes/${paneId}?cols=${cols}&rows=${rows}`;
    }

    function scheduleReconnect(): void {
      if (disposed || closedByUserRef.current) return;
      const attempt = reconnectAttemptRef.current;
      if (attempt >= MAX_RECONNECT_ATTEMPTS) {
        setConnState('disconnected');
        return;
      }
      const delay = RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)]!;
      reconnectAttemptRef.current = attempt + 1;
      if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
      reconnectTimerRef.current = setTimeout(connect, delay);
    }

    function connect(): void {
      if (disposed || closedByUserRef.current) return;
      const existing = wsRef.current;
      if (existing && existing.readyState !== WebSocket.CLOSED) return;
      let ws: WebSocket;
      try {
        ws = new WebSocket(buildWsUrl());
      } catch {
        scheduleReconnect();
        return;
      }
      ws.binaryType = 'arraybuffer';
      wsRef.current = ws;

      ws.addEventListener('open', () => {
        if (disposed) return;
        reconnectAttemptRef.current = 0;
        setConnState('open');
        // boolab pattern: send our currently-measured size as a resize frame
        // on every WS open. Server's PTY adopts it immediately; no race
        // window between attach and resize.
        const { cols, rows } = lastSizeRef.current;
        if (cols >= 2 && rows >= 1) {
          try {
            ws.send(JSON.stringify({ type: 'resize', cols, rows }));
          } catch {
            /* ignore */
          }
        }
      });

      ws.addEventListener('message', (e) => {
        const t = termRef.current;
        if (!t) return;
        if (typeof e.data === 'string') {
          try {
            const parsed = JSON.parse(e.data) as { type?: string; code?: number };
            if (parsed.type === 'init') {
              // boolab pattern: wipe any pre-attach buffer state, then the
              // server's next frame (a single binary capture-pane replay)
              // will paint the current tmux pane state in cleanly. Re-send
              // our measured size in case the server's init size (from the
              // WS query string) is stale relative to a fit that landed
              // between opening and now.
              try {
                t.clear();
              } catch {
                /* ignore */
              }
              const { cols, rows } = lastSizeRef.current;
              if (cols >= 2 && rows >= 1) {
                try {
                  ws.send(JSON.stringify({ type: 'resize', cols, rows }));
                } catch {
                  /* ignore */
                }
              }
              return;
            }
            if (parsed.type === 'exit') {
              t.write(`\r\n\x1b[2m[process exited with code ${parsed.code ?? 0}]\x1b[0m\r\n`);
              return;
            }
          } catch {
            /* not JSON — fall through and write as text */
          }
          t.write(e.data);
        } else {
          t.write(new Uint8Array(e.data as ArrayBuffer));
        }
      });

      ws.addEventListener('close', () => {
        if (disposed) return;
        wsRef.current = null;
        if (closedByUserRef.current) return;
        setConnState('reconnecting');
        scheduleReconnect();
      });

      ws.addEventListener('error', () => {
        // close fires after; let close own the retry.
      });
    }

    function manualReconnect(): void {
      if (disposed) return;
      closedByUserRef.current = false;
      reconnectAttemptRef.current = 0;
      if (reconnectTimerRef.current) {
        clearTimeout(reconnectTimerRef.current);
        reconnectTimerRef.current = null;
      }
      try {
        wsRef.current?.close();
      } catch {
        /* ignore */
      }
      setConnState('reconnecting');
      setTimeout(connect, 50);
    }
    reconnectFnRef.current = manualReconnect;

    // Init: rAF-defer one frame so xterm's render service can populate
    // _renderService.dimensions, then fitFull → /start (idempotent, sizes
    // the tmux session) → connect WS. The WS handler also ensureSession's
    // as belt-and-suspenders, so a /start failure is non-fatal.
    const initRaf = requestAnimationFrame(() => {
      if (disposed) return;
      fitFull();
      const { cols, rows } = lastSizeRef.current;
      api.terminals
        .start(sessionId, paneId, cols, rows)
        .catch(() => {
          /* WS handler will ensureSession itself — non-fatal */
        })
        .finally(() => {
          if (disposed) return;
          connect();
        });
    });

    // Boolab pattern: term measures cell metrics on open() against whatever
    // font is registered at that instant; if the font hasn't loaded yet we
    // get a fallback measurement and the wrong cols. Refit when
    // document.fonts.ready resolves.
    let fontRaf: number | null = null;
    if (typeof document !== 'undefined' && document.fonts && document.fonts.ready) {
      document.fonts.ready
        .then(() => {
          if (disposed) return;
          fontRaf = requestAnimationFrame(() => fitFull());
        })
        .catch(() => {
          /* ignore */
        });
    }

    // Second-pass refit: mobile browsers can settle layout slightly late,
    // causing the rAF fit to measure a stale clientWidth.
    const delayedFit = setTimeout(() => fitFull(), 150);

    // ResizeObserver: refit on any container size change. No debounce —
    // term.onResize itself is the throttle (no-op when cols/rows unchanged).
    const ro = new ResizeObserver(() => {
      fitFull();
    });
    ro.observe(ctr);

    const onVis = (): void => {
      if (document.visibilityState !== 'visible') return;
      const ws = wsRef.current;
      if (!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) {
        manualReconnect();
      } else {
        // Refit in case the layout changed while hidden. Single rAF so the
        // measurement runs after the tab's first composited frame back in
        // the foreground.
        requestAnimationFrame(() => {
          if (disposed) return;
          fitFull();
        });
      }
    };
    document.addEventListener('visibilitychange', onVis);

    // v1.10.8d: visualViewport.resize fires when the iOS keyboard opens or
    // closes — the layout viewport stays full-height (so our h-dvh root
    // doesn't shrink), but the visual viewport contracts above the keyboard.
    // Refit so xterm's row count matches the visible area, then scroll to
    // bottom so the cursor stays above the keyboard fold (boolab pattern).
    let onVpResize: (() => void) | null = null;
    const vp = typeof window !== 'undefined' ? window.visualViewport : null;
    if (vp) {
      onVpResize = (): void => {
        if (disposed) return;
        fitFull();
        const t = termRef.current;
        if (t) {
          try { t.scrollToBottom(); } catch { /* ignore */ }
        }
      };
      vp.addEventListener('resize', onVpResize);
    }

    // ============================================================
    // v1.10.4 features (long-press menu, right-click, search, registry)
    // Kept verbatim — independent of the WS/fit path that v1.10.8c fixes.
    // ============================================================

    function pasteFromClipboard(): void {
      if (!navigator.clipboard || typeof navigator.clipboard.readText !== 'function') {
        toast.error('Paste blocked — long-press input area instead');
        return;
      }
      navigator.clipboard
        .readText()
        .then((text) => {
          if (!text) return;
          const ws = wsRef.current;
          if (ws && ws.readyState === WebSocket.OPEN) {
            try {
              ws.send(new TextEncoder().encode(text));
            } catch {
              /* ignore */
            }
          }
        })
        .catch(() => {
          toast.error('Paste blocked — long-press input area instead');
        });
    }

    term.attachCustomKeyEventHandler((e) => {
      if (e.type !== 'keydown') return true;
      const mod = e.ctrlKey || e.metaKey;
      if (!mod) return true;
      const isC = e.key === 'c' || e.key === 'C';
      const isV = e.key === 'v' || e.key === 'V';
      const isF = e.key === 'f' || e.key === 'F';
      if (isC) {
        if (term.hasSelection()) {
          const sel = term.getSelection();
          if (sel) {
            navigator.clipboard.writeText(sel).catch(() => {
              toast.error('Clipboard write failed');
            });
          }
          return false;
        }
        return !e.shiftKey;
      }
      if (isV) {
        pasteFromClipboard();
        return false;
      }
      if (isF) {
        setSearchOpenRef.current(true);
        return false;
      }
      return true;
    });

    // Long-press selection + floating menu (touch).
    let lpTimer: ReturnType<typeof setTimeout> | null = null;
    let lpStart: { x: number; y: number } | null = null;
    let lpAnchor: { col: number; bufferRow: number } | null = null;
    let inSelection = false;
    function clearLp(): void {
      if (lpTimer !== null) {
        clearTimeout(lpTimer);
        lpTimer = null;
      }
      lpStart = null;
    }
    function selectWord(col: number, bufferRow: number): boolean {
      const line = term.buffer.active.getLine(bufferRow);
      if (!line) return false;
      const text = line.translateToString(true);
      const range = wordRangeAt(text, col);
      if (!range) return false;
      term.select(range.start, bufferRow, range.end - range.start);
      return true;
    }
    function extendSelection(
      anchor: { col: number; bufferRow: number },
      to: { col: number; bufferRow: number },
    ): void {
      const a = anchor;
      const b = to;
      let s: { col: number; row: number };
      let e: { col: number; row: number };
      if (a.bufferRow < b.bufferRow || (a.bufferRow === b.bufferRow && a.col <= b.col)) {
        s = { col: a.col, row: a.bufferRow };
        e = { col: b.col, row: b.bufferRow };
      } else {
        s = { col: b.col, row: b.bufferRow };
        e = { col: a.col, row: a.bufferRow };
      }
      const rowsBetween = e.row - s.row;
      const length = rowsBetween * term.cols + (e.col - s.col) + 1;
      term.select(s.col, s.row, length);
    }
    function onTouchStart(e: TouchEvent): void {
      if (e.touches.length !== 1) return;
      const t = e.touches[0]!;
      if ((e.target as Element | null)?.closest('[data-term-menu]')) return;
      lpStart = { x: t.clientX, y: t.clientY };
      lpAnchor = pointToCell(term, ctr, t.clientX, t.clientY);
      inSelection = false;
      lpTimer = setTimeout(() => {
        if (disposed || !lpAnchor) return;
        const ok = selectWord(lpAnchor.col, lpAnchor.bufferRow);
        if (!ok) return;
        inSelection = true;
        setMenuRef.current({ x: t.clientX, y: Math.max(t.clientY - 50, 8) });
      }, LONG_PRESS_MS);
    }
    function onTouchMove(e: TouchEvent): void {
      if (e.touches.length !== 1) return;
      const t = e.touches[0]!;
      if (inSelection && lpAnchor) {
        e.preventDefault();
        const to = pointToCell(term, ctr, t.clientX, t.clientY);
        extendSelection(lpAnchor, to);
        return;
      }
      if (!lpStart) return;
      const dx = t.clientX - lpStart.x;
      const dy = t.clientY - lpStart.y;
      if (Math.abs(dx) > LONG_PRESS_TOLERANCE_PX || Math.abs(dy) > LONG_PRESS_TOLERANCE_PX) {
        clearLp();
      }
    }
    function onTouchEnd(): void {
      if (!inSelection) clearLp();
      inSelection = false;
    }
    function onTouchCancel(): void {
      clearLp();
      inSelection = false;
    }
    ctr.addEventListener('touchstart', onTouchStart, { passive: true });
    ctr.addEventListener('touchmove', onTouchMove, { passive: false });
    ctr.addEventListener('touchend', onTouchEnd, { passive: true });
    ctr.addEventListener('touchcancel', onTouchCancel, { passive: true });

    function onContextMenu(e: MouseEvent): void {
      e.preventDefault();
      setMenuRef.current({ x: e.clientX, y: e.clientY });
    }
    ctr.addEventListener('contextmenu', onContextMenu);

    function onDocPointerDown(e: PointerEvent): void {
      const t = e.target as Element | null;
      if (t && t.closest('[data-term-menu]')) return;
      setMenuRef.current(null);
    }
    document.addEventListener('pointerdown', onDocPointerDown);

    const unregister = terminalsRegistry.register(
      paneId,
      label,
      () => {
        try {
          term.focus();
        } catch {
          /* ignore */
        }
      },
      () => setSearchOpenRef.current(true),
      () => pasteFromClipboard(),
    );
    const unsubscribe = sendToTerminal.subscribe(({ pane_id, text }) => {
      if (pane_id !== paneId) return;
      const ws = wsRef.current;
      if (!ws || ws.readyState !== WebSocket.OPEN) return;
      const payload = text.endsWith('\n') ? text : `${text}\n`;
      try {
        ws.send(new TextEncoder().encode(payload));
      } catch {
        /* ignore */
      }
    });
    setChatInputsRef.current(chatInputsRegistry.list());
    const unsubChats = chatInputsRegistry.subscribe(() => {
      setChatInputsRef.current(chatInputsRegistry.list());
    });

    return () => {
      disposed = true;
      closedByUserRef.current = true;
      cancelAnimationFrame(initRaf);
      if (fontRaf !== null) cancelAnimationFrame(fontRaf);
      clearTimeout(delayedFit);
      if (reconnectTimerRef.current) {
        clearTimeout(reconnectTimerRef.current);
        reconnectTimerRef.current = null;
      }
      if (ctrlTimerRef.current) {
        clearTimeout(ctrlTimerRef.current);
        ctrlTimerRef.current = null;
      }
      if (lpTimer !== null) clearTimeout(lpTimer);
      ro.disconnect();
      document.removeEventListener('visibilitychange', onVis);
      if (vp && onVpResize) vp.removeEventListener('resize', onVpResize);
      document.removeEventListener('pointerdown', onDocPointerDown);
      ctr.removeEventListener('touchstart', onTouchStart);
      ctr.removeEventListener('touchmove', onTouchMove);
      ctr.removeEventListener('touchend', onTouchEnd);
      ctr.removeEventListener('touchcancel', onTouchCancel);
      ctr.removeEventListener('contextmenu', onContextMenu);
      unsubscribe();
      unsubChats();
      unregister();
      const ws = wsRef.current;
      if (ws) {
        try {
          ws.close(1000, 'unmount');
        } catch {
          /* ignore */
        }
        wsRef.current = null;
      }
      try {
        term.dispose();
      } catch {
        /* ignore */
      }
      termRef.current = null;
      searchRef.current = null;
      reconnectFnRef.current = () => {};
    };
  }, [sessionId, paneId, label]);

  // Refit when this pane becomes the visible/focused one (e.g. mobile pane
  // switch, maximize toggle). term.onResize will send the resize WS frame if
  // cols or rows actually changed.
  useEffect(() => {
    if (!active) return;
    const raf = requestAnimationFrame(() => {
      const t = termRef.current;
      const host = containerRef.current;
      if (!t || !host) return;
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      const core: any = (t as any)._core;
      const cellW: number | undefined = core?._renderService?.dimensions?.css?.cell?.width;
      const cellH: number | undefined = core?._renderService?.dimensions?.css?.cell?.height;
      if (!cellW || !cellH || cellW <= 0 || cellH <= 0) return;
      const cols = Math.max(2, Math.floor(host.clientWidth / cellW));
      const rows = Math.max(1, Math.floor(host.clientHeight / cellH));
      if (cols !== t.cols || rows !== t.rows) {
        try {
          t.resize(cols, rows);
        } catch {
          /* ignore */
        }
      }
    });
    return () => cancelAnimationFrame(raf);
  }, [active]);

  function actCopy(): void {
    const term = termRef.current;
    if (!term) return;
    const sel = term.getSelection();
    if (!sel) {
      setMenu(null);
      return;
    }
    navigator.clipboard.writeText(sel).catch(() => toast.error('Clipboard write failed'));
    term.clearSelection();
    setMenu(null);
  }
  function actPaste(): void {
    const reg = terminalsRegistry.get(paneId);
    reg?.paste();
    setMenu(null);
  }
  function actSelectAll(): void {
    termRef.current?.selectAll();
    setMenu(null);
  }
  function actSearch(): void {
    setSearchOpen(true);
    setMenu(null);
  }
  function actSendToChat(chatId: string): void {
    const term = termRef.current;
    if (!term) return;
    const sel = term.getSelection();
    if (!sel) {
      setMenu(null);
      return;
    }
    sendToChat.emit({ chat_id: chatId, text: sel });
    setMenu(null);
  }

  const hasSelection = !!termRef.current?.getSelection();

  return (
    <div className="relative flex flex-col flex-1 min-w-0 self-stretch w-full h-full bg-[#0b0f14]">
      {/* xterm CSS overrides live in src/styles/globals.css (boolab pattern).
          Putting them in an inline <style> here races the upstream xterm.css
          on first paint — the right-edge #000 stripe survives the override. */}
      <TerminalHotkeyBar
        ctrlArmed={ctrlArmed}
        onSendBytes={sendInput}
        onArmCtrl={armCtrl}
        onFit={triggerFit}
      />
      <div
        ref={containerRef}
        className="flex-1 min-h-0 w-full overflow-hidden"
        style={{ touchAction: 'pan-y', background: TERM_BG }}
        data-testid="terminal-pane"
      />
      {searchOpen && (
        <SearchBar
          searchRef={searchRef}
          theme={XTERM_THEME}
          onClose={() => setSearchOpen(false)}
        />
      )}
      {menu && (
        <FloatingMenu
          x={menu.x}
          y={menu.y}
          hasSelection={hasSelection}
          chatInputs={chatInputs}
          onCopy={actCopy}
          onPaste={actPaste}
          onSelectAll={actSelectAll}
          onSearch={actSearch}
          onSendToChat={actSendToChat}
          onDismiss={() => setMenu(null)}
        />
      )}
      {connState === 'reconnecting' && (
        <div className="absolute inset-x-0 top-0 bg-amber-900/85 text-amber-100 text-xs px-3 py-1 flex items-center gap-2 pointer-events-none">
          <RefreshCw size={12} className="animate-spin" />
          <span>Reconnecting…</span>
        </div>
      )}
      {connState === 'disconnected' && (
        <div className="absolute inset-x-0 top-0 bg-destructive/85 text-destructive-foreground text-xs px-3 py-1 flex items-center gap-2">
          <span>Disconnected from terminal server.</span>
          <button
            type="button"
            onClick={() => reconnectFnRef.current()}
            className="ml-auto inline-flex items-center gap-1 rounded bg-background/20 hover:bg-background/30 px-2 py-0.5"
          >
            <RefreshCw size={12} /> Reconnect
          </button>
        </div>
      )}
    </div>
  );
}

// ============================================================
// TerminalHotkeyBar — v1.10.8d port of boolab's TerminalHotkeyBar.jsx +
// terminalHotkeysStore.js DEFAULT_BAR. The catalog is hardcoded inline (no
// zustand store, no settings UI) — single-user homelab doesn't need either.
// Add new entries by extending BAR below.
// ============================================================
type Hotkey =
  | { id: string; label: string; bytes: string; sticky?: undefined }
  | { id: string; label: string; sticky: 'ctrl'; bytes?: undefined };

const HOTKEY_BAR: Hotkey[] = [
  { id: 'esc', label: 'Esc', bytes: '\x1b' },
  { id: 'shift-tab', label: '⇧Tab', bytes: '\x1b[Z' },
  { id: 'tab', label: 'Tab', bytes: '\t' },
  { id: 'ctrl', label: 'Ctrl', sticky: 'ctrl' },
  { id: 'ctrl-c', label: 'Ctrl+C', bytes: '\x03' },
  { id: 'arrow-up', label: '↑', bytes: '\x1b[A' },
  { id: 'arrow-down', label: '↓', bytes: '\x1b[B' },
  { id: 'arrow-left', label: '←', bytes: '\x1b[D' },
  { id: 'arrow-right', label: '→', bytes: '\x1b[C' },
];

interface TerminalHotkeyBarProps {
  ctrlArmed: boolean;
  onSendBytes: (bytes: string) => void;
  onArmCtrl: () => void;
  onFit: () => void;
}

function TerminalHotkeyBar({
  ctrlArmed,
  onSendBytes,
  onArmCtrl,
  onFit,
}: TerminalHotkeyBarProps) {
  // Stop the touch from reaching the terminal pane below (which calls
  // preventDefault on touchmove to suppress page-scroll). Without this a
  // tap-and-drag on a hotkey button would also scroll the terminal buffer.
  const stopTouch = useCallback((e: React.TouchEvent) => e.stopPropagation(), []);
  const press = useCallback(
    (entry: Hotkey) => {
      if (entry.sticky === 'ctrl') {
        onArmCtrl();
      } else if (entry.bytes !== undefined) {
        onSendBytes(entry.bytes);
      }
    },
    [onArmCtrl, onSendBytes],
  );

  return (
    <div
      role="toolbar"
      aria-label="Terminal hotkeys"
      className="flex shrink-0 items-center gap-1 overflow-x-auto border-b border-border bg-muted/30 px-2 py-1"
      style={{
        scrollbarWidth: 'thin',
        WebkitOverflowScrolling: 'touch',
        // Suppress iOS native swipe-back gesture starting on the bar; pinch
        // and other multi-touch gestures still pass through.
        touchAction: 'pan-x',
      }}
      onTouchStart={stopTouch}
      onTouchMove={stopTouch}
    >
      {HOTKEY_BAR.map((entry) => {
        const isCtrl = entry.sticky === 'ctrl';
        const armed = isCtrl && ctrlArmed;
        return (
          <button
            key={entry.id}
            type="button"
            onClick={() => press(entry)}
            aria-pressed={isCtrl ? armed : undefined}
            aria-label={entry.label}
            className={cn(
              'shrink-0 rounded border px-2 py-0.5 text-xs font-mono transition-colors',
              armed
                ? 'border-primary bg-primary text-primary-foreground'
                : 'border-border text-foreground hover:bg-muted',
            )}
            style={{
              minHeight: 28,
              minWidth: 36,
              WebkitTouchCallout: 'none',
              userSelect: 'none',
            }}
          >
            {entry.label}
          </button>
        );
      })}
      <button
        type="button"
        onClick={onFit}
        aria-label="Fit terminal"
        title="Fit terminal to container"
        className="shrink-0 inline-flex items-center justify-center rounded border border-border text-foreground hover:bg-muted"
        style={{
          minHeight: 28,
          minWidth: 36,
          paddingInline: 8,
          WebkitTouchCallout: 'none',
          userSelect: 'none',
        }}
      >
        <Maximize2 size={14} />
      </button>
    </div>
  );
}

// ============================================================
// FloatingMenu — kept from v1.10.4 (mobile long-press + desktop right-click)
// ============================================================
interface FloatingMenuProps {
  x: number;
  y: number;
  hasSelection: boolean;
  chatInputs: ChatInputRegistration[];
  onCopy: () => void;
  onPaste: () => void;
  onSelectAll: () => void;
  onSearch: () => void;
  onSendToChat: (chatId: string) => void;
  onDismiss: () => void;
}
function FloatingMenu({
  x,
  y,
  hasSelection,
  chatInputs,
  onCopy,
  onPaste,
  onSelectAll,
  onSearch,
  onSendToChat,
  onDismiss,
}: FloatingMenuProps) {
  const [submenu, setSubmenu] = useState(false);
  const MENU_W = 200;
  const MENU_H = 220;
  const left = Math.min(x, window.innerWidth - MENU_W - 8);
  const top = Math.min(y, window.innerHeight - MENU_H - 8);

  useEffect(() => {
    function onKey(ev: KeyboardEvent): void {
      if (ev.key === 'Escape') onDismiss();
    }
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [onDismiss]);

  const flatChat = chatInputs.length === 1 ? chatInputs[0] : null;

  return (
    <div
      data-term-menu="1"
      role="menu"
      style={{
        position: 'fixed',
        top,
        left,
        background: '#1a1d24',
        border: '1px solid #2a2d34',
        borderRadius: 8,
        padding: 4,
        fontSize: 14,
        minWidth: MENU_W,
        boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
        zIndex: 50,
        color: '#d6deeb',
      }}
    >
      <MenuItem disabled={!hasSelection} onClick={onCopy}>Copy</MenuItem>
      <MenuItem onClick={onPaste}>Paste</MenuItem>
      <MenuItem onClick={onSelectAll}>Select All</MenuItem>
      <MenuItem onClick={onSearch}>Search</MenuItem>
      {flatChat && (
        <MenuItem disabled={!hasSelection} onClick={() => onSendToChat(flatChat.chatId)}>
          Send to {flatChat.label}
        </MenuItem>
      )}
      {chatInputs.length > 1 && (
        <div>
          <button
            type="button"
            disabled={!hasSelection}
            onClick={() => setSubmenu((v) => !v)}
            style={{
              display: 'flex',
              alignItems: 'center',
              justifyContent: 'space-between',
              width: '100%',
              padding: '10px 12px',
              minHeight: 44,
              background: 'transparent',
              border: 0,
              color: hasSelection ? '#d6deeb' : '#575656',
              cursor: hasSelection ? 'pointer' : 'not-allowed',
              borderRadius: 6,
              textAlign: 'left',
            }}
          >
            <span>Send to chat</span>
            {submenu ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
          </button>
          {submenu && hasSelection && (
            <div style={{ paddingLeft: 8 }}>
              {chatInputs.map((c) => (
                <MenuItem key={c.chatId} onClick={() => onSendToChat(c.chatId)}>
                  {c.label}
                </MenuItem>
              ))}
            </div>
          )}
        </div>
      )}
      <div style={{ height: 1, background: '#2a2d34', margin: '4px 0' }} />
      <MenuItem onClick={onDismiss}>Dismiss</MenuItem>
    </div>
  );
}

function MenuItem({
  children,
  onClick,
  disabled = false,
}: {
  children: React.ReactNode;
  onClick: () => void;
  disabled?: boolean;
}) {
  return (
    <button
      type="button"
      role="menuitem"
      disabled={disabled}
      onClick={onClick}
      style={{
        display: 'block',
        width: '100%',
        padding: '10px 12px',
        minHeight: 44,
        background: 'transparent',
        border: 0,
        color: disabled ? '#575656' : '#d6deeb',
        cursor: disabled ? 'not-allowed' : 'pointer',
        textAlign: 'left',
        borderRadius: 6,
        fontSize: 14,
      }}
      onMouseEnter={(ev) => {
        if (!disabled) (ev.currentTarget as HTMLButtonElement).style.background = '#2a2d34';
      }}
      onMouseLeave={(ev) => {
        (ev.currentTarget as HTMLButtonElement).style.background = 'transparent';
      }}
    >
      {children}
    </button>
  );
}

// ============================================================
// SearchBar — kept from v1.10.4
// ============================================================
interface SearchBarProps {
  searchRef: React.MutableRefObject<SearchAddon | null>;
  theme: typeof XTERM_THEME;
  onClose: () => void;
}
function SearchBar({ searchRef, theme, onClose }: SearchBarProps) {
  const [q, setQ] = useState('');
  const [counts, setCounts] = useState<{ idx: number; total: number }>({ idx: -1, total: 0 });
  const inputRef = useRef<HTMLInputElement | null>(null);
  useEffect(() => {
    inputRef.current?.focus();
  }, []);
  useEffect(() => {
    const addon = searchRef.current;
    if (!addon) return;
    const sub = addon.onDidChangeResults(({ resultIndex, resultCount }) => {
      setCounts({ idx: resultIndex, total: resultCount });
    });
    return () => sub.dispose();
  }, [searchRef]);
  useEffect(() => {
    const addon = searchRef.current;
    if (!addon) return;
    if (q.length === 0) {
      addon.clearDecorations?.();
      setCounts({ idx: -1, total: 0 });
      return;
    }
    addon.findNext(q, {
      incremental: true,
      decorations: {
        matchBackground: theme.selectionBackground,
        matchOverviewRuler: theme.cursor,
        activeMatchBackground: theme.cursor,
        activeMatchColorOverviewRuler: theme.cursor,
      },
    });
  }, [q, searchRef, theme]);

  function findNext(): void {
    if (!q) return;
    searchRef.current?.findNext(q);
  }
  function findPrev(): void {
    if (!q) return;
    searchRef.current?.findPrevious(q);
  }
  function onKey(ev: React.KeyboardEvent<HTMLInputElement>): void {
    if (ev.key === 'Escape') {
      ev.preventDefault();
      onClose();
      return;
    }
    if (ev.key === 'Enter') {
      ev.preventDefault();
      if (ev.shiftKey) findPrev();
      else findNext();
    }
  }

  return (
    <div
      style={{
        position: 'absolute',
        top: 8,
        right: 8,
        background: '#1a1d24',
        border: '1px solid #2a2d34',
        borderRadius: 8,
        padding: 4,
        display: 'flex',
        alignItems: 'center',
        gap: 4,
        boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
        zIndex: 40,
      }}
    >
      <input
        ref={inputRef}
        value={q}
        onChange={(ev) => setQ(ev.target.value)}
        onKeyDown={onKey}
        placeholder="Search…"
        style={{
          background: 'transparent',
          border: 0,
          outline: 'none',
          color: '#d6deeb',
          padding: '8px 8px',
          fontSize: 13,
          width: 160,
          minHeight: 36,
        }}
      />
      {q.length > 0 && (
        <span
          style={{
            fontSize: 11,
            color: counts.total === 0 ? '#ef5350' : '#575656',
            minWidth: 56,
            textAlign: 'right',
            padding: '0 4px',
            whiteSpace: 'nowrap',
          }}
        >
          {counts.total === 0
            ? 'No match'
            : counts.idx === -1
              ? `${counts.total}+`
              : `${counts.idx + 1} of ${counts.total}`}
        </span>
      )}
      <button
        type="button"
        onClick={findPrev}
        aria-label="Previous match"
        title="Previous (Shift+Enter)"
        style={iconBtnStyle}
      >
        <ChevronUp size={16} />
      </button>
      <button
        type="button"
        onClick={findNext}
        aria-label="Next match"
        title="Next (Enter)"
        style={iconBtnStyle}
      >
        <ChevronDown size={16} />
      </button>
      <button
        type="button"
        onClick={onClose}
        aria-label="Close search"
        title="Close (Esc)"
        style={iconBtnStyle}
      >
        <X size={16} />
      </button>
    </div>
  );
}

const iconBtnStyle: React.CSSProperties = {
  display: 'inline-flex',
  alignItems: 'center',
  justifyContent: 'center',
  width: 44,
  height: 44,
  background: 'transparent',
  border: 0,
  color: '#d6deeb',
  cursor: 'pointer',
  borderRadius: 6,
};
