/**
 * Recursive terminal-layout grid (L4). Renders a {@link LayoutTree} as nested
 * `Split` / `Grid` containers down to `Leaf` cells, each hosting a
 * {@link TerminalView} (L3). Provides the spreadsheet-style interactions:
 *
 * - **resize**: drag the separator between two split children → recomputes the
 *   two adjacent weights (`resizeAdjacent`, pure) → `mutateLayout` Resize;
 * - **split**: per-cell buttons split a leaf into rows/columns → Split;
 * - **merge**: when a split has > 1 child, a cell can collapse its parent split
 *   onto itself (spreadsheet-like cell merge) → Merge.
 * - **agent**: each leaf cell has a dropdown to pin an agent; persisted via
 *   `setCellAgent` in the layout (#3).
 *
 * Pure presentation: all behaviour comes from {@link useLayout}, which speaks to
 * the {@link LayoutGateway} port — no `invoke()` here. Track *sizing* is the pure
 * {@link normalizeWeights} function, kept out of the render for testability.
 */

import { useEffect, useRef, useState } from "react";

import type { Agent } from "@/domain";
import type { LayoutNode } from "@/domain";
import type {
  ConversationDetails,
  LiveAgent,
  OpenTerminalOptions,
  TerminalHandle,
} from "@/ports";
import {
  MediatedInput,
  ResumeConversationPopup,
  TerminalView,
  useAgentBusy,
} from "@/features/terminals";
import { useGateways } from "@/app/di";
import { leaves, normalizeWeights, resizeAdjacent } from "./layout";
import { useLayout, type LayoutViewModel } from "./useLayout";

interface LayoutGridProps {
  /** Project whose layout to render. */
  projectId: string;
  /** Working directory new terminals open in (the project root). */
  cwd: string;
  /** Active layout id; when provided the grid loads/mutates this layout. */
  layoutId?: string;
}

export function LayoutGrid({ projectId, cwd, layoutId }: LayoutGridProps) {
  const vm = useLayout(projectId, layoutId);

  if (!vm.layout) {
    return (
      <div data-testid="layout-grid" style={{ width: "100%", height: "100%" }}>
        {vm.error ? (
          <p role="alert" style={{ color: "crimson" }}>
            {vm.error}
          </p>
        ) : (
          <p>Loading layout…</p>
        )}
      </div>
    );
  }
  const visibleNodeIds = new Set(leaves(vm.layout).map((l) => l.id));

  return (
    <div
      data-testid="layout-grid"
      style={{ width: "100%", height: "100%", position: "relative" }}
    >
      {vm.error && (
        <p role="alert" style={{ color: "crimson", margin: 0 }}>
          {vm.error}
        </p>
      )}
      <NodeView
        node={vm.layout.root}
        cwd={cwd}
        vm={vm}
        parentSplit={null}
        projectId={projectId}
        visibleNodeIds={visibleNodeIds}
      />
    </div>
  );
}

interface NodeViewProps {
  node: LayoutNode;
  cwd: string;
  vm: LayoutViewModel;
  /** The enclosing split + this node's index in it, for the merge action. */
  parentSplit: { container: string; index: number; siblings: number } | null;
  projectId: string;
  visibleNodeIds: Set<string>;
}

function NodeView({ node, cwd, vm, parentSplit, projectId, visibleNodeIds }: NodeViewProps) {
  switch (node.type) {
    case "leaf":
      return (
        <LeafView
          id={node.node.id}
          session={node.node.session ?? null}
          agent={node.node.agent ?? null}
          conversationId={node.node.conversationId ?? null}
          agentWasRunning={node.node.agentWasRunning ?? false}
          cwd={cwd}
          vm={vm}
          parentSplit={parentSplit}
          projectId={projectId}
          visibleNodeIds={visibleNodeIds}
        />
      );
    case "split":
      return (
        <SplitView
          split={node.node}
          cwd={cwd}
          vm={vm}
          projectId={projectId}
          visibleNodeIds={visibleNodeIds}
        />
      );
    case "grid":
      return (
        <GridView
          grid={node.node}
          cwd={cwd}
          vm={vm}
          projectId={projectId}
          visibleNodeIds={visibleNodeIds}
        />
      );
  }
}

interface LeafViewProps {
  id: string;
  session: string | null;
  agent: string | null;
  conversationId: string | null;
  agentWasRunning: boolean;
  cwd: string;
  vm: LayoutViewModel;
  parentSplit: { container: string; index: number; siblings: number } | null;
  projectId: string;
  visibleNodeIds: Set<string>;
}

/**
 * A launch deferred by the resume popup (T7): TerminalView asked the opener to
 * launch (fresh open or reattach-failed fallback) for a cell that carries a
 * `conversationId`. We hold the open request here until the user picks
 * Reprendre / Nouvelle conversation, then resolve the promise with the handle.
 */
interface PendingResume {
  opts: OpenTerminalOptions;
  onData: (bytes: Uint8Array) => void;
  resolve: (handle: TerminalHandle) => void;
  reject: (e: unknown) => void;
}

/**
 * A transient in-cell notice. `goToNodeId`, when present, identifies the layout
 * leaf that already hosts the conflicting live agent; the notice then renders an
 * "aller à la cellule" button that focuses that cell. An agent is a singleton
 * ("1 agent = 1 employé"): we surface where it already lives instead of cloning.
 */
interface CellNotice {
  message: string;
  goToNodeId?: string;
}

/**
 * Focuses the layout leaf with the given node id: scrolls it into view and
 * flashes a brief outline so the user sees where the agent already lives. Works
 * off the `data-node-id` attribute each {@link LeafView} renders. Best-effort —
 * a no-op when the node is not currently mounted (e.g. on another tab).
 */
function goToCell(nodeId: string): void {
  if (typeof document === "undefined") return;
  const el = document.querySelector<HTMLElement>(`[data-node-id="${nodeId}"]`);
  if (!el) return;
  // `scrollIntoView` is absent in some headless DOMs (jsdom); the highlight is
  // the essential affordance, so guard the scroll and still flash the outline.
  el.scrollIntoView?.({ block: "nearest", inline: "nearest" });
  const previous = el.style.outline;
  el.style.outline = "2px solid var(--color-primary, #5b9bd5)";
  window.setTimeout(() => {
    el.style.outline = previous;
  }, 1200);
}

function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm, parentSplit, projectId, visibleNodeIds }: LeafViewProps) {
  // A cell can be closed only when it lives inside a (binary) split: closing it
  // collapses the parent split, keeping the *sibling*. Splits are always binary
  // in this model (a split wraps a leaf into a 2-child container), so the kept
  // child is simply the other index. Guarding on `siblings === 2` keeps the
  // operation correct (merge keeps exactly one child) and avoids ever dropping
  // the wrong terminal. The root cell (no parent split) cannot be closed.
  const canClose = parentSplit !== null && parentSplit.siblings === 2;
  const siblingIndex = parentSplit ? (parentSplit.index === 0 ? 1 : 0) : 0;
  const { agent: agentGateway, system } = useGateways();

  // Per-agent busy map (lot F1) feeds the mediated input strip: while the agent
  // is processing a turn, "Envoyer" is dimmed (but the enqueue stays open) and
  // "Interrompre" is active. Absent key ⇒ idle.
  const busyMap = useAgentBusy();

  // Load the project's agents for the dropdown.
  const [agents, setAgents] = useState<Agent[]>([]);
  useEffect(() => {
    if (!agentGateway) return;
    let cancelled = false;
    agentGateway.listAgents(projectId).then((list) => {
      if (!cancelled) setAgents(list);
    }).catch(() => {/* ignore — dropdown stays empty */});
    return () => { cancelled = true; };
  }, [agentGateway, projectId]);

  // Load the agents currently running (and where), so the dropdown can disable an
  // agent already live in another cell — it cannot run in two cells at once. The
  // backend refuses such a launch (`AGENT_ALREADY_RUNNING`); disabling it here is
  // the matching UI affordance.
  const [liveAgents, setLiveAgents] = useState<LiveAgent[]>([]);
  const refreshLive = () => {
    if (!agentGateway?.listLiveAgents) return;
    agentGateway.listLiveAgents(projectId).then(setLiveAgents).catch(() => {
      /* ignore — treat as none live */
    });
  };
  useEffect(() => {
    if (!agentGateway?.listLiveAgents) return;
    let cancelled = false;
    agentGateway.listLiveAgents(projectId).then((list) => {
      if (!cancelled) setLiveAgents(list);
    }).catch(() => {/* ignore */});
    return () => { cancelled = true; };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [agentGateway, projectId]);

  // Live refresh on agent lifecycle events: a launch/exit in any cell changes
  // who is running where, so re-pull the live set to keep every dropdown in sync.
  useEffect(() => {
    if (!system) return;
    let unsubscribe: (() => void) | undefined;
    let cancelled = false;
    void system
      .onDomainEvent((event) => {
        if (event.type === "agentLaunched" || event.type === "agentExited") {
          refreshLive();
        }
      })
      .then((un) => {
        if (cancelled) un();
        else unsubscribe = un;
      });
    return () => {
      cancelled = true;
      unsubscribe?.();
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [system, agentGateway, projectId]);

  // Build the terminal opener based on whether an agent is pinned.
  const agentId = agent ?? null;

  /** The live session for `candidate`, if any. */
  const liveFor = (candidate: string): LiveAgent | undefined =>
    liveAgents.find((la) => la.agentId === candidate);

  /**
   * Maps an error into a {@link CellNotice}. For the singleton-invariant refusal
   * (`AGENT_ALREADY_RUNNING`) it resolves the host cell from the live-agents set
   * (the source of truth exposed by R0b — PTY *and* chat) so the notice can
   * offer "aller à la cellule" rather than relaunch a clone. The lookup is done
   * against a *fresh* `listLiveAgents` call (the refusal often races the launch
   * on mount, before the cached `liveAgents` state has settled), falling back to
   * the cached set. The error message embeds the host node id too, but the
   * structured live-agents lookup is preferred.
   */
  const noticeFromError = async (
    err: unknown,
    candidate: string,
  ): Promise<CellNotice> => {
    const message = describeNotice(err);
    if (!isAlreadyRunning(err)) return { message };
    const fresh = agentGateway?.listLiveAgents
      ? await agentGateway.listLiveAgents(projectId).catch(() => liveAgents)
      : liveAgents;
    const host = fresh.find((la) => la.agentId === candidate);
    return {
      message: "Cet agent est déjà actif dans une autre cellule.",
      goToNodeId: host && host.nodeId !== id ? host.nodeId : undefined,
    };
  };

  /** True when the live session is already displayed by another visible cell. */
  const visibleElsewhere = (candidate: string): LiveAgent | undefined => {
    const live = liveFor(candidate);
    return live && live.nodeId !== id && visibleNodeIds.has(live.nodeId)
      ? live
      : undefined;
  };

  /** A live session whose previous host cell no longer exists in the layout. */
  const backgroundLive = (candidate: string): LiveAgent | undefined => {
    const live = liveFor(candidate);
    return live && live.nodeId !== id && !visibleNodeIds.has(live.nodeId)
      ? live
      : undefined;
  };

  // A transient notice shown when an action is blocked by the singleton
  // invariant (selecting / launching an agent already live in another cell).
  // `goToNodeId`, when set, is the host cell of the conflicting live agent: the
  // notice then offers an "aller à la cellule" action that focuses that cell —
  // we never silently relaunch a clone (product rule "1 agent = 1 employé").
  const [busyNotice, setBusyNotice] = useState<CellNotice | null>(null);

  // ── Resume popup state (T7) ───────────────────────────────────────────────
  // When an agent cell carries a persisted conversation id and its PTY session
  // is dead, the opener is about to relaunch in Resume mode. We intercept that
  // launch with a popup so the user can choose Resume vs a fresh conversation.
  // The reattach path (live PTY) never goes through this opener, so the popup is
  // naturally skipped there.
  const [pendingResume, setPendingResume] = useState<PendingResume | null>(null);
  const [resumeDetails, setResumeDetails] = useState<ConversationDetails | null>(null);

  // ── Cell routing (Option 1 — Terminal + MCP) ──────────────────────────────
  // Every agent cell renders the raw {@link TerminalView}: the human view is the
  // native interactive PTY (live reasoning + Échap are native CLI behaviours,
  // zero model parsing), and cross-model delegation flows through MCP tools, not
  // a structured chat view. The former `AgentChatView`/`cellKind:"chat"` routing
  // has been removed (F-2 cleanup); any `cellKind` field still emitted by the
  // backend on the wire is simply ignored.

  /** Performs the actual launch and persists any assigned id (T4b loop). */
  const doLaunch = async (
    opts: OpenTerminalOptions,
    onData: (bytes: Uint8Array) => void,
    convId: string | undefined,
  ): Promise<TerminalHandle> => {
    const background = backgroundLive(agentId!);
    if (background?.sessionId && agentGateway!.attachLiveAgent) {
      const attached = await agentGateway!.attachLiveAgent(projectId, agentId!, id);
      const sessionId = attached.sessionId ?? background.sessionId;
      void vm.setSession(id, sessionId);
      const result = await agentGateway!.reattach(sessionId, onData);
      refreshLive();
      return result.handle;
    }

    const handle = await agentGateway!
      .launchAgent(
        projectId,
        agentId!,
        { ...opts, conversationId: convId, nodeId: id },
        onData,
      )
      .catch(async (err: unknown) => {
        // A neat backend refusal (`AGENT_ALREADY_RUNNING`) means the agent is a
        // singleton already alive in another cell: surface a clear notice with
        // an "aller à la cellule" action (host resolved from the live set), then
        // re-throw so the view layer does not treat the launch as succeeded.
        if (isAlreadyRunning(err)) {
          setBusyNotice(await noticeFromError(err, agentId!));
        }
        throw err;
      });
    // First launch on a fresh cell mints a conversation id: persist it on the
    // leaf so the next open resumes (T4b — closes the persistence loop).
    if (handle.assignedConversationId) {
      void vm.setCellConversation(id, handle.assignedConversationId);
    }
    return handle;
  };

  const terminalOpener = agentGateway && agentId
    ? (opts: OpenTerminalOptions, onData: (bytes: Uint8Array) => void): Promise<TerminalHandle> => {
        // No persisted conversation ⇒ fresh cell: open straight away (the launch
        // may assign a new id). No popup on this path.
        if (!conversationId) {
          return doLaunch(opts, onData, undefined);
        }
        // Resume case: defer the launch behind the popup. Fetch the best-effort
        // enriched details (last topic + tokens) to enrich it; failure or empty
        // ⇒ degraded mode (status only). Inspection never blocks the resume.
        Promise.resolve(
          agentGateway.inspectConversation?.(projectId, agentId, conversationId),
        )
          .then((details) => setResumeDetails(details ?? {}))
          .catch(() => setResumeDetails({}));
        return new Promise<TerminalHandle>((resolve, reject) => {
          setPendingResume({ opts, onData, resolve, reject });
        });
      }
    : undefined;

  /** "Reprendre" → launch resuming the existing conversation id. */
  const onResume = () => {
    const p = pendingResume;
    if (!p) return;
    setPendingResume(null);
    setResumeDetails(null);
    doLaunch(p.opts, p.onData, conversationId ?? undefined).then(p.resolve, p.reject);
  };

  /** "Nouvelle conversation" → clear the id first (Assign), then launch fresh. */
  const onNewConversation = () => {
    const p = pendingResume;
    if (!p) return;
    setPendingResume(null);
    setResumeDetails(null);
    // Clear the persisted conversation id BEFORE launching so the backend treats
    // it as a fresh cell and assigns a new conversation.
    void vm.setCellConversation(id, null);
    doLaunch(p.opts, p.onData, undefined).then(p.resolve, p.reject);
  };
  // Agent cells re-attach through the agent gateway; plain cells fall back to
  // the terminal gateway's reattach (handled by TerminalView's default).
  const reattachOpener = agentGateway && agentId
    ? (sessionId: string, onData: (bytes: Uint8Array) => void) =>
        agentGateway.reattach(sessionId, onData)
    : undefined;

  return (
    <div
      data-testid="layout-leaf"
      data-node-id={id}
      style={{
        position: "relative",
        width: "100%",
        height: "100%",
        minHeight: 0,
        minWidth: 0,
        border: "1px solid #2a2a2a",
        boxSizing: "border-box",
        overflow: "hidden",
      }}
    >
      <div
        style={{
          position: "absolute",
          top: 2,
          // Clear the xterm viewport scrollbar (rendered flush against the right
          // edge, ~15px wide). Without this offset the right-most control — the
          // close button — sits *behind* the scrollbar and is hard to click.
          right: 16,
          zIndex: 2,
          display: "flex",
          gap: 2,
          alignItems: "center",
        }}
      >
        {/* Agent selector */}
        <select
          aria-label={`agent selector ${id}`}
          value={agentId ?? ""}
          onChange={(e) => {
            const val = e.target.value;
            if (val === "") {
              setBusyNotice(null);
              void vm.setCellAgent(id, null);
              return;
            }
            void (async () => {
              const current = agentGateway?.listLiveAgents
                ? await agentGateway.listLiveAgents(projectId).catch(() => liveAgents)
                : liveAgents;
              setLiveAgents(current);
              const live = current.find((la) => la.agentId === val);
              const isVisible =
                live && live.nodeId !== id && visibleNodeIds.has(live.nodeId);
              if (isVisible) {
                setBusyNotice({
                  message: "Cet agent est déjà actif dans une autre cellule.",
                  goToNodeId: live!.nodeId,
                });
                return;
              }
              const isBackground =
                live && live.nodeId !== id && !visibleNodeIds.has(live.nodeId);
              if (isBackground) {
                if (!agentGateway?.attachLiveAgent || !live.sessionId) {
                  setBusyNotice({
                    message: "Session active introuvable pour cet agent.",
                  });
                  return;
                }
                setBusyNotice(null);
                const attached = await agentGateway.attachLiveAgent(projectId, val, id);
                await vm.attachLiveAgentToCell(id, val, attached.sessionId ?? live.sessionId);
                refreshLive();
                return;
              }
              setBusyNotice(null);
              await vm.setCellAgent(id, val);
            })().catch(async (err: unknown) =>
              setBusyNotice(await noticeFromError(err, val)),
            );
          }}
          style={{
            fontSize: 11,
            background: "var(--color-surface, #1e1e1e)",
            color: "var(--color-content, #e0e0e0)",
            border: "1px solid var(--color-border, #3a3a3a)",
            borderRadius: 3,
            padding: "1px 2px",
            maxWidth: 100,
          }}
        >
          <option value="">Plain</option>
          {agents.map((a) => {
            const elsewhere = visibleElsewhere(a.id);
            const background = backgroundLive(a.id);
            return (
              <option key={a.id} value={a.id} disabled={Boolean(elsewhere)}>
                {a.name}
                {elsewhere ? " (visible ailleurs)" : background ? " (arrière-plan)" : ""}
              </option>
            );
          })}
        </select>

        <button
          type="button"
          title="Split into columns"
          aria-label={`split ${id} columns`}
          onClick={() => void vm.split(id, "row")}
        >
          ⬌
        </button>
        <button
          type="button"
          title="Split into rows"
          aria-label={`split ${id} rows`}
          onClick={() => void vm.split(id, "column")}
        >
          ⬍
        </button>
        {canClose && (
          <button
            type="button"
            title="Close this terminal"
            aria-label={`close ${id}`}
            onClick={() => void vm.merge(parentSplit.container, siblingIndex)}
          >
            ✕
          </button>
        )}
      </div>
      {/* Option 1 (Terminal + MCP): every cell — plain or agent — renders the
          raw xterm {@link TerminalView}. Agent cells are interactive PTYs; their
          cross-model delegation happens through MCP tools, not a chat view.
          Re-key on the agent so the right opener is captured at mount; the
          persisted session drives re-attach (never a re-spawn) when navigating.

          Lot F2 (cadrage §4.2): in an **agent** cell the terminal is output-only
          for the human (`agentMode`) and the {@link MediatedInput} strip is
          mounted **under** it — keystrokes route through IdeA, not the PTY. A
          **plain** cell keeps the raw-shell behaviour (keystrokes → PTY) and has
          no input strip. We stack terminal + strip in a column so the strip sits
          beneath the live output. */}
      <div
        style={{
          display: "flex",
          flexDirection: "column",
          width: "100%",
          height: "100%",
          minHeight: 0,
          minWidth: 0,
        }}
      >
        <div style={{ flex: "1 1 auto", minHeight: 0, minWidth: 0 }}>
          <TerminalView
            key={`${id}-${agentId ?? "plain"}`}
            cwd={cwd}
            open={terminalOpener}
            reattach={reattachOpener}
            sessionId={session}
            onSessionId={(sid) => void vm.setSession(id, sid)}
            agentMode={agentId != null}
          />
        </div>
        {agentId != null && (
          <MediatedInput
            projectId={projectId}
            agentId={agentId}
            busy={busyMap[agentId] ?? false}
          />
        )}
      </div>
      {busyNotice && (
        <div
          role="status"
          style={{
            position: "absolute",
            bottom: 4,
            left: 4,
            right: 4,
            margin: 0,
            fontSize: 11,
            color: "var(--color-content-muted, #9a9a9a)",
            background: "var(--color-surface, #1e1e1e)",
            padding: "2px 6px",
            borderRadius: 3,
            zIndex: 3,
            display: "flex",
            alignItems: "center",
            justifyContent: "space-between",
            gap: 6,
          }}
        >
          <span style={{ minWidth: 0 }}>{busyNotice.message}</span>
          {busyNotice.goToNodeId && (
            <button
              type="button"
              aria-label="aller à la cellule"
              onClick={() => {
                goToCell(busyNotice.goToNodeId!);
                setBusyNotice(null);
              }}
              style={{
                flexShrink: 0,
                fontSize: 11,
                cursor: "pointer",
                background: "transparent",
                color: "var(--color-primary, #5b9bd5)",
                border: "1px solid var(--color-primary, #5b9bd5)",
                borderRadius: 3,
                padding: "1px 6px",
              }}
            >
              Aller à la cellule
            </button>
          )}
        </div>
      )}
      {pendingResume && (
        <ResumeConversationPopup
          agentWasRunning={agentWasRunning}
          details={resumeDetails}
          onResume={onResume}
          onNewConversation={onNewConversation}
        />
      )}
    </div>
  );
}

interface SplitViewProps {
  split: Extract<LayoutNode, { type: "split" }>["node"];
  cwd: string;
  vm: LayoutViewModel;
  projectId: string;
  visibleNodeIds: Set<string>;
}

function SplitView({ split, cwd, vm, projectId, visibleNodeIds }: SplitViewProps) {
  const isRow = split.direction === "row";
  const baseWeights = split.children.map((c) => c.weight);
  const containerRef = useRef<HTMLDivElement | null>(null);
  // Live drag preview: while a separator is dragged we override the rendered
  // sizes locally (so the split moves under the cursor) and only commit the new
  // weights to the backend on release (avoids a mutate round-trip per mousemove).
  const [dragWeights, setDragWeights] = useState<number[] | null>(null);
  const sizes = normalizeWeights(dragWeights ?? baseWeights);

  return (
    <div
      ref={containerRef}
      data-testid="layout-split"
      data-direction={split.direction}
      style={{
        display: "flex",
        flexDirection: isRow ? "row" : "column",
        width: "100%",
        height: "100%",
        minHeight: 0,
        minWidth: 0,
      }}
    >
      {split.children.map((child, i) => (
        <div key={keyOf(child.node, i)} style={{ display: "contents" }}>
          <div
            style={{
              flexBasis: `${sizes[i]}%`,
              flexGrow: 0,
              flexShrink: 0,
              minHeight: 0,
              minWidth: 0,
              overflow: "hidden",
            }}
          >
            <NodeView
              node={child.node}
              cwd={cwd}
              vm={vm}
              projectId={projectId}
              visibleNodeIds={visibleNodeIds}
              parentSplit={{
                container: split.id,
                index: i,
                siblings: split.children.length,
              }}
            />
          </div>
          {i < split.children.length - 1 && (
            <Separator
              isRow={isRow}
              container={containerRef}
              onDragMove={(deltaFraction) =>
                setDragWeights(resizeAdjacent(baseWeights, i, deltaFraction))
              }
              onDragEnd={(deltaFraction) => {
                const weights = resizeAdjacent(baseWeights, i, deltaFraction);
                setDragWeights(null);
                void vm.resize(split.id, weights);
              }}
            />
          )}
        </div>
      ))}
    </div>
  );
}

interface SeparatorProps {
  isRow: boolean;
  container: React.RefObject<HTMLDivElement | null>;
  /** Called continuously during the drag with the signed fraction of the container extent (live preview). */
  onDragMove: (deltaFraction: number) => void;
  /** Called on release with the final fraction (commit). */
  onDragEnd: (deltaFraction: number) => void;
}

/**
 * A draggable separator between two split children. It captures the pointer and
 * reports the dragged distance (as a fraction of the container extent) **live**
 * on every move so the split tracks the cursor, then once more on release so the
 * parent can commit the new weights.
 */
function Separator({ isRow, container, onDragMove, onDragEnd }: SeparatorProps) {
  const startRef = useRef<number | null>(null);

  function fraction(clientPos: number): number | null {
    if (startRef.current === null) return null;
    const rect = container.current?.getBoundingClientRect();
    const extent = rect ? (isRow ? rect.width : rect.height) : 0;
    if (extent <= 0) return null;
    return (clientPos - startRef.current) / extent;
  }

  function onPointerDown(e: React.PointerEvent<HTMLDivElement>) {
    e.preventDefault();
    e.currentTarget.setPointerCapture(e.pointerId);
    startRef.current = isRow ? e.clientX : e.clientY;
  }
  function onPointerMove(e: React.PointerEvent<HTMLDivElement>) {
    if (startRef.current === null) return;
    const f = fraction(isRow ? e.clientX : e.clientY);
    if (f !== null) onDragMove(f);
  }
  function onPointerUp(e: React.PointerEvent<HTMLDivElement>) {
    const f = fraction(isRow ? e.clientX : e.clientY);
    startRef.current = null;
    if (f !== null) onDragEnd(f);
  }

  return (
    <div
      role="separator"
      aria-orientation={isRow ? "vertical" : "horizontal"}
      onPointerDown={onPointerDown}
      onPointerMove={onPointerMove}
      onPointerUp={onPointerUp}
      style={{
        flex: "0 0 6px",
        cursor: isRow ? "col-resize" : "row-resize",
        background: "#3a3a3a",
        touchAction: "none",
      }}
    />
  );
}

interface GridViewProps {
  grid: Extract<LayoutNode, { type: "grid" }>["node"];
  cwd: string;
  vm: LayoutViewModel;
  projectId: string;
  visibleNodeIds: Set<string>;
}

function GridView({ grid, cwd, vm, projectId, visibleNodeIds }: GridViewProps) {
  const cols = normalizeWeights(grid.colWeights)
    .map((p) => `${p}fr`)
    .join(" ");
  const rows = normalizeWeights(grid.rowWeights)
    .map((p) => `${p}fr`)
    .join(" ");
  return (
    <div
      data-testid="layout-grid-container"
      style={{
        display: "grid",
        gridTemplateColumns: cols,
        gridTemplateRows: rows,
        width: "100%",
        height: "100%",
        minHeight: 0,
        minWidth: 0,
      }}
    >
      {grid.cells.map((cell, i) => (
        <div
          key={keyOf(cell.node, i)}
          style={{
            gridColumn: `${cell.col + 1} / span ${cell.colSpan}`,
            gridRow: `${cell.row + 1} / span ${cell.rowSpan}`,
            minHeight: 0,
            minWidth: 0,
            overflow: "hidden",
          }}
        >
          <NodeView
            node={cell.node}
            cwd={cwd}
            vm={vm}
            parentSplit={null}
            projectId={projectId}
            visibleNodeIds={visibleNodeIds}
          />
        </div>
      ))}
    </div>
  );
}

/** A stable-ish React key for a node (its id when it has one). */
function keyOf(node: LayoutNode, fallback: number): string {
  return node.node.id ?? String(fallback);
}

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

/** True when `e` is the backend singleton-invariant refusal (`GatewayError`). */
function isAlreadyRunning(e: unknown): boolean {
  return (
    typeof e === "object" &&
    e !== null &&
    "code" in e &&
    (e as { code: unknown }).code === "AGENT_ALREADY_RUNNING"
  );
}
