/**
 * One tool round trip, rendered as evidence rather than as a spinner.
 *
 * The /piggy page promises that the user can inspect the PIG records behind an
 * answer. The server has always streamed the whole tool payload, but the
 * timeline expanded onto `JSON.stringify(arguments)` — and since almost every
 * Piggy tool declares `z.object({}).strict()`, that was the literal string
 * `{}`. A chip that proves nothing is worse than no chip: it looks like
 * provenance and carries none.
 *
 * So the default reading is a sentence — "Northwind Robotics · 4 contacts, 2
 * contracts, 0 demand deals" — with the records themselves linked, and the raw
 * payload one further click down for anyone who wants to check the sentence
 * against it.
 */
import type { ReactNode } from 'react';
import { CheckCircle2, CircleSlash, Loader2, XCircle } from 'lucide-react';
import { money, unitPrice } from '@/lib/api';
import type { ToolStep } from '@/lib/piggy-chat';
import { isPiggyWriteTool, piggyToolLabel } from '@/lib/piggy-tool-labels';
import { RecordLink } from '@/components/RecordLink';
import { Label, cn } from '@/components/ui';
import { Disclosure } from '@/components/ui/disclosure';
import { usePiggyConversationReveal } from './conversation';

/**
 * How many records the evidence row will link before it stops.
 *
 * `readFocusedRecord` reads up to a hundred rows per relation, and a chip per
 * contact would bury the answer under its own footnotes. The count in the
 * headline stays exact; only the links are capped.
 */
const LINKED_RECORDS_MAX = 8;

/** Past this the raw payload is a scroll container nobody reads to the end of. */
const RAW_PAYLOAD_MAX_CHARS = 20_000;

// ------------------------------------------------------------------ routing

/**
 * The record kinds a payload can name, and the noun each is called by.
 *
 * The URLs themselves live in `components/RecordLink`, which is the one place a
 * record id becomes a URL and the one place that knows an account chip opens
 * the record while everything else lands on the list holding it. This table is
 * what remains: the vocabulary a summary sentence is written in.
 *
 * A contact is the case worth stating: it would want `/accounts/:accountId`,
 * and the summariser reads contacts out of collections that carry the contact's
 * own id and not its account's, so a per-record contact link would point at an
 * account that does not exist.
 */
const RECORD_LABELS = {
  account: 'account',
  contact: 'contact',
  demand_deal: 'demand deal',
  supply_deal: 'supply deal',
  contract: 'contract',
  commitment: 'capacity commitment',
  allocation: 'allocation',
} as const;

type RecordKind = keyof typeof RECORD_LABELS;

interface EvidenceRecord {
  kind: RecordKind;
  id: string;
  label: string;
}

// ------------------------------------------------------------------ evidence

interface Evidence {
  /** One line a human reads instead of the payload. */
  headline: string | null;
  /** The records the answer rests on, each openable. */
  links: EvidenceRecord[];
  /** Records read but not linked, so the cap is admitted rather than hidden. */
  hiddenLinkCount: number;
  /**
   * What a WRITE tool's call actually came to.
   *
   * A write that the user declined returns normally — the tool did its job,
   * which was to ask — so its step is `succeeded` and it was drawn with the
   * same green check as a lookup, above a card reading "Rejected. Nothing was
   * changed." On the surface whose whole promise is that a person decides, the
   * evidence row was contradicting the decision. `state` alone cannot tell
   * these apart; the payload's own `status` can.
   */
  outcome?: PigWriteStatus;
}

/** The three ways a write tool's call can end. Mirrors `PigWriteDetails`. */
type PigWriteStatus = 'applied' | 'declined' | 'refused';

const NO_EVIDENCE: Evidence = { headline: null, links: [], hiddenLinkCount: 0 };

/**
 * A write that has stopped and is asking, drawn by the approval card instead.
 *
 * A write tool holds its own call open across the whole approval — so for as
 * long as the question is on screen, this step is `running` and renders a
 * spinning row saying "Log activity" directly above a card that says the same
 * thing with a diff and two buttons. One of the two is the decision surface and
 * the other is a spinner that reads as work in progress on a turn where nothing
 * is progressing. The step returns the moment the decision resolves it.
 *
 * The one case this over-reaches is auto mode, where a write runs without ever
 * asking: its row is withheld for the second or so the call takes, then appears
 * complete with its duration. That is the cheaper of the two mistakes — the
 * duplicate row is on the default mode and on the product's most important
 * card, the silent second is on a mode that has already said it will not ask.
 */
export function isParkedWriteStep(step: ToolStep): boolean {
  return step.state === 'running' && isPiggyWriteTool(step.name);
}

export function PiggyToolStep({ step }: { step: ToolStep }) {
  const reveal = usePiggyConversationReveal();
  const evidence = describeStep(step);
  const input = formatArguments(step.arguments);
  const payload = step.state === 'succeeded' ? formatPayload(step.result) : null;

  if (isParkedWriteStep(step)) return null;

  return (
    <Disclosure
      // A transcript pinned to its newest message treats an unfolded step as
      // new content and scrolls past it, so the evidence the user asked to see
      // leaves the screen. `onExpand` fires inside the click, before the
      // browser lays the expansion out, which is the only moment early enough
      // to get in front of the follow.
      onExpand={reveal}
      className="rounded-lg border border-border text-xs"
      // The chevron is nudged onto the first line's optical centre, the same
      // half-step the state icon beside it takes, so a two-line headline does
      // not leave the two glyphs at different heights.
      summaryClassName="items-start px-3 text-xs font-normal [&>svg]:mt-0.5"
      contentClassName="flex flex-col gap-3 border-t border-border p-3"
      summary={
        <span className="flex min-w-0 items-start gap-2">
          <StepIcon state={step.state} outcome={evidence.outcome} />
          <span className="min-w-0 flex-1">
            <span className="flex items-center gap-2">
              <span className="min-w-0 flex-1 truncate font-medium">
                {piggyToolLabel(step.name)}
              </span>
              {step.durationMs === undefined ? null : (
                <span className="shrink-0 tabular-nums text-muted">
                  {formatDuration(step.durationMs)}
                </span>
              )}
            </span>
            {evidence.headline ? (
              // Clamped shut, whole when open: a calendar headline runs to
              // several sentences, and a chip that tall stops being a chip.
              <span
                className={cn(
                  'mt-0.5 line-clamp-2 break-words leading-5 group-open:line-clamp-none',
                  step.state === 'failed' ? 'text-danger' : 'text-muted',
                )}
              >
                {evidence.headline}
              </span>
            ) : null}
          </span>
        </span>
      }
    >
      {input ? (
        <EvidenceSection title="Input">
          <RawBlock text={input} />
        </EvidenceSection>
      ) : null}
      <EvidenceSection title="Output">
        {step.state === 'running' ? (
          <p className="text-muted">Waiting for PIG…</p>
        ) : step.state === 'failed' ? (
          // The reason is already in the header, unclamped once open, so
          // repeating it here would print the same sentence twice in a row.
          <p className="text-muted">Nothing was returned; the call did not complete.</p>
        ) : (
          <div className="flex flex-col gap-2">
            {evidence.links.length ? (
              <RecordChips links={evidence.links} hidden={evidence.hiddenLinkCount} />
            ) : null}
            {payload ? (
              <Disclosure
                onExpand={reveal}
                summary="Raw payload"
                summaryClassName="text-xs font-normal text-muted"
              >
                <RawBlock text={payload} />
              </Disclosure>
            ) : (
              <p className="text-muted">The tool returned no payload.</p>
            )}
          </div>
        )}
      </EvidenceSection>
    </Disclosure>
  );
}

/**
 * What became of one call, in a glyph and a word.
 *
 * `outcome` overrides `state` for the write tools, and it has to: a proposal
 * the user declined is a call that RETURNED, so its step is `succeeded` and it
 * was drawn "Succeeded" with a positive check directly above the card saying
 * nothing had changed. Declined and refused are neither successes nor failures
 * — nothing went wrong and nothing was written — so they take the neutral mark
 * the decided colour table gives every other process outcome.
 */
function StepIcon({ state, outcome }: { state: ToolStep['state']; outcome?: PigWriteStatus }) {
  const unwritten = state === 'succeeded' && (outcome === 'declined' || outcome === 'refused');
  const label = unwritten
    ? 'Not saved'
    : state === 'running'
      ? 'Running'
      : state === 'succeeded'
        ? 'Succeeded'
        : 'Failed';
  return (
    <span className="mt-0.5 shrink-0">
      {state === 'running' ? (
        <Loader2 className="size-4 animate-spin text-muted" aria-hidden />
      ) : unwritten ? (
        <CircleSlash className="size-4 text-muted" aria-hidden />
      ) : state === 'succeeded' ? (
        <CheckCircle2 className="size-4 text-positive" aria-hidden />
      ) : (
        <XCircle className="size-4 text-danger" aria-hidden />
      )}
      <span className="sr-only">{label}</span>
    </span>
  );
}

/**
 * A named group inside a step, labelled without a heading.
 *
 * Deliberately not the shared `Section`: this renders inside `role="log"`,
 * where every step would contribute an `h3` or `h4` and a transcript of forty
 * of them turns heading navigation — the way a screen-reader user skims a
 * page — into a list of "Input, Output, Input, Output". The label itself is the
 * shared one, so it measures the same as every other micro label in PIG.
 */
function EvidenceSection({ title, children }: { title: string; children: ReactNode }) {
  return (
    <section aria-label={title}>
      <Label className="mb-1">{title}</Label>
      {children}
    </section>
  );
}

function RawBlock({ text }: { text: string }) {
  return (
    <pre className="mt-1 max-h-72 overflow-auto rounded-md bg-surface-2 p-2 font-mono text-xs leading-5 text-muted">
      {text}
    </pre>
  );
}

/**
 * The records the answer rests on, each openable.
 *
 * Same tail, same link, same honesty about where it lands as the approval
 * card's escape hatch — this is the other half of the promise that an answer
 * can be checked against its rows.
 *
 * `newTab`, for the same reason the approval card has it, and the reason is
 * `RecordLink`'s own: "turn it on where leaving would destroy unsubmitted state
 * — the pending approval card, and any link inside a streaming transcript."
 * This is a link inside a streaming transcript. Measured with a proposal on
 * screen, following one of these chips in-tab unmounted the transcript, and
 * Back returned to the empty starter state — no card, no answer, and no notice
 * that a decision had been abandoned. The chip that exists so an answer can be
 * checked was destroying the thing being checked.
 */
function RecordChips({ links, hidden }: { links: EvidenceRecord[]; hidden: number }) {
  return (
    <ul className="flex flex-wrap gap-1.5" aria-label="Records read">
      {links.map((link) => (
        <li key={`${link.kind}:${link.id}`} className="min-w-0 max-w-full">
          <RecordLink
            type={link.kind}
            id={link.id}
            label={link.label}
            newTab
            className="border border-border px-2 hover:bg-surface-2 hover:no-underline"
          />
        </li>
      ))}
      {hidden > 0 ? (
        <li className="flex min-h-11 items-center text-muted">and {hidden} more</li>
      ) : null}
    </ul>
  );
}

// --------------------------------------------------------------- summarising

function describeStep(step: ToolStep): Evidence {
  // A failure with no message still needs a line, or the chip reads as a
  // success whose summary happened not to render.
  if (step.state === 'failed') {
    return { ...NO_EVIDENCE, headline: step.error ?? 'The tool failed without saying why.' };
  }
  if (step.state === 'running') return NO_EVIDENCE;
  return summariseResult(step.result);
}

function summariseResult(result: unknown): Evidence {
  const payload = asRecord(result);
  if (!payload) return NO_EVIDENCE;

  // A write tool first: its payload is `{ tool, kind, status, recordId?,
  // reason? }` and carries no `headline`, no subject and no collections, so it
  // fell all the way through to `composeHeadline(null, [])` — an empty string.
  // The measured result was a row reading "Succeeded / Log activity / 1.4s"
  // with nothing under it, above a card saying the change had been rejected.
  const written = writeOutcome(payload);
  if (written) return written;

  // The page tools compose the sentence they want quoted and the system prompt
  // tells the model to quote it, so deriving a second summary here would put a
  // subtly different reading of the same numbers next to the model's. They drop
  // record ids on purpose, so those chips carry a sentence and nothing else —
  // but the lookup layer keeps its ids, and those rows are linked.
  const headline = asString(payload.headline);
  if (headline) {
    const found = readHeadlineLinks(payload);
    return {
      headline,
      links: found.slice(0, LINKED_RECORDS_MAX),
      hiddenLinkCount: Math.max(0, found.length - LINKED_RECORDS_MAX),
    };
  }

  const subject = describeSubject(payload);
  const collections = readCollections(payload);
  const parts = [
    ...(subject?.figures ?? []),
    ...lifecycleFigures(payload),
    ...collections.counts,
  ];

  const found = [
    ...(subject && subject.id ? [{ kind: subject.kind, id: subject.id, label: subject.name }] : []),
    ...collections.links,
  ];

  return {
    headline: composeHeadline(subject?.name ?? null, parts),
    links: found.slice(0, LINKED_RECORDS_MAX),
    hiddenLinkCount: Math.max(0, found.length - LINKED_RECORDS_MAX),
  };
}

/**
 * A write tool's own account of itself.
 *
 * The three statuses come straight from `PigWriteDetails` in the agent, and the
 * sentence deliberately says the same thing the approval card two elements
 * below says. Two surfaces describing one decision have to agree; before this
 * they did not, and the one that disagreed was the one wearing a green check.
 *
 * `reason` is quoted rather than paraphrased for `refused`, because the reason
 * is a permission the person holds ("you cannot change a contract") and only
 * the server knows which one it was.
 */
function writeOutcome(payload: Record<string, unknown>): Evidence | null {
  const status = asString(payload.status);
  if (status !== 'applied' && status !== 'declined' && status !== 'refused') return null;
  // `tool` and `kind` are the shape's fingerprint: a read payload could carry a
  // `status` column off a record row (a contract's status is "executed"), and
  // that must not be read as a write outcome.
  if (!asString(payload.tool)) return null;

  const reason = asString(payload.reason);
  const headline =
    status === 'applied'
      ? 'Saved to PIG.'
      : status === 'declined'
        ? 'Not saved. You declined this change.'
        : reason
          ? `Not saved. ${reason}`
          : 'Not saved.';

  return { headline, links: [], hiddenLinkCount: 0, outcome: status };
}

/**
 * The rows behind a headline, where the tool kept their ids.
 *
 * The lookup tools are the reason this exists. `pig_search_records` answers
 * "which Meridian?" with typed ids, and `pig_list_renewals` with contract ids —
 * the very records the answer rests on — and until they were read here a search
 * chip proved nothing but its own sentence, on the page whose whole claim is
 * that the records behind an answer can be opened. The page tools are untouched:
 * they carry no `results` or `renewals`, so they still summarise to a sentence.
 */
function readHeadlineLinks(payload: Record<string, unknown>): EvidenceRecord[] {
  const links: EvidenceRecord[] = [];
  // A search hit names its own type, because a search spans five tables.
  for (const row of asArray(payload.results)) {
    const record = asRecord(row);
    const kind = record && asRecordKind(record.type);
    const id = record && asString(record.id);
    const label = record && recordName(record);
    if (kind && id && label) links.push({ kind, id, label });
  }
  // A renewal is always a contract, and says so by carrying no type at all.
  for (const row of asArray(payload.renewals)) {
    const record = asRecord(row);
    const id = record && asString(record.id);
    const label = record && recordName(record);
    if (id && label) links.push({ kind: 'contract', id, label });
  }
  return links;
}

/**
 * A middot separates the name from the figures, not an em dash. PIG's own
 * record names are full of em dashes — "DEMO — MSA — coreweave.com" is a real
 * one — and a second em dash makes the name and the evidence read as one
 * run-on title.
 */
function composeHeadline(name: string | null, parts: string[]): string | null {
  if (name && parts.length) return `${name} · ${parts.join(', ')}`;
  if (name) return name;
  return parts.length ? parts.join(', ') : null;
}

interface Subject {
  kind: RecordKind;
  id: string | null;
  name: string;
  /** The one or two facts worth putting beside the name. */
  figures: string[];
}

/**
 * The record the payload is *about*.
 *
 * Order matters: a contact result also carries its account, and a deal result
 * carries both, so the most specific key wins. `readFocusedRecord` is the only
 * producer of these shapes and each one names its subject differently, which
 * is why this is a lookup rather than a discriminant.
 */
function describeSubject(payload: Record<string, unknown>): Subject | null {
  const contact = asRecord(payload.contact);
  if (contact) return subjectOf('contact', contact, [asString(contact.title)]);

  const deal = asRecord(payload.deal);
  if (deal) {
    // The two deal shapes are told apart by the sibling array rather than by
    // sniffing columns: `readFocusedRecord` returns `commitments` beside a
    // supply deal and `allocations` beside a demand one.
    return Array.isArray(payload.commitments)
      ? subjectOf('supply_deal', deal, [hardwareFigure(deal), costFigure(deal.targetCostPerGpuHourCents)])
      : subjectOf('demand_deal', deal, [dealValueFigure(deal)]);
  }

  const commitment = asRecord(payload.commitment);
  if (commitment) {
    return subjectOf('commitment', commitment, [
      hardwareFigure(commitment),
      costFigure(commitment.costPerGpuHourCents),
    ]);
  }

  const contract = asRecord(payload.contract);
  if (contract) return subjectOf('contract', contract, [asString(contract.status)]);

  const account = asRecord(payload.account);
  if (account) return subjectOf('account', account, []);

  return null;
}

function subjectOf(
  kind: RecordKind,
  record: Record<string, unknown>,
  figures: (string | null)[],
): Subject {
  return {
    kind,
    id: asString(record.id),
    name: recordName(record) ?? `Unnamed ${RECORD_LABELS[kind]}`,
    figures: figures.filter((figure): figure is string => figure !== null),
  };
}

interface Collection {
  key: string;
  /** Null where the rows have nowhere to link to — no route lists them. */
  kind: RecordKind | null;
  one: string;
  many: string;
}

/** Every named array `readFocusedRecord` can return, in the order it reads them. */
const COLLECTIONS: readonly Collection[] = [
  { key: 'contacts', kind: 'contact', one: 'contact', many: 'contacts' },
  { key: 'demandDeals', kind: 'demand_deal', one: 'demand deal', many: 'demand deals' },
  { key: 'supplyDeals', kind: 'supply_deal', one: 'supply deal', many: 'supply deals' },
  { key: 'contracts', kind: 'contract', one: 'contract', many: 'contracts' },
  { key: 'commitments', kind: 'commitment', one: 'commitment', many: 'commitments' },
  { key: 'allocations', kind: 'allocation', one: 'allocation', many: 'allocations' },
  { key: 'slaTerms', kind: null, one: 'SLA term', many: 'SLA terms' },
  { key: 'slaMetricTargets', kind: null, one: 'SLA target', many: 'SLA targets' },
  { key: 'obligations', kind: null, one: 'obligation', many: 'obligations' },
];

function readCollections(payload: Record<string, unknown>): {
  counts: string[];
  links: EvidenceRecord[];
} {
  const counts: string[] = [];
  const links: EvidenceRecord[] = [];

  for (const collection of COLLECTIONS) {
    const rows = payload[collection.key];
    if (!Array.isArray(rows)) continue;
    // Zero is reported rather than skipped. "0 contracts" is the difference
    // between Piggy having looked and found nothing and Piggy never having
    // looked, and that distinction is the whole point of showing the working.
    counts.push(`${rows.length} ${rows.length === 1 ? collection.one : collection.many}`);

    const kind = collection.kind;
    if (!kind) continue;
    for (const row of rows) {
      const record = asRecord(row);
      const id = record && asString(record.id);
      if (!record || !id) continue;
      // An allocation has no name of its own, so it is labelled by kind and a
      // short id rather than by a bare hex string nobody can place.
      const label = recordName(record) ?? `${collection.one} ${id.slice(0, 8)}`;
      links.push({ kind, id, label });
    }
  }

  return { counts, links };
}

/**
 * The lifecycle tool returns a score rather than rows, and the score is what
 * the answer will have quoted — so it belongs in the summary beside the name.
 */
function lifecycleFigures(payload: Record<string, unknown>): string[] {
  const lifecycle = asRecord(payload.lifecycle);
  if (!lifecycle) return [];
  const figures: string[] = [];
  const score = asNumber(lifecycle.score);
  if (score !== null) figures.push(`lifecycle score ${Math.round(score)}`);
  const state = asString(lifecycle.relationshipState);
  if (state) figures.push(state.replaceAll('_', ' '));
  const blockers = Array.isArray(lifecycle.blockers) ? lifecycle.blockers.length : 0;
  if (blockers > 0) figures.push(`${blockers} blocker${blockers === 1 ? '' : 's'}`);
  return figures;
}

/** Accounts and deals carry `name`, contacts `fullName`, contracts `title`. */
function recordName(record: Record<string, unknown>): string | null {
  return asString(record.name) ?? asString(record.fullName) ?? asString(record.title);
}

function hardwareFigure(record: Record<string, unknown>): string | null {
  const count = asNumber(record.gpuCount);
  const type = asString(record.gpuType);
  if (count !== null && type) return `${count}× ${type}`;
  if (type) return type;
  return count === null ? null : `${count} GPUs`;
}

/**
 * Every `…Cents` column is an integer of US cents, so it goes through the app's
 * own formatters rather than being divided by a hundred here for the second
 * time in the codebase. `unitPrice` and not `money`, because this is a price
 * per GPU-hour: `money` drops the cents when they happen to be round, and $1.89
 * against $2 is the difference between a quotable figure and a rounded one.
 */
function costFigure(value: unknown): string | null {
  const cents = asNumber(value);
  return cents === null ? null : `${unitPrice(cents)}/GPU-hour`;
}

/** Total contract value where it is known, annual value otherwise — the same
 *  precedence `readPipeline` uses, so the two never disagree about a deal. */
function dealValueFigure(deal: Record<string, unknown>): string | null {
  const cents = asNumber(deal.tcvCents) ?? asNumber(deal.acvCents);
  return cents === null ? null : money(cents);
}

// ------------------------------------------------------------------ payloads

/**
 * The Input section, or nothing at all.
 *
 * Most Piggy tools declare `z.object({}).strict()`, so an unconditional input
 * panel prints `{}` under nearly every chip. A malformed tool call arrives as
 * the unparsed string the model emitted — that is what makes it malformed — so
 * it is shown verbatim instead of being stringified into a quoted one-liner.
 */
function formatArguments(value: unknown): string | null {
  if (value === null || value === undefined) return null;
  if (typeof value === 'string') return value.trim() || null;
  const record = asRecord(value);
  if (record && Object.keys(record).length === 0) return null;
  return formatPayload(value);
}

function formatPayload(value: unknown): string | null {
  if (value === undefined) return null;
  let text: string;
  try {
    text = JSON.stringify(value, null, 2) ?? String(value);
  } catch {
    // A payload that cannot be serialised must not take the transcript down
    // with it: the answer above it is still worth reading.
    return null;
  }
  return text.length > RAW_PAYLOAD_MAX_CHARS
    ? `${text.slice(0, RAW_PAYLOAD_MAX_CHARS)}\n\n… shortened for display. Piggy read the whole payload.`
    : text;
}

/**
 * Below this the transcript stops quoting a figure and admits a floor instead.
 *
 * The clock is the gap between the `tool_call` and `tool_result` lines arriving
 * on the stream, which carries the event loop and the NDJSON parse along with
 * the query, so a millisecond reading would claim a precision this timing does
 * not have. A tenth of a second is the finest thing it can honestly say.
 */
const DURATION_FLOOR_MS = 100;

/**
 * One decimal below ten seconds: most calls land under a second, where "0.4s"
 * carries more than "0s".
 *
 * Under the floor it reads "<0.1s" rather than rounding to "0.0s". Against a
 * database on the same host nearly every real Piggy tool call lands there, and
 * "0.0s" turned the one number that exists to show the call took time into
 * something that reads as a failed measurement.
 */
function formatDuration(ms: number): string {
  if (ms < DURATION_FLOOR_MS) return '<0.1s';
  return ms < 10_000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms / 1000)}s`;
}

// ------------------------------------------------------------------- reading
//
// The payload is `unknown` and must stay that way. It crossed a network from a
// process that is free to change its tool return shapes without telling the
// browser, so every field is read through a guard and a shape that has drifted
// costs a missing line rather than a blank transcript.

function asRecord(value: unknown): Record<string, unknown> | null {
  return typeof value === 'object' && value !== null && !Array.isArray(value)
    ? (value as Record<string, unknown>)
    : null;
}

function asString(value: unknown): string | null {
  return typeof value === 'string' && value.trim() ? value : null;
}

function asNumber(value: unknown): number | null {
  return typeof value === 'number' && Number.isFinite(value) ? value : null;
}

function asArray(value: unknown): unknown[] {
  return Array.isArray(value) ? (value as unknown[]) : [];
}

/** A record type the transcript knows how to open, or nothing. */
function asRecordKind(value: unknown): RecordKind | null {
  const key = asString(value);
  return key !== null && key in RECORD_LABELS ? (key as RecordKind) : null;
}
