import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
  cancelAllDownloads, cancelDownload, deleteAllDownloads, deleteDownload, downloadVideo,
  interruptedDownloads,
  fetchDurations, onRefreshProgress, refreshFeeds, setCookieSource,
} from "./api";
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,
  QUALITIES, STREAM_QUALITIES, SUB_LANGS,
  type FeedFilter, type FeedItem, type Quality, type RefreshProgress,
} 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 [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);
  // 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;
    }
  });
  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.browser", browser);
      localStorage.setItem("flighttube.downloadedOnly", downloadedOnly ? "1" : "0");
      localStorage.setItem("flighttube.hideShorts", hideShorts ? "1" : "0");
      localStorage.setItem("flighttube.sidebarHidden", sidebarHidden ? "1" : "0");
    } catch {
      /* storage blocked */
    }
  }, [view, quality, bulkLimit, streamQuality, subLang, browser, downloadedOnly,
      hideShorts, 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.

  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"}`,
      );
    } catch (e) {
      setFailure(String(e));
    } finally {
      setRefreshing(false);
      setRefreshProgress(null);
    }
  }, [reload, say]);

  // 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) => {
      if (playableAt(i)) setPlayingIndex(i);
      else setFailure("That video isn't downloaded, and you're offline.");
    },
    [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]);

  // 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)}
              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)}
            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}
          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>

      {playing && playingIndex != null && (
        <Player
          key={playing.item.id}
          item={playing.item}
          path={playing.path}
          index={playingIndex}
          total={items.length}
          maxHeight={streamQuality === "best" ? null : Number(streamQuality)}
          subLang={subLang}
          onSubLang={setSubLang}
          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);
            // 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}
          browser={browser}
          onBrowser={setBrowser}
          onError={setFailure}
          onImported={(n) => {
            reload();
            say(`Imported ${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>
      )}

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

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