import {
  useCallback,
  useEffect,
  useLayoutEffect,
  useRef,
  useState,
  type ReactNode,
} from 'react';
import { useQuery } from '@tanstack/react-query';
import { CircleStop, Database, Loader2, Send, XCircle } from 'lucide-react';
import type { PiggyApprovalDecision } from '@pig/core';
import { get } from '@/lib/api';
import { useIsMobile } from '@/hooks/use-media-query';
import { PIGGY_ASK_LABEL, piggyCopy, piggyLine } from '@/lib/piggy-copy';
import { usePiggyCurrentContext } from '@/lib/piggy-context';
import {
  PIGGY_MESSAGE_MAX_LENGTH,
  isRetryable,
  usePiggyConversation,
  type PiggyChatContext,
  // Aliased because the transcript viewport below is also called
  // `PiggyConversation`: one is the state a panel is driven by, the other is
  // the element it is drawn in, and they meet in this file only.
  type PiggyConversation as PiggyConversationState,
  type PiggyStatus,
  type TranscriptMessage,
} from '@/lib/piggy-chat';
import { PIGGY_FOLLOW_UP_COUNT, piggyFollowUps } from '@/lib/piggy-suggestions';
import { PiggyApprovalCard } from './piggy/approval-card';
import { PiggyConversation, PiggyConversationScrollButton } from './piggy/conversation';
import { PiggyMessageActions } from './piggy/message-actions';
import { PiggyReasoning } from './piggy/reasoning';
import { PiggyResponse } from './piggy/response';
import { PiggyToolStep } from './piggy/tool';
import { PiggyControls, usePiggyChatSession, type PiggyControlsState } from './piggy/workspace/controls';
import { PiggyWorkspaceStarters } from './piggy/workspace/starters';
import { PiggyMark } from './PiggyMark';
import { Button, Badge, EmptyState, cn } from './ui';
import {
  Drawer,
  DrawerContent,
  DrawerDescription,
  DrawerHeader,
  DrawerTitle,
} from './ui/drawer';
import {
  Sheet,
  SheetContent,
  SheetDescription,
  SheetHeader,
  SheetTitle,
} from './ui/sheet';
import { Textarea } from './ui/textarea';

/**
 * The composer starts counting down only near the cap. A counter that is
 * always on reads as a limit the user is expected to work within; one that
 * appears in the last few hundred characters reads as a warning, which is what
 * it is — past 4,000 the relay answers 400 and the send is lost.
 */
const COUNTER_VISIBLE_FROM = PIGGY_MESSAGE_MAX_LENGTH - 400;

/**
 * How narrow a Piggy surface has to be before it takes the tight treatment.
 *
 * 28rem, and measured on the panel rather than the window, because the four
 * surfaces this panel is drawn in disagree with the viewport: the dock is 22rem
 * inside a 1440px window, the phone drawer is 393px inside a 393px one. It used
 * to be a prop that only the dock passed, so the narrowest surface in the
 * product — the drawer a phone gets — was drawing the roomy layout.
 */
const COMPACT_WIDTH = 448;

/** `max-h-36` on the field, in pixels, for the auto-grow below to cap at. */
const COMPOSER_MAX_HEIGHT = 144;

// ------------------------------------------------------------- one front door

/**
 * A question a page asked on the user's behalf.
 *
 * `id` is new on every press so that pressing the same button twice puts the
 * question back in the composer, rather than the second press doing nothing
 * because the request happened to be identical.
 */
export interface PiggyAsk {
  id: number;
  context?: PiggyChatContext;
  prompt?: string;
}

let nextAskId = 1;
const askListeners = new Set<(ask: PiggyAsk) => void>();

/**
 * Ask Piggy something from anywhere, on whichever surface Piggy is showing.
 *
 * PIG used to have two front doors on the same row of pixels: the pig in the
 * header opened the dock, and a speech bubble labelled "Ask Piggy" beside it
 * opened a *different* Piggy in a sheet on top of it. Two surfaces, two
 * transcripts, one agent — and no sentence that told them apart.
 *
 * So a page button no longer opens anything. It publishes the question, and
 * the one Piggy this viewport has room for takes it: the docked column above
 * 1280px, the sheet or drawer below it. The question is seeded into the
 * composer and never sent, because the user asked for a starting point, not
 * for a turn to be spent on their behalf.
 */
export function requestPiggyAsk(request: Omit<PiggyAsk, 'id'>): void {
  const ask: PiggyAsk = { ...request, id: nextAskId++ };
  for (const listener of askListeners) listener(ask);
}

/** Subscribe to those requests for as long as this component is mounted. */
export function usePiggyAskRequests(handler: (ask: PiggyAsk) => void): void {
  const latest = useRef(handler);
  // Written in a layout effect rather than during render: a handler captured
  // during a render React later discards would answer with stale state.
  useLayoutEffect(() => {
    latest.current = handler;
  });
  useEffect(() => {
    const listener = (ask: PiggyAsk) => latest.current(ask);
    askListeners.add(listener);
    return () => {
      askListeners.delete(listener);
    };
  }, []);
}

export function PiggyAskButton({
  context,
  prompt,
  label = PIGGY_ASK_LABEL,
  variant = 'outline',
}: {
  context?: PiggyChatContext;
  prompt?: string;
  label?: string;
  variant?: React.ComponentProps<typeof Button>['variant'];
}) {
  const status = usePiggyStatus();
  const unavailable = status.data && !status.data.canUse;
  // An explicit prop always wins. Every existing call site passes the record
  // the user pressed the button on, and the ambient page context is a guess
  // that must never displace it.
  const ambient = usePiggyCurrentContext();
  return (
    <Button
      type="button"
      variant={variant}
      disabled={Boolean(unavailable)}
      title={unavailable ? 'Piggy is disabled or this credential lacks read access.' : undefined}
      onClick={() => requestPiggyAsk({ context: context ?? ambient, prompt })}
    >
      <PiggyMark className="size-4" />
      {label}
    </Button>
  );
}

/**
 * Piggy is unavailable, said the same way wherever it is discovered.
 *
 * The relay answers 503 when the runtime is off, so every surface that draws a
 * composer has to ask `usePiggyStatus` first; this is what they draw instead.
 */
export function PiggyUnavailable({ status }: { status: PiggyStatus | undefined }) {
  return (
    <EmptyState
      icon={<PiggyMark className="size-6" />}
      title="Piggy is unavailable"
      description={
        status?.enabled
          ? 'This credential does not have read access.'
          : 'An administrator must enable the isolated Piggy runtime. No question is sent while this state is shown.'
      }
    />
  );
}

export function ResponsivePiggyChat({
  open,
  onOpenChange,
  context,
  initialPrompt,
  seed,
}: {
  open: boolean;
  onOpenChange(open: boolean): void;
  context?: PiggyChatContext;
  initialPrompt?: string;
  /** A later question from a page's Ask Piggy button. See `PiggyChatPanel`. */
  seed?: PiggySeed;
}) {
  // The same breakpoint the shell switches navigation at. It used to be `md`,
  // which meant a 900px tablet got the desktop side sheet sliding in behind
  // the phone tab bar it was still showing.
  const desktop = !useIsMobile();
  // Held here, one level above the overlay, because both the Sheet and the
  // Drawer unmount their children when they close. With the thread inside,
  // dismissing the overlay for two seconds to look at the record underneath
  // destroyed the conversation, the draft and any answer still streaming — and
  // with the controls inside, the mode went with it.
  const { conversation, controls } = usePiggyChatSession({ context, initialPrompt });
  const subtitle = context
    ? `Working from ${contextLabel(context)}`
    : 'Working from your PIG workspace';
  if (desktop) {
    return (
      <Sheet open={open} onOpenChange={onOpenChange}>
        <SheetContent side="right" className="flex h-dvh w-full flex-col p-0 sm:max-w-xl">
          {/* `band` rather than a hand-rolled top strip: the padding, the rule
              and the safe-area inset are the overlay primitive's job, and this
              file was one of four keeping its own copy of them. */}
          <SheetHeader band>
            <SheetTitle>{PIGGY_ASK_LABEL}</SheetTitle>
            <SheetDescription>{subtitle}</SheetDescription>
          </SheetHeader>
          <PiggyChatPanel
            conversation={conversation}
            controls={controls}
            context={context}
            seed={seed}
            autoFocusComposer
            className="min-h-0 flex-1"
          />
        </SheetContent>
      </Sheet>
    );
  }
  return (
    <Drawer open={open} onOpenChange={onOpenChange}>
      <DrawerContent className="h-[92dvh]">
        <DrawerHeader className="border-b border-border px-5 py-4 text-left">
          <DrawerTitle>{PIGGY_ASK_LABEL}</DrawerTitle>
          <DrawerDescription>{subtitle}</DrawerDescription>
        </DrawerHeader>
        {/* No autofocus on the phone: focusing the composer raises the keyboard
            over most of the drawer before the user has read anything. A seeded
            question fills the composer here too, but silently — see the seed
            effect in `PiggyChatPanel`. */}
        <PiggyChatPanel
          conversation={conversation}
          controls={controls}
          context={context}
          seed={seed}
          className="min-h-0 flex-1"
        />
      </DrawerContent>
    </Drawer>
  );
}

/** A question handed to the composer from outside. See `requestPiggyAsk`. */
export interface PiggySeed {
  /** Changes on every request, so the same question can be seeded twice. */
  id: number;
  text?: string;
}

/**
 * The transcript and composer. Width-agnostic on purpose — it is used at a
 * full page, in a 36rem sheet, in a phone drawer and in the 22rem dock.
 *
 * It measures itself rather than being told how much room it has. At 22rem the
 * ordinary spacing does not fail, it just crowds: the assistant avatar takes a
 * tenth of the line, a user bubble at 88% leaves no gutter to read the
 * alignment from, and the suggestion chips wrap to three lines each. That used
 * to be a `compact` prop, which only the dock ever passed — so the phone
 * drawer, the narrowest surface in the product, drew the roomy layout while a
 * 22rem column inside a 1440px window drew the tight one. The surface's own
 * width is the honest question, and it is the one asked here.
 */
export function PiggyChatPanel({
  context,
  initialPrompt = '',
  className,
  conversation,
  controls,
  emptyState,
  seed,
  autoFocusComposer = false,
}: {
  context?: PiggyChatContext;
  initialPrompt?: string;
  className?: string;
  /**
   * A conversation owned by something that outlives this panel. The overlays
   * pass one because they unmount their children on close; the dock and the
   * workspace page stay mounted and let the panel keep its own.
   */
  conversation?: PiggyConversationState;
  /**
   * The model and mode controls, bound to that conversation by whoever owns it.
   *
   * Passed in rather than built here because `usePiggyMode` and
   * `usePiggyModelChoice` each hold their own copy of the stored preference: a
   * second binding inside the panel would mean the workspace header and the
   * composer disagreeing about what the next turn may do, which is precisely
   * the disagreement the mode control exists to prevent. Omitted, the composer
   * simply shows no controls — the surface above it has them.
   */
  controls?: PiggyControlsState;
  /**
   * Replaces the default openers. The workspace passes its own so it can wire
   * the escalation to the thread that owns the mode.
   */
  emptyState?: ReactNode;
  /** A question from a page's Ask Piggy button, seeded into the composer. */
  seed?: PiggySeed;
  autoFocusComposer?: boolean;
}) {
  // Called unconditionally — hooks must be — and then ignored when a
  // conversation was handed in. It holds no resources until something is sent.
  const own = usePiggyConversation({ context, initialPrompt });
  const active = conversation ?? own;
  const { messages, draft, setDraft, running, send, stop, retry, approve } = active;
  const composerRef = useRef<HTMLTextAreaElement | null>(null);
  const rootRef = useRef<HTMLDivElement | null>(null);
  const compact = useIsNarrow(rootRef);
  // Never on a phone, however the composer was filled: focusing a textarea
  // raises the keyboard over the drawer the user has not read yet.
  const isMobile = useIsMobile();

  /*
   * A pending approval owns the conversation until it is answered.
   *
   * The chips offer a different question, and offering one under a card that
   * is asking whether to write to the book invites the user to walk away from
   * a decision that is still open.
   */
  const awaitingApproval = messages.some((message) =>
    message.approvals?.some((approval) => approval.state === 'pending' || approval.state === 'submitting'),
  );
  // One chip on a narrow surface. Nothing fits two on a line at 22rem, so the
  // second is a whole extra row of chrome taken off the shortest transcript in
  // the product.
  const followUps =
    messages.length && !awaitingApproval
      ? piggyFollowUps(context, userQuestions(messages)).slice(
          0,
          compact ? 1 : PIGGY_FOLLOW_UP_COUNT,
        )
      : [];

  const focusComposer = useCallback(() => {
    const composer = composerRef.current;
    if (!composer) return;
    // Radix moves focus to the first tabbable element in the sheet — its own
    // close button — from a layout effect that runs after this one, so a
    // synchronous focus here is immediately undone. A frame later it is not.
    // Without this, "Ask Piggy" opened with a prefilled prompt and put the
    // caret nowhere.
    const frame = requestAnimationFrame(() => {
      composer.focus();
      composer.setSelectionRange(composer.value.length, composer.value.length);
    });
    return () => cancelAnimationFrame(frame);
  }, []);

  useEffect(() => {
    if (!autoFocusComposer) return;
    return focusComposer();
  }, [autoFocusComposer, focusComposer]);

  /*
   * A write opener pressed while Piggy is in Read only.
   *
   * The mode has to be committed before the turn leaves, because `send` reads
   * it off the conversation — so the text is parked here and sent by the effect
   * below once the conversation is actually holding the new mode. Sending in
   * the same tick would ask Piggy to change something with the write tools
   * still withheld, and the answer would be a polite refusal.
   */
  const [escalating, setEscalating] = useState<string | null>(null);
  const conversationMode = active.mode;
  const askWithChange = useCallback(
    (text: string) => {
      if (!controls?.canWrite) return;
      if (conversationMode === 'read_only') {
        controls.setMode('confirm');
        setEscalating(text);
        return;
      }
      send(text);
    },
    [controls, conversationMode, send],
  );

  useEffect(() => {
    if (escalating === null) return;
    if (conversationMode === 'read_only') return;
    setEscalating(null);
    send(escalating);
  }, [escalating, conversationMode, send]);

  /*
   * The composer grows with what is in it, up to `COMPOSER_MAX_HEIGHT`.
   *
   * It never did, and a seeded question made that impossible to ignore: a page
   * button hands the composer a whole sentence, the caret lands at the end, and
   * a 44px box scrolled to the caret shows the user the middle of a question
   * they have not read yet — with the first line sliced through the middle of
   * its letters. Growing is also what the `max-h-36` already on the field was
   * plainly written for.
   */
  useLayoutEffect(() => {
    const composer = composerRef.current;
    if (!composer) return;
    const fit = () => {
      // Collapse first: `scrollHeight` on an already-tall box reports the
      // height it currently has, so without this the field can only ever grow.
      composer.style.height = 'auto';
      composer.style.height = `${Math.min(composer.scrollHeight, COMPOSER_MAX_HEIGHT)}px`;
    };
    fit();
    /*
     * Again next frame, and again on every resize.
     *
     * An inline height measured once at mount is a height that goes stale, and
     * measurably did: on a 393px-wide phone the first pass read 62px for an
     * empty single-row field that measures 42px a frame later, so the composer
     * settled 20px taller than its own content for the life of the mount — and
     * a phone rotated after mount kept the height it was given in the other
     * orientation. The listener costs nothing and the read is cheap; a stale
     * measurement on the control the whole company types into is not.
     */
    const frame = requestAnimationFrame(fit);
    window.addEventListener('resize', fit);
    return () => {
      cancelAnimationFrame(frame);
      window.removeEventListener('resize', fit);
    };
    // `compact` too: it flips when the surface's measured width crosses the
    // narrow threshold, which is the other way this field's line count changes
    // without a keystroke.
  }, [draft, compact]);

  const seedId = seed?.id;
  const seedText = seed?.text;
  useEffect(() => {
    if (seedId == null) return;
    // Replaces the draft rather than appending to it: the user pressed a
    // button asking for this exact question, and a half-typed line joined to a
    // canned one is a sentence neither of them wrote.
    if (seedText) setDraft(seedText);
    if (isMobile) return;
    return focusComposer();
  }, [seedId, seedText, setDraft, focusComposer, isMobile]);

  return (
    <div ref={rootRef} className={cn('flex min-h-0 flex-col', className)}>
      {/* The viewport owns the scrolling, the log role and the follow-the-tail
          behaviour. There is deliberately no scroll effect left in this file:
          the `scrollIntoView` it replaced fired once per streamed token, which
          made re-reading an earlier answer mid-stream impossible and dragged
          the page behind the dock down with it. Gutters go on the scrollport
          so they scroll with the transcript rather than fencing it. */}
      {messages.length === 0 ? (
        /*
         * The blank state is deliberately NOT inside the transcript viewport.
         * That viewport sticks to the bottom of its content, which is right for
         * an answer arriving and wrong for a page of openers: at 393x852 the
         * workspace's front door opened already scrolled past its own pig, its
         * headline and the first column heading. There is nothing to follow
         * here and nothing to announce, so it is a plain scrollport anchored at
         * the top, and the viewport below takes over the moment a turn exists.
         */
        <div
          className={cn(
            'flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain',
            // Measured: at 393x852 the workspace's front door overran its own
            // scrollport by 32px, which cut the last note in half — a sliced
            // line of type reads as a rendering fault rather than as something
            // to scroll to. Sixteen of those pixels are here.
            compact ? 'px-3 py-3' : 'px-4 py-5 sm:px-5',
          )}
        >
          {/* One front door. The dock used to draw its own openers — three
              read questions under a Sparkles glyph, with no mention that
              Piggy can write, which is the product's headline capability
              missing from the surface people keep open all day. */}
          {emptyState ?? (
            <PiggyWorkspaceStarters
              context={context}
              mode={controls?.mode ?? conversationMode}
              canWrite={controls?.canWrite ?? false}
              onAsk={send}
              onAskWithChange={askWithChange}
              narrow={compact}
            />
          )}
        </div>
      ) : (
        <PiggyConversation
          /*
           * `busy` is dropped the moment a proposal is parked, not when the
           * turn ends. A write tool holds its call open across the whole
           * approval, so `running` stays true for as long as the card is on
           * screen — and `aria-busy` on the live-region ROOT is precisely the
           * flag an assistive technology consults before deciding whether to
           * speak what arrived inside it. Left set, the one announcement the
           * product's entire safety argument rests on ("Piggy wants to change
           * this; nothing has happened yet") was the announcement being
           * suppressed. The stream is still running; it is running *waiting for
           * this person*, which is not the state the attribute means.
           */
          busy={running && !awaitingApproval}
          className={cn('py-5', compact ? 'px-3' : 'px-4 sm:px-5')}
        >
          <div
            // The column is capped at a reading measure rather than filling the
            // page: at 1440 the workspace panel is over a thousand pixels wide,
            // and a markdown answer set across all of it is a wall.
            // The busy state that holds the announcement back belongs on the
            // live region root, which is the viewport above, not on this column.
            className={cn('mx-auto flex w-full max-w-3xl flex-col', compact ? 'gap-5' : 'gap-6')}
          >
            {messages.map((message) => (
              <ChatMessage
                key={message.id}
                message={message}
                compact={compact}
                onApprove={approve}
                onRetry={isRetryable(message) && !running ? () => retry(message.id) : undefined}
              />
            ))}
          </div>
          <PiggyConversationScrollButton />
        </PiggyConversation>
      )}

      {/*
        The composer's short-viewport diet.

        Measured on a stored thread: at 852x393 this form was 173px of a 393px
        screen and at 393x390 it was 171px of 390 — 44% of the viewport, on the
        page whose point is the transcript above it. Nothing in it was wrong;
        there was simply no gate on a screen with no room. So under
        `(max-height: 500px)` the two pieces that are conveniences give way —
        the follow-up suggestions and the standing safety sentence — and the
        padding halves, leaving the textarea, the Send button and the counter,
        which are the parts a person on a landscape phone actually came for.
        The sentence is a standing reminder, not a warning about this turn; the
        approval card states the stakes at the moment they exist.
      */}
      <form className={cn('shrink-0 border-t border-border bg-surface', compact ? 'p-3' : 'p-3 sm:p-4', '[@media(max-height:500px)]:p-2')} onSubmit={(event) => { event.preventDefault(); send(); }}>
        {/* The same measure the transcript is set to. Without it the composer
            ran the full width of the workspace pane while every answer above it
            stopped at 48rem, so the box you type into and the column you read
            back were visibly different documents. */}
        <div className="mx-auto flex w-full max-w-3xl flex-col">
          {followUps.length ? (
            // Wrapped, not scrolled sideways. A row of whole questions is wider
            // than every surface but the full page, and a chip sliced off by the
            // panel edge reads as a rendering fault — where a second line reads
            // as a second suggestion.
            <section className="mb-2 flex flex-wrap gap-1.5 [@media(max-height:500px)]:hidden" aria-label="Suggested questions">
              {followUps.map((suggestion) => (
                <button
                  key={suggestion}
                  type="button"
                  // Dead rather than absent while a turn runs: `send` refuses
                  // anything mid-stream, and a row that vanishes and returns
                  // moves the composer under the user's thumb.
                  disabled={running}
                  // Wraps rather than truncating, and 12px-radius rather than a
                  // pill, because a chip cut off mid-word — "How much of this
                  // block is still un…" — is a question nobody can decide
                  // whether they want asked. A second line costs less than a
                  // suggestion nobody presses.
                  title={suggestion}
                  className={cn(
                    'flex min-h-11 max-w-full items-center rounded-lg border border-border px-3 py-2',
                    'text-left text-xs leading-4 text-muted transition-colors duration-1 ease-enter',
                    'hover:bg-surface-2 hover:text-fg disabled:opacity-50',
                  )}
                  onClick={() => send(suggestion)}
                >
                  {suggestion}
                </button>
              ))}
            </section>
          ) : null}
          {/* Above the textarea, not below it: these decide what the next turn may
              do, and they are read at the moment the send button is looked at.
              Disabled while a turn runs, because that turn's settings are already
              fixed — changing them mid-answer would suggest otherwise. */}
          {controls ? (
            <PiggyControls controls={controls} compact={compact} disabled={running} className="mb-2">
              {context ? (
                // `basis-full` so the badge takes a row of its own rather than
                // sitting beside the controls and pushing the row wider than the
                // dock: an inline-flex badge sizes to its content, and a page
                // context's label is a whole sentence of it.
                <Badge className="flex min-w-0 basis-full">
                  <Database aria-hidden className="shrink-0" />
                  <span className="truncate">{contextLabel(context)}</span>
                </Badge>
              ) : null}
            </PiggyControls>
          ) : context ? (
            <Badge className="mb-2 max-w-full truncate"><Database aria-hidden /> {contextLabel(context)}</Badge>
          ) : null}
          <div className="flex items-end gap-2">
            <Textarea
              ref={composerRef}
              value={draft}
              onChange={(event) => setDraft(event.target.value)}
              onKeyDown={(event) => {
                if (event.key === 'Enter' && !event.shiftKey) {
                  event.preventDefault();
                  send();
                }
              }}
              maxLength={PIGGY_MESSAGE_MAX_LENGTH}
              /*
               * One row at rest, not the element default of two.
               *
               * The effect above sizes the field to its own `scrollHeight`, and
               * `scrollHeight` counts `rows` — so an empty composer was 64px, a
               * blank second line the user has not typed, beside a 44px Send
               * button it therefore never lined up with. On a 390px keyboard-up
               * screen those 20px are 5% of the viewport taken from the
               * transcript, which is what made this worth chasing; on every
               * other surface it is simply the composer finally matching the
               * 44px floor it already declares. It still grows to `max-h-36`
               * from the first keystroke, so nothing about typing changes.
               */
              rows={1}
              // `overflow-y-auto` so the field scrolls once it has grown to its
              // cap rather than hiding the rest of a long paste.
              className="min-h-11 max-h-36 resize-none overflow-y-auto"
              placeholder={piggyLine(piggyCopy.composerPlaceholder, compact)}
              aria-label="Message Piggy"
            />
            {running ? (
              <Button
                type="button"
                size="icon"
                variant="outline"
                aria-label="Stop Piggy"
                // The Stop button is replaced by Send the instant the turn
                // ends, so focus was landing on `<body>` — on the workspace,
                // 121 Tab presses from the composer the user was about to type
                // the next question into.
                onClick={() => {
                  stop();
                  focusComposer();
                }}
              >
                <CircleStop aria-hidden />
              </Button>
            ) : (
              <Button type="submit" size="icon" variant="primary" disabled={!draft.trim()} aria-label="Send message"><Send aria-hidden /></Button>
            )}
          </div>
          <div className="mt-2 flex items-baseline gap-2 text-xs leading-4 text-muted [@media(max-height:500px)]:mt-0">
            {/* No longer "Read-only session": Piggy writes now, and what it may
                do this turn is stated by the mode control above rather than by a
                line of copy that would have to be kept in step with it. What is
                left is the part that is true in every mode. */}
            <p className="flex-1 text-center [@media(max-height:500px)]:hidden">{compact ? 'Check the records behind an answer.' : 'Check the source records before acting on material terms.'}</p>
            {/* No live region: this changes on every keystroke, and the cap is
                already announced from the textarea's own `maxLength`.
                `ml-auto` keeps it right-aligned on a short viewport, where the
                sentence that was pushing it there is gone. */}
            {draft.length >= COUNTER_VISIBLE_FROM ? (
              <p className={cn('ml-auto shrink-0 tabular-nums', draft.length >= PIGGY_MESSAGE_MAX_LENGTH && 'text-danger')}>
                {draft.length}/{PIGGY_MESSAGE_MAX_LENGTH}
              </p>
            ) : null}
          </div>
        </div>
      </form>
    </div>
  );
}

/**
 * How wide the panel actually is, as a boolean.
 *
 * A media query cannot answer this: the dock is 22rem inside a 1440px window,
 * and asking the window would give the roomy layout to a column that has no
 * room. Measured in a layout effect so the first paint is already correct —
 * a panel that renders roomy and reflows to tight one frame later is a panel
 * that visibly twitches every time an overlay opens.
 */
function useIsNarrow(ref: React.RefObject<HTMLElement | null>): boolean {
  const [narrow, setNarrow] = useState(false);
  useLayoutEffect(() => {
    const element = ref.current;
    if (!element) return;
    const measure = () => setNarrow(element.getBoundingClientRect().width < COMPACT_WIDTH);
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(element);
    return () => observer.disconnect();
  }, [ref]);
  return narrow;
}

/** What the user has already asked, so a follow-up chip cannot offer back a
 *  question that is sitting in the transcript above it. */
function userQuestions(messages: TranscriptMessage[]): string[] {
  return messages.filter((message) => message.role === 'user').map((message) => message.content);
}

/** A page context has no id and its `type` is the literal 'page', which reads
 *  as nothing useful in a badge — show the route the dock is following. */
function contextLabel(context: PiggyChatContext): string {
  if (context.label) return context.label;
  if (context.type === 'page') return context.route === '/' ? 'Overview' : context.route.slice(1);
  return context.type.replaceAll('_', ' ');
}

function ChatMessage({
  message,
  compact = false,
  onApprove,
  onRetry,
}: {
  message: TranscriptMessage;
  compact?: boolean;
  /** Answer a proposed write. Absent only where no conversation is driving. */
  onApprove?: (changeId: string, decision: PiggyApprovalDecision) => void;
  onRetry?: () => void;
}) {
  if (message.role === 'user') {
    return (
      <div className={cn('ml-auto flex flex-col items-end', compact ? 'max-w-[94%]' : 'max-w-[88%]')}>
        <div className={cn('rounded-2xl rounded-br-md bg-primary py-3 text-sm text-accent-on', compact ? 'px-3' : 'px-4')}>
          <p className="whitespace-pre-wrap">{message.content}</p>
        </div>
        {/* The question is still on screen after a failed send, so the user's
            words are never lost — but the bubble alone reads as sent. */}
        {message.failed ? <p className="mt-1 text-xs leading-4 text-muted">Not sent</p> : null}
      </div>
    );
  }
  return (
    <div className={cn('flex', compact ? 'gap-2' : 'gap-3')}>
      {/* The signature on every answer the company will read this year, and
          until now it was a stock robot glyph — the one place Piggy's identity
          is seen most, wearing somebody else's face. Round, because this is an
          avatar and avatars are round in this product. */}
      <div
        className={cn(
          'flex shrink-0 items-center justify-center rounded-full bg-accent-subtle text-accent-fg',
          compact ? 'size-7' : 'size-9',
        )}
      >
        <PiggyMark className={compact ? 'size-4' : 'size-5'} />
      </div>
      {/* `group/actions` is the name `PiggyMessageActions` reveals its buttons
          on, and it is repeated here on purpose: the footer marks itself, so
          without this the only way to find Copy is to sweep the pointer across
          the blank strip the hidden buttons occupy. A named group matches on
          any hovered ancestor, so hovering the answer reveals them. */}
      <div className="group/actions min-w-0 flex-1">
        {/* Working, then evidence, then the answer, then what the answer cost.
            Everything above the answer is deliberately smaller and quieter than
            it: this is a chain of custody, and the reader came for the last
            link in it. `PiggyReasoning` draws nothing when there is nothing to
            show, which is every turn while PIGGY_REASONING_EFFORT is 'none'. */}
        <PiggyReasoning text={message.reasoning ?? ''} streaming={isThinking(message)} />
        {message.tools?.length ? (
          // `aria-live="off"`: these rows change several times a second while a
          // turn runs, and they sit inside the transcript's own live region, so
          // without it a screen reader reads out every tool starting and
          // finishing before the answer the user asked for arrives.
          <section className="mb-3 flex flex-col gap-1.5" aria-label="Piggy tool activity" aria-live="off">
            {message.tools.map((tool) => (
              <PiggyToolStep key={tool.id} step={tool} />
            ))}
          </section>
        ) : null}
        {message.content ? <PiggyResponse content={message.content} /> : null}
        {/* Below the answer, because the answer is where Piggy says what it
            intends to do and the card is the thing that lets it. A card above
            the sentence explaining it would ask for a decision before giving
            the reason for it. */}
        {message.approvals?.length ? (
          <div className={cn('flex flex-col gap-2', message.content && 'mt-3')}>
            {message.approvals.map((approval) => (
              <PiggyApprovalCard
                key={approval.change.id}
                change={approval.change}
                state={approval.state}
                error={approval.error}
                onDecide={(decision) => onApprove?.(approval.change.id, decision)}
              />
            ))}
          </div>
        ) : null}
        {/* Only while the turn has produced nothing at all. Once a tool chip or
            the reasoning panel is on screen, the turn is visibly working and a
            second spinner saying so is noise. */}
        {message.pending && !message.content && !message.tools?.length && !message.reasoning?.trim() ? (
          <div className="flex min-h-11 items-center gap-2 text-sm text-muted"><Loader2 className="animate-spin" aria-hidden /> Piggy is checking PIG…</div>
        ) : null}
        {/* The state chip in the footer below names a stopped or truncated
            turn. An error is different: it carries a sentence the chip cannot,
            and it is the one thing here allowed to be loud. */}
        {message.error ? <div className="mt-3 flex items-start gap-2 rounded-lg bg-danger/10 p-3 text-sm text-danger"><XCircle className="shrink-0" aria-hidden /> {message.error}</div> : null}
        <PiggyMessageActions message={message} onRetry={onRetry} />
      </div>
    </div>
  );
}

/**
 * Whether the reasoning panel should read as live.
 *
 * The stream has no event for "thinking finished", but the model writes its
 * scratch work before its answer — so the first content token is the end of the
 * thinking, and waiting for the turn to settle instead would leave the panel
 * open, uncollapsed and unmeasured underneath the answer it preceded.
 */
function isThinking(message: TranscriptMessage): boolean {
  return Boolean(message.pending && message.reasoning?.trim() && !message.content);
}

/**
 * The availability gate. Exported because anything that renders a chat surface
 * — the workspace page, the ask button, the dock — has to check it first:
 * `/api/piggy/chat` answers 503 when the runtime is off, and a panel that
 * renders without asking shows a composer that cannot send.
 */
export function usePiggyStatus() {
  return useQuery({ queryKey: ['piggy', 'status'], queryFn: () => get<PiggyStatus>('/api/piggy/status'), staleTime: 60_000, retry: false });
}
