/**
 * xterm.js wrapper (L3). Mounts a `Terminal`, wires it bidirectionally to the
 * {@link TerminalGateway} port (or a custom opener), and fits it to its container:
 *
 * - PTY output (gateway `onData`) → `term.write(bytes)`.
 * - xterm `onData` (keystrokes) → `handle.write(bytes)` in **both** modes: the
 *   agent cell is a **native terminal** (ARCHITECTURE §20), so human keystrokes
 *   (Enter included) reach the PTY unconditionally, exactly like a raw shell.
 *   In agent mode the keystrokes are additionally reported to the write-portal
 *   (a {@link WritePortal} supplied via `portal`) which (a) keeps a *human line*
 *   counter (+1 per printable, reset on Enter/Ctrl-C) and (b) can momentarily
 *   **suspend** the keystroke relay while it injects a delegation. The PTY
 *   **output** path stays live and unchanged in both modes.
 * - container resize (fit addon) → `handle.resize(rows, cols)`.
 *
 * Pure presentation: it only knows the port, never `invoke()`/`Channel`
 * (ARCHITECTURE §1.3). The cwd it opens in is supplied by the caller (the
 * project tab passes the project root).
 *
 * An optional `open` prop can override the default `terminal.openTerminal` call,
 * enabling the agent terminal to reuse this component with `agent.launchAgent`.
 *
 * **PTY lifecycle is decoupled from the view lifecycle.** Navigating (switching
 * layout or project tab) tears this view down but must NEVER kill the backend
 * PTY — otherwise running AIs would be cut off. So:
 * - On unmount the cleanup only `detach`es (drops the local output subscription)
 *   and disposes xterm; it never calls `handle.close()`. Killing a PTY is an
 *   explicit user action handled elsewhere (the terminal's close button).
 * - On mount, if a `sessionId` already exists for this cell (persisted by the
 *   caller via `onSessionId`), the view **re-attaches** to the still-running PTY
 *   — repainting its scrollback and resuming its output — instead of opening a
 *   fresh one. If the session is gone (was explicitly closed), it opens fresh.
 */

import { useEffect, useRef } from "react";

import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import "@xterm/xterm/css/xterm.css";

import { useGateways } from "@/app/di";
import type {
  OpenTerminalOptions,
  ReattachResult,
  TerminalHandle,
  WritePortal,
} from "@/ports";

interface TerminalViewProps {
  /** Working directory the shell opens in (typically the project root). */
  cwd: string;
  /**
   * Optional custom opener. When provided, it is used instead of the terminal
   * gateway's `openTerminal`. This lets agent terminals reuse the same xterm
   * wrapper with a different backend opener (e.g. `launchAgent`).
   * When absent, falls back to `terminal.openTerminal` from the DI context.
   */
  open?: (
    options: OpenTerminalOptions,
    onData: (bytes: Uint8Array) => void,
  ) => Promise<TerminalHandle>;
  /**
   * Optional re-attach opener. When provided together with a {@link sessionId},
   * the view re-binds to the existing live PTY instead of opening a new one.
   * When absent, falls back to the terminal gateway's `reattach`.
   */
  reattach?: (
    sessionId: string,
    onData: (bytes: Uint8Array) => void,
  ) => Promise<ReattachResult>;
  /**
   * Persisted session id for this cell, if a PTY is already running for it.
   * Drives the reattach-vs-open decision at mount.
   */
  sessionId?: string | null;
  /**
   * Called once a session is established (opened) so the caller can persist its
   * id for this cell and re-attach to it on the next mount. Not called on
   * reattach (the id is already known).
   */
  onSessionId?: (sessionId: string) => void;
  /**
   * Agent mode (ARCHITECTURE §20). When `true` the cell hosts an agent and the
   * terminal is **native**: keystrokes (`term.onData`) reach the PTY exactly
   * like a plain shell, AND are reported to the {@link WritePortal} (`portal`)
   * for line counting / suspension. When `false`/absent the cell is a plain
   * shell with no portal. Defaults to `false`.
   */
  agentMode?: boolean;
  /**
   * The write-portal for this agent cell (ARCHITECTURE §20). Only meaningful in
   * agent mode: it receives keystroke reports, gates the relay via
   * {@link WritePortal.isSuspended}, and is given the live handle so it can
   * inject delegations through the same single PTY writer. Absent for plain
   * cells.
   */
  portal?: WritePortal;
}

export function TerminalView({
  cwd,
  open,
  reattach,
  sessionId,
  onSessionId,
  agentMode = false,
  portal,
}: TerminalViewProps) {
  const { terminal } = useGateways();
  const containerRef = useRef<HTMLDivElement | null>(null);

  // The opener (`open` or the terminal gateway) is read through a ref so the
  // effect does NOT depend on its identity. Otherwise every parent re-render
  // (e.g. App's domain-event counter bumping on `AgentLaunched`) would create a
  // fresh `open` closure, re-run the effect, close + relaunch the PTY, emit
  // another event, and so on — an infinite launch loop (black terminal, events
  // skyrocketing). The terminal is re-mounted by a `key` when the agent changes,
  // so the correct opener is always captured at mount.
  const openRef = useRef(open);
  openRef.current = open;
  const reattachRef = useRef(reattach);
  reattachRef.current = reattach;
  const sessionIdRef = useRef(sessionId);
  sessionIdRef.current = sessionId;
  const onSessionIdRef = useRef(onSessionId);
  onSessionIdRef.current = onSessionId;
  const terminalRef = useRef(terminal);
  terminalRef.current = terminal;
  const agentModeRef = useRef(agentMode);
  agentModeRef.current = agentMode;
  const portalRef = useRef(portal);
  portalRef.current = portal;

  useEffect(() => {
    const container = containerRef.current;
    const tgw = terminalRef.current;
    const opener = openRef.current ?? tgw?.openTerminal.bind(tgw);
    const reattacher = reattachRef.current ?? tgw?.reattach.bind(tgw);
    if (!container || !opener) return;

    const term = new Terminal({
      convertEol: false,
      cursorBlink: true,
      fontSize: 13,
      fontFamily:
        'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
    });
    const fit = new FitAddon();
    term.loadAddon(fit);
    // xterm needs a real layout engine; in non-DOM environments `open` throws.
    // Bail gracefully so a headless render (jsdom tests) doesn't break the view.
    try {
      term.open(container);
    } catch {
      term.dispose();
      return;
    }
    try {
      fit.fit();
    } catch {
      /* container not laid out yet; a resize will retry */
    }

    let disposed = false;
    let handle: TerminalHandle | null = null;
    const encoder = new TextEncoder();

    // Keystroke → PTY path. The agent cell is a **native terminal**
    // (ARCHITECTURE §20): keystrokes reach the PTY exactly like a plain shell.
    // In agent mode we additionally (1) report the keystroke to the write-portal
    // for line counting, and (2) honour the portal's `isSuspended()` flag, which
    // is raised while the portal injects a delegation so the human keystroke does
    // not race the injected text. Keystrokes arriving before the PTY opened are
    // buffered. The output path below is unchanged in both modes.
    let pending = "";
    const onKey = term.onData((data) => {
      const portal = portalRef.current;
      if (agentModeRef.current && portal) {
        // Always report for counting (even while suspended — the portal decides
        // what counts), then drop the relay while the portal is injecting.
        portal.onHumanData(data);
        if (portal.isSuspended()) return;
      }
      if (handle) void handle.write(encoder.encode(data));
      else pending += data;
    });

    const onData = (bytes: Uint8Array) => {
      if (!disposed) term.write(bytes);
    };

    // Adopt a freshly-established handle: flush buffered keystrokes. If the view
    // was disposed before the promise resolved, just detach (NEVER close — the
    // PTY must survive a transient mount/unmount).
    const adopt = (h: TerminalHandle) => {
      if (disposed) {
        h.detach();
        return;
      }
      handle = h;
      // Hand the live handle to the write-portal so it can inject delegations
      // through the SAME single PTY writer (no second physical writer).
      if (agentModeRef.current) portalRef.current?.bindHandle(h);
      if (pending) {
        void h.write(encoder.encode(pending));
        pending = "";
      }
    };

    const onOpenError = (e: unknown) => {
      if (disposed) return;
      // The agent is already running in another cell (singleton invariant): this
      // is NOT an error — the cell is simply available. Show a calm, muted notice
      // (not the red failure line) so the user can pick another agent.
      if (errorCode(e) === "AGENT_ALREADY_RUNNING") {
        term.write(
          `\r\n\x1b[2mCellule disponible — l'agent est en cours dans une autre cellule.\x1b[0m\r\n`,
        );
        return;
      }
      term.write(
        `\r\n\x1b[31mfailed to open terminal: ${describe(e)}\x1b[0m\r\n`,
      );
    };

    // Re-attach to an existing live PTY when this cell already has a session;
    // otherwise open a fresh one and persist its id for the next mount.
    const existingSession = sessionIdRef.current;
    if (existingSession && reattacher) {
      reattacher(existingSession, onData)
        .then(({ handle: h, scrollback }) => {
          if (disposed) {
            h.detach();
            return;
          }
          if (scrollback.length > 0) term.write(scrollback);
          adopt(h);
        })
        .catch(() => {
          // The session is gone (explicitly closed / exited): fall back to a
          // fresh terminal so the cell still works.
          if (disposed) return;
          opener({ cwd, rows: term.rows, cols: term.cols }, onData)
            .then((h) => {
              onSessionIdRef.current?.(h.sessionId);
              adopt(h);
            })
            .catch(onOpenError);
        });
    } else {
      opener({ cwd, rows: term.rows, cols: term.cols }, onData)
        .then((h) => {
          onSessionIdRef.current?.(h.sessionId);
          adopt(h);
        })
        .catch(onOpenError);
    }

    // Refit + propagate size to the PTY on container resize. ResizeObserver can
    // fire many times per frame, and during layout/tab transitions the container
    // momentarily reports a zero or transient size — fitting then would size
    // xterm's grid (and the PTY) to a stale value, leaving the repainted content
    // shifted/misaligned once the real size settles. So we: (1) coalesce bursts
    // into a single `requestAnimationFrame` that runs after layout settles,
    // (2) skip fitting while the container has no real size, and (3) push a PTY
    // resize only when rows/cols actually change (avoids redundant reflows).
    let rafId = 0;
    let lastRows = term.rows;
    let lastCols = term.cols;
    const refit = () => {
      rafId = 0;
      if (disposed) return;
      if (container.clientWidth === 0 || container.clientHeight === 0) return;
      try {
        fit.fit();
      } catch {
        return;
      }
      if (handle && (term.rows !== lastRows || term.cols !== lastCols)) {
        lastRows = term.rows;
        lastCols = term.cols;
        void handle.resize(term.rows, term.cols);
      }
    };
    const ro = new ResizeObserver(() => {
      if (rafId) cancelAnimationFrame(rafId);
      rafId = requestAnimationFrame(refit);
    });
    ro.observe(container);

    return () => {
      disposed = true;
      if (rafId) cancelAnimationFrame(rafId);
      ro.disconnect();
      onKey.dispose();
      portalRef.current?.unbindHandle();
      // DETACH, never close: tearing the view down (navigation / layout change)
      // must leave the backend PTY running so the AI isn't cut off. Killing the
      // PTY is an explicit user action handled elsewhere.
      if (handle) handle.detach();
      term.dispose();
    };
    // Only re-open on cwd change (or mount). The opener is read from a ref, and
    // agent switches re-mount via `key`, so we must NOT depend on `open`.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [cwd]);

  return (
    <div
      ref={containerRef}
      data-testid="terminal-view"
      style={{ width: "100%", height: "100%", minHeight: "16rem" }}
    />
  );
}

function describe(e: unknown): string {
  if (e && typeof e === "object" && "message" in e) {
    return String((e as { message: unknown }).message);
  }
  return String(e);
}

/** Extracts a gateway error `code` when present (the Tauri/mock error shape). */
function errorCode(e: unknown): string | undefined {
  if (e && typeof e === "object" && "code" in e) {
    return String((e as { code: unknown }).code);
  }
  return undefined;
}
