/**
 * useWritePortal — the single write-portal of an agent cell (ARCHITECTURE §20).
 *
 * The agent cell hosts the CLI as a **native terminal**: every human keystroke
 * (Enter included) reaches the PTY through {@link TerminalView}. IdeA never owns
 * a chat box; it only *observes* a "human line in progress" counter and, when a
 * delegation is ready, injects its text at a **clean boundary** (human line
 * empty) through the SAME handle the human keystrokes use — so there is exactly
 * one physical PTY writer (the front), no race between two writers.
 *
 * Responsibilities (the four the cadrage assigns to the portal):
 *  1. The **human-line counter**: `+1` per printable keystroke, `reset` on Enter
 *     (`\r`/`\n`) and Ctrl-C (`\x03`). Only keystrokes are counted; control
 *     sequences (arrows/escape, CSI…) are ignored so they never falsely mark the
 *     line "non-empty" and block injection forever.
 *  2. The **local FIFO** of `delegationReady` events received for this agent
 *     (via `system.onDomainEvent`, filtered like {@link useAgentBusy}).
 *  3. The **handshake (b→e)**: only when a delegation is at the head of the FIFO
 *     AND the human line is empty:
 *       (b) suspend the keystroke relay + raise the overlay;
 *       (c) re-check the counter K; if K>0 write exactly K backspaces `\x7f`
 *           (never Ctrl-U) to erase what the human typed in the micro-window;
 *       (d) `write(text)` WITHOUT `\n`, then after `submitDelayMs ?? 60` ms
 *           `write(submitSequence ?? "\r")`; then ack via `delegationDelivered`
 *           exactly once;
 *       (e) drop the suspension + lower the overlay, with a **2 s floor** from
 *           (b) (anti-flash): if (e) would happen < 2000 ms after (b), the
 *           overlay/suspension are held until 2000 ms.
 *     A delegation that arrives while the line is non-empty simply **waits** in
 *     the FIFO (no write, no refusal, no loss) and is released at the next empty
 *     line.
 *  4. The boolean **overlay** state, rendered by the hosting cell.
 *
 * Hexagonal: the hook talks to ports via DI (`system`, `input`), never to
 * `invoke()`. It returns a {@link WritePortal} object whose identity is stable
 * (memoised) so {@link TerminalView} can hold it in a ref.
 */

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

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

/** Default submit sequence when the profile omits one (paste-detection esquive). */
const DEFAULT_SUBMIT_SEQUENCE = "\r";
/**
 * Default delay (ms) between text and submit-sequence writes.
 *
 * Keep this conservative: Codex's interactive TUI can otherwise keep a delegated
 * prompt in the input editor until the user presses Enter manually.
 */
const DEFAULT_SUBMIT_DELAY_MS = 350;
/** Anti-flash overlay floor (ms) from step (b). */
const OVERLAY_FLOOR_MS = 2000;
/** Max UTF-8 bytes per delegation write chunk. */
const DELEGATION_WRITE_CHUNK_BYTES = 512;
/** Delay between delegation chunks, to avoid TUI paste/drop behaviour on cold agents. */
const DELEGATION_WRITE_CHUNK_DELAY_MS = 8;

const LOG_PREFIX = "[write-portal]";

/** A pending delegation kept in the local FIFO. */
interface PendingDelegation {
  ticket: string;
  text: string;
  submitSequence?: string;
  submitDelayMs?: number;
}

/** What the hook returns: the portal object (for TerminalView) + overlay state. */
export interface UseWritePortalResult {
  /** The portal object handed to {@link TerminalView} via its `portal` prop. */
  portal: WritePortal;
  /** Whether the "an agent is speaking…" overlay must be shown. */
  overlay: boolean;
}

const encoder = new TextEncoder();

/**
 * True for a single printable keystroke chunk. We count printable input only;
 * control bytes (ESC/CSI: arrows, function keys, Ctrl-*) must not bump the
 * counter, otherwise an arrow keypress would mark the line "non-empty" forever.
 *
 * A keystroke chunk from xterm's `onData` is normally a single grapheme, but a
 * paste can deliver many. We treat the chunk as printable iff it contains **no**
 * control character (code point < 0x20 or 0x7f). Enter (`\r`/`\n`) and Ctrl-C
 * (`\x03`) are handled separately as resets before this check.
 */
function isPrintable(data: string): boolean {
  if (data.length === 0) return false;
  for (const ch of data) {
    const code = ch.codePointAt(0)!;
    if (code < 0x20 || code === 0x7f) return false;
  }
  return true;
}

export function useWritePortal(
  projectId: string,
  agentId: string | null,
): UseWritePortalResult {
  const { system, input } = useGateways();
  const [overlay, setOverlay] = useState(false);

  // ── Mutable state held in refs (the handshake runs outside React render) ───
  const counterRef = useRef(0); // human line counter K
  const queueRef = useRef<PendingDelegation[]>([]); // local FIFO
  const handleRef = useRef<TerminalHandle | null>(null);
  const suspendedRef = useRef(false); // relay suspended while injecting
  const injectingRef = useRef(false); // a handshake is in flight
  const subscribedAgentRef = useRef<string | null>(null);
  const frontAttachedAgentRef = useRef<string | null>(null);

  // Stable refs to the gateways used inside the (non-reactive) handshake.
  const inputRef = useRef(input);
  inputRef.current = input;
  const agentIdRef = useRef(agentId);
  agentIdRef.current = agentId;

  // The input port needs the project id for the delivery ack.
  const projectIdRef = useRef(projectId);
  projectIdRef.current = projectId;

  // Backend routing must only switch to the frontend path once this cell can
  // actually consume DelegationReady events. If we report "front attached"
  // immediately at bindHandle time, a delegation emitted before the async event
  // subscription is installed is lost: the backend publishes an event nobody is
  // listening to, and it also skips the headless PTY write fallback.
  const reconcileFrontAttachment = useRef<() => void>(() => {});
  reconcileFrontAttachment.current = () => {
    const desired = handleRef.current && subscribedAgentRef.current
      ? subscribedAgentRef.current
      : null;
    const previous = frontAttachedAgentRef.current;
    if (previous === desired) return;

    if (previous) {
      portalLog("front-attached:set", { agent: previous, attached: false });
      void inputRef.current?.setFrontAttached(previous, false).catch((error) => {
        portalLog("front-attached:set-failed", {
          agent: previous,
          attached: false,
          error: describeError(error),
        });
      });
    }
    if (desired) {
      portalLog("front-attached:set", { agent: desired, attached: true });
      void inputRef.current?.setFrontAttached(desired, true).catch((error) => {
        portalLog("front-attached:set-failed", {
          agent: desired,
          attached: true,
          error: describeError(error),
        });
      });
    }
    frontAttachedAgentRef.current = desired;
  };

  // ── (3)+(4): the handshake, attempted whenever a boundary may have opened ──
  const tryInject = useRef<() => void>(() => {});
  tryInject.current = () => {
    if (injectingRef.current) return; // one handshake at a time
    const head = queueRef.current[0];
    if (!head) return;
    if (counterRef.current > 0) return; // line not empty ⇒ wait
    const handle = handleRef.current;
    if (!handle) return; // no live PTY yet ⇒ wait
    const agent = agentIdRef.current;
    const project = projectIdRef.current;
    if (!agent) return;

    injectingRef.current = true;
    const startedAt = Date.now();

    // (b) suspend the relay + raise the overlay.
    suspendedRef.current = true;
    setOverlay(true);

    void (async () => {
      let drainNext = false;
      try {
        portalLog("inject:start", {
          agent,
          ticket: head.ticket,
          textBytes: encoder.encode(head.text).byteLength,
          queue: queueRef.current.length,
          submitSequence: describeControl(head.submitSequence),
          submitDelayMs: head.submitDelayMs ?? DEFAULT_SUBMIT_DELAY_MS,
        });
        // Yield once so any keystroke that landed in the micro-window between
        // "empty observed" (a) and the suspension taking hold (b) is counted
        // before we re-check K at step (c). Without this the race window would
        // be zero and a late keystroke would survive the injection un-erased.
        await Promise.resolve();
        // (c) re-check K; erase exactly K human keystrokes with `\x7f`
        // (backspace) — never Ctrl-U (which could clear more than the human typed).
        const k = counterRef.current;
        if (k > 0) {
          portalLog("inject:erase-human-window", { agent, ticket: head.ticket, count: k });
          await handle.write(encoder.encode("\x7f".repeat(k)));
          counterRef.current = 0;
        }

        // (d) write the text WITHOUT a trailing newline, then the submit
        // sequence after the profile's delay (esquive de la paste-detection).
        // Long delegated prompts are chunked: Codex's TUI can otherwise treat a
        // large single write like a paste and occasionally keep/drop the tail on
        // cold-started agents before Enter is received.
        await writeDelegationText(handle, head.text, head.ticket);
        const delay = head.submitDelayMs ?? DEFAULT_SUBMIT_DELAY_MS;
        portalLog("inject:before-submit-delay", { agent, ticket: head.ticket, delay });
        await sleep(delay);
        const submit = normalizeSubmitSequence(head.submitSequence, head.ticket);
        portalLog("inject:submit-write:start", {
          agent,
          ticket: head.ticket,
          submitSequence: describeControl(submit),
          submitBytes: encoder.encode(submit).byteLength,
        });
        await handle.write(encoder.encode(submit));
        portalLog("inject:submit-write:ok", { agent, ticket: head.ticket });

        // Dequeue + ack exactly once (best-effort; never throws the handshake).
        queueRef.current.shift();
        drainNext = true;
        try {
          portalLog("inject:ack:start", { agent, ticket: head.ticket });
          await inputRef.current.delegationDelivered(project, agent, head.ticket);
          portalLog("inject:ack:ok", { agent, ticket: head.ticket });
        } catch {
          portalLog("inject:ack:failed", { agent, ticket: head.ticket });
          /* ack is observability-only; never block the portal */
        }
      } catch (error) {
        portalLog("inject:failed", {
          agent,
          ticket: head.ticket,
          error: describeError(error),
        });
      } finally {
        // (e) lower the overlay + resume the relay, with the 2 s anti-flash floor.
        const elapsed = Date.now() - startedAt;
        const remaining = OVERLAY_FLOOR_MS - elapsed;
        if (remaining > 0) await sleep(remaining);
        suspendedRef.current = false;
        setOverlay(false);
        injectingRef.current = false;
        // A boundary may now be open for the next queued delegation.
        if (queueRef.current.length > 0 && drainNext) tryInject.current();
      }
    })();
  };

  // ── (2): subscribe to delegationReady for THIS agent and enqueue ───────────
  useEffect(() => {
    if (!system || !agentId) {
      subscribedAgentRef.current = null;
      reconcileFrontAttachment.current();
      return;
    }
    let unsubscribe: (() => void) | undefined;
    let cancelled = false;
    const subscribedAgent = agentId;

    void system
      .onDomainEvent((event: DomainEvent) => {
        if (event.type !== "delegationReady") return;
        if (event.agentId !== subscribedAgent) return;
        queueRef.current.push({
          ticket: event.ticket,
          text: event.text,
          submitSequence: event.submitSequence,
          submitDelayMs: event.submitDelayMs,
        });
        portalLog("event:delegation-ready", {
          agent: subscribedAgent,
          ticket: event.ticket,
          textBytes: encoder.encode(event.text).byteLength,
          queue: queueRef.current.length,
          submitSequence: describeControl(event.submitSequence),
          submitDelayMs: event.submitDelayMs,
        });
        // A delegation may already be at a clean boundary (line empty) — try now.
        tryInject.current();
      })
      .then((un) => {
        if (cancelled) un();
        else {
          unsubscribe = un;
          subscribedAgentRef.current = subscribedAgent;
          portalLog("event:subscribed", { agent: subscribedAgent });
          reconcileFrontAttachment.current();
        }
      });

    return () => {
      cancelled = true;
      unsubscribe?.();
      if (subscribedAgentRef.current === subscribedAgent) {
        subscribedAgentRef.current = null;
        portalLog("event:unsubscribed", { agent: subscribedAgent });
      }
      if (frontAttachedAgentRef.current === subscribedAgent) {
        void inputRef.current?.setFrontAttached(subscribedAgent, false).catch(() => {});
        frontAttachedAgentRef.current = null;
      } else {
        reconcileFrontAttachment.current();
      }
    };
  }, [system, agentId]);

  // ── (1)+(3): the portal object handed to TerminalView (stable identity) ────
  const portal = useMemo<WritePortal>(
    () => ({
      onHumanData(data: string) {
        // Reset on Enter / Ctrl-C, increment on a printable keystroke, ignore
        // control sequences. After a reset the line is empty ⇒ a queued
        // delegation may now be injectable.
        if (data === "\r" || data === "\n" || data === "\x03") {
          counterRef.current = 0;
          tryInject.current();
          return;
        }
        if (isPrintable(data)) {
          counterRef.current += 1;
        }
      },
      isSuspended() {
        return suspendedRef.current;
      },
      bindHandle(handle: TerminalHandle) {
        handleRef.current = handle;
        portalLog("handle:bound", { agent: agentIdRef.current, sessionId: handle.sessionId });
        // Tell the backend a frontend cell is mounted only after the event
        // subscription is ready. Until then, the backend keeps the headless PTY
        // write fallback, so a delegation cannot disappear during mount.
        reconcileFrontAttachment.current();
        // The PTY just became available — a queued delegation may be injectable.
        tryInject.current();
      },
      unbindHandle() {
        portalLog("handle:unbound", {
          agent: agentIdRef.current,
          sessionId: handleRef.current?.sessionId,
        });
        handleRef.current = null;
        // The cell is gone — let the backend fall back to headless delivery so a
        // delegation arriving while this agent has no live cell is not lost.
        reconcileFrontAttachment.current();
      },
    }),
    [],
  );

  return { portal, overlay };
}

/** Promise-based delay used by the handshake (driven by fake timers in tests). */
function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function writeDelegationText(
  handle: TerminalHandle,
  text: string,
  ticket: string,
): Promise<void> {
  const chunks = splitUtf8Chunks(text, DELEGATION_WRITE_CHUNK_BYTES);
  for (let i = 0; i < chunks.length; i += 1) {
    portalLog("inject:text-chunk:start", {
      ticket,
      sessionId: handle.sessionId,
      chunk: i + 1,
      chunks: chunks.length,
      bytes: encoder.encode(chunks[i]).byteLength,
    });
    await handle.write(encoder.encode(chunks[i]));
    portalLog("inject:text-chunk:ok", {
      ticket,
      sessionId: handle.sessionId,
      chunk: i + 1,
      chunks: chunks.length,
    });
    if (i + 1 < chunks.length) {
      await sleep(DELEGATION_WRITE_CHUNK_DELAY_MS);
    }
  }
}

function normalizeSubmitSequence(sequence: string | undefined, ticket: string): string {
  if (sequence == null) return DEFAULT_SUBMIT_SEQUENCE;
  if (sequence.length > 0) return sequence;
  portalLog("inject:empty-submit-sequence-defaulted", {
    ticket,
    defaultSubmitSequence: describeControl(DEFAULT_SUBMIT_SEQUENCE),
  });
  return DEFAULT_SUBMIT_SEQUENCE;
}

function splitUtf8Chunks(text: string, maxBytes: number): string[] {
  if (text.length === 0) return [""];
  const chunks: string[] = [];
  let current = "";
  let currentBytes = 0;

  for (const ch of text) {
    const bytes = encoder.encode(ch).byteLength;
    if (current.length > 0 && currentBytes + bytes > maxBytes) {
      chunks.push(current);
      current = "";
      currentBytes = 0;
    }
    current += ch;
    currentBytes += bytes;
  }

  if (current.length > 0) chunks.push(current);
  return chunks;
}

function describeControl(value: string | undefined): string {
  if (value == null) return "<default>";
  if (value.length === 0) return "<empty>";
  return [...value]
    .map((ch) => {
      switch (ch) {
        case "\r":
          return "\\r";
        case "\n":
          return "\\n";
        case "\x7f":
          return "\\x7f";
        default: {
          const code = ch.codePointAt(0)!;
          if (code < 0x20) return `\\x${code.toString(16).padStart(2, "0")}`;
          return ch;
        }
      }
    })
    .join("");
}

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

function portalLog(event: string, fields: Record<string, unknown>): void {
  console.info(LOG_PREFIX, event, fields);
}
