/**
 * @file TerminalView.tsx
 * @description Renders one lane's live terminal — a real `xterm.js` instance
 * attached via WebSocket to the server's `/ws-pty/:runId` path (see
 * server/lib/pty-attach.js), which is itself a `node-pty`-backed
 * `tmux attach-session`. Binary WS frames are raw PTY bytes in both
 * directions; a JSON text frame carries the initial `resize` on mount and
 * the server's one-shot `exit` notice when the pane process ends.
 * The socket auto-reconnects: a server restart kills the attach PTY, and
 * without a retry the pane silently freezes on its last painted frame — it
 * still LOOKS live, but no keystroke or mouse report reaches tmux again.
 * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
 */
import { useEffect, useRef } from "react";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import "@xterm/xterm/css/xterm.css";

interface TerminalViewProps {
  runId: string;
  wsBaseUrl: string;
}

export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) {
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const term = new Terminal({ convertEol: true, fontSize: 13, cursorBlink: true });
    const fit = new FitAddon();
    term.loadAddon(fit);
    if (containerRef.current) term.open(containerRef.current);
    fit.fit();

    // When the pane's program has NOT enabled wheel reporting, xterm falls back
    // to converting each wheel notch on an alt-screen buffer into a cursor-key
    // press (ESC[A / ESC[B). In a `claude` pane that reads as arrow up/down —
    // the wheel silently walks the prompt history instead of scrolling. Kill
    // ONLY that branch: xterm runs this same handler before sending a real SGR
    // mouse report too (`bindMouse`'s wheel case consults it), so a blanket
    // `false` also blocks tmux's own wheel scrolling. `x10` counts as off here
    // because that protocol reports button presses only, never the wheel — so
    // xterm takes the emulation path for it as well.
    term.attachCustomWheelEventHandler(() => {
      const mode = term.modes.mouseTrackingMode;
      const tracked = mode !== "none" && mode !== "x10";
      return tracked || term.buffer.active.type !== "alternate";
    });

    // xterm sends Shift+Tab as ESC[Z but — unlike plain Tab — never marks the
    // event cancelled, so the browser still runs its default action and moves
    // focus out of the terminal. The pane then only ever sees the first press,
    // which is why `shift+tab to cycle` (permission mode in claude) looked dead
    // here while working in a real terminal. Returning true keeps xterm's own
    // handling; the preventDefault only stops focus from escaping.
    term.attachCustomKeyEventHandler((ev) => {
      if (ev.type === "keydown" && ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
      return true;
    });

    const decoder = new TextDecoder();
    let ws: WebSocket | null = null;
    let retryTimer: ReturnType<typeof setTimeout> | null = null;
    let ended = false; // server said the pane exited — nothing left to attach to
    let disposed = false;

    const connect = () => {
      const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}`);
      ws = sock;
      // Server sends PTY bytes as binary frames — default binaryType ("blob")
      // would hand onmessage a Blob that the string checks below never match,
      // silently dropping all terminal output. "arraybuffer" keeps it sync.
      sock.binaryType = "arraybuffer";

      sock.onopen = () => {
        sock.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
      };
      sock.onmessage = (event) => {
        const isArrayBuffer = Object.prototype.toString.call(event.data) === "[object ArrayBuffer]";
        const data = isArrayBuffer ? decoder.decode(event.data as ArrayBuffer) : event.data;
        if (typeof data === "string") {
          // A JSON control frame is the only thing that starts with `{"type"`.
          if (data.startsWith('{"type"')) {
            try {
              const msg = JSON.parse(data);
              if (msg.type === "exit") {
                ended = true;
                term.write(`\r\n[session ended, exit code ${msg.code}]\r\n`);
              }
              return;
            } catch {
              /* not JSON — fall through and render as PTY output */
            }
          }
          term.write(data);
        }
      };
      sock.onclose = () => {
        if (disposed || ended) return;
        // tmux keeps the session alive across a server restart, so re-attaching
        // repaints the pane in full — no output is lost by retrying.
        retryTimer = setTimeout(connect, 1500);
      };
    };
    connect();

    const dataDisposable = term.onData((data) => {
      if (ws && ws.readyState === WebSocket.OPEN) ws.send(data);
    });

    const resizeObserver = new ResizeObserver(() => {
      fit.fit();
      if (ws && ws.readyState === WebSocket.OPEN) {
        ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
      }
    });
    if (containerRef.current) resizeObserver.observe(containerRef.current);

    return () => {
      disposed = true;
      if (retryTimer) clearTimeout(retryTimer);
      resizeObserver.disconnect();
      dataDisposable.dispose();
      ws?.close();
      term.dispose();
    };
  }, [runId, wsBaseUrl]);

  return <div ref={containerRef} className="h-full w-full" data-testid="terminal-view" />;
}
