import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import {
  DndContext,
  KeyboardSensor,
  PointerSensor,
  closestCenter,
  useSensor,
  useSensors,
  type DragEndEvent,
} from "@dnd-kit/core";
import {
  SortableContext,
  arrayMove,
  sortableKeyboardCoordinates,
  useSortable,
  verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
  AlertTriangle,
  Check,
  ExternalLink,
  GripVertical,
  ListPlus,
  Pencil,
  Play,
  RefreshCw,
  RotateCcw,
  Trash2,
  Upload,
  Youtube,
} from "lucide-react";
import { api, HttpError, type Video } from "../lib/api";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { playlistName } from "../lib/playlistName";
import { useUndoable } from "../lib/useUndoable";
import { cycleSort, type SortState } from "../lib/columnSort";
import { applyView, sameOrder } from "../lib/playlistView";
const PlayerModal = lazy(() => import("./PlayerModal"));
import UndoToolbar from "./UndoToolbar";
import { PageToolbar } from "./PageShell";
import { useChoice, useConfirm } from "./ConfirmProvider";
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
import { useMe } from "../lib/useMe";
import {
  ColumnHead,
  alignClass,
  filterRows,
  isActive,
  type Column,
  type FilterMap,
} from "./tableHeader";
import { GRIP_KEY, playlistColumns } from "./playlistColumns";

// The playlist detail is a HYBRID table (E4 S4): real sortable/filterable column headers over a
// body that is still a drag-reorderable @dnd-kit list.
//
// The distinction that shapes the whole module: **the column sort and the filters are a VIEW**,
// while the playlist's own order is stored data. Before S4 the sort control wrote straight through
// to the server, so picking "sort by title" silently rewrote the playlist and marked a
// YouTube-linked mirror dirty. Now nothing is written until the user presses "Save this order",
// which still goes through the undoable path — the capability is intact, it just stopped being a
// side effect of looking at the list.
//
// It composes `tableHeader`'s pieces instead of rendering a `DataTable`, because DataTable owns
// its own filter/sort/page state and the stored order needs a single owner.

// The neutral view: "#" ascending IS the stored order, so the header can show a real arrow for it
// instead of the list looking unsorted (or, worse, looking sorted by whatever column the stored
// order happens to correlate with — the user read a channel-grouped playlist as sorted by Channel).
const STORED_SORT: SortState = { key: "pos", dir: "asc" };
const isStoredSort = (s: SortState) => s?.key === "pos" && s.dir === "asc";

const idsKey = (items: Video[]) =>
  items
    .map((v) => v.id)
    .sort()
    .join(",");

function Row({
  video,
  columns,
  dragDisabled,
  dragHint,
}: {
  video: Video;
  columns: Column<Video>[];
  dragDisabled: boolean;
  dragHint: string;
}) {
  const { t } = useTranslation();
  const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
    id: video.id,
    disabled: dragDisabled,
  });
  return (
    <tr
      ref={setNodeRef}
      style={{
        transform: CSS.Transform.toString(transform),
        // No transition on the row being dragged: dnd-kit drives `transform` from the pointer, so
        // animating it makes the row trail the cursor. The others keep it for the reflow animation.
        transition: isDragging ? undefined : transition,
        opacity: isDragging ? 0.6 : 1,
      }}
      // transition-COLORS, not `transition` — the bare utility animates transform too, which is
      // the same lag by another route (150ms behind the pointer, on every row).
      className="border-b border-border/50 hover:bg-card/40 transition-colors"
    >
      {columns.map((col) => (
        <td
          key={col.key}
          style={col.width ? { width: col.width } : undefined}
          className={`px-2 py-1.5 align-middle ${col.nowrap ? "whitespace-nowrap" : ""} ${alignClass(col.align)}`}
        >
          {col.key === GRIP_KEY ? (
            // The one cell the column defs can't render: the handle needs this row's sortable
            // attributes/listeners. Disabled (not hidden) under a view, so the affordance stays
            // put and can explain itself instead of silently doing nothing.
            <button
              {...attributes}
              {...(dragDisabled ? {} : listeners)}
              // `disabled` would drop it from the tab order AND suppress the tooltip, so the
              // reason reached nobody. aria-disabled keeps it focusable and the name carries the why.
              aria-disabled={dragDisabled || undefined}
              onClick={(e) => dragDisabled && e.preventDefault()}
              title={dragDisabled ? dragHint : t("playlists.dragHandle")}
              aria-label={
                dragDisabled ? dragHint : t("playlists.dragHandleOf", { title: video.title })
              }
              className={`text-muted ${dragDisabled ? "opacity-30 cursor-not-allowed" : "hover:text-fg cursor-grab active:cursor-grabbing"}`}
            >
              <GripVertical className="w-4 h-4" />
            </button>
          ) : (
            col.render(video)
          )}
        </td>
      ))}
    </tr>
  );
}

export default function Playlists() {
  const me = useMe();
  const canWrite = me.can_write;
  // The selected playlist lives in PlaylistsProvider, shared with the PlaylistsRail (which owns the
  // list + its rail controls). This detail pane reacts to the selection.
  const { selectedId } = usePlaylists();
  const { setSelectedId } = usePlaylistsActions();
  const { t } = useTranslation();
  const qc = useQueryClient();
  const confirm = useConfirm();
  const choose = useChoice();
  const [renaming, setRenaming] = useState(false);
  const [renameValue, setRenameValue] = useState("");
  // The stored order is undoable: drag and "save this order" both go through `order.set`, so each
  // is reversible (buttons + Ctrl+Z/Y). onApply persists the new order to the server.
  const order = useUndoable<Video[]>([], {
    onApply: (next) => {
      if (selectedId == null) return;
      api
        .reorderPlaylist(
          selectedId,
          next.map((v) => v.id),
        )
        .then(() => {
          // Refresh the sidebar (cover may change) and the detail (so the now-dirty state,
          // and the Reset/Unsynced indicators, show without an F5). The membership guard
          // keeps the refetch from wiping the undo history on a pure reorder.
          qc.invalidateQueries({ queryKey: qk.playlists() });
          qc.invalidateQueries({ queryKey: qk.playlist(selectedId) });
        })
        .catch((e) => {
          // Was a bare .then(): a failed reorder left the UI showing an order the server had
          // rejected, and produced an unhandled rejection. Say so and resync from the server.
          notifyYouTubeActionError(e, t("playlists.reorderFailed"));
          // Clear the membership guard first: a reorder never changes the id SET, so without this
          // the arriving detail is judged "same list" and the rejected order stays on screen — and
          // becomes the base for the next drag.
          lastSetRef.current = "";
          qc.invalidateQueries({ queryKey: qk.playlist(selectedId) });
        });
    },
  });
  const items = order.value;
  // VIEW state — never written to the server on its own.
  const [viewSort, setViewSort] = useState<SortState>(STORED_SORT);
  const [filters, setFilters] = useState<FilterMap>({});
  const [groupBy, setGroupBy] = useState(false);
  // Tracks the membership (id set) currently loaded into `order`, so a refetch that only
  // re-confirms our own reorder doesn't wipe the undo history — we reset history only when
  // the actual set of items changes (different playlist, or an add/remove).
  const lastSetRef = useRef<string>("");
  // The open player is tracked by video ID, not by a position: `shown` is derived, so a refetch or
  // a change of view would otherwise slide a different video under the index — or unmount the player
  // mid-playback. PlayerModal already tracks its own queue by id; this is the parent half of that.
  const [playingId, setPlayingId] = useState<string | null>(null);

  const listQuery = useQuery({
    queryKey: qk.playlists(),
    queryFn: () => api.playlists(),
    refetchOnWindowFocus: true,
  });
  const playlists = listQuery.data ?? [];

  const detailQuery = useQuery({
    queryKey: qk.playlist(selectedId),
    queryFn: () => api.playlist(selectedId as number),
    enabled: selectedId != null,
    // Refetch when returning to the tab so a change made elsewhere (another tab/device)
    // shows up — including the player's live N / M count, which reads the queue length.
    refetchOnWindowFocus: true,
  });
  const detail = detailQuery.data;

  useEffect(() => {
    if (!detail) return;
    // Scoped to the playlist: two lists holding the SAME videos would otherwise look identical to
    // the guard, so the second would render in the first's order and inherit its undo stack.
    const key = `${selectedId}:${idsKey(detail.items)}`;
    if (key !== lastSetRef.current) {
      lastSetRef.current = key;
      order.reset(detail.items);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [detail]);

  // Reset the view when switching playlists (the stored order itself is per-playlist).
  useEffect(() => {
    setViewSort(STORED_SORT);
    setFilters({});
    setGroupBy(false);
  }, [selectedId]);

  const builtin = detail?.kind === "watch_later";
  // YouTube-mirrored playlists can be edited locally and synced back (the read-sync skips a
  // dirty mirror so it won't clobber unpushed edits); the YT-link button marks their origin.
  const editable = !builtin;
  const linked = editable && !!detail?.yt_playlist_id;
  // LOCAL editing (reorder, remove) needs no YouTube write scope — `canWrite` gates only the
  // push/delete-on-YouTube paths. Folding it in here would have taken drag and remove away from
  // every account without the scope, which they have always had.
  const canEdit = editable;
  const [pushing, setPushing] = useState(false);
  const [reverting, setReverting] = useState(false);

  const onPlay = useCallback((v: Video) => setPlayingId(v.id), []);
  const onRemove = useCallback((v: Video) => {
    removeRef.current(v);
  }, []);

  const storedIndex = useMemo(() => {
    const m = new Map(items.map((v, i) => [v.id, i]));
    return (v: Video) => m.get(v.id) ?? 0;
  }, [items]);

  const columns = useMemo(
    () => playlistColumns({ t, indexOf: storedIndex, onPlay, onRemove, canEdit }),
    [t, storedIndex, onPlay, onRemove, canEdit],
  );

  const filterActive = Object.values(filters).some(isActive);
  const shown = useMemo(
    () => applyView(filterRows(items, columns, filters), columns, viewSort, groupBy),
    [items, columns, filters, viewSort, groupBy],
  );
  // Read by the drag announcements, which must report the CURRENT positions without making the
  // announcement closures a render dependency of DndContext.
  const shownRef = useRef(shown);
  shownRef.current = shown;

  const viewActive = !isStoredSort(viewSort) || groupBy || filterActive;
  // Saving a FILTERED view would drop every hidden item from the playlist, so the button only
  // offers itself when the view is a pure reordering of the whole list.
  const canSaveOrder = canEdit && !filterActive && !sameOrder(shown, items);

  function toggleSort(key: string) {
    // Cycling off any column lands back on the stored order rather than on a nameless "unsorted".
    setViewSort((s) => cycleSort(s, key) ?? STORED_SORT);
  }
  function applyFilter(key: string, value: string | string[]) {
    setFilters((f) => ({ ...f, [key]: value }));
  }
  function clearView() {
    setViewSort(STORED_SORT);
    setFilters({});
    setGroupBy(false);
  }
  function saveOrder() {
    order.set(shown); // onApply persists it
    clearView();
  }

  const sensors = useSensors(
    useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
    // The grip was focusable and called itself "reorder", but only a pointer could move it.
    useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
  );

  const posOf = (id: string | number) => shownRef.current.findIndex((v) => v.id === id) + 1;
  const titleOf = (id: string | number) => shownRef.current.find((v) => v.id === id)?.title ?? "";
  const total = () => shownRef.current.length;
  const screenReaderInstructions = { draggable: t("playlists.dndInstructions") };
  const announcements = {
    onDragStart: ({ active }: { active: { id: string | number } }) =>
      t("playlists.dndPickedUp", {
        title: titleOf(active.id),
        pos: posOf(active.id),
        total: total(),
      }),
    onDragOver: ({ active }: { active: { id: string | number } }) =>
      t("playlists.dndMovedOver", {
        title: titleOf(active.id),
        pos: posOf(active.id),
        total: total(),
      }),
    onDragEnd: ({ active }: { active: { id: string | number } }) =>
      t("playlists.dndDropped", {
        title: titleOf(active.id),
        pos: posOf(active.id),
        total: total(),
      }),
    onDragCancel: ({ active }: { active: { id: string | number } }) =>
      t("playlists.dndCancelled", { title: titleOf(active.id), pos: posOf(active.id) }),
  };

  const plName = (p: { kind: string; name: string }) => playlistName(p, t);

  function refreshAll() {
    qc.invalidateQueries({ queryKey: qk.playlists() });
    qc.invalidateQueries({ queryKey: qk.playlist(selectedId) });
  }

  async function revertToYoutube() {
    if (selectedId == null || !detail || reverting) return;
    const ok = await confirm({
      title: t("playlists.revertTitle"),
      message: t("playlists.revertMsg"),
      confirmLabel: t("playlists.revertConfirm"),
      danger: true,
    });
    if (!ok) return;
    setReverting(true);
    try {
      await api.revertPlaylist(selectedId);
      refreshAll();
      notify({ level: "success", message: t("playlists.revertDone", { name: plName(detail) }) });
    } catch (e) {
      notifyYouTubeActionError(e, t("playlists.revertFailed"));
    } finally {
      setReverting(false);
    }
  }

  async function pushToYoutube() {
    if (selectedId == null || !detail || pushing) return;
    setPushing(true);
    try {
      const plan = await api.playlistPushPlan(selectedId);
      if (plan.action === "update" && !plan.to_insert && !plan.to_delete && !plan.to_reorder) {
        notify({ level: "info", message: t("playlists.pushUpToDate", { name: plName(detail) }) });
        return;
      }
      if (!plan.affordable) {
        notify({
          level: "warning",
          message: t("playlists.pushNoQuota", {
            left: plan.remaining_today,
            units: plan.units_estimate,
          }),
        });
        return;
      }
      let message =
        plan.action === "create"
          ? t("playlists.pushPlanCreate", {
              count: plan.to_insert,
              units: plan.units_estimate,
              left: plan.remaining_today,
            })
          : t("playlists.pushPlanUpdate", {
              insert: plan.to_insert,
              del: plan.to_delete,
              reorder: plan.to_reorder,
              units: plan.units_estimate,
              left: plan.remaining_today,
            });
      if (plan.yt_extra > 0) message += " " + t("playlists.pushDiverged", { count: plan.yt_extra });
      const ok = await confirm({
        title: t("playlists.pushTitle"),
        message,
        confirmLabel: t("playlists.pushConfirm"),
      });
      if (!ok) return;
      const r = await api.pushPlaylist(selectedId);
      refreshAll();
      if (r.failures.length) {
        notify({
          level: "warning",
          message: t("playlists.pushPartial", { count: r.failures.length }),
        });
      } else {
        notify({ level: "success", message: t("playlists.pushDone", { name: plName(detail) }) });
      }
    } catch (e) {
      notifyYouTubeActionError(e, t("playlists.pushFailed"));
    } finally {
      setPushing(false);
    }
  }

  function onDragEnd(e: DragEndEvent) {
    const { active: a, over } = e;
    if (!over || a.id === over.id || selectedId == null) return;
    const oldIndex = items.findIndex((v) => v.id === a.id);
    const newIndex = items.findIndex((v) => v.id === over.id);
    if (oldIndex < 0 || newIndex < 0) return;
    order.set(arrayMove(items, oldIndex, newIndex)); // onApply persists
  }

  async function removeItem(video: Video) {
    if (selectedId == null) return;
    const ok = await confirm({
      title: t("playlists.removeItem"),
      message: t("playlists.confirmRemove", { title: video.title }),
      confirmLabel: t("playlists.removeItem"),
      danger: true,
    });
    if (!ok) return;
    // A removal isn't itself undoable, but it no longer THROWS AWAY the reorder history: rebasing
    // drops the item from every snapshot, so the stack stays valid for what's left.
    const next = items.filter((v) => v.id !== video.id);
    order.rebase((list) => list.filter((v) => v.id !== video.id));
    lastSetRef.current = `${selectedId}:${idsKey(next)}`;
    try {
      await api.removeFromPlaylist(selectedId, video.id);
      refreshAll();
    } catch {
      // The optimistic removal above didn't stick — resync from the server to restore the row.
      // api.req already surfaced the error to the user via the global error dialog.
      refreshAll();
    }
  }
  // Kept in a ref so the memoized column callbacks don't rebuild on every state change.
  const removeRef = useRef(removeItem);
  removeRef.current = removeItem;

  async function saveRename() {
    if (selectedId == null) return;
    const name = renameValue.trim();
    setRenaming(false);
    if (name && name !== detail?.name) {
      try {
        await api.renamePlaylist(selectedId, name);
      } catch (e) {
        notifyYouTubeActionError(e, t("playlists.renameFailed"));
      }
      refreshAll();
    }
  }

  async function deletePlaylist() {
    if (selectedId == null || !detail) return;
    // ONE dialog with every outcome. It used to be two chained confirms, where the second one's
    // Cancel/Escape/backdrop meant "delete here only" — dismissing the dialog deleted the playlist.
    const offerYoutube = linked && canWrite;
    const picked = await choose({
      title: t("playlists.delete"),
      message: offerYoutube
        ? t("playlists.confirmDeleteLinked", { name: plName(detail) })
        : t("playlists.confirmDelete", { name: plName(detail) }),
      choices: offerYoutube
        ? [
            { id: "here", label: t("playlists.deleteHereOnly"), danger: true },
            { id: "both", label: t("playlists.deleteBoth"), danger: true },
          ]
        : [{ id: "here", label: t("playlists.delete"), danger: true }],
    });
    if (picked == null) return;
    const onYoutube = picked === "both";
    const deletedId = selectedId;
    // Pick the next selection from what's left *now* (don't go via null, or the
    // auto-select effect would re-pick the just-deleted id from the stale list).
    const remaining = playlists.filter((p) => p.id !== deletedId);
    setSelectedId(remaining[0]?.id ?? null);
    try {
      await api.deletePlaylist(deletedId, onYoutube);
    } catch (err) {
      // Prefer the server's specific reason (e.g. a local vs YouTube-side failure) over a blanket
      // "couldn't delete on YouTube" that misleads when the failure wasn't the YouTube side.
      notify({
        level: "warning",
        message:
          (err instanceof HttpError && err.detail) ||
          (onYoutube ? t("playlists.deleteYtFailed") : t("playlists.deleteFailed")),
      });
    }
    qc.removeQueries({ queryKey: qk.playlist(deletedId) });
    qc.invalidateQueries({ queryKey: qk.playlists() });
  }

  function playerState(id: string, status: string) {
    api.setState(id, status).then(() => {
      qc.invalidateQueries({ queryKey: qk.playlist(selectedId) });
      qc.invalidateQueries({ queryKey: qk.feed() });
    });
  }

  // Stable identity: PlayerModal's keydown/scroll-lock effect depends on it, so a fresh closure
  // every render re-subscribed those listeners and re-ran focusModal() under the open player.
  const closePlayer = useCallback(() => setPlayingId(null), []);

  // Name the current view in the user's terms: "Title ↑", "grouped by channel", or both.
  const sortLabel =
    viewSort && !isStoredSort(viewSort)
      ? `${columns.find((c) => c.key === viewSort.key)?.header ?? viewSort.key} ${
          viewSort.dir === "asc" ? "↑" : "↓"
        }`
      : null;
  const viewName = groupBy
    ? sortLabel
      ? t("playlists.viewGroupedAnd", { sort: sortLabel })
      : t("playlists.viewGrouped")
    : (sortLabel ?? "");
  const viewStatus = filterActive
    ? t("playlists.viewFiltered", { shown: shown.length, total: items.length })
    : canSaveOrder
      ? t("playlists.viewDiffers", { view: viewName })
      : t("playlists.viewMatches", { view: viewName });

  // Order matters: a built-in list is never reorderable, whatever the view says.
  const dragHint = !canEdit
    ? t("playlists.dragBlockedBuiltin")
    : filterActive
      ? t("playlists.dragBlockedFilter")
      : t("playlists.dragBlockedSort");
  const playingIndex = playingId ? shown.findIndex((v) => v.id === playingId) : -1;

  if (selectedId == null) {
    return <div className="text-muted text-sm p-4">{t("playlists.pickOne")}</div>;
  }
  // The page used to render "Loading…" forever when the detail query failed — an error read as a
  // slow network that never arrived.
  if (detailQuery.isError) {
    return (
      <div className="text-sm p-4 flex flex-col items-start gap-3">
        <span className="inline-flex items-center gap-2 text-muted">
          <AlertTriangle className="w-4 h-4 text-red-400" />
          {t("playlists.loadFailed")}
        </span>
        <button
          onClick={() => detailQuery.refetch()}
          className="px-3 py-1.5 rounded-lg border border-border text-xs text-muted hover:text-fg hover:border-accent transition"
        >
          {t("common.retry")}
        </button>
      </div>
    );
  }
  if (!detail) {
    return <div className="text-muted text-sm p-4">{t("playlists.loading")}</div>;
  }

  return (
    <div className="px-4 pb-4 pt-3">
      {/* Header (title, actions, view controls) is fixed chrome — it portals into the shell's
          fixed band, so only the video list scrolls. */}
      <PageToolbar>
        <div className="px-4 pt-3 pb-2">
          <div className="flex gap-3 items-start mb-4">
            <div className="w-24 h-[54px] rounded-lg bg-card overflow-hidden shrink-0">
              {items[0]?.thumbnail_url && (
                <img src={items[0].thumbnail_url} alt="" className="w-full h-full object-cover" />
              )}
            </div>
            <div className="min-w-0 flex-1">
              {renaming ? (
                <input
                  autoFocus
                  value={renameValue}
                  onChange={(e) => setRenameValue(e.target.value)}
                  onBlur={saveRename}
                  onKeyDown={(e) => {
                    if (e.key === "Enter") saveRename();
                    if (e.key === "Escape") setRenaming(false);
                  }}
                  aria-label={t("playlists.rename")}
                  className="text-lg font-semibold bg-card border border-border rounded-md px-2 py-0.5 outline-none focus:border-accent"
                />
              ) : (
                <div className="flex items-center gap-2 min-w-0">
                  <h2 className="text-lg font-semibold truncate">{plName(detail)}</h2>
                  {editable && (
                    <button
                      onClick={() => {
                        setRenameValue(detail.name);
                        setRenaming(true);
                      }}
                      title={t("playlists.rename")}
                      aria-label={t("playlists.rename")}
                      className="shrink-0 text-muted hover:text-fg"
                    >
                      <Pencil className="w-3.5 h-3.5" />
                    </button>
                  )}
                  {detail.yt_playlist_id && (
                    <a
                      href={`https://www.youtube.com/playlist?list=${detail.yt_playlist_id}`}
                      target="_blank"
                      rel="noreferrer noopener"
                      title={t("playlists.openOnYoutube")}
                      aria-label={t("playlists.openOnYoutube")}
                      className="shrink-0 text-muted hover:text-accent"
                    >
                      <ExternalLink className="w-3.5 h-3.5" />
                    </a>
                  )}
                </div>
              )}
              <div className="text-xs text-muted mt-0.5">
                {t("playlists.itemCount", { count: items.length })}
                {filterActive && ` · ${t("playlists.filteredCount", { count: shown.length })}`}
              </div>
              <div className="flex gap-1.5 mt-3 flex-wrap items-center">
                <button
                  onClick={() => shown.length && setPlayingId(shown[0]!.id)}
                  disabled={!shown.length}
                  className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg bg-accent text-accent-fg disabled:opacity-40 hover:opacity-90 transition"
                >
                  <Play className="w-3.5 h-3.5 fill-current" /> {t("playlists.playAll")}
                </button>
                {editable && canWrite && (
                  <button
                    onClick={pushToYoutube}
                    disabled={pushing}
                    title={linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")}
                    aria-label={
                      linked ? t("playlists.pushToYoutube") : t("playlists.exportToYoutube")
                    }
                    className={`inline-flex items-center justify-center p-2 rounded-lg border transition disabled:opacity-40 ${
                      detail.dirty
                        ? "border-accent text-accent hover:bg-accent/10"
                        : "border-border text-muted hover:text-fg hover:border-accent"
                    }`}
                  >
                    {pushing ? (
                      <RefreshCw className="w-3.5 h-3.5 animate-spin" />
                    ) : linked ? (
                      <Youtube className="w-3.5 h-3.5" />
                    ) : (
                      <Upload className="w-3.5 h-3.5" />
                    )}
                  </button>
                )}
                {linked && detail.dirty && (
                  <button
                    onClick={revertToYoutube}
                    disabled={reverting}
                    title={t("playlists.revert")}
                    aria-label={t("playlists.revert")}
                    className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-fg hover:border-accent transition disabled:opacity-40"
                  >
                    <RotateCcw className={`w-3.5 h-3.5 ${reverting ? "animate-spin" : ""}`} />
                  </button>
                )}
                {editable && (
                  <button
                    onClick={deletePlaylist}
                    title={t("playlists.delete")}
                    aria-label={t("playlists.delete")}
                    className="inline-flex items-center justify-center p-2 rounded-lg border border-border text-muted hover:text-red-400 hover:border-red-400/50 transition"
                  >
                    <Trash2 className="w-3.5 h-3.5" />
                  </button>
                )}
                {linked && detail.dirty && (
                  <span
                    title={t("playlists.unsyncedHint")}
                    className="inline-flex items-center gap-1.5 text-[11px] text-accent ml-1"
                  >
                    <span className="w-1.5 h-1.5 rounded-full bg-accent" />
                    {t("playlists.unsynced")}
                  </span>
                )}
              </div>
            </div>
          </div>

          {(items.length > 1 || viewActive) && (
            <div className="flex items-center gap-2 mb-3 flex-wrap max-w-5xl">
              <label className="inline-flex items-center gap-1.5 text-xs text-muted cursor-pointer select-none">
                <input
                  type="checkbox"
                  checked={groupBy}
                  onChange={(e) => setGroupBy(e.target.checked)}
                  className="accent-accent"
                />
                {t("playlists.groupByChannel")}
              </label>
              {viewActive && (
                <button
                  onClick={clearView}
                  className="text-xs text-muted hover:text-accent underline underline-offset-2 transition"
                >
                  {t("playlists.clearView")}
                </button>
              )}
              {canSaveOrder && (
                <button
                  onClick={saveOrder}
                  className="inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-lg border border-accent text-accent hover:bg-accent/10 transition"
                >
                  <Check className="w-3.5 h-3.5" /> {t("playlists.saveOrder")}
                </button>
              )}
              {/* ONE sentence that names the state. Three loose texts used to pile up here (reset
                  link + save hint + "reordering is off"), and none of them answered the question a
                  user actually has — "did I just change my playlist?". Worse, sorting a list whose
                  stored order ALREADY matches the sort produced a warning, no Save button and no
                  visible change, which reads as a malfunction. Now the line says which of the three
                  situations you are in; the disabled grip still explains itself on hover/focus. */}
              {viewActive && <span className="text-[11px] text-muted/80">{viewStatus}</span>}
              {canEdit && (
                <div className="ml-auto">
                  <UndoToolbar
                    canUndo={order.canUndo}
                    canRedo={order.canRedo}
                    onUndo={order.undo}
                    onRedo={order.redo}
                  />
                </div>
              )}
            </div>
          )}
        </div>
      </PageToolbar>

      {items.length === 0 ? (
        <div className="text-sm text-muted grid place-items-center text-center py-12">
          <div>
            <ListPlus className="w-6 h-6 mx-auto mb-2 opacity-60" />
            {t("playlists.empty")}
          </div>
        </div>
      ) : (
        <DndContext
          sensors={sensors}
          collisionDetection={closestCenter}
          onDragEnd={onDragEnd}
          // dnd-kit's defaults are hardcoded English. They were harmless while only a pointer could
          // drag; now that the keyboard can, they ARE the instructions for the feature.
          accessibility={{ screenReaderInstructions, announcements }}
        >
          {/* Not clipped, so the header's filter popovers can overflow the table box. */}
          <div className="max-w-5xl">
            {/* table-fixed, not auto: every column but the title has a declared width, so the
                title absorbs the rest and its `truncate` works. Auto layout sizes columns from
                min-content, and a nowrap title cell's min-content is the entire title. */}
            <table className="w-full table-fixed border-collapse text-sm">
              <ColumnHead
                columns={columns}
                sort={viewSort}
                onToggleSort={toggleSort}
                filters={filters}
                onFilter={applyFilter}
              />
              <SortableContext
                items={shown.map((v) => v.id)}
                strategy={verticalListSortingStrategy}
              >
                <tbody>
                  {shown.map((v) => (
                    <Row
                      key={v.id}
                      video={v}
                      columns={columns}
                      dragDisabled={!canEdit || viewActive}
                      dragHint={dragHint}
                    />
                  ))}
                </tbody>
              </SortableContext>
            </table>
            {shown.length === 0 && (
              <div className="text-muted py-8 text-center text-sm">
                {t("playlists.noneMatchFilter")}
              </div>
            )}
          </div>
        </DndContext>
      )}

      {playingIndex >= 0 && (
        <Suspense fallback={null}>
          <PlayerModal
            video={shown[playingIndex]!}
            queue={shown}
            startIndex={playingIndex}
            startAt={null}
            onClose={closePlayer}
            onState={playerState}
          />
        </Suspense>
      )}
    </div>
  );
}
