import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
  keepPreviousData,
  useInfiniteQuery,
  useQuery,
  useQueryClient,
} from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import {
  ArrowDown,
  ArrowUp,
  ArrowLeft,
  Columns2,
  Grid3x3,
  Info,
  LayoutGrid,
  List,
  RefreshCw,
  Rows3,
  Trash2,
  Youtube,
  type LucideIcon,
} from "lucide-react";
import { api, HttpError, type FeedFilters, type Video, type VideoStatus } from "../lib/api";
import i18n from "../i18n";
import { notify, resolveVideo } from "../lib/notifications";
import { useDebounced } from "../lib/useDebounced";
import {
  buildSort,
  DIRECTIONLESS,
  parseSort,
  SORT_DEFAULT_DIR,
  SORT_KEYS,
  type SortKey,
} from "../lib/feedSort";
import { FEED_VIEWS, type FeedView } from "../lib/feedView";
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
import ActiveFilterChips from "./ActiveFilterChips";
import { LoadingState, StateMessage } from "./QueryState";
import { PageToolbar } from "./PageShell";
import { useWizard } from "./WizardProvider";
import ViewSwitcher from "./ViewSwitcher";
import VirtualFeed from "./VirtualFeed";
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
const PlayerModal = lazy(() => import("./PlayerModal"));

const PAGE = 60;

// The "Show" view filter, content-type filter and ordering live in the feed toolbar
// (above the cards), not the filter sidebar.
const SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "hidden"];
const CONTENT = [
  { key: "includeNormal", label: "sidebar.content.normal" },
  { key: "includeShorts", label: "sidebar.content.shorts" },
  { key: "includeLive", label: "sidebar.content.live" },
] as const;
const rollSeed = () => Math.floor(Math.random() * 1_000_000_000);

// Icon per view mode; the labels are i18n'd at render (see feed.view.*).
const VIEW_ICON: Record<FeedView, LucideIcon> = {
  cards: LayoutGrid,
  cardsSmall: Grid3x3,
  rows: Rows3,
  rowsCompact: List,
  tiles: Columns2,
};

// Ordering = a key + direction (like the Playlists page), mapped to the backend sort strings
// (which encode both). One entry per concept; a single arrow flips the direction.
// "relevance" (full-text search ranking) and "shuffle" are directionless single-string sorts.

function matchesView(status: VideoStatus, show: string): boolean {
  switch (show) {
    case "hidden":
      return status === "hidden";
    case "watched":
      return status === "watched";
    case "unwatched":
    case "in_progress":
      // (in_progress is further narrowed server-side by resume position; here we only
      // need to drop a card once it's optimistically marked watched/hidden.)
      return status !== "watched" && status !== "hidden";
    default:
      return status !== "hidden"; // all
  }
}

export default function Feed({
  filters,
  setFilters,
  view,
  setView,
  canRead,
  isDemo = false,
  ytSearch,
  onYtSearch,
  onExitYtSearch,
  channelScoped,
}: {
  filters: FeedFilters;
  setFilters: (f: FeedFilters) => void;
  // The view mode is an account pref, not a filter: it persists on pick, so it isn't part of
  // FeedFilters (no share-link param, no saved view, no active-filter chip). Provided by the caller
  // (FeedViewProvider on the feed page; ChannelPage threads its own).
  view: FeedView;
  setView: (v: FeedView) => void;
  canRead: boolean;
  isDemo?: boolean;
  // Live YouTube search: the active search term (null = normal feed). Entering a search and
  // leaving it both step browser history (the search is a feed sub-view with its own history
  // entry), so the empty-state CTA enters via onYtSearch and "back to feed" pops via
  // onExitYtSearch. Search affordances are hidden for demo.
  ytSearch: string | null;
  onYtSearch: (q: string) => void;
  onExitYtSearch: () => void;
  // True when this feed is scoped to a single channel (the channel page). Drops sort options
  // that are constant within one channel (subscribers, priority) — they'd be meaningless.
  channelScoped?: boolean;
}) {
  const { t } = useTranslation();
  // "Connect YouTube" onboarding lives in WizardProvider (the empty-state CTA when not connected).
  const { openWizard: onOpenWizard } = useWizard();
  // Same list the panel's badge counts — see lib/useActiveFilters.
  const activeFilters = useActiveFilters(filters, setFilters);
  const sortKeys = channelScoped
    ? SORT_KEYS.filter((k) => k !== "subscribers" && k !== "priority")
    : SORT_KEYS;
  const [overrides, setOverrides] = useState<Record<string, VideoStatus>>({});
  const [savedOverrides, setSavedOverrides] = useState<Record<string, boolean>>({});
  // The open player: which video and where to start (null = resume from saved position).
  const [activeVideo, setActiveVideo] = useState<{ video: Video; startAt: number | null } | null>(
    null,
  );
  const qc = useQueryClient();

  const openVideo = useCallback(
    (video: Video, startAt: number | null = null) => setActiveVideo({ video, startAt }),
    [],
  );
  // Stable identity, like Playlists' `closePlayer`: PlayerModal's keydown/scroll-lock effect
  // depends on `onClose`, so a fresh closure every Feed render re-subscribed those listeners,
  // re-ran focusModal() under the open player, dropped its in-flight timers and flushed the
  // debounced volume write on each pass.
  const closePlayer = useCallback(() => setActiveVideo(null), []);

  const ytActive = !!ytSearch;
  // How many live-search results to gather (the count selector replaces a manual "load more";
  // the free scrape source pages through continuations until this many are collected).
  const [ytCount, setYtCount] = useState(20);

  // Debounce only the search term feeding the queries: the input still updates instantly (it
  // owns filters.q), but the feed/count keys settle after a pause, so typing doesn't refetch —
  // and re-fetched pages keep the previous results on screen, so the feed never blanks/flickers.
  const debouncedQ = useDebounced(filters.q, 300);
  const queryFilters: FeedFilters = { ...filters, q: debouncedQ };

  const query = useInfiniteQuery({
    queryKey: qk.feed(queryFilters),
    queryFn: ({ pageParam }) => api.feed(queryFilters, pageParam as string | null, PAGE),
    initialPageParam: null as string | null,
    getNextPageParam: (last) => last.next_cursor ?? undefined,
    enabled: !ytActive, // don't load the normal feed while showing live YouTube results
    placeholderData: keepPreviousData,
  });

  // Live YouTube search results. We never auto-paginate on scroll — the user pulls more with an
  // explicit button (each api-source page spends 100 quota units; scrape-source pages are free).
  // retry:false so a 429 (quota/limit) surfaces at once for inline display. staleTime keeps a
  // revisited search from re-spending.
  const ytQuery = useInfiniteQuery({
    queryKey: qk.ytSearch(ytSearch, ytCount),
    queryFn: ({ pageParam }) =>
      api.searchYoutube(ytSearch as string, pageParam as string | null, ytCount),
    initialPageParam: null as string | null,
    getNextPageParam: (last) => last.next_cursor ?? undefined,
    enabled: ytActive,
    staleTime: 5 * 60_000,
    retry: false,
  });

  // Remember (in history.state) that this live search was served by the zero-quota scrape source,
  // so a reload restores the results instead of dropping to the feed — re-fetching scrape pages
  // costs no quota. An api-source search is left unmarked (a reload would re-spend ~100 units).
  useEffect(() => {
    if (!ytActive) return;
    const st = window.history.state;
    if (!st || st._yt !== ytSearch) return; // only mark our own sub-view entry
    if (ytQuery.data?.pages?.[0]?.source === "scrape" && !st._ytScrape) {
      window.history.replaceState({ ...st, _ytScrape: true }, "");
    }
  }, [ytActive, ytSearch, ytQuery.data]);

  // Switching to the relevance sort when a search starts happens atomically in the header's
  // input onChange (race-free with the query update). Here we only handle the reverse: when
  // the term is cleared, fall back to the default sort — relevance has no dropdown option and
  // doesn't rank without a query.
  const qEmpty = !filters.q.trim();
  useEffect(() => {
    if (qEmpty && parseSort(filters.sort).key === "relevance") {
      setFilters({ ...filters, sort: buildSort("date", "desc") });
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [qEmpty]);

  // Drop optimistic status overrides when filters change OR fresh server data
  // arrives — after a refetch the server is authoritative, so a stale override
  // (e.g. a video reverted to "new" from the notification center) won't keep it
  // filtered out of the current view.
  useEffect(() => {
    setOverrides({});
    setSavedOverrides({});
  }, [filters]);
  // ...but NOT on infinite-scroll appends: fetchNextPage also bumps dataUpdatedAt while page 1's
  // rows are unchanged, so clearing overrides there would make a just-hidden card flash back.
  // Only a real refetch (page count doesn't grow) is authoritative.
  const pagesLenRef = useRef(0);
  useEffect(() => {
    const len = query.data?.pages?.length ?? 0;
    const appended = len > pagesLenRef.current;
    pagesLenRef.current = len;
    if (!appended) {
      setOverrides({});
      setSavedOverrides({});
    }
  }, [query.dataUpdatedAt]);

  const countQuery = useQuery({
    queryKey: qk.feedCount(queryFilters),
    queryFn: () => api.feedCount(queryFilters),
    staleTime: 30_000,
    enabled: !ytActive,
    placeholderData: keepPreviousData,
  });

  const { hasNextPage, isFetchingNextPage, fetchNextPage } = query;

  // Keep the loaded videos in a ref so onState can stay referentially stable
  // (it needs the list only to look up a title for the notification). A stable
  // onState lets memo(VideoCard) skip re-rendering existing cards on page append.
  const loadedRef = useRef<Video[]>([]);

  const onState = useCallback(
    (id: string, status: VideoStatus) => {
      setOverrides((o) => ({ ...o, [id]: status }));
      // Refetch once the server has the change so other views (e.g. Hidden) are in sync.
      // Announce the change only AFTER the server confirms it: a "Marked watched"/"Hidden"
      // notice (or resolving a stale one) is a claim of success, so firing it optimistically
      // would falsely report a change that the .catch below is about to roll back (e.g. when
      // the API is unreachable).
      api
        .setState(id, status)
        .then(() => {
          qc.invalidateQueries({ queryKey: qk.feed() });
          if (status === "hidden") {
            const v = loadedRef.current.find((x) => x.id === id);
            notify({
              message: v?.title
                ? i18n.t("feed.hiddenNamed", { title: v.title })
                : i18n.t("feed.hidden"),
              action: { label: i18n.t("feed.undo"), onClick: () => onState(id, "new") },
              meta: {
                kind: "video-hidden",
                videoId: id,
                title: v?.title ?? i18n.t("feed.thisVideo"),
                channelId: v?.channel_id ?? "",
                channelName: v?.channel_title ?? "",
              },
            });
          } else if (status === "watched") {
            const v = loadedRef.current.find((x) => x.id === id);
            notify({
              message: v?.title
                ? i18n.t("feed.markedWatchedNamed", { title: v.title })
                : i18n.t("feed.markedWatched"),
              action: { label: i18n.t("feed.unwatch"), onClick: () => onState(id, "new") },
              meta: {
                kind: "video-watched",
                videoId: id,
                title: v?.title ?? i18n.t("feed.thisVideo"),
                channelId: v?.channel_id ?? "",
                channelName: v?.channel_title ?? "",
              },
            });
          } else if (status === "new") {
            // Unhide / unwatch (from a card, the toast's Undo, or the center): quietly resolve
            // any stale hide/watch notice for this video so it doesn't linger with a dead action.
            resolveVideo(id);
          }
        })
        .catch(() =>
          // Server rejected the change — drop the optimistic override so the card
          // reverts to the real (unchanged) state instead of staying phantom-hidden.
          setOverrides((o) => {
            const next = { ...o };
            delete next[id];
            return next;
          }),
        );
    },
    [qc],
  );

  const onResetState = useCallback(
    (id: string) => {
      // Reset to pristine (drop the whole VideoState row, incl. resume position) — the
      // un-watch toggle can't clear an in-progress position, this can.
      setOverrides((o) => ({ ...o, [id]: "new" }));
      api
        .clearState(id)
        .then(() => qc.invalidateQueries({ queryKey: qk.feed() }))
        .catch(() =>
          setOverrides((o) => {
            const next = { ...o };
            delete next[id];
            return next;
          }),
        );
    },
    [qc],
  );

  const onToggleSave = useCallback(
    (id: string, saved: boolean) => {
      setSavedOverrides((o) => ({ ...o, [id]: saved }));
      (saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id))
        .then(() => {
          qc.invalidateQueries({ queryKey: qk.playlists() });
          qc.invalidateQueries({ queryKey: qk.playlist() });
        })
        .catch(() => setSavedOverrides((o) => ({ ...o, [id]: !saved })));
    },
    [qc],
  );

  const loaded: Video[] = (query.data?.pages ?? []).flatMap((p) => p.items);
  loadedRef.current = loaded;
  const withOverrides = (list: Video[]): Video[] =>
    list
      .map((v) => {
        const status = overrides[v.id]; // read once so the narrowing sticks
        return status ? { ...v, status } : v;
      })
      .map((v) => {
        const saved = savedOverrides[v.id];
        return saved !== undefined ? { ...v, saved } : v;
      });
  const merged: Video[] = withOverrides(loaded);
  const items: Video[] = merged.filter((v) => matchesView(v.status, filters.show));
  // The in-app player's queue (auto-advance / loop / prev-next). It's the filtered feed, but the
  // watch-state view filter is NOT applied — so marking the current video watched keeps it in the
  // sequence (no reindex/reload mid-play), matching "watched doesn't affect the list". A video that
  // goes hidden still drops out ("visible affects").
  const playerQueue: Video[] = merged.filter((v) => v.status !== "hidden");

  // A live search ingests new catalog videos, so the disabled feed queries still hold their stale
  // pre-search cache. Drop them so the feed re-fetches fresh on return.
  const dropFeedCaches = () => {
    qc.removeQueries({ queryKey: qk.feed() });
    qc.removeQueries({ queryKey: qk.feedCount() });
    qc.removeQueries({ queryKey: qk.facets() });
  };

  // --- Live YouTube search mode -------------------------------------------------------------
  // A separate data source rendered in the same cards/player. No watch-state view filter (the
  // user explicitly searched, so show everything) and no auto-pagination (each page costs quota).
  if (ytActive) {
    const ytLoaded: Video[] = (ytQuery.data?.pages ?? []).flatMap((p) => p.items);
    loadedRef.current = ytLoaded;
    const ytItems: Video[] = withOverrides(ytLoaded);
    const ytPlayerQueue: Video[] = ytItems.filter((v) => v.status !== "hidden");
    const ytError =
      ytQuery.error instanceof HttpError
        ? ytQuery.error.detail || t("feed.yt.error")
        : ytQuery.isError
          ? t("feed.yt.error")
          : null;
    // The backend reports which source served the search; scrape spends no search quota, so we
    // drop the quota warning + "uses quota" wording in that mode.
    const zeroQuota = ytQuery.data?.pages?.[0]?.source === "scrape";

    return (
      <div className="p-4">
        <div className="pb-3 mb-1 border-b border-border">
          <div className="flex items-center gap-2 flex-wrap">
            <button
              onClick={() => {
                // Surface the just-searched videos in the feed you return to: switch the Source
                // filter to "search" (your own search finds) and rank them by relevance.
                setFilters({ ...filters, librarySource: "search", sort: "relevance" });
                onExitYtSearch();
                dropFeedCaches();
              }}
              className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-accent transition shrink-0"
            >
              <ArrowLeft className="w-4 h-4" />
              {t("feed.yt.back")}
            </button>
            <span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
            <Youtube className="w-4 h-4 text-accent shrink-0" />
            <span className="text-sm font-semibold truncate">
              {t("feed.yt.resultsFor", { query: ytSearch })}
            </span>
            <span className="text-xs text-muted">
              {zeroQuota ? t("feed.yt.freeNote") : t("feed.yt.quotaNote")}
            </span>
            <label className="ml-auto flex items-center gap-1.5 text-xs text-muted shrink-0">
              {t("feed.yt.show")}
              <select
                value={ytCount}
                onChange={(e) => setYtCount(Number(e.target.value))}
                title={t("feed.yt.showHint")}
                className="bg-card border border-border rounded-lg px-2 py-1 text-xs outline-none focus:border-accent"
              >
                {[20, 40, 60, 100].map((n) => (
                  <option key={n} value={n}>
                    {n}
                  </option>
                ))}
              </select>
            </label>
          </div>
          {ytItems.length > 0 && (
            <div className="flex items-center gap-2 flex-wrap mt-2 text-xs text-muted">
              <Info className="w-3.5 h-3.5 shrink-0" />
              <span>{t("feed.yt.ephemeralNote")}</span>
              <button
                onClick={() => {
                  const ids = ytItems.map((v) => v.id);
                  api
                    .clearSearch(ids)
                    .then((r) =>
                      notify({ message: i18n.t("feed.yt.cleared", { count: r.removed }) }),
                    )
                    .catch(() => {});
                  setFilters({ ...filters, librarySource: "organic", sort: "newest" });
                  onExitYtSearch();
                  dropFeedCaches();
                  qc.removeQueries({ queryKey: qk.ytSearch() });
                }}
                className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border border-border text-muted hover:text-danger hover:border-danger transition"
              >
                <Trash2 className="w-3 h-3" />
                {t("feed.yt.clearNow")}
              </button>
            </div>
          )}
        </div>

        {ytQuery.isLoading ? (
          <div className="py-16 text-center text-muted">{t("feed.yt.searching")}</div>
        ) : ytError ? (
          <div className="py-16 text-center text-muted max-w-md mx-auto">{ytError}</div>
        ) : ytItems.length === 0 ? (
          <div className="py-16 text-center text-muted">{t("feed.yt.noResults")}</div>
        ) : (
          <>
            <VirtualFeed
              items={ytItems}
              view={view}
              onState={onState}
              onToggleSave={onToggleSave}
              onResetState={onResetState}
              onOpen={openVideo}
              hasNextPage={false}
              isFetchingNextPage={false}
              fetchNextPage={() => {}}
            />
            {ytQuery.hasNextPage && (
              <div className="text-center pt-4">
                <button
                  onClick={() => ytQuery.fetchNextPage()}
                  disabled={ytQuery.isFetchingNextPage}
                  className="inline-flex items-center gap-2 px-4 py-2 rounded-xl border border-border bg-card text-sm font-medium hover:border-accent hover:text-accent disabled:opacity-50 transition"
                >
                  {ytQuery.isFetchingNextPage
                    ? t("feed.loadingMore")
                    : zeroQuota
                      ? t("feed.yt.loadMoreFree")
                      : t("feed.yt.loadMore")}
                </button>
              </div>
            )}
          </>
        )}

        {activeVideo && (
          <Suspense fallback={null}>
            <PlayerModal
              video={activeVideo.video}
              startAt={activeVideo.startAt}
              queue={ytPlayerQueue}
              onClose={closePlayer}
              onState={onState}
            />
          </Suspense>
        )}
      </div>
    );
  }

  if (query.isLoading) return <LoadingState label={t("feed.loading")} />;
  if (query.isError && !query.data)
    return (
      <StateMessage tone="error" onRetry={() => query.refetch()}>
        {t("feed.loadError")}
      </StateMessage>
    );
  // Empty "my" feed with no YouTube access. The shared demo account can never connect
  // YouTube, so it gets a gentle nudge into the shared library instead of the connect
  // wizard; real users get the onboarding prompt. No toolbar (sort/type are meaningless).
  if (items.length === 0 && !canRead && filters.scope === "my")
    return (
      <div className="p-8 grid place-items-center text-center">
        <div className="max-w-sm">
          <h2 className="text-lg font-semibold">
            {isDemo ? t("feed.demoEmptyTitle") : t("feed.emptyTitle")}
          </h2>
          <p className="text-sm text-muted mt-2 mb-4">
            {isDemo ? t("feed.demoEmptyBody") : t("feed.emptyBody")}
          </p>
          {isDemo ? (
            <button
              onClick={() => setFilters({ ...filters, scope: "all" })}
              className="inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
            >
              {t("feed.browseLibrary")}
            </button>
          ) : (
            <>
              <button
                onClick={onOpenWizard}
                className="inline-flex items-center justify-center gap-2 px-5 py-2.5 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
              >
                {t("feed.setUp")}
              </button>
              <button
                onClick={() => setFilters({ ...filters, scope: "all" })}
                className="block mx-auto mt-3 text-sm text-muted hover:text-accent transition"
              >
                {t("feed.browseShared")}
              </button>
            </>
          )}
        </div>
      </div>
    );

  const { key: sortKey, dir: sortDir } = parseSort(filters.sort);

  const toolbar = (
    <div className="pb-3">
      <div className="flex items-center gap-1.5 gap-y-2 flex-wrap">
        {SHOW_IDS.map((id) => (
          <button
            key={id}
            data-testid={`feed-show-${id}`}
            onClick={() => setFilters({ ...filters, show: id })}
            aria-pressed={filters.show === id}
            className={`text-xs px-3 py-1.5 rounded-full border transition ${
              filters.show === id
                ? "bg-accent text-accent-fg border-accent"
                : "border-border text-muted hover:text-fg hover:border-accent"
            }`}
          >
            {t("sidebar.show." + id)}
          </button>
        ))}
        <span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
        {CONTENT.map((c) => {
          const on = filters[c.key];
          return (
            <button
              key={c.key}
              data-testid={`feed-content-${c.key}`}
              onClick={() => setFilters({ ...filters, [c.key]: !on })}
              aria-pressed={on}
              className={`text-xs px-3 py-1.5 rounded-full border transition ${
                on
                  ? "bg-accent text-accent-fg border-accent"
                  : "border-border text-muted hover:text-fg hover:border-accent"
              }`}
            >
              {t(c.label)}
            </button>
          );
        })}
        {/* The Source filter (organic / include-search / search-only) is a global-catalog concept —
            it selects how videos ENTERED the library. On a channel-scoped view we already show all
            of this one channel's videos (librarySource is pinned to "all"), so the selector would
            only offer a near-empty, confusing slice — hide it here. */}
        {!channelScoped && (
          <>
            <span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
            <label className="inline-flex items-center gap-1.5 text-xs text-muted">
              <span>{t("feed.source.label")}</span>
              <select
                value={filters.librarySource ?? "organic"}
                onChange={(e) =>
                  setFilters({
                    ...filters,
                    librarySource: e.target.value as FeedFilters["librarySource"],
                  })
                }
                title={t("feed.source.tip")}
                className="bg-card border border-border rounded-lg px-2 py-1 text-xs outline-none focus:border-accent"
              >
                <option value="organic">{t("feed.source.organic")}</option>
                <option value="all">{t("feed.source.all")}</option>
                <option value="search">{t("feed.source.only")}</option>
              </select>
            </label>
          </>
        )}
        <span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
        <span data-testid="feed-result-count" className="text-xs text-muted whitespace-nowrap">
          {countQuery.data
            ? t("feed.videoCount", {
                count: countQuery.data.count,
                formattedCount: countQuery.data.count.toLocaleString(),
              })
            : " "}
        </span>
        <div className="ml-auto flex items-center gap-2">
          <span className="text-xs text-muted">{t("feed.sortLabel")}</span>
          <select
            value={sortKey}
            onChange={(e) => {
              const key = e.target.value as SortKey;
              const dir = DIRECTIONLESS.includes(key)
                ? "desc"
                : SORT_DEFAULT_DIR[key as Exclude<SortKey, "shuffle" | "relevance">];
              setFilters({
                ...filters,
                sort: buildSort(key, dir),
                seed: key === "shuffle" ? rollSeed() : undefined,
              });
            }}
            aria-label={t("feed.sortLabel")}
            className="bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-none focus:border-accent"
          >
            {/* Relevance only ranks meaningfully when there's a search term — offer it then. */}
            {(filters.q.trim() ? (["relevance", ...sortKeys] as SortKey[]) : sortKeys).map((k) => (
              <option key={k} value={k}>
                {t("feed.sortKey." + k)}
              </option>
            ))}
          </select>
          {!DIRECTIONLESS.includes(sortKey) && (
            <button
              onClick={() =>
                setFilters({
                  ...filters,
                  sort: buildSort(sortKey, sortDir === "asc" ? "desc" : "asc"),
                })
              }
              title={sortDir === "asc" ? t("feed.dirAsc") : t("feed.dirDesc")}
              aria-label={sortDir === "asc" ? t("feed.dirAsc") : t("feed.dirDesc")}
              className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
            >
              {sortDir === "asc" ? (
                <ArrowUp className="w-4 h-4" />
              ) : (
                <ArrowDown className="w-4 h-4" />
              )}
            </button>
          )}
          {sortKey === "shuffle" && (
            <button
              onClick={() => setFilters({ ...filters, seed: rollSeed() })}
              title={t("sidebar.reshuffle")}
              aria-label={t("sidebar.reshuffle")}
              className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
            >
              <RefreshCw className="w-4 h-4" />
            </button>
          )}
          <span className="mx-0.5 h-5 w-px bg-border" aria-hidden="true" />
          <ViewSwitcher
            value={view}
            options={FEED_VIEWS.map((id) => ({
              id,
              label: t("feed.view." + id),
              icon: VIEW_ICON[id],
            }))}
            onChange={setView}
            label={t("feed.viewLabel")}
            // This toolbar is packed — show/content chips, source, count, sort — so the mode's name
            // only earns its place once the window is wide.
            labelClassName="hidden xl:inline"
          />
        </div>
      </div>
    </div>
  );

  return (
    <div className={channelScoped ? "px-4 sm:px-6 pb-4" : "px-4 pb-4 pt-2"}>
      {/* The toolbar (+ active-filter chips) is FIXED chrome: it portals into a PageToolbarSlot so
          it stays put while the cards scroll (bug 4). On the feed page that slot is the shell's top
          band; on a channel page ChannelPage overrides the slot with its own sticky sub-header, so
          the toolbar rides with the tabs. Channel pages get no chips: their Feed starts from its OWN
          baseline (channel-scoped, whole-catalog, show-all), so each would read as a "filter you
          applied" when it's just the page being itself. */}
      <PageToolbar>
        <div className={channelScoped ? "px-4 sm:px-6" : "px-4 pt-4 pb-1"}>
          {toolbar}
          {!channelScoped && (
            <ActiveFilterChips
              filters={activeFilters}
              onClearAll={() => setFilters(clearedFilters(filters))}
            />
          )}
        </div>
      </PageToolbar>
      {items.length === 0 ? (
        <div className="py-16 text-center text-muted">
          <p>{t("feed.noMatches")}</p>
          {filters.q.trim() && !isDemo && (
            <button
              onClick={() => onYtSearch(filters.q.trim())}
              className="mt-4 inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
            >
              <Youtube className="w-4 h-4" />
              {t("feed.yt.searchFor", { query: filters.q.trim() })}
            </button>
          )}
        </div>
      ) : (
        <VirtualFeed
          items={items}
          view={view}
          onState={onState}
          onToggleSave={onToggleSave}
          onResetState={onResetState}
          onOpen={openVideo}
          hasNextPage={!!hasNextPage}
          isFetchingNextPage={isFetchingNextPage}
          fetchNextPage={fetchNextPage}
        />
      )}
      {activeVideo && (
        <Suspense fallback={null}>
          <PlayerModal
            video={activeVideo.video}
            startAt={activeVideo.startAt}
            queue={playerQueue}
            onClose={closePlayer}
            onState={onState}
          />
        </Suspense>
      )}
      {isFetchingNextPage && (
        <div className="text-center text-muted py-4">{t("feed.loadingMore")}</div>
      )}
    </div>
  );
}
