import type { CSSProperties } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Terminal as XTerm } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { WebglAddon } from '@xterm/addon-webgl';
import { Unicode11Addon } from '@xterm/addon-unicode11';
import { WebLinksAddon } from '@xterm/addon-web-links';
import { SearchAddon } from '@xterm/addon-search';
import '@xterm/xterm/css/xterm.css';
import { useMounted } from 'hooks/useMounted';
import { copyToClipboard } from 'helpers/clipboard';

// Scrollback the browser keeps. The sidecar keeps its own replay buffer for re-attach; this is what you
// can scroll back to within a live session, and 1000 (xterm's default) is about one long agent turn.
const SCROLLBACK_LINES = 10_000;

type TerminalTheme = {
  background?: string;
  foreground?: string;
  cursor?: string;
  selectionBackground?: string;
};

export type TerminalConnectionState = 'connected' | 'disconnected' | 'reconnecting';

export type TerminalViewProps = {
  className?: string;
  style?: CSSProperties;
  wsPath?: string;
  sessionId?: string;
  cwd?: string;
  command?: string;
  initialInput?: string;
  fontSize?: number;
  fontFamily?: string;
  theme?: TerminalTheme;
  autoFocus?: boolean;
  onReady?: (term: XTerm) => void;
  onExit?: () => void;
  onCommandDone?: (exitCode: number, output: string) => void;
  onDisconnect?: () => void;
  onConnectionChange?: (state: TerminalConnectionState) => void;
  /** The shell's own title (OSC 0/2) — what's actually running, for the panel header. */
  onTitleChange?: (title: string) => void;
  /** The shell rang the bell. Useful to flag a finished agent turn on a panel you aren't watching. */
  onBell?: () => void;
};

const DEFAULT_THEME: Required<TerminalTheme> = {
  background: '#1a1a2e',
  foreground: '#e0e0e0',
  cursor: '#e0e0e0',
  selectionBackground: '#3a3a5e',
};

const buildWsUrl = (
  wsPath: string,
  sessionId?: string,
  cwd?: string,
  command?: string,
  cols?: number,
  rows?: number,
) => {
  const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
  const token = localStorage.getItem('BEARER_TOKEN') ?? '';
  const separator = wsPath.includes('?') ? '&' : '?';
  let url = `${protocol}//${window.location.host}${wsPath}${separator}token=${encodeURIComponent(token)}`;
  if (sessionId) url += `&sessionId=${encodeURIComponent(sessionId)}`;
  if (cwd) url += `&cwd=${encodeURIComponent(cwd)}`;
  if (command) url += `&command=${encodeURIComponent(command)}`;
  if (cols) url += `&cols=${cols}`;
  if (rows) url += `&rows=${rows}`;
  return url;
};

export const TerminalView = ({
  className,
  style,
  wsPath = '/api/terminal/ws',
  sessionId,
  cwd,
  command,
  initialInput,
  fontSize = 14,
  fontFamily = 'Menlo, Monaco, "Courier New", monospace',
  theme,
  autoFocus = true,
  onReady,
  onExit,
  onCommandDone,
  onDisconnect,
  onConnectionChange,
  onTitleChange,
  onBell,
}: TerminalViewProps) => {
  const containerRef = useRef<HTMLDivElement>(null);
  const termRef = useRef<XTerm | null>(null);
  const wsRef = useRef<WebSocket | null>(null);
  const searchRef = useRef<SearchAddon | null>(null);
  const [searchOpen, setSearchOpen] = useState(false);
  const [searchTerm, setSearchTerm] = useState('');
  const isMounted = useMounted();
  const onReadyRef = useRef<TerminalViewProps['onReady']>(onReady);
  const onExitRef = useRef<TerminalViewProps['onExit']>(onExit);
  const onCommandDoneRef = useRef<TerminalViewProps['onCommandDone']>(onCommandDone);
  const onDisconnectRef = useRef<TerminalViewProps['onDisconnect']>(onDisconnect);
  const onConnectionChangeRef = useRef<TerminalViewProps['onConnectionChange']>(onConnectionChange);
  const commandRef = useRef(command);
  const initialInputRef = useRef(initialInput);

  onReadyRef.current = onReady;
  onExitRef.current = onExit;
  onCommandDoneRef.current = onCommandDone;
  onDisconnectRef.current = onDisconnect;
  onConnectionChangeRef.current = onConnectionChange;
  commandRef.current = command;
  initialInputRef.current = initialInput;

  const onTitleChangeRef = useRef<TerminalViewProps['onTitleChange']>(onTitleChange);
  const onBellRef = useRef<TerminalViewProps['onBell']>(onBell);
  onTitleChangeRef.current = onTitleChange;
  onBellRef.current = onBell;

  const background = theme?.background ?? DEFAULT_THEME.background;
  const foreground = theme?.foreground ?? DEFAULT_THEME.foreground;
  const cursor = theme?.cursor ?? DEFAULT_THEME.cursor;
  const selectionBackground = theme?.selectionBackground ?? DEFAULT_THEME.selectionBackground;

  useEffect(() => {
    if (!isMounted) return;
    const container = containerRef.current;
    if (!container) return;

    let disposed = false;

    // Step 2: Create at 80x24, then fit after layout settles.
    const term = new XTerm({
      cursorBlink: true,
      cols: 80,
      rows: 24,
      fontSize,
      fontFamily,
      scrollback: SCROLLBACK_LINES,
      allowProposedApi: true, // required by the unicode11 addon
      // On macOS, Option is a compose key by default, so Alt-based bindings never reach the shell — which
      // silently breaks the M-arrow / M-hjkl pane switching in the seeded .tmux.conf. Treating it as Meta
      // sends the ESC-prefixed sequence tmux expects. No effect on other platforms.
      macOptionIsMeta: true,
      // Right-click selects a word rather than opening the browser menu over the terminal.
      rightClickSelectsWord: true,
      theme: {
        background,
        foreground,
        cursor,
        selectionBackground,
      },
    });

    const fitAddon = new FitAddon();
    term.loadAddon(fitAddon);

    // Correct display widths for emoji, CJK and box-drawing. Agent TUIs lean on all three, and the
    // default (unicode 6) tables are what make their frames look one column out.
    const unicode11 = new Unicode11Addon();
    term.loadAddon(unicode11);
    term.unicode.activeVersion = '11';

    // Clickable URLs in output.
    term.loadAddon(new WebLinksAddon());

    // ── OSC 52: let a program in the terminal put text on the user's clipboard ──
    //
    // xterm.js does not handle OSC 52 unless something registers for it, and nothing did. Programs that
    // offer "press c to copy" emit the sequence and it vanished — so the confirmation they print was true
    // about having SENT it and false about anything arriving. Observed while signing a member into Claude
    // Code on 2026-08-12: its first-run login prints a URL too long to read off a wrapped pane and offers
    // to copy it, "(Copied!)" appeared, and the clipboard was untouched. Without this, first-run login in
    // the web terminal cannot be completed at all, which is every new member's first five minutes.
    //
    // The payload is `<target>;<base64>`. A lone `?` in the data position is a READ request — a program
    // asking the terminal to hand it whatever the user has copied — and it is deliberately not answered:
    // a shell should not be able to exfiltrate the clipboard of the person watching it. Writes only.
    //
    // `navigator.clipboard` needs a secure context and generally a user gesture; the keypress that caused
    // the sequence is that gesture. It can still be refused, so the failure is swallowed rather than
    // thrown into the render — a copy that does not land is the status quo, not a reason to break the pane.
    term.parser.registerOscHandler(52, (payload) => {
      const data = payload.slice(payload.indexOf(';') + 1);
      if (!data || data === '?') return true;
      try {
        const bytes = Uint8Array.from(atob(data), (c) => c.charCodeAt(0));
        void copyToClipboard(new TextDecoder().decode(bytes));
      } catch {
        // Malformed base64, no clipboard API, or permission refused.
      }
      return true;
    });

    const searchAddon = new SearchAddon();
    term.loadAddon(searchAddon);
    searchRef.current = searchAddon;

    term.open(container);

    // WebGL renderer — the reason fast-repainting output feels smooth instead of syrupy. It can fail on
    // machines with no GL context, and the context can be lost later (GPU reset, tab backgrounded too
    // long), so both paths fall back to the DOM renderer rather than leaving a dead canvas.
    let webgl: WebglAddon | null = null;
    try {
      webgl = new WebglAddon();
      webgl.onContextLoss(() => {
        webgl?.dispose();
        webgl = null;
      });
      term.loadAddon(webgl);
    } catch {
      webgl = null;
    }

    if (autoFocus) term.focus();

    termRef.current = term;
    onReadyRef.current?.(term);

    const titleDisposable = term.onTitleChange((title) => onTitleChangeRef.current?.(title));
    const bellDisposable = term.onBell(() => onBellRef.current?.());

    // Reconnect state
    let reconnectAttempts = 0;
    let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
    let processExited = false;
    const MAX_RECONNECT_ATTEMPTS = 5;
    const RECONNECT_DELAYS = [1000, 2000, 3000, 5000, 5000];

    let commandSent = false;
    let commandDone = false;
    let initialInputSent = false;
    let commandOutput = '';
    const EXIT_MARKER = '__OFFICER_EXIT_';
    // eslint-disable-next-line no-control-regex
    const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07/g, '');

    const connect = () => {
      if (disposed) return;

      // ── One socket per session, enforced here rather than at each caller ──
      //
      // There are two independent reconnect triggers — `handleClose` arms a timer, and
      // `handleVisibilityChange` fires when the tab comes back — and they overlap exactly: a pending timer
      // leaves `readyState === CLOSED`, which is the visibility handler's own condition to reconnect. Both
      // then ran, and the session ended up with TWO live sockets: every keystroke delivered twice, two
      // `replay` frames fighting over the screen, two `resize` frames racing. That is the "goes weird after
      // reconnecting" report, and it is also why only one socket was ever cleaned up —
      // `__terminalCleanup` is overwritten by whichever connect ran last.
      //
      // Guarding the entry point covers both callers and any future third one.
      if (reconnectTimer) {
        clearTimeout(reconnectTimer);
        reconnectTimer = null;
      }
      const existing = wsRef.current;
      if (existing && (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING)) {
        return;
      }

      fitAddon.fit();
      const cols = term.cols;
      const rows = term.rows;

      const ws = new WebSocket(buildWsUrl(wsPath, sessionId, cwd, command, cols, rows));
      wsRef.current = ws;

      const cleanupWs = () => {
        ws.removeEventListener('open', handleOpen);
        ws.removeEventListener('message', handleMessage);
        ws.removeEventListener('close', handleClose);
        ws.close();
      };

      const handleOpen = () => {
        reconnectAttempts = 0;
        onConnectionChangeRef.current?.('connected');
        ws.send(JSON.stringify({ type: 'resize', cols, rows }));
      };

      const handleMessage = (ev: MessageEvent) => {
        try {
          const msg = JSON.parse(ev.data as string);
          if (msg.type === 'output') {
            term.write(msg.data);
            if (commandRef.current && !commandSent) {
              commandSent = true;
              setTimeout(() => {
                if (ws.readyState === WebSocket.OPEN) {
                  const wrapped = onCommandDoneRef.current
                    ? `${commandRef.current}; echo "${EXIT_MARKER}$?__"`
                    : commandRef.current;
                  ws.send(JSON.stringify({ type: 'input', data: wrapped + '\r' }));
                }
              }, 100);
            }
            if (!commandRef.current && initialInputRef.current && !initialInputSent) {
              initialInputSent = true;
              setTimeout(() => {
                if (ws.readyState === WebSocket.OPEN) {
                  ws.send(JSON.stringify({ type: 'input', data: initialInputRef.current + '\r' }));
                }
              }, 500);
            }
            if (commandSent && !commandDone && onCommandDoneRef.current) {
              commandOutput += msg.data as string;
              const markerMatch = stripAnsi(commandOutput).match(/__OFFICER_EXIT_(\d+)__/);
              if (markerMatch) {
                const exitCode = Number(markerMatch[1]);
                const raw = stripAnsi(commandOutput).slice(0, markerMatch.index);
                const lines = raw
                  .split(/\r?\n/)
                  .map((l) => l.trim())
                  .filter(Boolean);
                const cmdLine = lines.findIndex((l) => l.includes(commandRef.current!.slice(0, 20)));
                const output = lines
                  .slice(cmdLine >= 0 ? cmdLine + 1 : 0)
                  .join('\n')
                  .trim();
                commandDone = true;
                onCommandDoneRef.current(exitCode, output);
              }
            }
          } else if (msg.type === 'replay') {
            // Scrollback for a session we are re-attaching to. On a page load this terminal is empty and
            // the reset is a no-op; after an officer restart it still holds what it had before the socket
            // dropped, and the replay overlaps it — so rebuild from the sidecar's copy rather than append
            // a second one. Deliberately outside the `output` branch: replay must not re-trigger the
            // command/initial-input logic above.
            //
            // It does however PROVE the session already exists and has already run whatever we would have
            // typed into it — `initialInputSent` is scoped to this mount, so without this a remount (a layout
            // change, a route change, reopening the panel) would type the command a second time into a live
            // shell. Harmless for `ls`; for the Headscale console it means an `ssh` nested inside the `ssh`
            // you were already in.
            initialInputSent = true;
            term.reset();
            term.write(msg.data);
          } else if (msg.type === 'exit') {
            processExited = true;
            term.write('\r\n[Process exited]\r\n');
            onExitRef.current?.();
          } else if (msg.type === 'detached') {
            term.write('\r\n[Session taken over]\r\n');
          }
        } catch {
          // ignore
        }
      };

      const handleClose = () => {
        cleanupWs();
        // A socket that is no longer the session's must not arm a reconnect: its close arrives AFTER the
        // replacement is already open, and the timer it sets would then tear down a healthy connection to
        // build a third. Only the current socket speaks for the session.
        if (wsRef.current !== ws) return;
        if (disposed || processExited) {
          onConnectionChangeRef.current?.('disconnected');
          term.write('\r\n[Disconnected]\r\n');
          onDisconnectRef.current?.();
          return;
        }
        if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
          const delay = RECONNECT_DELAYS[reconnectAttempts] ?? 5000;
          reconnectAttempts++;
          onConnectionChangeRef.current?.('reconnecting');
          term.write(`\r\n[Reconnecting (${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})...]\r\n`);
          reconnectTimer = setTimeout(connect, delay);
        } else {
          onConnectionChangeRef.current?.('disconnected');
          term.write('\r\n[Disconnected]\r\n');
          onDisconnectRef.current?.();
        }
      };

      ws.addEventListener('open', handleOpen);
      ws.addEventListener('message', handleMessage);
      ws.addEventListener('close', handleClose);

      (container as any).__terminalCleanup = () => {
        cleanupWs();
      };
    };

    const dataDisposable = term.onData((data) => {
      if (wsRef.current?.readyState === WebSocket.OPEN) {
        wsRef.current.send(JSON.stringify({ type: 'input', data }));
      }
    });

    // Ctrl/Cmd-Shift-F opens the find bar. Deliberately NOT plain Ctrl-F: that is "forward one character"
    // in readline and emacs, and swallowing it would break every shell in the app. Returning false stops
    // xterm forwarding the chord to the PTY.
    term.attachCustomKeyEventHandler((ev) => {
      if (ev.type === 'keydown' && ev.shiftKey && (ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === 'f') {
        setSearchOpen(true);
        return false;
      }
      return true;
    });

    // Reconnect when tab becomes visible again
    // Coming back to the tab should not WAIT out a backoff that may be seconds long — but it must not open a
    // second socket either. `connect` now decides that (it returns early on a live one), so this only has to
    // say "try now", and the CLOSED/CLOSING distinction that used to be here stops mattering.
    const handleVisibilityChange = () => {
      if (document.visibilityState !== 'visible' || disposed || processExited) return;
      reconnectAttempts = 0;
      connect();
    };
    document.addEventListener('visibilitychange', handleVisibilityChange);

    // Refit on any panel resize and tell the PTY the new size. Without this the shell keeps the cols/rows
    // it was born with, so anything drawing a full screen — an agent TUI, top, vim — renders into a box
    // that no longer matches the one you're looking at. fit() used to run once, at connect.
    let resizeFrame = 0;
    const applyFit = () => {
      if (disposed) return;
      try {
        fitAddon.fit();
      } catch {
        return; // container has no layout yet — the initial double-rAF path will fit it
      }
      const ws = wsRef.current;
      if (ws?.readyState === WebSocket.OPEN) {
        ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
      }
    };
    // Coalesced to one fit per frame: dragging a panel edge fires this continuously and each fit reflows.
    const resizeObserver = new ResizeObserver(() => {
      cancelAnimationFrame(resizeFrame);
      resizeFrame = requestAnimationFrame(applyFit);
    });
    resizeObserver.observe(container);

    // Wait for layout to fully settle (double rAF), then fit + connect
    requestAnimationFrame(() => {
      requestAnimationFrame(() => {
        connect();
      });
    });

    const originalCleanup = () => {
      dataDisposable.dispose();
      titleDisposable.dispose();
      bellDisposable.dispose();
      resizeObserver.disconnect();
      cancelAnimationFrame(resizeFrame);
      webgl?.dispose();
      searchRef.current = null;
      document.removeEventListener('visibilitychange', handleVisibilityChange);
      if (reconnectTimer) clearTimeout(reconnectTimer);
      const wsCleanup = (container as any).__terminalCleanup as (() => void) | undefined;
      wsCleanup?.();
    };

    (container as any).__terminalFullCleanup = originalCleanup;

    return () => {
      disposed = true;
      const cleanup = (container as any).__terminalFullCleanup as (() => void) | undefined;
      cleanup?.();
      delete (container as any).__terminalFullCleanup;
      delete (container as any).__terminalCleanup;
      wsRef.current = null;
      termRef.current?.dispose();
      termRef.current = null;
    };
  }, [
    isMounted,
    wsPath,
    sessionId,
    cwd,
    command,
    fontSize,
    fontFamily,
    background,
    foreground,
    cursor,
    selectionBackground,
    autoFocus,
  ]);

  const closeSearch = () => {
    searchRef.current?.clearDecorations();
    setSearchOpen(false);
    setSearchTerm('');
    termRef.current?.focus();
  };

  const runSearch = (value: string, direction: 'next' | 'previous') => {
    const addon = searchRef.current;
    if (!addon || !value) return;
    const opts = {
      decorations: {
        activeMatchBackground: cursor,
        matchBackground: selectionBackground,
        // Overview-ruler marks: the scrollbar-side hints showing where matches sit in the scrollback.
        activeMatchColorOverviewRuler: cursor,
        matchOverviewRuler: selectionBackground,
      },
    };
    if (direction === 'next') addon.findNext(value, opts);
    else addon.findPrevious(value, opts);
  };

  return (
    <div
      className={className}
      style={{ backgroundColor: background, overflow: 'hidden', position: 'relative', ...style }}
    >
      <div ref={containerRef} style={{ width: '100%', height: '100%' }} />
      {searchOpen && (
        <div
          style={{
            position: 'absolute',
            top: 8,
            right: 8,
            display: 'flex',
            alignItems: 'center',
            gap: 4,
            padding: '4px 6px',
            borderRadius: 6,
            background: background,
            border: `1px solid ${selectionBackground}`,
            boxShadow: '0 2px 8px rgba(0,0,0,0.35)',
            zIndex: 5,
          }}
        >
          <input
            autoFocus
            value={searchTerm}
            placeholder="Find"
            onChange={(ev) => {
              setSearchTerm(ev.target.value);
              runSearch(ev.target.value, 'next');
            }}
            onKeyDown={(ev) => {
              if (ev.key === 'Enter') runSearch(searchTerm, ev.shiftKey ? 'previous' : 'next');
              if (ev.key === 'Escape') closeSearch();
            }}
            style={{
              background: 'transparent',
              border: 'none',
              outline: 'none',
              color: foreground,
              fontSize: 12,
              width: 140,
            }}
          />
          <button
            type="button"
            onClick={() => runSearch(searchTerm, 'previous')}
            style={{ background: 'transparent', border: 'none', color: foreground, cursor: 'pointer' }}
          >
            ↑
          </button>
          <button
            type="button"
            onClick={() => runSearch(searchTerm, 'next')}
            style={{ background: 'transparent', border: 'none', color: foreground, cursor: 'pointer' }}
          >
            ↓
          </button>
          <button
            type="button"
            onClick={closeSearch}
            style={{ background: 'transparent', border: 'none', color: foreground, cursor: 'pointer' }}
          >
            ✕
          </button>
        </div>
      )}
    </div>
  );
};
