/**
 * The environment picker: one card shape, two pages.
 *
 * Home leads with it and Gallery filters it, so the card lives here rather than
 * twice. The list is built at module scope from the registry and the vertical
 * lineup — creating `src/demos/<slug>/` adds a card with no edit to this file,
 * which is the same discovery-by-existence property the registry itself has.
 *
 * The asymmetry is the honest part. Almost everything in the lineup is written
 * rather than built, and a wall of identical tiles would imply a wall of
 * working demos on a site whose entire argument is that its claims are
 * checkable. So a built environment gets its own board, its own block and the
 * only filled call to action, and the written ones read as a published roadmap
 * underneath it: dimmed, badged, and each linking to a real specification
 * rather than to nothing. Both pages count them 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 { ArrowRight, FileText, Play } from 'lucide-react';

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 * as s from '@/content/styles';
import {
  listDemos,
  listVerticals,
  loadDemoModule,
  VERTICAL_LABELS,
  VERTICAL_ORDER,
} from '@/lib/demo-kit/registry';
import type { DemoStatus, Vertical } from '@/lib/demo-kit/types';
import { cn } from '@/lib/utils';

/** One card's worth of 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 card, e.g. "Not in the first set". */
  note: string | null;
}

/**
 * The card 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 card 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 EnvironmentEntry[] = 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 EnvironmentEntry[] = 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 dimmed card 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',
  }));

/** Everything, built first. `lineup` is already ranked, so proposals keep that order. */
export const environments: readonly EnvironmentEntry[] = [
  ...demoEntries.filter((entry) => entry.status === 'live'),
  ...demoEntries.filter((entry) => entry.status === 'spec'),
  ...proposalEntries,
];

/** 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),
);

/* ------------------------------------------------------------------ cards */

function StatusBadge({ status }: { status: DemoStatus }) {
  if (status === 'live') {
    return (
      <Badge variant="positive" className="uppercase tracking-wide">
        Live
      </Badge>
    );
  }
  return (
    <Badge variant="outline" className="uppercase tracking-wide">
      <FileText aria-hidden="true" className="size-3.5" />
      Spec
    </Badge>
  );
}

/**
 * The two lines an executive actually scans for: whose budget this is, and
 * what the number pays for. The vertical is not repeated here — both card
 * layouts already print it beside the icon.
 */
function MetaRows({ entry }: { entry: EnvironmentEntry }) {
  return (
    <dl className="mt-4 space-y-1.5 text-sm">
      <div className="flex gap-2">
        <dt className="w-16 shrink-0 text-muted">For</dt>
        <dd className="min-w-0 text-fg">{entry.persona}</dd>
      </div>
      <div className="flex gap-2">
        <dt className="w-16 shrink-0 text-muted">Reward</dt>
        <dd className="min-w-0 text-fg">{entry.rewardLine}</dd>
      </div>
    </dl>
  );
}

/**
 * One environment, as a card.
 *
 * The whole rectangle is the link — one target, one focus ring, no nested
 * interactive elements to trap a keyboard in.
 */
export function EnvironmentCard({
  entry,
  className,
}: {
  entry: EnvironmentEntry;
  className?: string;
}) {
  const isSpec = entry.status === 'spec';
  return (
    <Link
      aria-label={`${entry.title} — ${isSpec ? 'read the specification' : 'play it'}`}
      className={cn(
        s.cardLink,
        'h-full',
        // Dimmed, never disabled. It goes to a real specification, which is the
        // only thing that makes dimming it honest rather than teasing.
        isSpec && 'opacity-75 hover:opacity-100 focus-visible:opacity-100',
        className,
      )}
      to={entry.href}
    >
      <span className="flex items-start justify-between gap-3">
        <span className="flex min-w-0 items-center gap-2">
          <span
            className={cn(
              'grid size-10 shrink-0 place-items-center rounded-lg',
              isSpec ? 'bg-surface-2 text-muted' : 'bg-accent-subtle text-accent-fg',
            )}
          >
            <DemoIcon className="size-5" name={entry.icon} />
          </span>
          {entry.verticalLabel === null ? null : (
            <span className="truncate text-xs text-muted">{entry.verticalLabel}</span>
          )}
        </span>
        <StatusBadge status={entry.status} />
      </span>

      <h3 className="mt-4 text-base font-semibold leading-snug tracking-tight text-fg">
        {entry.title}
      </h3>
      <p className={`${s.prose} mt-1.5 text-sm`}>{entry.tagline}</p>

      <MetaRows entry={entry} />

      {/* `mt-auto` so the call to action sits on the same line in every card of
          a row, however long the tagline above it ran. */}
      <span className="mt-auto flex flex-wrap items-center justify-between gap-x-3 gap-y-1 pt-4">
        <span className="inline-flex items-center gap-1.5 text-sm font-semibold text-accent-fg">
          {isSpec ? 'Read the specification' : 'Play it'}
          <ArrowRight
            aria-hidden="true"
            className="size-4 transition-transform duration-2 ease-enter group-hover:translate-x-0.5"
          />
        </span>
        {entry.note === null ? null : <span className="text-xs text-muted">{entry.note}</span>}
      </span>
    </Link>
  );
}

/**
 * The board, drawn by the demo's own `Surface`.
 *
 * 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.
 */
const THUMBNAIL_SEED = 0;

function BoardThumbnail({ slug }: { slug: string }) {
  const [board, setBoard] = useState<ReactNode>(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 loadDemoModule(slug)
      .then((module) => {
        if (!live || !module.interactive) return;
        const { Surface } = module;
        setBoard(<Surface state={module.interactive.init(THUMBNAIL_SEED)} />);
      })
      .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 (
    // 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">
      <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="mt-2 text-xs text-muted">The environment’s own board, before a move.</p>
    </div>
  );
}

/**
 * The built environment, given the weight it has earned.
 *
 * Wider than a card, with the real board in it and the only filled call to
 * action on the page. If a second demo ever ships, this renders for that one
 * too and the picker stays honest without an edit.
 */
export function FeaturedEnvironment({
  entry,
  className,
}: {
  entry: EnvironmentEntry;
  className?: string;
}) {
  return (
    <Link
      aria-label={`${entry.title} — play it`}
      className={cn(s.cardLink, 'p-5 sm:p-7', className)}
      to={entry.href}
    >
      <div className="grid gap-6 lg:grid-cols-[minmax(0,17rem)_minmax(0,1fr)] lg:gap-10">
        {/*
          The copy is FIRST in the source and the board is pulled left only from
          `lg`. On a phone a full-width board above the title pushes the name of
          the environment and its call to action below the fold, which is the
          one thing this card exists to avoid.
        */}
        <div className="flex flex-col">
          <span className="flex flex-wrap items-center gap-2">
            <StatusBadge status={entry.status} />
            {entry.verticalLabel === null ? null : (
              <span className="inline-flex items-center gap-1.5 text-xs text-muted">
                <DemoIcon className="size-3.5" name={entry.icon} />
                {entry.verticalLabel}
              </span>
            )}
          </span>

          <h3 className="mt-3 text-2xl font-bold tracking-tight text-fg sm:text-3xl">
            {entry.title}
          </h3>
          <p className={`${s.lede} mt-2 text-base sm:text-lg`}>{entry.tagline}</p>

          <MetaRows entry={entry} />

          <p className={`${s.prose} mt-4 text-sm`}>
            Four tabs. Play the board, watch a recorded model play the same board, move the reward
            weights and see the ranking change, then read the Python that scored it.
          </p>

          <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={s.btnPrimary}>
              <Play className="size-4" />
              Play it
            </span>
            <span className="text-xs text-muted">Opens on the board. No sign-up, no sales call.</span>
          </span>
        </div>

        {entry.slug === null ? null : (
          <div className="max-w-[17rem] lg:order-first">
            <BoardThumbnail slug={entry.slug} />
          </div>
        )}
      </div>
    </Link>
  );
}

/* ----------------------------------------------------------------- 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. Wraps on a phone rather than scrolling sideways — a chip
 * hidden off the right edge is a filter nobody knows exists.
 */
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(
                'tap inline-flex items-center rounded-lg border px-4 py-2 text-sm font-medium transition-colors duration-2 ease-enter',
                selected
                  ? 'border-brand bg-accent-subtle text-accent-fg'
                  : 'border-border bg-surface text-muted hover:bg-surface-2 hover:text-fg',
              )}
              onClick={() => onSelect(key)}
              type="button"
            >
              {key === ALL_VERTICALS ? 'All' : VERTICAL_LABELS[key]}
            </button>
          </li>
        );
      })}
    </ul>
  );
}

/** Cards in a responsive grid. One list, so a screen reader gets the count. */
export function EnvironmentGrid({
  entries,
  className,
}: {
  entries: readonly EnvironmentEntry[];
  className?: string;
}) {
  return (
    <ul className={cn('grid gap-4 sm:grid-cols-2 lg:grid-cols-3', className)}>
      {entries.map((entry) => (
        <li className="flex" key={entry.id}>
          <EnvironmentCard entry={entry} />
        </li>
      ))}
    </ul>
  );
}
