import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { listen } from "@tauri-apps/api/event";
import {
  cancelAllDownloads, cancelDownload, deleteAllDownloads, deleteChannel,
  deleteDownload, downloadVideo, importScraped, interruptedDownloads, listFeed,
  openExternal, previewDeleteChannel, previewScrapedImport,
  setDownloadDefaults, type RemovalPreview, type ScrapeResult,
  fetchDurations, onRefreshProgress, refreshFeeds, setCookieSource,
} from "./api";
import AddChannel from "./components/AddChannel";
import Player from "./components/Player";
import Settings from "./components/Settings";
import Sidebar from "./components/Sidebar";
import TopBar, { type ViewMode } from "./components/TopBar";
import { Dialog, Toast } from "./components/ui";
import VideoRow from "./components/VideoRow";
import VideoTile from "./components/VideoTile";
import { useAppearance } from "./hooks/useAppearance";
import { useConnectivity } from "./hooks/useConnectivity";
import { useDownloads } from "./hooks/useDownloads";
import { useFeed } from "./hooks/useFeed";
import { useWindowFullscreen } from "./hooks/useWindowFullscreen";
import {
  BULK_LIMITS, DEFAULT_BULK_LIMIT, DEFAULT_SUB_LANG, DEFAULT_SUB_STYLE,
  QUALITIES, STREAM_QUALITIES, SUB_LANGS,
  type FeedFilter, type FeedItem, type ImportPreview, type Quality,
  type RefreshProgress, type SubStyle,
} from "./types";

const TOAST_MS = 2400;
/** How often to pull new videos while online, so the feed stays live. */
const AUTO_REFRESH_MS = 10 * 60 * 1000;
/**
 * Video lengths trickle in, four at a time, for whatever is on screen. An
 * early version fetched two pages a second across the whole feed and got the
 * IP challenged by YouTube, breaking playback and downloads too — so this stays
 * slow on purpose.
 */
const DURATION_FILL_MS = 30 * 1000;

function remembered(key: string): boolean {
  try {
    return localStorage.getItem(`flighttube.${key}`) === "1";
  } catch {
    return false;
  }
}

export default function App() {
  const [channelId, setChannelId] = useState<string | null>(null);
  const [search, setSearch] = useState("");
  const [downloadedOnly, setDownloadedOnly] = useState(() => remembered("downloadedOnly"));
  const [hideShorts, setHideShorts] = useState(() => remembered("hideShorts"));
  const [autoplayNext, setAutoplayNext] = useState(() => remembered("autoplayNext"));
  const [autoMode, setAutoMode] = useState(() => remembered("autoMode"));
  const [confirmAuto, setConfirmAuto] = useState<{ fetch: number; remove: number } | null>(null);
  // A subscription list read out of the browser, waiting to be confirmed.
  const [scraped, setScraped] = useState<(ScrapeResult & { preview: ImportPreview }) | null>(null);
  const [sidebarHidden, setSidebarHidden] = useState(() => remembered("sidebarHidden"));
  const [sidebarPeek, setSidebarPeek] = useState(false);
  const [view, setView] = useState<ViewMode>(() => {
    try {
      return localStorage.getItem("flighttube.view") === "grid" ? "grid" : "list";
    } catch {
      return "list";
    }
  });
  const [showSettings, setShowSettings] = useState(false);
  // Which subscription is being removed, with what it would take with it.
  const [removing, setRemoving] = useState<(RemovalPreview & { id: string }) | null>(null);
  const [addingChannel, setAddingChannel] = useState(false);

  // Index into the current feed, so the player can step through it.
  const [playingIndex, setPlayingIndex] = useState<number | null>(null);
  const [browser, setBrowser] = useState(() => {
    try {
      return localStorage.getItem("flighttube.browser") ?? "";
    } catch {
      return "";
    }
  });
  const [subLang, setSubLang] = useState(() => {
    try {
      const stored = localStorage.getItem("flighttube.subLang");
      // "Off" used to be the default, so anyone who never opened Settings had
      // subtitles silently switched off everywhere. Correct that once; a
      // deliberate Off chosen after this is left alone.
      if (stored === "off" && !localStorage.getItem("flighttube.subLangFixed")) {
        localStorage.setItem("flighttube.subLangFixed", "1");
        return DEFAULT_SUB_LANG;
      }
      localStorage.setItem("flighttube.subLangFixed", "1");
      return SUB_LANGS.some((l) => l.value === stored) ? stored! : DEFAULT_SUB_LANG;
    } catch {
      return DEFAULT_SUB_LANG;
    }
  });
  const [streamQuality, setStreamQuality] = useState<Quality>(() => {
    try {
      const stored = localStorage.getItem("flighttube.streamQuality");
      return STREAM_QUALITIES.some((q) => q.value === stored) ? (stored as Quality) : "best";
    } catch {
      return "best";
    }
  });
  const [quality, setQuality] = useState<Quality>(() => {
    try {
      const stored = localStorage.getItem("flighttube.quality");
      return QUALITIES.some((q) => q.value === stored) ? (stored as Quality) : "best";
    } catch {
      return "best";
    }
  });
  const [bulkLimit, setBulkLimit] = useState(() => {
    try {
      const stored = Number(localStorage.getItem("flighttube.bulkLimit"));
      return BULK_LIMITS.some((b) => b.value === stored) ? stored : DEFAULT_BULK_LIMIT;
    } catch {
      return DEFAULT_BULK_LIMIT;
    }
  });
  // How subtitles look, chosen from the player's own subtitle menu.
  const [subStyle, setSubStyle] = useState<SubStyle>(() => {
    try {
      const stored = JSON.parse(localStorage.getItem("flighttube.subStyle") || "null");
      return stored && typeof stored === "object"
        ? { ...DEFAULT_SUB_STYLE, ...stored }
        : DEFAULT_SUB_STYLE;
    } catch {
      return DEFAULT_SUB_STYLE;
    }
  });
  const [refreshing, setRefreshing] = useState(false);
  const [refreshProgress, setRefreshProgress] = useState<RefreshProgress | null>(null);
  const [toast, setToast] = useState<string | null>(null);
  const [failure, setFailure] = useState<string | null>(null);

  // The backend holds no state across launches, so the stored choice has to be
  // handed back to it before the first yt-dlp call.
  useEffect(() => {
    setCookieSource(browser).catch(() => {
      /* falls back to no cookies */
    });
  }, [browser]);

  const { mode, setMode } = useAppearance();
  const windowFullscreen = useWindowFullscreen();
  const { online, reachable, forcedOffline, setForcedOffline, probe } = useConnectivity();

  // A toast reports success and fades; a modal reports a failure or asks a
  // question. Never make someone dismiss a box to be told it worked.
  const toastTimer = useRef<number | undefined>(undefined);
  const say = useCallback((message: string) => {
    setToast(message);
    window.clearTimeout(toastTimer.current);
    toastTimer.current = window.setTimeout(() => setToast(null), TOAST_MS);
  }, []);
  useEffect(() => () => window.clearTimeout(toastTimer.current), []);

  useEffect(() => {
    try {
      localStorage.setItem("flighttube.view", view);
      localStorage.setItem("flighttube.quality", quality);
      localStorage.setItem("flighttube.bulkLimit", String(bulkLimit));
      localStorage.setItem("flighttube.streamQuality", streamQuality);
      localStorage.setItem("flighttube.subLang", subLang);
      localStorage.setItem("flighttube.subStyle", JSON.stringify(subStyle));
      localStorage.setItem("flighttube.browser", browser);
      localStorage.setItem("flighttube.downloadedOnly", downloadedOnly ? "1" : "0");
      localStorage.setItem("flighttube.hideShorts", hideShorts ? "1" : "0");
      localStorage.setItem("flighttube.autoplayNext", autoplayNext ? "1" : "0");
      localStorage.setItem("flighttube.autoMode", autoMode ? "1" : "0");
      localStorage.setItem("flighttube.sidebarHidden", sidebarHidden ? "1" : "0");
    } catch {
      /* storage blocked */
    }
  }, [view, quality, bulkLimit, streamQuality, subLang, subStyle, browser, downloadedOnly,
      hideShorts, autoplayNext, autoMode, sidebarHidden]);

  // The language a download embeds. Like the player's fetch, this does not
  // depend on the on/off preference: that says what is shown, and a file
  // downloaded without a subtitle track can never gain one offline.
  const embedLang = subLang === "off" ? DEFAULT_SUB_LANG : subLang;

  // Offline, the only videos that can be played are the ones already on disk,
  // so the feed collapses to those regardless of the toggle.
  const effectiveDownloadedOnly = downloadedOnly || !online;

  const filter: FeedFilter = useMemo(
    () => ({
      channel_id: channelId,
      search: search.trim() || null,
      downloaded_only: effectiveDownloadedOnly,
      hide_shorts: hideShorts,
      limit: 500,
    }),
    [channelId, search, effectiveDownloadedOnly, hideShorts],
  );

  const { items, channels, loading, error, reload } = useFeed(filter);
  const { live, clear: clearLive } = useDownloads(reload);

  useEffect(() => {
    let un: (() => void) | undefined;
    onRefreshProgress(setRefreshProgress).then((u) => (un = u));
    return () => un?.();
  }, []);

  useEffect(() => {
    if (error) setFailure(error);
  }, [error]);

  const totals = useMemo(
    () =>
      channels.reduce(
        (acc, c) => ({
          videos: acc.videos + c.video_count,
          downloaded: acc.downloaded + c.downloaded_count,
        }),
        { videos: 0, downloaded: 0 },
      ),
    [channels],
  );

  // Named exactly, never as a wildcard: "en.*" also matches every
  // machine-translated variant YouTube offers, and asking for all of them
  // earns an HTTP 429. Off means no subtitle requests at all.

  /**
   * Auto mode: keep the newest `bulkLimit` videos of the feed on disk, and
   * nothing else.
   *
   * Runs after every check of the feed, so the library follows the feed rather
   * than accumulating. Deliberate one-off saves from the menu bar are left
   * alone — their channel is not a subscription, so they are not part of what
   * this is managing, and deleting something saved by hand a minute ago would
   * be a nasty surprise.
   */
  const reconciling = useRef(false);
  const autoModeRef = useRef(autoMode);
  autoModeRef.current = autoMode;

  const reconcileAuto = useCallback(async () => {
    if (reconciling.current || bulkLimit <= 0) return;
    reconciling.current = true;
    try {
      const shared = { channel_id: null, search: null, hide_shorts: hideShorts };
      const [wanted, held] = await Promise.all([
        listFeed({ ...shared, downloaded_only: false, limit: bulkLimit }),
        // Everything on disk, Shorts included: one downloaded before Hide
        // Shorts was switched on still takes up room.
        listFeed({ ...shared, hide_shorts: false, downloaded_only: true, limit: 1000 }),
      ]);

      const keep = new Set(wanted.map((v) => v.id));
      const subscribed = new Set(channels.map((c) => c.id));

      const stale = held.filter(
        (v) => !keep.has(v.id) && v.state === "done" && subscribed.has(v.channel_id),
      );
      for (const v of stale) {
        await deleteDownload(v.id).catch(() => {});
        clearLive(v.id);
      }

      const missing = wanted.filter(
        (v) => v.state !== "done" && v.state !== "queued" && v.state !== "running",
      );
      for (const v of missing) {
        downloadVideo(v.id, quality, embedLang).catch(() => {});
      }

      if (stale.length > 0 || missing.length > 0) {
        reload();
        say(
          `Auto: ${missing.length} to fetch` +
            (stale.length > 0 ? `, ${stale.length} removed` : ""),
        );
      }
    } finally {
      reconciling.current = false;
    }
  }, [bulkLimit, hideShorts, channels, quality, embedLang, reload, say, clearLive]);

  const doRefresh = useCallback(async () => {
    setRefreshing(true);
    try {
      const s = await refreshFeeds();
      await reload();
      const failed = s.failures.length;
      say(
        failed
          ? `Checked ${s.channels} channels · ${failed} failed`
          : `Checked ${s.channels} channel${s.channels === 1 ? "" : "s"}`,
      );
      if (autoModeRef.current) void reconcileAuto();
    } catch (e) {
      setFailure(String(e));
    } finally {
      setRefreshing(false);
      setRefreshProgress(null);
    }
  }, [reload, say, reconcileAuto]);

  // Downloaded plays from disk; anything else streams. Only being offline with
  // no local copy leaves nothing to play.
  const playableAt = useCallback(
    (i: number): { item: FeedItem; path: string | null } | null => {
      const item = items[i];
      if (!item) return null;
      const path = live[item.id]?.path ?? item.path;
      const done = (live[item.id]?.state ?? item.state) === "done";
      if (done && path) return { item, path };
      return online ? { item, path: null } : null;
    },
    [items, live, online],
  );

  const openIndex = useCallback(
    (i: number) => {
      const at = playableAt(i);
      if (!at) {
        setFailure("That video isn't downloaded, and you're offline.");
        return;
      }
      setPlayingIndex(i);
      // Noted while it is open and forgotten when it is closed, so a restart
      // only reopens something you were actually in the middle of.
      try {
        localStorage.setItem("flighttube.playing", at.item.id);
      } catch {
        /* storage blocked */
      }
    },
    [playableAt],
  );

  /** Next/previous item that can actually be played right now. */
  const stepFrom = useCallback(
    (from: number, dir: 1 | -1): number | null => {
      for (let i = from + dir; i >= 0 && i < items.length; i += dir) {
        if (playableAt(i)) return i;
      }
      return null;
    },
    [items.length, playableAt],
  );

  const playing = playingIndex == null ? null : playableAt(playingIndex);
  // The feed can change under an open player (a refresh, a filter change).
  useEffect(() => {
    if (playingIndex != null && !items[playingIndex]) setPlayingIndex(null);
  }, [items, playingIndex]);

  // Keep the feed live while online. Skipped whenever a refresh is already
  // running or the player is open, so it never yanks the list under you.
  useEffect(() => {
    if (!online) return;
    const id = setInterval(() => {
      if (!refreshing && playingIndex == null) void doRefresh();
    }, AUTO_REFRESH_MS);
    return () => clearInterval(id);
  }, [online, refreshing, playingIndex, doRefresh]);

  // The menu bar downloads without the front end being involved, so the
  // backend needs the settings that live in this window.
  useEffect(() => {
    setDownloadDefaults(quality, embedLang).catch(() => {
      /* falls back to what it was built with */
    });
  }, [quality, embedLang]);

  // The menu bar reads the subscription list, but replacing what is here is
  // not a thing to agree to in a panel that closes when you look away.
  useEffect(() => {
    const un = listen<ScrapeResult>("subs:scraped", (e) => {
      previewScrapedImport(e.payload.channels)
        .then((preview) => setScraped({ ...e.payload, preview }))
        .catch((err) => setFailure(String(err)));
    });
    return () => {
      void un.then((f) => f());
    };
  }, []);

  // Anything saved from the menu bar arrives behind the app's back.
  useEffect(() => {
    const un = listen("feed:changed", () => reload());
    return () => {
      void un.then((f) => f());
    };
  }, [reload]);

  /** What engaging auto mode would do right now, so the question is concrete. */
  const askAutoMode = useCallback(async () => {
    if (autoMode) {
      setAutoMode(false);
      say("Auto mode off");
      return;
    }
    if (bulkLimit <= 0) {
      setFailure(
        "Auto mode needs a number to keep. Set Download all in Settings to 5, 10, 25, " +
          "50 or 100 — with no limit there is nothing to trim to.",
      );
      return;
    }
    try {
      const shared = { channel_id: null, search: null, hide_shorts: hideShorts };
      const [wanted, held] = await Promise.all([
        listFeed({ ...shared, downloaded_only: false, limit: bulkLimit }),
        listFeed({ ...shared, hide_shorts: false, downloaded_only: true, limit: 1000 }),
      ]);
      const keep = new Set(wanted.map((v) => v.id));
      const subscribed = new Set(channels.map((c) => c.id));
      setConfirmAuto({
        fetch: wanted.filter((v) => v.state !== "done").length,
        remove: held.filter(
          (v) => !keep.has(v.id) && v.state === "done" && subscribed.has(v.channel_id),
        ).length,
      });
    } catch (e) {
      setFailure(String(e));
    }
  }, [autoMode, bulkLimit, hideShorts, channels, say]);

  const askRemoveChannel = useCallback((id: string) => {
    previewDeleteChannel(id)
      .then((p) => setRemoving({ ...p, id }))
      .catch((e) => setFailure(String(e)));
  }, []);

  const removeChannel = useCallback(() => {
    if (!removing) return;
    const { id, title } = removing;
    setRemoving(null);
    deleteChannel(id)
      .then(() => {
        if (channelId === id) setChannelId(null);
        reload();
        say(`Removed ${title}`);
        // And then the other half of the job. YouTube has no address that
        // unsubscribes on its own, so this opens the channel, where Subscribed
        // is one click from Unsubscribe.
        openExternal(`https://www.youtube.com/channel/${id}`);
      })
      .catch((e) => setFailure(String(e)));
  }, [removing, channelId, reload, say]);

  // Reopen whatever was playing when the app last closed, once the feed has
  // loaded and only if that video is still in it and still playable — offline,
  // a video that was streaming is not.
  const resumedOnce = useRef(false);
  useEffect(() => {
    if (resumedOnce.current || items.length === 0 || playingIndex != null) return;
    resumedOnce.current = true;
    let id: string | null = null;
    try {
      id = localStorage.getItem("flighttube.playing");
    } catch {
      /* storage blocked */
    }
    if (!id) return;
    const at = items.findIndex((i) => i.id === id);
    if (at >= 0 && playableAt(at)) setPlayingIndex(at);
    // playableAt changes with every render; the guard above runs this once.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [items]);

  // On launch, bring the library in line before the first ten-minute check —
  // otherwise auto mode looks asleep for the first ten minutes. Waits for the
  // channel list, since knowing which channels are subscriptions is what keeps
  // hand-saved videos from being swept.
  const reconciledOnce = useRef(false);
  useEffect(() => {
    if (!autoMode || reconciledOnce.current || channels.length === 0) return;
    reconciledOnce.current = true;
    void reconcileAuto();
  }, [autoMode, channels.length, reconcileAuto]);

  // Anything in flight, whether or not it is currently listed — a download
  // started on one channel keeps running while you look at another.
  const activeDownloads = useMemo(() => {
    const ids = new Set<string>();
    for (const [id, l] of Object.entries(live)) {
      if (l.state === "queued" || l.state === "running") ids.add(id);
    }
    for (const i of items) {
      const state = live[i.id]?.state ?? i.state;
      if (state === "queued" || state === "running") ids.add(i.id);
    }
    return ids.size;
  }, [live, items]);

  // Closing the app kills yt-dlp, so anything in flight is left unfinished.
  // Pick it up on the next launch, at the quality and language it was asked
  // for rather than whatever the settings happen to say now.
  useEffect(() => {
    let cancelled = false;
    interruptedDownloads()
      .then((rows) => {
        if (cancelled || rows.length === 0) return;
        say(
          rows.length === 1
            ? "Resuming 1 unfinished download"
            : `Resuming ${rows.length} unfinished downloads`,
        );
        for (const [id, q, lang] of rows) {
          downloadVideo(id, (q || "best") as Quality, lang).catch(() => {});
        }
      })
      .catch(() => {
        /* nothing to resume is the normal case */
      });
    return () => {
      cancelled = true;
    };
    // Once, on launch.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  const stopAll = useCallback(() => {
    cancelAllDownloads()
      .then((n) => {
        reload();
        say(n === 1 ? "Stopped 1 download" : `Stopped ${n} downloads`);
      })
      .catch((e) => setFailure(String(e)));
  }, [reload, say]);

  // Everything listed that is not already here or on its way.
  const pendingDownloads = useMemo(
    () =>
      items.filter((i) => {
        const state = live[i.id]?.state ?? i.state;
        return state !== "done" && state !== "queued" && state !== "running";
      }),
    [items, live],
  );

  // Queues the lot in one go. The backend runs two at a time and the rest wait
  // their turn, so this is a queue rather than a stampede; a video that fails
  // reports it on its own row instead of throwing a dialog for each one.
  const bulkTargets = useMemo(
    () => (bulkLimit > 0 ? pendingDownloads.slice(0, bulkLimit) : pendingDownloads),
    [pendingDownloads, bulkLimit],
  );

  const downloadAll = useCallback(() => {
    if (bulkTargets.length === 0) return;
    const capped = bulkTargets.length < pendingDownloads.length;
    say(
      capped
        ? `Queued the newest ${bulkTargets.length} of ${pendingDownloads.length} — limit set in Settings`
        : `Queued ${bulkTargets.length} video${bulkTargets.length === 1 ? "" : "s"}`,
    );
    for (const i of bulkTargets) {
      downloadVideo(i.id, quality, embedLang).catch(() => {});
    }
  }, [bulkTargets, pendingDownloads.length, quality, embedLang, say]);

  // Ids on screen still lacking a length, newest first. Joined into a string
  // so the effect below only re-runs when the set actually changes.
  const missingDurations = useMemo(
    () => items.filter((i) => i.duration == null).slice(0, 40).map((i) => i.id),
    [items],
  );
  const missingKey = missingDurations.join(",");

  // The Atom feed carries no duration, so lengths are looked up a batch at a
  // time in the background and cached. Paused while the player is open.
  useEffect(() => {
    if (!online) return;
    let stop = false;
    // Set when the backend reports it is being refused, so we stop for the
    // rest of the session rather than making the block worse.
    let refused = false;
    const tick = async () => {
      if (stop || refused || playingIndex != null) return;
      try {
        if ((await fetchDurations(missingDurations)) > 0 && !stop) await reload();
      } catch {
        refused = true;
      }
    };
    void tick();
    const id = setInterval(tick, DURATION_FILL_MS);
    return () => {
      stop = true;
      clearInterval(id);
    };
    // Re-runs when the visible set changes, so switching channel fills that
    // channel rather than whatever is newest overall.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [online, playingIndex, missingKey]);

  // Refresh once on launch, as soon as there is a connection and something to
  // refresh, so the feed is current without anyone pressing anything.
  const launched = useRef(false);
  useEffect(() => {
    if (launched.current || !online || channels.length === 0) return;
    launched.current = true;
    void doRefresh();
  }, [online, channels.length, doRefresh]);

  const [confirmWipe, setConfirmWipe] = useState(false);

  const wipeDownloads = useCallback(async () => {
    setConfirmWipe(false);
    try {
      const n = await deleteAllDownloads();
      items.forEach((i) => clearLive(i.id));
      await reload();
      say(`Deleted ${n} download${n === 1 ? "" : "s"}`);
    } catch (e) {
      setFailure(String(e));
    }
  }, [items, clearLive, reload, say]);

  const activeChannelError =
    channelId == null
      ? null
      : (channels.find((c) => c.id === channelId)?.last_error ?? null);

  const emptyMessage = () => {
    if (loading) return "Loading…";
    if (channels.length === 0)
      return "No subscriptions yet. Open Settings — it walks you through exporting them from Google Takeout.";
    if (totals.videos === 0) return "Subscriptions imported. Hit Refresh to pull in their latest videos.";
    if (!online) return "You're offline, and nothing has been downloaded yet.";
    if (effectiveDownloadedOnly) return "No downloaded videos match this filter.";
    return "Nothing matches this filter.";
  };

  // Each pane reserves its own strip for the traffic lights instead of one
  // band across the top, so the sidebar's right border runs unbroken from the
  // very top of the window. In macOS window fullscreen there are no traffic
  // lights, so the inset collapses.
  const titleBarInset = !windowFullscreen;

  return (
    <div className="flex h-screen flex-col">
      <div className="relative flex min-h-0 flex-1 flex-col lg:flex-row">
        {/* With the sidebar hidden, a thin strip along the left edge brings it
            back on hover. */}
        {sidebarHidden && (
          <div
            onMouseEnter={() => setSidebarPeek(true)}
            className="absolute inset-y-0 left-0 z-20 w-3"
            aria-hidden
          />
        )}
        {sidebarHidden && sidebarPeek && (
          <div
            onMouseLeave={() => setSidebarPeek(false)}
            className="absolute inset-y-0 left-0 z-30 w-[280px]"
          >
            <Sidebar
              floating
              channels={channels}
              activeChannel={channelId}
              onSelect={(id) => { setChannelId(id); setSidebarPeek(false); }}
              onOpenSettings={() => setShowSettings(true)}
            onAddChannel={() => setAddingChannel(true)}
            onDeleteChannel={askRemoveChannel}
              totalVideos={totals.videos}
              totalDownloaded={totals.downloaded}
              onHide={() => { setSidebarHidden(true); setSidebarPeek(false); }}
              titleBarInset={titleBarInset}
            />
          </div>
        )}

        {!sidebarHidden && (
          <Sidebar
            channels={channels}
            activeChannel={channelId}
            onSelect={setChannelId}
            onOpenSettings={() => setShowSettings(true)}
            onAddChannel={() => setAddingChannel(true)}
            onDeleteChannel={askRemoveChannel}
            totalVideos={totals.videos}
            totalDownloaded={totals.downloaded}
            onHide={() => setSidebarHidden(true)}
            titleBarInset={titleBarInset}
          />
        )}

      <main className="flex min-h-0 min-w-0 flex-1 flex-col">
        <TopBar
          search={search} onSearch={setSearch}
          downloadedOnly={effectiveDownloadedOnly} onDownloadedOnly={setDownloadedOnly}
          online={online} reachable={reachable} forcedOffline={forcedOffline}
          onToggleForcedOffline={() => { setForcedOffline(!forcedOffline); probe(); }}
          onRefresh={doRefresh} refreshing={refreshing} refreshProgress={refreshProgress}
          resultCount={items.length}
          view={view} onView={setView}
          onDeleteAll={totals.downloaded > 0 ? () => setConfirmWipe(true) : undefined}
          onDownloadAll={online && bulkTargets.length > 0 ? downloadAll : undefined}
          downloadAllCount={bulkTargets.length}
          downloadAllTotal={pendingDownloads.length}
          onStopAll={activeDownloads > 0 ? stopAll : undefined}
          stopAllCount={activeDownloads}
          autoMode={autoMode}
          onAutoMode={askAutoMode}
          autoModeCount={bulkLimit}
          sidebarHidden={sidebarHidden}
          onShowSidebar={() => { setSidebarHidden(false); setSidebarPeek(false); }}
          titleBarInset={titleBarInset}
        />

        {activeChannelError && (
          <div
            className="border-b border-red-500/30 bg-red-500/10 px-4 py-2 text-[11px]
                       leading-snug text-red-700 dark:text-red-300"
          >
            <b>This channel failed to refresh.</b>{" "}
            {activeChannelError.replace(/\.?$/, ".")} Anything listed below is from the
            last successful check.
          </div>
        )}

        {!online && (
          <div
            className="border-b border-amber-500/30 bg-amber-500/10 px-4 py-2 text-[11px]
                       leading-snug text-amber-700 dark:text-amber-300"
          >
            Offline — showing only videos you've downloaded.
            {forcedOffline && " Offline mode is forced on."}
          </div>
        )}

        <div className="min-h-0 flex-1 overflow-y-auto p-3 lg:p-5">
          {items.length === 0 ? (
            <div className="grid h-full place-items-center">
              <p className="max-w-sm text-center text-[12px] leading-relaxed text-slate-500 dark:text-slate-400">
                {emptyMessage()}
              </p>
            </div>
          ) : (
            <ul
              className={
                view === "grid"
                  ? "mx-auto grid max-w-7xl grid-cols-[repeat(auto-fill,minmax(240px,1fr))] gap-x-4 gap-y-6"
                  : "mx-auto max-w-4xl space-y-1.5"
              }
            >
              {items.map((item, idx) => {
                const shared = {
                  item,
                  live: live[item.id],
                  online,
                  onOpen: () => openIndex(idx),
                  onOpenChannel: () => { setChannelId(item.channel_id); setSearch(""); },
                  onDownload: () =>
                    downloadVideo(item.id, quality, embedLang).catch((e) => setFailure(String(e))),
                  onCancel: () =>
                    cancelDownload(item.id).catch((e) => setFailure(String(e))),
                  onDelete: () =>
                    deleteDownload(item.id)
                      .then(() => { clearLive(item.id); reload(); say("Download deleted"); })
                      .catch((e) => setFailure(String(e))),
                };
                return view === "grid" ? (
                  <VideoTile key={item.id} {...shared} />
                ) : (
                  <VideoRow key={item.id} {...shared} />
                );
              })}
            </ul>
          )}
        </div>
      </main>
      </div>

      {/* The player is deliberately not keyed on the video. Remounting would
          build a new stage element, and the stage is what is fullscreen — so
          every Next dropped out of fullscreen. It resets its own per-video
          state instead. */}
      {playing && playingIndex != null && (
        <Player
          item={playing.item}
          path={playing.path}
          index={playingIndex}
          total={items.length}
          maxHeight={streamQuality === "best" ? null : Number(streamQuality)}
          subLang={subLang}
          onSubLang={setSubLang}
          subStyle={subStyle}
          onSubStyle={setSubStyle}
          autoplayNext={autoplayNext}
          onOpenChannel={() => {
            setChannelId(playing.item.channel_id);
            setSearch("");
            setPlayingIndex(null);
          }}
          titleBarInset={titleBarInset}
          onPrev={
            stepFrom(playingIndex, -1) != null
              ? () => setPlayingIndex(stepFrom(playingIndex, -1))
              : undefined
          }
          onNext={
            stepFrom(playingIndex, 1) != null
              ? () => setPlayingIndex(stepFrom(playingIndex, 1))
              : undefined
          }
          onDownload={
            playing.path === null
              ? () => {
                  downloadVideo(playing.item.id, quality, embedLang).catch((e) => setFailure(String(e)));
                  say("Download started");
                }
              : undefined
          }
          downloading={["queued", "running"].includes(
            live[playing.item.id]?.state ?? playing.item.state ?? "",
          )}
          onClose={() => {
            setPlayingIndex(null);
            try {
              localStorage.removeItem("flighttube.playing");
            } catch {
              /* storage blocked */
            }
            // Coming back from a video is the natural moment to pick up
            // whatever has been posted since.
            if (online && !refreshing) void doRefresh();
            else void reload();
          }}
          onDelete={async () => {
            await deleteDownload(playing.item.id);
            clearLive(playing.item.id);
            setPlayingIndex(null);
            reload();
            say("Download deleted");
          }}
        />
      )}

      {showSettings && (
        <Settings
          onClose={() => setShowSettings(false)}
          appearance={mode}
          onAppearance={setMode}
          quality={quality}
          onQuality={setQuality}
          bulkLimit={bulkLimit}
          onBulkLimit={setBulkLimit}
          streamQuality={streamQuality}
          onStreamQuality={setStreamQuality}
          subLang={subLang}
          onSubLang={setSubLang}
          hideShorts={hideShorts}
          onHideShorts={setHideShorts}
          autoplayNext={autoplayNext}
          onAutoplayNext={setAutoplayNext}
          browser={browser}
          onBrowser={setBrowser}
          onError={setFailure}
          onImported={(n) => {
            reload();
            say(`Imported ${n} subscription${n === 1 ? "" : "s"}`);
          }}
          onRemovedAll={(n) => {
            setChannelId(null);
            reload();
            say(`Removed ${n} subscription${n === 1 ? "" : "s"}`);
          }}
        />
      )}

      {confirmWipe && (
        <Dialog
          title="Delete every download?"
          onCancel={() => setConfirmWipe(false)}
          onConfirm={wipeDownloads}
          confirmLabel="Delete all"
          destructive
        >
          <p>
            All <b>{totals.downloaded}</b> downloaded video
            {totals.downloaded === 1 ? "" : "s"} and their subtitles will be removed from
            disk. Your subscriptions and the feed are untouched.
          </p>
        </Dialog>
      )}

      {scraped && (
        <Dialog
          title="Replace your subscriptions?"
          onCancel={() => setScraped(null)}
          onConfirm={() => {
            const list = scraped.channels;
            setScraped(null);
            importScraped(list)
              .then((n) => {
                reload();
                say(`Imported ${n} subscriptions`);
                void doRefresh();
              })
              .catch((e) => setFailure(String(e)));
          }}
          confirmLabel="Replace"
          destructive={scraped.preview.removed_channels > 0}
          wide
        >
          <p>
            Read <b>{scraped.channels.length}</b> channels off YouTube
            {scraped.looked_up > 0 && <> ({scraped.looked_up} looked up by hand)</>}.
          </p>
          <p className="mt-2">
            This replaces the list here. <b>{scraped.preview.removed_channels}</b> channel
            {scraped.preview.removed_channels === 1 ? "" : "s"} would go, taking{" "}
            <b>{scraped.preview.removed_videos}</b> videos and{" "}
            <b>{scraped.preview.removed_downloads}</b> downloaded file
            {scraped.preview.removed_downloads === 1 ? "" : "s"} with them.
          </p>
          {scraped.unresolved.length > 0 && (
            <p className="mt-2">
              <b>{scraped.unresolved.length}</b> could not be identified and are left out:{" "}
              <span className="text-slate-500 dark:text-slate-400">
                {scraped.unresolved.slice(0, 8).join(", ")}
                {scraped.unresolved.length > 8 && ` and ${scraped.unresolved.length - 8} more`}
              </span>
              . Add those by hand if you want them.
            </p>
          )}
        </Dialog>
      )}

      {confirmAuto && (
        <Dialog
          title="Turn on auto mode?"
          onCancel={() => setConfirmAuto(null)}
          onConfirm={() => {
            setConfirmAuto(null);
            setAutoMode(true);
            say("Auto mode on");
            void reconcileAuto();
          }}
          confirmLabel="Turn on"
          destructive={confirmAuto.remove > 0}
        >
          <p>
            FlightTube will keep the newest <b>{bulkLimit}</b> videos of your feed on this
            Mac, and check again after every refresh.
          </p>
          <p className="mt-2">
            Right now that means downloading <b>{confirmAuto.fetch}</b> video
            {confirmAuto.fetch === 1 ? "" : "s"}
            {confirmAuto.remove > 0 ? (
              <>
                {" "}and <b>deleting {confirmAuto.remove}</b> already downloaded that fall
                outside the newest {bulkLimit}
              </>
            ) : null}
            . From then on, anything that drops out of the newest {bulkLimit} is deleted to
            make room.
          </p>
          <p className="mt-2">
            Videos you saved by hand from the menu bar are left alone — those are not part
            of a subscription, so auto mode does not manage them.
          </p>
        </Dialog>
      )}

      {removing && (
        <Dialog
          title={`Remove ${removing.title}?`}
          onCancel={() => setRemoving(null)}
          onConfirm={removeChannel}
          confirmLabel="Remove"
          destructive
        >
          <p>
            It leaves FlightTube along with its <b>{removing.videos}</b> video
            {removing.videos === 1 ? "" : "s"}
            {removing.downloaded > 0 && (
              <>
                , <b>{removing.downloaded}</b> of them downloaded — those files are deleted
                from disk
              </>
            )}
            .
          </p>
          <p className="mt-2">
            The channel then opens on YouTube so you can unsubscribe there too —
            YouTube has no link that does it on its own, so it is one click on{" "}
            <b>Subscribed</b>. Nothing changes there until you do.
          </p>
        </Dialog>
      )}

      {addingChannel && (
        <AddChannel
          onClose={() => setAddingChannel(false)}
          onAdded={(title) => {
            setAddingChannel(false);
            reload();
            say(`Added ${title}`);
          }}
        />
      )}

      {failure && (
        <Dialog title="Something went wrong" onCancel={() => setFailure(null)}>
          <p className="break-words">{failure}</p>
        </Dialog>
      )}

      <Toast message={toast} />
    </div>
  );
}
