/**
 * The structured half of a template, rendered per kind.
 *
 * `fields` is authored JSON. It is not validated on the way out of the
 * database, it is edited by anyone with `motion:write`, it survives promotion
 * from an engagement artefact unchanged, and it will outlive whatever shape
 * this file expects today. So every access below is narrowed, every list is
 * filtered to the entries that carry the property being rendered, and a shape
 * this file does not recognise renders as nothing at all.
 *
 * That last rule is the important one. The alternative — assume the shape and
 * let the page throw — turns one badly-typed seed row into a blank library for
 * everybody, and the crash surfaces in a route far from the row that caused it.
 * An omitted section is a bug someone reports; a white screen is an outage.
 *
 * A qualification framework's dimensions are shown with their weights and
 * their 0–4 anchors, because the anchors are what stop a score being a vibe:
 * the number only means something if two people reading the same evidence pick
 * the same one.
 */
import { type ReactNode } from 'react';
import { Skull, TriangleAlert } from 'lucide-react';
import { DEMAND_STAGE_LABELS, type DemandStage, type MotionKind } from '@pig/core';
import { Badge, cn } from '@/components/ui';
import { CodeBlock } from './CodeBlock';
import { anchorScale, asRecord, number, recordList, text, textList } from './fields';

export function FieldsView({
  kind,
  fields,
  className,
}: {
  kind: MotionKind;
  /** Untrusted. `unknown` on purpose — see the header. */
  fields: unknown;
  className?: string;
}) {
  const record = asRecord(fields);
  if (!record) return null;

  // Emptiness is decided inside each kind's renderer, not here: `renderKind`
  // hands back an element, and an element is truthy however little it draws.
  // A guard at this level could only ever read as one and never fire.
  return <div className={cn('min-w-0 space-y-5', className)}>{renderKind(kind, record)}</div>;
}

function renderKind(kind: MotionKind, fields: Record<string, unknown>): ReactNode {
  switch (kind) {
    case 'discovery':
      return <DiscoveryFields fields={fields} />;
    case 'qualification':
      return <QualificationFields fields={fields} />;
    case 'poc':
      return <PocFields fields={fields} />;
    case 'proposal':
      return <ProposalFields fields={fields} />;
    case 'pricing':
      return <PricingFields fields={fields} />;
    case 'architecture':
      return <ArchitectureFields fields={fields} />;
    case 'case_study':
      return <CaseStudyFields fields={fields} />;
    case 'narrative':
      return <NarrativeFields fields={fields} />;
    case 'playbook':
      return <PlaybookFields fields={fields} />;
  }
}

// ------------------------------------------------------------------ the kinds

function DiscoveryFields({ fields }: { fields: Record<string, unknown> }) {
  const sections = recordList(fields.sections);
  const decisions = recordList(fields.decisions);
  const blockingSet = recordList(fields.blockingSet);
  if (sections.length === 0 && decisions.length === 0 && blockingSet.length === 0) return null;

  const blockingCount = decisions.filter((decision) => decision.blocking === true).length;

  return (
    <>
      {sections.map((section, index) => {
        const name = text(section.name);
        if (!name) return null;
        const questions = recordList(section.questions);
        return (
          <Section key={index} title={name}>
            <Note label="Goal" value={text(section.goal)} />
            <Note label="A bad answer" value={text(section.badAnswer)} tone="warning" />
            {questions.length === 0 ? null : (
              <ol className="min-w-0 space-y-3">
                {questions.map((question, questionIndex) => {
                  const asked = text(question.q);
                  if (!asked) return null;
                  return (
                    <li key={questionIndex} className="min-w-0 rounded-lg border border-border p-3">
                      <p className="min-w-0 break-words font-medium leading-6">{asked}</p>
                      <Note label="Why" value={text(question.why)} />
                      <Note label="Listen for" value={text(question.listenFor)} />
                    </li>
                  );
                })}
              </ol>
            )}
          </Section>
        );
      })}

      {decisions.length === 0 ? null : (
        <Section
          title="Decisions"
          // A decision brief is read to find what is still open, and a blocking
          // row is a different object from the rest: it ends the deal rather
          // than delaying it, so the count leads.
          aside={blockingCount > 0 ? `${blockingCount} of ${decisions.length} blocking` : undefined}
        >
          <div className="min-w-0 space-y-3">
            {decisions.map((decision, index) => {
              const question = text(decision.question);
              if (!question) return null;
              const area = text(decision.area);
              const id = text(decision.id);
              const options = recordList(decision.options);
              return (
                <div key={index} className="min-w-0 rounded-lg border border-border p-3">
                  <div className="flex min-w-0 flex-wrap items-center gap-2">
                    {area ? <Badge tone="neutral">{area}</Badge> : null}
                    {decision.blocking === true ? <Badge tone="danger">Blocking</Badge> : null}
                    {/* The blocking set below names decisions by id, so the id
                        is content here rather than a React key. */}
                    {id ? <span className="whitespace-nowrap text-xs text-muted">{id}</span> : null}
                  </div>
                  <p className="mt-2 min-w-0 break-words font-medium leading-6">{question}</p>
                  <Note label="Answered by" value={text(decision.answeredBy)} />
                  {options.length === 0 ? null : (
                    <ul className="mt-3 min-w-0 space-y-3 border-t border-border pt-3">
                      {options.map((option, optionIndex) => {
                        const choice = text(option.option);
                        if (!choice) return null;
                        const verdict = text(option.verdict);
                        const escalatesTo = text(option.escalatesTo);
                        return (
                          <li key={optionIndex} className="min-w-0">
                            <div className="flex min-w-0 flex-wrap items-center gap-2">
                              {verdict ? <Badge tone={verdictTone(verdict)}>{verdict}</Badge> : null}
                              {/* Who answers follows the option chosen, not the
                                  topic: a lead who settles a weights question
                                  off the cuff has priced nothing. */}
                              {escalatesTo ? (
                                <span className="whitespace-nowrap text-xs text-muted">
                                  escalates to {escalatesTo}
                                </span>
                              ) : null}
                            </div>
                            <p className="mt-1.5 min-w-0 break-words text-sm leading-6">{choice}</p>
                            <Note label="Costs" value={text(option.costs)} />
                          </li>
                        );
                      })}
                    </ul>
                  )}
                </div>
              );
            })}
          </div>
        </Section>
      )}

      {blockingSet.length === 0 ? null : (
        <Section title="The blocking set" icon={<Skull className="size-4" aria-hidden />}>
          <ul className="min-w-0 space-y-3">
            {blockingSet.map((entry, index) => {
              const item = text(entry.item);
              if (!item) return null;
              return (
                <li key={index} className="min-w-0">
                  <p className="min-w-0 break-words font-medium leading-6">{item}</p>
                  <Pills label="Decisions" items={textList(entry.decisionIds)} />
                  <Note label="Why it kills the deal" value={text(entry.whyItKills)} tone="warning" />
                </li>
              );
            })}
          </ul>
        </Section>
      )}
    </>
  );
}

function QualificationFields({ fields }: { fields: Record<string, unknown> }) {
  const dimensions = recordList(fields.dimensions);
  const bands = recordList(fields.bands);
  const disqualifiers = recordList(fields.disqualifiers);
  if (dimensions.length === 0 && bands.length === 0 && disqualifiers.length === 0) return null;

  const weightTotal = dimensions.reduce((total, dimension) => total + (number(dimension.weight) ?? 0), 0);

  return (
    <>
      {dimensions.length === 0 ? null : (
        <Section
          title="Dimensions"
          // Weights need not sum to 100 — the score is a proportion of the
          // maximum — so the total is shown rather than assumed.
          aside={weightTotal > 0 ? `${weightTotal} weight in total` : undefined}
        >
          <div className="min-w-0 space-y-3">
            {dimensions.map((dimension, index) => {
              const name = text(dimension.name);
              if (!name) return null;
              const weight = number(dimension.weight);
              const group = text(dimension.group);
              return (
                <div key={index} className="min-w-0 rounded-lg border border-border p-3">
                  <div className="flex min-w-0 flex-wrap items-center gap-2">
                    <span className="min-w-0 break-words font-medium leading-6">{name}</span>
                    {group ? <Badge tone="neutral">{group}</Badge> : null}
                    {weight === null ? null : (
                      <span className="nums whitespace-nowrap text-xs text-muted">weight {weight}</span>
                    )}
                  </div>
                  <Note label="Why it predicts" value={text(dimension.why)} />
                  <Anchors anchors={dimension.anchors} />
                </div>
              );
            })}
          </div>
        </Section>
      )}

      {bands.length === 0 ? null : (
        // Not "what each score means": these bands are the framework author's
        // own calibration on their own scale, and `MOTION_BANDS` is the
        // product's — four bands in basis points, shown as a badge on the same
        // page. The shipped scorecard's five bands genuinely disagree with it
        // at 7500 (Strategic, against "do not start compute"), so a heading
        // that read as the product's verdict put two opposite instructions
        // about one number on one screen.
        <Section title="What this framework says to do at each score">
          <ul className="min-w-0 space-y-2">
            {bands.map((band, index) => {
              const label = text(band.label);
              if (!label) return null;
              const min = number(band.min);
              const max = number(band.max);
              return (
                <li key={index} className="min-w-0">
                  <span className="font-medium">{label}</span>
                  {min === null || max === null ? null : (
                    <span className="nums ml-2 text-xs text-muted">
                      {min}–{max}
                    </span>
                  )}
                  <Note label="Action" value={text(band.action)} />
                </li>
              );
            })}
          </ul>
        </Section>
      )}

      {disqualifiers.length === 0 ? null : (
        <Section title="Disqualifiers" icon={<Skull className="size-4" aria-hidden />}>
          <ul className="min-w-0 space-y-3">
            {disqualifiers.map((disqualifier, index) => {
              const name = text(disqualifier.name);
              if (!name) return null;
              return (
                <li key={index} className="min-w-0">
                  <p className="min-w-0 break-words font-medium leading-6">{name}</p>
                  <Note label="Test" value={text(disqualifier.test)} />
                  <Note label="Why" value={text(disqualifier.why)} />
                </li>
              );
            })}
          </ul>
        </Section>
      )}
    </>
  );
}

/** The 0–4 scale, extracted by the same rule the scorer reads it with. */
function Anchors({ anchors }: { anchors: unknown }) {
  const rows = anchorScale(anchors);
  if (rows.length === 0) return null;

  return (
    <dl className="mt-3 min-w-0 space-y-1.5 border-t border-border pt-3">
      {rows.map((row) => (
        <div key={row.score} className="flex min-w-0 gap-3">
          <dt className="nums w-5 shrink-0 text-sm font-semibold text-muted">{row.score}</dt>
          <dd className="min-w-0 break-words text-sm leading-6">{row.anchor}</dd>
        </div>
      ))}
    </dl>
  );
}

function PocFields({ fields }: { fields: Record<string, unknown> }) {
  const hypothesis = text(fields.hypothesis);
  const milestones = recordList(fields.milestones);
  const metrics = recordList(fields.successMetrics);
  const risks = recordList(fields.risks);
  if (!hypothesis && milestones.length === 0 && metrics.length === 0 && risks.length === 0) return null;

  return (
    <>
      {hypothesis ? (
        <Section title="Hypothesis">
          <p className="min-w-0 break-words leading-6">{hypothesis}</p>
        </Section>
      ) : null}

      {milestones.length === 0 ? null : (
        <Section title="Milestones">
          <ol className="min-w-0 space-y-3">
            {milestones.map((milestone, index) => {
              const title = text(milestone.title);
              if (!title) return null;
              return (
                <li key={index} className="min-w-0 rounded-lg border border-border p-3">
                  <div className="flex min-w-0 flex-wrap items-center gap-2">
                    {text(milestone.week) ? (
                      <Badge tone="neutral">{text(milestone.week)}</Badge>
                    ) : null}
                    <span className="min-w-0 break-words font-medium leading-6">{title}</span>
                    {/* A kill gate is the only reason a POC ends early rather
                        than drifting into a second quarter, so it is the one
                        property here that earns a colour. */}
                    {milestone.killGate === true ? <Badge tone="danger">Kill gate</Badge> : null}
                  </div>
                  <Note label="Exit criterion" value={text(milestone.exitCriterion)} />
                  <Note label="Owner" value={text(milestone.owner)} />
                </li>
              );
            })}
          </ol>
        </Section>
      )}

      {metrics.length === 0 ? null : (
        <Section title="Success metrics">
          <ScrollTable
            head={['Metric', 'Baseline', 'Target', 'Measured by']}
            rows={metrics.map((metric) => [
              text(metric.metric),
              text(metric.baseline),
              text(metric.target),
              text(metric.measuredBy),
            ])}
          />
        </Section>
      )}

      {risks.length === 0 ? null : (
        <Section title="Risks" icon={<TriangleAlert className="size-4" aria-hidden />}>
          <ScrollTable
            head={['Risk', 'Owner', 'Mitigation']}
            rows={risks.map((risk) => [text(risk.risk), text(risk.owner), text(risk.mitigation)])}
          />
        </Section>
      )}
    </>
  );
}

function ProposalFields({ fields }: { fields: Record<string, unknown> }) {
  const blocks = recordList(fields.blocks);
  if (blocks.length === 0) return null;

  return (
    <>
      {blocks.map((block, index) => {
        const title = text(block.title);
        if (!title) return null;
        const body = text(block.text);
        return (
          <Section key={index} title={title}>
            <Note label="Use when" value={text(block.useWhen)} />
            <Note label="Avoid when" value={text(block.avoidWhen)} tone="warning" />
            {body ? (
              // Proposal blocks are lifted verbatim into a document, so the
              // whitespace the author wrote is part of the block.
              <p className="min-w-0 whitespace-pre-wrap break-words rounded-lg border border-border bg-surface-2 p-3 text-sm leading-6">
                {body}
              </p>
            ) : null}
          </Section>
        );
      })}
    </>
  );
}

function PricingFields({ fields }: { fields: Record<string, unknown> }) {
  const inputs = recordList(fields.inputs);
  const packages = recordList(fields.packages);
  const tradeables = recordList(fields.tradeables);
  const budgetSources = recordList(fields.budgetSources);
  const forecast = asRecord(fields.computeForecast);
  const questionnaire = recordList(fields.questionnaireMap);
  const justification = asRecord(fields.soleSourceJustification);
  const steps = recordList(fields.steps);
  const championHomework = recordList(fields.championHomework);
  if (
    inputs.length === 0 &&
    packages.length === 0 &&
    tradeables.length === 0 &&
    budgetSources.length === 0 &&
    !forecast &&
    questionnaire.length === 0 &&
    !justification &&
    steps.length === 0 &&
    championHomework.length === 0
  ) {
    return null;
  }

  // Serial is the number the champion's own calendar produces if nobody runs
  // anything beside anything else, and it is what makes the `parallelWith`
  // column below worth reading.
  const serialDays = steps.reduce((total, step) => total + (number(step.typicalDays) ?? 0), 0);

  return (
    <>
      {inputs.length === 0 ? null : (
        <Section title="Inputs">
          <div className="min-w-0 space-y-3">
            {inputs.map((input, index) => {
              const name = text(input.name);
              if (!name) return null;
              const unit = text(input.unit);
              return (
                <div key={index} className="min-w-0 rounded-lg border border-border p-3">
                  <div className="flex min-w-0 flex-wrap items-center gap-2">
                    <span className="min-w-0 break-words font-medium leading-6">{name}</span>
                    {unit ? <Badge tone="neutral">{unit}</Badge> : null}
                  </div>
                  <Note label="Where it comes from" value={text(input.howToGet)} />
                  <Note label="Why it matters" value={text(input.whyItMatters)} />
                </div>
              );
            })}
          </div>
        </Section>
      )}

      {packages.length === 0 ? null : (
        <Section title="Packages">
          <ScrollTable
            head={['Package', 'Shape', 'Fits when', 'Fails when']}
            rows={packages.map((entry) => [
              text(entry.name),
              text(entry.shape),
              text(entry.fitsWhen),
              text(entry.failsWhen),
            ])}
          />
        </Section>
      )}

      {tradeables.length === 0 ? null : (
        <Section title="What to trade">
          <ScrollTable
            head={['Give', 'Get']}
            rows={tradeables.map((entry) => [text(entry.give), text(entry.get)])}
          />
        </Section>
      )}

      {budgetSources.length === 0 ? null : (
        <Section title="Where the money comes from">
          <div className="min-w-0 space-y-3">
            {budgetSources.map((entry, index) => {
              const source = text(entry.source);
              if (!source) return null;
              const days = number(entry.typicalDays);
              return (
                <div key={index} className="min-w-0 rounded-lg border border-border p-3">
                  <div className="flex min-w-0 flex-wrap items-center gap-2">
                    <span className="min-w-0 break-words font-medium leading-6">{source}</span>
                    {entry.fastest === true ? <Badge tone="positive">Fastest</Badge> : null}
                    {days === null ? null : (
                      <span className="nums whitespace-nowrap text-xs text-muted">~{days} days</span>
                    )}
                  </div>
                  <Note label="Approvers" value={text(entry.approvers)} />
                  {/* Speed and durability are different questions and the
                      fastest source is routinely the least durable one, so
                      neither is shown without the other. */}
                  <Note label="Durability" value={text(entry.durability)} />
                  <Note label="Durability test" value={text(entry.durabilityTest)} />
                  <Note label="Watch for" value={text(entry.watchFor)} tone="warning" />
                </div>
              );
            })}
          </div>
        </Section>
      )}

      {forecast ? (
        <Section title="Compute forecast">
          <Note label="Audience" value={text(forecast.audience)} />
          <ScrollTable
            head={['Line', 'Low', 'Expected', 'High']}
            rows={recordList(forecast.lines).map((line) => [
              text(line.line),
              text(line.low),
              text(line.expected),
              text(line.high),
            ])}
          />
          <Note label="Ceiling rule" value={text(forecast.ceilingRule)} />
          {/* Cost is charged against the full commitment, not the hours that
              sold — AGENTS.md §4 — so the rule travels with the forecast that
              tempts a reader to model it the other way. */}
          <Note label="Commitment rule" value={text(forecast.commitmentRule)} />
        </Section>
      ) : null}

      {questionnaire.length === 0 ? null : (
        <Section title="AI vendor questionnaire">
          <ScrollTable
            head={['Topic', 'Answered from']}
            rows={questionnaire.map((entry) => [text(entry.topic), text(entry.source)])}
          />
        </Section>
      )}

      {justification ? (
        <Section title="Sole-source justification">
          <Note label="When it is needed" value={text(justification.whenNeeded)} />
          {recordList(justification.paragraphs).map((paragraph, index) => {
            const heading = text(paragraph.heading);
            if (!heading) return null;
            const draft = text(paragraph.draft);
            return (
              <div key={index} className="mt-3 min-w-0">
                <p className="min-w-0 break-words text-sm font-medium leading-6">{heading}</p>
                {draft ? (
                  // The champion pastes this into their own requisition, so
                  // the whitespace the author wrote is part of the paragraph.
                  <p className="mt-1.5 min-w-0 whitespace-pre-wrap break-words rounded-lg border border-border bg-surface-2 p-3 text-sm leading-6">
                    {draft}
                  </p>
                ) : null}
              </div>
            );
          })}
          <Note label="Note" value={text(justification.note)} />
        </Section>
      ) : null}

      {steps.length === 0 ? null : (
        <Section
          title="The critical path"
          aside={serialDays > 0 ? `${serialDays} days end to end, serially` : undefined}
        >
          <ol className="min-w-0 space-y-3">
            {steps.map((entry, index) => {
              const step = text(entry.step);
              if (!step) return null;
              const days = number(entry.typicalDays);
              return (
                <li key={index} className="min-w-0 rounded-lg border border-border p-3">
                  <div className="flex min-w-0 flex-wrap items-center gap-2">
                    <span className="min-w-0 break-words font-medium leading-6">{step}</span>
                    {days === null ? null : (
                      <span className="nums whitespace-nowrap text-xs text-muted">~{days} days</span>
                    )}
                  </div>
                  <Note label="Produces" value={text(entry.produces)} />
                  <Note label="Runs beside" value={text(entry.parallelWith)} />
                  {/* Procurement never says no, it goes quiet, and the stated
                      reason for the silence is almost never the real one — so
                      the symptom and the move are the part that gets read. */}
                  <Note label="Stall symptom" value={text(entry.stallSymptom)} tone="warning" />
                  <Note label="Unstick move" value={text(entry.unstickMove)} />
                </li>
              );
            })}
          </ol>
        </Section>
      )}

      {championHomework.length === 0 ? null : (
        <Section title="The champion's homework">
          <ScrollTable
            head={['What they do', 'Why']}
            rows={championHomework.map((entry) => [text(entry.task), text(entry.why)])}
          />
        </Section>
      )}
    </>
  );
}

function ArchitectureFields({ fields }: { fields: Record<string, unknown> }) {
  const architectures = recordList(fields.architectures);
  if (architectures.length === 0) return null;

  return (
    <>
      {architectures.map((architecture, index) => {
        const name = text(architecture.name);
        if (!name) return null;
        const components = recordList(architecture.components);
        return (
          <Section key={index} title={name}>
            <Note label="Fits when" value={text(architecture.fitsWhen)} />
            <Note label="Fails when" value={text(architecture.failureMode)} tone="warning" />
            <Diagram source={text(architecture.mermaid)} name={name} />
            {components.length === 0 ? null : (
              <ScrollTable
                head={['Component', 'Run by', 'Why']}
                rows={components.map((component) => [
                  text(component.component),
                  text(component.runBy),
                  text(component.why),
                ])}
              />
            )}
          </Section>
        );
      })}
    </>
  );
}

/**
 * The diagram, as source rather than as a picture — deliberately, for now. See
 * `CodeBlock` for why, and for the copy button that makes it usable.
 */
function Diagram({ source, name }: { source: string | null; name: string }) {
  if (!source) return null;
  return <CodeBlock source={source} language="mermaid" label={`${name} diagram`} />;
}

function CaseStudyFields({ fields }: { fields: Record<string, unknown> }) {
  const sections = recordList(fields.sections);
  const harvest = recordList(fields.harvest);
  if (sections.length === 0 && harvest.length === 0) return null;

  return (
    <>
      {sections.length === 0 ? null : (
        <Section title="Sections">
          <ol className="min-w-0 space-y-3">
            {sections.map((section, index) => {
              const name = text(section.name);
              if (!name) return null;
              return (
                <li key={index} className="min-w-0 rounded-lg border border-border p-3">
                  <p className="min-w-0 break-words font-medium leading-6">{name}</p>
                  <Note label="What goes here" value={text(section.prompt)} />
                  {/* Evidence rules are the reason a case study can be shown to
                      the next customer at all — see AGENTS.md §4. */}
                  <Note label="Evidence rule" value={text(section.evidenceRule)} />
                </li>
              );
            })}
          </ol>
        </Section>
      )}

      {harvest.length === 0 ? null : (
        <Section title="When to capture it">
          <ScrollTable
            head={['When', 'Capture', 'Why']}
            rows={harvest.map((entry) => [text(entry.when), text(entry.capture), text(entry.why)])}
          />
        </Section>
      )}
    </>
  );
}

function NarrativeFields({ fields }: { fields: Record<string, unknown> }) {
  const narratives = recordList(fields.narratives);
  if (narratives.length === 0) return null;

  return (
    <>
      {narratives.map((narrative, index) => {
        const audience = text(narrative.audience);
        if (!audience) return null;
        const body = text(narrative.text);
        return (
          <Section key={index} title={audience}>
            <Note label="What they already believe" value={text(narrative.belief)} />
            {body ? (
              <p className="min-w-0 whitespace-pre-wrap break-words rounded-lg border border-border bg-surface-2 p-3 text-sm leading-6">
                {body}
              </p>
            ) : null}
            <Note label="Analogy that works" value={text(narrative.analogyThatWorks)} />
            <Note label="Analogy that fails" value={text(narrative.analogyThatFails)} tone="warning" />
            <Note label="What they ask next" value={text(narrative.nextQuestion)} />
            <Note label="Answer" value={text(narrative.answer)} />
          </Section>
        );
      })}
    </>
  );
}

function PlaybookFields({ fields }: { fields: Record<string, unknown> }) {
  const stages = recordList(fields.stages);
  const research = asRecord(fields.researchInterface);
  const promotion = recordList(fields.promotion);
  const checks = recordList(fields.checks);
  const fallbacks = recordList(fields.fallbacks);
  const firstThirtyDays = recordList(fields.firstThirtyDays);
  if (
    stages.length === 0 &&
    !research &&
    promotion.length === 0 &&
    checks.length === 0 &&
    fallbacks.length === 0 &&
    firstThirtyDays.length === 0
  ) {
    return null;
  }

  const gateCount = checks.filter((check) => check.gate === true).length;

  return (
    <>
      {stages.map((entry, index) => {
        const stage = stageLabel(entry.stage);
        if (!stage) return null;
        const days = number(entry.typicalDays);
        return (
          <Section key={index} title={stage} aside={days === null ? undefined : `~${days} days`}>
            <Note label="Entry" value={text(entry.entry)} />
            <Note label="What it does" value={text(entry.does)} />
            <Note label="Exit" value={text(entry.exit)} />
            <Pills label="Artefacts" items={textList(entry.artifacts)} />
            <Pills label="Who is involved" items={textList(entry.involves)} />
            {/* Where the stage dies is the part of a playbook that gets read
                twice — the sequence is obvious, the failure is not. */}
            <Note label="How it dies" value={text(entry.diesBy)} tone="warning" />
            <Note label="Stall symptom" value={text(entry.stallSymptom)} tone="warning" />
            <Note label="Unstick move" value={text(entry.unstickMove)} />
          </Section>
        );
      })}

      {research ? (
        <Section title="Research interface">
          <Pills label="Scope must contain" items={textList(research.scopeMustContain)} />
          <Pills label="Owed back" items={textList(research.oweBack)} />
        </Section>
      ) : null}

      {checks.length === 0 ? null : (
        <Section
          title="Readiness checks"
          // A checklist nobody can fail is a document. The gates are what make
          // this one a gate, so their share is stated before the list.
          aside={gateCount > 0 ? `${gateCount} of ${checks.length} are gates` : undefined}
        >
          <ul className="min-w-0 space-y-3">
            {checks.map((entry, index) => {
              const check = text(entry.check);
              if (!check) return null;
              const area = text(entry.area);
              const id = text(entry.id);
              return (
                <li key={index} className="min-w-0 rounded-lg border border-border p-3">
                  <div className="flex min-w-0 flex-wrap items-center gap-2">
                    {area ? <Badge tone="neutral">{area}</Badge> : null}
                    {/* A gate stops the traffic ramp rather than being work
                        somebody catches up on, which is the same reason a
                        POC's kill gate is the one property that earns colour. */}
                    {entry.gate === true ? <Badge tone="danger">Gate</Badge> : null}
                    {/* The fallbacks and the first thirty days name checks by
                        id, so the id is content rather than a React key. */}
                    {id ? <span className="whitespace-nowrap text-xs text-muted">{id}</span> : null}
                  </div>
                  <p className="mt-2 min-w-0 break-words leading-6">{check}</p>
                  <Note label="Evidence" value={text(entry.evidence)} />
                  <Note label="Owner" value={text(entry.owner)} />
                </li>
              );
            })}
          </ul>
        </Section>
      )}

      {fallbacks.length === 0 ? null : (
        <Section title="When it fails" icon={<TriangleAlert className="size-4" aria-hidden />}>
          <ul className="min-w-0 space-y-3">
            {fallbacks.map((entry, index) => {
              const failure = text(entry.failure);
              if (!failure) return null;
              return (
                <li key={index} className="min-w-0 rounded-lg border border-border p-3">
                  <p className="min-w-0 break-words font-medium leading-6">{failure}</p>
                  {/* A response nobody can trigger is not a fallback, so how
                      the failure is detected is shown before what to do. */}
                  <Note label="Detection" value={text(entry.detection)} />
                  <Note label="Response" value={text(entry.response)} />
                </li>
              );
            })}
          </ul>
        </Section>
      )}

      {firstThirtyDays.length === 0 ? null : (
        <Section title="The first thirty days">
          <ol className="min-w-0 space-y-3">
            {firstThirtyDays.map((entry, index) => {
              const when = text(entry.when);
              if (!when) return null;
              return (
                <li key={index} className="min-w-0 rounded-lg border border-border p-3">
                  <p className="min-w-0 break-words font-medium leading-6">{when}</p>
                  <Note label="Watch" value={text(entry.watch)} />
                  <Note label="Escalate if" value={text(entry.escalateIf)} tone="warning" />
                </li>
              );
            })}
          </ol>
        </Section>
      )}

      {promotion.length === 0 ? null : (
        <Section title="What to promote, and when">
          <ScrollTable
            head={['Trigger', 'Promote', 'Into']}
            rows={promotion.map((entry) => [text(entry.trigger), text(entry.promote), text(entry.into)])}
          />
        </Section>
      )}
    </>
  );
}

// ------------------------------------------------------------------- fragments

function Section({
  title,
  aside,
  icon,
  children,
}: {
  title: string;
  aside?: string;
  icon?: ReactNode;
  children: ReactNode;
}) {
  return (
    <section className="min-w-0 rounded-xl border border-border bg-surface p-4">
      <div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
        <h3 className="flex min-w-0 items-center gap-2 break-words font-semibold leading-snug">
          {icon ? <span className="shrink-0 text-muted">{icon}</span> : null}
          {title}
        </h3>
        {aside ? <span className="nums whitespace-nowrap text-xs text-muted">{aside}</span> : null}
      </div>
      <div className="mt-3 min-w-0 space-y-3">{children}</div>
    </section>
  );
}

/** A labelled paragraph. Renders nothing at all when the value is absent. */
function Note({
  label,
  value,
  tone,
}: {
  label: string;
  value: string | null;
  tone?: 'warning';
}) {
  if (!value) return null;
  return (
    <p className="mt-2 min-w-0 break-words text-sm leading-6">
      <span
        className={cn(
          'mr-2 text-xs font-medium uppercase tracking-wide',
          tone === 'warning' ? 'text-warning' : 'text-muted',
        )}
      >
        {label}
      </span>
      {value}
    </p>
  );
}

function Pills({ label, items }: { label: string; items: string[] }) {
  if (items.length === 0) return null;
  return (
    <div className="mt-2 min-w-0">
      <span className="text-xs font-medium uppercase tracking-wide text-muted">{label}</span>
      <div className="mt-1 flex min-w-0 flex-wrap gap-1.5">
        {items.map((item, index) => (
          <Badge key={index} tone="neutral" className="min-w-0">
            <span className="truncate">{item}</span>
          </Badge>
        ))}
      </div>
    </div>
  );
}

/**
 * A table whose overflow stays inside its own box. Rows are dropped when every
 * cell in them is empty, so a partially-authored list does not render as a
 * column of blank stripes.
 */
function ScrollTable({ head, rows }: { head: string[]; rows: (string | null)[][] }) {
  const present = rows.filter((row) => row.some((cell) => cell !== null));
  if (present.length === 0) return null;

  return (
    <div className="scroll-x min-w-0 rounded-lg border border-border">
      <table className="w-max min-w-full border-collapse text-left text-[13px] leading-5">
        <thead className="border-b border-border bg-surface-2">
          <tr>
            {head.map((heading) => (
              <th key={heading} className="whitespace-nowrap px-3 py-2 align-bottom font-medium text-muted">
                {heading}
              </th>
            ))}
          </tr>
        </thead>
        <tbody className="divide-y divide-border">
          {present.map((row, index) => (
            <tr key={index} className="transition-colors hover:bg-surface-2">
              {row.map((cell, cellIndex) => (
                <td key={cellIndex} className="max-w-[28rem] px-3 py-2 align-top">
                  {cell}
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

// -------------------------------------------------------------------- narrowing

/**
 * A decision option's verdict colours its badge. The text stays as the author
 * wrote it and an unrecognised verdict keeps the neutral tone, so a brief that
 * adds a fourth one still reads rather than losing its options to a colour
 * lookup that matched nothing.
 */
function verdictTone(verdict: string): 'positive' | 'danger' | 'neutral' {
  if (verdict === 'recommended') return 'positive';
  if (verdict === 'avoid') return 'danger';
  return 'neutral';
}

/**
 * A playbook stage names a `DemandStage`, and the label comes from `@pig/core`
 * so a renamed stage renames here too. An unrecognised value is shown as
 * written rather than dropped: content authored ahead of an ontology change is
 * still worth reading.
 */
function stageLabel(value: unknown): string | null {
  const raw = text(value);
  if (!raw) return null;
  return DEMAND_STAGE_LABELS[raw as DemandStage] ?? raw;
}
