/**
 * The footer under a finished Piggy turn: what you can do with the answer, and
 * what the answer cost.
 *
 * The run line is not telemetry for its own sake. PIG's whole argument is that
 * an agent-native CRM can run on Prime Intellect's inference and their model,
 * and until now the transcript gave no sign of either — the one fact the
 * product most needs to state was the one fact it kept to itself. It is
 * therefore always present, and always quiet: a caption, never a banner.
 */
import { useEffect, useRef, useState } from 'react';
import { Check, Copy, RotateCcw } from 'lucide-react';
import { toast } from 'sonner';
import type { TranscriptMessage } from '@/lib/piggy-chat';
import { piggyModeSummary } from '@/components/piggy/mode-control';
import { usePiggyModelLabel } from '@/components/piggy/model-picker';
import { Badge, Button, cn } from '@/components/ui';

/** How long the copy button admits it worked before returning to its label. */
const COPIED_RESET_MS = 2_000;

export function PiggyMessageActions({
  message,
  onRetry,
}: {
  message: TranscriptMessage;
  onRetry?: () => void;
}) {
  const [copied, setCopied] = useState(false);
  const resetRef = useRef<number | undefined>(undefined);

  // The transcript is a long-lived list and a turn can be dropped from it while
  // the confirmation is still counting down — `retry` removes the exchange it
  // replaces — so the timer has to die with the component.
  useEffect(() => () => window.clearTimeout(resetRef.current), []);

  const state = stateLabel(message);
  const usage = formatUsage(message);
  const modelLabel = usePiggyModelLabel(message.model);
  /*
   * What this turn was allowed to do, recorded on the turn itself.
   *
   * PIG's safety argument is that nothing lands until a person presses Apply,
   * and until now the transcript held no record of which permission each turn
   * ran under — so a header reading "Read only" could sit above two write
   * proposals made ten minutes earlier and nothing in the thread contradicted
   * it. The mode is a fact about a turn, not about the control, so it belongs
   * beside the model that answered.
   */
  const mode = message.mode ? piggyModeSummary(message.mode) : null;
  const ModeIcon = mode?.icon;
  // Only Piggy's words are worth a copy button. A user turn reaches this
  // footer too — a question the relay refused carries the `failed` chip — and
  // offering to copy back what they typed a second ago is noise.
  const copyable = message.role === 'assistant' && Boolean(message.content.trim());
  // Retry is offered for anything the caller passed a handler for; deciding
  // *which* turns deserve one is the transcript's job, not this footer's.
  const retryable = Boolean(onRetry) && Boolean(message.error || message.stopped || message.truncated);

  // Nothing to press and nothing to report is a row of whitespace under every
  // message. There is nothing to say, so say nothing.
  if (message.pending) return null;
  if (!copyable && !retryable && !state && !usage && !message.model && !mode) return null;

  const handleCopy = async () => {
    // `navigator.clipboard` is absent outside a secure context, which is not a
    // hypothetical here: PIG is routinely opened from a phone on the LAN over
    // plain http, and reading `.writeText` off undefined would throw before any
    // toast could explain itself.
    if (!navigator.clipboard) {
      toast.error('Copying needs a secure connection. Select the text instead.');
      return;
    }
    try {
      await navigator.clipboard.writeText(message.content);
    } catch {
      // Denied permission, or a document that was not focused when the write
      // landed. Either way the clipboard still holds whatever it held before,
      // so the user must be told rather than left to paste stale text.
      toast.error('The browser refused clipboard access.');
      return;
    }
    setCopied(true);
    toast.success('Answer copied');
    window.clearTimeout(resetRef.current);
    resetRef.current = window.setTimeout(() => setCopied(false), COPIED_RESET_MS);
  };

  return (
    <div className="group/actions mt-1.5 flex flex-col gap-0.5">
      {/* The run line keeps its own row rather than sharing one with the
          buttons, and comes first so that it stays against the answer it
          describes: at 22rem the buttons' reserved width truncated the model
          name away entirely, which defeats the point of showing it. It wraps
          rather than truncating now, because the mode is on it and "was this
          turn allowed to write?" is not a fact a narrow column may drop. */}
      {state || mode || modelLabel || usage ? (
        <p className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 text-xs leading-4 text-muted">
          {state ? <Badge className="shrink-0 px-2 font-normal">{state}</Badge> : null}
          {mode && ModeIcon ? (
            // Neutral, not coloured: a mode is a setting, not an outcome, and a
            // footer where every turn is amber is a footer nobody reads. Auto
            // takes the same warning tint the mode control gives it, on the
            // glyph only — it is the one mode that can write unattended.
            <Badge className="shrink-0 px-2 font-normal" title={`Piggy ran this turn in ${mode.label}`}>
              <ModeIcon aria-hidden className={cn('size-3', message.mode === 'auto' && 'text-warning')} />
              {mode.label}
            </Badge>
          ) : null}
          {modelLabel ? (
            // Sans, and the catalogue's own name for the model rather than the
            // wire id: `nvidia/nemotron-3-super-120b` set in monospace under a
            // sales answer was the product talking to itself. The full id stays
            // in `title`, so nothing is lost — it is just no longer shouted.
            // `truncate` only shrinks a flex child that is allowed to: without
            // `min-w-0` the name sets the row's minimum width and pushes the
            // counts off the side of the dock.
            <span className="min-w-0 truncate" title={message.model}>
              {modelLabel}
            </span>
          ) : null}
          {modelLabel && usage ? <span aria-hidden>·</span> : null}
          {usage ? (
            <span className="shrink-0 tabular-nums" title={exactUsage(message)}>
              {usage}
            </span>
          ) : null}
        </p>
      ) : null}

      {/*
       * Quiet where there is a pointer, permanent where there is not.
       * `@media (hover: hover)` is the only honest test for "can this user
       * reveal something by hovering"; a touch device never can, so hiding
       * these behind hover there would hide them for good. Opacity rather than
       * `hidden`, because the transcript is a scroll container and a row that
       * only claims its space once hovered would shove the message out from
       * under the pointer as it arrived.
       */}
      {copyable || retryable ? (
        <div
          className={cn(
            'flex flex-wrap items-center gap-1 transition-opacity',
            // Copy is a convenience and can wait to be hovered for. Retry is
            // the way out of a turn that failed, and a recovery affordance
            // nobody can see until they happen to sweep the pointer over the
            // error is not one — so a row containing it stays put.
            !retryable && '[@media(hover:hover)]:opacity-0',
            !retryable && '[@media(hover:hover)]:group-hover/actions:opacity-100',
            // Beats the rule above on specificity, so tabbing to a button
            // reveals the row it sits in whatever the pointer is doing.
            'focus-within:opacity-100',
          )}
        >
          {copyable ? (
            <Button
              type="button"
              variant="ghost"
              size="sm"
              aria-label="Copy answer"
              onClick={() => void handleCopy()}
            >
              {copied ? <Check className="size-4 text-positive" aria-hidden /> : <Copy className="size-4" aria-hidden />}
              {copied ? 'Copied' : 'Copy'}
            </Button>
          ) : null}
          {/* Both labels open with the word printed on the button. An
              accessible name that does not contain its own visible text is a
              voice-control dead end: "click Retry" would find nothing. */}
          {retryable ? (
            <Button type="button" variant="ghost" size="sm" aria-label="Retry this answer" onClick={onRetry}>
              <RotateCcw className="size-4" aria-hidden />
              Retry
            </Button>
          ) : null}
        </div>
      ) : null}
    </div>
  );
}

/**
 * The one-word account of a turn that did not simply finish.
 *
 * Ordered by what the user needs to know first: an outright failure outranks
 * having pressed stop, which outranks the line dropping. `failed` is last
 * because it belongs to the question rather than the answer.
 */
function stateLabel(message: TranscriptMessage): string | null {
  if (message.error) return 'Failed';
  if (message.stopped) return 'Stopped';
  if (message.truncated) return 'Ended early';
  if (message.failed) return 'Not sent';
  return null;
}

function formatUsage(message: TranscriptMessage): string | null {
  const parts: string[] = [];
  if (typeof message.inputTokens === 'number') parts.push(`${formatTokens(message.inputTokens)} in`);
  if (typeof message.outputTokens === 'number') parts.push(`${formatTokens(message.outputTokens)} out`);
  return parts.length ? parts.join(' / ') : null;
}

/** The unabbreviated figures, for the caption's `title`. Nothing is rounded away. */
function exactUsage(message: TranscriptMessage): string | undefined {
  const parts: string[] = [];
  if (typeof message.inputTokens === 'number') parts.push(`${message.inputTokens.toLocaleString()} input tokens`);
  if (typeof message.outputTokens === 'number') parts.push(`${message.outputTokens.toLocaleString()} output tokens`);
  return parts.length ? parts.join(' · ') : undefined;
}

/**
 * Thousands are abbreviated because this is a caption, not an invoice: at a
 * glance "4.1k" answers the question the exact figure does not, and a
 * five-digit number next to a model id is what breaks the row in the dock.
 * One decimal below ten thousand, where the difference between 4.1k and 4.9k
 * is still a real difference.
 */
function formatTokens(count: number): string {
  if (count < 1_000) return String(count);
  if (count < 10_000) return `${(count / 1_000).toFixed(1)}k`;
  return `${Math.round(count / 1_000)}k`;
}
