/**
 * The engagement workspace: one demand deal, worked with the motion.
 *
 * The stage rail is the spine, and it is the *deal's* stages rather than any
 * state of the engagement's own — an engagement that could disagree with its
 * deal about what stage the work is at would be a second answer to a question
 * that already has one.
 *
 * Two actions here are first-class because they are the loop: instantiate a
 * template into a stage, and promote a finished artefact back into the library.
 * Everything else on the page exists to make those two legible — the score
 * history because qualification is a movement rather than a number, and the
 * per-stage grouping because "what did we write at proposal" is the question
 * somebody opening this page a quarter later is actually asking.
 *
 * The loading, missing and failed branches are early returns rather than
 * sibling conditionals. That is the detail-page pattern in this repo
 * (`Account.tsx` is the same shape, 404 branch included), and it differs from
 * the list pages deliberately: a detail page has nothing to render around the
 * hole.
 */
import { useMemo, useState, type ReactNode } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useParams } from 'react-router-dom';
import {
  AlertTriangle,
  ArrowLeft,
  ArrowUpRight,
  ChevronRight,
  FileText,
  Gauge,
  Handshake,
  Library,
  Lock,
  RefreshCw,
  Sparkles,
} from 'lucide-react';
import { toast } from 'sonner';
import {
  ARTIFACT_STATUSES,
  ARTIFACT_STATUS_LABELS,
  DEMAND_OPEN_STAGES,
  DEMAND_STAGES,
  DEMAND_STAGE_LABELS,
  ENGAGEMENT_STATUSES,
  ENGAGEMENT_STATUS_LABELS,
  MOTION_KIND_STAGES,
  toPiggyPageRoute,
  type ArtifactStatus,
  type DemandStage,
  type EngagementStatus,
  type MotionBandTone,
  type MotionKind,
  type MotionVisibility,
} from '@pig/core';
import { ApiError, get, patch, post, shortDate } from '@/lib/api';
import { usePageTitle } from '@/lib/title';
import { usePiggyContext } from '@/lib/piggy-context';
import { useIdentity } from '@/lib/identity';
import { canAny } from '@/lib/permissions';
import {
  Badge,
  Button,
  Card,
  CardContent,
  EmptyState,
  Input,
  Skeleton,
  Stat,
  cn,
} from '@/components/ui';
import { Label } from '@/components/ui/label';
import {
  Select,
  SelectContent,
  SelectGroup,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Textarea } from '@/components/ui/textarea';
import { FieldsView } from '@/components/motion/FieldsView';
import { InstantiateDialog } from '@/components/motion/InstantiateDialog';
import { Markdown } from '@/components/motion/Markdown';
import { MotionKindBadge } from '@/components/motion/MotionKindBadge';
import { QualificationScorer } from '@/components/motion/QualificationScorer';
import { percent } from '@/components/motion/format';
import { StageRail } from '@/components/motion/StageRail';

// --------------------------------------------------------------------- wire

interface ScoreView {
  id: string;
  frameworkTemplateId: string | null;
  dimensions: unknown;
  basisPoints: number;
  band: string;
  tone: MotionBandTone;
  note: string | null;
  scoredByUserId: string | null;
  scoredAt: string;
}

interface ArtifactView {
  id: string;
  engagementId: string;
  templateId: string | null;
  kind: MotionKind;
  stage: DemandStage;
  title: string;
  body: string;
  fields: unknown;
  status: ArtifactStatus;
  authoredByUserId: string | null;
  promotedTemplateId: string | null;
  archivedAt: string | null;
  createdAt: string;
  updatedAt: string;
}

interface EngagementView {
  id: string;
  demandDealId: string;
  dealName: string | null;
  /** The deal's stage. An engagement has none of its own — that is the point. */
  stage: DemandStage | null;
  accountId: string | null;
  accountName: string | null;
  status: EngagementStatus;
  summary: string | null;
  ownerUserId: string | null;
  playbookTemplateId: string | null;
  openedAt: string;
  closedAt: string | null;
  artifactCount: number;
  latestScore: ScoreView | null;
}

interface TemplateSummary {
  id: string;
  kind: MotionKind;
  slug: string;
  version: number;
  title: string;
  summary: string;
  stage: DemandStage;
  visibility: MotionVisibility;
  usageCount: number;
}

/** `GET /api/motion/engagements/:id` — `stages` is all ten, always, in order. */
interface EngagementDetail {
  engagement: EngagementView;
  stages: { stage: DemandStage; artifacts: ArtifactView[] }[];
  scores: ScoreView[];
  playbook: TemplateSummary | null;
}

const WRITE_DENIED = 'Editing this engagement needs the motion:write permission.';
const PUBLISH_DENIED = 'Promoting into the library needs the motion:publish permission.';

// --------------------------------------------------------------------- page

export function Engagement() {
  const { id = '' } = useParams<{ id: string }>();
  const me = useIdentity();
  const mayWrite = canAny(me, 'motion:write');
  const mayPublish = canAny(me, 'motion:publish');
  const [stageFilter, setStageFilter] = useState<DemandStage | null>(null);
  const [instantiating, setInstantiating] = useState<{ kind: MotionKind | null } | null>(null);
  const [scoring, setScoring] = useState(false);
  const [promoting, setPromoting] = useState<ArtifactView | null>(null);

  const detail = useQuery({
    queryKey: ['motion', 'engagements', id, 'detail'],
    queryFn: () => get<EngagementDetail>(`/api/motion/engagements/${id}`),
    enabled: Boolean(id),
    retry: false,
  });

  const engagement = detail.data?.engagement;
  usePageTitle(engagement?.dealName ?? 'Engagement');
  usePiggyContext({
    type: 'page',
    route: toPiggyPageRoute('/motion/engagements'),
    label: engagement?.dealName ? `Engagement — ${engagement.dealName}` : 'Engagement',
  });

  const groups = detail.data?.stages ?? [];
  const counts = useMemo(() => {
    const record: Partial<Record<DemandStage, number>> = {};
    for (const group of groups) record[group.stage] = group.artifacts.length;
    return record;
  }, [groups]);

  if (!canAny(me, 'book:read')) {
    return (
      <Restricted
        icon={<Lock />}
        title="This engagement is restricted"
        description="An engagement sits on a demand deal, and reading the book needs the book permission. Ask a platform administrator for team membership."
      />
    );
  }

  if (detail.isLoading) {
    return (
      <div className="flex flex-col gap-4">
        <BackLink />
        <Skeleton className="h-32" />
        <Skeleton className="h-28" />
        <Skeleton className="h-96" />
      </div>
    );
  }

  if (detail.error instanceof ApiError && detail.error.status === 404) {
    return (
      <Restricted
        icon={<Handshake />}
        title="No such engagement"
        description="It has been removed, or the link was to an id that never existed."
      />
    );
  }

  if (detail.error || !detail.data || !engagement) {
    return (
      <div className="flex flex-col gap-4">
        <BackLink />
        <Card>
          <CardContent className="pt-5">
            <EmptyState
              icon={<AlertTriangle />}
              title="Engagement unavailable"
              description={
                detail.error instanceof Error
                  ? detail.error.message
                  : 'The workspace could not be loaded.'
              }
              action={
                <Button variant="outline" onClick={() => void detail.refetch()}>
                  <RefreshCw aria-hidden />
                  Try again
                </Button>
              }
            />
          </CardContent>
        </Card>
      </div>
    );
  }

  const artifacts = groups.flatMap((group) => group.artifacts);
  const promoted = artifacts.filter((artifact) => artifact.promotedTemplateId).length;
  const finalised = artifacts.filter((artifact) => artifact.status === 'final').length;
  const frameworks = artifacts.filter((artifact) => artifact.kind === 'qualification');
  const scores = detail.data.scores;

  /*
   * The eight open stages always, plus any closed stage that actually holds
   * work or is where the deal now sits. A rail that hides `closed_won` would
   * lose the case study written after the deal landed — which is precisely the
   * artefact most worth promoting.
   */
  const railStages = DEMAND_STAGES.filter(
    (stage) =>
      DEMAND_OPEN_STAGES.includes(stage) ||
      (counts[stage] ?? 0) > 0 ||
      stage === engagement.stage,
  );

  const visibleGroups = stageFilter
    ? groups.filter((group) => group.stage === stageFilter)
    : groups.filter((group) => group.artifacts.length > 0);

  return (
    <div className="flex min-w-0 flex-col gap-5">
      <BackLink />

      <Header
        engagement={engagement}
        mayWrite={mayWrite}
        onInstantiate={() => setInstantiating({ kind: null })}
      />

      <section className="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-4" aria-label="Engagement totals">
        <Stat label="Artefacts" value={artifacts.length} hint="Not archived" />
        <Stat label="Final" value={finalised} hint="Promotable" />
        <Stat
          label="Promoted"
          value={promoted}
          hint="Back into the library"
          tone={promoted ? 'positive' : 'default'}
        />
        <Stat
          label="Qualification"
          value={engagement.latestScore ? percent(engagement.latestScore.basisPoints) : '—'}
          hint={engagement.latestScore ? engagement.latestScore.band : 'Never scored'}
          tone={engagement.latestScore?.tone === 'danger' ? 'danger' : engagement.latestScore?.tone === 'warning' ? 'warning' : 'default'}
        />
      </section>

      <section aria-label="Artefacts by stage" className="flex min-w-0 flex-col gap-2">
        <div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
          <h2 className="font-semibold">Where the work sits</h2>
          {stageFilter ? (
            <Button variant="ghost" onClick={() => setStageFilter(null)}>
              Show every stage
            </Button>
          ) : null}
        </div>
        <StageRail
          counts={counts}
          stages={railStages}
          countNoun={['artefact', 'artefacts']}
          activeStage={stageFilter ?? engagement.stage}
          onSelect={(stage) => setStageFilter((current) => (current === stage ? null : stage))}
        />
        <p className="text-xs text-muted">
          {stageFilter
            ? `Filtered to ${DEMAND_STAGE_LABELS[stageFilter]}.`
            : engagement.stage
              ? `The deal is at ${DEMAND_STAGE_LABELS[engagement.stage]}. Select a stage to filter the artefacts below.`
              : 'Select a stage to filter the artefacts below.'}
        </p>
      </section>

      <Qualification
        scores={scores}
        frameworks={frameworks}
        mayWrite={mayWrite}
        onScore={() => setScoring(true)}
        onInstantiateFramework={() => setInstantiating({ kind: 'qualification' })}
      />

      {detail.data.playbook ? <Playbook playbook={detail.data.playbook} /> : null}

      <section className="flex min-w-0 flex-col gap-3" aria-label="Artefacts">
        <div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
          <h2 className="font-semibold">Artefacts</h2>
          <Button
            variant="primary"
            disabled={!mayWrite}
            title={mayWrite ? undefined : WRITE_DENIED}
            onClick={() => setInstantiating({ kind: null })}
          >
            <Library aria-hidden />
            Instantiate from library
          </Button>
        </div>

        {visibleGroups.length === 0 || visibleGroups.every((group) => group.artifacts.length === 0) ? (
          <Card>
            <CardContent className="pt-5">
              <EmptyState
                icon={<FileText />}
                title={stageFilter ? 'Nothing filed at this stage' : 'No artefacts yet'}
                description={
                  stageFilter
                    ? 'Instantiate a template that serves this stage, or clear the filter to see the rest.'
                    : 'Instantiate a discovery guide or a qualification framework from the library. What comes out of this engagement can be promoted back into it.'
                }
                action={
                  <Button
                    variant="primary"
                    disabled={!mayWrite}
                    title={mayWrite ? undefined : WRITE_DENIED}
                    onClick={() => setInstantiating({ kind: null })}
                  >
                    Instantiate a template
                  </Button>
                }
              />
            </CardContent>
          </Card>
        ) : (
          visibleGroups.map((group) => (
            <div key={group.stage} className="flex min-w-0 flex-col gap-2">
              <h3 className="text-xs font-medium uppercase tracking-wide text-muted">
                {DEMAND_STAGE_LABELS[group.stage]}
              </h3>
              {group.artifacts.map((artifact) => (
                <ArtifactPanel
                  key={artifact.id}
                  artifact={artifact}
                  mayWrite={mayWrite}
                  mayPublish={mayPublish}
                  onPromote={() => setPromoting(artifact)}
                />
              ))}
            </div>
          ))
        )}
      </section>

      {instantiating ? (
        <InstantiateDialog
          // Remounted per intent, so opening it to find a framework really does
          // open on the frameworks rather than on whatever was filtered last.
          key={instantiating.kind ?? 'any'}
          engagementId={engagement.id}
          /*
           * The deal's stage seeds the filter, but only where the forced kind
           * actually serves it. The service ANDs kind and stage, so "Add a
           * framework" on a deal at POC asked for a qualification template at
           * POC — a pair no shipped template can satisfy — and the picker said
           * "No template matches" over a library that has three. Where the two
           * are coherent the stage is kept, because an architecture prefilter
           * on a deal at POC should still open on POC.
           */
          defaultStage={
            instantiating.kind
              ? engagement.stage && MOTION_KIND_STAGES[instantiating.kind].includes(engagement.stage)
                ? engagement.stage
                : null
              : engagement.stage
          }
          defaultKind={instantiating.kind}
          open
          onOpenChange={(open) => {
            if (!open) setInstantiating(null);
          }}
        />
      ) : null}

      <Sheet open={scoring} onOpenChange={setScoring}>
        <SheetContent side="right" className="w-full overflow-y-auto sm:max-w-2xl">
          <SheetHeader>
            <SheetTitle>Score qualification</SheetTitle>
            <SheetDescription>
              Answered against the anchors, so two people reading the same evidence reach the same
              number. Scores are appended, never edited — the movement is the evidence that
              qualification happened.
            </SheetDescription>
          </SheetHeader>
          <ScoringPanel
            engagementId={engagement.id}
            frameworks={frameworks}
            previousBasisPoints={scores[0]?.basisPoints ?? null}
            onDone={() => setScoring(false)}
          />
        </SheetContent>
      </Sheet>

      <PromoteSheet artifact={promoting} onClose={() => setPromoting(null)} />
    </div>
  );
}

// ------------------------------------------------------------------ sections

function Header({
  engagement,
  mayWrite,
  onInstantiate,
}: {
  engagement: EngagementView;
  mayWrite: boolean;
  onInstantiate: () => void;
}) {
  const queryClient = useQueryClient();
  const update = useMutation({
    mutationFn: (status: EngagementStatus) =>
      patch<{ engagement: { id: string } }>(`/api/motion/engagements/${engagement.id}`, { status }),
    onSuccess: async (_data, status) => {
      await queryClient.invalidateQueries({ queryKey: ['motion'] });
      toast.success(`Engagement marked ${ENGAGEMENT_STATUS_LABELS[status].toLocaleLowerCase()}`);
    },
    onError: (error: Error) => toast.error(error.message),
  });

  return (
    <header className="flex min-w-0 flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
      <div className="min-w-0">
        <div className="flex min-w-0 flex-wrap items-center gap-2">
          {engagement.stage ? (
            <Badge tone="neutral" className="min-w-0">
              <span className="truncate">{DEMAND_STAGE_LABELS[engagement.stage]}</span>
            </Badge>
          ) : null}
          <Badge tone={statusTone(engagement.status)}>
            {ENGAGEMENT_STATUS_LABELS[engagement.status]}
          </Badge>
          <span className="nums text-xs text-muted">
            Opened {shortDate(engagement.openedAt)}
            {engagement.closedAt ? ` · closed ${shortDate(engagement.closedAt)}` : ''}
          </span>
        </div>
        <h1 className="mt-1 min-w-0 break-words text-xl font-semibold tracking-tight sm:text-2xl">
          {engagement.dealName ?? 'Untitled deal'}
        </h1>
        <p className="mt-1 min-w-0 truncate text-sm text-muted">
          {engagement.accountId ? (
            <Link
              to={`/accounts/${engagement.accountId}`}
              className="underline-offset-4 hover:text-accent-fg hover:underline"
            >
              {engagement.accountName ?? 'Unknown account'}
            </Link>
          ) : (
            (engagement.accountName ?? 'Unknown account')
          )}
        </p>
        {engagement.summary ? (
          <p className="mt-2 max-w-2xl min-w-0 break-words text-sm leading-6 text-muted">
            {engagement.summary}
          </p>
        ) : null}
      </div>

      <div className="flex min-w-0 flex-wrap items-center gap-2">
        <Select
          value={engagement.status}
          disabled={!mayWrite || update.isPending}
          onValueChange={(value) => update.mutate(value as EngagementStatus)}
        >
          <SelectTrigger
            aria-label="Engagement status"
            title={mayWrite ? undefined : WRITE_DENIED}
            className="h-11 w-40 min-w-0"
          >
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            <SelectGroup>
              {ENGAGEMENT_STATUSES.map((status) => (
                <SelectItem key={status} value={status}>
                  {ENGAGEMENT_STATUS_LABELS[status]}
                </SelectItem>
              ))}
            </SelectGroup>
          </SelectContent>
        </Select>
        <Button
          variant="primary"
          disabled={!mayWrite}
          title={mayWrite ? undefined : WRITE_DENIED}
          onClick={onInstantiate}
        >
          <Library aria-hidden />
          Instantiate
        </Button>
      </div>
    </header>
  );
}

/**
 * The score, and what it did.
 *
 * The number on its own answers a question nobody asks twice. What a review
 * actually needs is the direction: a deal that has moved 5,800 → 7,100 across
 * three weeks is a different deal from one that has sat at 7,100 since it was
 * opened, and the second one is the one where nothing has been learned.
 */
function Qualification({
  scores,
  frameworks,
  mayWrite,
  onScore,
  onInstantiateFramework,
}: {
  scores: ScoreView[];
  frameworks: ArtifactView[];
  mayWrite: boolean;
  onScore: () => void;
  onInstantiateFramework: () => void;
}) {
  const latest = scores[0] ?? null;
  const scorable = frameworks.length > 0;

  return (
    <Card className="flex min-w-0 flex-col gap-4 p-4 sm:p-5">
      <div className="flex min-w-0 flex-wrap items-start justify-between gap-3">
        <div className="min-w-0">
          <h2 className="font-semibold">Qualification</h2>
          <p className="mt-0.5 text-sm text-muted">
            Weighted dimensions, scored against anchors. The band is what gets acted on.
          </p>
        </div>
        {scorable ? (
          <Button
            variant="primary"
            disabled={!mayWrite}
            title={mayWrite ? undefined : WRITE_DENIED}
            onClick={onScore}
          >
            <Gauge aria-hidden />
            {latest ? 'Score again' : 'Score this deal'}
          </Button>
        ) : (
          <Button
            variant="outline"
            disabled={!mayWrite}
            title={
              mayWrite
                ? 'Scoring needs a qualification framework in this engagement.'
                : WRITE_DENIED
            }
            onClick={onInstantiateFramework}
          >
            Add a framework
          </Button>
        )}
      </div>

      {latest ? (
        <div className="flex min-w-0 flex-wrap items-baseline gap-x-3 gap-y-1">
          <span className="nums text-3xl font-semibold leading-none">
            {percent(latest.basisPoints)}
          </span>
          <Badge tone={latest.tone}>{latest.band}</Badge>
          <span className="nums text-xs text-muted">scored {shortDate(latest.scoredAt)}</span>
        </div>
      ) : (
        <p className="text-sm text-muted">
          {scorable
            ? 'Not scored yet. The framework is here; the first score is the baseline everything after is read against.'
            : 'Not scored yet, and there is no framework in this engagement to score against. Instantiate one from the library.'}
        </p>
      )}

      {scores.length > 1 ? (
        <div className="min-w-0 border-t border-border pt-3">
        <h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted">Movement</h3>
        <ol className="min-w-0 space-y-2">
          {scores.map((score, index) => {
            const previous = scores[index + 1];
            const delta = previous ? score.basisPoints - previous.basisPoints : null;
            return (
              <li key={score.id} className="flex min-w-0 flex-col gap-1 sm:flex-row sm:items-baseline sm:gap-3">
                <span className="nums w-16 shrink-0 text-sm font-medium">
                  {percent(score.basisPoints)}
                </span>
                <span className="w-28 shrink-0">
                  <Badge tone={score.tone}>{score.band}</Badge>
                </span>
                <span
                  className={cn(
                    'nums w-20 shrink-0 text-xs font-medium',
                    delta === null ? 'text-muted' : delta > 0 ? 'text-positive' : delta < 0 ? 'text-danger' : 'text-muted',
                  )}
                >
                  {delta === null
                    ? 'baseline'
                    : delta === 0
                      ? 'no change'
                      : `${delta > 0 ? '+' : '−'}${percent(Math.abs(delta))}`}
                </span>
                <span className="min-w-0 flex-1 break-words text-xs text-muted">
                  {shortDate(score.scoredAt)}
                  {score.note ? ` · ${score.note}` : ''}
                </span>
              </li>
            );
          })}
        </ol>
        </div>
      ) : null}
    </Card>
  );
}

/** Which framework the scorer is filling in, when the engagement holds several. */
function ScoringPanel({
  engagementId,
  frameworks,
  previousBasisPoints,
  onDone,
}: {
  engagementId: string;
  frameworks: ArtifactView[];
  previousBasisPoints: number | null;
  onDone: () => void;
}) {
  const [selected, setSelected] = useState(frameworks[0]?.id ?? '');
  const framework = frameworks.find((artifact) => artifact.id === selected) ?? frameworks[0];

  if (!framework) {
    return (
      <div className="mt-6 rounded-lg border border-border p-4">
        <p className="font-medium">No framework in this engagement</p>
        <p className="mt-1 text-sm text-muted">
          Instantiate a qualification template first — the dimensions and their anchors come from
          it, and a score without them is a number two people would not agree on.
        </p>
      </div>
    );
  }

  return (
    <>
      {frameworks.length > 1 ? (
        <div className="mt-5 flex min-w-0 flex-col gap-1.5">
          <Label htmlFor="scoring-framework">Framework</Label>
          <Select value={framework.id} onValueChange={setSelected}>
            <SelectTrigger id="scoring-framework" aria-label="Framework" className="h-11 min-w-0">
              <SelectValue />
            </SelectTrigger>
            <SelectContent>
              <SelectGroup>
                {frameworks.map((artifact) => (
                  <SelectItem key={artifact.id} value={artifact.id}>
                    {artifact.title}
                  </SelectItem>
                ))}
              </SelectGroup>
            </SelectContent>
          </Select>
        </div>
      ) : null}
      <QualificationScorer
        key={framework.id}
        engagementId={engagementId}
        // The template behind the artefact, not the artefact: the score records
        // which framework produced it, and a from-scratch framework has none.
        frameworkTemplateId={framework.templateId}
        fields={framework.fields}
        previousBasisPoints={previousBasisPoints}
        onScored={onDone}
        onCancel={onDone}
      />
    </>
  );
}

function Playbook({ playbook }: { playbook: TemplateSummary }) {
  return (
    <Card className="flex min-w-0 flex-col gap-2 p-4 sm:flex-row sm:items-center sm:justify-between">
      <div className="min-w-0">
        <p className="text-xs font-medium uppercase tracking-wide text-muted">Playbook</p>
        <p className="mt-1 min-w-0 break-words font-medium">{playbook.title}</p>
        <p className="mt-0.5 line-clamp-2 min-w-0 break-words text-sm text-muted">
          {playbook.summary}
        </p>
      </div>
      <Button asChild variant="outline" className="shrink-0">
        <Link to={`/motion/library/${playbook.id}`}>
          Open v{playbook.version}
          <ArrowUpRight aria-hidden />
        </Link>
      </Button>
    </Card>
  );
}

/**
 * One artefact, with the two controls that move it: its status, and promotion.
 *
 * Promotion is refused by the API for an artefact that is not final and for one
 * already promoted, so the button is disabled with the reason on it rather than
 * hidden — a control that vanishes teaches nothing, and "why can I not promote
 * this" is a question the status select right beside it answers.
 */
function ArtifactPanel({
  artifact,
  mayWrite,
  mayPublish,
  onPromote,
}: {
  artifact: ArtifactView;
  mayWrite: boolean;
  mayPublish: boolean;
  onPromote: () => void;
}) {
  const queryClient = useQueryClient();
  const update = useMutation({
    mutationFn: (status: ArtifactStatus) =>
      patch<{ artifact: { id: string } }>(`/api/motion/artifacts/${artifact.id}`, { status }),
    onSuccess: async (_data, status) => {
      await queryClient.invalidateQueries({ queryKey: ['motion'] });
      toast.success(`Marked ${ARTIFACT_STATUS_LABELS[status].toLocaleLowerCase()}`);
    },
    onError: (error: Error) => toast.error(error.message),
  });

  const promotable = artifact.status === 'final' && !artifact.promotedTemplateId && mayPublish;
  const promoteReason = artifact.promotedTemplateId
    ? 'Already in the library. Promote a later revision instead.'
    : artifact.status !== 'final'
      ? 'Only a final artefact may be promoted — the library is what the next deployment copies.'
      : PUBLISH_DENIED;

  return (
    <Card className="flex min-w-0 flex-col gap-3 p-4">
      <div className="flex min-w-0 flex-wrap items-center gap-1.5">
        <MotionKindBadge kind={artifact.kind} />
        <Badge tone={artifactTone(artifact.status)}>{ARTIFACT_STATUS_LABELS[artifact.status]}</Badge>
        {artifact.promotedTemplateId ? (
          <Badge tone="positive" title="This artefact became a library version">
            <Sparkles className="size-3 shrink-0" aria-hidden />
            In the library
          </Badge>
        ) : null}
        {artifact.templateId ? null : (
          <Badge tone="neutral" title="Written here rather than instantiated">
            From scratch
          </Badge>
        )}
      </div>

      <div className="min-w-0">
        <h4 className="min-w-0 break-words font-semibold leading-snug">{artifact.title}</h4>
        <p className="nums mt-0.5 text-xs text-muted">Updated {shortDate(artifact.updatedAt)}</p>
      </div>

      <details className="group min-w-0">
        <summary className="tap flex cursor-pointer items-center gap-1.5 text-sm font-medium text-muted hover:text-fg">
          <ChevronRight className="size-4 transition-transform group-open:rotate-90" aria-hidden />
          Read it
        </summary>
        <div className="mt-3 min-w-0 border-t border-border pt-3">
          {artifact.body ? <Markdown content={artifact.body} /> : null}
          <FieldsView kind={artifact.kind} fields={artifact.fields} className="mt-4" />
        </div>
      </details>

      <div className="flex min-w-0 flex-wrap items-center gap-2">
        <Select
          value={artifact.status}
          disabled={!mayWrite || update.isPending}
          onValueChange={(value) => update.mutate(value as ArtifactStatus)}
        >
          <SelectTrigger
            aria-label={`Status of ${artifact.title}`}
            title={mayWrite ? undefined : WRITE_DENIED}
            className="h-11 w-36 min-w-0"
          >
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            <SelectGroup>
              {ARTIFACT_STATUSES.map((status) => (
                <SelectItem key={status} value={status}>
                  {ARTIFACT_STATUS_LABELS[status]}
                </SelectItem>
              ))}
            </SelectGroup>
          </SelectContent>
        </Select>

        {artifact.promotedTemplateId ? (
          <Button asChild variant="outline">
            <Link to={`/motion/library/${artifact.promotedTemplateId}`}>
              Open the library version
              <ArrowUpRight aria-hidden />
            </Link>
          </Button>
        ) : (
          <Button
            variant="primary"
            disabled={!promotable}
            title={promotable ? undefined : promoteReason}
            onClick={onPromote}
          >
            <Sparkles aria-hidden />
            Promote to library
          </Button>
        )}
      </div>

      {promotable ? null : (
        <p className="min-w-0 break-words text-xs text-muted">{promoteReason}</p>
      )}
    </Card>
  );
}

/**
 * Promotion, with the two fields the library reads and nothing else.
 *
 * The lineage is not offered as a control. An artefact instantiated from a
 * template lands as the next version of that template's lineage, which is what
 * makes "this came from playbook v3, and v4 is what we learned" true; letting
 * somebody retarget it here would break the only chain that carries the claim.
 */
function PromoteSheet({ artifact, onClose }: { artifact: ArtifactView | null; onClose: () => void }) {
  return (
    <Sheet open={Boolean(artifact)} onOpenChange={(open) => !open && onClose()}>
      <SheetContent side="right" className="w-full overflow-y-auto sm:max-w-lg">
        {artifact ? <PromoteForm key={artifact.id} artifact={artifact} onDone={onClose} /> : null}
      </SheetContent>
    </Sheet>
  );
}

function PromoteForm({ artifact, onDone }: { artifact: ArtifactView; onDone: () => void }) {
  const queryClient = useQueryClient();
  const [title, setTitle] = useState(artifact.title);
  const [summary, setSummary] = useState('');

  const promote = useMutation({
    mutationFn: () =>
      post<{ template: { id: string; version: number } }>(
        `/api/motion/artifacts/${artifact.id}/promote`,
        {
          title: title.trim() || artifact.title,
          ...(summary.trim() ? { summary: summary.trim() } : {}),
        },
      ),
    onSuccess: async (result) => {
      await queryClient.invalidateQueries({ queryKey: ['motion'] });
      toast.success(`Promoted as v${result.template.version}`);
      onDone();
    },
    onError: (error: Error) => toast.error(error.message),
  });

  return (
    <>
      <SheetHeader>
        <SheetTitle>Promote to the library</SheetTitle>
        <SheetDescription>
          {artifact.templateId
            ? 'This lands as the next version of the lineage it came from, pointing back at both its predecessor and this artifact.'
            : 'This starts a new lineage at version 1, pointing back at this artefact as the engagement that proved it.'}{' '}
          Promoted templates are shared with everyone.
        </SheetDescription>
      </SheetHeader>

      <form
        className="mt-5 flex min-w-0 flex-col gap-4 pb-6"
        onSubmit={(event) => {
          event.preventDefault();
          promote.mutate();
        }}
      >
        <div className="flex min-w-0 flex-col gap-1.5">
          <Label htmlFor="promote-title">Title</Label>
          <Input
            id="promote-title"
            required
            value={title}
            onChange={(event) => setTitle(event.target.value)}
          />
        </div>
        <div className="flex min-w-0 flex-col gap-1.5">
          <Label htmlFor="promote-summary">Summary</Label>
          <Textarea
            id="promote-summary"
            className="min-h-24"
            value={summary}
            onChange={(event) => setSummary(event.target.value)}
            placeholder="What the next person needs to know before copying this."
          />
          <p className="text-xs text-muted">
            Left blank, the previous version&apos;s summary carries over.
          </p>
        </div>

        {promote.isError ? <p className="text-sm text-danger">{promote.error.message}</p> : null}

        <div className="flex min-w-0 flex-wrap justify-end gap-2">
          <Button type="button" variant="ghost" onClick={onDone}>
            Cancel
          </Button>
          <Button type="submit" variant="primary" disabled={promote.isPending}>
            Promote
          </Button>
        </div>
      </form>
    </>
  );
}

// ------------------------------------------------------------------- helpers

function BackLink() {
  return (
    <Link
      to="/motion/engagements"
      className="tap -ml-1 inline-flex w-fit items-center gap-2 rounded-lg px-1 text-sm font-medium text-muted hover:text-fg"
    >
      <ArrowLeft className="size-4" aria-hidden />
      All engagements
    </Link>
  );
}

function Restricted({
  icon,
  title,
  description,
}: {
  icon: ReactNode;
  title: string;
  description: string;
}) {
  return (
    <div className="flex flex-col gap-4">
      <BackLink />
      <Card>
        <CardContent className="pt-5">
          <EmptyState icon={icon} title={title} description={description} />
        </CardContent>
      </Card>
    </div>
  );
}

function statusTone(status: EngagementStatus): 'positive' | 'danger' | 'warning' | 'info' {
  return status === 'won' ? 'positive' : status === 'lost' ? 'danger' : status === 'paused' ? 'warning' : 'info';
}

function artifactTone(status: ArtifactStatus): 'neutral' | 'info' | 'positive' {
  return status === 'final' ? 'positive' : status === 'review' ? 'info' : 'neutral';
}
