import type { PlaybackPlan } from './shared';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link, useLocation, useSearchParams } from 'react-router';
import { Loader2, X } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { closeHref, episodeLabel, PLAY_PARAM, streamUrl, TICKS_PER_SECOND, ticksToSeconds } from './shared';
import { useJellyfinItem, usePlaybackPlan, usePlaystateReporter } from './useJellyfinData';
import { PlayerControls } from './PlayerControls';
import Hls from './vendor/hls.mjs';

// The player — a full-panel overlay opened by `?play=<id>`.
//
// THE ONE THING THAT MAKES THIS DIFFERENT FROM A NORMAL <video>: what the server hands back is not always a
// file. There are three transports, and `mode` names which one is in use rather than letting the difference
// leak out as mysterious behaviour:
//
//   file        — direct play or direct stream. A real byte-range resource; native seeking, `currentTime` is
//                 the position, and a scrub can follow the pointer.
//   hls-mse     — a transcode, driven by the vendored hls.js over Media Source Extensions. A real timeline
//                 too: Jellyfin's playlist is VOD and spans the whole runtime, so a scrub is a segment
//                 request and the server moves its ffmpeg to that point. Committed on release, because each
//                 intermediate position would be a real repositioning of that ffmpeg.
//   progressive — the fallback for a browser without MSE. One endless mp4 with no length and no ranges, so
//                 the browser CANNOT seek it: seeking means asking the server for a NEW stream at an offset,
//                 and the position is `startSeconds + currentTime`.
//
// `hasTimeline` is the single flag every other decision here reads.
//
// Safari plays an m3u8 natively and is deliberately NOT given one. Segment URIs inside the playlist are
// relative, so they inherit the playlist's path but not its `?token=` — every segment would come back 401,
// and Safari cannot set an Authorization header the way hls.js can. Where hls.js runs (including iOS 17.1+,
// via Managed Media Source) it is used; where it does not, progressive is the honest fallback.
//
// The controls are ours in every mode. Not for looks: progressive has no timeline the browser can render, and
// an audio-track or quality change is a re-negotiation with the server rather than something a <video> knows
// how to do. One control bar for all three is what keeps those from being three different players.
//
// NO SCRUBBER THUMBNAILS. They come from Jellyfin's trickplay tiles, and this server generates none
// (`item.Trickplay` is `{}`) — a hover preview would be a permanently empty box.

const PROGRESS_INTERVAL_MS = 10_000;
const CHROME_HIDE_MS = 2_500;
/** Manhattan pixels a pointer must travel before it counts as the user, rather than as a resting mouse. */
const WAKE_THRESHOLD_PX = 8;
const VOLUME_KEY = 'jellyfin:volume';

type Mode = 'file' | 'hls-mse' | 'progressive';

function transportFor(plan: PlaybackPlan): Mode {
  if (plan.seekable) return 'file';
  if (plan.hlsUrl && Hls.isSupported()) return 'hls-mse';
  return 'progressive';
}

function readStoredVolume(): { volume: number; muted: boolean } {
  try {
    const raw = localStorage.getItem(VOLUME_KEY);
    if (!raw) return { volume: 1, muted: false };
    const parsed = JSON.parse(raw) as { volume?: number; muted?: boolean };
    return { volume: Math.min(1, Math.max(0, parsed.volume ?? 1)), muted: !!parsed.muted };
  } catch {
    return { volume: 1, muted: false };
  }
}

/** How far the element has buffered past the point being played, flattened to one number for the bar. */
function bufferedAhead(video: HTMLVideoElement): number {
  const { buffered, currentTime } = video;
  for (let i = 0; i < buffered.length; i += 1) {
    if (buffered.start(i) <= currentTime && buffered.end(i) >= currentTime) return buffered.end(i);
  }
  return currentTime;
}

export const VideoPlayer = ({ id }: { id: string }) => {
  const { token } = useClient();
  const { pathname } = useLocation();
  const [params] = useSearchParams();
  const { data: item } = useJellyfinItem(id);
  const negotiate = usePlaybackPlan();
  const report = usePlaystateReporter();

  const shellRef = useRef<HTMLDivElement>(null);
  const videoRef = useRef<HTMLVideoElement>(null);
  const [plan, setPlan] = useState<PlaybackPlan | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [position, setPosition] = useState(0);
  const [bufferedTo, setBufferedTo] = useState(0);
  const [playing, setPlaying] = useState(false);

  const stored = useMemo(readStoredVolume, []);
  const [volume, setVolume] = useState(stored.volume);
  const [muted, setMuted] = useState(stored.muted);
  const [rate, setRate] = useState(1);
  const [subtitleIndex, setSubtitleIndex] = useState<number | null>(null);
  const [fullscreen, setFullscreen] = useState(false);
  const [chrome, setChrome] = useState(true);

  // Refs shadow the state the unmount cleanup needs. A cleanup closes over the values from the render that
  // registered it, and the last thing this component does — report where playback stopped — must use the
  // position at the moment of unmount, not the one from whenever the effect last ran.
  const planRef = useRef<PlaybackPlan | null>(null);
  const positionRef = useRef(0);
  const startedRef = useRef(false);
  const idRef = useRef(id);
  idRef.current = id;
  // React Query hands back a new mutation object each render, so this is the same story as `report` below:
  // keeping `open` stable is what keeps the start-once effect from re-running on every frame of playback.
  const negotiateRef = useRef(negotiate);
  negotiateRef.current = negotiate;
  // The chosen track and rung, kept in refs so `open` does not have to be rebuilt (and the start-once effect
  // re-run) every time one of them changes.
  const audioRef = useRef<number | null>(null);
  const bitrateRef = useRef<number | null>(null);
  // Where the pointer was when it last counted as movement — see `onPointerMove`.
  const pointerRef = useRef<{ x: number; y: number } | null>(null);

  const mode = useMemo(() => (plan ? transportFor(plan) : null), [plan]);
  const hasTimeline = mode !== null && mode !== 'progressive';
  const duration = plan?.runtimeSeconds ?? ticksToSeconds(item?.RunTimeTicks);
  const subtitle = plan?.subtitles.find((track) => track.index === subtitleIndex && track.url) ?? null;

  // `report` is a NEW function on every render — `useClient()` rebuilds its verbs each time — so it is held in
  // a ref rather than named as a dependency. This is not a micro-optimisation: an unstable `sendReport` made
  // the "report where playback stopped" cleanup below re-run on every render, which nulled `planRef` a few
  // milliseconds after the stream opened and turned every later report into a no-op. Nothing ever reached
  // Jellyfin, so nothing ever appeared in Continue Watching.
  const reportRef = useRef(report);
  reportRef.current = report;

  const sendReport = useCallback((action: 'playing' | 'progress' | 'stopped', isPaused = false) => {
    const current = planRef.current;
    if (!current) return;
    reportRef.current(action, {
      ItemId: idRef.current,
      PlaySessionId: current.playSessionId,
      MediaSourceId: current.mediaSourceId,
      PositionTicks: Math.max(0, Math.round(positionRef.current * TICKS_PER_SECOND)),
      IsPaused: isPaused,
      CanSeek: current.seekable,
    });
  }, []);

  /**
   * Open (or re-open) the stream. In progressive mode a seek is exactly this call; so is any audio-track or
   * quality change in every mode, because both are decided during negotiation and baked into the stream.
   */
  const open = useCallback(
    async (startSeconds: number) => {
      // Tell the server the previous session ended before starting another, or its ffmpeg keeps running and
      // Jellyfin shows two sessions playing the same thing.
      if (planRef.current) sendReport('stopped');
      setError(null);
      try {
        const next = await negotiateRef.current.mutateAsync({
          id: idRef.current,
          startSeconds,
          audioStreamIndex: audioRef.current ?? undefined,
          maxStreamingBitrate: bitrateRef.current ?? undefined,
        });
        planRef.current = next;
        positionRef.current = next.startSeconds;
        setPlan(next);
        setPosition(next.startSeconds);
        setBufferedTo(next.startSeconds);
      } catch (err) {
        setError((err as { message?: string } | null)?.message ?? 'Playback could not be started');
      }
    },
    [sendReport],
  );

  // Start once, at the stored resume point. Guarded by a ref rather than by the dependency list because
  // React runs effects twice in development and a second negotiation would start a second transcode.
  useEffect(() => {
    if (startedRef.current || !item) return;
    startedRef.current = true;
    void open(ticksToSeconds(item.UserData?.PlaybackPositionTicks));
  }, [item, open]);

  // hls.js owns the element's buffer in `hls-mse` mode, so the element gets no `src` at all — attaching one
  // alongside MSE is how you get two sources fighting over the same video.
  //
  // The credential goes on as a header rather than in the URL because hls.js fetches every segment itself:
  // segment URIs are RELATIVE to the playlist, so a `?token=` on the playlist would not survive onto them.
  // `xhrSetup` runs before hls.js opens the request and it skips its own `open` if one already happened,
  // which is what makes setting a header here possible at all.
  useEffect(() => {
    const video = videoRef.current;
    if (mode !== 'hls-mse' || !plan?.hlsUrl || !video) return;

    const hls = new Hls({
      startPosition: plan.startSeconds > 0 ? plan.startSeconds : -1,
      xhrSetup: (xhr, url) => {
        xhr.open('GET', url, true);
        if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
      },
    });

    // Non-fatal errors are hls.js's normal weather and it recovers from them itself. A fatal one is worth one
    // attempt at the documented recovery for its kind before giving the player back to the user as broken.
    hls.on(Hls.Events.ERROR, (_event, data) => {
      if (!data.fatal) return;
      if (data.type === Hls.ErrorTypes.NETWORK_ERROR) hls.startLoad();
      else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) hls.recoverMediaError();
      else setError('The stream stopped unexpectedly.');
    });

    hls.loadSource(`/api/jellyfin${plan.hlsUrl}`);
    hls.attachMedia(video);
    return () => hls.destroy();
  }, [mode, plan, token]);

  // The stop report, on the way out. Also the only place the server learns a transcode is no longer wanted.
  // This must run exactly once, at unmount: the cleanup nulls `planRef`, so anything that made it re-run
  // mid-playback would silently switch off every report that follows. `sendReport` is `useCallback(…, [])`
  // and never changes identity, which is what makes naming it here equivalent to an empty list — and is why
  // it has to stay that way. If it ever grows a dependency, this becomes the Continue Watching bug again.
  useEffect(
    () => () => {
      sendReport('stopped');
      planRef.current = null;
    },
    [sendReport],
  );

  useEffect(() => {
    if (!plan) return;
    const timer = setInterval(() => sendReport('progress', videoRef.current?.paused ?? false), PROGRESS_INTERVAL_MS);
    return () => clearInterval(timer);
  }, [plan, sendReport]);

  // Volume, mute and rate are element properties, not attributes — React will not set them from JSX, and they
  // have to be re-applied after the element remounts (which in progressive mode is every scrub).
  useEffect(() => {
    const video = videoRef.current;
    if (!video) return;
    video.volume = volume;
    video.muted = muted;
    video.playbackRate = rate;
  }, [volume, muted, rate, plan]);

  useEffect(() => {
    localStorage.setItem(VOLUME_KEY, JSON.stringify({ volume, muted }));
  }, [volume, muted]);

  // Only the selected subtitle is rendered as a <track>, so Jellyfin is asked to extract exactly one — and
  // `mode` has to be set imperatively because `default` only applies on the element's first parse.
  useEffect(() => {
    const video = videoRef.current;
    if (!video) return;
    for (let i = 0; i < video.textTracks.length; i += 1) {
      video.textTracks[i]!.mode = subtitle ? 'showing' : 'disabled';
    }
  }, [subtitle, plan]);

  useEffect(() => {
    const onChange = () => setFullscreen(document.fullscreenElement === shellRef.current);
    document.addEventListener('fullscreenchange', onChange);
    return () => document.removeEventListener('fullscreenchange', onChange);
  }, []);

  // Auto-hide, but never while paused — a paused player with no controls looks broken rather than clean.
  //
  // `position` must NOT be a dependency, however much it reads like activity. It advances ~4x a second from
  // onTimeUpdate, so naming it here re-ran this effect — and so cleared the pending timer — long before 2.5s
  // had passed. The chrome could then only hide while playback was stopped, which is to say never. Real
  // activity is `wake()`, which sets `chrome` back to true and restarts the timer through the dep below.
  useEffect(() => {
    if (!chrome || !playing) return;
    const timer = setTimeout(() => setChrome(false), CHROME_HIDE_MS);
    return () => clearTimeout(timer);
  }, [chrome, playing]);

  const seek = useCallback(
    (seconds: number) => {
      const video = videoRef.current;
      const clamped = Math.max(0, duration ? Math.min(seconds, duration - 1) : seconds);
      if (hasTimeline && video) {
        video.currentTime = clamped;
        return;
      }
      void open(clamped);
    },
    [duration, hasTimeline, open],
  );

  const togglePlay = useCallback(() => {
    const video = videoRef.current;
    if (!video) return;
    if (video.paused) void video.play().catch(() => undefined);
    else video.pause();
  }, []);

  const toggleFullscreen = useCallback(() => {
    // The container, not the <video> — fullscreening the element itself would take the controls with it into
    // a UA-drawn shell that has none of these buttons.
    if (document.fullscreenElement) void document.exitFullscreen().catch(() => undefined);
    else void shellRef.current?.requestFullscreen().catch(() => undefined);
  }, []);

  const changeAudio = (index: number) => {
    audioRef.current = index;
    void open(positionRef.current);
  };

  const changeQuality = (bitrate: number | null) => {
    bitrateRef.current = bitrate;
    void open(positionRef.current);
  };

  const wake = () => setChrome(true);

  /**
   * Wake on real movement only.
   *
   * A bare `pointermove` handler is too credulous to sit in front of a two-hour film: a mouse resting on a
   * desk still emits the odd one-pixel event, a bumped table emits a few, and a browser will synthesise a
   * zero-delta move of its own when the cursor style changes — which this player does every time it hides the
   * cursor. Any one of those read as "the user is here" and put the scrubber back for two and a half seconds,
   * minutes apart, with nobody having touched anything.
   *
   * The anchor only moves when a wake actually happens, so a slow deliberate drift still accumulates past the
   * threshold and wakes; it is jitter around a fixed point that never does.
   */
  const onPointerMove = (ev: React.PointerEvent) => {
    const last = pointerRef.current;
    if (last && Math.abs(ev.clientX - last.x) + Math.abs(ev.clientY - last.y) < WAKE_THRESHOLD_PX) return;
    pointerRef.current = { x: ev.clientX, y: ev.clientY };
    wake();
  };

  // Keyboard on the container rather than on window: the player is a panel inside a workspace, and stealing
  // space or the arrow keys from whatever else is on screen would be wrong. Autofocus puts it in reach.
  const onKeyDown = (ev: React.KeyboardEvent) => {
    const target = ev.target as HTMLElement;
    if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return;
    const bump = (delta: number) => setVolume((was) => Math.min(1, Math.max(0, was + delta)));
    const keys: Record<string, () => void> = {
      ' ': togglePlay,
      k: togglePlay,
      ArrowLeft: () => seek(positionRef.current - 10),
      j: () => seek(positionRef.current - 10),
      ArrowRight: () => seek(positionRef.current + 10),
      l: () => seek(positionRef.current + 10),
      ArrowUp: () => bump(0.05),
      ArrowDown: () => bump(-0.05),
      m: () => setMuted((was) => !was),
      f: toggleFullscreen,
      c: () => setSubtitleIndex((was) => (was !== null ? null : (plan?.subtitles.find((s) => s.url)?.index ?? null))),
    };
    const action = keys[ev.key];
    if (!action) return;
    ev.preventDefault();
    wake();
    action();
  };

  const onTimeUpdate = () => {
    const video = videoRef.current;
    if (!video || !plan) return;
    // Only progressive starts the element's clock at zero for a stream that begins mid-film.
    const offset = hasTimeline ? 0 : plan.startSeconds;
    positionRef.current = offset + video.currentTime;
    setPosition(offset + video.currentTime);
    setBufferedTo(offset + bufferedAhead(video));
  };

  // `src` for everything except MSE, where hls.js feeds the element instead. The key remounts the element when
  // the source genuinely changes — which in progressive mode is every scrub.
  const source = plan && mode !== 'hls-mse' ? streamUrl(plan.url, token) : null;

  const close = closeHref(pathname, params, PLAY_PARAM);
  const title = item ? episodeLabel(item) : '';
  const idle = playing && !chrome;

  return (
    <div
      ref={shellRef}
      tabIndex={-1}
      autoFocus
      onKeyDown={onKeyDown}
      onPointerMove={onPointerMove}
      onPointerDown={wake}
      className={`absolute inset-0 z-30 bg-black outline-none ${idle ? 'cursor-none' : ''}`}
    >
      {/* Chrome floats OVER the video rather than sitting beside it in a column, so that going fullscreen —
          which fullscreens this container — does not change the video's size or the layout around it. */}
      <div
        className={`absolute inset-x-0 top-0 z-10 flex items-center gap-2 bg-gradient-to-b from-black/80 to-transparent px-3 py-2 text-white transition-opacity ${
          idle ? 'pointer-events-none opacity-0' : 'opacity-100'
        }`}
      >
        <span className="min-w-0 flex-1 truncate text-xs font-medium">{title}</span>
        {plan && plan.playMethod !== 'DirectPlay' && (
          <span className="rounded bg-white/10 px-1.5 py-0.5 text-[10px] uppercase tracking-wide">
            {plan.playMethod === 'Transcode' ? 'transcoding' : 'remuxing'}
          </span>
        )}
        <Link
          to={close}
          aria-label="Close player"
          className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-white/10"
        >
          <X className="h-4 w-4" />
        </Link>
      </div>

      <div className="absolute inset-0 bg-black">
        {error ? (
          <div className="flex h-full flex-col items-center justify-center gap-1 p-6 text-center text-white">
            <p className="text-sm font-medium">This will not play</p>
            <p className="max-w-md text-xs text-white/60">{error}</p>
          </div>
        ) : plan ? (
          <video
            key={mode === 'hls-mse' ? (plan.hlsUrl ?? plan.url) : plan.url}
            ref={videoRef}
            src={source ?? undefined}
            className="h-full w-full"
            autoPlay
            playsInline
            onClick={togglePlay}
            onDoubleClick={toggleFullscreen}
            onTimeUpdate={onTimeUpdate}
            onProgress={onTimeUpdate}
            onPlay={() => {
              setPlaying(true);
              sendReport('playing');
            }}
            onPause={() => {
              setPlaying(false);
              setChrome(true);
              sendReport('progress', true);
            }}
            onEnded={() => {
              setPlaying(false);
              sendReport('stopped');
            }}
            // hls.js reports its own errors through the ERROR event, and sets a MediaError on the element for
            // ones it is already recovering from — so in MSE mode this handler would fire on the recoverable.
            onError={() => mode !== 'hls-mse' && setError('The stream stopped unexpectedly.')}
          >
            {/* A <track> cannot send headers, so this is the `?token=` form. Safe here in a way an m3u8 is
                not: it is one self-contained file with no relative children to lose the query string. */}
            {subtitle && (
              <track
                key={subtitle.index}
                kind="subtitles"
                src={streamUrl(subtitle.url!, token)}
                srcLang={subtitle.language ?? undefined}
                label={subtitle.label}
                default
              />
            )}
          </video>
        ) : (
          <div className="flex h-full items-center justify-center text-white/60">
            <Loader2 className="h-6 w-6 animate-spin" />
          </div>
        )}
      </div>

      {plan && !error && (
        <div
          className={`absolute inset-x-0 bottom-0 z-10 transition-opacity ${
            idle ? 'pointer-events-none opacity-0' : 'opacity-100'
          }`}
        >
          <PlayerControls
            playing={playing}
            position={position}
            duration={duration}
            bufferedTo={bufferedTo}
            liveScrub={mode === 'file'}
            volume={volume}
            muted={muted}
            rate={rate}
            fullscreen={fullscreen}
            audio={plan.audio}
            audioIndex={plan.audioStreamIndex}
            subtitles={plan.subtitles}
            subtitleIndex={subtitleIndex}
            qualityRungs={plan.qualityRungs}
            maxBitrate={plan.maxStreamingBitrate}
            busy={negotiate.isPending}
            onTogglePlay={togglePlay}
            onScrub={seek}
            onScrubCommit={seek}
            onSkip={(delta) => seek(positionRef.current + delta)}
            onVolume={(next) => {
              setVolume(next);
              setMuted(next === 0);
            }}
            onToggleMute={() => setMuted((was) => !was)}
            onRate={setRate}
            onAudio={changeAudio}
            onSubtitle={setSubtitleIndex}
            onQuality={changeQuality}
            onFullscreen={toggleFullscreen}
          />
        </div>
      )}
    </div>
  );
};
