import { lazy, Suspense, useEffect, useState } from "react";
import { qk } from "./lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api, getActiveAccount, setActiveAccount, setUnauthorizedHandler } from "./lib/api";
import { setLanguage, isSupported, type LangCode } from "./i18n";
import { hasFilterParams, stripUrlParams } from "./lib/urlState";
import type { Page } from "./lib/urlState";
import {
  loadLayout,
  normalizeLayout,
  PREF_KEY,
  saveLayoutLocal,
  type PanelId,
  type PanelLayout,
} from "./lib/panelLayout";
import { notify, removeByMetaKind } from "./lib/notifications";
import { LS, readAccount, setAccountRaw, writeAccount } from "./lib/storage";
import { useNavigation, useNavigationActions } from "./components/NavigationProvider";
import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider";
import { useChannelLayout } from "./components/ChannelsProvider";
import { useFeedView, useFeedViewActions } from "./components/FeedViewProvider";
import { moduleDef } from "./lib/modules";
import { PAGE_CONTENT, PAGE_RAIL } from "./components/moduleRegistry";
// 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 BackToTop from "./components/BackToTop";
import PageShell from "./components/PageShell";
import GlassTuner from "./components/GlassTuner";
import ChatDock from "./components/ChatDock";
import Toaster from "./components/Toaster";
import { StateMessage } from "./components/QueryState";
import ErrorDialog from "./components/ErrorDialog";
import VersionBanner from "./components/VersionBanner";
import DemoBanner from "./components/DemoBanner";
import { focusAccessRequestsTab } from "./lib/adminUsersTab";
// The built app version, NOT imported from releaseNotes — importing anything from that module
// pulls its whole ~900-line changelog into the eager App chunk and defeats the lazy ReleaseNotes
// split. FRONTEND_VERSION equals RELEASE_NOTES[0].version by release convention (both bump together).
import { FRONTEND_VERSION } from "./lib/version";

// The page modules (and their side rails) are code-split per route via the module registry
// (components/moduleRegistry). App keeps only the persistent-shell chunks + the channel page and the
// two modals, which sit outside the registry-driven content column.
const ChannelPage = lazy(() => import("./components/ChannelPage"));
const About = lazy(() => import("./components/About"));
const ReleaseNotes = lazy(() => import("./components/ReleaseNotes"));

export default function App() {
  const { t, i18n } = useTranslation();
  const { page, channelView, ytSearch } = useNavigation();
  const { setPage, enterYtSearch } = useNavigationActions();
  // The global feed filters live in FeedFiltersProvider (per-account persistence + the account-load
  // effect + share-link capture all moved there). App reads them to hand to <Feed> and to the
  // handlers that mutate the feed view (onShowInFeed, tag-click, the feed scrollKey).
  const filters = useFeedFilters();
  const { setFilters } = useFeedFiltersActions();
  // The channel manager turns its top fade OFF for the table (its sticky header sits at the scroller
  // top) but wants it ON for the card layout (no sticky header) — so the fade depends on the layout.
  // Deliberately the NARROW layout context: subscribing to the manager's whole state here would
  // re-render the app root on every keystroke in its search box.
  const channelLayout = useChannelLayout();
  // The feed's view mode (card/row/tile density) lives in FeedViewProvider — server-synced, shared
  // by the feed page and the channel page. App reads it only to hand to <Feed>/<ChannelPage>.
  const view = useFeedView();
  const { setView } = useFeedViewActions();
  // Per-panel "customize" layouts (order/collapsed/hidden of each side panel's group cards),
  // persisted per-account. One record so feed/plex/playlists share the same mechanism.
  const [panelLayouts, setPanelLayouts] = useState<Record<PanelId, PanelLayout>>(() => ({
    feed: loadLayout("feed"),
    plex: loadLayout("plex"),
    playlists: loadLayout("playlists"),
  }));
  const [aboutOpen, setAboutOpen] = useState(false);
  const [notesOpen, setNotesOpen] = useState(false);
  const [notesHighlight, setNotesHighlight] = useState<string | undefined>(undefined);

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

  function setPanelLayout(panel: PanelId, next: PanelLayout) {
    setPanelLayouts((prev) => ({ ...prev, [panel]: next }));
    saveLayoutLocal(panel, next);
    api.savePrefs({ [PREF_KEY[panel]]: 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.
  // Default unpinned (slim + hover-expand overlay); accounts that explicitly pin keep their choice.
  const [navCollapsed, setNavCollapsedState] = useState<boolean>(() =>
    Boolean(readAccount(LS.navCollapsed, true)),
  );
  // Default unpinned (tab + hover-expand overlay); accounts that explicitly pin keep their choice.
  const [filterCollapsed, setFilterCollapsedState] = useState<boolean>(() =>
    Boolean(readAccount(LS.filterCollapsed, true)),
  );
  // The Playlists rail has its own collapse flag (it's a list/navigator, not filters).
  const [playlistsCollapsed, setPlaylistsCollapsedState] = useState<boolean>(() =>
    Boolean(readAccount(LS.playlistsCollapsed, true)),
  );
  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(() => {});
  }
  function setPlaylistsCollapsed(next: boolean) {
    setPlaylistsCollapsedState(next);
    writeAccount(LS.playlistsCollapsed, next);
    api.savePrefs({ playlistsCollapsed: next }).catch(() => {});
  }
  // 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();
    }
  }, []);

  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: qk.setupStatus(),
    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: qk.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]);

  // 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(qk.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

  // A Google OAuth flow started while already signed in (add-another-account, link, or upgrade) was
  // cancelled at Google's consent screen. The backend only emits this for a live session, so a
  // neutral toast is the right feedback — the pre-auth login banner would never render here.
  useEffect(() => {
    if (new URLSearchParams(window.location.search).get("oauth") !== "cancelled") return;
    notify({ level: "info", message: t("settings.account.googleCancelled") });
    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

  // On login, adopt the server-stored SHELL preferences (panel layouts, rail collapse flags,
  // language). The Settings-page prefs (theme/perf/hints/notifications) are adopted by PrefsProvider
  // and the feed view by FeedViewProvider — each via its own passive qk.me() observer.
  useEffect(() => {
    const prefs = meQuery.data?.preferences;
    if (!prefs) return;
    (["feed", "plex", "playlists"] as PanelId[]).forEach((p) => {
      const raw = prefs[PREF_KEY[p]];
      if (raw) {
        const l = normalizeLayout(p, raw);
        setPanelLayouts((prev) => ({ ...prev, [p]: l }));
        saveLayoutLocal(p, 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 (typeof prefs.playlistsCollapsed === "boolean") {
      setPlaylistsCollapsedState(prefs.playlistsCollapsed);
      writeAccount(LS.playlistsCollapsed, prefs.playlistsCollapsed);
    }
    const lang = prefs.language;
    if (typeof lang === "string" && isSupported(lang)) setLanguage(lang);
    // (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]);

  // Language is edited outside the Settings page (sidebar/login), so it stays instant.
  function changeLanguage(code: LangCode) {
    setLanguage(code);
    api.savePrefs({ language: code }).catch(() => {});
  }

  // 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">
        <StateMessage tone="error" onRetry={() => meQuery.refetch()}>
          {t("common.somethingWrong")}
        </StateMessage>
      </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>
  );

  // Scroll restore/reset key for the shared normal-page scroller (see PageScroller): the same
  // page/feed-filters returns you to where you were (bug 1); a changed feed filter is a new key, so
  // the fresh result set starts at the top (bug 3). `q` is normalised out so typing in the search
  // box doesn't reset the scroll on every keystroke; the live-YouTube sub-view gets its own key.
  const normalScrollKey =
    page !== "feed"
      ? `page:${page}`
      : ytSearch != null
        ? `yt:${ytSearch}`
        : `feed:${JSON.stringify({ ...filters, q: "" })}`;

  // The module registry drives the render from `page` alone — no per-page chrome conditionals. A
  // page the account can't reach (a gated module via stale nav / a deep link) falls back to the feed,
  // exactly as the old ternary's fallthrough did. A channel page overlays the content column, so no
  // side rail shows behind it.
  const me = meQuery.data!;
  const pageGate = moduleDef(page).gate;
  const activePage: Page = !pageGate || pageGate(me) ? page : "feed";
  const { Component: PageContent, fadeTop } = PAGE_CONTENT[activePage];
  const railDef = channelView ? undefined : PAGE_RAIL[activePage];

  return (
    <div className="h-screen flex text-fg">
      <NavSidebar
        me={meQuery.data!}
        onOpenAbout={() => setAboutOpen(true)}
        onChangeLanguage={changeLanguage}
        language={i18n.language as LangCode}
        collapsed={navCollapsed}
        onToggleCollapse={() => setNavCollapsed(!navCollapsed)}
      />
      {/* The active page's side rail (feed filters / plex filters / playlists list), if it has one.
          A floating collapsible column beside the nav rail; hidden while a channel page overlays the
          content. feed + plex share the filter-rail collapse flag, playlists has its own. */}
      {railDef && (
        <Suspense fallback={null}>
          <railDef.Rail
            layout={panelLayouts[railDef.panelId]}
            setLayout={(l) => setPanelLayout(railDef.panelId, l)}
            collapsed={railDef.collapse === "playlists" ? playlistsCollapsed : filterCollapsed}
            onToggleCollapse={() =>
              railDef.collapse === "playlists"
                ? setPlaylistsCollapsed(!playlistsCollapsed)
                : setFilterCollapsed(!filterCollapsed)
            }
          />
        </Suspense>
      )}
      <div className="relative flex-1 min-w-0 flex flex-col">
        {channelView ? (
          <PageShell scrollKey={`channel:${channelView.id}`}>
            <Suspense fallback={pageFallback}>
              <ChannelPage
                key={channelView.id}
                channelId={channelView.id}
                initialName={channelView.name}
                me={meQuery.data!}
                view={view}
                setView={setView}
                onShowInFeed={() => {
                  // Keep the rest of the feed's filters — the point is "this channel, the way I
                  // normally read": unwatched, my tags, my date window.
                  // ADD to the picked set, don't replace it: channels OR together now, so arriving
                  // from a second channel page should widen the feed, not silently drop the first.
                  // Deduped — clicking it again for a channel already picked is a no-op.
                  setFilters({
                    ...filters,
                    channelIds: [...new Set([...filters.channelIds, channelView.id])],
                  });
                  // setPage already leaves an open channel page and pushes a clean history entry.
                  // Calling closeChannel() first ran history.back(), whose popstate landed AFTER this
                  // and restored whatever page the channel had been opened from.
                  setPage("feed");
                }}
              />
            </Suspense>
          </PageShell>
        ) : (
          <PageShell
            scrollKey={normalScrollKey}
            // Channels: the registry disables the top fade for its sticky-header table, but the card
            // layout has no sticky header, so re-enable it there.
            fadeTop={activePage === "channels" ? channelLayout === "cards" : (fadeTop ?? true)}
            header={<Header me={meQuery.data!} onYtSearch={enterYtSearch} />}
            banners={
              <>
                {meQuery.data!.is_demo && <DemoBanner />}
                <VersionBanner onOpen={() => openReleaseNotes(FRONTEND_VERSION)} />
              </>
            }
          >
            <Suspense fallback={pageFallback}>
              <PageContent />
            </Suspense>
          </PageShell>
        )}
        {/* 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}>
        {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} />
      {/* Opt-in live appearance tuner (Settings → Appearance; off by default). */}
      <GlassTuner />
    </div>
  );
}
