import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api, HttpError, setUnauthorizedHandler, type FeedFilters } 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 {
  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 { LS, readMerged, usePersistedState } from "./lib/storage";
import { useConfirm } from "./components/ConfirmProvider";
import type { PrefsController } from "./components/SettingsPanel";
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 Feed from "./components/Feed";
import Channels, { type ChannelStatusFilter, type ChannelsView } from "./components/Channels";
import Playlists from "./components/Playlists";
import Stats from "./components/Stats";
import Scheduler from "./components/Scheduler";
import ConfigPanel from "./components/ConfigPanel";
import AdminUsers, { focusAccessRequestsTab } from "./components/AdminUsers";
import SettingsPanel from "./components/SettingsPanel";
import NotificationsPanel from "./components/NotificationsPanel";
import Messages from "./components/Messages";
import ChatDock from "./components/ChatDock";
import OnboardingWizard from "./components/OnboardingWizard";
import { shouldAutoOpenOnboarding } from "./lib/onboarding";
import Toaster from "./components/Toaster";
import ErrorDialog from "./components/ErrorDialog";
import About from "./components/About";
import ReleaseNotes from "./components/ReleaseNotes";
import VersionBanner from "./components/VersionBanner";
import DemoBanner from "./components/DemoBanner";
import { CURRENT_VERSION } from "./lib/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 = localStorage.getItem(PAGE_KEY);
  return isPage(stored) ? stored : "feed";
}

function loadStoredFilters(): FeedFilters {
  return readMerged(FILTERS_KEY, 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 loadStoredFilters();
}

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);
  const [view, setView] = useState<"grid" | "list">("grid");
  // Settings-page prefs draft (apply live, persist on Save — see EditablePrefs).
  const [perf, setPerf] = useState(() => localStorage.getItem(PERF_KEY) === "1");
  const [hints, setHints] = useState(() => hintsEnabled());
  const [notif, setNotif] = useState<NotifSettings>(() => getNotifSettings());
  const [savedPrefs, setSavedPrefs] = useState<EditablePrefs>(() => ({
    theme: loadLocalTheme(),
    view: "grid",
    performanceMode: localStorage.getItem(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);
  const [wizardOpen, setWizardOpen] = useState(false);
  // Channel status chip, persisted so a reload (F5) keeps it; clamp a stale value at render.
  const [channelFilterRaw, setChannelFilter] = usePersistedState(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] = usePersistedState(LS.channelsView, "subscribed");
  const channelsView: ChannelsView =
    channelsViewRaw === "discovery" ? "discovery" : "subscribed";
  // 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 focusChannel = (name: string) => {
    setFocusChannelName(name);
    setChannelsView("subscribed"); // the name filter applies to the subscriptions table
    setPage("channels");
  };
  useEffect(() => {
    if (page !== "channels") setFocusChannelName(null);
  }, [page]);

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

  function setFilters(next: FeedFilters) {
    setFiltersState(next);
    localStorage.setItem(FILTERS_KEY, JSON.stringify(next));
  }

  function setPage(next: Page) {
    if (next === page) return;
    const go = () => {
      setPageState(next);
      localStorage.setItem(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 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();
  }

  function setSidebarLayout(next: SidebarLayout) {
    setSidebarLayoutState(next);
    saveLayoutLocal(next);
    api.savePrefs({ sidebarLayout: 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" : "";
    localStorage.setItem(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")) {
      localStorage.setItem(FILTERS_KEY, JSON.stringify(filters));
      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(() => {
    window.history.replaceState(
      { ...window.history.state, 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);
          localStorage.setItem(PAGE_KEY, p);
        });
        return;
      }
      setPageState(p);
      localStorage.setItem(PAGE_KEY, p);
    }
    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),
  });

  // 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` exists), reload to the login page at once (option 2 — also covers any click that hits
  // the API). Guarded on cached `me` so the public login page's own /api/me 401 can't loop.
  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();
      localStorage.setItem(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

  // 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
          : localStorage.getItem(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 (isSupported(prefs.language)) setLanguage(prefs.language);
    // The demo account is shared: there are no subscriptions, so default it to the whole
    // library (the "my" feed would be empty). The communal-state warning is a permanent
    // banner (see DemoBanner below), not a toast that re-pops on every reload.
    if (meQuery.data?.is_demo) {
      setFilters({ ...loadStoredFilters(), scope: "all" });
    }
    // 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>
    );
  if (meQuery.error instanceof HttpError && meQuery.error.status === 401)
    return <Welcome />;
  if (meQuery.error)
    return (
      <div className="min-h-screen grid place-items-center text-muted">
        {t("common.somethingWrong")}
      </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}
      />
      <div className="relative flex-1 min-w-0 flex flex-col">
        <Header
          me={meQuery.data!}
          filters={filters}
          setFilters={setFilters}
          page={page}
          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");
          }}
        />
        {meQuery.data!.is_demo && <DemoBanner />}
        <VersionBanner onOpen={() => openReleaseNotes(CURRENT_VERSION)} />
        <div className="flex flex-1 min-h-0">
          {page === "feed" && (
            <Sidebar
              filters={filters}
              setFilters={setFilters}
              layout={sidebarLayout}
              setLayout={setSidebarLayout}
              onFocusChannel={focusChannel}
            />
          )}
          <main className="flex-1 min-w-0 overflow-y-auto">
          {page === "channels" ? (
            <Channels
              canWrite={meQuery.data!.can_write}
              isAdmin={meQuery.data!.role === "admin"}
              focusChannelName={focusChannelName}
              onFocusChannel={focusChannel}
              statusFilter={channelFilter}
              setStatusFilter={setChannelFilter}
              view={channelsView}
              setView={setChannelsView}
              filtersResetToken={channelsFilterReset}
              onOpenWizard={() => setWizardOpen(true)}
              onViewChannel={(id, name) => {
                setFilters({ ...filters, channelId: id, channelName: name, show: "all" });
                setPage("feed");
              }}
              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 === "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 === "settings" ? (
            <SettingsPanel
              me={meQuery.data!}
              prefs={prefsCtl}
              onOpenWizard={() => setWizardOpen(true)}
            />
          ) : (
            <Feed
              filters={filters}
              setFilters={setFilters}
              view={view}
              canRead={meQuery.data!.can_read}
              isDemo={meQuery.data!.is_demo}
              onOpenWizard={() => setWizardOpen(true)}
            />
          )}
          </main>
        </div>
        {/* 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} />}
      {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} />
      )}
      <ErrorDialog />
    </div>
  );
}
