/**
 * What Piggy has been doing, and what it has cost.
 *
 * This is the audit surface. An agent-native CRM is only defensible if the
 * agent's work is legible after the fact, so everything the ledger knows is
 * shown rather than summarised away: the turn that failed, the task that is
 * still queued, the money that has gone.
 *
 * The one rule that matters here is the money. `costMicroCents` is millionths
 * of a cent — the unit the provider bills in and the unit the column stores —
 * and a turn genuinely costs a few ten-thousandths of a cent, so the naive
 * rendering rounds every real figure to `$0.00`. So no raw factor is ever
 * written in this file: the conversion goes through `MICRO_CENTS_PER_DOLLAR`
 * every time, `spendMoney` is the only thing that formats money, and every
 * figure carries its exact micro-cent value in a title attribute so a reader
 * who does not believe the conversion can check it. Getting this wrong by a
 * factor of anything is the worst error this panel could make.
 *
 * Layout: a single column that scrolls inside whatever height its parent gives
 * it, so the same component is a right-hand rail on a desktop and the contents
 * of a sheet on a phone. Each section collapses, which is what makes it usable
 * at 393px — the spend figures stay, the two lists fold away.
 */
import { useState, type ReactNode } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { AlertTriangle } from 'lucide-react';
import { compactNumber, get, relativeTime } from '@/lib/api';
import { Button, EmptyState, Section, Skeleton, Stat, cn } from '@/components/ui';
import { RunStatusBadge, TaskStateBadge } from '@/components/status';
import { piggyCopy, piggyLine } from '@/lib/piggy-copy';

// -------------------------------------------------------------- the wire

/** Mirrors `PiggyRunSummary` in apps/api/src/services/piggy-activity.ts. */
export interface PiggyRunSummary {
  id: string;
  kind: 'chat' | 'task';
  agent: string;
  /** Free text on purpose — see the note on the server type. */
  status: string;
  model: string | null;
  label: string;
  summary: string | null;
  error: string | null;
  inputTokens: number | null;
  outputTokens: number | null;
  costMicroCents: number | null;
  startedAt: string;
  finishedAt: string | null;
  durationMs: number | null;
  taskKind: string | null;
  /** Present only when the transcript is the viewer's own. */
  conversation: { id: string; title: string } | null;
  /** Present only when the run was somebody else's — a platform admin's view. */
  principal: { id: string; name: string } | null;
}

/** Mirrors `PiggyTaskSummary`. */
export interface PiggyTaskSummary {
  id: string;
  kind: string;
  subject: string;
  reason: string | null;
  state: 'running' | 'queued' | 'scheduled' | 'succeeded' | 'failed' | 'skipped' | 'cancelled';
  attempts: number;
  maxAttempts: number;
  priority: number;
  dueAt: string;
  startedAt: string | null;
  finishedAt: string | null;
  error: string | null;
}

export interface PiggyActivityResponse {
  runs: PiggyRunSummary[];
  tasks: PiggyTaskSummary[];
  spend: { todayMicroCents: number; monthMicroCents: number; turns: number };
}

// ------------------------------------------------------------ formatting

/**
 * Micro-cents to US dollars. A cent is 10^6 micro-cents; a dollar is 100 cents.
 * Written as one constant so the two conversions cannot be applied separately
 * and end up compounding.
 */
const MICRO_CENTS_PER_DOLLAR = 100_000_000;

/** Runs shown before the list asks to be expanded. See `allRuns`. */
const RUNS_BEFORE_EXPANDING = 8;

const EXACT = new Intl.NumberFormat('en-US');

/**
 * Money, at whatever precision the figure actually has.
 *
 * A month of Piggy costs about a penny and a single turn costs three
 * ten-thousandths of one, so a fixed two decimal places would render the entire
 * panel as `$0.00` and quietly answer "what is the credit doing?" with
 * "nothing". Precision instead widens as the number shrinks, to **two
 * significant figures** and never past six decimal places.
 *
 * Two figures, not the six the panel used to print: `$0.000483` claims a
 * precision the reader cannot use and cannot check, and a column of them reads
 * as noise rather than as money. Anyone who does want the exact number has it —
 * every figure here carries its raw micro-cent value in a title attribute, and
 * that is the audit trail, not the rendering.
 */
function decimalsFor(dollars: number): number {
  const size = Math.abs(dollars);
  if (size === 0 || size >= 1) return 2;
  // floor(log10) is the position of the leading digit; one place past it is the
  // second significant figure.
  return Math.min(6, 1 - Math.floor(Math.log10(size)));
}

/*
 * Exported for the conversation's own spend figure, which sits directly beside
 * this panel in the workspace rail. A second formatter for millionths of a cent
 * one tab away from this one is exactly how two figures of the same money come
 * to be shown at two precisions.
 */
export function spendMoney(microCents: number | null, decimals?: number): string {
  if (microCents == null) return '—';
  const dollars = microCents / MICRO_CENTS_PER_DOLLAR;
  const digits = decimals ?? decimalsFor(dollars);
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD',
    minimumFractionDigits: digits,
    maximumFractionDigits: digits,
  }).format(dollars);
}

/**
 * One precision for a pair of figures shown side by side.
 *
 * Today at six places beside the month at four reads as two different kinds of
 * number rather than one number in two windows. The smallest non-zero figure
 * decides, so the narrower window never rounds away to nothing.
 */
function sharedDecimals(...microCents: number[]): number {
  const positive = microCents
    .map((value) => Math.abs(value) / MICRO_CENTS_PER_DOLLAR)
    .filter((value) => value > 0);
  if (positive.length === 0) return 2;
  return decimalsFor(Math.min(...positive));
}

/**
 * What went wrong, in the words the person at the keyboard already heard.
 *
 * `agent_runs.error` is deliberately the raw reason — the chat stream sanitises
 * what the browser is told and the ledger keeps the truth, which is the right
 * division. But this panel rendered that truth verbatim, so PIG's own audit
 * surface showed a turn stopping as
 * `turn stopped by the model_calls ceiling: 8 model calls, 5457 tokens, ceiling 8`
 * and a rate limit as a JSON document from a third party. Both had already been
 * explained to the same reader, in English, in the transcript a moment earlier.
 *
 * So the prefixes the relay writes are mapped back to the sentences the relay
 * emits (`chat-server.ts` → `reportBreach` / `reportInferenceFailure`). Prose is
 * left alone — an error that is already a sentence is somebody's considered
 * wording and this table is not an improvement on it. The raw string is always
 * one hover away in `title`, which is what makes the mapping safe.
 */
const ERROR_SENTENCES: readonly {
  match: RegExp;
  say: (groups: RegExpExecArray) => string;
}[] = [
  {
    match: /^turn stopped by the model_calls ceiling: (\d+) model calls/,
    say: ([, calls]) =>
      `Piggy stopped after ${calls} step${calls === '1' ? '' : 's'}, which is the most one ` +
      'question may take, so this answer is incomplete.',
  },
  {
    match: /^turn stopped by the tokens ceiling: \d+ model calls, (\d+) tokens/,
    say: ([, tokens]) =>
      `Piggy reached the size limit for a single question (${Number(tokens).toLocaleString(
        'en-GB',
      )} tokens), so this answer is incomplete.`,
  },
  {
    // The AbortError, which is what Stop and a closed tab both leave behind.
    // The badge beside it already says "Stopped by you"; this says what it cost.
    match: /^This operation was aborted/,
    say: () => 'Stopped before Piggy finished the answer.',
  },
];

/** The shapes a rate limit arrives in. Matches `isRateLimited` in the relay. */
const RATE_LIMITED = /\b429\b|rate.?limit|rate_limited|too many requests|resourceexhausted/i;

function readableError(error: string): string {
  const trimmed = error.trim();

  for (const { match, say } of ERROR_SENTENCES) {
    const found = match.exec(trimmed);
    if (found) return say(found);
  }

  // `429: {"message":…}` and friends: a status code and a provider's JSON body,
  // which is the one shape that is never anybody's considered wording.
  const wire = /^(\d{3}):\s*(\{.*\})\s*$/s.exec(trimmed);
  if (!wire) return error;
  if (RATE_LIMITED.test(trimmed)) {
    return 'The inference endpoint was rate limiting us, so this turn was turned away. Waiting a few seconds and asking again usually clears it.';
  }
  const [, status, payload] = wire;
  try {
    const body = JSON.parse(payload!) as { message?: unknown; error?: unknown };
    const message =
      typeof body.message === 'string'
        ? body.message
        : typeof body.error === 'string'
          ? body.error
          : null;
    // The status is kept: "500" is the part an operator acts on.
    return message ? `${status}: ${message}` : error;
  } catch {
    // Not JSON after all. Showing it unchanged beats showing nothing.
    return error;
  }
}

/** The unit, spelled out, for the title attribute on every money figure. */
export function spendTitle(microCents: number | null): string | undefined {
  if (microCents == null) return undefined;
  return `${EXACT.format(microCents)} micro-cents (millionths of a US cent)`;
}

function formatDuration(ms: number | null): string | null {
  if (ms == null || ms < 0) return null;
  if (ms < 1_000) return `${ms} ms`;
  if (ms < 60_000) return `${(ms / 1_000).toFixed(1)}s`;
  const minutes = Math.floor(ms / 60_000);
  const seconds = Math.round((ms % 60_000) / 1_000);
  return `${minutes}m ${seconds}s`;
}

/** The model name without its vendor prefix, which is the same on every row. */
function shortModel(model: string | null): string | null {
  if (!model) return null;
  const parts = model.split('/');
  return parts[parts.length - 1] ?? model;
}

function humanise(value: string): string {
  return value.replaceAll('_', ' ').replace(/^./, (letter) => letter.toUpperCase());
}

// ------------------------------------------------------------- primitives

/**
 * The copy module owns the sentence; an empty state needs it as a heading and a
 * body. Split once here rather than restated, so the panel and Piggy's front
 * door cannot end up describing the ledger in two different ways.
 */
function firstSentence(line: string): { title: string; description?: string } {
  const at = line.indexOf('. ');
  return at === -1
    ? { title: line }
    : { title: line.slice(0, at), description: line.slice(at + 2) };
}

/**
 * A meta line: small, muted, wrapping.
 *
 * Separated by space rather than by interpunct characters, because these lines
 * wrap at every width the panel is used at and a dot between items lands at the
 * start of the next line as often as between two of them.
 */
function Meta({ parts }: { parts: (ReactNode | null)[] }) {
  const kept = parts.filter((part): part is ReactNode => part != null && part !== '');
  if (kept.length === 0) return null;
  return (
    <div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted">
      {kept.map((part, index) => (
        <span key={index}>{part}</span>
      ))}
    </div>
  );
}

/**
 * A run's reason, tinted by whether anybody has to do something about it.
 *
 * `danger` is reserved for a failure a person must resolve. A turn you stopped
 * yourself, or one that ran into its own step ceiling, is a fact about the turn
 * — so it reads as an inset note rather than as an alarm. A column in which
 * every ended turn is red is a column nobody reads.
 */
function RunReason({ status, error }: { status: string; error: string }) {
  const alarming = status === 'failed';
  return (
    <p
      className={cn(
        'mt-1.5 flex items-start gap-1.5 break-words rounded-md px-2 py-1.5 text-xs leading-relaxed',
        alarming ? 'bg-danger/10 text-danger' : 'bg-surface-2 text-muted',
      )}
      // The whole of it, for an operator who needs the provider's own words.
      title={error}
    >
      {alarming ? <AlertTriangle className="mt-0.5 size-3.5 shrink-0" aria-hidden /> : null}
      <span className="min-w-0">{readableError(error)}</span>
    </p>
  );
}

// ------------------------------------------------------------------- rows

/**
 * Is the thread's name just the run's label again?
 *
 * A conversation is titled from its opening question, so on the turn that
 * started it the two strings are the same one and the row printed it twice —
 * once as the heading and once, clipped, at the end of the meta line. Compared
 * by prefix, with the server's own truncation mark stripped, because a title
 * cut at 120 characters ends in an ellipsis the label it was cut from does not.
 */
function sameWords(label: string, title: string): boolean {
  const trim = (value: string) => value.trim().toLowerCase().replace(/(\.\.\.|…)$/, '');
  const one = trim(label);
  const other = trim(title);
  return one.startsWith(other) || other.startsWith(one);
}

function RunRow({ run }: { run: PiggyRunSummary }) {
  const duration = formatDuration(run.durationMs);
  const tokens =
    run.inputTokens == null && run.outputTokens == null
      ? null
      : `${compactNumber(run.inputTokens ?? 0)} in · ${compactNumber(run.outputTokens ?? 0)} out`;

  const body = (
    <>
      <div className="flex items-start justify-between gap-2">
        {/* Clamped rather than truncated to one line: two lines is enough to
            tell two similar questions apart, and a turn's whole prompt can be a
            paragraph that would otherwise own the panel. The full text stays in
            the title attribute. */}
        <p
          className="line-clamp-2 min-w-0 flex-1 break-words text-sm font-medium leading-snug"
          title={run.label}
        >
          {run.label}
        </p>
        <RunStatusBadge status={run.status} className="shrink-0" />
      </div>

      {run.summary ? (
        <p
          className="mt-1 line-clamp-2 break-words text-xs leading-relaxed text-muted"
          title={run.summary}
        >
          {run.summary}
        </p>
      ) : null}

      {run.error ? <RunReason status={run.status} error={run.error} /> : null}

      <Meta
        parts={[
          <span key="when">{relativeTime(run.startedAt)}</span>,
          duration ? <span key="took" className="nums">{duration}</span> : null,
          tokens ? <span key="tokens" className="nums">{tokens}</span> : null,
          run.costMicroCents == null ? null : (
            <span key="cost" className="nums" title={spendTitle(run.costMicroCents)}>
              {spendMoney(run.costMicroCents)}
            </span>
          ),
          shortModel(run.model),
          run.kind === 'task' && run.taskKind ? humanise(run.taskKind) : null,
          // Present only when the run was somebody else's — see the server type.
          run.principal ? run.principal.name : null,
          run.conversation && !sameWords(run.label, run.conversation.title) ? (
            <span
              key="conversation"
              // `inline-block` is load-bearing: `max-width` and `overflow` do
              // nothing on a non-replaced inline box, so the truncation here was
              // inert and a run whose title is a question with a UUID in it
              // rendered 624px wide inside a 320px rail — clipped mid-word by
              // the column rather than ellipsised.
              className="inline-block max-w-[14rem] truncate align-bottom text-accent-fg underline-offset-2 group-hover/run:underline"
              title={run.conversation.title}
            >
              {run.conversation.title}
            </span>
          ) : null,
        ]}
      />
    </>
  );

  /*
   * Only the caller's own conversations resolve to a link — the server refuses
   * to name anybody else's — so an admin reading the workspace ledger sees the
   * run without a doorway into a private transcript.
   *
   * The whole row is the target, not the thread name at the end of the meta
   * line. That name was a 14px-tall link at the bottom of a hundred-pixel row,
   * which is a 44px rule broken by the one control in this panel that goes
   * anywhere; and the row already reads as a unit, so the visible affordance
   * was in the wrong place as well as the wrong size.
   */
  return (
    <li className="border-t border-border first:border-t-0">
      {run.conversation ? (
        <Link
          to={`/piggy?conversation=${encodeURIComponent(run.conversation.id)}`}
          aria-label={`Open the conversation “${run.conversation.title}”`}
          className={cn(
            'group/run -mx-2 block min-h-11 rounded-md px-2 py-3',
            'transition-colors duration-1 ease-enter hover:bg-surface-2',
          )}
        >
          {body}
        </Link>
      ) : (
        <div className="py-3">{body}</div>
      )}
    </li>
  );
}

function TaskRow({ task }: { task: PiggyTaskSummary }) {
  const outstanding = task.state === 'queued' || task.state === 'scheduled' || task.state === 'running';
  return (
    <li className="border-t border-border py-3 first:border-t-0">
      <div className="flex items-start justify-between gap-2">
        <p className="min-w-0 flex-1 break-words text-sm font-medium leading-snug">
          {humanise(task.kind)}
        </p>
        <TaskStateBadge state={task.state} className="shrink-0" />
      </div>

      {task.reason ? (
        <p
          className="mt-1 line-clamp-3 break-words text-xs leading-relaxed text-muted"
          title={task.reason}
        >
          {task.reason}
        </p>
      ) : null}

      {task.error ? <RunReason status="failed" error={task.error} /> : null}

      <Meta
        parts={[
          // A pending task is described by when it may next run; a finished one
          // by when it finished. Showing `dueAt` for both would render a task
          // that completed last week as though it were a week overdue.
          <span key="when" className="nums">
            {outstanding
              ? `due ${relativeTime(task.dueAt)}`
              : `finished ${relativeTime(task.finishedAt ?? task.dueAt)}`}
          </span>,
          task.attempts > 0 ? (
            <span key="attempts" className="nums">
              attempt {task.attempts} of {task.maxAttempts}
            </span>
          ) : null,
          <span key="subject" className="nums font-mono" title={task.subject}>
            {task.subject.slice(0, 8)}
          </span>,
        ]}
      />
    </li>
  );
}

// ------------------------------------------------------------------ panel

export function PiggyActivityPanel({ className }: { className?: string }) {
  const activity = useQuery({
    queryKey: ['piggy-activity'],
    queryFn: () => get<PiggyActivityResponse>('/api/piggy/activity'),
    /*
     * Poll faster while something is in flight. A ledger that only updates on
     * navigation shows a turn as running long after it finished, which is the
     * one thing an activity view must not do; polling every ten seconds
     * regardless would be a request a minute from an idle tab for nothing.
     */
    refetchInterval: (query) =>
      query.state.data?.runs.some((run) => run.status === 'running') ? 10_000 : 60_000,
    /*
     * One retry, not three. The default backoff leaves the panel showing
     * loading skeletons for the better part of a minute before it admits the
     * read failed, and an audit surface that looks like it is still thinking
     * is worse than one that says it could not read the ledger.
     */
    retry: 1,
  });

  /*
   * The ledger opens on a readable number of rows and keeps the rest one click
   * away. Without this the queue below sits under twenty-five runs, which on a
   * phone means the pending work — the half of this panel that needs a person —
   * is off the bottom of a very long scroll.
   */
  const [allRuns, setAllRuns] = useState(false);

  const spend = activity.data?.spend;
  const runs = activity.data?.runs ?? [];
  const tasks = activity.data?.tasks ?? [];
  const shownRuns = allRuns ? runs : runs.slice(0, RUNS_BEFORE_EXPANDING);
  const average =
    spend && spend.turns > 0 ? Math.round(spend.monthMicroCents / spend.turns) : null;
  const spendDigits = spend
    ? sharedDecimals(spend.todayMicroCents, spend.monthMicroCents)
    : 2;

  const runsEmpty = firstSentence(piggyLine(piggyCopy.activityEmpty));

  /*
   * Not a landmark. The workspace already wraps this in an `<aside>` called
   * "Piggy activity" on a wide screen, and in a Sheet titled "Activity" on a
   * phone, so a complementary landmark of the same name nested inside it gave a
   * screen-reader user two doors into one panel.
   */
  return (
    <div className={cn('flex min-h-0 min-w-0 flex-col gap-3 overflow-y-auto', className)}>
      {/* First, not last: a ledger that could not be read must say so before it
          shows anything that looks like a figure. */}
      {activity.isError ? (
        <div className="card order-1 min-w-0 p-4 sm:p-5">
          <p className="flex items-start gap-2 text-sm text-danger">
            <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
            <span className="min-w-0 break-words">
              {activity.error instanceof Error
                ? activity.error.message
                : 'The activity ledger could not be read.'}
            </span>
          </p>
          <Button
            variant="outline"
            size="sm"
            className="mt-3"
            onClick={() => void activity.refetch()}
          >
            Try again
          </Button>
        </div>
      ) : null}

      {/*
        Spend sits under the runs on a narrow screen. What people open this
        panel for is what Piggy just did; the month's spend is a figure they
        check occasionally and it was pushing the first run below the fold of a
        393px sheet. On a wide screen the rail is tall enough for both, and the
        figure reads better at the top of the column.
      */}
      <Section
        title="Spend"
        level={3}
        action={<span className="text-xs text-muted">US dollars</span>}
        className="card order-3 p-4 sm:order-2 sm:p-5"
      >
        {/*
          Nothing here falls back to zero. A figure the panel could not read is
          an em dash, never `$0.00`: on a spend surface those two are opposite
          claims, and only one of them is true.
        */}
        {spend || activity.isError ? (
          <div className="grid grid-cols-2 gap-3">
            <SpendFigure
              label="Today"
              microCents={spend ? spend.todayMicroCents : null}
              digits={spendDigits}
            />
            <SpendFigure
              label="This month"
              microCents={spend ? spend.monthMicroCents : null}
              digits={spendDigits}
            />
          </div>
        ) : (
          <div className="grid grid-cols-2 gap-3">
            <Skeleton className="h-12" />
            <Skeleton className="h-12" />
          </div>
        )}

        {spend ? (
          <p className="mt-2 text-xs leading-relaxed text-muted">
            {spend.turns > 0 ? (
              <>
                <span className="nums">{EXACT.format(spend.turns)}</span> turns this month,
                averaging{' '}
                <span className="nums" title={spendTitle(average)}>
                  {spendMoney(average)}
                </span>{' '}
                each. Billed in micro-cents — millionths of a cent — and converted here.
              </>
            ) : (
              'No turns have been billed this month. Every question you ask Piggy is priced per token and lands here.'
            )}
          </p>
        ) : null}
      </Section>

      <Section
        title="Recent runs"
        level={3}
        count={activity.data ? runs.length : undefined}
        collapsible
        className="card order-2 p-4 sm:order-3 sm:p-5"
      >
        {/*
          `activity.data`, not `isPending`: after a failed read the query is
          neither pending nor holding rows, and keying the empty state off
          pending would announce "nothing has run yet" about a ledger nobody
          managed to open.
        */}
        {!activity.data ? (
          activity.isError ? (
            <EmptyState size="inline" title="Unavailable while the ledger cannot be read." />
          ) : (
            <div className="flex flex-col gap-3">
              <Skeleton className="h-12" />
              <Skeleton className="h-12" />
              <Skeleton className="h-12" />
            </div>
          )
        ) : runs.length === 0 ? (
          <EmptyState size="inline" title={runsEmpty.title} description={runsEmpty.description} />
        ) : (
          <>
            <ul className="flex flex-col">
              {shownRuns.map((run) => (
                <RunRow key={run.id} run={run} />
              ))}
            </ul>
            {runs.length > RUNS_BEFORE_EXPANDING ? (
              <Button
                variant="ghost"
                size="sm"
                className="mt-2 w-full"
                onClick={() => setAllRuns((was) => !was)}
              >
                {allRuns ? 'Show fewer' : `Show all ${runs.length} runs`}
              </Button>
            ) : null}
          </>
        )}
      </Section>

      <Section
        title="Queue"
        level={3}
        count={activity.data ? tasks.length : undefined}
        collapsible
        className="card order-4 p-4 sm:p-5"
      >
        {!activity.data ? (
          activity.isError ? (
            <EmptyState size="inline" title="Unavailable while the ledger cannot be read." />
          ) : (
            <div className="flex flex-col gap-3">
              <Skeleton className="h-12" />
              <Skeleton className="h-12" />
            </div>
          )
        ) : tasks.length === 0 ? (
          <EmptyState
            size="inline"
            title="No background work is queued"
            description="Enrichment, renewal watches and supplier research are written here as tasks before Piggy runs them, and stay with their result afterwards."
          />
        ) : (
          <ul className="flex flex-col">
            {tasks.map((task) => (
              <TaskRow key={task.id} task={task} />
            ))}
          </ul>
        )}
      </Section>
    </div>
  );
}

/**
 * One spend figure, with its exact value where a doubter can find it.
 *
 * The `title` carries the raw micro-cent integer, which is the audit trail: the
 * rendered figure is a rounding of a number stored in millionths of a cent, and
 * a money surface that cannot be checked against its own source is a claim
 * rather than a record.
 */
function SpendFigure({
  label,
  microCents,
  digits,
}: {
  label: string;
  microCents: number | null;
  digits: number;
}) {
  const text = spendMoney(microCents, digits);
  return (
    <Stat
      size="md"
      surface="bare"
      label={label}
      value={microCents == null ? text : <span title={spendTitle(microCents)}>{text}</span>}
    />
  );
}
