import { useEffect, useRef, useState } from 'react';
import type {
  PiggyApprovalDecision,
  PiggyChatContext,
  PiggyChatEvent,
  PiggyMode,
  PiggyModelOption,
  PiggyProposedChange,
} from '@pig/core';
import { ApiError, get, getSupabase, post } from './api';

/**
 * Re-exported from @pig/core rather than declared here. The old local copy was
 * a fourth definition of a shape that already existed in the relay's zod
 * schema, the Piggy server's own interface and the model prompt — and widening
 * it for the docked panel meant widening it in all of them or getting a 400
 * from whichever hop was missed.
 */
export type { PiggyChatContext };
export {
  PiggyContextProvider,
  usePiggyContext,
  usePiggyCurrentContext,
} from './piggy-context';

export interface PiggyChatTurn {
  role: 'user' | 'assistant';
  content: string;
}

/**
 * The event union comes from @pig/core now, and the local copy is gone.
 *
 * It was declared here as well, which was survivable while the server only ever
 * added fields, and stopped being survivable the moment a turn could pause on an
 * approval: a client that has not been told about `approval_required` folds it
 * into nothing, the card never appears, and the turn sits open until the agent's
 * five-minute timeout rejects a change the user was never shown.
 */
export type { PiggyChatEvent, PiggyMode, PiggyModelOption, PiggyProposedChange };

/**
 * What Piggy may do without being asked again.
 *
 * `read_only` is the default here for the same reason the relay defaults to it:
 * write tools are something the user turns on, never something a forgotten
 * field turns on for them.
 */
export const PIGGY_DEFAULT_MODE: PiggyMode = 'read_only';

export interface PiggyStatus {
  enabled: boolean;
  canUse: boolean;
  /** What a client with no stored preference should open in. */
  mode: PiggyMode;
  /** The deployment's default model, or null when the agent cannot be asked. */
  modelId: string | null;
}

/**
 * The relay's own cap (`message: z.string().trim().min(1).max(4_000)`).
 * Named here so the composer stops the user at the same number rather than
 * letting them write a long question and collecting a 400 for it.
 */
export const PIGGY_MESSAGE_MAX_LENGTH = 4_000;

/** The relay's per-turn history cap. A longer turn is a 400 for the whole send. */
const HISTORY_CONTENT_MAX_LENGTH = 8_000;

/** The relay accepts at most twenty prior turns. */
const HISTORY_MAX_TURNS = 20;

// ------------------------------------------------------------- transcript

/**
 * One tool round trip, as the transcript remembers it.
 *
 * `result` and `durationMs` are not decoration: the server already streams the
 * tool's payload and the timeline used to throw it away, so "where did that
 * number come from?" had no answer inside the UI.
 */
export interface ToolStep {
  id: string;
  name: string;
  arguments: unknown;
  state: 'running' | 'succeeded' | 'failed';
  /** The tool's own payload, verbatim, so the answer can be checked against it. */
  result?: unknown;
  error?: string;
  /** Wall-clock time the call took, filled in when its result arrives. */
  durationMs?: number;
  /**
   * `performance.now()` at the `tool_call`. No event on the wire carries a
   * timestamp, so the only clock available to us is this one.
   */
  startedAt: number;
}

/**
 * A write Piggy has proposed and not made, as the transcript holds it.
 *
 * The states are deliberately more than "pending or done". `submitting` exists
 * because the decision travels on a second request while the turn's own stream
 * stays open, so there is a real interval in which the user has answered and
 * nothing has happened yet; and `error` sits alongside `pending` rather than
 * replacing it, because a decision that did not reach the relay leaves the
 * change exactly as it was — still waiting, still answerable.
 */
export interface ApprovalStep {
  change: PiggyProposedChange;
  state: 'pending' | 'submitting' | 'applied' | 'rejected' | 'failed';
  /** What the user answered, once they have. */
  decision?: PiggyApprovalDecision;
  /** Why the decision could not be delivered, or why the write itself failed. */
  error?: string;
}

export interface TranscriptMessage {
  id: string;
  role: 'user' | 'assistant';
  content: string;
  reasoning?: string;
  tools?: ToolStep[];
  /** Writes this turn proposed, in the order they were proposed. */
  approvals?: ApprovalStep[];
  error?: string;
  pending?: boolean;
  /** The user pressed stop. The answer is as complete as it will ever be. */
  stopped?: boolean;
  /** The response body closed without a `done` or an `error`. */
  truncated?: boolean;
  /** A user turn the relay never accepted. It is in the transcript but not in the model's. */
  failed?: boolean;
  /**
   * Epoch milliseconds before which re-sending this turn would be refused
   * again. Set only by a refusal that told us when it stops refusing — the
   * hourly rate limit — so that the transcript offers a wait rather than a
   * button whose one job is to collect the same 429.
   */
  retryableAt?: number;
  /** From the `meta` event: which model actually answered. */
  model?: string;
  /** From the `meta` event: what Piggy was allowed to do while answering. */
  mode?: PiggyMode;
  inputTokens?: number | null;
  outputTokens?: number | null;
  /** Whole micro-cents this turn cost, when the provider reported usage. */
  costMicroCents?: number | null;
  /**
   * The provider's own word for why the answer stopped. `length` means the
   * token budget cut it off mid-sentence, which is a different thing from the
   * connection dying and reads differently to the user.
   */
  finishReason?: string;
}

/**
 * Fold one streamed event into the assistant turn.
 *
 * Exported because the transcript state model is shared with the components
 * that render it, and a second copy of this reducer would drift from the event
 * union it consumes.
 */
export function applyEvent(message: TranscriptMessage, event: PiggyChatEvent): TranscriptMessage {
  if (event.type === 'meta') return { ...message, model: event.model, mode: event.mode };
  if (event.type === 'content_delta') return { ...message, content: message.content + event.delta };
  if (event.type === 'reasoning_delta') return { ...message, reasoning: (message.reasoning ?? '') + event.delta };
  if (event.type === 'tool_call') {
    const step: ToolStep = {
      id: event.id,
      name: event.name,
      arguments: event.arguments,
      state: 'running',
      startedAt: performance.now(),
    };
    return { ...message, tools: [...(message.tools ?? []), step] };
  }
  if (event.type === 'tool_result') {
    return {
      ...message,
      tools: (message.tools ?? []).map((tool) =>
        tool.id === event.id
          ? {
              ...tool,
              state: event.ok ? 'succeeded' : 'failed',
              result: event.result,
              error: event.error,
              durationMs: Math.round(performance.now() - tool.startedAt),
            }
          : tool,
      ),
    };
  }
  if (event.type === 'approval_required') {
    // Parked, not applied. Nothing in the CRM has changed at this point and the
    // card must not suggest otherwise — the tool is holding its own turn open
    // waiting for the answer this event asks for.
    const already = (message.approvals ?? []).some((entry) => entry.change.id === event.change.id);
    if (already) return message;
    return {
      ...message,
      approvals: [...(message.approvals ?? []), { change: event.change, state: 'pending' }],
    };
  }
  if (event.type === 'approval_resolved') {
    return {
      ...message,
      approvals: (message.approvals ?? []).map((entry) =>
        entry.change.id === event.changeId ? settleApproval(entry, event) : entry,
      ),
    };
  }
  if (event.type === 'done') {
    return {
      ...message,
      pending: false,
      inputTokens: event.inputTokens,
      outputTokens: event.outputTokens,
      costMicroCents: event.costMicroCents,
      finishReason: event.finishReason,
      /**
       * A turn cannot end with a write still waiting: the agent resolves every
       * pending change before it settles, and one still `pending` here means the
       * timeout rejected it. Leaving the card mid-flight would keep offering
       * buttons that no longer answer anything.
       */
      approvals: message.approvals?.map((entry) =>
        entry.state === 'pending' || entry.state === 'submitting'
          ? { ...entry, state: 'failed', error: 'This change expired before it was answered.' }
          : entry,
      ),
    };
  }
  if (event.type === 'error') return { ...message, pending: false, error: event.message };
  return message;
}

/**
 * The authoritative outcome of an approval: the agent has now either performed
 * the write or not, and says which. It is the only thing allowed to move a card
 * to `applied`, so no failure path can leave the transcript claiming a change
 * was saved.
 */
function settleApproval(
  entry: ApprovalStep,
  event: Extract<PiggyChatEvent, { type: 'approval_resolved' }>,
): ApprovalStep {
  if (!event.ok) {
    return { ...entry, state: 'failed', decision: event.decision, error: event.error };
  }
  return {
    ...entry,
    state: event.decision === 'apply' ? 'applied' : 'rejected',
    decision: event.decision,
    error: undefined,
  };
}

/**
 * A turn worth offering a re-send for: one that ended without an answer
 * through no choice of the user's. A stopped turn is excluded deliberately —
 * the user asked for it to end.
 *
 * So is a turn the hourly limit refused, until the hour it named has passed.
 * Retry sends the identical request to the identical limiter, so before then
 * the button cannot do the one thing it offers; the wait is in the turn's error
 * sentence instead, and `usePiggyConversation` re-renders when it elapses so
 * the button comes back the moment it means something.
 */
export function isRetryable(message: TranscriptMessage): boolean {
  if (message.role !== 'assistant') return false;
  if (!message.error && !message.truncated) return false;
  // The clock is read here rather than taken as a defaulted second parameter:
  // `messages.filter(isRetryable)` would then hand it the array index, which
  // typechecks and quietly answers the wrong question.
  return message.retryableAt === undefined || Date.now() >= message.retryableAt;
}

/**
 * The turns worth replaying to the model.
 *
 * Two exclusions, both load-bearing rather than tidiness. A `failed` user turn
 * is one the relay refused, so the model has never seen it; replaying it asks
 * for an answer to a question the user has since retried, and after the retry
 * it would be in there twice. An `error`ed assistant turn is dropped because
 * anything it holds is a fragment the model never finished, and its visible
 * text is our own error copy — which it would read back as its own words.
 */
export function toChatHistory(messages: TranscriptMessage[]): PiggyChatTurn[] {
  return messages
    .filter((entry) => !entry.failed && !entry.error && entry.content.trim())
    .slice(-HISTORY_MAX_TURNS)
    .map((entry) => ({ role: entry.role, content: entry.content.slice(0, HISTORY_CONTENT_MAX_LENGTH) }));
}

// ---------------------------------------------------------------- refusals

/** The relay's code for a spent hourly quota, as `apps/api` writes it. */
const PIGGY_RATE_LIMITED = 'piggy_rate_limited';

/**
 * How long to sit out a 429 that arrived without a retry-after.
 *
 * Only an intermediary that dropped both the header and the body can produce
 * one, so this is a guess — kept short, because a wait invented here that is
 * longer than the real one strands a user who could have asked again.
 */
const UNKNOWN_WAIT_SECONDS = 60;

/**
 * A refusal that carries when it stops being a refusal.
 *
 * `ApiError` is shared with the whole REST client and has nowhere to put the
 * relay's `retryAfterSeconds`, so the transcript used to see a rate limit as an
 * ordinary failed turn — indistinguishable from a dropped connection, and
 * offered the same Retry button, which spent the user's next request on the
 * identical refusal.
 */
export class PiggyRateLimitError extends ApiError {
  constructor(
    message: string,
    readonly retryAfterSeconds: number | null,
  ) {
    super(message, 429, PIGGY_RATE_LIMITED);
    this.name = 'PiggyRateLimitError';
  }
}

interface Refusal {
  message: string;
  /** Epoch ms, when the failure named a time before which a retry is pointless. */
  retryableAt?: number;
}

function describeFailure(error: unknown): Refusal {
  if (error instanceof PiggyRateLimitError) {
    const clearsAt = rateLimitClearsAt(error.retryAfterSeconds);
    return { message: rateLimitMessage(clearsAt, error.message), retryableAt: clearsAt.getTime() };
  }
  return { message: error instanceof Error ? error.message : 'Piggy chat failed.' };
}

/**
 * One moment, used for both the sentence and the return of the Retry button, so
 * that the two cannot disagree.
 *
 * Rounded up to the whole minute because the real window almost always ends
 * part-way through one: naming the minute it ends in would invite a retry a few
 * seconds early, and the limiter would refuse that too.
 */
function rateLimitClearsAt(retryAfterSeconds: number | null): Date {
  const seconds = retryAfterSeconds ?? UNKNOWN_WAIT_SECONDS;
  return new Date(Date.now() + Math.ceil(seconds / 60) * 60_000);
}

/**
 * A wait still worth reading ten minutes later.
 *
 * "Try again in twelve minutes" is written once and then goes quietly wrong as
 * it sits in the transcript, which is the same defect as a duration that reads
 * "0.0s": a number that stopped being a measurement. A clock time does not
 * drift, and the user's question is left on screen above it, so the sentence
 * says what will happen to it rather than only what went wrong.
 *
 * WHY the wait happened is the server's to say, not this function's. A 429
 * reaching this client has two quite different causes — PIG's own per-person
 * hourly quota, and Prime Inference throttling the deployment upstream — and
 * this hardcoded the first one for both. In a deployment where the upstream is
 * the common case, that is the product telling a GTM lead they have exhausted
 * an allowance they have barely touched, and discarding a far better sentence
 * the relay had already written ("Prime Inference is rate limiting us, so this
 * question was never answered… none of this was charged to you"). So the
 * server's reason is quoted and only the deadline is composed here.
 */
function rateLimitMessage(clearsAt: Date, serverReason: string): string {
  const time = clearsAt.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
  const reason = serverReason.trim() || 'Piggy is rate limited right now.';
  // A relayed sentence may or may not be punctuated; two full stops read as a
  // typo and none reads as a run-on.
  const stopped = /[.!?]$/.test(reason) ? reason : `${reason}.`;
  return `${stopped} Retry will work again at ${time}.`;
}

/** Both the body field and the header are integers of seconds, and both may be absent. */
function readRetryAfter(value: unknown): number | null {
  // `Number('')` is zero, which would print a limit that clears immediately.
  if (typeof value === 'string' && !value.trim()) return null;
  const seconds = typeof value === 'string' ? Number(value) : value;
  return typeof seconds === 'number' && Number.isFinite(seconds) && seconds >= 0 ? seconds : null;
}

// ------------------------------------------------------------ conversation

export interface PiggyConversation {
  messages: TranscriptMessage[];
  draft: string;
  setDraft: (value: string) => void;
  running: boolean;
  /**
   * How far Piggy may act, and in which model. Owned here rather than by the
   * controls that set them, because they belong to the conversation: the panel
   * that draws the pickers is unmounted every time the sheet closes, and a mode
   * that reset itself to read-only behind a closed overlay would be a silent
   * change to what the next question is allowed to do.
   */
  mode: PiggyMode;
  setMode: (mode: PiggyMode) => void;
  /** Undefined means "whatever the deployment's default is" — never a guess. */
  modelId: string | undefined;
  setModelId: (modelId: string | undefined) => void;
  /** Assigned by the relay on the first turn; needed to answer an approval. */
  conversationId: string | undefined;
  /**
   * Answer a proposed write. Resolves once the decision has been delivered, not
   * once the write has happened — the outcome arrives on the open stream as an
   * `approval_resolved` event, which is the only thing that marks a card applied.
   */
  approve: (changeId: string, decision: PiggyApprovalDecision) => void;
  /**
   * Send `text`, or the composer draft when it is omitted — a suggestion chip
   * and the retry button both have something to say and no reason to make the
   * user press send afterwards.
   *
   * `from` is the transcript the history is built out of. Only `retry` passes
   * it, with the exchange being replaced already removed, because that
   * exchange is superseded rather than continued.
   */
  send: (text?: string, from?: TranscriptMessage[]) => void;
  stop: () => void;
  /** Re-ask the question that produced this failed or truncated answer. */
  retry: (assistantId: string) => void;
}

/**
 * The whole client side of a Piggy conversation, deliberately separable from
 * the panel that renders it.
 *
 * It lives outside the panel because the panel is destroyed and rebuilt more
 * often than the conversation should be: the sheet and the drawer unmount
 * their children on close, and a thread that evaporates because the user
 * dismissed an overlay to look at the record behind it is the single most
 * expensive thing this UI can do. Whoever stays mounted owns the hook and
 * passes the result down.
 */
export function usePiggyConversation({
  context,
  initialPrompt = '',
  initialMode = PIGGY_DEFAULT_MODE,
  initialModelId,
  initialMessages,
  initialConversationId,
}: {
  context?: PiggyChatContext;
  initialPrompt?: string;
  /** Seeds only. The conversation owns both afterwards; see `PiggyConversation`. */
  initialMode?: PiggyMode;
  initialModelId?: string;
  /**
   * A transcript this conversation is resuming, read back from
   * `GET /api/piggy/conversations/:id`.
   *
   * Adopted until the first send of this session, not only at mount. It is
   * almost never available at mount: the workspace puts the thread on screen
   * as soon as the id is in the URL, and `GET /api/piggy/conversations/:id` is
   * a round trip behind it — so a state initialiser captured `[]` every time
   * and threw the stored transcript away as it arrived. The relay also builds
   * a turn's prompt from the `history` the client sends, so an unseeded hook
   * carried on a reopened thread having forgotten every word of it.
   */
  initialMessages?: TranscriptMessage[];
  /**
   * The stored conversation this thread continues, when it is not a new one.
   *
   * Sent with the first turn so the relay carries on the same conversation
   * rather than minting a second id for a thread the sidebar already lists,
   * and so an approval posted before any `meta` event has an id to travel with.
   */
  initialConversationId?: string;
} = {}): PiggyConversation {
  const [messages, setMessages] = useState<TranscriptMessage[]>(initialMessages ?? []);
  const [draft, setDraft] = useState(initialPrompt);
  const [running, setRunning] = useState(false);
  const [mode, setMode] = useState<PiggyMode>(initialMode);
  const [modelId, setModelId] = useState<string | undefined>(initialModelId);
  const [conversationId, setConversationId] = useState<string | undefined>(initialConversationId);
  /**
   * The id `approve` posts with.
   *
   * A ref as well as state because an approval can be answered in the same
   * frame the `meta` event arrived in — the card is drawn from a `setMessages`
   * that React may commit before it commits `setConversationId`, and posting an
   * approval with no conversation is a 400 the user reads as Piggy losing their
   * change.
   */
  const conversationRef = useRef<string | undefined>(initialConversationId);
  /**
   * The gate `send` actually reads, because `running` cannot close in time.
   *
   * A state flag only takes effect once React has re-rendered, so two Send
   * presses inside one frame — a double click, a held Enter key, a chip pressed
   * twice — both saw `running: false` and both opened a stream. Measured: three
   * clicks dispatched together produced three relay calls, three questions in
   * the transcript and three answers interleaving into it. That is three of the
   * user's thirty hourly messages spent at once, and only the last stream is
   * still reachable by Stop, since each one overwrites `abortRef`. A ref is
   * written synchronously, so the second press is refused by the first.
   */
  const runningRef = useRef(false);
  /**
   * Raised by the first send of this session, and never lowered.
   *
   * It is what makes the late seed below safe. Once this conversation has said
   * anything, what is on screen is ahead of anything the store can hand back,
   * and adopting a fetch would delete the turn being read.
   */
  const touched = useRef(false);
  const abortRef = useRef<AbortController | null>(null);
  // Bumped only to re-read the clock. `isRetryable` withholds the Retry button
  // while a rate limit holds, and nothing else in a transcript nobody is typing
  // into would ever re-render to bring it back.
  const [retryClock, setRetryClock] = useState(0);

  useEffect(() => () => abortRef.current?.abort(), []);

  /**
   * Put the stored transcript on screen when it lands.
   *
   * The store keeps every question, tool call, proposed change and answer, and
   * the client used to discard all of it: ~50 rows in the history rail opened
   * on a blank pane that said, in writing, that nothing was stored. This is the
   * half of that loop that was missing.
   *
   * Compared by identity rather than adopted outright because callers rebuild
   * the array on every render — `toTranscript(detail.data.messages)` is a fresh
   * object each time — and replacing state with an equal value would re-render
   * the whole transcript for nothing.
   */
  useEffect(() => {
    if (touched.current || !initialMessages) return;
    setMessages((current) => (sameTurns(current, initialMessages) ? current : initialMessages));
  }, [initialMessages]);

  useEffect(() => {
    const now = Date.now();
    const waits = messages
      .map((entry) => entry.retryableAt)
      .filter((at): at is number => at !== undefined && at > now);
    if (!waits.length) return;
    // One timer for the soonest wait; the effect re-runs when it fires and arms
    // the next, so a transcript with several refusals still costs one timeout.
    const timer = setTimeout(() => setRetryClock(Date.now()), Math.min(...waits) - now);
    return () => clearTimeout(timer);
  }, [messages, retryClock]);

  const updateTurn = (id: string, change: (turn: TranscriptMessage) => TranscriptMessage) =>
    setMessages((current) => current.map((entry) => (entry.id === id ? change(entry) : entry)));

  const send = async (text?: string, from?: TranscriptMessage[]) => {
    const message = (text ?? draft).trim();
    if (!message || runningRef.current) return;
    // Claimed before the first await, so nothing else can enter this turn.
    runningRef.current = true;
    touched.current = true;
    const userId = crypto.randomUUID();
    const assistantId = crypto.randomUUID();
    const history = toChatHistory(from ?? messages);
    setMessages((current) => [
      ...current,
      { id: userId, role: 'user', content: message },
      { id: assistantId, role: 'assistant', content: '', reasoning: '', tools: [], pending: true },
    ]);
    // Only the composer's own text is cleared. A suggestion or a retry has not
    // touched what the user was typing and must not throw it away.
    if (text === undefined) setDraft('');
    setRunning(true);
    const abort = new AbortController();
    abortRef.current = abort;

    // Tracked here rather than read back out of state: `messages` is a stale
    // closure by the time the stream finishes, and the question we need to
    // answer — did anything terminate this turn? — is about the events, not
    // about what React has committed.
    let settled = false;
    /**
     * Whether the relay ever started answering.
     *
     * The question below is marked "Not sent" only when this is false. A
     * connection that dies half-way through has still spent the turn — the
     * tokens are gone, the tools have run, and part of the answer is on screen
     * — so telling the user their question never left is both wrong and the
     * thing that makes them ask it again.
     */
    let accepted = false;
    try {
      const request = {
        message,
        history,
        context,
        mode,
        modelId,
        // Sent from the second turn on, so the agent can keep one thread rather
        // than starting a fresh one under every question.
        conversationId: conversationRef.current,
      };
      for await (const event of streamPiggyChat(request, abort.signal)) {
        accepted = true;
        if (event.type === 'meta' && event.conversationId !== conversationRef.current) {
          conversationRef.current = event.conversationId;
          setConversationId(event.conversationId);
        }
        if (event.type === 'done' || event.type === 'error') settled = true;
        updateTurn(assistantId, (turn) => applyEvent(turn, event));
      }
      if (!settled) {
        // The body closed mid-answer. `readNdjson` returns normally when that
        // happens, so without this the turn stays `pending` forever and a dead
        // connection is indistinguishable from Piggy still thinking.
        updateTurn(assistantId, (turn) =>
          strandApprovals({ ...turn, pending: false, truncated: true }),
        );
      }
    } catch (error) {
      if (abort.signal.aborted) {
        // Aborting rejects the read, so neither `done` nor `error` ever
        // arrives and nothing else will clear `pending` — which left the
        // docked panel spinning across every subsequent navigation.
        updateTurn(assistantId, (turn) =>
          strandApprovals({ ...turn, pending: false, stopped: true }),
        );
      } else {
        const failure = describeFailure(error);
        setMessages((current) =>
          current.map((entry) => {
            if (entry.id === assistantId) {
              return strandApprovals({
                ...entry,
                pending: false,
                error: failure.message,
                retryableAt: failure.retryableAt,
              });
            }
            // The question is marked, not deleted: the user's words stay on
            // screen to be re-sent, and `toChatHistory` knows to keep a turn
            // the relay refused out of the model's history. Only a turn the
            // relay never began answering is marked — see `accepted`.
            if (entry.id === userId && !accepted) return { ...entry, failed: true };
            return entry;
          }),
        );
      }
    } finally {
      abortRef.current = null;
      runningRef.current = false;
      setRunning(false);
    }
  };

  const retry = (assistantId: string) => {
    if (runningRef.current) return;
    const index = messages.findIndex((entry) => entry.id === assistantId);
    const question = index > 0 ? messages[index - 1] : undefined;
    if (!question || question.role !== 'user') return;
    // Drop the failed exchange rather than leaving it above the new one: the
    // same question twice in the transcript reads as Piggy having been asked
    // twice, and a partial answer left in place would be replayed as history
    // for the very question it failed to answer.
    setMessages((current) => current.filter((entry) => entry.id !== question.id && entry.id !== assistantId));
    void send(question.content, messages.slice(0, index - 1));
  };

  /**
   * Answer a proposed write.
   *
   * Optimistic only as far as honesty allows: the card moves to `submitting` so
   * the buttons stop inviting a second press, and no further. Only the
   * `approval_resolved` event that comes back on the open stream can say the
   * change was applied, because only the agent knows whether `executeMutation`
   * accepted it. A POST that fails puts the card back where it was, with the
   * reason attached — the change really is still pending at the agent, so
   * offering the buttons again is the truth rather than a courtesy.
   */
  const approve = async (changeId: string, decision: PiggyApprovalDecision) => {
    const conversation = conversationRef.current;
    // Only the turn actually holding the card is rewritten. Mapping every
    // message would give the whole transcript new identities and re-render a
    // long conversation on each button press.
    const settleCard = (change: (entry: ApprovalStep) => ApprovalStep) =>
      setMessages((current) =>
        current.map((entry) =>
          entry.approvals?.some((approval) => approval.change.id === changeId)
            ? {
                ...entry,
                approvals: entry.approvals.map((approval) =>
                  approval.change.id === changeId ? change(approval) : approval,
                ),
              }
            : entry,
        ),
      );

    if (!conversation) {
      settleCard((entry) => ({
        ...entry,
        error: 'Piggy has not identified this conversation yet.',
      }));
      return;
    }

    settleCard((entry) => ({ ...entry, state: 'submitting', decision, error: undefined }));
    try {
      await post<{ ok: boolean }>('/api/piggy/approve', {
        conversationId: conversation,
        changeId,
        decision,
      });
    } catch (error) {
      /*
       * A 404 is not a delivery failure, it is the change being over.
       *
       * The relay answers `approval_not_pending` when the agent no longer holds
       * the card: the five-minute deadline rejected it, or the turn was
       * abandoned. Putting the card back to `pending` there — which is what
       * this did for every failure alike — leaves it reading "Needs you" with a
       * live Apply button over a decision that can never be delivered, so the
       * user presses it and gets the same 404 for ever. Every OTHER failure
       * really does leave the change pending at the agent, and for those
       * offering the buttons again is the truth rather than a courtesy.
       */
      const settled = error instanceof ApiError && error.code === 'approval_not_pending';
      settleCard((entry) => ({
        ...entry,
        state: settled ? 'failed' : 'pending',
        decision: undefined,
        error: error instanceof Error ? error.message : 'That decision did not reach Piggy.',
      }));
    }
  };

  return {
    messages,
    draft,
    setDraft,
    running,
    mode,
    setMode,
    modelId,
    setModelId,
    conversationId,
    approve: (changeId, decision) => void approve(changeId, decision),
    send: (text, fromTranscript) => void send(text, fromTranscript),
    stop: () => abortRef.current?.abort(),
    retry,
  };
}

/**
 * A turn that ended without the agent's word on its pending writes.
 *
 * Stop, a dropped connection and a refused request all leave the stream that
 * `approval_resolved` would have arrived on closed for good. The change may
 * genuinely still be waiting at the agent until its five-minute timeout, but
 * nothing this client does can answer it any more, so the card says so instead
 * of showing buttons that post into a conversation nobody is reading.
 */
function strandApprovals(message: TranscriptMessage): TranscriptMessage {
  if (!message.approvals?.length) return message;
  return {
    ...message,
    approvals: message.approvals.map((entry) =>
      entry.state === 'pending' || entry.state === 'submitting'
        ? { ...entry, state: 'failed', error: 'This turn ended before the change was answered.' }
        : entry,
    ),
  };
}

/**
 * The models this deployment offers, for the picker.
 *
 * Served by the relay from the agent's own catalogue rather than a list kept
 * here, because the relay refuses any model id that is not in it — a hard-coded
 * option that has been retired upstream would be a menu entry whose only effect
 * is a 400.
 */
export function fetchPiggyModels(): Promise<{
  models: PiggyModelOption[];
  defaultModelId: string | null;
}> {
  return get('/api/piggy/models');
}

export interface PiggyChatRequest {
  message: string;
  history?: PiggyChatTurn[];
  context?: PiggyChatContext;
  /** Omitted, the relay reads it as `read_only`. Sent explicitly all the same. */
  mode?: PiggyMode;
  /** Must be one the relay's catalogue lists, or the turn is a 400. */
  modelId?: string;
  conversationId?: string;
}

export async function* streamPiggyChat(
  request: PiggyChatRequest,
  signal?: AbortSignal,
): AsyncGenerator<PiggyChatEvent> {
  const supabase = getSupabase();
  const token = supabase ? (await supabase.auth.getSession()).data.session?.access_token : null;
  const response = await fetch('/api/piggy/chat', {
    method: 'POST',
    headers: {
      'content-type': 'application/json',
      ...(token ? { authorization: `Bearer ${token}` } : {}),
    },
    body: JSON.stringify(request),
    signal,
  });

  if (!response.ok) {
    let message = response.statusText;
    let code: string | undefined;
    let retryAfterSeconds: number | null = null;
    try {
      const body = (await response.json()) as {
        error?: string;
        code?: string;
        retryAfterSeconds?: unknown;
      };
      message = body.error ?? message;
      code = body.code;
      retryAfterSeconds = readRetryAfter(body.retryAfterSeconds);
    } catch {
      // The authenticated proxy normally returns JSON, but an upstream proxy may not.
    }
    if (response.status === 429) {
      // The header is read as the fallback rather than the body's field alone:
      // an intermediary of its own may rate-limit us with a bare `Retry-After`
      // and no JSON at all, and a wait we cannot name is one the user is told
      // to guess at.
      throw new PiggyRateLimitError(
        message,
        retryAfterSeconds ?? readRetryAfter(response.headers.get('retry-after')),
      );
    }
    throw new ApiError(message, response.status, code);
  }
  if (!response.body) throw new Error('Piggy returned no response stream.');

  yield* readNdjson<PiggyChatEvent>(response.body, signal);
}

export async function* readNdjson<Value>(
  stream: ReadableStream<Uint8Array>,
  signal?: AbortSignal,
): AsyncGenerator<Value> {
  const reader = stream.getReader();
  const decoder = new TextDecoder();
  let buffer = '';
  try {
    while (true) {
      if (signal?.aborted) throw signal.reason;
      const { done, value } = await reader.read();
      buffer += decoder.decode(value, { stream: !done });
      const lines = buffer.split('\n');
      buffer = lines.pop() ?? '';
      for (const line of lines) {
        const event = parseNdjsonLine<Value>(line);
        if (event !== undefined) yield event;
      }
      if (done) {
        const event = parseNdjsonLine<Value>(buffer);
        if (event !== undefined) yield event;
        return;
      }
    }
  } finally {
    reader.releaseLock();
  }
}

/**
 * One NDJSON record, or nothing at all.
 *
 * A body cut mid-line leaves a fragment behind, and parsing it threw out of the
 * generator: the transcript then showed `Unexpected end of JSON input` where
 * Piggy's answer had been — the partial text already streamed was replaced by
 * that sentence, and the question above it was labelled "Not sent" after the
 * tokens had been spent. A fragment is not an event. Dropping it lets `send`
 * see the stream end without a `done`, which is exactly what happened, and the
 * turn is marked truncated with everything that did arrive still on screen.
 */
function parseNdjsonLine<Value>(line: string): Value | undefined {
  if (!line.trim()) return undefined;
  try {
    return JSON.parse(line) as Value;
  } catch {
    return undefined;
  }
}

/**
 * Whether two transcripts are the same turns in the same order.
 *
 * Ids are enough: a stored row's id is its primary key and a live turn's is a
 * UUID minted once, so two arrays agreeing on every id are two readings of one
 * conversation.
 */
function sameTurns(a: TranscriptMessage[], b: TranscriptMessage[]): boolean {
  return a.length === b.length && a.every((entry, index) => entry.id === b[index]?.id);
}
