/**
 * The environment picker: one catalogue, three shapes.
 *
 * The list is built at module scope from the registry and the vertical lineup —
 * creating `src/demos/<slug>/` adds an entry with no edit to this file, which is
 * the same discovery-by-existence property the registry itself has. The header
 * reads the same `environments` array, so the menu, the landing and the gallery
 * can never disagree about what exists.
 *
 * Three shapes:
 *   - `FeaturedEnvironment`: a built environment, with its own board and the
 *     mini tab strip. The only filled call to action on the landing.
 *   - `EnvironmentRow`: one line of the list. Built and written environments
 *     share it; the pill and the absent board are the whole difference. There is
 *     NO opacity dimming — a spec row links to a real specification and is
 *     told apart by token, not by fading.
 *   - `StatusPill`: LIVE / SPEC, exported so the header prints the same pill.
 *
 * Both pages count from the data, never from a number typed into the copy.
 */

import { useEffect, useState, type ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { ChevronRight, Play } from 'lucide-react';

import { TAB_LABELS, visibleTabs } from '@/components/demo/DemoTabs';
import { DemoIcon } from '@/components/site/DemoIcon';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { lineup, routes } from '@/content/lineup';
import { listRuns } from '@/lib/demo-kit/episode';
import {
  listDemos,
  listVerticals,
  loadDemoModule,
  VERTICAL_LABELS,
  VERTICAL_ORDER,
} from '@/lib/demo-kit/registry';
import type { DemoStatus, DemoTabId, Vertical } from '@/lib/demo-kit/types';
import { cn } from '@/lib/utils';

/** One environment, whether it is built or only written. */
export interface EnvironmentEntry {
  /** React key. Namespaced, because a demo and a vertical can share a slug. */
  id: string;
  title: string;
  /** One sentence on what the agent DOES. */
  tagline: string;
  /** The job title that owns the budget for it. */
  persona: string;
  /** What the reward pays for, and what it takes away. */
  rewardLine: string;
  /** A lucide icon name, resolved by `DemoIcon`. */
  icon: string;
  status: DemoStatus;
  href: string;
  /** Exec-facing vertical name. */
  verticalLabel: string | null;
  /** Filter identity. `null` matches no filter and shows only under "All". */
  verticalKey: Vertical | null;
  /** Set when the entry is a built demo; drives the board thumbnail. */
  slug: string | null;
  /** A standing qualifier printed on the row, e.g. "Not in the first set". */
  note: string | null;
  /**
   * 1-based position among the written environments, printed as `01`–`12`.
   * `null` for a built one: the built ones are not ranked, they are done.
   */
  rank: number | null;
}

/**
 * The row wants one sentence. `verticals.ts` writes two or three, because the
 * vertical page needs the whole argument. Trimming here rather than adding a
 * `tagline` field to the data is what stops a specification and its summary
 * drifting apart — there is only ever one copy of the sentence.
 */
function firstSentence(text: string): string {
  const trimmed = text.trim();
  const match = /^[\s\S]*?[.!?](?=\s|$)/.exec(trimmed);
  const first = match?.[0]?.trim();
  // A very short first fragment means the split found an abbreviation rather
  // than a sentence end, and half a clause on a row is worse than three lines.
  return first !== undefined && first.length >= 24 ? first : trimmed;
}

/** Verticals a demo already exists for. Those are shown as the demo, not as a proposal. */
const builtVerticals = new Set<Vertical>(listVerticals().map((group) => group.vertical));

const demoEntries: readonly Omit<EnvironmentEntry, 'rank'>[] = listDemos().map((meta) => ({
  id: `demo:${meta.slug}`,
  title: meta.title,
  tagline: meta.tagline,
  persona: meta.persona,
  rewardLine: meta.rewardLine,
  icon: meta.icon,
  status: meta.status,
  /*
   * `?tab=play` is written out even though `DEFAULT_TAB` in
   * `src/lib/url-state.ts` is already `play` and the param would be stripped
   * from a permalink. The promise this card makes is that one click puts you on
   * the board, and that promise should not quietly depend on which tab the
   * shell happens to default to next quarter.
   */
  href: meta.status === 'live' ? `${routes.demo(meta.slug)}?tab=play` : routes.demo(meta.slug),
  verticalLabel: VERTICAL_LABELS[meta.vertical],
  verticalKey: meta.vertical,
  slug: meta.slug,
  note: null,
}));

const proposalEntries: readonly Omit<EnvironmentEntry, 'rank'>[] = lineup
  .filter((vertical) => vertical.key === null || !builtVerticals.has(vertical.key))
  .map((vertical) => ({
    id: `vertical:${vertical.slug}`,
    title: vertical.title,
    tagline: firstSentence(vertical.task),
    persona: vertical.persona,
    rewardLine: firstSentence(vertical.reward),
    icon: vertical.icon,
    status: 'spec' as const,
    // Its own page, with the task, the reward, the counterweight and the
    // caveat written out. A spec row is only honest if it goes somewhere.
    href: routes.vertical(vertical.slug),
    verticalLabel: vertical.key === null ? null : VERTICAL_LABELS[vertical.key],
    verticalKey: vertical.key,
    slug: null,
    note: vertical.plannedForV1 ? null : 'Not in the first set',
  }));

const unranked: readonly Omit<EnvironmentEntry, 'rank'>[] = [
  ...demoEntries.filter((entry) => entry.status === 'live'),
  ...demoEntries.filter((entry) => entry.status === 'spec'),
  ...proposalEntries,
];

/** Everything, built first. `lineup` is already ranked, so proposals keep that order. */
export const environments: readonly EnvironmentEntry[] = (() => {
  let next = 0;
  return unranked.map((entry) => ({
    ...entry,
    rank: entry.status === 'spec' ? ++next : null,
  }));
})();

/** Playable in this tab. */
export const builtEnvironments: readonly EnvironmentEntry[] = environments.filter(
  (entry) => entry.status === 'live',
);

/** Published as a specification, with no interactive surface yet. */
export const writtenEnvironments: readonly EnvironmentEntry[] = environments.filter(
  (entry) => entry.status === 'spec',
);

/**
 * Filter options, in the contract's own order.
 *
 * An entry whose `verticalKey` is null — a proposal the contract has no key for
 * — is reachable only under "All". That is deliberate: such a proposal is on
 * the site as the strongest form of the argument, not as something we would
 * build, so it belongs to no filterable industry.
 */
export const filterKeys: readonly Vertical[] = VERTICAL_ORDER.filter((key) =>
  environments.some((entry) => entry.verticalKey === key),
);

/* ------------------------------------------------------------------ atoms */

/**
 * LIVE / SPEC. The one status pill on the site, shared with the header so a
 * visitor learns the shape once. Filled green means playable now; an outline
 * means a specification you can read. Nothing is dimmed to say "not yet".
 */
export function StatusPill({ status, className }: { status: DemoStatus; className?: string }) {
  return status === 'live' ? (
    <Badge variant="solid" className={cn('rounded-full', className)}>
      Live
    </Badge>
  ) : (
    <Badge variant="outline" className={cn('rounded-full', className)}>
      Spec
    </Badge>
  );
}

/** Mono key / sans value, the two lines an executive scans for. */
function MetaRows({ entry, className }: { entry: EnvironmentEntry; className?: string }) {
  return (
    <dl className={cn('space-y-1.5', className)}>
      <div className="flex gap-2">
        <dt className="w-16 shrink-0 pt-0.5 font-mono text-label uppercase tracking-label text-muted">
          For
        </dt>
        <dd className="min-w-0 text-ui text-fg">{entry.persona}</dd>
      </div>
      <div className="flex gap-2">
        <dt className="w-16 shrink-0 pt-0.5 font-mono text-label uppercase tracking-label text-muted">
          Reward
        </dt>
        <dd className="min-w-0 text-ui text-fg">{entry.rewardLine}</dd>
      </div>
    </dl>
  );
}

/* ----------------------------------------------------------- the featured */

/**
 * What a built demo ships, loaded from its own lazy chunk.
 *
 * `tabs` is derived through the shell's own `visibleTabs`, never declared:
 * `play` is whether the module has an interactive mode, `watch` is whether the
 * manifest lists a recorded run for it. (The demo page additionally requires
 * the run to adapt to at least one step; a card does not fetch an episode to
 * find that out, so a run listed but unplayable would show a WATCH chip the
 * page then drops. The manifest gate in check-demos is what keeps those equal.)
 */
interface DemoShape {
  board: ReactNode;
  tabs: readonly DemoTabId[];
}

const THUMBNAIL_SEED = 0;

function useDemoShape(slug: string): DemoShape | null {
  const [shape, setShape] = useState<DemoShape | null>(null);

  useEffect(() => {
    let live = true;
    /*
     * Deliberately after paint, and deliberately not budgeted as a picture.
     * This pulls the demo's own lazy chunk — the one the visitor is a click
     * away from — so the cost is a prefetch of the destination, and the board
     * arriving is proof the destination is already loaded.
     */
    void Promise.all([loadDemoModule(slug), listRuns(slug).catch(() => [])])
      .then(([module, runs]) => {
        if (!live) return;
        const { Surface } = module;
        const board = module.interactive ? (
          <Surface state={module.interactive.init(THUMBNAIL_SEED)} />
        ) : null;
        setShape({
          board,
          tabs: visibleTabs({ play: Boolean(module.interactive), watch: runs.length > 0 }),
        });
      })
      .catch(() => {
        // A card without a board is still a complete card. A demo whose chunk
        // fails to load has its own error page to say so; this is not it.
      });
    return () => {
      live = false;
    };
  }, [slug]);

  return shape;
}

/**
 * The four tabs the page will open with, as mono chips: `01 PLAY · 02 WATCH`.
 * Same-height placeholder until the shape lands, so the card does not jump.
 */
function MiniTabStrip({ tabs }: { tabs: readonly DemoTabId[] | null }) {
  return (
    <ul aria-label="Tabs on this environment's page" className="flex min-h-5 flex-wrap gap-1.5">
      {tabs === null
        ? Array.from({ length: 4 }, (_, i) => (
            <li key={i}>
              <Skeleton className="h-5 w-16 rounded-sm" />
            </li>
          ))
        : tabs.map((id, index) => (
            <li key={id}>
              <Badge variant="muted">
                {String(index + 1).padStart(2, '0')} {TAB_LABELS[id]}
              </Badge>
            </li>
          ))}
    </ul>
  );
}

/**
 * The board, drawn by the demo's own `Surface`, in a surface-2 inset.
 *
 * A screenshot would go stale the first time the board changed and nobody would
 * notice. This cannot: it is the same component the demo page renders, in the
 * state `interactive.init` hands a new player.
 */
function BoardThumbnail({ board }: { board: ReactNode | null }) {
  return (
    // Decorative: the title, the tagline and the reward already say everything
    // this board says, and an unplayed board is a shape, not information.
    <div aria-hidden="true" className="flex flex-col gap-2">
      <div className="rounded-lg border border-border bg-surface-2 p-3">
        {board ?? <Skeleton className="aspect-[5/6] w-full" />}
      </div>
      {/* An unplayed board is easy to read as a broken one. One line stops
          that, and it is true of any demo's Surface, not just this one. */}
      <p className="font-mono text-label uppercase tracking-label text-muted">
        The environment’s own board, before a move.
      </p>
    </div>
  );
}

/**
 * The built environment, given the weight it has earned.
 *
 * Whole card a link — one target, one focus ring, no nested controls. The copy
 * is FIRST in the source; the board sits to the right from `lg` and follows
 * the copy, centred, below that, so on a phone the name of the environment and
 * the call to action are never pushed under the fold by a board.
 */
export function FeaturedEnvironment({
  entry,
  className,
}: {
  entry: EnvironmentEntry;
  className?: string;
}) {
  return entry.slug === null ? (
    <FeaturedCard entry={entry} className={className} shape={null} withBoard={false} />
  ) : (
    <FeaturedWithShape entry={entry} slug={entry.slug} className={className} />
  );
}

function FeaturedWithShape({
  entry,
  slug,
  className,
}: {
  entry: EnvironmentEntry;
  slug: string;
  className?: string;
}) {
  const shape = useDemoShape(slug);
  return <FeaturedCard entry={entry} className={className} shape={shape} withBoard />;
}

function FeaturedCard({
  entry,
  shape,
  withBoard,
  className,
}: {
  entry: EnvironmentEntry;
  shape: DemoShape | null;
  withBoard: boolean;
  className?: string;
}) {
  const isSpec = entry.status === 'spec';
  return (
    <Link
      aria-label={`${entry.title} — ${isSpec ? 'read the specification' : 'play it'}`}
      className={cn(
        'card group flex flex-col p-4 transition-colors duration-1 ease-enter hover:border-border-strong sm:p-5',
        className,
      )}
      to={entry.href}
    >
      <div
        className={cn(
          'grid gap-5',
          withBoard && 'lg:grid-cols-[minmax(0,1fr)_minmax(0,17rem)] lg:gap-8',
        )}
      >
        <div className="flex min-w-0 flex-col">
          <span className="flex flex-wrap items-center gap-2">
            <StatusPill status={entry.status} />
            {entry.verticalLabel === null ? null : (
              <span className="inline-flex items-center gap-1.5 font-mono text-label uppercase tracking-label text-muted">
                <DemoIcon className="size-3.5 text-border-strong" name={entry.icon} />
                {entry.verticalLabel}
              </span>
            )}
          </span>

          <h3 className="mt-3 text-h2 text-fg">{entry.title}</h3>
          <p className="mt-2 max-w-measure text-prose text-fg-2">{entry.tagline}</p>

          <MetaRows entry={entry} className="mt-4" />

          <div className="mt-4">
            <MiniTabStrip tabs={shape?.tabs ?? (withBoard ? null : [])} />
          </div>

          <span className="mt-5 flex flex-wrap items-center gap-x-4 gap-y-2">
            {/* Styled as the button it behaves as. It is not a <button>: the
                whole card is already the one link, and a control inside a link
                is a keyboard trap dressed up as a call to action. */}
            <span
              aria-hidden="true"
              className="inline-flex min-h-11 items-center gap-2 rounded-md border border-accent-edge bg-brand px-4 text-ui font-medium text-accent-on"
            >
              {isSpec ? null : <Play className="size-4" />}
              {isSpec ? 'Read the specification' : 'Play it'}
            </span>
            <span className="text-small text-muted">
              {isSpec
                ? 'The task, the reward, the counterweight and where it stops being honest.'
                : 'Opens on the board. No sign-up, no sales call.'}
            </span>
          </span>
        </div>

        {withBoard ? (
          <div className="mx-auto w-full max-w-[17rem] lg:mx-0">
            <BoardThumbnail board={shape?.board ?? null} />
          </div>
        ) : null}
      </div>
    </Link>
  );
}

/* --------------------------------------------------------------- the list */

/**
 * One environment, as a row. Whole row a link; 56px from `lg`, 64px below.
 *
 * At a phone width it is two lines: title + pill, then the persona. The reward
 * line, the tagline and the icon square come in from `lg`, where there is room
 * for a row to carry the whole argument in one glance.
 */
export function EnvironmentRow({
  entry,
  rank = entry.rank,
  className,
}: {
  entry: EnvironmentEntry;
  /** The printed numeral. Defaults to the entry's own rank; a filtered list passes its own. */
  rank?: number | null;
  className?: string;
}) {
  const isSpec = entry.status === 'spec';
  return (
    // An `<li>`, because the pages render rows straight into their own `<ol>`
    // and the hairline between rows belongs to the row, not to the list.
    <li className={cn('border-b border-border last:border-b-0', className)}>
    <Link
      aria-label={`${entry.title} — ${isSpec ? 'read the specification' : 'play it'}`}
      to={entry.href}
      className="group flex min-h-16 items-center gap-3 px-2 py-2 transition-colors duration-1 ease-enter hover:bg-surface-2 lg:min-h-14 lg:gap-4"
    >
      <span className="hidden w-6 shrink-0 font-mono text-mono-data text-muted lg:block">
        {rank === null || rank === undefined ? '' : String(rank).padStart(2, '0')}
      </span>
      <span className="hidden size-9 shrink-0 place-items-center rounded-md bg-surface-3 lg:grid">
        <DemoIcon className="size-4 text-border-strong" name={entry.icon} />
      </span>

      <span className="flex min-w-0 flex-1 flex-col gap-0.5 lg:flex-row lg:items-center lg:gap-4">
        <span className="flex min-w-0 flex-col lg:w-[19rem] lg:shrink-0">
          <span className="flex items-center gap-2">
            <span className="truncate text-[0.9375rem] font-medium leading-tight text-fg">
              {entry.title}
            </span>
            <StatusPill status={entry.status} className="lg:hidden" />
          </span>
          <span className="hidden truncate text-small text-muted lg:block">{entry.tagline}</span>
        </span>

        <span className="truncate text-small text-muted lg:w-[11rem] lg:shrink-0 lg:text-fg-2">
          {entry.persona}
        </span>

        <span className="hidden min-w-0 flex-1 truncate text-small text-fg-2 lg:block">
          {entry.rewardLine}
        </span>
      </span>

      <span className="hidden shrink-0 items-center gap-2 lg:flex">
        {entry.note === null ? null : <Badge variant="muted">{entry.note}</Badge>}
        <StatusPill status={entry.status} />
      </span>
      <ChevronRight
        aria-hidden="true"
        className="size-4 shrink-0 text-border-strong transition-transform duration-1 ease-enter group-hover:translate-x-0.5"
      />
    </Link>
    </li>
  );
}

/** The rows in one ordered list, so a screen reader gets the count. */
export function EnvironmentList({
  entries,
  className,
}: {
  entries: readonly EnvironmentEntry[];
  className?: string;
}) {
  return (
    <ol className={cn('border-y border-border', className)}>
      {entries.map((entry) => (
        <EnvironmentRow entry={entry} key={entry.id} />
      ))}
    </ol>
  );
}

/**
 * @deprecated The catalogue is a list, not a grid of cards. Kept as an alias
 * so a page that still imports the old name renders the rows; use
 * `EnvironmentList`.
 */
export const EnvironmentGrid = EnvironmentList;

/* ----------------------------------------------------------------- filter */

/** The "no vertical chosen" value. Not a `Vertical`, so it cannot collide with one. */
export const ALL_VERTICALS = 'all';

export type VerticalFilterValue = Vertical | typeof ALL_VERTICALS;

/**
 * Vertical chips for `/gallery?vertical=`. Wraps on a phone rather than
 * scrolling sideways — a chip hidden off the right edge is a filter nobody
 * knows exists. 44px tall; the border is the affordance, so it is border-strong.
 */
export function VerticalFilter({
  active,
  onSelect,
  className,
}: {
  active: VerticalFilterValue;
  onSelect: (next: VerticalFilterValue) => void;
  className?: string;
}) {
  const options: readonly VerticalFilterValue[] = [ALL_VERTICALS, ...filterKeys];
  return (
    <ul aria-label="Filter environments by vertical" className={cn('flex flex-wrap gap-2', className)}>
      {options.map((key) => {
        const selected = key === active;
        return (
          <li key={key}>
            <button
              aria-pressed={selected}
              className={cn(
                // `text-sm`, not `text-ui`: composed through `cn`, where
                // tailwind-merge v2 reads an unknown `text-*` as a colour.
                'tap inline-flex items-center rounded-md border px-3.5 text-sm font-medium transition-colors duration-1 ease-enter',
                selected
                  ? 'border-accent-edge bg-accent-subtle text-accent-fg'
                  : 'border-border-strong bg-surface text-fg hover:bg-surface-2',
              )}
              onClick={() => onSelect(key)}
              type="button"
            >
              {key === ALL_VERTICALS ? 'All' : VERTICAL_LABELS[key]}
            </button>
          </li>
        );
      })}
    </ul>
  );
}
