import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, ArrowLeft, Check, CheckCheck, ChevronLeft, ChevronRight, ExternalLink, Repeat, Repeat1, Settings, Shuffle, SkipBack, SkipForward, Volume2, VolumeX, X } from "lucide-react";
import Avatar from "./Avatar";
import AddToPlaylist from "./AddToPlaylist";
import DownloadButton from "./DownloadButton";
import { api, type Video } from "../lib/api";
import { channelYouTubeUrl, formatDate, formatDuration, formatViews, relativeTime } from "../lib/format";
import { renderDescription } from "../lib/descriptionLinks";
import { useBackToClose } from "../lib/history";

// Experiment (branch experiment/inline-player): play the video in-app via the
// YouTube IFrame Player API (not a bare embed) so we can read playback position
// and resume where the user left off. The modal closes via the in-card Close
// button, the backdrop, or ESC (ESC only while focus is on our page, not inside
// the cross-origin player iframe — a browser security boundary we can't cross).

// How close to the end (seconds) counts as "finished" → auto-mark watched.
const FINISH_MARGIN = 10;

// Persistent playback settings (stored in users.preferences). Auto-advance = what plays when a
// video ends; loop = whether it repeats the current video ("one"), wraps the list at its ends
// ("all"), or neither ("off"). Both apply to any queued player (feed or playlist).
type AutoMode = "off" | "next" | "prev" | "random";
type LoopMode = "off" | "one" | "all";
const AUTO_MODES: AutoMode[] = ["off", "next", "prev", "random"];
const LOOP_MODES: LoopMode[] = ["off", "one", "all"];

// --- IFrame Player API loader (singleton) ---
let apiPromise: Promise<any> | null = null;
function loadYouTubeApi(): Promise<any> {
  if (apiPromise) return apiPromise;
  apiPromise = new Promise((resolve) => {
    const w = window as any;
    if (w.YT && w.YT.Player) return resolve(w.YT);
    const prev = w.onYouTubeIframeAPIReady;
    w.onYouTubeIframeAPIReady = () => {
      if (typeof prev === "function") prev();
      resolve(w.YT);
    };
    const tag = document.createElement("script");
    tag.src = "https://www.youtube.com/iframe_api";
    document.head.appendChild(tag);
  });
  return apiPromise;
}

export default function PlayerModal({
  video,
  startAt,
  queue: queueProp,
  startIndex,
  onClose,
  onState,
  onOpenChannel,
}: {
  video: Video;
  // Where to start the opened video: a number of seconds (0 = Restart), or null/undefined
  // to resume from the server-saved position carried on the video.
  startAt?: number | null;
  // Optional playback queue (e.g. a playlist). When present, the player advances through
  // it (auto-advance on end + prev/next), recreating per item to reuse the single-video
  // resume / auto-watch / progress logic.
  queue?: Video[];
  startIndex?: number;
  onClose: () => void;
  onState: (id: string, status: string) => void;
  // Open the active video's channel page in-app (closes the player first). The small external
  // icon next to the name keeps the open-on-YouTube behaviour.
  onOpenChannel?: (channelId: string, channelName?: string) => void;
}) {
  const { t, i18n } = useTranslation();
  const qc = useQueryClient();
  // Browser/mouse Back closes the player instead of leaving the page.
  useBackToClose(onClose);
  // Freeze the launch queue: the player steps through the SAME filtered + sorted list it was
  // opened with, for its whole session. The live feed refetches and reorders/drops items under
  // us — e.g. auto-marking each video watched near its end removes it from a filtered feed — which
  // would otherwise shrink the queue mid-playback and break prev/next + Loop "All" (it could never
  // wrap back to the first item once earlier items dropped out). Snapshot once at mount via
  // useState's lazy initializer and ignore later prop changes. Items that turn out gone/
  // non-embeddable still surface the existing playback-error overlay when reached.
  const [queue] = useState(() => queueProp);
  // Precise upload date shown inline after the relative time (e.g. "9 yr ago · 12 Jan 2017").
  const fullDate = (d: string | null | undefined) => formatDate(d, i18n.language);
  // Track the playing item by id (not a frozen index) so it survives the queue changing
  // under us — e.g. an item removed in another tab. The index is derived from the *live*
  // queue, so the N / M counter and prev/next neighbours stay correct without disrupting
  // playback of the current video.
  const [playingId, setPlayingId] = useState<string>(
    // A caller with an explicit index (playlist) starts there; otherwise locate the opened
    // `video` in the queue (the feed passes its whole list + the clicked video, no index).
    startIndex != null ? queue?.[startIndex]?.id ?? video.id : video.id
  );
  // Locate the playing item in the frozen launch queue → drives prev/next + the N/M counter.
  // Because the queue is frozen, the playing id always stays in it (unlike the old live queue,
  // where a just-watched item could drop out and snap playback to queue[0]).
  let index = queue ? queue.findIndex((v) => v.id === playingId) : 0;
  if (index < 0) index = 0;
  const active = queue && queue[index] ? queue[index] : video;
  const hasQueue = !!queue && queue.length > 1;
  // startAt only applies to the item we opened with; advanced items resume their own pos.
  const resumeAt =
    startAt != null && active.id === video.id ? startAt : active.position_seconds || 0;
  const mountRef = useRef<HTMLDivElement | null>(null);
  const playerRef = useRef<any>(null);
  const autoMarkedRef = useRef(false);
  // Bound consecutive unplayable items so an auto-advancing queue (esp. loop=all) can't spin over a
  // run of dead videos — after the cap we stop on the error overlay instead of skipping forever.
  const MAX_CONSECUTIVE_ERRORS = 3;
  const errorStreakRef = useRef(0);
  // Keyboard/wheel controls. We keep focus on the modal card (not the cross-origin player
  // iframe) so F / Space / Esc keep working until the user clicks into YouTube's native
  // controls; the interaction overlay below also stops the iframe stealing focus on a video
  // click. `stageRef` is the element we fullscreen (so the volume overlay stays visible).
  const cardRef = useRef<HTMLDivElement | null>(null);
  const stageRef = useRef<HTMLDivElement | null>(null);
  // The whole modal (backdrop + card) — the wheel-to-volume target, so scrolling anywhere over the
  // modal adjusts volume (not just the small player area).
  const dialogRef = useRef<HTMLDivElement | null>(null);
  const volTimerRef = useRef<number | undefined>(undefined);
  // Volume level to flash in the on-player overlay (null = hidden). Auto-fades after a moment.
  const [volumeUi, setVolumeUi] = useState<number | null>(null);
  // When the user interacts with YouTube's own controls (gear/seek/CC), focus moves into the
  // cross-origin player iframe. We detect that and drop the interaction overlay's pointer-events
  // so YouTube's settings menu — which expands DOWN over the video, past our overlay — is fully
  // navigable (previously the overlay ate clicks below the top items). Moving the pointer off the
  // video, or the window regaining focus, re-arms our click/scroll/keyboard shortcuts.
  const [nativeControls, setNativeControls] = useState(false);

  const focusModal = () => cardRef.current?.focus();
  const togglePlay = () => {
    const p = playerRef.current;
    if (!p || typeof p.getPlayerState !== "function") return;
    if (p.getPlayerState() === 1) p.pauseVideo?.(); // 1 === playing
    else p.playVideo?.();
  };
  const toggleFullscreen = () => {
    const el = stageRef.current;
    if (!el) return;
    if (document.fullscreenElement) document.exitFullscreen?.();
    else el.requestFullscreen?.();
  };
  const flashVolume = (level: number) => {
    setVolumeUi(level);
    window.clearTimeout(volTimerRef.current);
    volTimerRef.current = window.setTimeout(() => setVolumeUi(null), 1200);
  };
  const nudgeVolume = (delta: number) => {
    const p = playerRef.current;
    if (!p || typeof p.getVolume !== "function") return;
    const cur = p.isMuted?.() ? 0 : p.getVolume?.() ?? 50;
    const next = Math.max(0, Math.min(100, Math.round(cur + delta)));
    if (next > 0) p.unMute?.();
    p.setVolume?.(next);
    if (next === 0) p.mute?.();
    flashVolume(next);
  };

  // The player can navigate to other videos (YouTube links in a description). The
  // currently-playing id may differ from the active item.
  const [currentVideoId, setCurrentVideoId] = useState(active.id);
  const currentIdRef = useRef(active.id);
  const navigated = currentVideoId !== active.id;
  // Title/author of a navigated-to video, read from the player (free, no API call).
  const [liveData, setLiveData] = useState<{ title?: string; author?: string } | null>(null);
  // IFrame Player API error code, when the current video can't play in the embed (e.g. an
  // auto-generated Topic "Art Track" with embedding disabled → 101/150, or removed/private →
  // 100). We overlay a friendly message + "Open on YouTube" instead of YouTube's bare error.
  const [playerError, setPlayerError] = useState<number | null>(null);

  const loadVideo = (id: string, start: number | null) => {
    const p = playerRef.current;
    if (!p || typeof p.loadVideoById !== "function") return;
    // Explicit start wins; otherwise resume the active item, start others at 0.
    const startSeconds = start != null ? start : id === active.id ? resumeAt : 0;
    setPlayerError(null);
    p.loadVideoById({ videoId: id, startSeconds: startSeconds || 0 });
    currentIdRef.current = id;
    setCurrentVideoId(id);
    setLiveData(null);
  };

  // Local mirror of watch status so the toggle reacts instantly; changes are
  // propagated to the feed (and server) via onState.
  const [status, setStatus] = useState(active.status);
  const watched = status === "watched";
  const setWatched = (on: boolean) => {
    const next = on ? "watched" : "new";
    setStatus(next);
    onState(active.id, next);
  };

  // On queue advance (active changes), sync local state to the new item.
  useEffect(() => {
    setStatus(active.status);
    setCurrentVideoId(active.id);
    currentIdRef.current = active.id;
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [active.id]);

  const seekTo = (seconds: number) => {
    const p = playerRef.current;
    if (p && typeof p.seekTo === "function") {
      p.seekTo(seconds, true);
      p.playVideo?.();
    }
  };

  // Prev/next stepping through the queue (the feed's order, or a playlist). Read the live
  // queue+index from a ref so the window keydown handler (bound once) always steps from the
  // current position, not a stale closure.
  const navRef = useRef({ queue, index });
  navRef.current = { queue, index };
  const goPrev = () => {
    const { queue: q, index: i } = navRef.current;
    if (q && i > 0) setPlayingId(q[i - 1].id);
  };
  const goNext = () => {
    const { queue: q, index: i } = navRef.current;
    if (q && i < q.length - 1) setPlayingId(q[i + 1].id);
  };
  // Relative seek within the current video (plain Arrow keys), matching YouTube's ±5s.
  const nudgeSeek = (delta: number) => {
    const p = playerRef.current;
    if (!p || typeof p.getCurrentTime !== "function") return;
    p.seekTo(Math.max(0, (p.getCurrentTime() || 0) + delta), true);
  };

  // Auto-advance + loop are persistent per-user settings. Read the current values from the cached
  // `me`, mirror them in local state for instant UI, and write both to the server + cache on change
  // so they apply to every player and survive reloads. A ref feeds the (once-bound) end handler.
  const savedPrefs = useMemo(
    () =>
      (qc.getQueryData<{ preferences?: Record<string, unknown> }>(["me"])?.preferences ?? {}) as {
        playerAutoAdvance?: AutoMode;
        playerLoop?: LoopMode;
      },
    [qc]
  );
  const [autoMode, setAutoMode] = useState<AutoMode>(savedPrefs.playerAutoAdvance ?? "off");
  const [loopMode, setLoopMode] = useState<LoopMode>(savedPrefs.playerLoop ?? "off");
  const modeRef = useRef({ autoMode, loopMode });
  modeRef.current = { autoMode, loopMode };
  const persistPref = (patch: Record<string, string>) => {
    api.savePrefs(patch).catch(() => {});
    qc.setQueryData<{ preferences?: Record<string, unknown> } | undefined>(["me"], (m) =>
      m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m
    );
  };
  const cycleAuto = () => {
    const next = AUTO_MODES[(AUTO_MODES.indexOf(autoMode) + 1) % AUTO_MODES.length];
    setAutoMode(next);
    persistPref({ playerAutoAdvance: next });
  };
  const cycleLoop = () => {
    const next = LOOP_MODES[(LOOP_MODES.indexOf(loopMode) + 1) % LOOP_MODES.length];
    setLoopMode(next);
    persistPref({ playerLoop: next });
  };
  const replayCurrent = () => {
    const p = playerRef.current;
    if (p && typeof p.seekTo === "function") {
      p.seekTo(0, true);
      p.playVideo?.();
    }
  };
  // Run when the current video ends, per the saved settings. Loop "one" repeats it; otherwise
  // advance in the chosen direction, wrapping at the ends only when loop is "all" (a single-item
  // list repeats). Uses the live queue + settings via refs so it's correct from the bound handler.
  const advanceOnEnd = () => {
    const { autoMode: a, loopMode: l } = modeRef.current;
    if (l === "one") return replayCurrent();
    const { queue: q, index: i } = navRef.current;
    if (!q || q.length === 0) return;
    const wrap = l === "all";
    if (a === "next") {
      if (i < q.length - 1) setPlayingId(q[i + 1].id);
      else if (wrap) {
        if (q.length === 1) replayCurrent();
        else setPlayingId(q[0].id);
      }
    } else if (a === "prev") {
      if (i > 0) setPlayingId(q[i - 1].id);
      else if (wrap) {
        if (q.length === 1) replayCurrent();
        else setPlayingId(q[q.length - 1].id);
      }
    } else if (a === "random" && q.length > 1) {
      let r = i;
      while (r === i) r = Math.floor(Math.random() * q.length);
      setPlayingId(q[r].id);
    } else if (a === "random" && wrap) {
      replayCurrent();
    }
  };

  // Lazy description (fetched only when the title is hovered). The popover is
  // portaled to <body> with fixed positioning so the modal card's overflow-y-auto
  // can't clip it. A small close grace lets the mouse travel title → popover.
  const [showDesc, setShowDesc] = useState(false);
  // `bottom` anchors the popover just above the title so it grows upward (over the
  // player) instead of downward off-screen / behind the OS taskbar.
  const [descRect, setDescRect] = useState<{ left: number; bottom: number; width: number } | null>(
    null
  );
  const titleRef = useRef<HTMLSpanElement | null>(null);
  const closeTimer = useRef<number | undefined>(undefined);
  const openTimer = useRef<number | undefined>(undefined);
  const openDescNow = () => {
    window.clearTimeout(closeTimer.current);
    window.clearTimeout(openTimer.current);
    const el = titleRef.current;
    if (el) {
      const r = el.getBoundingClientRect();
      const width = Math.min(560, window.innerWidth - 32);
      const left = Math.max(16, Math.min(r.left, window.innerWidth - width - 16));
      setDescRect({ left, bottom: window.innerHeight - r.top + 8, width });
    }
    setShowDesc(true);
  };
  // Hover-intent: only pop the description if the pointer lingers on the title (~400ms), so a
  // quick pass over it doesn't flash the popover.
  const scheduleOpenDesc = () => {
    window.clearTimeout(closeTimer.current);
    window.clearTimeout(openTimer.current);
    openTimer.current = window.setTimeout(openDescNow, 400);
  };
  const scheduleCloseDesc = () => {
    window.clearTimeout(openTimer.current);
    closeTimer.current = window.setTimeout(() => setShowDesc(false), 150);
  };
  const detail = useQuery({
    queryKey: ["video-detail", currentVideoId],
    queryFn: () => api.videoDetail(currentVideoId),
    // On hover (for the description) and eagerly when navigated, so we can link the
    // linked video's channel. Both share the one cached result.
    enabled: showDesc || navigated,
    staleTime: 5 * 60_000,
  });

  // Keyboard shortcuts (Esc close / F fullscreen / Space play-pause) + background scroll lock.
  // These fire only while focus is on our page, not inside the cross-origin player iframe — so
  // we focus the modal card on open and again on player-ready (autoplay can grab focus).
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") {
        // In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal.
        if (document.fullscreenElement) return;
        onClose();
        return;
      }
      const el = document.activeElement as HTMLElement | null;
      const tag = (el?.tagName || "").toLowerCase();
      const typing = tag === "input" || tag === "textarea" || tag === "select" || !!el?.isContentEditable;
      if (e.key === "f" || e.key === "F") {
        if (typing) return;
        e.preventDefault();
        toggleFullscreen();
      } else if (e.key === " " || e.code === "Space") {
        // Let Space activate a focused button/link; otherwise toggle playback (and stop the
        // page from scrolling).
        if (typing || tag === "button" || tag === "a") return;
        e.preventDefault();
        togglePlay();
      } else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
        // Plain arrows seek within the video (YouTube-style ±5s); Shift+arrow steps to the
        // previous/next video in the queue (the feed's order or a playlist).
        if (typing) return;
        e.preventDefault();
        const forward = e.key === "ArrowRight";
        if (e.shiftKey) {
          if (forward) goNext();
          else goPrev();
        } else {
          nudgeSeek(forward ? 5 : -5);
        }
      }
    };
    window.addEventListener("keydown", onKey);
    focusModal();
    const prevOverflow = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    return () => {
      window.removeEventListener("keydown", onKey);
      document.body.style.overflow = prevOverflow;
      window.clearTimeout(closeTimer.current);
      window.clearTimeout(openTimer.current);
      window.clearTimeout(volTimerRef.current);
    };
  }, [onClose]);

  // Mouse-wheel volume over the WHOLE modal (backdrop + card), not just the small player area.
  // Non-passive so preventDefault stops the modal/page scrolling. Note: the cross-origin iframe
  // swallows wheel events over its own native-control strips, but the central interaction overlay
  // is our element (wheel there bubbles here), and everything outside the video works directly.
  useEffect(() => {
    const el = dialogRef.current;
    if (!el) return;
    const onWheel = (e: WheelEvent) => {
      e.preventDefault();
      focusModal(); // re-grab focus if it had slipped into the native controls
      nudgeVolume(e.deltaY < 0 ? 5 : -5);
    };
    el.addEventListener("wheel", onWheel, { passive: false });
    return () => el.removeEventListener("wheel", onWheel);
  }, []);

  // Yield the interaction overlay to YouTube's native UI. Clicking a native control (gear / seek
  // bar / CC) moves focus into the player iframe — the only cross-origin signal we get. While the
  // iframe holds focus we drop the overlay's pointer-events so the (arbitrarily tall) settings
  // menu is clickable end to end. Re-arm — restoring click-to-play, wheel volume and our keyboard
  // shortcuts — when the pointer leaves the video or the window regains focus (a click back on our
  // card blurs the iframe → the parent window fires 'focus').
  useEffect(() => {
    const iframeFocused = () => {
      const p = playerRef.current;
      const f = p && typeof p.getIframe === "function" ? p.getIframe() : null;
      return !!f && document.activeElement === f;
    };
    // activeElement settles on the tick after blur, so defer the check.
    const onBlur = () => window.setTimeout(() => iframeFocused() && setNativeControls(true), 0);
    const rearm = () => {
      setNativeControls(false);
      focusModal();
    };
    const stage = stageRef.current;
    window.addEventListener("blur", onBlur);
    window.addEventListener("focus", rearm);
    stage?.addEventListener("mouseleave", rearm);
    return () => {
      window.removeEventListener("blur", onBlur);
      window.removeEventListener("focus", rearm);
      stage?.removeEventListener("mouseleave", rearm);
    };
  }, []);

  // Create the player, resume from the saved position, persist progress, and
  // auto-mark watched once playback reaches the end.
  useEffect(() => {
    let cancelled = false;
    const id = active.id;
    autoMarkedRef.current = false;
    setPlayerError(null);

    // Auto-watch only applies to the active item — not to other videos the player
    // navigated to via description links.
    const maybeAutoWatch = (current: number, duration: number): boolean => {
      if (autoMarkedRef.current || duration <= 0 || currentIdRef.current !== id) return false;
      // Cap the finish margin at half the clip, so a video shorter than FINISH_MARGIN isn't
      // auto-marked watched almost immediately (its threshold would otherwise go negative).
      const margin = Math.min(FINISH_MARGIN, duration / 2);
      if (current > duration - margin) {
        autoMarkedRef.current = true;
        setWatched(true);
        return true;
      }
      return false;
    };
    const persist = (): Promise<unknown> | void => {
      const p = playerRef.current;
      if (!p || typeof p.getCurrentTime !== "function") return;
      try {
        const cur = p.getCurrentTime();
        const dur = typeof p.getDuration === "function" ? p.getDuration() : 0;
        // If we just auto-marked watched, skip the near-end progress checkpoint: it would
        // only clear the resume position, and firing both at once needlessly races the
        // watched write against the progress row it deletes.
        if (maybeAutoWatch(cur, dur)) return;
        // Only checkpoint the feed video we opened with — a navigated-to video may not
        // be in this user's library, so the server would 404 on it.
        if (currentIdRef.current === id) {
          return api.saveProgress(id, cur, dur).catch(() => {});
        }
      } catch {
        /* player may be tearing down */
      }
    };

    loadYouTubeApi().then((YT) => {
      if (cancelled || !mountRef.current) return;
      playerRef.current = new YT.Player(mountRef.current, {
        width: "100%",
        height: "100%",
        videoId: id,
        playerVars: {
          autoplay: 1,
          start: resumeAt || undefined,
          rel: 0, // limit "related" to the same channel (full removal is no longer possible)
          enablejsapi: 1,
          origin: window.location.origin,
          playsinline: 1,
          // Best-effort HD hint. The IFrame API's setPlaybackQuality/suggestedQuality are hard no-ops
          // now (YouTube removed them), and the undocumented `vq` URL param is only occasionally
          // honoured — kept because it's harmless. NOTE: YouTube hard-caps an embed's max quality by
          // the player's on-screen size; a windowed embed is stuck ~360p and only true fullscreen
          // lifts the cap (after which the higher pick persists for the session). We can't beat that
          // programmatically — a CSS transform-scale trick was tried and does NOT fool the cap.
          vq: "hd1080",
        },
        events: {
          // Keep keyboard focus on the modal, not the iframe, so shortcuts work right away.
          onReady: () => focusModal(),
          onStateChange: (e: any) => {
            // 1 === playing → sync the navigated-to video's title/author for display.
            if (e?.data === 1) {
              errorStreakRef.current = 0; // a successful play breaks any unplayable run
              const p = playerRef.current;
              const d = p && typeof p.getVideoData === "function" ? p.getVideoData() : null;
              if (d) setLiveData({ title: d.title, author: d.author });
            }
            // 0 === ended → mark watched, then advance to the next queue item if any.
            if (e?.data === 0 && currentIdRef.current === id) {
              if (!autoMarkedRef.current) {
                autoMarkedRef.current = true;
                setWatched(true);
              }
              advanceOnEnd();
            }
          },
          // The embed couldn't play this video (embedding disabled, removed, private…).
          // Surface our own message + an "Open on YouTube" escape hatch.
          onError: (e: any) => {
            setPlayerError(typeof e?.data === "number" ? e.data : -1);
            // The IFrame API fires onError (not 'ended') for an unplayable item, so advanceOnEnd()
            // never runs and an auto-advancing queue would freeze here. Skip forward instead — but
            // bounded, so a run of dead items (or loop=all over a broken queue) can't spin. We do
            // NOT mark the broken video watched (only the ended-handler does that).
            if (currentIdRef.current !== id) return; // a navigated description-link error: leave the queue alone
            const { autoMode: a } = modeRef.current;
            const { queue: q } = navRef.current;
            if (a === "off" || !q || q.length <= 1) return;
            errorStreakRef.current += 1;
            if (errorStreakRef.current >= MAX_CONSECUTIVE_ERRORS) return; // give up; leave the overlay
            advanceOnEnd();
          },
        },
      });
    });

    // Periodic checkpoint so progress (and auto-watch) survive a crash/refresh.
    const timer = window.setInterval(persist, 5000);
    // Save on page unload too (F5/close/navigate): effect cleanup doesn't run on a full reload, so
    // without this the resume point lags up to 5s (a seek right before F5 would be lost). A keepalive
    // beacon so the POST survives the unload. Only for the library item we opened with — a navigated
    // description-link video may not be in this user's library (the server would 404).
    const onHide = () => {
      const p = playerRef.current;
      if (!p || typeof p.getCurrentTime !== "function" || currentIdRef.current !== id) return;
      try {
        const cur = p.getCurrentTime();
        const dur = typeof p.getDuration === "function" ? p.getDuration() : 0;
        if (cur > 0 && dur > 0) api.saveProgressBeacon(id, cur, dur);
      } catch {
        /* player tearing down */
      }
    };
    window.addEventListener("pagehide", onHide);

    return () => {
      cancelled = true;
      window.clearInterval(timer);
      window.removeEventListener("pagehide", onHide);
      // Final flush, then refresh the feed + playlists so resume bars reflect this session.
      Promise.resolve(persist()).finally(() => {
        qc.invalidateQueries({ queryKey: ["feed"] });
        qc.invalidateQueries({ queryKey: ["playlist"] });
      });
      const p = playerRef.current;
      if (p && typeof p.destroy === "function") {
        try {
          p.destroy();
        } catch {
          /* ignore */
        }
      }
      playerRef.current = null;
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [active.id]);

  return (
    <div
      ref={dialogRef}
      className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 bg-black/80 backdrop-blur-sm"
      onClick={onClose}
      role="dialog"
      aria-modal="true"
    >
      {/* The card, flanked by full-height glassy strips that step through the feed's order (or a
          playlist). Each strip sits just outside the card, spans its full height, and fades out at
          the ends. stopPropagation so a click steps instead of closing. Hidden on narrow screens
          where there's no room beside the card. */}
      <div className="relative flex w-full max-w-6xl max-h-full">
        {hasQueue && (
          <button
            onClick={(e) => {
              e.stopPropagation();
              goPrev();
            }}
            disabled={index === 0}
            aria-label={t("player.previous")}
            title={`${t("player.previous")} · Shift+←`}
            className="absolute right-full top-0 bottom-0 mr-2 hidden w-12 place-items-center rounded-2xl bg-white/5 text-white/40 backdrop-blur-sm transition hover:bg-white/15 hover:text-white disabled:pointer-events-none disabled:opacity-0 sm:grid"
          >
            <ChevronLeft className="h-8 w-8" />
          </button>
        )}
      <div
        ref={cardRef}
        tabIndex={-1}
        className="glass-card relative flex-1 min-w-0 max-h-full overflow-y-auto rounded-2xl shadow-2xl outline-none"
        onClick={(e) => e.stopPropagation()}
      >
        <div
          ref={stageRef}
          className="player-stage relative aspect-video w-full bg-black rounded-t-2xl overflow-hidden"
        >
          {/* Hide the iframe entirely on error so YouTube's own error screen can't bleed
              through our overlay. */}
          <div ref={mountRef} className={`w-full h-full ${playerError != null ? "invisible" : ""}`} />
          {/* Interaction layer over the CENTRE of the video: catches click (play/pause) and stops
              the iframe stealing keyboard focus. It deliberately leaves the top AND bottom edges
              uncovered so YouTube's native controls — the top-right cluster (volume / CC / settings)
              and the bottom bar (seek / More videos / fullscreen) — stay clickable. Hidden on error
              so the "Open on YouTube" CTA is clickable. */}
          {playerError == null && (
            <div
              onClick={() => {
                focusModal();
                togglePlay();
              }}
              title={t("player.shortcutsHint")}
              aria-label={t("player.shortcutsHint")}
              className={`absolute inset-x-0 top-[12%] bottom-[22%] z-10 cursor-pointer ${
                nativeControls ? "pointer-events-none" : ""
              }`}
            />
          )}
          {/* Discreet badge while YouTube's own controls have taken over (overlay yielded). */}
          {playerError == null && nativeControls && (
            <div className="pointer-events-none absolute top-3 left-3 z-20 flex items-center gap-1.5 rounded-full bg-black/60 px-2.5 py-1 text-[11px] text-white/90 shadow-lg backdrop-blur-sm">
              <Settings className="h-3.5 w-3.5" />
              {t("player.nativeControls")}
            </div>
          )}
          {/* Volume level flash (auto-fades). pointer-events-none so it never blocks the video. */}
          {volumeUi != null && (
            <div className="pointer-events-none absolute top-3 left-1/2 -translate-x-1/2 z-20 flex items-center gap-2 rounded-full bg-black/70 px-3 py-1.5 text-xs text-white shadow-lg">
              {volumeUi === 0 ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
              <div className="h-1.5 w-28 overflow-hidden rounded-full bg-white/25">
                <div className="h-full rounded-full bg-white transition-all" style={{ width: `${volumeUi}%` }} />
              </div>
              <span className="w-7 text-right tabular-nums">{volumeUi}</span>
            </div>
          )}
          {playerError != null && (
            <div className="absolute inset-0 grid place-items-center bg-bg p-6 text-center">
              <div className="max-w-sm">
                <AlertTriangle className="w-8 h-8 text-amber-500 mx-auto mb-3" />
                <h3 className="text-base font-semibold">{t("player.unavailableTitle")}</h3>
                <p className="text-sm text-muted mt-1.5 mb-4">
                  {playerError === 101 || playerError === 150
                    ? t("player.embedDisabledBody")
                    : t("player.unavailableBody")}
                </p>
                <a
                  href={`https://www.youtube.com/watch?v=${currentVideoId}`}
                  target="_blank"
                  rel="noreferrer"
                  className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
                >
                  <ExternalLink className="w-4 h-4" />
                  {t("player.openOnYoutube")}
                </a>
              </div>
            </div>
          )}
        </div>

        {hasQueue && (
          <div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1.5 px-4 py-2 border-b border-border text-sm">
            <button
              onClick={goPrev}
              disabled={index === 0}
              title={`${t("player.previous")} · Shift+←`}
              className="inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition"
            >
              <SkipBack className="w-4 h-4" /> {t("player.previous")}
            </button>
            <span className="text-muted tabular-nums">
              {index + 1} / {queue!.length}
            </span>
            <button
              onClick={goNext}
              disabled={index === queue!.length - 1}
              title={`${t("player.next")} · Shift+→`}
              className="inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition"
            >
              {t("player.next")} <SkipForward className="w-4 h-4" />
            </button>
            <span className="w-px h-4 bg-border" />
            {/* Persistent playback settings — cycle on click, saved to your account. */}
            <button
              onClick={cycleAuto}
              title={t("player.autoAdvance.hint")}
              className={`inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 transition ${
                autoMode !== "off" ? "bg-accent/15 text-accent" : "text-muted hover:bg-surface hover:text-fg"
              }`}
            >
              {autoMode === "prev" ? (
                <SkipBack className="h-3.5 w-3.5" />
              ) : autoMode === "random" ? (
                <Shuffle className="h-3.5 w-3.5" />
              ) : (
                <SkipForward className="h-3.5 w-3.5" />
              )}
              {t("player.autoAdvance.label")}: {t(`player.autoAdvance.${autoMode}`)}
            </button>
            <button
              onClick={cycleLoop}
              title={t("player.loop.hint")}
              className={`inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 transition ${
                loopMode !== "off" ? "bg-accent/15 text-accent" : "text-muted hover:bg-surface hover:text-fg"
              }`}
            >
              {loopMode === "one" ? <Repeat1 className="h-3.5 w-3.5" /> : <Repeat className="h-3.5 w-3.5" />}
              {t("player.loop.label")}: {t(`player.loop.${loopMode}`)}
            </button>
          </div>
        )}

        <div className="p-4 sm:p-5">
          {/* Title row — title (with hover description) on the left, Close on the right. */}
          <div className="flex items-start gap-3">
            <h2 className="min-w-0 flex-1 text-lg font-semibold leading-snug">
              {/* Hover target is the text itself (inline), not the whole row. */}
              <span
                ref={titleRef}
                className="cursor-default"
                onMouseEnter={scheduleOpenDesc}
                onMouseLeave={scheduleCloseDesc}
              >
                {navigated ? liveData?.title ?? t("player.loading") : active.title}
              </span>
            </h2>
            {navigated && (
              <button
                onClick={() => loadVideo(active.id, null)}
                title={t("player.backToOriginal")}
                className="shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition"
              >
                <ArrowLeft className="w-4 h-4" />
                {t("player.back")}
              </button>
            )}
            {showDesc &&
              descRect &&
              createPortal(
                <div
                  className="fixed z-[60] bg-surface border border-border rounded-xl shadow-2xl p-4"
                  style={{
                    left: descRect.left,
                    bottom: descRect.bottom,
                    width: descRect.width,
                  }}
                  onMouseEnter={openDescNow}
                  onMouseLeave={scheduleCloseDesc}
                >
                  <div className="text-xs uppercase tracking-wide text-muted mb-2">
                    {t("player.description")}
                  </div>
                  {detail.isLoading ? (
                    <div className="text-sm text-muted">{t("player.loading")}</div>
                  ) : detail.data?.description ? (
                    <div className="text-sm whitespace-pre-wrap break-words max-h-64 overflow-y-auto leading-relaxed">
                      {renderDescription(detail.data.description, {
                        currentId: currentVideoId,
                        onSeek: seekTo,
                        onLoadVideo: loadVideo,
                        t,
                      })}
                    </div>
                  ) : (
                    <div className="text-sm text-muted">{t("player.noDescription")}</div>
                  )}
                </div>,
                document.body
              )}
            <button
              onClick={onClose}
              title={t("player.closeEsc")}
              className="shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition"
            >
              {t("player.close")}
              <X className="w-4 h-4" />
            </button>
          </div>

          {/* Channel + meta on one line, with the watched toggle pushed to the right.
              When navigated to a linked video we only have its author (from the player),
              so we show that as plain text and hide feed-video-specific bits. */}
          <div className="flex items-center gap-3 mt-3">
            {!navigated && (
              <Avatar
                src={active.channel_thumbnail}
                fallback={active.channel_title ?? ""}
                className="w-9 h-9 rounded-full shrink-0"
              />
            )}
            {navigated ? (
              detail.data?.channel_id ? (
                <a
                  href={channelYouTubeUrl(detail.data.channel_id)}
                  target="_blank"
                  rel="noreferrer"
                  className="font-medium hover:text-accent shrink-0"
                >
                  {liveData?.author ?? detail.data.channel_title ?? t("player.channel")}
                </a>
              ) : (
                <span className="font-medium shrink-0">{liveData?.author ?? ""}</span>
              )
            ) : (
              <div className="flex items-center gap-1.5 shrink-0 min-w-0">
                <button
                  onClick={() => {
                    if (!onOpenChannel) return onClose();
                    const cid = active.channel_id;
                    const cname = active.channel_title ?? undefined;
                    // Closing the player pops its own history entry (useBackToClose → history.back()).
                    // Open the channel only AFTER that pop's popstate — pushing the channel entry
                    // before it would let the back remove it again (the bug: clicking the name only
                    // closed the player). One-shot listener fires after App's popstate handler.
                    const open = () => {
                      window.removeEventListener("popstate", open);
                      onOpenChannel(cid, cname);
                    };
                    window.addEventListener("popstate", open);
                    onClose();
                  }}
                  className="font-medium hover:text-accent truncate text-left"
                >
                  {active.channel_title}
                </button>
                <a
                  href={active.channel_url}
                  target="_blank"
                  rel="noreferrer"
                  title={t("channels.row.openOnYouTube")}
                  aria-label={t("channels.row.openOnYouTube")}
                  className="text-muted hover:text-accent shrink-0"
                >
                  <ExternalLink className="w-3.5 h-3.5" />
                </a>
              </div>
            )}
            {!navigated ? (
              <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0">
                {active.view_count != null && (
                  <span>· {t("player.views", { count: active.view_count, formattedCount: formatViews(active.view_count) })}</span>
                )}
                <span>
                  · {relativeTime(active.published_at)}
                  {active.published_at && ` · ${fullDate(active.published_at)}`}
                </span>
                {active.duration_seconds != null && (
                  <span>· {formatDuration(active.duration_seconds)}</span>
                )}
                {active.live_status === "was_live" && (
                  <span className="bg-accent text-accent-fg text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded">
                    {t("player.stream")}
                  </span>
                )}
              </div>
            ) : (
              // Linked video's stats come from the (already-fetched) video detail.
              <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0">
                {detail.data?.view_count != null && (
                  <span>· {t("player.views", { count: detail.data.view_count, formattedCount: formatViews(detail.data.view_count) })}</span>
                )}
                {detail.data?.published_at && (
                  <span>
                    · {relativeTime(detail.data.published_at)} · {fullDate(detail.data.published_at)}
                  </span>
                )}
                {detail.data?.duration_seconds != null && (
                  <span>· {formatDuration(detail.data.duration_seconds)}</span>
                )}
              </div>
            )}

            {!navigated && (
              <AddToPlaylist
                videoId={active.id}
                className="ml-auto shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
              />
            )}
            {!navigated && (
              <DownloadButton
                videoId={active.id}
                title={active.title}
                className="shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
              />
            )}
            {!navigated && (
              <button
                onClick={() => setWatched(!watched)}
                title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
                className={
                  "shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg transition " +
                  (watched
                    ? "bg-accent text-accent-fg shadow-sm hover:opacity-90"
                    : "text-muted hover:text-fg hover:bg-surface border border-border")
                }
              >
                {watched ? <CheckCheck className="w-4 h-4" /> : <Check className="w-4 h-4" />}
                {watched ? t("player.watched") : t("player.markWatched")}
              </button>
            )}
          </div>
        </div>
      </div>
        {hasQueue && (
          <button
            onClick={(e) => {
              e.stopPropagation();
              goNext();
            }}
            disabled={index >= queue!.length - 1}
            aria-label={t("player.next")}
            title={`${t("player.next")} · Shift+→`}
            className="absolute left-full top-0 bottom-0 ml-2 hidden w-12 place-items-center rounded-2xl bg-white/5 text-white/40 backdrop-blur-sm transition hover:bg-white/15 hover:text-white disabled:pointer-events-none disabled:opacity-0 sm:grid"
          >
            <ChevronRight className="h-8 w-8" />
          </button>
        )}
      </div>
    </div>
  );
}
