"use client";

import type { CatalogDetails, CatalogItem, CatalogKind } from "@/lib/catalog";
import Image from "next/image";
import { useEffect, useMemo, useState, useTransition } from "react";
import { addToWatchNow, moveWatchNowItem, removeFromWatchNow, removeFromWatchNowByTmdbId } from "./watch-now-actions";
import { setPurged, setTimeless, type TitleActionResult, type TitleFlags } from "./title-actions";

// What the detail panel tracks about the open title. The three server-side
// flags, plus whether it is in one of this user's Watch Now slots -- which is
// state the panel owns rather than something a title action returns.
type PanelFlags = TitleFlags & { watchNow: boolean };

type WatchNowItem = {
  id: string;
  title: string;
  mediaType: "tv_series" | "movie";
  year: number | null;
  posterPath: string | null;
  slotNumber: number | null;
};

type DragPayload =
  | { type: "catalog"; item: CatalogItem }
  | { type: "watch-now"; watchNowItemId: string };

type DetailState = {
  item: CatalogItem;
  details: CatalogDetails | null;
  loading: boolean;
  error: string | null;
  flags: PanelFlags;
};

type CatalogBoardProps = {
  kind: CatalogKind;
  visibleItems: CatalogItem[];
  collectionIds: string[];
  watchNowExternalIds: string[];
  purgedExternalIds: string[];
  watchNowItems: WatchNowItem[];
  slotCount: number;
  isAdmin: boolean;
};

function mediaLabel(kind: CatalogItem["kind"]) {
  return kind === "movies" ? "Movie" : "TV";
}

function slotTitle(kind: CatalogKind) {
  return kind === "movies" ? "movie" : "TV";
}

function submitCatalogItem(item: CatalogItem, slotNumber?: number) {
  const formData = new FormData();
  formData.set("kind", item.kind);
  formData.set("tmdbId", item.id);
  formData.set("title", item.title);
  formData.set("year", item.year ?? "");
  formData.set("overview", item.overview ?? "");
  formData.set("posterPath", item.posterUrl ?? "");
  formData.set("releaseDate", item.releaseDate ?? "");

  if (slotNumber) {
    formData.set("slotNumber", String(slotNumber));
  }

  return formData;
}

function submitWatchNowItem(watchNowItemId: string, slotNumber: number) {
  const formData = new FormData();
  formData.set("watchNowItemId", watchNowItemId);
  formData.set("slotNumber", String(slotNumber));
  return formData;
}

function dragData(event: React.DragEvent, payload: DragPayload) {
  event.dataTransfer.effectAllowed = "move";
  event.dataTransfer.setData("application/json", JSON.stringify(payload));
}

function readDragData(event: React.DragEvent): DragPayload | null {
  const raw = event.dataTransfer.getData("application/json");
  if (!raw) {
    return null;
  }

  try {
    return JSON.parse(raw) as DragPayload;
  } catch {
    return null;
  }
}

function MediaPoster({ item, inCollection, inWatchNow, onOpenDetails, onRejectClassic }: { item: CatalogItem; inCollection: boolean; inWatchNow: boolean; onOpenDetails: (item: CatalogItem) => void; onRejectClassic: (title: string) => void }) {
  return (
    <article
      draggable
      onDragStart={(event) => dragData(event, { type: "catalog", item })}
      className={"ampelos-card group cursor-grab transition hover:-translate-y-0.5 active:cursor-grabbing " + (item.isClassic ? "shadow-[0_0_0_1px_rgba(204,177,95,0.55),0_0_32px_rgba(204,177,95,0.18)]" : "")}
    >
      <button
        type="button"
        onClick={() => onOpenDetails(item)}
        className="relative block aspect-[2/3] w-full cursor-grab bg-ampelos-ink/65 text-left transition focus:outline-none focus:ring-2 focus:ring-inset focus:ring-ampelos-gold active:cursor-grabbing"
        aria-label={"Open details for " + item.title}
      >
        {item.posterUrl ? (
          <Image src={item.posterUrl} alt="" fill sizes="(min-width: 1280px) 16vw, (min-width: 1024px) 20vw, (min-width: 640px) 33vw, 50vw" className="object-cover" />
        ) : (
          <div className="flex h-full items-center justify-center px-4 text-center text-sm font-medium text-ampelos-muted">
            {item.title}
          </div>
        )}
        <div className="absolute inset-0 bg-black/0 transition group-hover:bg-black/10" />
        <div className="absolute left-2 top-2 flex gap-1">
          <span className="rounded bg-ampelos-ink/80 px-2 py-1 text-[11px] font-medium text-ampelos-parchment">
            {mediaLabel(item.kind)}
          </span>
          {inCollection && (
            <span className="rounded bg-ampelos-sage px-2 py-1 text-[11px] font-medium text-ampelos-ink">
              Dionysus
            </span>
          )}
          {item.isClassic && (
            <span className="rounded bg-ampelos-gold px-2 py-1 text-[11px] font-semibold text-ampelos-ink">
              Timeless
            </span>
          )}
        </div>
      </button>
      <div className="space-y-2 p-3">
        <div>
          <h3 className="line-clamp-2 text-sm font-semibold leading-snug text-ampelos-parchment">{item.title}</h3>
          <p className="mt-1 text-xs text-ampelos-muted/75">
            {item.year ?? "Unknown year"}
            {item.rating ? " · " + item.rating.toFixed(1) : ""}
          </p>
        </div>
        <p className={"line-clamp-3 min-h-12 rounded text-xs leading-4 text-ampelos-muted " + (item.isClassic ? "border border-ampelos-gold/35 bg-ampelos-gold/10 p-2 shadow-[0_0_22px_rgba(204,177,95,0.18)]" : "")}>
          {item.overview ?? "No summary available yet."}
        </p>
        <form action={(formData) => {
          void addToWatchNow(formData).then((result) => {
            if (result && !result.ok) onRejectClassic(item.title);
          });
        }}>
          <input type="hidden" name="kind" value={item.kind} />
          <input type="hidden" name="tmdbId" value={item.id} />
          <input type="hidden" name="title" value={item.title} />
          <input type="hidden" name="year" value={item.year ?? ""} />
          <input type="hidden" name="overview" value={item.overview ?? ""} />
          <input type="hidden" name="posterPath" value={item.posterUrl ?? ""} />
          <input type="hidden" name="releaseDate" value={item.releaseDate ?? ""} />
          <button
            type={item.isClassic ? "button" : "submit"}
            onClick={item.isClassic ? () => onRejectClassic(item.title) : undefined}
            disabled={inWatchNow}
            className="ampelos-stone-button w-full px-3 py-2 text-sm disabled:cursor-default"
          >
            {item.isClassic ? "Always Available" : inWatchNow ? "In Watch Now" : "Add to Watch Now"}
          </button>
        </form>
      </div>
    </article>
  );
}

function titleFormData(item: CatalogItem, on: boolean) {
  const formData = submitCatalogItem(item);
  formData.set("on", on ? "1" : "0");
  return formData;
}

/**
 * The buttons that say what should happen to a title.
 *
 * The first is for everybody and is about the person: Watch Now is the demand
 * signal this app is built around, and it is a toggle because the panel is
 * where someone looks to find out whether they already asked -- a button that
 * can only ever add makes them go and check the tray.
 *
 * The other two are admin-only and are about the library, so they are penned
 * off below a rule rather than mixed in. Neither deletes anything: purge
 * withdraws intent, and the worst it can do is stop a download.
 */
function TitleActions({
  item,
  flags,
  isAdmin,
  onFlags,
  onNotice,
}: {
  item: CatalogItem;
  flags: PanelFlags;
  isAdmin: boolean;
  onFlags: (flags: Partial<PanelFlags>) => void;
  onNotice: (message: string) => void;
}) {
  const [pending, startTransition] = useTransition();

  function run(action: (formData: FormData) => Promise<TitleActionResult>, on: boolean) {
    startTransition(() => {
      void action(titleFormData(item, on)).then((result) => {
        if (result.ok) {
          onFlags(result.flags);
          if (result.message) onNotice(result.message);
        } else {
          onNotice(result.message);
        }
      });
    });
  }

  function toggleWatchNow() {
    // A timeless title is always available, so a slot spent on one buys
    // nothing; the server refuses too, and saying so here saves the round trip.
    if (!flags.watchNow && flags.timeless) {
      onNotice(item.title + " is a Timeless Classic. It will always be available, so it does not need a Watch Now slot.");
      return;
    }

    startTransition(() => {
      const action = flags.watchNow
        ? removeFromWatchNowByTmdbId(titleFormData(item, false))
        : addToWatchNow(submitCatalogItem(item));

      void action.then((result) => {
        // A full tray is the ordinary reason an add fails, and the flag must
        // not flip on a request the server turned down.
        if (result && !result.ok) {
          onNotice(result.message);
          return;
        }
        onFlags({ watchNow: !flags.watchNow });
      });
    });
  }

  return (
    <div className="space-y-3">
      <div>
        <h3 className="font-semibold text-ampelos-parchment">Watch Now</h3>
        <button
          type="button"
          disabled={pending}
          onClick={toggleWatchNow}
          className={
            "mt-2 block w-full px-3 py-2 text-center text-sm disabled:opacity-60 " +
            (flags.watchNow ? "ampelos-ghost-button font-medium" : "ampelos-stone-button")
          }
        >
          {flags.watchNow ? "Remove from Watch Now" : "Add to Watch Now"}
        </button>
        <p className="mt-2 text-xs leading-5 text-ampelos-muted/80">
          {flags.watchNow
            ? "In one of your slots: kept on the live tier at the best quality Ampelos can find."
            : "Takes one of your slots and puts this on the live tier at the best quality Ampelos can find."}
        </p>
      </div>

      {isAdmin && (
        <div className="border-t border-ampelos-gold/15 pt-3">
          <h3 className="font-semibold text-ampelos-parchment">Admin</h3>
          <button
            type="button"
            disabled={pending}
            onClick={() => run(setTimeless, !flags.timeless)}
            className="ampelos-ghost-button mt-2 block w-full px-3 py-2 text-center text-sm font-medium disabled:opacity-60"
          >
            {flags.timeless ? "Timeless — remove" : "Make timeless"}
          </button>
          <button
            type="button"
            disabled={pending}
            onClick={() => run(setPurged, !flags.purged)}
            className="ampelos-ghost-button mt-2 block w-full px-3 py-2 text-center text-sm font-medium disabled:opacity-60"
          >
            {flags.purged ? "Purged — allow again" : "Add to purge"}
          </button>
          <p className="mt-2 text-xs leading-5 text-ampelos-muted/80">
            Timeless keeps a title live permanently and is the only thing allowed a remux.
            Purge stops Ampelos wanting it; it deletes nothing, and watching it still brings it back.
          </p>
        </div>
      )}
    </div>
  );
}

function DetailOverlay({
  state,
  isAdmin,
  onClose,
  onFlags,
  onNotice,
}: {
  state: DetailState;
  isAdmin: boolean;
  onClose: () => void;
  onFlags: (flags: Partial<PanelFlags>) => void;
  onNotice: (message: string) => void;
}) {
  const details = state.details;
  // The flag, not the fetched item: an admin who just switched it off should
  // see it off, and the item this overlay was opened with cannot know that.
  const isClassic = state.flags.timeless;
  const item = details ? { ...details, isClassic } : state.item;

  useEffect(() => {
    function onKeyDown(event: KeyboardEvent) {
      if (event.key === "Escape") {
        onClose();
      }
    }

    window.addEventListener("keydown", onKeyDown);
    return () => window.removeEventListener("keydown", onKeyDown);
  }, [onClose]);

  return (
    <div className="fixed inset-0 z-40 overflow-y-auto bg-ampelos-ink/82 p-3 backdrop-blur-sm md:p-8" onClick={onClose}>
      <section className="ampelos-surface mx-auto min-h-[80vh] max-w-5xl overflow-hidden text-ampelos-parchment shadow-2xl" onClick={(event) => event.stopPropagation()}>
        <div className="relative min-h-64 bg-ampelos-ink text-ampelos-parchment md:min-h-80">
          {item.backdropUrl ? (
            <Image src={item.backdropUrl} alt="" fill sizes="100vw" className="object-cover opacity-55" priority />
          ) : null}
          <div className="absolute inset-0 bg-gradient-to-t from-ampelos-ink via-ampelos-ink/65 to-ampelos-ink/10" />
          <button
            type="button"
            onClick={onClose}
            className="absolute right-3 top-3 grid h-9 w-9 place-items-center rounded-full bg-ampelos-ink/65 text-xl leading-none text-ampelos-parchment hover:bg-ampelos-ink"
            aria-label="Close details"
          >
            ×
          </button>
          <div className="absolute inset-x-0 bottom-0 flex gap-4 p-4 md:p-6">
            <div className="relative hidden aspect-[2/3] w-32 shrink-0 overflow-hidden rounded-md bg-ampelos-stone shadow-xl sm:block">
              {item.posterUrl ? <Image src={item.posterUrl} alt="" fill sizes="128px" className="object-cover" /> : null}
            </div>
            <div className="min-w-0 self-end">
              <p className="mb-2 text-xs font-semibold uppercase tracking-[0.28em] text-ampelos-gold">{item.kind === "movies" ? "Movie" : "Television"}</p>
              <h2 className="text-3xl font-semibold tracking-normal md:text-5xl">{item.title}</h2>
              <div className="mt-3 flex flex-wrap gap-2 text-xs font-medium text-ampelos-muted">
                {item.year && <span className="rounded bg-ampelos-parchment/10 px-2 py-1">{item.year}</span>}
                {isClassic && <span className="rounded bg-ampelos-gold px-2 py-1 font-semibold text-ampelos-ink">Timeless Classic</span>}
                {details?.status && <span className="rounded bg-ampelos-parchment/10 px-2 py-1">{details.status}</span>}
                {details?.runtime && <span className="rounded bg-ampelos-parchment/10 px-2 py-1">{details.runtime}</span>}
                {item.rating && <span className="rounded bg-ampelos-parchment/10 px-2 py-1">{item.rating.toFixed(1)} rating</span>}
              </div>
            </div>
          </div>
        </div>

        <div className="grid gap-6 p-4 md:grid-cols-[1fr_18rem] md:p-6">
          <div className="space-y-6">
            {state.loading ? (
              <div className="rounded-md border border-ampelos-gold/20 bg-ampelos-ink/35 p-4 text-sm text-ampelos-muted">Loading details...</div>
            ) : state.error ? (
              <div className="rounded-md border border-red-200 bg-red-50 p-4 text-sm text-red-700">{state.error}</div>
            ) : null}

            {details?.tagline ? <p className="text-lg font-medium text-ampelos-muted">{details.tagline}</p> : null}
            <div className={isClassic ? "rounded-md border border-ampelos-gold/40 bg-ampelos-gold/10 p-4 shadow-[0_0_30px_rgba(204,177,95,0.18)]" : ""}>
              <h3 className="text-sm font-semibold uppercase tracking-[0.24em] text-ampelos-gold">Overview</h3>
              {isClassic ? <p className="mt-2 text-sm font-medium text-ampelos-gold">Timeless Classic: this title stays in the main collection and does not need a Watch Now slot.</p> : null}
              <p className="mt-2 text-sm leading-6 text-ampelos-muted">{item.overview ?? "No description available yet."}</p>
            </div>

            {details?.cast.length ? (
              <div>
                <h3 className="text-sm font-semibold uppercase tracking-[0.24em] text-ampelos-gold">Cast</h3>
                <div className="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
                  {details.cast.map((person) => (
                    <div key={person.id} className="min-w-0 rounded-md border border-ampelos-gold/15 bg-ampelos-ink/35 p-2">
                      <div className="relative mb-2 aspect-[2/3] overflow-hidden rounded bg-ampelos-stone">
                        {person.profileUrl ? <Image src={person.profileUrl} alt="" fill sizes="120px" className="object-cover" /> : null}
                      </div>
                      <p className="truncate text-sm font-medium">{person.name}</p>
                      {person.role ? <p className="truncate text-xs text-ampelos-muted/70">{person.role}</p> : null}
                    </div>
                  ))}
                </div>
              </div>
            ) : null}
          </div>

          <aside className="space-y-4 rounded-md border border-ampelos-gold/15 bg-ampelos-ink/35 p-4 text-sm">
            <TitleActions item={item} flags={state.flags} isAdmin={isAdmin} onFlags={onFlags} onNotice={onNotice} />

            {details?.genres.length ? (
              <div>
                <h3 className="font-semibold text-ampelos-parchment">Genres</h3>
                <div className="mt-2 flex flex-wrap gap-2">
                  {details.genres.map((genre) => <span key={genre} className="rounded bg-ampelos-parchment/10 px-2 py-1 text-xs text-ampelos-muted">{genre}</span>)}
                </div>
              </div>
            ) : null}
            {details?.creators.length ? (
              <div>
                <h3 className="font-semibold text-ampelos-parchment">Created by</h3>
                <p className="mt-1 text-ampelos-muted">{details.creators.map((person) => person.name).join(", ")}</p>
              </div>
            ) : null}
            {details?.networks.length ? (
              <div>
                <h3 className="font-semibold text-ampelos-parchment">Network</h3>
                <p className="mt-1 text-ampelos-muted">{details.networks.join(", ")}</p>
              </div>
            ) : null}
            {details?.seasonCount || details?.episodeCount ? (
              <div>
                <h3 className="font-semibold text-ampelos-parchment">Episodes</h3>
                <p className="mt-1 text-ampelos-muted">
                  {details.seasonCount ? details.seasonCount + " seasons" : ""}
                  {details.seasonCount && details.episodeCount ? " · " : ""}
                  {details.episodeCount ? details.episodeCount + " episodes" : ""}
                </p>
              </div>
            ) : null}
            {details?.homepage ? (
              <a href={details.homepage} target="_blank" rel="noreferrer" className="ampelos-stone-button block px-3 py-2 text-center text-sm">
                Official site
              </a>
            ) : null}
          </aside>
        </div>
      </section>
    </div>
  );
}

function MusicPlaceholder() {
  return (
    <section className="ampelos-panel border-dashed p-8 text-center">
      <h2 className="text-lg font-semibold text-ampelos-parchment">Music is warming up backstage</h2>
      <p className="mx-auto mt-2 max-w-xl text-sm leading-6 text-ampelos-muted">
        The tab is here so the shape of Ampelos is honest early. Once music support is designed, this will use the same search, demand, and availability model as TV and movies.
      </p>
    </section>
  );
}

function buildSlots(items: WatchNowItem[], slotCount: number) {
  const slots: Array<WatchNowItem | null> = Array.from({ length: slotCount }, () => null);
  const unplaced: WatchNowItem[] = [];

  for (const item of items) {
    if (item.slotNumber && item.slotNumber >= 1 && item.slotNumber <= slotCount && !slots[item.slotNumber - 1]) {
      slots[item.slotNumber - 1] = item;
    } else {
      unplaced.push(item);
    }
  }

  for (const item of unplaced) {
    const emptyIndex = slots.findIndex((slot) => slot === null);
    if (emptyIndex === -1) {
      break;
    }
    slots[emptyIndex] = item;
  }

  return slots;
}

function WatchNowTray({ kind, items, slotCount, onRejectClassic }: { kind: CatalogKind; items: WatchNowItem[]; slotCount: number; onRejectClassic: (title: string) => void }) {
  const [, startTransition] = useTransition();
  const [dragOverSlot, setDragOverSlot] = useState<number | null>(null);
  const [isClosedDropTarget, setIsClosedDropTarget] = useState(false);
  const [isOpen, setIsOpen] = useState(false);
  const slots = useMemo(() => buildSlots(items, slotCount), [items, slotCount]);
  const noun = slotTitle(kind);

  function onDrop(event: React.DragEvent, slotNumber: number) {
    event.preventDefault();
    setDragOverSlot(null);
    const payload = readDragData(event);

    if (!payload) {
      return;
    }

    if (payload.type === "catalog") {
      if (payload.item.isClassic) {
        onRejectClassic(payload.item.title);
        return;
      }
      startTransition(() => {
        void addToWatchNow(submitCatalogItem(payload.item, slotNumber)).then((result) => {
          if (result && !result.ok) onRejectClassic(payload.item.title);
        });
      });
      return;
    }

    startTransition(() => moveWatchNowItem(submitWatchNowItem(payload.watchNowItemId, slotNumber)));
  }

  function onClosedDrop(event: React.DragEvent) {
    event.preventDefault();
    setIsClosedDropTarget(false);
    const payload = readDragData(event);

    if (!payload) {
      return;
    }

    if (payload.type === "catalog") {
      if (payload.item.isClassic) {
        onRejectClassic(payload.item.title);
        return;
      }
      startTransition(() => {
        void addToWatchNow(submitCatalogItem(payload.item)).then((result) => {
          if (result && !result.ok) onRejectClassic(payload.item.title);
        });
      });
      return;
    }

    const firstOpenSlot = slots.findIndex((slot) => slot === null) + 1;
    if (firstOpenSlot > 0) {
      startTransition(() => moveWatchNowItem(submitWatchNowItem(payload.watchNowItemId, firstOpenSlot)));
    }
  }

  return (
    <>
      {!isOpen && (
        <button
          type="button"
          onClick={() => setIsOpen(true)}
          onDragEnter={() => setIsClosedDropTarget(true)}
          onDragOver={(event) => {
            event.preventDefault();
            setIsClosedDropTarget(true);
          }}
          onDragLeave={() => setIsClosedDropTarget(false)}
          onDrop={onClosedDrop}
          className={
            "fixed bottom-3 right-3 z-30 grid h-14 w-14 place-items-center rounded-full border text-ampelos-parchment shadow-2xl transition hover:-translate-y-0.5 md:bottom-6 md:right-6 " +
            (isClosedDropTarget
              ? "border-ampelos-gold bg-ampelos-gold-strong shadow-[0_0_0_5px_rgba(152,170,88,0.24)]"
              : "border-ampelos-gold/30 bg-ampelos-ink hover:border-ampelos-gold hover:bg-ampelos-stone")
          }
          aria-label="Open Watch Now tray"
        >
          <span className="relative h-6 w-10" aria-hidden="true">
            <span className="absolute left-0 top-2 h-3.5 w-4 rounded-full bg-ampelos-gold shadow-[24px_0_0_#98aa58]" />
            <span className="absolute left-4 top-3.5 h-0.5 w-5 bg-ampelos-gold" />
            <span className="absolute left-0 top-0.5 h-1 w-10 rounded-full bg-ampelos-parchment/90" />
            <span className="absolute left-1 top-2.5 h-1 w-2 rounded-full bg-ampelos-ink/60 shadow-[24px_0_0_rgba(7,16,23,0.6)]" />
          </span>
          <span className="absolute -right-1 -top-1 rounded-full bg-ampelos-gold px-1.5 py-0.5 text-[10px] font-bold text-ampelos-ink">
            {items.length}
          </span>
        </button>
      )}

      {isOpen && (
        <aside className="ampelos-surface fixed inset-x-3 bottom-3 z-30 p-3 text-ampelos-parchment shadow-2xl backdrop-blur md:inset-x-auto md:bottom-6 md:right-6 md:w-[28rem]">
          <div className="flex max-h-[16rem] flex-col gap-3 overflow-hidden md:max-h-[72vh]" onDragEnd={() => setDragOverSlot(null)}>
            <div className="flex items-center justify-between gap-3">
              <div className="min-w-0">
                <p className="text-xs font-semibold uppercase tracking-[0.28em] text-ampelos-gold">Watch Now</p>
                <h2 className="truncate text-base font-semibold">
                  {items.length ? "Your active " + noun + " slots" : "Your " + noun + " slots are empty"}
                </h2>
              </div>
              <div className="flex shrink-0 items-center gap-2">
                <p className="text-xs text-ampelos-muted">{items.length}/{slotCount}</p>
                <button
                  type="button"
                  onClick={() => setIsOpen(false)}
                  className="grid h-8 w-8 place-items-center rounded text-lg leading-none text-ampelos-muted hover:bg-ampelos-parchment/10 hover:text-ampelos-parchment"
                  aria-label="Collapse Watch Now tray"
                >
                  ×
                </button>
              </div>
            </div>
            <div className="grid max-h-[9.5rem] grid-cols-2 gap-2 overflow-y-auto pr-1 md:max-h-[calc(72vh-4.5rem)]">
              {slots.map((item, index) => {
                const slotNumber = index + 1;
                return (
                  <div
                    key={slotNumber}
                    onDragEnter={() => setDragOverSlot(slotNumber)}
                    onDragOver={(event) => {
                      event.preventDefault();
                      setDragOverSlot(slotNumber);
                    }}
                    onDragLeave={(event) => {
                      if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
                        setDragOverSlot((current) => current === slotNumber ? null : current);
                      }
                    }}
                    onDrop={(event) => onDrop(event, slotNumber)}
                    className={
                      "min-h-20 rounded-md border border-dashed p-2 transition " +
                      (dragOverSlot === slotNumber
                        ? "border-ampelos-gold bg-ampelos-gold/20 shadow-[0_0_0_2px_rgba(152,170,88,0.28)]"
                        : "border-ampelos-gold/20 bg-ampelos-ink/35 hover:border-ampelos-gold/75 hover:bg-ampelos-gold/10")
                    }
                  >
                    {item ? (
                      <div
                        draggable
                        onDragStart={(event) => dragData(event, { type: "watch-now", watchNowItemId: item.id })}
                        className="flex h-full cursor-grab items-center gap-2 rounded bg-ampelos-parchment/10 p-2 active:cursor-grabbing"
                      >
                        <div className="relative h-14 w-10 shrink-0 overflow-hidden rounded bg-ampelos-stone">
                          {item.posterPath ? (
                            <Image src={item.posterPath} alt="" fill sizes="40px" className="object-cover" />
                          ) : (
                            <span className="flex h-full w-full items-center justify-center text-xs font-semibold text-ampelos-muted">{item.title.slice(0, 1)}</span>
                          )}
                        </div>
                        <div className="min-w-0 flex-1">
                          <p className="truncate text-sm font-medium">{item.title}</p>
                        </div>
                        <form action={removeFromWatchNow}>
                          <input type="hidden" name="watchNowItemId" value={item.id} />
                          <button type="submit" className="grid h-7 w-7 place-items-center rounded text-lg leading-none text-ampelos-muted hover:bg-ampelos-parchment/10 hover:text-ampelos-parchment" aria-label={"Remove " + item.title + " from Watch Now"}>
                            ×
                          </button>
                        </form>
                      </div>
                    ) : (
                      <div className="flex h-full min-h-16 items-center justify-center rounded text-center text-xs font-medium text-ampelos-muted/70">
                        Drop {noun} here
                      </div>
                    )}
                  </div>
                );
              })}
            </div>
          </div>
        </aside>
      )}
    </>
  );
}

export function CatalogBoard({ kind, visibleItems, collectionIds, watchNowExternalIds, purgedExternalIds, watchNowItems, slotCount, isAdmin }: CatalogBoardProps) {
  const collectionSet = useMemo(() => new Set(collectionIds), [collectionIds]);
  const watchNowSet = useMemo(() => new Set(watchNowExternalIds), [watchNowExternalIds]);
  const purgedSet = useMemo(() => new Set(purgedExternalIds), [purgedExternalIds]);
  const [detailState, setDetailState] = useState<DetailState | null>(null);
  const [notice, setNotice] = useState<string | null>(null);

  function rejectClassic(title: string) {
    setNotice(title + " is a Timeless Classic. It will always be available, so it does not need a Watch Now slot.");
  }

  // Applied to whatever is open now, and only if it is still the same title --
  // a slow action must not stamp its result onto a panel the person has since
  // moved on from.
  function applyFlags(itemId: string, flags: Partial<PanelFlags>) {
    setDetailState((current) =>
      current && current.item.id === itemId
        ? { ...current, flags: { ...current.flags, ...flags } }
        : current,
    );
  }

  async function openDetails(item: CatalogItem) {
    const flags: PanelFlags = {
      timeless: Boolean(item.isClassic),
      purged: purgedSet.has(item.id),
      watchNow: watchNowSet.has(item.id),
    };
    setDetailState({ item, details: null, loading: true, error: null, flags });

    try {
      const response = await fetch("/api/catalog/" + item.kind + "/" + item.id);
      if (!response.ok) {
        throw new Error("Unable to load details for " + item.title);
      }

      const details = (await response.json()) as CatalogDetails;
      setDetailState((current) => {
        if (!current || current.item.id !== item.id) return current;
        return {
          ...current,
          details,
          loading: false,
          error: null,
          // The detail fetch can know a title is timeless when the board's list
          // did not, so it may only ever turn the flag ON -- reading it back
          // wholesale would undo an admin who just switched it off while this
          // request was in the air.
          flags: { ...current.flags, timeless: current.flags.timeless || Boolean(details.isClassic) },
        };
      });
    } catch (error) {
      setDetailState((current) => current && current.item.id === item.id
        ? {
          ...current,
          details: null,
          loading: false,
          error: error instanceof Error ? error.message : "Unable to load details.",
        }
        : current);
    }
  }

  return (
    <>
      {kind === "music" ? (
        <div className="mt-5"><MusicPlaceholder /></div>
      ) : visibleItems.length ? (
        <div className="mt-5 grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6">
          {visibleItems.map((item) => (
            <MediaPoster key={item.kind + "-" + item.id} item={item} inCollection={collectionSet.has(item.id)} inWatchNow={watchNowSet.has(item.id)} onOpenDetails={openDetails} onRejectClassic={rejectClassic} />
          ))}
        </div>
      ) : (
        <section className="ampelos-panel mt-5 p-8 text-center">
          <h2 className="text-lg font-semibold">No matching titles yet</h2>
          <p className="mt-2 text-sm text-ampelos-muted">
            Try clearing the Dionysus collection filter or searching for another title.
          </p>
        </section>
      )}

      {notice && (
        <div className="fixed left-3 right-3 top-3 z-50 mx-auto max-w-xl rounded-md border border-ampelos-gold/60 bg-ampelos-ink px-4 py-3 text-sm font-medium text-ampelos-parchment shadow-2xl md:left-auto md:right-6 md:top-6">
          <div className="flex items-start justify-between gap-3">
            <p>{notice}</p>
            <button type="button" onClick={() => setNotice(null)} className="text-lg leading-none text-ampelos-muted hover:text-ampelos-parchment" aria-label="Dismiss message">×</button>
          </div>
        </div>
      )}

      {detailState && (
        <DetailOverlay
          state={detailState}
          isAdmin={isAdmin}
          onClose={() => setDetailState(null)}
          onFlags={(flags) => applyFlags(detailState.item.id, flags)}
          onNotice={setNotice}
        />
      )}

      {kind !== "music" && <WatchNowTray kind={kind} items={watchNowItems} slotCount={slotCount} onRejectClassic={rejectClassic} />}
    </>
  );
}
