/**
 * 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, useState } 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 { ResolvedAgentSystemPermissions } from "@/domain";
import type {
  OpenTerminalOptions,
  ReattachResult,
  TerminalHandle,
  WritePortal,
} from "@/ports";

// The backend PTY retains a bounded byte tail for reattach (~100 KB today), but
// xterm also has its own viewport history. Its default is too shallow for chatty
// OpenCode TUIs, which made the visible cell stop scrolling long before the
// retained terminal output was exhausted.
export const TERMINAL_SCROLLBACK_LINES = 10_000;

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;
  /**
   * Called once, when xterm has mounted, with an imperative
   * {@link TerminalInputApi} for this cell (#69). Optional and inert when
   * absent, so the desktop path is unchanged; the web client uses it to drive
   * the mobile key toolbar. Not called when xterm fails to mount (headless).
   */
  onReady?: (api: TerminalInputApi) => void;
  /**
   * Bumped by the caller after a structural layout mutation (split/merge,
   * ticket #61) so surviving terminals refit even when their container's
   * `ResizeObserver` doesn't fire a useful event for the mutation (e.g. a
   * sibling appearing/disappearing without this cell's own box changing size
   * in a way the observer flags in time). Any value change schedules exactly
   * one coalesced refit on the next animation frame, reusing the same
   * zero-size guard and rows/cols-changed check as the resize-observer path —
   * it never remounts/reopens the terminal.
   */
  refitSignal?: number;
  /** Optional resolved system permissions for this agent/cell. */
  systemPermissions?: ResolvedAgentSystemPermissions | null;
}

/**
 * Imperative handle over a mounted terminal (#69), handed to the caller by
 * {@link TerminalViewProps.onReady}.
 *
 * It exists because a phone's virtual keyboard has no Esc, Tab, Ctrl or arrow
 * keys, so the web client renders a key toolbar that needs to inject them. Both
 * methods deliberately go through xterm rather than the PTY handle:
 * `send` routes through `term.input()` — the *same* path a real keystroke takes
 * — so the PTY relay, the write-portal's line counting and its suspension gate
 * all keep applying. Writing to the handle directly would bypass the portal and
 * let an injected key race a delegation.
 */
export interface TerminalInputApi {
  /** Inject `data` exactly as if the user had typed it. */
  send: (data: string) => void;
  /** Focus the terminal — on a phone this summons the virtual keyboard. */
  focus: () => void;
}

export function TerminalView({
  cwd,
  open,
  reattach,
  sessionId,
  onSessionId,
  agentMode = false,
  portal,
  onReady,
  refitSignal,
  systemPermissions,
}: TerminalViewProps) {
  const { terminal } = useGateways();
  const containerRef = useRef<HTMLDivElement | null>(null);
  // A user-visible launch failure (e.g. an OpenAI-compatible endpoint that is
  // unreachable). Rendered as a DOM `role="alert"` banner over the cell so the
  // failure is always visible and accessible — not only painted into the xterm
  // buffer (which is invisible to assistive tech and absent when xterm can't
  // mount). `null` ⇒ no error. The cell stays mounted and IdeA stays usable.
  const [openError, setOpenError] = useState<string | null>(null);
  const [terminalReady, setTerminalReady] = useState(false);

  // 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;
  const onReadyRef = useRef(onReady);
  onReadyRef.current = onReady;
  // Holds the mounted instance's `refit` closure so the `refitSignal` effect
  // below (a separate effect, since it must NOT re-run/reopen the terminal on
  // every parent render) can trigger it without depending on `cwd`'s effect.
  const refitRef = useRef<((maxFrames?: number) => void) | null>(null);

  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;

    // Fresh (re)mount: clear any prior failure banner before we try to open.
    setOpenError(null);
    setTerminalReady(false);

    const term = new Terminal({
      convertEol: false,
      cursorBlink: true,
      fontSize: 13,
      fontFamily:
        'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
      scrollback: TERMINAL_SCROLLBACK_LINES,
    });
    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;
    }
    let disposed = false;
    let handle: TerminalHandle | null = null;
    const encoder = new TextEncoder();
    let rafId = 0;
    let lastRows = term.rows;
    let lastCols = term.cols;
    let hasUsefulFit = false;

    // 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;
    });

    // Publish the input API now that the keystroke relay above is live, so an
    // injected key is handled exactly like a typed one. Guarded on `disposed`:
    // the caller may still hold this object after the cell unmounts, and driving
    // a disposed xterm throws.
    onReadyRef.current?.({
      send: (data) => {
        if (!disposed) term.input(data);
      },
      focus: () => {
        if (!disposed) term.focus();
      },
    });

    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 resizeHandleToCurrentGeometry = () => {
      if (!handle) return;
      if (term.rows <= 0 || term.cols <= 0) return;
      void handle.resize(term.rows, term.cols);
    };
    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 = "";
      }
      if (hasUsefulFit) resizeHandleToCurrentGeometry();
    };

    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;
      }
      // A genuine launch failure (unreachable endpoint, model missing, network
      // drop…). Surface it as an accessible DOM banner AND as a red line in the
      // buffer. The cell renders as failed; IdeA stays usable (other cells work).
      setOpenError(`Échec du lancement de l'agent : ${describe(e)}`);
      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).
    // A refit can land on a transient 0x0 container (mount, or a structural
    // layout mutation, before the box has actually settled). Previously this
    // just gave up — fine on desktop, where a later window resize always
    // re-triggers the ResizeObserver and retries, but on mobile (no window
    // resize possible) the cell was then stuck unfit forever. So a zero size
    // now reschedules on the next few frames instead of abandoning — bounded,
    // so a container that is genuinely never laid out (e.g. headless tests)
    // doesn't spin forever.
    // A successful fit can also land on a non-zero but still intermediate box
    // during project/layout switches, split/merge commits, re-attach, and OS
    // minimize/restore. Instead of guessing a fixed number of follow-up frames,
    // keep fitting until the measured DOM box and resulting xterm grid stay
    // stable for consecutive frames. A hard frame cap keeps genuinely unstable
    // or never-laid-out containers bounded.
    const STABLE_REFIT_FRAMES = 2;
    const SETTLE_REFIT_MAX_FRAMES = 24;
    const MOUNT_REFIT_MAX_FRAMES = 60;
    let refitFramesRemaining = 0;
    let stableFrames = 0;
    let lastStabilityKey = "";
    const refit = () => {
      rafId = 0;
      if (disposed) return;

      const consumeFrame = () => {
        refitFramesRemaining = Math.max(0, refitFramesRemaining - 1);
      };
      const scheduleNextIfNeeded = () => {
        if (refitFramesRemaining > 0) {
          rafId = requestAnimationFrame(refit);
        }
      };

      const width = container.clientWidth;
      const height = container.clientHeight;
      if (width === 0 || height === 0) {
        stableFrames = 0;
        lastStabilityKey = "";
        consumeFrame();
        scheduleNextIfNeeded();
        return;
      }

      try {
        fit.fit();
      } catch {
        return;
      }
      if (term.rows <= 0 || term.cols <= 0) {
        stableFrames = 0;
        lastStabilityKey = "";
        consumeFrame();
        scheduleNextIfNeeded();
        return;
      }

      const isFirstUsefulFit = !hasUsefulFit;
      if (isFirstUsefulFit) {
        hasUsefulFit = true;
        setTerminalReady(true);
      }

      if (term.rows !== lastRows || term.cols !== lastCols) {
        lastRows = term.rows;
        lastCols = term.cols;
        resizeHandleToCurrentGeometry();
      } else if (isFirstUsefulFit) {
        resizeHandleToCurrentGeometry();
      }

      const stabilityKey = `${width}x${height}:${term.rows}x${term.cols}`;
      if (stabilityKey === lastStabilityKey) {
        stableFrames += 1;
      } else {
        lastStabilityKey = stabilityKey;
        stableFrames = 1;
      }

      consumeFrame();
      if (stableFrames < STABLE_REFIT_FRAMES) scheduleNextIfNeeded();
    };
    const scheduleRefit = (maxFrames = SETTLE_REFIT_MAX_FRAMES) => {
      refitFramesRemaining = Math.max(refitFramesRemaining, maxFrames);
      stableFrames = 0;
      lastStabilityKey = "";
      if (!rafId) rafId = requestAnimationFrame(refit);
    };
    const scheduleSettledRefit = () => scheduleRefit(SETTLE_REFIT_MAX_FRAMES);
    const scheduleVisibleRefit = () => {
      if (document.visibilityState === "hidden") return;
      scheduleSettledRefit();
    };
    const ro = new ResizeObserver(() => scheduleRefit());
    ro.observe(container);
    scheduleRefit(MOUNT_REFIT_MAX_FRAMES);
    window.addEventListener("resize", scheduleSettledRefit);
    window.addEventListener("focus", scheduleSettledRefit);
    window.addEventListener("pageshow", scheduleSettledRefit);
    document.addEventListener("visibilitychange", scheduleVisibleRefit);
    window.visualViewport?.addEventListener("resize", scheduleSettledRefit);
    // Let the `refitSignal` effect below trigger the SAME coalesced refit after
    // a structural layout mutation (split/merge, ticket #61) — surviving cells
    // don't always get a timely useful ResizeObserver event from a sibling
    // appearing/disappearing.
    refitRef.current = scheduleRefit;

    return () => {
      disposed = true;
      refitRef.current = null;
      if (rafId) cancelAnimationFrame(rafId);
      ro.disconnect();
      window.removeEventListener("resize", scheduleSettledRefit);
      window.removeEventListener("focus", scheduleSettledRefit);
      window.removeEventListener("pageshow", scheduleSettledRefit);
      document.removeEventListener("visibilitychange", scheduleVisibleRefit);
      window.visualViewport?.removeEventListener("resize", scheduleSettledRefit);
      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]);

  // Ticket #61 — explicit refit after a structural layout mutation (split /
  // merge). A survivor's own container may not fire a timely, useful
  // ResizeObserver event when a SIBLING cell appears/disappears, so the caller
  // (LayoutGrid, via `useLayout`'s layout-tree version) bumps `refitSignal` on
  // every successful mutation and we reuse the mounted instance's coalesced
  // `refit` (same zero-size guard, same rows/cols-changed check) instead of
  // remounting/reopening the terminal. Deliberately a SEPARATE effect from the
  // `[cwd]` one above so bumping the signal never re-runs the open/reattach
  // logic.
  useEffect(() => {
    if (refitSignal === undefined) return;
    refitRef.current?.(24);
  }, [refitSignal]);

  const showNetworkBanner =
    systemPermissions != null &&
    (systemPermissions.runtimeLock.state === "locked" ||
      systemPermissions.effective === "deny");
  const networkReason =
    systemPermissions?.runtimeLock.reason ??
    systemPermissions?.control.reason ??
    "Le réseau est interdit pour cette cellule.";

  return (
    <div
      data-testid="terminal-view"
      style={{
        position: "relative",
        width: "100%",
        height: "100%",
        minHeight: "16rem",
      }}
    >
      {/* xterm mounts into this inner node; the error banner is a sibling so
          React never fights xterm over the same subtree. */}
      <div
        ref={containerRef}
        data-testid="terminal-xterm-container"
        aria-hidden={!terminalReady}
        style={{
          width: "100%",
          height: "100%",
          visibility: terminalReady ? "visible" : "hidden",
        }}
      />
      {showNetworkBanner && (
        <div
          role="status"
          data-testid="terminal-network-banner"
          style={{
            position: "absolute",
            top: 8,
            left: 8,
            right: 8,
            padding: "0.5rem 0.75rem",
            border: "1px solid rgba(245, 158, 11, 0.45)",
            borderRadius: 6,
            background: "rgba(24, 24, 27, 0.94)",
            color: "var(--color-warning, #f59e0b)",
            fontSize: 12,
            fontFamily:
              'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
            whiteSpace: "pre-wrap",
            wordBreak: "break-word",
            zIndex: 2,
          }}
        >
          {systemPermissions.runtimeLock.state === "locked"
            ? "Réseau verrouillé par le runtime."
            : "Réseau interdit pour cet agent."}{" "}
          {networkReason}
        </div>
      )}
      {!terminalReady && !openError && (
        <div
          data-testid="terminal-boot-placeholder"
          aria-live="polite"
          style={{
            position: "absolute",
            inset: 0,
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
            padding: "1rem",
            background: "#101214",
            color: "#9ca3af",
            fontSize: 13,
            fontFamily:
              'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
            zIndex: 1,
          }}
        >
          Préparation du terminal…
        </div>
      )}
      {openError && (
        <div
          role="alert"
          data-testid="terminal-error"
          style={{
            position: "absolute",
            top: 0,
            left: 0,
            right: 0,
            padding: "0.5rem 0.75rem",
            background: "rgba(120, 20, 20, 0.92)",
            color: "#fff",
            fontSize: 13,
            fontFamily:
              'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
            whiteSpace: "pre-wrap",
            wordBreak: "break-word",
            zIndex: 3,
          }}
        >
          {openError}
        </div>
      )}
    </div>
  );
}

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;
}
