import {
  lazy,
  Suspense,
  useEffect,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
} from "react";
import { qk } from "../lib/queryKeys";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import {
  useInfiniteQuery,
  useQuery,
  useQueryClient,
  type InfiniteData,
} from "@tanstack/react-query";
import {
  ArrowLeft,
  Check,
  CheckCheck,
  CheckCircle2,
  Film,
  Info,
  Layers,
  ListPlus,
  Play,
  RotateCcw,
  Star,
  Tv2,
  User,
  X,
  type LucideIcon,
} from "lucide-react";
import {
  api,
  EMPTY_PLEX_FILTERS,
  plexFilterCount,
  type PlexCard,
  type PlexCastMember,
  type PlexFilters,
  type PlexSeasonDetail,
  type PlexUnifiedResult,
} from "../lib/api";
import { formatRuntime } from "../lib/format";
import { useDebounced } from "../lib/useDebounced";
import { useHistorySubview } from "../lib/history";
import {
  DetailCustomizeMenu,
  Filterable,
  useArtBackdrop,
  useDetailPrefs,
} from "../lib/plexDetailUi";
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
import PlexCollectionEditor from "./PlexCollectionEditor";
import { LoadingState, StateMessage } from "./QueryState";
import { PageToolbar } from "./PageShell";
import { usePlex, usePlexActions } from "./PlexProvider";

// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
const PlexPlayer = lazy(() => import("./PlexPlayer"));
const PlexInfo = lazy(() => import("./PlexInfo"));
const PlexPlaylistView = lazy(() => import("./PlexPlaylistView"));

type Sub =
  | { kind: "grid" }
  | { kind: "show"; id: string }
  | { kind: "season"; showId: string; seasonId: string }
  | { kind: "player"; id: string; queue?: string[] }
  | { kind: "info"; id: string }
  | { kind: "playlist"; id: number };

// The Plex module's content area: browse/search the mirrored library as poster cards, drill from a
// show into seasons/episodes. Library scope / watch-state / sort live in the left PlexSidebar; the
// search term comes from the shared top Header search box (q). A show drill-down rides history
// (useHistorySubview) so browser Back returns to the grid. Playback lands in P2 — a card announces
// for now. Rendered by App for page==="plex" (its own nav module).

const PAGE = 40;

export default function PlexBrowse() {
  const { t } = useTranslation();
  const qc = useQueryClient();
  // Scope / watch-state / sort / filters live in PlexProvider (edited mainly from PlexSidebar); the
  // search term (q) comes from the shared header box; the rail hands off a playlist to open via the
  // shared `playlistOpen` slot, which we consume into a history subview then clear.
  const { q, scope, show, sort, filters, playlistOpen: openPlaylist } = usePlex();
  const { setScope, setFilters, setPlaylistOpen } = usePlexActions();
  const dq = useDebounced(q.trim(), 350);
  const sub = useHistorySubview<Sub>({ kind: "grid" });

  // Sidebar asked to open a playlist → push it as a subview (browser Back returns to the grid), then
  // clear the signal so it doesn't re-open.
  useEffect(() => {
    if (openPlaylist != null) {
      sub.open({ kind: "playlist", id: openPlaylist });
      setPlaylistOpen(null);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [openPlaylist]);

  // Opening a show/player replaces the grid entirely (the component returns early below), so the
  // scroll container resets to the top. Remember where the grid was scrolled when leaving it, and
  // restore it when we come back — so the browser/mouse Back from the player lands on the same card
  // instead of the top of the library. The scroller is App's <main> (the page's overflow-y-auto).
  const scrollRef = useRef(0);
  // Same idea for the info page: "Browse collection" (onFilter) leaves the info view for a filtered
  // grid, and browser Back returns to this same info page — restore where the user had scrolled it
  // (down to a collection strip) instead of snapping to the top. Reset on a fresh info open so a
  // different title's info starts at the top.
  const infoScrollRef = useRef(0);
  const scroller = () => document.querySelector("main");
  useLayoutEffect(() => {
    const el = scroller();
    if (!el) return;
    if (sub.view.kind === "grid" && scrollRef.current) el.scrollTop = scrollRef.current;
    else if (sub.view.kind === "info" && infoScrollRef.current)
      el.scrollTop = infoScrollRef.current;
  }, [sub.view.kind]);

  // Backspace steps back one drill-down level (grid ← show ← season, and out of info/playlist),
  // matching the browser/mouse Back. The player has its OWN Backspace handling, so we skip it here;
  // and never hijack Backspace while typing in a field.
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if (e.key !== "Backspace") return;
      const el = document.activeElement as HTMLElement | null;
      const tag = (el?.tagName || "").toLowerCase();
      if (tag === "input" || tag === "textarea" || tag === "select" || el?.isContentEditable)
        return;
      if (["show", "season", "info", "playlist"].includes(sub.view.kind)) {
        e.preventDefault();
        sub.back();
      }
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [sub]);

  const browseQ = useInfiniteQuery({
    queryKey: qk.plexLibrary(scope, dq, sort, show, filters),
    enabled: !!scope && sub.view.kind === "grid",
    initialPageParam: 0,
    queryFn: ({ pageParam }) =>
      api.plexLibrary({
        scope,
        q: dq || undefined,
        sort,
        show,
        filters,
        offset: pageParam as number,
        limit: PAGE,
      }),
    getNextPageParam: (last) => {
      const seen = last.offset + last.items.length;
      return seen < last.total ? seen : undefined;
    },
  });

  const items = browseQ.data?.pages.flatMap((p) => p.items) ?? [];
  const total = browseQ.data?.pages[0]?.total ?? 0;
  // Cards for the actors currently in the filter (name + headshot + in-scope count) — shown above the
  // grid so you SEE who you're exploring, not just chips in the rail.
  const peopleCardsQ = useQuery({
    queryKey: qk.plexPeopleCards(scope, filters.actors),
    queryFn: () => api.plexPeopleCards(filters.actors, scope),
    enabled: sub.view.kind === "grid" && filters.actors.length > 0,
    staleTime: 60_000,
  });
  const personCards = peopleCardsQ.data?.people ?? [];
  const removeActor = (name: string) =>
    setFilters({ ...filters, actors: filters.actors.filter((x) => x !== name) });
  // Episode matches (grouped "Episodes" section) — search-only; same set on every page, take page 0.
  const episodes = browseQ.data?.pages[0]?.episodes ?? [];

  // Infinite scroll: auto-load the next page when the sentinel scrolls into view. The sentinel lives
  // in the grid branch, which UNMOUNTS when we drill into an info/show/season page and remounts on
  // Back — as a NEW element. A callback ref (state, not useRef) re-runs this effect on every
  // mount/unmount, so the observer always watches the live node; a plain ref would keep observing the
  // detached old node after Back and never fire fetchNextPage again (feed stuck at the loaded pages).
  const [sentinelEl, setSentinelEl] = useState<HTMLDivElement | null>(null);
  const { hasNextPage, isFetchingNextPage, fetchNextPage } = browseQ;
  useEffect(() => {
    if (!sentinelEl) return;
    const io = new IntersectionObserver(
      (entries) => {
        if (entries[0]?.isIntersecting && hasNextPage && !isFetchingNextPage) fetchNextPage();
      },
      { rootMargin: "600px" },
    );
    io.observe(sentinelEl);
    return () => io.disconnect();
  }, [sentinelEl, hasNextPage, isFetchingNextPage, fetchNextPage]);

  function onCard(card: PlexCard) {
    scrollRef.current = scroller()?.scrollTop ?? 0; // remember grid position for the trip back
    if (card.type === "show") sub.open({ kind: "show", id: card.id });
    else sub.open({ kind: "player", id: card.id });
  }
  function onInfo(card: PlexCard) {
    scrollRef.current = scroller()?.scrollTop ?? 0;
    infoScrollRef.current = 0; // fresh info page starts at the top
    sub.open({ kind: "info", id: card.id });
  }
  // Optimistically flip a card's status in every cached library page — both the title grid (`items`)
  // and the search "Episodes" section (`episodes`) — so the quick toggle reacts instantly and reliably
  // BOTH ways (mark and un-mark). An id matches in only one array, so touching both is safe.
  function optimisticStatus(id: string, next: string) {
    qc.setQueriesData<InfiniteData<PlexUnifiedResult>>({ queryKey: qk.plexLibrary() }, (old) =>
      old
        ? {
            ...old,
            pages: old.pages.map((pg) => ({
              ...pg,
              items: pg.items.map((it) => (it.id === id ? { ...it, status: next } : it)),
              episodes: pg.episodes.map((ep) => (ep.id === id ? { ...ep, status: next } : ep)),
            })),
          }
        : old,
    );
  }
  async function toggleWatched(card: PlexCard) {
    const next = card.status === "watched" ? "new" : "watched";
    optimisticStatus(card.id, next);
    await api.plexSetState(card.id, next).catch(() => {});
    qc.invalidateQueries({ queryKey: qk.plexLibrary() });
  }
  // Apply a metadata filter (clicked on an info page / show hero / cast) then show the unified grid.
  // Array filters (genres/people/studios) UNION with what's set; scalars replace. Clicking a person
  // (cast/crew) widens to the 'both' scope so their movies AND shows show up together (mixed feed).
  function applyFilter(patch: Partial<PlexFilters>) {
    const merged = { ...filters } as unknown as Record<string, unknown>;
    for (const [k, v] of Object.entries(patch)) {
      const cur = merged[k];
      if (Array.isArray(v) && Array.isArray(cur)) {
        merged[k] = Array.from(new Set([...(cur as unknown[]), ...(v as unknown[])]));
      } else {
        merged[k] = v;
      }
    }
    setFilters(merged as unknown as PlexFilters);
    if (patch.actors?.length || patch.directors?.length) setScope("both");
    infoScrollRef.current = scroller()?.scrollTop ?? 0; // restore on Back to the info/show page
    sub.open({ kind: "grid" });
  }

  if (sub.view.kind === "player") {
    // The fallback is portaled for the same reason the player itself is: it is a fixed
    // full-viewport overlay, and this tree renders inside the page scroller.
    return (
      <Suspense
        fallback={createPortal(<div className="fixed inset-0 z-overlay bg-black" />, document.body)}
      >
        <PlexPlayer itemId={sub.view.id} queue={sub.view.queue} onClose={sub.back} />
      </Suspense>
    );
  }
  if (sub.view.kind === "playlist") {
    const pid = sub.view.id;
    return (
      <Suspense fallback={<p className="p-8 text-muted text-sm">{t("plex.loading")}</p>}>
        <PlexPlaylistView
          playlistId={pid}
          onBack={sub.back}
          onPlay={(itemRk, queue) => sub.open({ kind: "player", id: itemRk, queue })}
        />
      </Suspense>
    );
  }
  if (sub.view.kind === "info") {
    const infoId = sub.view.id;
    return (
      <PlexInfoView
        id={infoId}
        onBack={sub.back}
        onPlay={() => sub.open({ kind: "player", id: infoId })}
        onPlayItem={(id) => sub.open({ kind: "player", id })}
        onFilter={applyFilter}
      />
    );
  }
  if (sub.view.kind === "show") {
    const showId = sub.view.id;
    return (
      <PlexShowView
        showId={showId}
        onBack={sub.back}
        onPlay={(epRk, queue) => sub.open({ kind: "player", id: epRk, queue })}
        onOpenSeason={(seasonId) => sub.open({ kind: "season", showId, seasonId })}
        onOpenShow={(id) => sub.open({ kind: "show", id })}
        onFilter={applyFilter}
      />
    );
  }
  if (sub.view.kind === "season") {
    const { showId, seasonId } = sub.view;
    return (
      <PlexSeasonView
        showId={showId}
        seasonId={seasonId}
        onBack={sub.back}
        onPlay={(epRk, queue) => sub.open({ kind: "player", id: epRk, queue })}
      />
    );
  }

  return (
    <div className="p-4 max-w-[1600px] mx-auto">
      {/* The title count is fixed chrome — it portals into the shell's fixed band so it stays put
          while only the poster grid scrolls. */}
      <PageToolbar>
        <div className="px-4 pt-3 pb-1 max-w-[1600px] mx-auto">
          <p className="text-xs text-muted">
            {browseQ.isLoading
              ? " "
              : dq
                ? t("plex.searchCount", { count: total })
                : t("plex.count", { count: total })}
          </p>
        </div>
      </PageToolbar>

      {personCards.length > 0 && (
        <div className="mb-4 flex flex-wrap gap-3">
          {personCards.map((p) => (
            <div
              key={p.name}
              className="glass-card flex items-center gap-2.5 rounded-xl py-1.5 pl-1.5 pr-2.5"
            >
              {p.photo ? (
                <img
                  src={p.photo}
                  alt=""
                  loading="lazy"
                  className="w-10 h-10 rounded-full object-cover shrink-0"
                />
              ) : (
                <div className="w-10 h-10 rounded-full grid place-items-center bg-surface text-muted shrink-0">
                  <User className="w-5 h-5" />
                </div>
              )}
              <div className="min-w-0">
                <div className="text-sm font-medium truncate max-w-[150px]">{p.name}</div>
                <div className="text-[11px] text-muted">
                  {t("plex.people.count", { count: p.count })}
                </div>
              </div>
              <button
                onClick={() => removeActor(p.name)}
                title={t("plex.people.remove")}
                aria-label={t("plex.people.remove")}
                className="ml-1 p-1 rounded-full text-muted hover:text-fg hover:bg-card transition"
              >
                <X className="w-4 h-4" />
              </button>
            </div>
          ))}
        </div>
      )}

      {browseQ.isError && !browseQ.data ? (
        <StateMessage tone="error" onRetry={() => browseQ.refetch()}>
          {t("common.loadError")}
        </StateMessage>
      ) : browseQ.isLoading ? (
        <p className="text-muted text-sm">{t("plex.loading")}</p>
      ) : items.length === 0 && episodes.length === 0 ? (
        dq && plexFilterCount(filters) > 0 ? (
          // A search that comes up empty WHILE filters are active is usually the filters, not the
          // query — say so and offer a one-click escape, instead of a bare "No matches".
          <div className="flex flex-wrap items-center gap-3 text-sm text-muted">
            <span>{t("plex.noMatchesFiltered", { count: plexFilterCount(filters) })}</span>
            <button
              onClick={() => setFilters(EMPTY_PLEX_FILTERS)}
              className="rounded-full border border-border bg-card px-3 py-1 text-xs font-medium transition hover:border-accent hover:text-accent"
            >
              {t("plex.filter.clearAll")}
            </button>
          </div>
        ) : (
          <p className="text-muted text-sm">{dq ? t("plex.noMatches") : t("plex.empty")}</p>
        )
      ) : (
        <>
          {/* Titles grid — movies + shows mixed (visually tagged). */}
          {items.length > 0 && (
            <>
              {episodes.length > 0 && (
                <h2 className="text-sm font-semibold mb-2">{t("plex.unified.titles")}</h2>
              )}
              <div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
                {items.map((c) => (
                  <PlexPosterCard
                    key={c.id}
                    card={c}
                    onOpen={() => onCard(c)}
                    onInfo={() => onInfo(c)}
                    onToggleWatched={toggleWatched}
                  />
                ))}
              </div>
            </>
          )}
          {/* Episodes section (Proposal 3) — matching episodes on a search, kept out of the title grid. */}
          {episodes.length > 0 && (
            <section className="mt-8">
              <h2 className="text-sm font-semibold mb-3">{t("plex.unified.episodes")}</h2>
              <div className="grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-x-3 gap-y-4">
                {episodes.map((ep) => (
                  <EpisodeCard
                    key={ep.id}
                    ep={ep}
                    withShowTitle
                    onPlay={() => onCard(ep)}
                    onToggleWatched={() => toggleWatched(ep)}
                  />
                ))}
              </div>
            </section>
          )}
        </>
      )}

      <div ref={setSentinelEl} className="h-8" />
      {isFetchingNextPage && (
        <p className="text-center text-xs text-muted pb-4">{t("plex.loading")}</p>
      )}
    </div>
  );
}

const PLAYABLE_TINT: Record<string, string> = {
  direct: "bg-emerald-500",
  remux: "bg-amber-500",
  transcode: "bg-orange-600",
};

function PlexPosterCard({
  card,
  onOpen,
  onInfo,
  onToggleWatched,
}: {
  card: PlexCard;
  onOpen: () => void;
  onInfo: () => void;
  onToggleWatched: (c: PlexCard) => void;
}) {
  const { t } = useTranslation();
  const inProgress = (card.position_seconds ?? 0) > 0 && card.status !== "watched";
  const isPlayable = card.type !== "show"; // movies/episodes play; shows open the drill-down
  const pct =
    inProgress && card.duration_seconds
      ? Math.min(100, Math.round(((card.position_seconds ?? 0) / card.duration_seconds) * 100))
      : 0;
  return (
    <div
      className="group text-left"
      // content-visibility keeps off-screen posters cheap to render (smoother long-list scroll).
      style={{ contentVisibility: "auto", containIntrinsicSize: "auto 300px" } as CSSProperties}
    >
      <div
        onClick={onOpen}
        role="button"
        tabIndex={0}
        onKeyDown={(e) => {
          if (e.key === "Enter" || e.key === " ") {
            e.preventDefault();
            onOpen();
          }
        }}
        className="relative aspect-[2/3] rounded-xl overflow-hidden bg-card border border-border cursor-pointer"
      >
        <img
          src={card.thumb}
          alt=""
          loading="lazy"
          decoding="async"
          className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
        />
        {/* Hover affordance: a play overlay on every card (movies/episodes play; a show opens). */}
        <div className="absolute inset-0 bg-black/45 opacity-0 group-hover:opacity-100 transition grid place-items-center">
          <div className="flex flex-col items-center gap-1 text-white">
            <Play className="w-10 h-10" fill="currentColor" />
            <span className="text-xs font-medium">
              {card.type === "show"
                ? t("plex.openShow")
                : inProgress
                  ? t("plex.resume")
                  : t("plex.play")}
            </span>
          </div>
        </div>
        {/* Quick watched toggle (movies/episodes), on hover. */}
        {isPlayable && (
          <button
            onClick={(e) => {
              e.stopPropagation();
              onToggleWatched(card);
            }}
            title={card.status === "watched" ? t("plex.markUnwatched") : t("plex.markWatched")}
            className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 opacity-0 group-hover:opacity-100 hover:bg-black/80 transition"
          >
            <CheckCircle2
              className={`w-4 h-4 ${card.status === "watched" ? "text-emerald-400" : "text-white"}`}
            />
          </button>
        )}
        {/* Media info page (movies/episodes). */}
        {isPlayable && (
          <button
            onClick={(e) => {
              e.stopPropagation();
              onInfo();
            }}
            title={t("plex.info.title")}
            className="absolute bottom-1.5 left-1.5 p-1 rounded-full bg-black/60 opacity-0 group-hover:opacity-100 hover:bg-black/80 transition"
          >
            <Info className="w-4 h-4 text-white" />
          </button>
        )}
        {/* Watched badge when not hovering (the toggle replaces it on hover). */}
        {card.status === "watched" && (
          <span className="pointer-events-none absolute top-1.5 right-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded group-hover:opacity-0">
            {t("plex.watched")}
          </span>
        )}
        {card.type === "show" && card.season_count != null && (
          <span className="absolute bottom-1.5 left-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded">
            {t("plex.seasons", { count: card.season_count })}
          </span>
        )}
        {/* Aggregate in-progress badge for a partially-watched show. */}
        {card.type === "show" && card.status === "in_progress" && (
          <span className="pointer-events-none absolute top-1.5 right-1.5 text-[10px] bg-black/75 text-accent px-1.5 py-0.5 rounded group-hover:opacity-0">
            {t("plex.inProgress")}
          </span>
        )}
        {card.playable && (
          <span
            className={`absolute top-1.5 left-1.5 w-2 h-2 rounded-full ${PLAYABLE_TINT[card.playable] ?? "bg-gray-400"}`}
            title={t(`plex.playable.${card.playable}`)}
          />
        )}
        {/* Type tag — tell a movie card from a show card at a glance (unified feed). */}
        <span
          className="absolute bottom-1.5 right-1.5 rounded bg-black/65 p-0.5 text-white/90"
          title={t(
            card.type === "show" ? "plex.filter.scopeOpt.show" : "plex.filter.scopeOpt.movie",
          )}
        >
          {card.type === "show" ? <Tv2 className="w-3 h-3" /> : <Film className="w-3 h-3" />}
        </span>
        {pct > 0 && (
          <div className="absolute bottom-0 inset-x-0 h-1 bg-black/40">
            <div className="h-full bg-accent" style={{ width: `${pct}%` }} />
          </div>
        )}
      </div>
      <div
        onClick={onOpen}
        className="mt-1.5 rounded-lg px-2 py-1.5 cursor-pointer"
        style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }}
      >
        <div className="text-sm font-medium line-clamp-2 leading-tight hover:text-accent">
          {card.title}
        </div>
        <div className="text-xs text-muted">
          {[card.year, formatRuntime(card.duration_seconds)].filter(Boolean).join(" · ")}
        </div>
      </div>
    </div>
  );
}

function PlexInfoView({
  id,
  onBack,
  onPlay,
  onPlayItem,
  onFilter,
}: {
  id: string;
  onBack: () => void;
  onPlay: () => void;
  onPlayItem: (id: string) => void;
  onFilter: (patch: Partial<PlexFilters>) => void;
}) {
  const { t } = useTranslation();
  const q = useQuery({ queryKey: qk.plexItem(id), queryFn: () => api.plexItem(id) });
  return (
    <div className="w-[90%] max-w-[1600px] mx-auto">
      <div className="p-4 pb-0">
        <button
          onClick={onBack}
          className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs"
        >
          <ArrowLeft className="w-4 h-4" />
          {t("plex.backToLibrary")}
        </button>
      </div>
      {q.isError && !q.data ? (
        <StateMessage tone="error" onRetry={() => q.refetch()}>
          {t("common.loadError")}
        </StateMessage>
      ) : q.isLoading || !q.data ? (
        <LoadingState label={t("plex.loading")} />
      ) : (
        <Suspense fallback={<p className="p-8 text-muted text-sm">{t("plex.loading")}</p>}>
          <PlexInfo
            detail={q.data}
            variant="page"
            library={q.data.library ?? undefined}
            onPlay={onPlay}
            onPlayItem={onPlayItem}
            onFilter={onFilter}
            // No onStateChange: PlexInfo.setState already invalidates qk.plexItem(id) (this very
            // query), so q refetches automatically — a manual refetch here just double-fetched.
          />
        </Suspense>
      )}
    </div>
  );
}

// --- Series drill-down: show detail page → season subpage → player -------------------------------
// Both the show page and the season page read the SAME cached qk.plexShow(showId) payload (the
// season page just picks its season out of it), so opening a season is instant and PlexPlaylistAdd's
// whole-show/season gather keeps working. Actions: Resume (on-deck episode), Play (from the start),
// Mark whole show/season watched/unwatched, Add to playlist (show/season/episode), and — admin only —
// Add the whole show to a collection.

function epLabel(ep?: PlexCard | null): string | undefined {
  if (!ep) return undefined;
  if (ep.season_number != null && ep.episode_number != null)
    return `S${ep.season_number} · E${ep.episode_number}`;
  return ep.title;
}

function BackBtn({ onBack, label }: { onBack: () => void; label: string }) {
  return (
    <button
      onClick={onBack}
      className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs mb-4"
    >
      <ArrowLeft className="w-4 h-4" />
      {label}
    </button>
  );
}

function ActionBtn({
  onClick,
  icon: Icon,
  label,
  sub,
  primary,
  disabled,
}: {
  onClick: () => void;
  icon: LucideIcon;
  label: string;
  sub?: string;
  primary?: boolean;
  disabled?: boolean;
}) {
  return (
    <button
      onClick={onClick}
      disabled={disabled}
      className={`inline-flex items-center gap-2 rounded-xl px-3 py-2 text-sm font-medium transition disabled:opacity-50 ${
        primary ? "bg-accent text-accent-fg shadow-sm hover:opacity-90" : "glass-card glass-hover"
      }`}
    >
      <Icon className="w-4 h-4 shrink-0" />
      <span className="flex flex-col items-start leading-tight">
        <span>{label}</span>
        {sub && <span className="text-[11px] font-normal opacity-70">{sub}</span>}
      </span>
    </button>
  );
}

function SeasonCard({
  se,
  onOpen,
  onToggleWatched,
  onAdd,
}: {
  se: PlexSeasonDetail;
  onOpen: () => void;
  onToggleWatched?: () => void;
  onAdd?: () => void;
}) {
  const { t } = useTranslation();
  const watched = se.status === "watched";
  return (
    <div className="group text-left">
      <div
        onClick={onOpen}
        role="button"
        tabIndex={0}
        onKeyDown={(e) => {
          if (e.key === "Enter" || e.key === " ") {
            e.preventDefault();
            onOpen();
          }
        }}
        className="relative aspect-[2/3] rounded-xl overflow-hidden bg-card border border-border cursor-pointer"
      >
        <img
          src={se.thumb}
          alt=""
          loading="lazy"
          className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
        />
        <div className="absolute inset-0 bg-black/45 opacity-0 group-hover:opacity-100 transition grid place-items-center">
          <div className="flex flex-col items-center gap-1 text-white">
            <Play className="w-9 h-9" fill="currentColor" />
            <span className="text-xs font-medium">{t("plex.openShow")}</span>
          </div>
        </div>
        {/* Quick watched toggle (whole season), on hover. */}
        {onToggleWatched && (
          <button
            onClick={(e) => {
              e.stopPropagation();
              onToggleWatched();
            }}
            title={
              watched ? t("plex.series.markSeasonUnwatched") : t("plex.series.markSeasonWatched")
            }
            className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 opacity-0 group-hover:opacity-100 hover:bg-black/80 transition"
          >
            <CheckCircle2 className={`w-4 h-4 ${watched ? "text-emerald-400" : "text-white"}`} />
          </button>
        )}
        {/* Add whole season to a playlist, on hover. */}
        {onAdd && (
          <button
            onClick={(e) => {
              e.stopPropagation();
              onAdd();
            }}
            title={t("plex.playlist.addSeason")}
            className="absolute bottom-1.5 right-1.5 p-1 rounded-full bg-black/60 opacity-0 group-hover:opacity-100 hover:bg-black/80 transition"
          >
            <ListPlus className="w-4 h-4 text-white" />
          </button>
        )}
        {watched && (
          <span className="pointer-events-none absolute top-1.5 right-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded group-hover:opacity-0">
            {t("plex.watched")}
          </span>
        )}
        {se.status === "in_progress" && (
          <span className="pointer-events-none absolute top-1.5 right-1.5 text-[10px] bg-black/75 text-accent px-1.5 py-0.5 rounded group-hover:opacity-0">
            {t("plex.inProgress")}
          </span>
        )}
        <span className="absolute bottom-1.5 left-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded">
          {t("plex.series.episodeCount", { count: se.episode_count })}
        </span>
      </div>
      <div
        onClick={onOpen}
        className="mt-1.5 rounded-lg px-2 py-1.5 text-sm font-medium line-clamp-2 leading-tight cursor-pointer hover:text-accent"
        style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }}
      >
        {se.title}
      </div>
    </div>
  );
}

function CastStrip({
  cast,
  onFilter,
}: {
  cast: PlexCastMember[];
  onFilter?: (patch: Partial<PlexFilters>) => void;
}) {
  const { t } = useTranslation();
  return (
    <section
      className="glass-card rounded-2xl p-4 sm:p-5 mb-6"
      style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
    >
      <h2 className="text-sm font-semibold mb-3">{t("plex.info.cast")}</h2>
      <div className="flex gap-4 overflow-x-auto pb-2">
        {cast.map((c, i) => {
          const inner = (
            <>
              <div
                className={`mx-auto h-20 w-20 overflow-hidden rounded-full border bg-surface ${onFilter ? "border-border group-hover/cast:border-accent" : "border-border"}`}
              >
                {c.thumb ? (
                  <img src={c.thumb} alt="" loading="lazy" className="h-full w-full object-cover" />
                ) : (
                  <div className="grid h-full w-full place-items-center opacity-40">
                    {c.name.charAt(0)}
                  </div>
                )}
              </div>
              <div
                className={`mt-1.5 text-xs font-medium line-clamp-2 leading-tight ${onFilter ? "group-hover/cast:text-accent" : ""}`}
              >
                {c.name}
              </div>
              {c.role && <div className="text-[11px] text-muted line-clamp-1">{c.role}</div>}
            </>
          );
          return onFilter ? (
            <button
              key={i}
              onClick={() => onFilter({ actors: [c.name] })}
              title={t("plex.info.filterActor", { name: c.name })}
              className="group/cast w-20 shrink-0 text-center"
            >
              {inner}
            </button>
          ) : (
            <div key={i} className="w-20 shrink-0 text-center">
              {inner}
            </div>
          );
        })}
      </div>
    </section>
  );
}

function RelatedStrip({ related, onOpen }: { related: PlexCard[]; onOpen: (id: string) => void }) {
  const { t } = useTranslation();
  return (
    <section
      className="glass-card rounded-2xl p-4 sm:p-5 mb-6"
      style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
    >
      <h2 className="text-sm font-semibold mb-3">{t("plex.series.related")}</h2>
      <div className="flex gap-3 overflow-x-auto pb-2">
        {related.map((r) => (
          <button
            key={r.id}
            onClick={() => onOpen(r.id)}
            className="group w-[130px] shrink-0 text-left"
          >
            <div className="relative aspect-[2/3] rounded-xl overflow-hidden bg-card border border-border">
              <img
                src={r.thumb}
                alt=""
                loading="lazy"
                className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
              />
              {r.status === "watched" && (
                <span className="absolute top-1.5 right-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded">
                  {t("plex.watched")}
                </span>
              )}
            </div>
            <div className="mt-1.5 text-sm font-medium line-clamp-2 leading-tight">{r.title}</div>
          </button>
        ))}
      </div>
    </section>
  );
}

function EpisodeCard({
  ep,
  onPlay,
  onAdd,
  onToggleWatched,
  withShowTitle,
}: {
  ep: PlexCard;
  onPlay: () => void;
  onAdd?: () => void;
  onToggleWatched?: () => void;
  withShowTitle?: boolean;
}) {
  const { t } = useTranslation();
  const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched";
  const pct =
    inProgress && ep.duration_seconds
      ? Math.min(100, Math.round(((ep.position_seconds ?? 0) / ep.duration_seconds) * 100))
      : 0;
  return (
    <div className="group">
      <div
        onClick={onPlay}
        role="button"
        tabIndex={0}
        onKeyDown={(e) => {
          if (e.key === "Enter" || e.key === " ") {
            e.preventDefault();
            onPlay();
          }
        }}
        className="relative block w-full aspect-video rounded-xl overflow-hidden bg-card border border-border text-left cursor-pointer"
      >
        <img
          src={ep.thumb}
          alt=""
          loading="lazy"
          className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
        />
        <div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition grid place-items-center">
          <Play className="w-9 h-9 text-white" fill="currentColor" />
        </div>
        {/* Quick watched toggle (single episode), on hover. */}
        {onToggleWatched && (
          <button
            onClick={(e) => {
              e.stopPropagation();
              onToggleWatched();
            }}
            title={ep.status === "watched" ? t("plex.markUnwatched") : t("plex.markWatched")}
            className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 opacity-0 group-hover:opacity-100 hover:bg-black/80 transition"
          >
            <CheckCircle2
              className={`w-4 h-4 ${ep.status === "watched" ? "text-emerald-400" : "text-white"}`}
            />
          </button>
        )}
        {ep.status === "watched" && (
          <span
            className={`pointer-events-none absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 ${
              onToggleWatched ? "group-hover:opacity-0" : ""
            }`}
          >
            <CheckCircle2 className="w-4 h-4 text-emerald-400" />
          </span>
        )}
        {pct > 0 && (
          <div className="absolute bottom-0 inset-x-0 h-1 bg-black/40">
            <div className="h-full bg-accent" style={{ width: `${pct}%` }} />
          </div>
        )}
      </div>
      <div
        className="mt-1.5 flex items-start gap-1.5 rounded-lg px-2 py-1.5"
        style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }}
      >
        <div className="min-w-0 flex-1">
          {withShowTitle && ep.show_title && (
            <div className="text-[11px] text-muted truncate">
              {ep.show_title}
              {ep.season_number != null && ep.episode_number != null
                ? ` · S${ep.season_number}·E${ep.episode_number}`
                : ""}
            </div>
          )}
          <div className="text-sm font-medium leading-tight">
            {!withShowTitle && <span className="text-muted mr-1">{ep.episode_number}.</span>}
            {ep.title}
          </div>
          <div className="text-[11px] text-muted mt-0.5">{formatRuntime(ep.duration_seconds)}</div>
        </div>
        {onAdd && (
          <button
            onClick={onAdd}
            title={t("plex.playlist.addEpisode")}
            aria-label={t("plex.playlist.addEpisode")}
            className="shrink-0 rounded p-1 text-muted transition hover:text-accent"
          >
            <ListPlus className="w-4 h-4" />
          </button>
        )}
      </div>
    </div>
  );
}

function PlexShowView({
  showId,
  onBack,
  onPlay,
  onOpenSeason,
  onOpenShow,
  onFilter,
}: {
  showId: string;
  onBack: () => void;
  onPlay: (epRk: string, queue: string[]) => void;
  onOpenSeason: (seasonId: string) => void;
  onOpenShow: (id: string) => void;
  onFilter?: (patch: Partial<PlexFilters>) => void;
}) {
  const { t } = useTranslation();
  const qc = useQueryClient();
  const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
  const q = useQuery({ queryKey: qk.plexShow(showId), queryFn: () => api.plexShow(showId) });
  const d = q.data;
  const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
  const [collOpen, setCollOpen] = useState(false);
  const [busy, setBusy] = useState(false);

  const show = d?.show;
  const showLib = show?.library ?? undefined;
  const allKeys = (d?.seasons ?? []).flatMap((se) => se.episodes.map((e) => e.id));
  const watched = show?.status === "watched";
  const { artBg, showCast, showRelated, toggleArtBg, toggleCast, toggleRelated } = useDetailPrefs();
  useArtBackdrop(show?.art, artBg);
  async function markAll(w: boolean) {
    setBusy(true);
    try {
      await api.plexShowState(showId, w);
    } finally {
      setBusy(false);
    }
    qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
    qc.invalidateQueries({ queryKey: qk.plexLibrary() });
  }
  async function markSeason(seasonRk: string, w: boolean) {
    await api.plexSeasonState(seasonRk, w).catch(() => {});
    qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
    qc.invalidateQueries({ queryKey: qk.plexLibrary() });
  }

  return (
    <div className="p-4 max-w-[1200px] mx-auto glass-media">
      <BackBtn onBack={onBack} label={t("plex.backToLibrary")} />

      {q.isError && !q.data ? (
        <StateMessage tone="error" onRetry={() => q.refetch()}>
          {t("common.loadError")}
        </StateMessage>
      ) : q.isLoading || !d || !show ? (
        <LoadingState label={t("plex.loading")} />
      ) : (
        <>
          {/* Hero */}
          <div className="glass relative rounded-2xl p-4 sm:p-5 mb-8 flex gap-4 sm:gap-5">
            <div className="absolute right-3 top-3 z-base">
              <DetailCustomizeMenu
                artBg={artBg}
                onToggleArtBg={toggleArtBg}
                showCast={showCast}
                onToggleCast={toggleCast}
                hasCast={show.cast.length > 0}
                showRelated={showRelated}
                onToggleRelated={toggleRelated}
                hasRelated={d.related.length > 0}
              />
            </div>
            <button
              onClick={() => show.resume && onPlay(show.resume.id, allKeys)}
              disabled={!show.resume}
              className="group/poster relative w-32 sm:w-44 shrink-0 self-start"
              title={show.status === "new" ? t("plex.play") : t("plex.resume")}
            >
              <img
                src={show.thumb}
                alt=""
                className="w-full aspect-[2/3] object-cover rounded-xl border border-border"
              />
              {show.resume && (
                <div className="absolute inset-0 rounded-xl bg-black/40 opacity-0 group-hover/poster:opacity-100 transition grid place-items-center">
                  <Play className="w-10 h-10 text-white" fill="currentColor" />
                </div>
              )}
            </button>
            <div className="min-w-0 flex-1">
              <h1 className="text-2xl font-semibold leading-tight">{show.title}</h1>
              <div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted">
                {show.year != null && (
                  <Filterable
                    onClick={
                      onFilter
                        ? () => onFilter({ yearMin: show.year, yearMax: show.year })
                        : undefined
                    }
                  >
                    {show.year}
                  </Filterable>
                )}
                {show.content_rating && (
                  <Filterable
                    onClick={
                      onFilter
                        ? () => onFilter({ contentRatings: [show.content_rating!] })
                        : undefined
                    }
                  >
                    · {show.content_rating}
                  </Filterable>
                )}
                {show.season_count != null && (
                  <span>· {t("plex.seasons", { count: show.season_count })}</span>
                )}
                {show.imdb_rating != null && (
                  <button
                    onClick={
                      onFilter
                        ? () => onFilter({ ratingMin: Math.floor(show.imdb_rating!) })
                        : undefined
                    }
                    className={`inline-flex items-center gap-1 ${onFilter ? "cursor-pointer hover:text-accent" : "cursor-default"}`}
                  >
                    · <Star className="w-3.5 h-3.5 text-amber-400" fill="currentColor" />
                    {show.imdb_rating}
                  </button>
                )}
              </div>
              {show.genres.length > 0 && (
                <div className="mt-2 flex flex-wrap gap-1.5">
                  {show.genres.map((g) => (
                    <Filterable
                      key={g}
                      className="text-[11px] rounded-full bg-surface px-2 py-0.5 text-muted"
                      onClick={onFilter ? () => onFilter({ genres: [g] }) : undefined}
                    >
                      {g}
                    </Filterable>
                  ))}
                </div>
              )}
              {show.summary && (
                <p className="text-sm text-muted mt-3 leading-relaxed line-clamp-5">
                  {show.summary}
                </p>
              )}
              <div className="mt-4 flex flex-wrap gap-2">
                {show.resume && (
                  <ActionBtn
                    primary
                    onClick={() => onPlay(show.resume!.id, allKeys)}
                    icon={Play}
                    label={show.status === "new" ? t("plex.play") : t("plex.resume")}
                    sub={epLabel(show.resume)}
                  />
                )}
                {show.first && show.status !== "new" && (
                  <ActionBtn
                    onClick={() => onPlay(show.first!.id, allKeys)}
                    icon={RotateCcw}
                    label={t("plex.series.playFromStart")}
                  />
                )}
                <ActionBtn
                  onClick={() => markAll(!watched)}
                  disabled={busy}
                  icon={watched ? CheckCheck : Check}
                  label={
                    watched ? t("plex.series.markShowUnwatched") : t("plex.series.markShowWatched")
                  }
                />
                {allKeys.length > 0 && (
                  <ActionBtn
                    onClick={() =>
                      setAddTarget({ kind: "group", ratingKeys: allKeys, title: show.title })
                    }
                    icon={ListPlus}
                    label={t("plex.playlist.addShow")}
                  />
                )}
                {isAdmin && showLib && (
                  <ActionBtn
                    onClick={() => setCollOpen(true)}
                    icon={Layers}
                    label={t("plex.series.addShowCollection")}
                  />
                )}
              </div>
            </div>
          </div>

          {/* Seasons — in a glassy panel like the movie info-page strips. */}
          <section
            className="glass-card rounded-2xl p-4 sm:p-5 mb-6"
            style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
          >
            <h2 className="text-sm font-semibold mb-3">{t("plex.series.seasons")}</h2>
            <div className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3">
              {d.seasons.map((se) => (
                <SeasonCard
                  key={se.id}
                  se={se}
                  onOpen={() => onOpenSeason(se.id)}
                  onToggleWatched={() => markSeason(se.id, se.status !== "watched")}
                  onAdd={
                    se.episodes.length > 0
                      ? () =>
                          setAddTarget({
                            kind: "group",
                            ratingKeys: se.episodes.map((e) => e.id),
                            title: `${show.title} — ${se.title}`,
                          })
                      : undefined
                  }
                />
              ))}
            </div>
          </section>

          {show.cast.length > 0 && showCast && <CastStrip cast={show.cast} onFilter={onFilter} />}
          {d.related.length > 0 && showRelated && (
            <RelatedStrip related={d.related} onOpen={onOpenShow} />
          )}
        </>
      )}

      {addTarget && <PlexPlaylistAdd target={addTarget} onClose={() => setAddTarget(null)} />}
      {collOpen && show && showLib && (
        <PlexCollectionEditor
          item={{ id: show.id, title: show.title }}
          library={showLib}
          memberOf={show.collection_keys}
          onClose={() => setCollOpen(false)}
          onChanged={() => qc.invalidateQueries({ queryKey: qk.plexShow(showId) })}
        />
      )}
    </div>
  );
}

function PlexSeasonView({
  showId,
  seasonId,
  onBack,
  onPlay,
}: {
  showId: string;
  seasonId: string;
  onBack: () => void;
  onPlay: (epRk: string, queue: string[]) => void;
}) {
  const { t } = useTranslation();
  const qc = useQueryClient();
  const q = useQuery({ queryKey: qk.plexShow(showId), queryFn: () => api.plexShow(showId) });
  const d = q.data;
  const se = d?.seasons.find((s) => s.id === seasonId);
  const [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
  const [busy, setBusy] = useState(false);

  const seasonKeys = se?.episodes.map((e) => e.id) ?? [];
  const watched = se?.status === "watched";
  const { artBg, showCast, toggleArtBg, toggleCast } = useDetailPrefs();
  useArtBackdrop(d?.show.art, artBg);
  async function markAll(w: boolean) {
    setBusy(true);
    try {
      await api.plexSeasonState(seasonId, w);
    } finally {
      setBusy(false);
    }
    qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
    qc.invalidateQueries({ queryKey: qk.plexLibrary() });
  }
  async function markEpisode(ep: PlexCard) {
    await api.plexSetState(ep.id, ep.status === "watched" ? "new" : "watched").catch(() => {});
    qc.invalidateQueries({ queryKey: qk.plexShow(showId) });
    qc.invalidateQueries({ queryKey: qk.plexLibrary() });
  }

  return (
    <div className="p-4 max-w-[1200px] mx-auto glass-media">
      <BackBtn onBack={onBack} label={t("plex.series.backToShow")} />

      {q.isError && !q.data ? (
        <StateMessage tone="error" onRetry={() => q.refetch()}>
          {t("common.loadError")}
        </StateMessage>
      ) : q.isLoading || !d || !se ? (
        <LoadingState label={t("plex.loading")} />
      ) : (
        <>
          <div className="glass relative rounded-2xl p-4 sm:p-5 mb-8 flex gap-4 sm:gap-5">
            <div className="absolute right-3 top-3 z-base">
              <DetailCustomizeMenu
                artBg={artBg}
                onToggleArtBg={toggleArtBg}
                showCast={showCast}
                onToggleCast={toggleCast}
                hasCast={false}
              />
            </div>
            <button
              onClick={() => se.resume && onPlay(se.resume.id, seasonKeys)}
              disabled={!se.resume}
              className="group/poster relative w-28 sm:w-40 shrink-0 self-start"
              title={se.status === "new" ? t("plex.play") : t("plex.resume")}
            >
              <img
                src={se.thumb}
                alt=""
                className="w-full aspect-[2/3] object-cover rounded-xl border border-border"
              />
              {se.resume && (
                <div className="absolute inset-0 rounded-xl bg-black/40 opacity-0 group-hover/poster:opacity-100 transition grid place-items-center">
                  <Play className="w-10 h-10 text-white" fill="currentColor" />
                </div>
              )}
            </button>
            <div className="min-w-0 flex-1">
              <button
                onClick={onBack}
                className="text-sm text-muted hover:text-accent transition text-left"
              >
                {d.show.title}
              </button>
              <h1 className="text-2xl font-semibold leading-tight">{se.title}</h1>
              <p className="text-sm text-muted mt-1">
                {t("plex.series.episodeCount", { count: se.episode_count })}
              </p>
              <div className="mt-4 flex flex-wrap gap-2">
                {se.resume && (
                  <ActionBtn
                    primary
                    onClick={() => onPlay(se.resume!.id, seasonKeys)}
                    icon={Play}
                    label={se.status === "new" ? t("plex.play") : t("plex.resume")}
                    sub={epLabel(se.resume)}
                  />
                )}
                {se.first && se.status !== "new" && (
                  <ActionBtn
                    onClick={() => onPlay(se.first!.id, seasonKeys)}
                    icon={RotateCcw}
                    label={t("plex.series.playFromStart")}
                  />
                )}
                <ActionBtn
                  onClick={() => markAll(!watched)}
                  disabled={busy}
                  icon={watched ? CheckCheck : Check}
                  label={
                    watched
                      ? t("plex.series.markSeasonUnwatched")
                      : t("plex.series.markSeasonWatched")
                  }
                />
                {seasonKeys.length > 0 && (
                  <ActionBtn
                    onClick={() =>
                      setAddTarget({
                        kind: "group",
                        ratingKeys: seasonKeys,
                        title: `${d.show.title} — ${se.title}`,
                      })
                    }
                    icon={ListPlus}
                    label={t("plex.playlist.addSeason")}
                  />
                )}
              </div>
            </div>
          </div>

          <div className="grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-x-3 gap-y-4">
            {se.episodes.map((ep) => (
              <EpisodeCard
                key={ep.id}
                ep={ep}
                onPlay={() => onPlay(ep.id, seasonKeys)}
                onAdd={() => setAddTarget({ kind: "single", ratingKey: ep.id, title: ep.title })}
                onToggleWatched={() => markEpisode(ep)}
              />
            ))}
          </div>
        </>
      )}

      {addTarget && <PlexPlaylistAdd target={addTarget} onClose={() => setAddTarget(null)} />}
    </div>
  );
}
