import { Fragment, lazy, Suspense, useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Hls from "hls.js";
import {
  ArrowLeft,
  Clock,
  Download,
  Info,
  Keyboard,
  Maximize,
  Minimize,
  Pause,
  Play,
  RotateCcw,
  Settings,
  SkipBack,
  SkipForward,
  Square,
  Volume2,
  VolumeX,
  X,
} from "lucide-react";
import { api, type PlexMarker } from "../lib/api";

// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
const PlexInfo = lazy(() => import("./PlexInfo"));

// The Plex module's rich, full-page player. Plays the LOCAL file: direct-playable files stream raw
// (native <video>), remux files stream via the seek-restart HLS session (hls.js). The visible
// timeline is ABSOLUTE (0…duration); the HLS session is relative to its start offset, so we map
// currentTime = sessionStart + video.currentTime and, on a seek beyond the generated region,
// restart the session at the target. Watch state / resume persist to plex_states.

// `queue` (ordered rating_keys) drives play-through for a playlist: prev/next + auto-advance follow
// the queue instead of the item's own episode neighbours.
type Props = { itemId: string; onClose: () => void; queue?: string[] };

function fmt(t: number): string {
  if (!isFinite(t) || t < 0) t = 0;
  const s = Math.floor(t % 60);
  const m = Math.floor((t / 60) % 60);
  const h = Math.floor(t / 3600);
  const pad = (n: number) => String(n).padStart(2, "0");
  return h ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
}

export default function PlexPlayer({ itemId, onClose, queue }: Props) {
  const { t } = useTranslation();
  const qc = useQueryClient();
  const [id, setId] = useState(itemId);
  const detailQ = useQuery({ queryKey: ["plex-item", id], queryFn: () => api.plexItem(id) });
  const detail = detailQ.data;
  const duration = detail?.duration_seconds ?? 0;

  const videoRef = useRef<HTMLVideoElement>(null);
  const hlsRef = useRef<Hls | null>(null);
  // False once unmounted, so a late play() (from MANIFEST_PARSED / loadedmetadata firing after Back)
  // can't start a detached <video> playing audio in the background.
  const aliveRef = useRef(true);
  const wrapRef = useRef<HTMLDivElement>(null);
  const sessionStartRef = useRef(0); // absolute offset the current HLS/direct session begins at
  const durationRef = useRef(0);

  const [playing, setPlaying] = useState(false);
  const [abs, setAbs] = useState(0); // absolute current time
  const [seekableEnd, setSeekableEnd] = useState(0); // absolute end of the loaded region
  const [volume, setVolume] = useState(1);
  const [muted, setMuted] = useState(false);
  const [fs, setFs] = useState(false);
  const [ready, setReady] = useState(false);
  const [loadError, setLoadError] = useState<string | null>(null);
  const [uiVisible, setUiVisible] = useState(true);
  const hideTimer = useRef<number | null>(null);
  // Selected audio / subtitle stream ordinals (null audio = default first; null subtitle = off).
  const [audioOrd, setAudioOrd] = useState<number | null>(null);
  const [subOrd, setSubOrd] = useState<number | null>(null);
  const [menuOpen, setMenuOpen] = useState(false);
  const audioRef = useRef<number | null>(null);
  const menuOpenRef = useRef(false);
  audioRef.current = audioOrd;
  menuOpenRef.current = menuOpen;
  // Keyboard-shortcut cheat sheet (toggled with "h") + a live wall clock for the top bar.
  const [helpOpen, setHelpOpen] = useState(false);
  const helpOpenRef = useRef(false);
  helpOpenRef.current = helpOpen;
  // "I" toggles a rich info overlay (poster/cast/ratings) over the running video.
  const [infoOpen, setInfoOpen] = useState(false);
  const infoOpenRef = useRef(false);
  infoOpenRef.current = infoOpen;
  const [now, setNow] = useState(() => new Date());
  useEffect(() => {
    const iv = window.setInterval(() => setNow(new Date()), 15000);
    return () => window.clearInterval(iv);
  }, []);

  durationRef.current = duration;

  // A new item starts with default tracks (its stream layout differs from the last one's).
  useEffect(() => {
    setAudioOrd(null);
    setSubOrd(null);
  }, [id]);

  // --- media setup: (re)load a session for `id` at a start offset -------------------------------
  const loadSession = useCallback(
    async (startAt: number) => {
      const video = videoRef.current;
      if (!video) return;
      setReady(false);
      setLoadError(null);
      let sess;
      try {
        sess = await api.plexSession(id, startAt, audioRef.current);
      } catch (e: any) {
        // Surface WHY playback can't start instead of an eternal spinner. 404 = the physical file
        // isn't reachable (missing media bind-mount or a wrong plex_path_map); 501 = the codec
        // needs full transcoding (a later phase).
        setLoadError(
          e?.status === 501
            ? t("plex.player.errTranscode")
            : e?.status === 404
              ? t("plex.player.errNotFound")
              : t("plex.player.errGeneric"),
        );
        return;
      }
      sessionStartRef.current = sess.start_s;
      if (hlsRef.current) {
        hlsRef.current.destroy();
        hlsRef.current = null;
      }
      if (sess.mode === "hls" && Hls.isSupported()) {
        const hls = new Hls({ maxBufferLength: 30, enableWorker: true });
        hlsRef.current = hls;
        hls.loadSource(`${sess.url}?t=${Math.floor(startAt)}`);
        hls.attachMedia(video);
        // Subtitles are native <track>s (not muxed into the HLS), so nothing subtitle-related here.
        hls.on(Hls.Events.MANIFEST_PARSED, () => {
          setReady(true);
          if (aliveRef.current) video.play().catch(() => {});
        });
        // A fatal HLS error (e.g. the remux session died / the file became unreadable) would
        // otherwise leave the spinner up forever — surface it instead.
        hls.on(Hls.Events.ERROR, (_e, data) => {
          if (data.fatal) setLoadError(t("plex.player.errGeneric"));
        });
      } else {
        // direct file (or Safari native HLS)
        video.src = sess.url;
        video.load();
        const onMeta = () => {
          if (sess.mode === "direct" && startAt > 0) video.currentTime = startAt;
          setReady(true);
          if (aliveRef.current) video.play().catch(() => {});
          video.removeEventListener("loadedmetadata", onMeta);
        };
        video.addEventListener("loadedmetadata", onMeta);
      }
    },
    [id, t],
  );

  // Start playback when the detail arrives (resume from the saved position unless already watched).
  useEffect(() => {
    if (!detail) return;
    const resume = detail.status !== "watched" ? detail.position_seconds || 0 : 0;
    loadSession(resume);
    return () => {
      if (hlsRef.current) {
        hlsRef.current.destroy();
        hlsRef.current = null;
      }
    };
  }, [detail, loadSession]);

  // Full media teardown on unmount (Back): stop + detach the <video> and destroy hls, so a late
  // play() can't leave audio playing on a detached element after leaving the player.
  useEffect(() => {
    return () => {
      aliveRef.current = false;
      if (hlsRef.current) {
        hlsRef.current.destroy();
        hlsRef.current = null;
      }
      const v = videoRef.current;
      if (v) {
        try {
          v.pause();
          v.removeAttribute("src");
          v.load();
        } catch {
          /* ignore */
        }
      }
    };
  }, []);

  // --- time tracking + progress checkpoints ----------------------------------------------------
  useEffect(() => {
    const video = videoRef.current;
    if (!video) return;
    const onTime = () => {
      const a = sessionStartRef.current + video.currentTime;
      setAbs(a);
      try {
        const end = video.seekable.length ? sessionStartRef.current + video.seekable.end(0) : a;
        setSeekableEnd(end);
      } catch {
        /* ignore */
      }
    };
    const onPlay = () => setPlaying(true);
    const onPause = () => setPlaying(false);
    video.addEventListener("timeupdate", onTime);
    video.addEventListener("play", onPlay);
    video.addEventListener("pause", onPause);
    return () => {
      video.removeEventListener("timeupdate", onTime);
      video.removeEventListener("play", onPlay);
      video.removeEventListener("pause", onPause);
    };
  }, [id]);

  // Periodic + on-unmount progress checkpoint.
  const absRef = useRef(0);
  absRef.current = abs;
  useEffect(() => {
    const flush = () => {
      if (absRef.current > 0 && durationRef.current > 0)
        api.plexProgress(id, Math.floor(absRef.current), durationRef.current).catch(() => {});
    };
    const iv = window.setInterval(flush, 10000);
    return () => {
      window.clearInterval(iv);
      flush();
      qc.invalidateQueries({ queryKey: ["plex-browse"] });
      qc.invalidateQueries({ queryKey: ["plex-show"] });
    };
  }, [id, qc]);

  // --- seeking (native within the loaded region, else restart the session) ---------------------
  const seekTo = useCallback(
    (target: number) => {
      const video = videoRef.current;
      if (!video) return;
      const dur = durationRef.current || target;
      const T = Math.max(0, Math.min(target, dur - 1));
      const rel = T - sessionStartRef.current;
      if (rel >= 0 && rel <= (video.seekable.length ? video.seekable.end(0) : 0) + 0.5) {
        video.currentTime = rel;
      } else {
        loadSession(T);
      }
    },
    [loadSession],
  );

  // --- controls --------------------------------------------------------------------------------
  const togglePlay = useCallback(() => {
    const v = videoRef.current;
    if (!v) return;
    if (v.paused) v.play().catch(() => {});
    else v.pause();
  }, []);

  const applyVolume = useCallback((vol: number, mute: boolean) => {
    const v = videoRef.current;
    if (!v) return;
    v.volume = Math.max(0, Math.min(1, vol));
    v.muted = mute;
  }, []);
  const nudgeVolume = useCallback(
    (d: number) => {
      setVolume((prev) => {
        const nv = Math.max(0, Math.min(1, prev + d));
        applyVolume(nv, false);
        setMuted(nv === 0);
        return nv;
      });
    },
    [applyVolume],
  );

  const toggleFs = useCallback(() => {
    const el = wrapRef.current;
    if (!el) return;
    if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
    else el.requestFullscreen().catch(() => {});
  }, []);
  useEffect(() => {
    const onFs = () => setFs(!!document.fullscreenElement);
    document.addEventListener("fullscreenchange", onFs);
    return () => document.removeEventListener("fullscreenchange", onFs);
  }, []);

  const go = useCallback(
    (otherId: string | null | undefined) => {
      if (otherId) {
        setAbs(0);
        setId(otherId);
      }
    },
    [],
  );

  // Prev/next: from the playlist queue when playing one, else the item's own episode neighbours.
  const qIdx = queue ? queue.indexOf(id) : -1;
  const prevId = queue ? (qIdx > 0 ? queue[qIdx - 1] : null) : detail?.prev_id;
  const nextId = queue ? (qIdx >= 0 && qIdx < queue.length - 1 ? queue[qIdx + 1] : null) : detail?.next_id;

  // Download the ORIGINAL physical file (no re-encode/repackage). One user gesture, direct link.
  const download = useCallback(() => {
    const a = document.createElement("a");
    a.href = api.plexDownloadUrl(id);
    a.rel = "noopener";
    document.body.appendChild(a);
    a.click();
    a.remove();
  }, [id]);

  // Switching AUDIO re-maps the track server-side, so it restarts the session at the current
  // position (like a seek). The ref is updated synchronously so loadSession sees it.
  const changeAudio = useCallback(
    (ord: number | null) => {
      audioRef.current = ord;
      setAudioOrd(ord);
      setMenuOpen(false);
      loadSession(absRef.current);
    },
    [loadSession],
  );
  // Subtitles are native <track>s overlaid by the browser — switching one just flips TextTrack modes
  // (applied by the effect below), NO session restart. Off = null.
  const changeSubtitle = useCallback((ord: number | null) => {
    setSubOrd(ord);
    setMenuOpen(false);
  }, []);

  // Text subtitles offered to the browser as <track>s; image subs (PGS/VobSub) can't be shown as text.
  const textSubs = (detail?.subtitle_streams ?? []).filter((s) => s.text);
  // Apply the selected subtitle: show the matching <track>, disable the rest. The <track> DOM order
  // matches `textSubs`, so textTracks[i] ↔ textSubs[i]. Setting mode!="disabled" triggers the VTT fetch.
  useEffect(() => {
    const video = videoRef.current;
    if (!video) return;
    const tracks = Array.from(video.textTracks);
    tracks.forEach((tr, i) => {
      const s = textSubs[i];
      tr.mode = s && s.ord === subOrd ? "showing" : "disabled";
    });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [subOrd, id, textSubs.length]);

  // Lift subtitle cues off the very bottom edge. The video element fills the viewport height, so a
  // default (line "auto") cue renders at the extreme bottom and gets clipped — nudge every cue up
  // to ~88% so it sits just above the control bar. Re-applied on each cue change (hls.js streams
  // cues in as subtitle segments load, and TextTrack has no per-cue "added" event).
  useEffect(() => {
    const video = videoRef.current;
    if (!video) return;
    const placeAll = () => {
      for (const tr of Array.from(video.textTracks)) {
        for (const c of Array.from(tr.cues || [])) {
          try {
            (c as VTTCue).snapToLines = false;
            (c as VTTCue).line = 88;
          } catch {
            /* not a positionable cue */
          }
        }
      }
    };
    const bind = (tr: TextTrack) => tr.addEventListener("cuechange", placeAll);
    for (const tr of Array.from(video.textTracks)) bind(tr);
    const onAddTrack = (e: TrackEvent) => {
      if (e.track) {
        bind(e.track as TextTrack);
        placeAll();
      }
    };
    video.textTracks.addEventListener("addtrack", onAddTrack as EventListener);
    return () => {
      video.textTracks.removeEventListener("addtrack", onAddTrack as EventListener);
      for (const tr of Array.from(video.textTracks)) tr.removeEventListener("cuechange", placeAll);
    };
  }, [id]);

  // Auto-advance to the next episode when one finishes.
  useEffect(() => {
    const video = videoRef.current;
    if (!video) return;
    const onEnded = () => {
      api.plexSetState(id, "watched").catch(() => {});
      if (nextId) go(nextId);
    };
    video.addEventListener("ended", onEnded);
    return () => video.removeEventListener("ended", onEnded);
  }, [id, nextId, go]);

  // Reveal the controls and re-arm the auto-hide timer (on mouse move OR any key).
  const wake = useCallback(() => {
    setUiVisible(true);
    if (hideTimer.current) window.clearTimeout(hideTimer.current);
    hideTimer.current = window.setTimeout(() => {
      // Keep the controls up while the audio/subtitle menu is open, else it would vanish mid-choice.
      if (!videoRef.current?.paused && !menuOpenRef.current) setUiVisible(false);
    }, 3000);
  }, []);

  // --- keyboard --------------------------------------------------------------------------------
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      const tag = (e.target as HTMLElement)?.tagName;
      if (tag === "INPUT" || tag === "TEXTAREA") return;
      wake(); // keyboard use also reveals the controls
      switch (e.key) {
        case " ":
        case "k":
          e.preventDefault();
          togglePlay();
          break;
        case "f":
          toggleFs();
          break;
        case "ArrowLeft":
          e.preventDefault();
          if (e.shiftKey) go(prevId);
          else seekTo(absRef.current - 10);
          break;
        case "ArrowRight":
          e.preventDefault();
          if (e.shiftKey) go(nextId);
          else seekTo(absRef.current + 10);
          break;
        case "ArrowUp":
          e.preventDefault();
          nudgeVolume(0.05);
          break;
        case "ArrowDown":
          e.preventDefault();
          nudgeVolume(-0.05);
          break;
        case "m":
          setMuted((m) => {
            applyVolume(volume, !m);
            return !m;
          });
          break;
        case "h":
        case "H":
          setHelpOpen((v) => !v);
          break;
        case "i":
        case "I":
          setInfoOpen((v) => !v);
          break;
        case "Backspace":
          // HTPC-style "stop & back to the feed" — mirrors the mouse Back button.
          e.preventDefault();
          if (helpOpenRef.current) setHelpOpen(false);
          else if (infoOpenRef.current) setInfoOpen(false);
          else onClose();
          break;
        case "Escape":
          if (helpOpenRef.current) setHelpOpen(false);
          else if (infoOpenRef.current) setInfoOpen(false);
          else if (!document.fullscreenElement) onClose();
          break;
      }
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [togglePlay, toggleFs, seekTo, go, nudgeVolume, applyVolume, volume, detail, prevId, nextId, onClose, wake]);

  const activeMarker: PlexMarker | undefined = detail?.markers.find(
    (m) => abs >= m.start_s && abs < m.end_s - 1,
  );

  const pct = duration > 0 ? (abs / duration) * 100 : 0;
  const bufPct = duration > 0 ? (seekableEnd / duration) * 100 : 0;

  return (
    <div
      ref={wrapRef}
      className="fixed inset-0 z-50 bg-black flex items-center justify-center select-none"
      onMouseMove={wake}
      onClick={wake}
      style={{ cursor: uiVisible ? "default" : "none" }}
    >
      <video
        ref={videoRef}
        className="max-h-full max-w-full w-auto h-auto"
        onClick={togglePlay}
        playsInline
      >
        {/* Text subtitles as native tracks (same-origin → sent with the session cookie). The effect
            above flips modes; the browser fetches a track's VTT the first time it's shown. */}
        {textSubs.map((s) => (
          <track
            key={s.ord}
            kind="subtitles"
            src={api.plexSubtitleUrl(id, s.ord)}
            srcLang={s.language ?? undefined}
            label={s.label}
          />
        ))}
      </video>

      {!ready && (
        <div className="absolute inset-0 grid place-items-center px-6 text-center text-sm pointer-events-none">
          {loadError ? (
            <span className="max-w-md text-red-300">{loadError}</span>
          ) : (
            <span className="text-white/70">{t("plex.player.loading")}</span>
          )}
        </div>
      )}

      {/* Top bar */}
      <div
        className={`absolute top-0 inset-x-0 p-4 bg-gradient-to-b from-black/70 to-transparent transition-opacity ${
          uiVisible ? "opacity-100" : "opacity-0"
        }`}
      >
        <div className="flex items-center gap-3 text-white">
          <button onClick={onClose} className="p-1.5 rounded-lg hover:bg-white/15" title={t("plex.player.back")}>
            <ArrowLeft className="w-5 h-5" />
          </button>
          <div className="min-w-0">
            <div className="font-semibold truncate">
              {detail?.kind === "episode" && detail.show_title ? detail.show_title : detail?.title}
            </div>
            {detail?.kind === "episode" && (
              <div className="text-xs text-white/70 truncate">
                S{detail.season_number}·E{detail.episode_number} — {detail.title}
              </div>
            )}
          </div>
          {/* Live wall clock — handy on a lean-back / HTPC screen. */}
          <div className="ml-auto flex items-center gap-1.5 text-sm tabular-nums text-white/80 shrink-0">
            <Clock className="w-4 h-4" />
            {now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
          </div>
        </div>
      </div>

      {/* Skip intro / credits */}
      {activeMarker && (
        <button
          onClick={() => seekTo(activeMarker.end_s)}
          className={`absolute right-6 bottom-28 px-4 py-2 rounded-lg bg-white/90 text-black text-sm font-medium hover:bg-white transition ${
            uiVisible ? "opacity-100" : "opacity-80"
          }`}
        >
          {activeMarker.type === "intro" ? t("plex.player.skipIntro") : t("plex.player.skipCredits")}
        </button>
      )}

      {/* Bottom controls */}
      <div
        className={`absolute bottom-0 inset-x-0 px-4 pb-3 pt-8 bg-gradient-to-t from-black/80 to-transparent transition-opacity ${
          uiVisible ? "opacity-100" : "opacity-0 pointer-events-none"
        }`}
      >
        {/* Seek bar */}
        <div
          className="relative h-1.5 rounded-full bg-white/25 cursor-pointer group mb-2"
          onClick={(e) => {
            const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
            seekTo(((e.clientX - r.left) / r.width) * duration);
          }}
        >
          <div className="absolute inset-y-0 left-0 bg-white/30 rounded-full" style={{ width: `${bufPct}%` }} />
          {/* intro/credit marker regions */}
          {detail?.markers.map((m, i) => (
            <div
              key={i}
              className="absolute inset-y-0 bg-amber-400/50"
              style={{ left: `${(m.start_s / duration) * 100}%`, width: `${((m.end_s - m.start_s) / duration) * 100}%` }}
            />
          ))}
          <div className="absolute inset-y-0 left-0 bg-accent rounded-full" style={{ width: `${pct}%` }} />
          <div
            className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3 h-3 rounded-full bg-accent opacity-0 group-hover:opacity-100"
            style={{ left: `${pct}%` }}
          />
        </div>

        <div className="flex items-center gap-3 text-white">
          <Ctrl label={t("plex.player.playPause")} onClick={togglePlay}>
            {playing ? <Pause className="w-6 h-6" /> : <Play className="w-6 h-6" />}
          </Ctrl>
          <Ctrl label={t("plex.player.restart")} onClick={() => seekTo(0)}>
            <RotateCcw className="w-5 h-5" />
          </Ctrl>
          <Ctrl label={t("plex.player.stop")} onClick={onClose}>
            <Square className="w-5 h-5" />
          </Ctrl>
          {(detail?.kind === "episode" || queue) && (
            <>
              <Ctrl label={t("plex.player.prev")} onClick={() => go(prevId)} disabled={!prevId}>
                <SkipBack className="w-5 h-5" />
              </Ctrl>
              <Ctrl label={t("plex.player.next")} onClick={() => go(nextId)} disabled={!nextId}>
                <SkipForward className="w-5 h-5" />
              </Ctrl>
            </>
          )}

          <div className="flex items-center gap-1.5 group/vol">
            <Ctrl
              label={t("plex.player.mute")}
              onClick={() =>
                setMuted((m) => {
                  applyVolume(volume, !m);
                  return !m;
                })
              }
            >
              {muted || volume === 0 ? <VolumeX className="w-5 h-5" /> : <Volume2 className="w-5 h-5" />}
            </Ctrl>
            <input
              type="range"
              min={0}
              max={1}
              step={0.05}
              value={muted ? 0 : volume}
              onChange={(e) => {
                const nv = Number(e.target.value);
                setVolume(nv);
                setMuted(nv === 0);
                applyVolume(nv, false);
              }}
              className="w-20 accent-accent"
            />
          </div>

          <div className="text-xs tabular-nums text-white/90">
            {fmt(abs)} / {fmt(duration)}
            <span className="ml-1.5 text-white/55">-{fmt(Math.max(0, duration - abs))}</span>
          </div>

          <div className="ml-auto flex items-center gap-2">
            {detail && (detail.audio_streams.length > 1 || textSubs.length > 0) && (
              <div className="relative">
                <button
                  onClick={() => setMenuOpen((o) => !o)}
                  aria-label={t("plex.player.tracks")}
                  className={`p-1 hover:text-accent ${menuOpen ? "text-accent" : ""}`}
                >
                  <Settings className="w-5 h-5" />
                </button>
                {menuOpen && (
                  <div className="absolute bottom-full right-0 mb-2 w-60 max-h-72 overflow-auto rounded-lg border border-white/15 bg-black/95 p-1.5 text-sm">
                    {detail.audio_streams.length > 1 && (
                      <>
                        <div className="px-2 pt-1 pb-0.5 text-[11px] uppercase tracking-wide text-white/50">
                          {t("plex.player.audio")}
                        </div>
                        {detail.audio_streams.map((a) => (
                          <button
                            key={a.ord}
                            onClick={() => changeAudio(a.ord)}
                            className={`block w-full truncate rounded px-2 py-1 text-left hover:bg-white/10 ${
                              (audioOrd ?? 0) === a.ord ? "text-accent" : "text-white"
                            }`}
                          >
                            {a.label}
                          </button>
                        ))}
                      </>
                    )}
                    {textSubs.length > 0 && (
                      <>
                        <div className="px-2 pt-2 pb-0.5 text-[11px] uppercase tracking-wide text-white/50">
                          {t("plex.player.subtitles")}
                        </div>
                        <button
                          onClick={() => changeSubtitle(null)}
                          className={`block w-full rounded px-2 py-1 text-left hover:bg-white/10 ${
                            subOrd == null ? "text-accent" : "text-white"
                          }`}
                        >
                          {t("plex.player.subOff")}
                        </button>
                        {textSubs.map((s) => (
                          <button
                            key={s.ord}
                            onClick={() => changeSubtitle(s.ord)}
                            className={`block w-full truncate rounded px-2 py-1 text-left hover:bg-white/10 ${
                              subOrd === s.ord ? "text-accent" : "text-white"
                            }`}
                          >
                            {s.label}
                          </button>
                        ))}
                      </>
                    )}
                  </div>
                )}
              </div>
            )}
            <Ctrl label={t("plex.player.infoBtn")} onClick={() => setInfoOpen((v) => !v)}>
              <Info className="w-5 h-5" />
            </Ctrl>
            <Ctrl label={t("plex.player.help")} onClick={() => setHelpOpen((v) => !v)}>
              <Keyboard className="w-5 h-5" />
            </Ctrl>
            <Ctrl label={t("plex.player.download")} onClick={download}>
              <Download className="w-5 h-5" />
            </Ctrl>
            <Ctrl label={t("plex.player.fullscreen")} onClick={toggleFs}>
              {fs ? <Minimize className="w-5 h-5" /> : <Maximize className="w-5 h-5" />}
            </Ctrl>
          </div>
        </div>
      </div>

      {/* Keyboard-shortcut cheat sheet (toggle with "h" or the keyboard button). */}
      {helpOpen && (
        <div
          className="absolute inset-0 z-20 grid place-items-center bg-black/60 p-4"
          onClick={(e) => {
            e.stopPropagation();
            setHelpOpen(false);
          }}
        >
          <div
            className="w-[min(92vw,26rem)] rounded-2xl border border-white/15 bg-neutral-900/95 p-5 text-white shadow-2xl"
            onClick={(e) => e.stopPropagation()}
          >
            <div className="mb-4 flex items-center justify-between">
              <div className="flex items-center gap-2 font-semibold">
                <Keyboard className="w-5 h-5" /> {t("plex.player.help")}
              </div>
              <button
                onClick={() => setHelpOpen(false)}
                className="p-1 rounded-lg hover:bg-white/10"
                aria-label={t("plex.player.back")}
              >
                <X className="w-5 h-5" />
              </button>
            </div>
            <dl className="grid grid-cols-[auto_1fr] items-center gap-x-4 gap-y-2.5 text-sm">
              {(
                [
                  [["Space", "K"], "plex.player.keys.playPause"],
                  [["←", "→"], "plex.player.keys.seek"],
                  ...(detail?.kind === "episode"
                    ? [[["⇧ ←", "⇧ →"], "plex.player.keys.episode"] as [string[], string]]
                    : []),
                  [["↑", "↓"], "plex.player.keys.volume"],
                  [["M"], "plex.player.keys.mute"],
                  [["F"], "plex.player.keys.fullscreen"],
                  [["I"], "plex.player.keys.info"],
                  [["⌫", "Esc"], "plex.player.keys.back"],
                  [["H"], "plex.player.keys.help"],
                ] as [string[], string][]
              ).map(([keys, labelKey]) => (
                <Fragment key={labelKey}>
                  <dt className="flex flex-wrap gap-1">
                    {keys.map((k) => (
                      <kbd
                        key={k}
                        className="rounded bg-white/15 px-1.5 py-0.5 text-xs font-mono whitespace-nowrap"
                      >
                        {k}
                      </kbd>
                    ))}
                  </dt>
                  <dd className="text-white/80">{t(labelKey)}</dd>
                </Fragment>
              ))}
            </dl>
          </div>
        </div>
      )}

      {/* Rich info overlay (poster/cast/ratings) — "i" or the info button; video keeps playing. */}
      {infoOpen && detail && (
        <div
          className="absolute inset-0 z-20 grid place-items-center overflow-y-auto bg-black/70 p-4"
          onClick={(e) => {
            e.stopPropagation();
            setInfoOpen(false);
          }}
        >
          <div
            className="my-auto w-full max-w-3xl rounded-2xl border border-white/15 bg-neutral-900/95 shadow-2xl"
            onClick={(e) => e.stopPropagation()}
          >
            <Suspense fallback={null}>
              <PlexInfo detail={detail} variant="overlay" onClose={() => setInfoOpen(false)} />
            </Suspense>
          </div>
        </div>
      )}
    </div>
  );
}

// A control button with a tooltip that opens ABOVE it (so it never clips off the bottom of the
// viewport, unlike a native title tooltip on the bottom control bar).
function Ctrl({
  label,
  onClick,
  disabled,
  children,
}: {
  label: string;
  onClick: () => void;
  disabled?: boolean;
  children: ReactNode;
}) {
  return (
    <div className="relative flex group/ctrl">
      <button
        onClick={onClick}
        disabled={disabled}
        aria-label={label}
        className="p-1 hover:text-accent disabled:opacity-30"
      >
        {children}
      </button>
      <span className="pointer-events-none absolute bottom-full left-1/2 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-black/90 px-2 py-1 text-[11px] text-white opacity-0 transition group-hover/ctrl:opacity-100">
        {label}
      </span>
    </div>
  );
}
