// Embedded xterm.js terminal pane for v1.1 terminal mode.
//
// Renders a single live PTY bound to a claudex session. Handles:
//   1. Initial mount: reattach to an existing PTY if one is
//      registered in the store, otherwise spawn a fresh one.
//   2. Ring buffer replay on reattach so the xterm picks up the
//      recent stdout history.
//   3. Bi-directional I/O: stdout chunks arrive via `pty:data`
//      events and get written to the terminal; keystrokes flow out
//      via `onData` → `writePty`.
//   4. Resize propagation: xterm's `onResize` (driven by a
//      `ResizeObserver` on the container) pushes new dimensions
//      down to the PTY master.
//
// **Critical lifecycle rule**: unmount does NOT close the PTY.
// The whole codex-parallel-threads goal is that terminals survive
// session switches, mode toggles, and anything short of an explicit
// user teardown. The only paths that kill a PTY are
// `store.closeSessionPty` and the window-destroy reaper in Rust.

import { useEffect, useRef } from "react";

import {
  getPtyBuffer,
  logFrontend,
  onPtyData,
  resizePty,
  spawnPty,
  writePty,
  type PtyDataEvent,
} from "@/lib/ipc/client";
import { useSessionStore } from "@/lib/store/sessions";

import { FitAddon } from "@xterm/addon-fit";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { Terminal } from "@xterm/xterm";

// NOTE: WebglAddon is intentionally NOT loaded. The macOS webview
// has a very low limit on live WebGL contexts (~8) and rapid
// session switching blows past that limit every few clicks,
// stalling the main thread for seconds while the GPU driver
// tears contexts down. The default DOM renderer is fast enough
// for our single-terminal use case and has zero init cost per
// mount. Revisit if we ever want to render multi-pane tmux.

const trace = (msg: string, extra?: Record<string, unknown>) => {
  const payload = extra ? `${msg} ${JSON.stringify(extra)}` : msg;
  // eslint-disable-next-line no-console
  console.debug("[TerminalPane]", payload);
  void logFrontend("debug", "TerminalPane", payload);
};

/** Module-level dedup lock for concurrent spawn attempts on the
 *  same `sessionId`. React 19 StrictMode double-invokes effects
 *  (mount → cleanup → mount), and both invocations used to race
 *  into `spawn_pty`, producing TWO claude subprocesses per click.
 *  The first one was then orphaned — its ptyId never landed in
 *  the store so the frontend lost track of it, but the backend
 *  kept the subprocess alive until window-destroy.
 *
 *  With this map, the second mount awaits the first mount's
 *  promise and reuses the same ptyId. Exactly one subprocess per
 *  session, StrictMode-safe. */
const spawnLocks = new Map<string, Promise<string>>();

async function getOrSpawnPty(
  sessionId: string,
  cwd: string,
  claudeArgs: string[],
  cols: number,
  rows: number,
): Promise<string> {
  const existing = useSessionStore.getState().ptyIds.get(sessionId);
  if (existing) return existing;

  const inflight = spawnLocks.get(sessionId);
  if (inflight) return inflight;

  const promise = (async () => {
    const newId = crypto.randomUUID();
    trace("spawn path", { ptyId: newId, cols, rows });
    await spawnPty({
      ptyId: newId,
      sessionId,
      cwd,
      args: claudeArgs,
      cols,
      rows,
    });
    // Register in the store before returning so any second mount
    // awaiting the same promise can look up the ptyId via the
    // `existing` check above on its next call.
    useSessionStore.getState().registerPty(sessionId, {
      ptyId: newId,
      sessionId,
      cwd,
      startedAt: new Date().toISOString(),
    });
    trace("spawn_pty ok", { ptyId: newId });
    return newId;
  })();

  spawnLocks.set(sessionId, promise);
  try {
    return await promise;
  } finally {
    spawnLocks.delete(sessionId);
  }
}

/** Write a large base64 payload into xterm in paced chunks so we
 *  don't block the UI thread. xterm.write accepts a callback that
 *  fires once the write has been parsed and rendered, which we use
 *  to schedule the next chunk. For a fresh mount with a 200 KB
 *  ring buffer replay this keeps the main thread responsive
 *  (keystrokes still register) instead of dropping a multi-second
 *  stall while xterm parses the whole blob. */
const REPLAY_CHUNK_BYTES = 8 * 1024;

function writeBase64Chunked(term: Terminal, b64: string): void {
  const binary = atob(b64);
  const total = binary.length;
  if (total === 0) return;
  if (total <= REPLAY_CHUNK_BYTES) {
    term.write(decodeBinary(binary));
    return;
  }
  let offset = 0;
  const step = () => {
    const end = Math.min(offset + REPLAY_CHUNK_BYTES, total);
    const slice = decodeBinary(binary.slice(offset, end));
    offset = end;
    if (offset >= total) {
      term.write(slice);
    } else {
      term.write(slice, step);
    }
  };
  step();
}

function decodeBinary(binary: string): Uint8Array {
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) {
    bytes[i] = binary.charCodeAt(i);
  }
  return bytes;
}

/** Theme matching the claudex design tokens in `src/index.css`. */
const TERMINAL_THEME = {
  foreground: "#d4d4d8",
  background: "#0a0a0b",
  cursor: "#f97316",
  cursorAccent: "#0a0a0b",
  selectionBackground: "rgba(249, 115, 22, 0.3)",
  black: "#1a1a1a",
  red: "#ef4444",
  green: "#22c55e",
  yellow: "#eab308",
  blue: "#3b82f6",
  magenta: "#a855f7",
  cyan: "#06b6d4",
  white: "#e5e7eb",
  brightBlack: "#4b5563",
  brightRed: "#fca5a5",
  brightGreen: "#86efac",
  brightYellow: "#fef08a",
  brightBlue: "#93c5fd",
  brightMagenta: "#e9d5ff",
  brightCyan: "#a5f3fc",
  brightWhite: "#f9fafb",
};

export interface TerminalPaneProps {
  /** Claudex store key for this session (may carry the
   *  `pending-` prefix for in-flight new sessions). Used as the
   *  binding key in `store.ptyIds`. */
  sessionId: string;
  /** Absolute working directory for the spawned claude subprocess.
   *  Must exist on disk. */
  cwd: string;
  /** Claude CLI args — usually `["--resume", <id>]` or
   *  `["--session-id", <uuid>]`. Baked in at mount time; respawning
   *  with different args requires remounting the component. */
  claudeArgs: string[];
}

export function TerminalPane({ sessionId, cwd, claudeArgs }: TerminalPaneProps) {
  const containerRef = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;
    // Clear any stale xterm DOM left behind by a previous mount.
    container.replaceChildren();

    const term = new Terminal({
      theme: TERMINAL_THEME,
      fontFamily:
        '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
      fontSize: 13,
      lineHeight: 1.2,
      cursorBlink: true,
      scrollback: 10000,
      // Required before we can set `term.unicode.activeVersion`.
      allowProposedApi: true,
    });

    const fit = new FitAddon();
    term.loadAddon(fit);
    term.loadAddon(new WebLinksAddon());
    term.loadAddon(new Unicode11Addon());

    term.open(container);
    try {
      term.unicode.activeVersion = "11";
    } catch {
      // Older xterm builds may not have unicode registered — ignore.
    }

    try {
      fit.fit();
    } catch {
      // No layout yet — ResizeObserver below will retry on first
      // real box update.
    }

    let ptyId: string | null = null;
    let unlistenData: (() => void) | null = null;
    let cancelled = false;
    let disposeListeners: Array<() => void> = [];

    const attach = async () => {
      // Resolve a ptyId — either reattach to an already-running
      // subprocess or dedupe-safely spawn a new one. The spawn
      // lock guarantees at most one spawn per sessionId.
      const existing = useSessionStore.getState().ptyIds.get(sessionId);
      const reattach = !!existing;
      let resolved: string;
      try {
        resolved = await getOrSpawnPty(
          sessionId,
          cwd,
          claudeArgs,
          term.cols,
          term.rows,
        );
      } catch (err) {
        if (!cancelled) {
          term.write(
            `\r\n\x1b[31m[claudex] spawn_pty failed: ${formatErr(err)}\x1b[0m\r\n\x1b[90m${cwd}\x1b[0m\r\n`,
          );
        }
        return;
      }
      if (cancelled) return;
      ptyId = resolved;

      // Replay on reattach. Chunked inside `writeBase64Chunked`
      // so a ~200 KB scrollback doesn't stall the main thread.
      if (reattach) {
        try {
          const snapshot = await getPtyBuffer(resolved);
          if (cancelled) return;
          if (snapshot.length > 0) writeBase64Chunked(term, snapshot);
        } catch (err) {
          trace("reattach replay failed", { error: String(err) });
        }
      }

      // Per-pty event listener. Only wakes up for our own bytes.
      try {
        const pid = resolved;
        const un = await onPtyData(pid, (ev: PtyDataEvent) => {
          writeBase64Chunked(term, ev.base64);
        });
        if (cancelled) {
          un();
        } else {
          unlistenData = un;
        }
      } catch (err) {
        trace("onPtyData listen failed", { error: String(err) });
      }

      const dataDispose = term.onData((data) => {
        if (!ptyId) return;
        void writePty(ptyId, data).catch(() => {
          /* silent — the next keystroke will retry */
        });
      });
      disposeListeners.push(() => dataDispose.dispose());

      const resizeDispose = term.onResize(({ cols, rows }) => {
        if (!ptyId) return;
        void resizePty(ptyId, cols, rows).catch(() => {
          /* silent — harmless if the backend dropped the pty */
        });
      });
      disposeListeners.push(() => resizeDispose.dispose());
    };

    void attach();

    // Keep the terminal in sync with its container's box. ResizeObserver
    // fires whenever the parent pane grows or shrinks (window resize,
    // splitter drag, etc.).
    const ro = new ResizeObserver(() => {
      try {
        fit.fit();
      } catch {
        // Container has no layout — harmless to ignore.
      }
    });
    ro.observe(container);

    return () => {
      cancelled = true;
      ro.disconnect();
      if (unlistenData) unlistenData();
      for (const d of disposeListeners) d();
      disposeListeners = [];
      // Do NOT close the PTY — it must survive unmount so switching
      // back to this session reattaches. Only closeSessionPty and
      // the window-destroy reaper kill PTYs.
      try {
        term.dispose();
      } catch {
        /* disposed twice — harmless */
      }
      container.replaceChildren();
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [sessionId]);

  return (
    <div className="flex h-full w-full flex-col bg-bg-0">
      <div ref={containerRef} className="min-h-0 flex-1 overflow-hidden" />
    </div>
  );
}

function formatErr(err: unknown): string {
  if (err instanceof Error) return err.message;
  if (typeof err === "string") return err;
  return JSON.stringify(err);
}
