import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import {
  ArrowDown,
  ArrowUp,
  Ban,
  Check,
  Eye,
  EyeOff,
  History,
  LayoutGrid,
  Pencil,
  RefreshCw,
  Table,
  UserMinus,
  X,
} from "lucide-react";
import { api, type ManagedChannel } from "../lib/api";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { accountKey, LS } from "../lib/storage";
import { formatEta, formatTotalHours, relativeTime } from "../lib/format";
import { subsColumn } from "./channelColumns";
import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip";
import DataTable, { type Column } from "./DataTable";
import ChannelLayoutGrid from "./ChannelLayoutGrid";
import { LoadingState, StateMessage } from "./QueryState";
import { ChannelTagsControl } from "./ChannelTags";
import { useChannelTagActions } from "../lib/useChannelTagActions";
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
import ColumnSortControl from "./ColumnSortControl";
import Pager from "./Pager";
import { CHANNEL_LAYOUTS, type ChannelLayout } from "../lib/channelLayout";
import { sortRows } from "../lib/columnSort";
import { useCardPager } from "../lib/useCardPager";
import { PageToolbar } from "./PageShell";
import ChannelLink from "./ChannelLink";
import AboutCell from "./AboutCell";
import ChannelDiscovery from "./ChannelDiscovery";
import TagManager from "./TagManager";
import { useConfirm } from "./ConfirmProvider";
import { useChannelLayout, useChannels, useChannelsActions } from "./ChannelsProvider";
import { useNavigationActions } from "./NavigationProvider";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
import { useWizard } from "./WizardProvider";
import { useMe } from "../lib/useMe";

export type ChannelsView = "subscribed" | "discovery" | "blocked";

export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden";

const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [
  { id: "all", labelKey: "channels.filters.all" },
  { id: "needs_full", labelKey: "channels.filters.needsFull" },
  { id: "fully_synced", labelKey: "channels.filters.fullySynced" },
  { id: "hidden", labelKey: "channels.filters.hidden" },
];

const LAYOUT_ICON: Record<ChannelLayout, typeof Table> = {
  table: Table,
  cards: LayoutGrid,
};

export default function Channels() {
  const me = useMe();
  const canWrite = me.can_write;
  const isAdmin = me.role === "admin";
  // "Connect YouTube" onboarding lives in WizardProvider (a notify action / a mutation-error CTA).
  const { openWizard: onOpenWizard } = useWizard();
  // The manager's name filter, status chip, active tab, reset token, and focus-channel intent live
  // in ChannelsProvider (shared with the header search box + the cross-module "focus this channel"
  // callers); this page is their primary editor.
  const {
    search,
    statusFilter,
    view,
    sort,
    filtersResetToken,
    focusName: focusChannelName,
    focusToken: focusChannelToken,
  } = useChannels();
  const layout = useChannelLayout();
  const {
    setSearch,
    setStatusFilter,
    setView,
    setLayout,
    setSort,
    focusChannel: onFocusChannel,
  } = useChannelsActions();
  const { openChannel: onViewChannel, setPage } = useNavigationActions();
  const feedFilters = useFeedFilters();
  const { setFilters: setFeedFilters } = useFeedFiltersActions();
  const { t } = useTranslation();
  const qc = useQueryClient();
  const confirm = useConfirm();

  // Clicking a tag chip filters the feed by that tag and jumps there. Internalised (was an App
  // callback) now that Channels reads the feed filters + navigation straight from their providers.
  const onFilterByTag = (tagId: number, name: string) => {
    setFeedFilters({ ...feedFilters, tags: [tagId], channelIds: [] });
    setPage("feed");
    notify({ level: "info", message: t("channels.notify.filteredByTag", { name }) });
  };

  const channelsQuery = useQuery({ queryKey: qk.channels(), queryFn: api.channels });
  const tagsQuery = useQuery({ queryKey: qk.tags(), queryFn: api.tags });
  const statusQuery = useQuery({ queryKey: qk.myStatus(), queryFn: api.myStatus });
  const blockedQuery = useQuery({ queryKey: qk.blockedChannels(), queryFn: api.blockedChannels });
  const [tagManagerOpen, setTagManagerOpen] = useState(false);
  const unblock = useMutation({
    mutationFn: (id: string) => api.unblockChannel(id),
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: qk.blockedChannels() });
      qc.invalidateQueries({ queryKey: qk.feed() });
    },
  });

  const invalidate = () => {
    qc.invalidateQueries({ queryKey: qk.channels() });
    qc.invalidateQueries({ queryKey: qk.tags() });
    qc.invalidateQueries({ queryKey: qk.myStatus() });
    // FB2: syncing subscriptions can change the feed immediately — newly-followed channels may
    // already have videos in the shared catalog, so refresh the feed too (not just the manager).
    qc.invalidateQueries({ queryKey: qk.feed() });
    qc.invalidateQueries({ queryKey: qk.feedCount() });
  };

  const userTags = (tagsQuery.data ?? []).filter((t) => !t.system);

  const patch = useMutation({
    mutationFn: (v: {
      id: string;
      body: { priority?: number; hidden?: boolean; deep_requested?: boolean };
    }) => api.updateChannel(v.id, v.body),
    // Requesting full history may have just pulled in a channel's recent uploads, so
    // refresh the per-user status and feed too — not only the channel list.
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: qk.channels() });
      qc.invalidateQueries({ queryKey: qk.myStatus() });
      qc.invalidateQueries({ queryKey: qk.feed() });
      qc.invalidateQueries({ queryKey: qk.feedCount() });
    },
  });
  // Priority is updated optimistically in place and NOT re-sorted/refetched, so the list
  // doesn't jump (the server list is priority-sorted; refetching would reorder rows and
  // lose your scroll position). The new order takes effect next time the page loads.
  // Press-and-hold fires many bumps, so the PATCH is DEBOUNCED per channel: each bump reads the
  // accumulated value from the cache, and the server call flushes once ~400ms after the last step
  // (instead of one request per notch). Pending flushes are fired on unmount so a last-moment bump
  // isn't lost.
  const prioFlush = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
  const flushPriority = (id: string) => {
    const latest = qc
      .getQueryData<ManagedChannel[]>(qk.channels())
      ?.find((c) => c.id === id)?.priority;
    if (latest == null) return;
    api
      .updateChannel(id, { priority: latest })
      .catch(() => qc.invalidateQueries({ queryKey: qk.channels() }));
  };
  // Set an absolute priority (used by both the arrows — via bumpPriority — and the inline edit),
  // optimistically + a debounced flush.
  const setPriority = (id: string, next: number) => {
    qc.setQueryData<ManagedChannel[]>(qk.channels(), (old) =>
      (old ?? []).map((ch) => (ch.id === id ? { ...ch, priority: next } : ch)),
    );
    const timers = prioFlush.current;
    const pending = timers.get(id);
    if (pending) clearTimeout(pending);
    timers.set(
      id,
      setTimeout(() => {
        timers.delete(id);
        flushPriority(id);
      }, 400),
    );
  };
  const bumpPriority = (id: string, delta: number) => {
    const cur =
      qc.getQueryData<ManagedChannel[]>(qk.channels())?.find((c) => c.id === id)?.priority ?? 0;
    setPriority(id, cur + delta);
  };
  useEffect(() => {
    const timers = prioFlush.current;
    return () => {
      timers.forEach((timer, id) => {
        clearTimeout(timer);
        flushPriority(id);
      });
      timers.clear();
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // Tagging is updated optimistically in place (no refetch of the whole channel list, which
  // made the toggle feel ~2s slow); only revert by refetching if the server call fails. The
  // attach/detach + create-and-attach flow is shared with the channel detail page.
  const { toggleTag, createAndAttachTag } = useChannelTagActions({
    optimisticToggle: (id, tagId, attach) =>
      qc.setQueryData<ManagedChannel[]>(qk.channels(), (old) =>
        (old ?? []).map((ch) =>
          ch.id === id
            ? {
                ...ch,
                tag_ids: attach ? [...ch.tag_ids, tagId] : ch.tag_ids.filter((x) => x !== tagId),
              }
            : ch,
        ),
      ),
    // Keep the channel DETAIL cache in step with the manager list (the detail page reads its tags
    // from qk.channel(id)); mirrors the detail page invalidating the manager list on its side.
    onSuccess: (id) => qc.invalidateQueries({ queryKey: qk.channel(id) }),
    onError: () => qc.invalidateQueries({ queryKey: qk.channels() }),
  });
  const syncSubs = useMutation({
    mutationFn: () => api.syncSubscriptions(),
    onSuccess: (r: { subscriptions?: number }) => {
      invalidate();
      notify({
        level: "success",
        message: t("channels.notify.synced", { count: r.subscriptions ?? 0 }),
      });
    },
    onError: (e) => notifyYouTubeActionError(e, t("channels.notify.syncFailed"), onOpenWizard),
  });
  const unsubscribe = useMutation({
    mutationFn: (v: { id: string; name: string }) => api.unsubscribeChannel(v.id),
    onSuccess: (_d, v) => {
      qc.invalidateQueries({ queryKey: qk.channels() });
      qc.invalidateQueries({ queryKey: qk.myStatus() });
      notify({ level: "success", message: t("channels.notify.unsubscribed", { name: v.name }) });
    },
    onError: (e) =>
      notifyYouTubeActionError(e, t("channels.notify.unsubscribeFailed"), onOpenWizard),
  });
  const resetBackfill = useMutation({
    mutationFn: (v: { id: string; name: string }) => api.resetChannelBackfill(v.id),
    onSuccess: (_d, v) => {
      qc.invalidateQueries({ queryKey: qk.channels() });
      qc.invalidateQueries({ queryKey: qk.myStatus() });
      notify({ level: "success", message: t("channels.notify.resetDone", { name: v.name }) });
    },
    onError: (e) => notifyYouTubeActionError(e, t("channels.notify.resetFailed"), onOpenWizard),
  });
  const deepAll = useMutation({
    mutationFn: () => api.deepAll(true),
    onSuccess: (r: { updated?: number }) => {
      qc.invalidateQueries({ queryKey: qk.channels() });
      qc.invalidateQueries({ queryKey: qk.myStatus() });
      notify({
        level: "success",
        message: t("channels.notify.fullHistoryRequested", { count: r.updated ?? 0 }),
      });
    },
    onError: (e) =>
      notifyYouTubeActionError(e, t("channels.notify.fullHistoryFailed"), onOpenWizard),
  });

  // Channel-name search (from the shared top SearchBar, via props) + tag-chip filtering, applied
  // client-side over the status-filtered list.
  const [tagFilter, setTagFilter] = useState<number[]>([]);
  // A focus-channel intent (header "without full history" / tag manager) seeds the search box.
  useEffect(() => {
    if (focusChannelName) setSearch(focusChannelName);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [focusChannelToken]);
  // The header's reset intent clears the search + tag chips (skip the initial mount).
  const resetRef = useRef(filtersResetToken);
  useEffect(() => {
    if (filtersResetToken === resetRef.current) return;
    resetRef.current = filtersResetToken;
    setSearch("");
    setTagFilter([]);
  }, [filtersResetToken]);

  // The Sync-status filter stays a top-level chip set (not a column filter) because the
  // header's "go to full history" deep-link drives it; search/tag filtering + sort + pagination
  // run over whatever the status chip leaves.
  const channels = (channelsQuery.data ?? []).filter((c) =>
    statusFilter === "needs_full"
      ? !c.backfill_done
      : statusFilter === "fully_synced"
        ? c.backfill_done
        : statusFilter === "hidden"
          ? c.hidden
          : true,
  );
  const q = search.trim().toLowerCase();
  const visibleChannels = channels.filter((c) => {
    if (q && !`${c.title ?? ""} ${c.handle ?? ""}`.toLowerCase().includes(q)) return false;
    if (tagFilter.length && !tagFilter.some((id) => c.tag_ids.includes(id))) return false;
    return true;
  });
  const s = statusQuery.data;

  const onView = (c: ManagedChannel) =>
    onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"));
  const onUnsub = async (c: ManagedChannel) => {
    const ok = await confirm({
      title: t("channels.row.unsubscribeOnYoutube"),
      message: t("channels.confirmUnsubscribe", { name: c.title ?? c.id }),
      confirmLabel: t("channels.row.unsubscribeOnYoutube"),
      danger: true,
    });
    if (ok) unsubscribe.mutate({ id: c.id, name: c.title ?? c.id });
  };
  const onReset = async (c: ManagedChannel) => {
    const ok = await confirm({
      title: t("channels.row.backfillThis"),
      message: t("channels.confirmReset", { name: c.title ?? c.id }),
      confirmLabel: t("channels.row.backfillThis"),
    });
    if (ok) resetBackfill.mutate({ id: c.id, name: c.title ?? c.id });
  };

  // Distinct priority levels present in the catalog → the priority column's filter options.
  const prioOptions = [...new Set((channelsQuery.data ?? []).map((c) => c.priority))]
    .sort((a, b) => b - a)
    .map((p) => ({ value: String(p), label: String(p) }));

  const columns: Column<ManagedChannel>[] = [
    {
      key: "priority",
      header: t("channels.cols.priority"),
      align: "center",
      width: "56px",
      sortable: true,
      hideInCard: true,
      sortValue: (c) => c.priority,
      filter: { kind: "select", options: prioOptions, test: (c, v) => String(c.priority) === v },
      render: (c) => (
        <PriorityCell
          c={c}
          onPriority={(d) => bumpPriority(c.id, d)}
          onSet={(v) => setPriority(c.id, v)}
        />
      ),
    },
    {
      key: "actions",
      header: t("channels.cols.actions"),
      align: "left",
      nowrap: true,
      width: "104px",
      cardLabel: false,
      render: (c) => (
        <ActionsCell
          c={c}
          isAdmin={isAdmin}
          canWrite={canWrite}
          onHide={() => patch.mutate({ id: c.id, body: { hidden: !c.hidden } })}
          onDeep={() => patch.mutate({ id: c.id, body: { deep_requested: !c.deep_requested } })}
          onReset={() => onReset(c)}
          onUnsubscribe={() => onUnsub(c)}
        />
      ),
    },
    {
      key: "channel",
      header: t("channels.cols.channel"),
      sortable: true,
      sortValue: (c) => (c.title ?? c.id).toLowerCase(),
      filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` },
      cardPrimary: true,
      // Cap the width so a long-titled channel can't stretch the column (table-layout:auto ignores a
      // cell max-width, but a max-w element INSIDE the cell bounds it — same trick AboutCell uses).
      // Without this a single long name blows the column ~289→429px and overflows the centered table.
      render: (c) => (
        <div className="max-w-[18rem]">
          <ChannelLink
            id={c.id}
            title={c.title}
            handle={c.handle}
            thumbnailUrl={c.thumbnail_url}
            onView={() => onView(c)}
          />
        </div>
      ),
    },
    {
      key: "about",
      header: t("channels.cols.about"),
      hideInCard: true,
      render: (c) => <AboutCell text={c.description} channelId={c.id} />,
    },
    {
      key: "stored",
      header: t("channels.cols.stored"),
      align: "right",
      nowrap: true,
      sortable: true,
      sortValue: (c) => c.stored_videos,
      render: (c) => (
        <span className="text-muted tabular-nums">{c.stored_videos.toLocaleString()}</span>
      ),
    },
    subsColumn<ManagedChannel>(t),
    {
      key: "lastUpload",
      header: t("channels.cols.lastUpload"),
      nowrap: true,
      sortable: true,
      sortValue: (c) => (c.last_video_at ? new Date(c.last_video_at).getTime() : 0),
      render: (c) => <span className="text-muted">{relativeTime(c.last_video_at) || "—"}</span>,
    },
    {
      key: "length",
      header: t("channels.cols.length"),
      align: "right",
      nowrap: true,
      sortable: true,
      sortValue: (c) => c.total_duration_seconds,
      render: (c) => (
        <span className="text-muted tabular-nums">
          {formatTotalHours(c.total_duration_seconds)}
        </span>
      ),
    },
    {
      key: "types",
      header: t("channels.cols.types"),
      nowrap: true,
      render: (c) => (
        <Tooltip hint={t("channels.cols.typesHint")}>
          <span className="text-muted tabular-nums cursor-help">
            {c.count_normal} / {c.count_short} / {c.count_live}
          </span>
        </Tooltip>
      ),
    },
    {
      key: "sync",
      header: t("channels.cols.sync"),
      nowrap: true,
      cardLabel: false,
      filter: {
        kind: "select",
        options: [
          { value: "fully", label: t("channels.filters.fullySynced") },
          { value: "needsFull", label: t("channels.filters.needsFull") },
        ],
        test: (c, v) =>
          v === "fully"
            ? c.recent_synced && c.backfill_done
            : v === "needsFull"
              ? !c.backfill_done
              : true,
      },
      render: (c) => <SyncCell c={c} />,
    },
    {
      key: "tags",
      header: t("channels.cols.tags"),
      cardLabel: false,
      // Bounded width so the cell can't widen the column and reflow the whole table; the control
      // fills it and collapses overflow into "+N" (see ChannelTagsControl).
      width: "12rem",
      filter: {
        kind: "multi",
        options: userTags.map((tg) => ({ value: String(tg.id), label: tg.name })),
        test: (c, values) => values.some((v) => c.tag_ids.includes(Number(v))),
      },
      render: (c) => (
        <ChannelTagsControl
          userTags={userTags}
          attachedIds={c.tag_ids}
          onToggle={(tagId) => toggleTag(c.id, tagId, c.tag_ids.includes(tagId))}
          onCreate={(name) => createAndAttachTag(c.id, name)}
          onChipClick={onFilterByTag}
        />
      ),
    },
  ];

  // Status chips sit in the table's controls row (next to paging) — compact and close to
  // where they act.
  const statusChips = (
    <div className="flex flex-wrap items-center gap-1.5">
      {STATUS_FILTERS.map((f) => (
        <button
          key={f.id}
          onClick={() => setStatusFilter(f.id)}
          className={`text-xs px-2.5 py-1 rounded-full border transition ${
            statusFilter === f.id
              ? "bg-accent text-accent-fg border-accent"
              : "bg-card border-border text-muted hover:border-accent"
          }`}
        >
          {t(f.labelKey)}
        </button>
      ))}
    </div>
  );

  // The catalog-wide stats ride on the right of the tab row (subscribed only), leaving room for
  // future tabs on the left.
  const statsBlock = (
    <div className="flex flex-wrap items-center justify-end gap-x-5 gap-y-1 text-sm text-muted">
      {s && (
        <>
          <Stat
            label={t("channels.stats.channels")}
            value={s.channels_total}
            hint={t("channels.stats.channelsHint")}
          />
          <Stat
            label={t("channels.stats.recentSynced")}
            value={`${s.channels_recent_synced}/${s.channels_total}`}
            hint={t("channels.stats.recentSyncedHint")}
          />
          <Stat
            label={t("channels.stats.fullHistory")}
            value={`${s.channels_deep_done}/${s.channels_deep_requested}`}
            hint={t("channels.stats.fullHistoryHint")}
          />
          {s.deep_pending_count > 0 && (
            <Stat
              label={t("channels.stats.left")}
              value={formatEta(s.deep_eta_seconds)}
              hint={t("channels.stats.leftHint", { count: s.deep_pending_count })}
            />
          )}
          <Stat
            label={t("channels.stats.myVideos")}
            value={s.my_videos.toLocaleString()}
            hint={t("channels.stats.myVideosHint")}
          />
          <Stat
            label={t("channels.stats.quotaLeft")}
            value={s.quota_remaining_today.toLocaleString()}
            hint={t("channels.stats.quotaLeftHint")}
          />
        </>
      )}
    </div>
  );

  // Sync + backfill — now share the tags row (right side).
  const actionButtons = (
    <div className="flex items-center gap-2 shrink-0">
      <Tooltip side="bottom" hint={t("channels.syncSubscriptionsHint")}>
        <button
          onClick={() => syncSubs.mutate()}
          disabled={syncSubs.isPending}
          className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
        >
          <RefreshCw className={`w-4 h-4 ${syncSubs.isPending ? "animate-spin" : ""}`} />
          {t("channels.syncSubscriptions")}
        </button>
      </Tooltip>
      <Tooltip side="bottom" hint={t("channels.backfillEverythingHint")}>
        <button
          onClick={() => deepAll.mutate()}
          disabled={deepAll.isPending}
          className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
        >
          <History className={`w-4 h-4 ${deepAll.isPending ? "animate-pulse" : ""}`} />
          {t("channels.backfillEverything")}
        </button>
      </Tooltip>
    </div>
  );

  // Table / cards density — shared by the subscribed + discovery tables (blocked is a chip list, no
  // layout to switch). Rides the same generic ViewSwitcher the feed uses.
  const layoutOptions: ViewOption<ChannelLayout>[] = CHANNEL_LAYOUTS.map((id) => ({
    id,
    label: t("channels.layout." + id),
    icon: LAYOUT_ICON[id],
  }));
  const layoutSwitcher = (
    <ViewSwitcher
      value={layout}
      options={layoutOptions}
      onChange={setLayout}
      label={t("channels.layoutLabel")}
      labelClassName="hidden lg:inline"
    />
  );
  const hasLayout = view === "subscribed" || view === "discovery";
  // In cards there are no column headers to sort by, so a dropdown drives the SAME shared sort state
  // the table header uses (table ↔ cards stay in sync). Table mode keeps the header clicks.
  const sortControl = (
    <ColumnSortControl
      columns={columns}
      sort={sort}
      onChange={setSort}
      label={t("channels.sort.label")}
      defaultLabel={t("channels.sort.default")}
      ascLabel={t("channels.sort.asc")}
      descLabel={t("channels.sort.desc")}
    />
  );
  // The card layout paginates like the table (a page of cards + a pager), rather than one long
  // infinite scroll. Slice the sorted rows; the Pager reports page/size changes.
  // Only computed for cards: in table mode DataTable sorts and slices internally, so doing it here
  // too would copy+sort the whole ~300-row array on every render for a result nothing reads.
  const isCards = layout === "cards";
  const sortedChannels = isCards ? sortRows(visibleChannels, columns, sort) : visibleChannels;
  const {
    paged: pagedCards,
    pagerProps,
    resetPage,
  } = useCardPager(sortedChannels, LS.channelCardSize, isCards);
  // Changing what you're looking at starts over at the first page, the way DataTable resets its own
  // page on a filter/sort change. Deliberately NOT keyed on the row count: the qk.channels() query
  // refetches on window focus and after every sync, so a catalogue that merely gained a channel would
  // otherwise yank you off page 7 for something you didn't do — a list that shrank past your page is
  // handled by the hook's clamp instead.
  useEffect(() => {
    resetPage();
  }, [statusFilter, search, tagFilter, sort, resetPage]);
  const cardPager = <Pager {...pagerProps} />;

  const tabs = (
    <div className="px-4 pt-4 max-w-[96rem] mx-auto flex items-center justify-between gap-4 flex-wrap">
      <div className="flex flex-wrap items-center gap-1.5">
        {(["subscribed", "discovery", "blocked"] as ChannelsView[]).map((v) => (
          <button
            key={v}
            onClick={() => setView(v)}
            className={`text-sm px-3 py-1.5 rounded-full border transition ${
              view === v
                ? "bg-accent text-accent-fg border-accent"
                : "bg-card border-border text-muted hover:border-accent"
            }`}
          >
            {t(`channels.tabs.${v}`)}
          </button>
        ))}
      </div>
      <div className="flex items-center gap-3">
        {view === "subscribed" && statsBlock}
        {view === "subscribed" && layout === "cards" && sortControl}
        {hasLayout && layoutSwitcher}
      </div>
    </div>
  );

  return (
    <>
      {/* The tab bar is always fixed chrome (it portals into the shell's fixed band, so it never
        scrolls and doesn't jump between tabs). On Subscriptions the stats/description/tags ride
        with it, and the status-chip + pager row lands just below (DataTable's controlsInBand). */}
      <PageToolbar>
        {tabs}
        {view === "subscribed" && (
          <div className="px-4 pt-2 pb-2 max-w-[96rem] mx-auto">
            {/* Your tags (left) share one row with the two catalog-wide actions (right); the channel-name
          search lives in the shared top SearchBar, tag create/delete in the manager. */}
            <div className="flex items-center justify-between gap-4 flex-wrap">
              <div className="flex flex-wrap items-center gap-1.5">
                <Tooltip hint={t("channels.tags.yourTagsHint")}>
                  <span className="text-xs uppercase tracking-wide text-muted mr-1 cursor-help underline decoration-dotted decoration-muted/40 underline-offset-4">
                    {t("channels.tags.yourTags")}
                  </span>
                </Tooltip>
                {userTags.map((tg) => {
                  const active = tagFilter.includes(tg.id);
                  return (
                    <button
                      key={tg.id}
                      onClick={() =>
                        setTagFilter((f) => (active ? f.filter((x) => x !== tg.id) : [...f, tg.id]))
                      }
                      aria-pressed={active}
                      className={`text-xs px-2 py-1 rounded-full border transition ${
                        active
                          ? "bg-accent text-accent-fg border-accent"
                          : "bg-card border-border text-muted hover:text-fg hover:border-accent"
                      }`}
                    >
                      {tg.name}
                    </button>
                  );
                })}
                {tagFilter.length > 0 && (
                  <button
                    onClick={() => setTagFilter([])}
                    className="text-xs px-2 py-1 text-muted hover:text-accent transition"
                  >
                    {t("datatable.clear")}
                  </button>
                )}
                <button
                  onClick={() => setTagManagerOpen(true)}
                  className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
                >
                  <Pencil className="w-3 h-3" />
                  {t("channels.tags.manage")}
                </button>
              </div>
              {actionButtons}
            </div>
          </div>
        )}
        {/* In table mode the status chips + pager ride in DataTable's own in-band controls row. The card
        layout has no such row, so surface the same status chips (left) and its pager (right) here. */}
        {view === "subscribed" && layout === "cards" && (
          <div className="px-4 pb-2 max-w-[96rem] mx-auto flex items-center justify-between gap-3 flex-wrap">
            {statusChips}
            {cardPager}
          </div>
        )}
      </PageToolbar>
      {tagManagerOpen && (
        <TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
      )}

      {view === "discovery" ? (
        <ChannelDiscovery
          canWrite={canWrite}
          search={search}
          onOpenWizard={onOpenWizard}
          onViewChannel={onViewChannel}
        />
      ) : view === "blocked" ? (
        /* Blocked channels — videos from these are hidden everywhere and dropped from live search. */
        <div className="px-4 pb-4 pt-3 max-w-[96rem] mx-auto">
          {(blockedQuery.data?.length ?? 0) === 0 ? (
            <p className="text-sm text-muted py-8">{t("channels.blocked.empty")}</p>
          ) : (
            <>
              <div className="flex items-center gap-2 mb-3">
                <Ban className="w-4 h-4 text-muted" />
                <span className="text-xs uppercase tracking-wide text-muted">
                  {t("channels.blocked.title")}
                </span>
              </div>
              <div className="flex flex-wrap gap-1.5">
                {blockedQuery.data!.map((c) => (
                  <span
                    key={c.id}
                    className="inline-flex items-center gap-1.5 text-xs pl-2.5 pr-1 py-1 rounded-full bg-card border border-border"
                  >
                    {c.title ?? c.id}
                    <button
                      onClick={() => unblock.mutate(c.id)}
                      title={t("channel.unblock")}
                      aria-label={t("channel.unblock")}
                      className="rounded-full p-0.5 text-muted hover:text-danger transition"
                    >
                      <X className="w-3 h-3" />
                    </button>
                  </span>
                ))}
              </div>
              <p className="text-[11px] text-muted mt-2">{t("channels.blocked.hint")}</p>
            </>
          )}
        </div>
      ) : (
        /* No top padding: the sticky header sits flush at the scroller top so it doesn't shift when
         `position: sticky` engages on the first scroll (the band's controls row above gives the
         visual gap). */
        <div className="px-4 pb-4 max-w-[96rem] mx-auto">
          {/* Channel table — only the rows scroll; the header row is sticky, and the controls row
            (status chips + pager) rides in the fixed band right above it (controlsInBand). The
            card layout swaps the table for a virtualized grid over the same rows (its pager and
            status chips ride in the fixed band above instead). */}
          {channelsQuery.isError && !channelsQuery.data ? (
            <StateMessage tone="error" onRetry={() => channelsQuery.refetch()}>
              {t("common.loadError")}
            </StateMessage>
          ) : channelsQuery.isLoading ? (
            <LoadingState label={t("channels.loading")} />
          ) : layout === "table" ? (
            <DataTable
              rows={visibleChannels}
              columns={columns}
              rowKey={(c) => c.id}
              persistKey={accountKey(LS.channelsTable) ?? undefined}
              controlsPosition="both"
              controlsLeading={statusChips}
              controlsInBand
              controlsBandClassName="px-4 max-w-[96rem] mx-auto"
              sort={sort}
              onSortChange={setSort}
              rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
              emptyText={t("channels.empty")}
            />
          ) : (
            <ChannelLayoutGrid
              rows={pagedCards}
              columns={columns}
              rowKey={(c) => c.id}
              leadKey="priority"
              aboutKey="about"
              actionsKey="actions"
              rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
              emptyText={t("channels.empty")}
            />
          )}
        </div>
      )}
    </>
  );
}

function Stat({ label, value, hint }: { label: string; value: string | number; hint?: string }) {
  return (
    <Tooltip hint={hint ?? ""}>
      <span className={hint ? "cursor-help" : ""}>
        <span className="text-fg font-semibold">{value}</span> {label}
      </span>
    </Tooltip>
  );
}

function SyncBadge({ ok, label, hint }: { ok: boolean; label: string; hint?: string }) {
  return (
    <Tooltip hint={hint ?? ""}>
      <span
        className={`inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full border ${
          ok ? "border-accent/40 text-accent" : "border-border text-muted"
        }`}
      >
        {ok && <Check className="w-3 h-3" />}
        {label}
      </span>
    </Tooltip>
  );
}

// Module-level (NOT defined inside PriorityCell) so its identity is stable across PriorityCell
// re-renders — otherwise every hold-repeat tick would remount the button and drop the pointer
// capture set on pointerdown, letting a hold run away if the pointer drifts off the arrow.
function PriorityStepButton({
  delta,
  Icon,
  label,
  onHoldStart,
  onStop,
  onTap,
}: {
  delta: number;
  Icon: typeof ArrowUp;
  label: string;
  onHoldStart: (delta: number) => void;
  onStop: () => void;
  onTap: (delta: number) => void;
}) {
  return (
    <button
      onPointerDown={(e) => {
        // Keep receiving pointer events even if the pointer drifts off the small button mid-hold.
        try {
          e.currentTarget.setPointerCapture(e.pointerId);
        } catch {
          /* invalid pointer id (e.g. synthetic events) — capture is a nicety, not required */
        }
        onHoldStart(delta);
      }}
      onPointerUp={onStop}
      onPointerCancel={onStop}
      onClick={() => onTap(delta)}
      className="text-muted hover:text-accent leading-none"
      aria-label={label}
    >
      <Icon className="w-3.5 h-3.5" />
    </button>
  );
}

function PriorityCell({
  c,
  onPriority,
  onSet,
}: {
  c: ManagedChannel;
  onPriority: (delta: number) => void;
  onSet: (value: number) => void;
}) {
  const { t } = useTranslation();
  const [editing, setEditing] = useState(false);
  const [draft, setDraft] = useState("");
  const holdDelay = useRef<ReturnType<typeof setTimeout> | null>(null);
  const holdRepeat = useRef<ReturnType<typeof setTimeout> | null>(null);
  const suppressClick = useRef(false);

  const stop = useCallback(() => {
    if (holdDelay.current) clearTimeout(holdDelay.current);
    if (holdRepeat.current) clearTimeout(holdRepeat.current);
    holdDelay.current = holdRepeat.current = null;
  }, []);
  useEffect(() => stop, [stop]); // clear any running timers on unmount

  // Hold past a threshold → auto-repeat at an accelerating rate, and suppress the trailing click so
  // a hold doesn't double-count its final notch. A quick tap never crosses the threshold, so it
  // falls through to onClick — which also keeps the keyboard path (Enter/Space) working.
  const start = (delta: number) => {
    suppressClick.current = false;
    holdDelay.current = setTimeout(() => {
      suppressClick.current = true;
      let rate = 140;
      const tick = () => {
        onPriority(delta);
        rate = Math.max(40, rate - 12);
        holdRepeat.current = setTimeout(tick, rate);
      };
      tick();
    }, 350);
  };
  const onTap = (delta: number) => {
    if (suppressClick.current) {
      suppressClick.current = false;
      return;
    }
    onPriority(delta);
  };

  const commitEdit = () => {
    const parsed = parseInt(draft, 10);
    if (!Number.isNaN(parsed) && parsed !== c.priority) onSet(parsed);
    setEditing(false);
  };
  const beginEdit = () => {
    setDraft(String(c.priority));
    setEditing(true);
  };

  // The value sits to the LEFT of the arrows (not between them) so the cursor — which rests on the
  // arrows while you press-and-hold — never covers the live value you're setting. Clicking the value
  // opens an inline edit to type any integer directly (negatives allowed).
  return (
    <Tooltip hint={t("channels.row.priorityHint")}>
      <div className="inline-flex items-center gap-1.5 cursor-help">
        {/* The number keeps its footprint (w-6) whether displayed or editing; the input is absolutely
          positioned OVER it (out of flow) so opening the editor never changes the row height/width —
          it just overlays, extending left for a longer number. */}
        <span className="relative inline-block w-6 h-5 text-right leading-5">
          <button
            onClick={beginEdit}
            aria-label={t("channels.row.editPriority")}
            title={t("channels.row.editPriority")}
            className={`text-sm font-medium tabular-nums hover:text-accent ${editing ? "invisible" : ""}`}
          >
            {c.priority}
          </button>
          {editing && (
            <input
              type="number"
              value={draft}
              autoFocus
              onFocus={(e) => e.currentTarget.select()}
              onChange={(e) => setDraft(e.target.value)}
              onBlur={commitEdit}
              onKeyDown={(e) => {
                if (e.key === "Enter") commitEdit();
                else if (e.key === "Escape") setEditing(false);
              }}
              aria-label={t("channels.row.priorityHint")}
              className="absolute top-1/2 right-0 -translate-y-1/2 w-14 h-6 text-sm font-medium tabular-nums text-right bg-card border border-accent rounded px-1 outline-none z-popover [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
            />
          )}
        </span>
        <span className="inline-flex flex-col">
          <PriorityStepButton
            delta={1}
            Icon={ArrowUp}
            label={t("channels.row.raisePriority")}
            onHoldStart={start}
            onStop={stop}
            onTap={onTap}
          />
          <PriorityStepButton
            delta={-1}
            Icon={ArrowDown}
            label={t("channels.row.lowerPriority")}
            onHoldStart={start}
            onStop={stop}
            onTap={onTap}
          />
        </span>
      </div>
    </Tooltip>
  );
}

// Status only — the backfill *action* lives in the Actions column. When both recent and
// full history are in, collapse to a single "fully synced" chip; otherwise show what's
// present plus the missing/queued state.
function SyncCell({ c }: { c: ManagedChannel }) {
  const { t } = useTranslation();
  if (c.recent_synced && c.backfill_done) {
    return (
      <SyncBadge
        ok
        label={t("channels.row.fullySynced")}
        hint={t("channels.row.fullySyncedHint")}
      />
    );
  }
  return (
    <div className="flex flex-nowrap items-center gap-1 whitespace-nowrap">
      <SyncBadge
        ok={c.recent_synced}
        label={t("channels.row.recent")}
        hint={
          c.recent_synced
            ? t("channels.row.recentSyncedHint")
            : t("channels.row.recentNotSyncedHint")
        }
      />
      {c.backfill_done ? (
        <SyncBadge ok label={t("channels.row.full")} hint={t("channels.row.fullHint")} />
      ) : c.deep_requested ? (
        <SyncBadge
          ok={false}
          label={t("channels.row.fullHistoryQueued")}
          hint={t("channels.row.queuedRequestedHint")}
        />
      ) : c.deep_in_queue ? (
        <SyncBadge
          ok={false}
          label={t("channels.row.fullHistoryComing")}
          hint={t("channels.row.queuedByOtherHint")}
        />
      ) : (
        <SyncBadge
          ok={false}
          label={t("channels.row.full")}
          hint={t("channels.row.fullNotFetchedHint")}
        />
      )}
    </div>
  );
}

function ActionsCell({
  c,
  isAdmin,
  canWrite,
  onHide,
  onDeep,
  onReset,
  onUnsubscribe,
}: {
  c: ManagedChannel;
  isAdmin: boolean;
  canWrite: boolean;
  onHide: () => void;
  onDeep: () => void;
  onReset: () => void;
  onUnsubscribe: () => void;
}) {
  const { t } = useTranslation();
  // Admins get a "reset" trigger that re-fetches the channel from scratch regardless of
  // state — always actionable. Regular users get the per-user "request full history" opt-in,
  // which only applies until the back-catalog is in (or already coming for everyone).
  const done = c.backfill_done;
  const requested = !done && c.deep_requested;
  const optInActionable = !done && !c.deep_in_queue;
  const backfillHint = isAdmin
    ? t("channels.row.resetHint")
    : c.deep_in_queue
      ? t("channels.row.queuedByOtherHint")
      : requested
        ? t("channels.row.queuedRequestedHint")
        : done
          ? t("channels.row.fullHint")
          : t("channels.row.backfillThisHint");
  const enabled = isAdmin || optInActionable;
  return (
    <div className="inline-flex items-center gap-2">
      <Tooltip hint={backfillHint}>
        <button
          onClick={!enabled ? undefined : isAdmin ? onReset : onDeep}
          disabled={!enabled}
          className={`shrink-0 transition ${
            requested
              ? "text-accent"
              : enabled
                ? "text-muted hover:text-fg"
                : "text-muted/40 cursor-default"
          }`}
          aria-label={isAdmin ? t("channels.row.resetBackfill") : t("channels.row.backfillThis")}
        >
          <History className="w-4 h-4" />
        </button>
      </Tooltip>
      <Tooltip hint={c.hidden ? t("channels.row.hiddenHint") : t("channels.row.hideHint")}>
        <button
          onClick={onHide}
          className="text-muted hover:text-fg shrink-0"
          aria-label={c.hidden ? t("channels.row.unhide") : t("channels.row.hideFromFeed")}
        >
          {c.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
        </button>
      </Tooltip>
      {canWrite && (
        <Tooltip hint={t("channels.row.unsubscribeHint")}>
          <button
            onClick={onUnsubscribe}
            className="text-muted hover:text-red-400 shrink-0"
            aria-label={t("channels.row.unsubscribeOnYoutube")}
          >
            <UserMinus className="w-4 h-4" />
          </button>
        </Tooltip>
      )}
    </div>
  );
}
