/**
 * The moment a person decides whether an agent may change the company's records.
 *
 * Everything else in the Piggy workspace is reversible or read-only; this card
 * is not. So it is built around three refusals:
 *
 *   it never claims more than it knows — only an `approval_resolved` event moves
 *     a card to `applied`, so `submitting` is drawn as its own state rather than
 *     as an optimistic tick that would have to be taken back;
 *   it never invites a press by accident — nothing here is autofocused, the
 *     actions sit below the evidence rather than under the reader's thumb, and a
 *     held Enter cannot fire Apply twice;
 *   it never shows a change without its context — where a field has a
 *     `previous`, both values are on screen, because "Move Aurelian to legal"
 *     means nothing to someone who cannot see where Aurelian was.
 *
 * The five states come from `ApprovalStep` in lib/piggy-chat, which owns the
 * transitions. This file renders them and reports a decision; it decides nothing
 * about the change itself.
 *
 * Colour follows the product's rule rather than this card's own instincts: the
 * only state drawn in a status colour is the one that has stopped and is waiting
 * for a person. A settled card — applied, rejected — returns to the ordinary
 * border, and the confirmation keeps exactly one positive mark. A transcript in
 * which every approved write is a green block is a transcript where the one card
 * that still needs answering is invisible.
 */
import { useEffect, useId, useRef, useState, type KeyboardEvent } from 'react';
import { ArrowRight, CheckCircle2, Loader2, ShieldAlert, TriangleAlert, XCircle } from 'lucide-react';
import type { PiggyApprovalDecision, PiggyProposedChange } from '@pig/core';
import { piggyToolLabel } from '@/lib/piggy-tool-labels';
import { ApprovalStateBadge } from '@/components/status';
import { RecordLink, recordHref } from '@/components/RecordLink';
import { Button, Card, Label, cn } from '@/components/ui';
import { Disclosure } from '@/components/ui/disclosure';

export type PiggyApprovalState = 'pending' | 'submitting' | 'applied' | 'rejected' | 'failed';

// --------------------------------------------------------------------- card

export function PiggyApprovalCard({
  change,
  state,
  error,
  onDecide,
}: {
  change: PiggyProposedChange;
  state: PiggyApprovalState;
  error?: string;
  onDecide: (decision: PiggyApprovalDecision) => void;
}) {
  const headingId = useId();

  /**
   * Which answer is in flight.
   *
   * The contract hands this card a state, not a decision, so `submitting` alone
   * cannot say whether the user pressed Apply or Reject — and "Sending your
   * decision" is a poor thing to read when you have just authorised a write to a
   * customer record. Holding it locally also guards the double press: the parent
   * moves to `submitting` on the same tick, but a second click dispatched before
   * React re-renders would post the decision twice, and applying a change twice
   * logs two activities on someone's account.
   */
  const [choice, setChoice] = useState<PiggyApprovalDecision | null>(null);

  // Cleared whenever the card is answerable again — a POST that never reached
  // the relay puts the state back to `pending`, and a stale "Applying" label on
  // a card that is waiting for a decision would be a lie about a write.
  useEffect(() => {
    if (state === 'pending' || state === 'failed') setChoice(null);
  }, [state]);

  /**
   * Whether a decision has already been dispatched from this render.
   *
   * The buttons are disabled the moment the parent moves the card to
   * `submitting`, which it does synchronously inside `onDecide` — but that is
   * one render away, and two clicks (or a click and a synthesised one) in the
   * same tick would both get through and post the decision twice. Applying twice
   * logs two calls on someone's account. Reset after every commit rather than
   * only on a state change, so a parent that answers with an error instead of a
   * new state leaves the buttons usable rather than dead.
   */
  const dispatched = useRef(false);
  useEffect(() => {
    dispatched.current = false;
  });

  /**
   * Where the keyboard goes when the buttons stop existing.
   *
   * Applying removes the pair the user just pressed, and focus falls to
   * `<body>` — 121 Tab presses from the transcript on /piggy, and the outcome
   * of the write announced to nobody in particular. Focus moves here instead,
   * onto the live region that says what happened, so the answer to "did it
   * land?" is both spoken and one Tab from the record link that proves it.
   */
  const statusRef = useRef<HTMLDivElement>(null);
  const decided = useRef(false);
  useEffect(() => {
    if (!decided.current || state === 'pending') return;
    decided.current = false;
    statusRef.current?.focus();
  }, [state]);

  const answerable = state === 'pending' || state === 'failed';

  const decide = (decision: PiggyApprovalDecision) => {
    if (!answerable || dispatched.current) return;
    dispatched.current = true;
    decided.current = true;
    setChoice(decision);
    onDecide(decision);
  };

  /**
   * Auto-repeat must not decide anything.
   *
   * Holding Enter on a focused button fires a click per repeat, and this is the
   * one control in PIG where the second one is a duplicate write rather than a
   * duplicate render. The first press still works; only the repeats are dropped.
   */
  const swallowRepeat = (event: KeyboardEvent<HTMLButtonElement>) => {
    if (event.repeat) event.preventDefault();
  };

  const record = change.record;
  const linkable = Boolean(record && recordHref(record.type, record.id));
  // The note explains why a card appeared at all, so it retires once the change
  // is settled and the question is no longer live.
  const showForcedNote = Boolean(change.forcedConfirm) && state !== 'applied' && state !== 'rejected';
  const showActions = state === 'pending' || state === 'submitting' || state === 'failed';
  // An applied card carries its record inside the confirmation sentence, so the
  // footer link would be the same destination twice in two consecutive rows.
  const showFooterLink = linkable && state !== 'applied';
  const hasFooterRow = showActions || showFooterLink;

  return (
    <Card
      // A group rather than a region: a turn can propose several writes, and a
      // transcript full of landmarks makes the landmark list useless.
      role="group"
      aria-labelledby={headingId}
      className={cn(
        'w-full overflow-hidden',
        // Warning is spent on the one state that has stopped and is waiting for
        // a person; `submitting` keeps it because the question is still open
        // until the relay answers, and a border that changes twice in a second
        // reads as a flicker rather than as progress.
        state === 'pending' || state === 'submitting'
          ? 'border-warning/50'
          : state === 'rejected'
            ? 'border-border bg-surface-2/40'
            : 'border-border',
      )}
    >
      <div className="flex items-start gap-2 p-4 sm:p-5">
        <StateIcon state={state} />
        <div className="min-w-0 flex-1">
          <Label>{piggyToolLabel(change.tool)}</Label>
          {/* The summary is the headline: everything below it is evidence for
              this one sentence, so it is the only thing set at full weight. */}
          <h4 id={headingId} className="mt-1 break-words text-sm font-semibold leading-snug">
            {change.summary}
          </h4>
        </div>
        <ApprovalStateBadge state={state} decision={choice} />
      </div>

      {showForcedNote ? <ForcedConfirmNote kind={change.kind} /> : null}

      {change.fields.length === 0 ? null : state === 'rejected' ? (
        /*
          A rejected change is history, and history the user has already
          declined. Folding the evidence away keeps a long transcript readable
          while leaving it recoverable — deleting it outright would remove the
          only record of what was declined, which is exactly what an audit asks
          for.
        */
        <Disclosure
          summary="What was proposed"
          className="border-t border-border"
          summaryClassName="px-4 text-xs font-normal text-muted sm:px-5"
        >
          <FieldList fields={change.fields} settled />
        </Disclosure>
      ) : (
        <div className="border-t border-border">
          <FieldList fields={change.fields} settled={false} />
        </div>
      )}

      <div className="flex flex-col border-t border-border p-4 sm:p-5">
        {/*
          One live region, mounted for the life of the card and never empty: a
          status element that appears at the same moment as its text is
          announced unreliably, and this is exactly the transition — pending to
          applied — that a screen-reader user must not miss. The spacing hangs
          off the child rather than off a `gap` so that a settled card, whose
          footer holds nothing else, does not end in a band of dead space.

          `tabIndex={-1}` is the target of the focus move above; it is never in
          the tab order.
        */}
        <div
          ref={statusRef}
          tabIndex={-1}
          role="status"
          aria-live="polite"
          className={cn('min-w-0 outline-none', hasFooterRow && '[&>*]:mb-3')}
        >
          {/*
            An error is the status. Left to the generic line as well, a failed
            card stated the same fact three times — the badge, "The change was
            not applied", and the reason — and a card that repeats itself reads
            as a card that is guessing.
          */}
          {error ? (
            <p className="flex items-start gap-2 rounded-md bg-danger/10 px-2.5 py-2 text-xs leading-5 text-danger">
              <TriangleAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden />
              <span className="min-w-0 break-words">{error}</span>
            </p>
          ) : (
            <StatusLine state={state} choice={choice} record={record} />
          )}
        </div>

        {/*
          The record and the decision share a row: the link is the one thing a
          reader might want *before* answering — open the account, check the
          note is not already there — and putting it beside the buttons keeps
          the footer to a single line on a phone. It wraps above them when the
          dock is too narrow for both.
        */}
        {hasFooterRow ? (
          <div className="flex flex-wrap items-center justify-end gap-2">
            {showFooterLink && record ? (
              <RecordLink
                type={record.type}
                id={record.id}
                label={record.label ?? record.id}
                verb={answerable || state === 'submitting' ? 'Check' : 'Open'}
                // A new tab, always, on this card. The escape hatch was
                // destroying the proposal it exists to help verify: a same-tab
                // navigation unmounts the transcript and takes the pending card
                // with it, so the reader came back to no question at all.
                newTab
                // `mr-auto` rather than `justify-between` on the row: when the
                // pair of buttons wraps to its own line in a narrow dock, the
                // row must still hold them at the right edge, and `between`
                // would strand a lone wrapped item at the left.
                className="mr-auto"
              />
            ) : null}

            {showActions ? (
              /*
                The decision sits last, after the evidence, and never under the
                reader's eye while they are still reading the diff. Reject comes
                first so the hand travelling rightwards ends on the deliberate
                action rather than passing over it, and Apply carries the only
                filled treatment on the card. Nothing is autofocused: the card
                arrives mid-stream, and a button that grabs focus while someone is
                typing turns their next Enter into a write.

                Full width below `sm` so the two buttons split a phone row evenly,
                content width above it so they sit as a pair at the right of the
                footer — and so that in a 22rem dock the pair wraps to its own line
                intact rather than stacking one button above the other.
              */
              <div className="flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto">
                <Button
                  type="button"
                  variant="outline"
                  size="sm"
                  disabled={!answerable}
                  onKeyDown={swallowRepeat}
                  onClick={() => decide('reject')}
                  className="min-w-[6rem] flex-1 sm:flex-none"
                >
                  {state === 'submitting' && choice === 'reject' ? (
                    <Loader2 className="size-4 animate-spin" aria-hidden />
                  ) : null}
                  Reject
                </Button>
                <Button
                  type="button"
                  variant="primary"
                  size="sm"
                  disabled={!answerable}
                  onKeyDown={swallowRepeat}
                  onClick={() => decide('apply')}
                  className="min-w-[6rem] flex-1 sm:flex-none"
                >
                  {state === 'submitting' && choice === 'apply' ? (
                    <Loader2 className="size-4 animate-spin" aria-hidden />
                  ) : null}
                  {state === 'failed' ? 'Try again' : 'Apply'}
                </Button>
              </div>
            ) : null}
          </div>
        ) : null}
      </div>
    </Card>
  );
}

// ------------------------------------------------------------------- pieces

/**
 * The card's mark, coloured only while the card is waiting.
 *
 * A settled outcome is a process fact, and process facts get no colour here —
 * the single positive mark this card is allowed to spend belongs on the
 * confirmation sentence, next to the record the change landed on.
 */
function StateIcon({ state }: { state: PiggyApprovalState }) {
  const className = 'mt-0.5 size-4 shrink-0';
  if (state === 'applied') {
    return <CheckCircle2 className={cn(className, 'text-muted')} aria-hidden />;
  }
  if (state === 'rejected') return <XCircle className={cn(className, 'text-muted')} aria-hidden />;
  if (state === 'failed') {
    return <TriangleAlert className={cn(className, 'text-danger')} aria-hidden />;
  }
  return <ShieldAlert className={cn(className, 'text-warning')} aria-hidden />;
}

/**
 * Why a card appeared in a mode that promised not to ask.
 *
 * Without this the user reads a stopped write as a broken mode and turns the
 * guardrail off. The four guarded kinds are the ones that move money or make a
 * promise to a counterparty, so the note names the kind rather than reciting the
 * policy.
 */
function ForcedConfirmNote({ kind }: { kind: string }) {
  return (
    // The bottom margin matches the header's own padding above it: without it
    // the note sat flush against the divider under it and read as part of the
    // diff rather than as a note about why the card exists.
    <p className="mx-4 mb-4 flex items-start gap-2 rounded-md bg-warning/10 px-2.5 py-2 text-xs leading-5 text-warning sm:mx-5 sm:mb-5">
      <TriangleAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden />
      <span className="min-w-0 break-words">
        Auto mode stopped here on purpose. A {kindNoun(kind)} change always needs a person, whatever
        the mode is set to.
      </span>
    </p>
  );
}

function kindNoun(kind: string): string {
  return kind.replaceAll('_', ' ').trim() || 'guarded';
}

function StatusLine({
  state,
  choice,
  record,
}: {
  state: PiggyApprovalState;
  choice: PiggyApprovalDecision | null;
  record?: PiggyProposedChange['record'];
}) {
  // Pending says the thing the reader most needs to be sure of, and says it in
  // the live region so that the card's arrival is heard rather than merely
  // drawn. The badge says a person is needed; this says what has happened so
  // far, which is nothing.
  if (state === 'pending') {
    return <p className="text-xs text-muted">Nothing has changed yet. Piggy is waiting for your answer.</p>;
  }

  if (state === 'submitting') {
    return (
      <p className="flex items-center gap-2 text-xs text-muted">
        <Loader2 className="size-3.5 animate-spin" aria-hidden />
        {choice === 'reject' ? 'Rejecting the change…' : 'Applying the change to PIG…'}
      </p>
    );
  }

  if (state === 'applied') {
    // The one positive mark on the card, spent here rather than on the border
    // or the badge, and spent beside the record so the confirmation and the
    // proof of it are the same sentence.
    return (
      <p className="flex flex-wrap items-center gap-x-1.5 text-xs text-fg">
        <CheckCircle2 className="size-3.5 shrink-0 text-positive" aria-hidden />
        {/* The trailing space is for the announcement, not the layout: the flex
            gap separates the words on screen, and without it a screen reader
            reads "Applied to PIG onDEMO — Northwind Robotics". */}
        <span>Applied to PIG{record?.label ? ' on ' : '.'}</span>
        {record ? (
          <RecordLink type={record.type} id={record.id} label={record.label ?? record.id} newTab />
        ) : null}
      </p>
    );
  }

  if (state === 'rejected') {
    return <p className="text-xs text-muted">Rejected. Nothing was changed.</p>;
  }

  // `failed` covers both a write PIG refused and a turn that ended before the
  // decision could be delivered. Reached only when no reason came with it — the
  // reason replaces this line when there is one.
  return <p className="text-xs text-danger">The change was not applied.</p>;
}

function FieldList({
  fields,
  settled,
}: {
  fields: PiggyProposedChange['fields'];
  settled: boolean;
}) {
  return (
    <dl className="flex flex-col gap-2 p-4 sm:p-5">
      {fields.map((field, index) => (
        // Keyed by position as well as label: nothing stops a tool proposing two
        // rows with the same label, and a duplicate key drops one of them.
        <FieldRow key={`${index}:${field.label}`} field={field} settled={settled} />
      ))}
    </dl>
  );
}

/**
 * One field, with its old value where there is one.
 *
 * A diff without the before is not a diff, and this is the moment where the old
 * value matters most: "Stage: Legal" is agreeable to anybody, "Stage: Discovery
 * → Legal" is the thing you either recognise or stop. `del`/`ins` carry the
 * before and after semantically, with the words spelled out for readers whose
 * software announces neither.
 */
function FieldRow({
  field,
  settled,
}: {
  field: PiggyProposedChange['fields'][number];
  settled: boolean;
}) {
  return (
    <div className="min-w-0">
      <Label as="dt">{field.label}</Label>
      <dd className="mt-1 min-w-0">
        {field.previous === undefined ? (
          <span
            className={cn('block break-words text-sm leading-5', settled ? 'text-muted' : 'text-fg')}
          >
            {field.value}
          </span>
        ) : (
          // Wraps rather than truncates: a stage name is short, a reason is a
          // sentence, and the 22rem dock has to hold both without a scrollbar.
          <span className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
            <del className="min-w-0 break-words text-sm leading-5 text-muted decoration-muted/70">
              <span className="sr-only">Was: </span>
              {field.previous}
            </del>
            <ArrowRight className="size-3.5 shrink-0 self-center text-muted" aria-hidden />
            <ins
              className={cn(
                'min-w-0 break-words text-sm font-medium leading-5 no-underline',
                settled ? 'text-muted' : 'text-fg',
              )}
            >
              <span className="sr-only">Becomes: </span>
              {field.value}
            </ins>
          </span>
        )}
      </dd>
    </div>
  );
}
