import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
  api,
  EMPTY_PLEX_FILTERS,
  getActiveAccount,
  setActiveAccount,
  setUnauthorizedHandler,
  type FeedFilters,
  type PlexFilters,
} from "./lib/api";
import { setLanguage, isSupported, type LangCode } from "./i18n";
import {
  applyTheme,
  DEFAULT_THEME,
  loadLocalTheme,
  saveLocalTheme,
  type ThemePrefs,
} from "./lib/theme";
import { hasFilterParams, isPage, paramsToFilters, readPage, stripUrlParams, type Page } from "./lib/urlState";
import { pageTitleKey } from "./lib/pageMeta";
import {
  loadLayout,
  normalizeLayout,
  saveLayoutLocal,
  type SidebarLayout,
} from "./lib/sidebarLayout";
import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications";
import { hintsEnabled, setHintsEnabled } from "./lib/hints";
import {
  accountKey,
  getAccountRaw,
  LS,
  readAccount,
  readJSON,
  readMerged,
  setAccountRaw,
  useAccountPersistedState,
  writeAccount,
  writeJSON,
} from "./lib/storage";
import { useConfirm } from "./components/ConfirmProvider";
import type { PrefsController } from "./components/SettingsPanel";
import type { ChannelStatusFilter, ChannelsView } from "./components/Channels";
// Persistent shell — always mounted, so eagerly imported.
import Welcome from "./components/Welcome";
import SetupWizard from "./components/SetupWizard";
import Header from "./components/Header";
import NavSidebar from "./components/NavSidebar";
import Sidebar from "./components/Sidebar";
import BackToTop from "./components/BackToTop";
import GlassTuner from "./components/GlassTuner";
import ChatDock from "./components/ChatDock";
import Toaster from "./components/Toaster";
import ErrorDialog from "./components/ErrorDialog";
import VersionBanner from "./components/VersionBanner";
import DemoBanner from "./components/DemoBanner";
import { setNavigator } from "./lib/nav";
import { focusAccessRequestsTab } from "./lib/adminUsersTab";
import { shouldAutoOpenOnboarding } from "./lib/onboarding";
import { CURRENT_VERSION } from "./lib/releaseNotes";

// Route-level code splitting: each module page (and the heavier modals) is its own lazy chunk,
// so the initial load — and the logged-out landing — pulls only the shell, and admin-only pages
// never reach non-admins. All are rendered inside a <Suspense> boundary below.
const Feed = lazy(() => import("./components/Feed"));
const PlexBrowse = lazy(() => import("./components/PlexBrowse"));
const PlexSidebar = lazy(() => import("./components/PlexSidebar"));
const ChannelPage = lazy(() => import("./components/ChannelPage"));
const Channels = lazy(() => import("./components/Channels"));
const Playlists = lazy(() => import("./components/Playlists"));
const Stats = lazy(() => import("./components/Stats"));
const Scheduler = lazy(() => import("./components/Scheduler"));
const ConfigPanel = lazy(() => import("./components/ConfigPanel"));
const AdminUsers = lazy(() => import("./components/AdminUsers"));
const AuditLog = lazy(() => import("./components/AuditLog"));
const SettingsPanel = lazy(() => import("./components/SettingsPanel"));
const NotificationsPanel = lazy(() => import("./components/NotificationsPanel"));
const Messages = lazy(() => import("./components/Messages"));
const DownloadCenter = lazy(() => import("./components/DownloadCenter"));
const OnboardingWizard = lazy(() => import("./components/OnboardingWizard"));
const About = lazy(() => import("./components/About"));
const ReleaseNotes = lazy(() => import("./components/ReleaseNotes"));

const DEFAULT_FILTERS: FeedFilters = {
  tags: [],
  tagMode: "or",
  q: "",
  sort: "newest",
  scope: "my",
  includeNormal: true,
  includeShorts: false,
  includeLive: false,
  show: "unwatched",
};

const FILTERS_KEY = LS.filters;
const PAGE_KEY = LS.page;
const PERF_KEY = LS.perfMode;

// The preferences edited on the Settings page. They apply locally for instant preview but
// persist to the server only on an explicit Save — so App holds both the live "draft" (the
// individual states below) and the last-saved "baseline" to compute dirty / revert on discard.
type EditablePrefs = {
  theme: ThemePrefs;
  view: "grid" | "list";
  performanceMode: boolean;
  hints: boolean;
  notifications: NotifSettings;
};

// Page is navigation state, not a filter, but it's also kept out of the address bar — so
// persist it to localStorage to survive a reload. A share link's ?page= still wins.
function loadInitialPage(): Page {
  const params = new URLSearchParams(window.location.search);
  if (params.has("page")) return readPage();
  const stored = getAccountRaw(PAGE_KEY);
  return isPage(stored) ? stored : "feed";
}

// This account's own persisted filters (per-account keys — never another account's). A chosen
// default saved view wins over the last-session filters (SavedViewsWidget mirrors it here). When
// the account isn't known yet (first load, before the tab is pinned), start from defaults; the
// post-login effect loads the real ones once the id arrives.
function loadAccountFilters(id: number | null): FeedFilters {
  // Your last-applied filters win, so a reload keeps whatever view you're on (a saved view you
  // picked, or manual tweaks) — setFilters persists them under LS.filters on every change. The
  // starred default view only SEEDS a fresh account that has never stored filters: it drives the
  // very first load, after which that state persists like any other. (Re-apply the default view
  // from the sidebar to return to it.)
  const fKey = accountKey(LS.filters, id);
  let hasStored = false;
  try {
    hasStored = !!fKey && localStorage.getItem(fKey) != null;
  } catch {
    /* localStorage may be unavailable */
  }
  if (hasStored && fKey) return readMerged(fKey, DEFAULT_FILTERS);
  const dvKey = accountKey(LS.defaultViewFilters, id);
  const def = dvKey ? readJSON<FeedFilters | null>(dvKey, null) : null;
  if (def) return { ...DEFAULT_FILTERS, ...def };
  return { ...DEFAULT_FILTERS };
}

// URL wins over localStorage so a pasted link reproduces the exact view.
function loadInitialFilters(): FeedFilters {
  const params = new URLSearchParams(window.location.search);
  if (hasFilterParams(params)) return paramsToFilters(params, DEFAULT_FILTERS);
  return loadAccountFilters(getActiveAccount());
}

export default function App() {
  const { t, i18n } = useTranslation();
  const confirm = useConfirm();
  // Refs the (stable) navigation handlers read so they always see the latest unsaved-prefs
  // state without being recreated; populated by an effect each render (below).
  const prefsDirtyRef = useRef(false);
  const discardRef = useRef<() => void>(() => {});
  const confirmRef = useRef(confirm);
  const pageRef = useRef<Page>("feed");
  const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
  const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
  // Whether this load arrived via a "Share view" link (captured once, before the URL is stripped)
  // — such filters win over the account's stored view and are persisted to the account instead.
  const [initialHadUrlFilters] = useState(() =>
    hasFilterParams(new URLSearchParams(window.location.search))
  );
  const [view, setView] = useState<"grid" | "list">("grid");
  // Settings-page prefs draft (apply live, persist on Save — see EditablePrefs).
  const [perf, setPerf] = useState(() => getAccountRaw(PERF_KEY) === "1");
  const [hints, setHints] = useState(() => hintsEnabled());
  const [notif, setNotif] = useState<NotifSettings>(() => getNotifSettings());
  const [savedPrefs, setSavedPrefs] = useState<EditablePrefs>(() => ({
    theme: loadLocalTheme(),
    view: "grid",
    performanceMode: getAccountRaw(PERF_KEY) === "1",
    hints: hintsEnabled(),
    notifications: getNotifSettings(),
  }));
  const [prefsSaveState, setPrefsSaveState] = useState<PrefsController["saveState"]>("idle");
  const saveMsgTimer = useRef<number | undefined>(undefined);
  const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout);
  const [page, setPageState] = useState<Page>(loadInitialPage);
  // Live YouTube search term (null = normal feed). Ephemeral: not persisted and not in the URL,
  // so a reload returns to the normal feed (a search spends quota, so we never auto-replay it).
  // The search is a feed SUB-VIEW that owns a browser-history entry (history.state._yt): the
  // popstate handler below derives ytSearch from it, so Back steps player → search → feed in
  // that order (a player opened over the results pops first, then the search, then the feed) —
  // instead of the search vanishing because it had no history entry of its own.
  const [ytSearch, setYtSearch] = useState<string | null>(() => {
    // Restore a live search across a reload ONLY when it was served by the zero-quota scrape
    // source (re-fetching is free) — an api-source search would re-spend ~100 units, so it's
    // dropped to the normal feed as before. The scrape flag rides in history.state (survives F5).
    const st = window.history.state;
    return st?._yt && st?._ytScrape ? (st._yt as string) : null;
  });
  const enterYtSearch = useCallback((q: string) => {
    setYtSearch(q);
    // Drop any prior _ytScrape marker — the new search's source isn't known yet; Feed re-stamps
    // it once the results resolve, so a reload only restores a confirmed scrape-sourced search.
    const { _ytScrape: _drop, ...st } = window.history.state || {};
    if (st._yt) window.history.replaceState({ ...st, _yt: q }, ""); // refine current search
    else window.history.pushState({ ...st, sfPage: "feed", _yt: q }, ""); // new sub-view entry
  }, []);
  const exitYtSearch = useCallback(() => {
    // Pop our search entry (so Forward still works); fall back to a plain clear if we don't own one.
    if (window.history.state?._yt) window.history.back();
    else setYtSearch(null);
  }, []);
  // The open channel page (null = no channel page). Like the YouTube-search sub-view it owns a
  // browser-history entry (history.state._chan = channel id, _chanName = a display name to show
  // before the detail loads), so Back closes the channel page (after any player opened over it)
  // and reload returns to the normal app. Channels/videos are reached by id, so it's not in the URL.
  const [channelView, setChannelView] = useState<{ id: string; name?: string } | null>(() => {
    // Restore the open channel page across a reload (it rides in history.state, which survives F5),
    // unlike the YouTube search which is intentionally dropped (it would re-spend quota).
    const c = window.history.state?._chan as string | undefined;
    return c ? { id: c, name: (window.history.state?._chanName as string) ?? undefined } : null;
  });
  const openChannel = useCallback((id: string, name?: string) => {
    setChannelView({ id, name });
    const st = window.history.state || {};
    if (st._chan)
      window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "");
    else window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "");
  }, []);
  const closeChannel = useCallback(() => {
    if (window.history.state?._chan) window.history.back();
    else setChannelView(null);
  }, []);
  const [wizardOpen, setWizardOpen] = useState(false);
  // Channel status chip, persisted so a reload (F5) keeps it; clamp a stale value at render.
  const [channelFilterRaw, setChannelFilter] = useAccountPersistedState(LS.channelFilter, "all");
  const channelFilter: ChannelStatusFilter = (
    ["needs_full", "fully_synced", "hidden", "all"] as const
  ).includes(channelFilterRaw as ChannelStatusFilter)
    ? (channelFilterRaw as ChannelStatusFilter)
    : "all";
  const [aboutOpen, setAboutOpen] = useState(false);
  const [notesOpen, setNotesOpen] = useState(false);
  const [notesHighlight, setNotesHighlight] = useState<string | undefined>(undefined);
  // The Channel manager's active tab (subscriptions vs playlist discovery). Lifted here and
  // persisted so navigation intents that target the subscriptions table — the header's
  // "without full history" link, focus-channel — can force it back to "subscribed" instead
  // of dumping the user on whatever tab they last left open.
  const [channelsViewRaw, setChannelsView] = useAccountPersistedState(LS.channelsView, "subscribed");
  const channelsView: ChannelsView =
    channelsViewRaw === "discovery" ? "discovery" : "subscribed";
  // Plex module filters (its own left-sidebar filter section) — per-account persisted.
  // The former per-library picker is now a cross-library SCOPE: movie | show | both (unified library).
  // (Reuses the old LS.plexLibrary key; an old stored library-id value falls back to "both".)
  const [plexScopeRaw, setPlexScope] = useAccountPersistedState(LS.plexLibrary, "both");
  const plexScope = ["movie", "show", "both"].includes(plexScopeRaw) ? plexScopeRaw : "both";
  const [plexShowFilter, setPlexShowFilter] = useAccountPersistedState(LS.plexShow, "all");
  const [plexSort, setPlexSort] = useAccountPersistedState(LS.plexSort, "title");
  const [plexPlaylistOpen, setPlexPlaylistOpen] = useState<number | null>(null); // sidebar → open a playlist
  // The expanded Plex filters live as one JSON blob (the string-only account store serializes it).
  const [plexFiltersRaw, setPlexFiltersRaw] = useAccountPersistedState(LS.plexFilters, "{}");
  const plexFilters = useMemo<PlexFilters>(() => {
    try {
      return { ...EMPTY_PLEX_FILTERS, ...JSON.parse(plexFiltersRaw) };
    } catch {
      return EMPTY_PLEX_FILTERS;
    }
  }, [plexFiltersRaw]);
  const setPlexFilters = (f: PlexFilters) => setPlexFiltersRaw(JSON.stringify(f));
  // The Plex library search term is its OWN ephemeral state — deliberately NOT the persisted feed
  // `filters.q`. Sharing that box meant a Plex search leaked into the feed and, being persisted,
  // greeted you with a stale query (colliding with a persisted collection filter → confusing
  // "0 matches") after a reload. Kept in App so it survives page switches within a session, but
  // resets on reload.
  const [plexQ, setPlexQ] = useAccountPersistedState(LS.plexQ, ""); // survives F5, unlike the feed search
  // Bumped to tell the channel manager to drop a stale column filter when we send the user
  // there to see a specific set (the header's "without full history" link).
  const [channelsFilterReset, setChannelsFilterReset] = useState(0);
  // "Focus this channel in the manager": jump to the Channels page and seed its name filter
  // so the channel is isolated. Cleared when leaving the page so it doesn't re-apply later.
  const [focusChannelName, setFocusChannelName] = useState<string | null>(null);
  const [focusChannelToken, setFocusChannelToken] = useState(0);
  const focusChannel = (name: string) => {
    setFocusChannelName(name);
    setFocusChannelToken((n) => n + 1); // re-seed even when the same channel is re-focused
    setChannelsView("subscribed"); // the name filter applies to the subscriptions table
    setPage("channels");
  };
  useEffect(() => {
    if (page !== "channels") setFocusChannelName(null);
    // Leaving the feed ends any live YouTube search, so coming back shows the normal feed.
    if (page !== "feed") setYtSearch(null);
  }, [page]);

  function openReleaseNotes(highlight?: string) {
    setNotesHighlight(highlight);
    setNotesOpen(true);
  }

  function setFilters(next: FeedFilters) {
    setFiltersState(next);
    // Persist under THIS tab's active account so filters don't bleed between accounts.
    const key = accountKey(FILTERS_KEY, getActiveAccount());
    if (key) writeJSON(key, next);
  }

  function setPage(next: Page) {
    // While a channel page is open it overlays the content column, so a nav click must close it
    // even when the underlying page is unchanged (e.g. "Feed" while a channel opened from the feed).
    if (next === page && !channelView) return;
    const go = () => {
      setChannelView(null); // leave any open channel page when navigating via the rail
      setPageState(next);
      setAccountRaw(PAGE_KEY, next);
      // Push an in-app history entry so the browser/mouse Back button steps through pages
      // instead of leaving the app (e.g. back to the OAuth redirect). The URL stays clean —
      // the page rides in history.state, not the query string (filters never go in the URL).
      // A fresh { sfPage } (no carried-over _sub/_ov/_chan markers) so each page starts at its root.
      window.history.pushState({ sfPage: next }, "");
    };
    // Guard leaving the Settings page with unsaved preference changes.
    if (page === "settings" && prefsDirtyRef.current) {
      void confirmRef.current({
        title: t("settings.unsaved.title"),
        message: t("settings.unsaved.message"),
        confirmLabel: t("settings.unsaved.discard"),
        danger: true,
      }).then((ok) => {
        if (!ok) return;
        discardRef.current();
        go();
      });
      return;
    }
    go();
  }
  // Expose the current setPage to decoupled callers (download toast "View", etc.).
  useEffect(() => setNavigator(setPage));

  // Reflect the current module in the browser tab title so open tabs are distinguishable and the
  // history reads sensibly. Feed = brand only; an open channel page shows the channel name.
  useEffect(() => {
    const brand = "Siftlode";
    const label = channelView
      ? channelView.name || t("header.channelManager")
      : page === "feed"
        ? null
        : t(pageTitleKey(page));
    document.title = label ? `${label} · ${brand}` : brand;
  }, [page, channelView, i18n.language, t]);

  function setSidebarLayout(next: SidebarLayout) {
    setSidebarLayoutState(next);
    saveLayoutLocal(next);
    api.savePrefs({ sidebarLayout: next }).catch(() => {});
  }

  // Collapse state for the two full-height panels (left nav + filter sidebar). Persisted to the
  // user's preferences so it follows the account across devices; a localStorage cache seeds the
  // initial render synchronously so nothing flashes open before the server prefs arrive.
  const [navCollapsed, setNavCollapsedState] = useState<boolean>(() =>
    Boolean(readAccount(LS.navCollapsed, false))
  );
  const [filterCollapsed, setFilterCollapsedState] = useState<boolean>(() =>
    Boolean(readAccount(LS.filterCollapsed, false))
  );
  function setNavCollapsed(next: boolean) {
    setNavCollapsedState(next);
    writeAccount(LS.navCollapsed, next);
    api.savePrefs({ navCollapsed: next }).catch(() => {});
  }
  function setFilterCollapsed(next: boolean) {
    setFilterCollapsedState(next);
    writeAccount(LS.filterCollapsed, next);
    api.savePrefs({ filterCollapsed: next }).catch(() => {});
  }

  useEffect(() => applyTheme(theme), [theme]);

  // Apply the draft prefs locally for instant preview (their localStorage mirrors update via
  // the stores too); persistence to the server is deferred to an explicit Save.
  useEffect(() => {
    document.documentElement.dataset.perf = perf ? "1" : "";
    setAccountRaw(PERF_KEY, perf ? "1" : "0");
  }, [perf]);
  useEffect(() => setHintsEnabled(hints), [hints]);
  useEffect(() => configureNotifications(notif), [notif]);

  // Filters live in localStorage, not the address bar. If we arrived via a "Share view"
  // link, its params have already hydrated the initial state — persist them and strip the
  // query so the URL stays clean from here on.
  useEffect(() => {
    const params = new URLSearchParams(window.location.search);
    if (hasFilterParams(params) || params.has("page")) {
      // A share link's filters are held in state; they're persisted to the account's key by the
      // post-login effect (which knows the account id). Here we only clean the address bar.
      stripUrlParams();
    }
  }, []); // eslint-disable-line react-hooks/exhaustive-deps

  // In-app Back/Forward: stamp the initial entry with the current page, then sync `page`
  // from history.state on popstate. (stripUrlParams preserves history.state, so this stamp
  // survives a later query-string strip.)
  useEffect(() => {
    // A reload returns to the normal feed by dropping a stale _yt — EXCEPT when the search was
    // scrape-sourced (zero quota): then we keep _yt + _ytScrape so it restores and re-fetches for
    // free (ytSearch above already initialised from it). An api-source search is still dropped (it
    // would re-spend ~100 units). _chan/_chanName are always kept so a channel page survives F5.
    const st = window.history.state || {};
    if (st._yt && st._ytScrape) {
      window.history.replaceState({ ...st, sfPage: page }, "");
    } else {
      const { _yt: _staleYt, _ytScrape: _staleScrape, ...rest } = st;
      window.history.replaceState({ ...rest, sfPage: page }, "");
    }
    function onPop(e: PopStateEvent) {
      const p = (e.state?.sfPage as Page) ?? "feed";
      // Guard a Back step that leaves Settings with unsaved changes: re-assert the Settings
      // entry so nothing is silently stranded, then ask; on discard, go where Back headed.
      if (pageRef.current === "settings" && p !== "settings" && prefsDirtyRef.current) {
        window.history.pushState({ ...window.history.state, sfPage: "settings" }, "");
        void confirmRef.current({
          title: t("settings.unsaved.title"),
          message: t("settings.unsaved.message"),
          confirmLabel: t("settings.unsaved.discard"),
          danger: true,
        }).then((ok) => {
          if (!ok) return;
          discardRef.current();
          setPageState(p);
          setAccountRaw(PAGE_KEY, p);
        });
        return;
      }
      setPageState(p);
      setAccountRaw(PAGE_KEY, p);
      // The YouTube-search sub-view rides in history.state._yt, so Back/Forward restores or
      // clears it to match the entry we landed on (and a player popped first leaves it intact).
      setYtSearch((e.state?._yt as string) ?? null);
      // The channel page rides in history.state._chan the same way.
      const chan = e.state?._chan as string | undefined;
      setChannelView(chan ? { id: chan, name: (e.state?._chanName as string) ?? undefined } : null);
    }
    window.addEventListener("popstate", onPop);
    return () => window.removeEventListener("popstate", onPop);
  }, []); // eslint-disable-line react-hooks/exhaustive-deps

  const queryClient = useQueryClient();
  // First-run install gate: a fresh instance reports configured=false and runs in setup mode.
  // We check this before anything else and render the wizard; `me` is held back until we know the
  // instance is set up (otherwise it would 503 against the setup-mode lock).
  const setupQuery = useQuery({
    queryKey: ["setup-status"],
    queryFn: api.setupStatus,
    staleTime: Infinity,
  });
  const needsSetup = setupQuery.data?.configured === false;
  // Poll the session so an account suspended/deleted by an admin is noticed even with no
  // interaction (option 1): the refetch 401s → the 401 handler below drops to the login page.
  // Only poll while signed in (data present) — polling on the public login page is pointless and
  // its periodic refetch caused a visible flicker there.
  const meQuery = useQuery({
    queryKey: ["me"],
    queryFn: api.me,
    enabled: setupQuery.isSuccess && !needsSetup,
    refetchInterval: (query) => (query.state.data ? 60_000 : false),
  });

  // Pin this tab to whatever account it first loaded as. Without this, a tab that never picked
  // an account (it was riding the session's default) would silently swap identity when ANOTHER
  // tab changes the default — e.g. that other tab adds/switches an account. Once pinned, this
  // tab always sends its own account header, so cross-tab changes can't reach it. An explicit
  // switch on THIS tab sets the override itself (so this no-ops then).
  useEffect(() => {
    if (meQuery.data && getActiveAccount() == null) setActiveAccount(meQuery.data.id);
  }, [meQuery.data?.id]);

  // Load THIS account's own filters when the account first resolves or changes (login / switch /
  // add). Filters are per-account localStorage state, so another account's last view (incl. its
  // default saved view) must never carry over. A share link's filters (captured at mount) win and
  // are persisted to the account instead. Runs on id change only, so a 60s background refetch of
  // the same account never clobbers the filters you're editing.
  useEffect(() => {
    const id = meQuery.data?.id;
    if (id == null) return;
    if (initialHadUrlFilters) {
      const key = accountKey(FILTERS_KEY, id);
      if (key) writeJSON(key, filters);
      return;
    }
    let f = loadAccountFilters(id);
    // The shared demo account has no subscriptions, so default it to the whole library.
    if (meQuery.data?.is_demo) f = { ...f, scope: "all" };
    setFiltersState(f);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [meQuery.data?.id]);

  // Once signed in, drop any leftover query string from the pre-login flow (e.g.
  // ?access=requested, ?verify, ?reset). The logged-in app reads nothing from the URL, so the
  // address bar stays clean. Gated on auth so the logged-out Welcome page still reads its params.
  useEffect(() => {
    if (meQuery.data) stripUrlParams();
  }, [meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps

  // Any request that 401s means the session ended server-side. If we were signed in (cached
  // `me` is a user object), reload to the login page at once (also covers any click that hits
  // the API). Guarded on a cached user so a stray 401 while logged out (cached `me` is null)
  // can't loop the public landing.
  useEffect(() => {
    setUnauthorizedHandler(() => {
      if (queryClient.getQueryData(["me"])) {
        queryClient.clear();
        window.location.reload();
      }
    });
    return () => setUnauthorizedHandler(null);
  }, [queryClient]);

  // Returning from an in-app Google link/upgrade round-trip (auth.py redirects to ?link=…).
  // Surface the outcome, refresh `me` (can_read/can_write/has_google may have flipped), land on
  // Settings → Account so the new state is visible, then strip the param for a clean URL.
  useEffect(() => {
    const result = new URLSearchParams(window.location.search).get("link");
    if (!result) return;
    if (result === "ok") {
      notify({ level: "success", message: t("settings.account.googleLink.linked") });
      void meQuery.refetch();
      setAccountRaw(LS.settingsTab, "account");
      setPage("settings");
    } else {
      const key = result === "conflict" || result === "mismatch" ? result : "error";
      notify({ level: "error", message: t(`settings.account.googleLink.${key}`) });
    }
    stripUrlParams();
  }, []); // eslint-disable-line react-hooks/exhaustive-deps

  // Deep-link from the "new access request" admin email (?admin=access-requests) → jump straight to
  // the admin Users → Access requests view. Snapshot the param so the pre-login URL strip doesn't
  // drop it; act once `me` is known, and only for an admin (others just land on their default page).
  const [adminDeepLink] = useState(() => new URLSearchParams(window.location.search).get("admin"));
  useEffect(() => {
    if (adminDeepLink !== "access-requests" || !meQuery.data) return;
    if (meQuery.data.role === "admin") {
      focusAccessRequestsTab();
      setPage("users");
    }
    stripUrlParams();
  }, [adminDeepLink, meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps

  // First-login onboarding: prompt the user to connect YouTube (and resume the flow after
  // each consent redirect). Derived from granted scopes + storage flags so it's stable
  // across the full-page OAuth round-trip; dismissible and reopenable from Settings.
  useEffect(() => {
    if (meQuery.data && shouldAutoOpenOnboarding(meQuery.data)) setWizardOpen(true);
  }, [meQuery.data?.id, meQuery.data?.can_read, meQuery.data?.can_write]);

  // On login, adopt server-stored preferences.
  useEffect(() => {
    const prefs = meQuery.data?.preferences;
    if (!prefs) return;
    // Adopt the server prefs as both the saved baseline and the live draft. The runtime
    // effects above apply the draft (theme/perf/hints/notifications) for display.
    const adopted: EditablePrefs = {
      theme: prefs.theme ? { ...DEFAULT_THEME, ...prefs.theme } : loadLocalTheme(),
      view: prefs.view === "grid" || prefs.view === "list" ? prefs.view : "grid",
      performanceMode:
        typeof prefs.performanceMode === "boolean"
          ? prefs.performanceMode
          : getAccountRaw(PERF_KEY) === "1",
      hints: typeof prefs.hints === "boolean" ? prefs.hints : hintsEnabled(),
      notifications: prefs.notifications
        ? { ...getNotifSettings(), ...prefs.notifications }
        : getNotifSettings(),
    };
    setThemeState(adopted.theme);
    saveLocalTheme(adopted.theme);
    setView(adopted.view);
    setPerf(adopted.performanceMode);
    setHints(adopted.hints);
    setNotif(adopted.notifications);
    setSavedPrefs(adopted);
    // Out-of-scope prefs stay instant (edited outside the Settings page).
    if (prefs.sidebarLayout) {
      const l = normalizeLayout(prefs.sidebarLayout);
      setSidebarLayoutState(l);
      saveLayoutLocal(l);
    }
    if (typeof prefs.navCollapsed === "boolean") {
      setNavCollapsedState(prefs.navCollapsed);
      writeAccount(LS.navCollapsed, prefs.navCollapsed);
    }
    if (typeof prefs.filterCollapsed === "boolean") {
      setFilterCollapsedState(prefs.filterCollapsed);
      writeAccount(LS.filterCollapsed, prefs.filterCollapsed);
    }
    if (isSupported(prefs.language)) setLanguage(prefs.language);
    // (Per-account filters — incl. the demo account's whole-library default — are loaded by the
    // dedicated effect above, which also covers accounts that have no stored preferences.)
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [meQuery.data?.id]);

  // Nudge admins when access requests are waiting (in lieu of a server-push bell). Keyed on the
  // pending COUNT so it re-resolves the moment an approval/denial changes it — not only on a
  // fresh load (which is why the notice used to linger until F5 after an approval).
  useEffect(() => {
    if (meQuery.data?.role !== "admin") return;
    // Keep a single, current nudge: drop any prior one (persisted copies reload as dismissed
    // history, so they'd otherwise pile up one-per-reload) before re-issuing the live notice.
    removeByMetaKind("access-requests");
    const pending = meQuery.data.pending_invites ?? 0;
    if (pending > 0) {
      notify({
        level: "warning",
        title: t("common.accessRequestsTitle"),
        message: t("common.accessRequestsMessage", { count: pending }),
        // Durable inbox link (survives reload, unlike a live action callback); the toast also
        // gets a click via the action.
        meta: { kind: "access-requests" },
        action: {
          label: t("common.accessRequestsReview"),
          onClick: () => {
            focusAccessRequestsTab();
            setPage("users");
          },
        },
      });
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [meQuery.data?.role, meQuery.data?.pending_invites]);

  // Theme is part of the Settings draft: apply locally for preview, persist on Save.
  function setTheme(next: ThemePrefs) {
    setThemeState(next);
    saveLocalTheme(next);
  }
  // Language is edited outside the Settings page (sidebar/login), so it stays instant.
  function changeLanguage(code: LangCode) {
    setLanguage(code);
    api.savePrefs({ language: code }).catch(() => {});
  }

  const prefsDirty =
    JSON.stringify({ theme, view, performanceMode: perf, hints, notifications: notif }) !==
    JSON.stringify(savedPrefs);

  const savePrefs = useCallback(() => {
    const payload: EditablePrefs = { theme, view, performanceMode: perf, hints, notifications: notif };
    window.clearTimeout(saveMsgTimer.current);
    setPrefsSaveState("saving");
    api
      .savePrefs(payload)
      .then(() => {
        setSavedPrefs(payload);
        setPrefsSaveState("saved");
        saveMsgTimer.current = window.setTimeout(() => setPrefsSaveState("idle"), 2500);
      })
      .catch(() => {
        // api.req() already surfaces the failure (connection-lost status / error dialog);
        // just flag the bar so the user sees the save didn't take, then clear it.
        setPrefsSaveState("error");
        saveMsgTimer.current = window.setTimeout(() => setPrefsSaveState("idle"), 4000);
      });
  }, [theme, view, perf, hints, notif]);

  const discardPrefs = useCallback(() => {
    setThemeState(savedPrefs.theme);
    saveLocalTheme(savedPrefs.theme);
    setView(savedPrefs.view);
    setPerf(savedPrefs.performanceMode);
    setHints(savedPrefs.hints);
    setNotif(savedPrefs.notifications);
    window.clearTimeout(saveMsgTimer.current);
    setPrefsSaveState("idle");
  }, [savedPrefs]);

  const prefsCtl: PrefsController = {
    theme,
    setTheme,
    view,
    setView,
    perf,
    setPerf,
    hints,
    setHints,
    notif,
    setNotif,
    dirty: prefsDirty,
    save: savePrefs,
    discard: discardPrefs,
    saveState: prefsSaveState,
  };

  // Keep the navigation guards (setPage / popstate) reading the latest values.
  useEffect(() => {
    prefsDirtyRef.current = prefsDirty;
    discardRef.current = discardPrefs;
    confirmRef.current = confirm;
    pageRef.current = page;
  });
  // No beforeunload guard: a reload/close can only raise the browser's own native prompt
  // (we can't show our in-app confirm there), and unsaved prefs are local-only — they
  // revert to the saved server baseline on the next load — so there's nothing to lose.

  // First-run: a fresh instance is in setup mode (all other routes are locked) → show the wizard.
  if (setupQuery.isLoading)
    return (
      <div className="min-h-screen grid place-items-center text-muted">{t("common.loading")}</div>
    );
  if (needsSetup) return <SetupWizard />;

  if (meQuery.isLoading)
    return (
      <div className="min-h-screen grid place-items-center text-muted">{t("common.loading")}</div>
    );
  // /api/me answers 200 always now (null body = logged out), so a thrown error here is a real
  // network/5xx failure, and no data means "not signed in" → the public landing.
  if (meQuery.error)
    return (
      <div className="min-h-screen grid place-items-center text-muted">
        {t("common.somethingWrong")}
      </div>
    );
  if (!meQuery.data) return <Welcome />;

  // Shown briefly while a lazy page/module chunk loads (first visit to that page only).
  const pageFallback = (
    <div className="flex-1 grid place-items-center text-muted">{t("common.loading")}</div>
  );

  return (
    <div className="h-screen flex bg-bg text-fg">
      <NavSidebar
        me={meQuery.data!}
        page={page}
        setPage={setPage}
        onOpenAbout={() => setAboutOpen(true)}
        onChangeLanguage={changeLanguage}
        language={i18n.language as LangCode}
        collapsed={navCollapsed}
        onToggleCollapse={() => setNavCollapsed(!navCollapsed)}
        onGoToFullHistory={() => {
          setChannelFilter("needs_full");
          setChannelsView("subscribed"); // the status filter applies to the subscriptions tab
          setChannelsFilterReset((n) => n + 1); // drop any stale column filter hiding the rows
          setPage("channels");
        }}
      />
      {/* Full-height filter sidebar: feed only, and hidden while a channel page overlays the
          content column. Sits beside the nav rail as its own collapsible column. */}
      {page === "feed" && !channelView && (
        <Sidebar
          filters={filters}
          setFilters={setFilters}
          layout={sidebarLayout}
          setLayout={setSidebarLayout}
          onFocusChannel={focusChannel}
          isDemo={meQuery.data!.is_demo}
          collapsed={filterCollapsed}
          onToggleCollapse={() => setFilterCollapsed(!filterCollapsed)}
        />
      )}
      {page === "plex" && meQuery.data!.plex_enabled && (
        <Suspense fallback={null}>
          <PlexSidebar
            scope={plexScope}
            setScope={setPlexScope}
            show={plexShowFilter}
            setShow={setPlexShowFilter}
            sort={plexSort}
            setSort={setPlexSort}
            filters={plexFilters}
            setFilters={setPlexFilters}
            onOpenPlaylist={setPlexPlaylistOpen}
            collapsed={filterCollapsed}
            onToggleCollapse={() => setFilterCollapsed(!filterCollapsed)}
          />
        </Suspense>
      )}
      <div className="relative flex-1 min-w-0 flex flex-col">
        {channelView ? (
          <Suspense fallback={pageFallback}>
            <ChannelPage
              key={channelView.id}
              channelId={channelView.id}
              initialName={channelView.name}
              me={meQuery.data!}
              view={view}
              onBack={closeChannel}
              onOpenChannel={openChannel}
            />
          </Suspense>
        ) : (
          <>
        <Header
          me={meQuery.data!}
          filters={filters}
          setFilters={setFilters}
          plexQ={plexQ}
          setPlexQ={setPlexQ}
          page={page}
          onYtSearch={enterYtSearch}
        />
        {meQuery.data!.is_demo && <DemoBanner />}
        <VersionBanner onOpen={() => openReleaseNotes(CURRENT_VERSION)} />
          <main className="flex-1 min-w-0 overflow-y-auto">
          <Suspense fallback={pageFallback}>
          {page === "channels" ? (
            <Channels
              canWrite={meQuery.data!.can_write}
              isAdmin={meQuery.data!.role === "admin"}
              focusChannelName={focusChannelName}
              focusChannelToken={focusChannelToken}
              onFocusChannel={focusChannel}
              statusFilter={channelFilter}
              setStatusFilter={setChannelFilter}
              view={channelsView}
              setView={setChannelsView}
              filtersResetToken={channelsFilterReset}
              onOpenWizard={() => setWizardOpen(true)}
              onViewChannel={(id, name) => openChannel(id, name)}
              onFilterByTag={(tagId, name) => {
                setFilters({ ...filters, tags: [tagId], channelId: undefined, channelName: undefined });
                setPage("feed");
                notify({ level: "info", message: t("channels.notify.filteredByTag", { name }) });
              }}
            />
          ) : page === "stats" ? (
            <Stats me={meQuery.data!} />
          ) : page === "scheduler" && meQuery.data!.role === "admin" ? (
            <Scheduler />
          ) : page === "config" && meQuery.data!.role === "admin" ? (
            <ConfigPanel />
          ) : page === "users" && meQuery.data!.role === "admin" ? (
            <AdminUsers me={meQuery.data!} />
          ) : page === "audit" && meQuery.data!.role === "admin" ? (
            <AuditLog />
          ) : page === "playlists" ? (
            <Playlists canWrite={meQuery.data!.can_write} />
          ) : page === "notifications" ? (
            <NotificationsPanel filters={filters} setFilters={setFilters} setPage={setPage} onFocusChannel={focusChannel} />
          ) : page === "messages" && !meQuery.data!.is_demo ? (
            <div className="p-4 max-w-3xl w-full mx-auto">
              <Messages meId={meQuery.data!.id} />
            </div>
          ) : page === "downloads" && !meQuery.data!.is_demo ? (
            <DownloadCenter me={meQuery.data!} />
          ) : page === "settings" ? (
            <SettingsPanel
              me={meQuery.data!}
              prefs={prefsCtl}
              onOpenWizard={() => setWizardOpen(true)}
            />
          ) : page === "plex" && meQuery.data!.plex_enabled ? (
            <PlexBrowse
              q={plexQ}
              scope={plexScope}
              setScope={setPlexScope}
              show={plexShowFilter}
              sort={plexSort}
              filters={plexFilters}
              setFilters={setPlexFilters}
              openPlaylist={plexPlaylistOpen}
              onPlaylistOpened={() => setPlexPlaylistOpen(null)}
            />
          ) : (
            <Feed
              filters={filters}
              setFilters={setFilters}
              view={view}
              canRead={meQuery.data!.can_read}
              isDemo={meQuery.data!.is_demo}
              onOpenWizard={() => setWizardOpen(true)}
              ytSearch={ytSearch}
              onYtSearch={enterYtSearch}
              onExitYtSearch={exitYtSearch}
              onOpenChannel={openChannel}
            />
          )}
          </Suspense>
          </main>
          </>
        )}
        {/* Toasts rise from the bottom-left, by the notification bell in the nav rail.
            Anchored inside the content column so they clear the sidebar automatically
            (collapsed or expanded) without tracking its width. */}
        <Toaster />
      </div>
      {meQuery.data && !meQuery.data.is_demo && <ChatDock meId={meQuery.data.id} />}
      <Suspense fallback={null}>
        {wizardOpen && meQuery.data && !meQuery.data.is_demo && (
          <OnboardingWizard me={meQuery.data} onClose={() => setWizardOpen(false)} />
        )}
        {aboutOpen && (
          <About
            onClose={() => setAboutOpen(false)}
            onOpenReleaseNotes={() => {
              setAboutOpen(false);
              openReleaseNotes();
            }}
          />
        )}
        {notesOpen && (
          <ReleaseNotes onClose={() => setNotesOpen(false)} highlight={notesHighlight} />
        )}
      </Suspense>
      <ErrorDialog />
      {/* Re-binds to the active page's scroll container on navigation (page or channel change). */}
      <BackToTop dep={channelView ? `chan:${channelView.id}` : page} />
      {/* DEV-ONLY live appearance tuner (localhost-gated; self-returns null on prod). Delete when
          the glass-consistency epic bakes the final values into index.css. */}
      <GlassTuner />
    </div>
  );
}
