import { useCallback, useEffect, useRef, useState } from "react";
import {
  embeddedSubtitles, fetchSubtitles, fileUrl, listSubtitles, openExternal, resolveStream,
  savePlayback,
} from "../api";
import { DEFAULT_SUB_LANG, SUB_FONTS, SUB_PLACES, type FeedItem, type SubStyle } from "../types";
import { compactViews, relativeTime, subtitleLabel } from "./format";
import PlayerControls from "./PlayerControls";
import { Badge, BTN, Dialog, Spinner } from "./ui";

interface Props {
  item: FeedItem;
  /** Local file when downloaded; null means resolve and stream it instead. */
  path: string | null;
  onClose: () => void;
  onDelete: () => void;
  onPrev?: () => void;
  onNext?: () => void;
  /** Present only while streaming, so the video can be saved from here. */
  onDownload?: () => void;
  downloading?: boolean;
  /** Max height for streaming, or null to let the player adapt. */
  maxHeight: number | null;
  /** Preferred subtitle language, or "off". */
  subLang: string;
  subStyle: SubStyle;
  onSubStyle: (s: SubStyle) => void;
  /** Leaves the player for this video's channel. */
  onOpenChannel: () => void;
  /** Roll straight on to the next video when this one ends. */
  autoplayNext: boolean;
  /** Persists a subtitle choice made from the transport bar. */
  onSubLang: (l: string) => void;
  /** Position in the current feed, for the "3 of 180" readout. */
  index: number;
  total: number;
  /** False in window fullscreen, where there are no traffic lights to clear. */
  titleBarInset: boolean;
}

/**
 * Releases a <video> completely.
 *
 * Detaching the element is not enough: WebKit keeps a Picture-in-Picture
 * session (and its audio) running after the element leaves the DOM, so closing
 * the player or stepping to the next video would leave the previous one playing
 * with no way to stop it. Every exit path goes through here.
 */
function teardown(v: HTMLVideoElement | null) {
  if (!v) return;
  // Safari's PiP is the non-standard presentation-mode API; the spec one is
  // tried too, since either may be the live implementation.
  const webkit = v as WebkitVideo;
  try {
    if (webkit.webkitPresentationMode && webkit.webkitPresentationMode !== "inline") {
      webkit.webkitSetPresentationMode?.("inline");
    }
  } catch {
    /* not supported here */
  }
  try {
    if (webkit.webkitDisplayingFullscreen) webkit.webkitExitFullscreen?.();
  } catch {
    /* not supported here */
  }
  try {
    if (document.pictureInPictureElement) void document.exitPictureInPicture();
  } catch {
    /* not supported here */
  }
  try {
    if (document.fullscreenElement) void document.exitFullscreen();
  } catch {
    /* not supported here */
  }
  try {
    v.pause();
    // Dropping the source is what actually frees the decoder and the audio.
    v.removeAttribute("src");
    v.load();
  } catch {
    /* already gone */
  }
}

/** The WebKit-only members we rely on for PiP and native video fullscreen. */
type WebkitVideo = HTMLVideoElement & {
  webkitPresentationMode?: string;
  webkitSetPresentationMode?: (mode: string) => void;
  webkitEnterFullscreen?: () => void;
  webkitExitFullscreen?: () => void;
  webkitDisplayingFullscreen?: boolean;
};

/** Save at most this often while playing; also saved on close. */
const SAVE_EVERY_MS = 5000;
/** Ignore a saved position this close to either end — nothing useful to resume. */
const RESUME_EDGE_S = 5;

/**
 * Two sources, one player element.
 *
 * Downloaded: the local file through Tauri's asset protocol. Every download is
 * H.264/AAC in MP4 precisely so WKWebView can decode it natively.
 *
 * Not downloaded: YouTube's HLS master playlist, resolved by yt-dlp. Its
 * variants are H.264 + AAC up to 1080p, which AVFoundation streams natively —
 * so watching still happens here rather than in a browser. The iframe embed
 * cannot be used: it rejects a `tauri://` origin with "Error 153".
 */
/** Web addresses in a YouTube description, which are plain text as it stores them. */
const URL_IN_TEXT = /(https?:\/\/[^\s<>"']+)/g;

/**
 * A description with its addresses made clickable.
 *
 * They open in the real browser: a YouTube link is the one thing in here this
 * app has no way to show, and the rest belong to whoever wrote them.
 */
function Linked({ text }: { text: string }) {
  return (
    <>
      {text.split(URL_IN_TEXT).map((part, i) =>
        // Not URL_IN_TEXT.test: a global regex carries lastIndex between calls,
        // so every other address would come out as plain text.
        part.startsWith("http") ? (
          // Trailing punctuation is sentence, not address.
          <button
            key={i}
            onClick={() => openExternal(part.replace(/[.,;:!?)\]]+$/, ""))}
            title={part}
            className="cursor-pointer break-all text-left text-sky-600 underline
                       underline-offset-2 hover:text-sky-500 dark:text-sky-400"
          >
            {part}
          </button>
        ) : (
          <span key={i}>{part}</span>
        ),
      )}
    </>
  );
}

/**
 * Rewrites every cue's settings to one placement.
 *
 * WebVTT carries position on the cue itself, so this is the only way to move
 * subtitles: no CSS property places them. Existing settings are dropped rather
 * than merged — YouTube's own are exactly what needs overriding.
 */
function placeCues(vtt: string, line: number | null): string {
  return vtt.replace(
    /^(\s*[\d:.]+\s+-->\s+[\d:.]+)(.*)$/gm,
    (_m, times: string) => (line == null ? times : `${times} line:${line}%`),
  );
}

export default function Player({
  item, path, onClose, onDelete, onPrev, onNext, onDownload, downloading,
  maxHeight, subLang, onSubLang, subStyle, onSubStyle, onOpenChannel, autoplayNext,
  index, total, titleBarInset,
}: Props) {
  const streaming = path === null;
  const [src, setSrc] = useState<string | null>(path ? fileUrl(path) : null);
  const [error, setError] = useState<string | null>(null);
  const [buffering, setBuffering] = useState(true);
  // The height actually being decoded. With an adaptive stream this changes as
  // the player switches rendition, so it is read from the element rather than
  // assumed from the setting.
  const [height, setHeight] = useState(0);
  /**
   * Subtitle tracks as (language, URL), from whichever source has them:
   * WebVTT files yt-dlp saved beside a download, or a fetch.
   *
   * A stream has no other source — YouTube's HLS manifest carries a dozen
   * audio renditions and no subtitles at all — and a download saved before
   * subtitles were switched on has nothing beside it either.
   *
   * Fetching does not depend on the Settings preference. That preference says
   * which language is switched on by itself; it must not decide whether
   * subtitles exist to be chosen at all, or "None" would quietly empty the
   * player's subtitle menu as well.
   *
   * Fetched cues become blob URLs, which share the document's origin — a
   * file:// or 127.0.0.1 track would not.
   */
  const [rawTracks, setRawTracks] = useState<Array<[string, string]>>([]);
  const [tracks, setTracks] = useState<Array<[string, string]>>([]);
  const [fetchingSubs, setFetchingSubs] = useState(false);
  const [showDescription, setShowDescription] = useState(false);
  // The language to fetch, which is NOT the preference: turning subtitles on
  // from the player's menu moves the preference from "off" to that language,
  // and refetching then would tear down the tracks — and the stream with them —
  // the instant one is chosen.
  const wantLang = subLang === "off" ? DEFAULT_SUB_LANG : subLang;

  useEffect(() => {
    let cancelled = false;
    setRawTracks([]);
    setFetchingSubs(false);

    const load = async () => {
      if (path) {
        // Muxed into the download, which is where they belong — but read out
        // rather than left to the element's in-band rendering, which the media
        // pipeline draws wherever the container's text box points and which no
        // styling can reach.
        const inside = await embeddedSubtitles(item.id).catch(() => []);
        if (cancelled) return;
        if (inside.length > 0) {
          setRawTracks(inside);
          return;
        }
        // Downloads from before subtitles were embedded kept them beside the
        // file instead.
        const local = await listSubtitles(item.id).catch(() => []);
        if (cancelled) return;
        if (local.length > 0) {
          setRawTracks(local);
          return;
        }
      }
      if (cancelled) return;
      setFetchingSubs(true);
      try {
        const list = await fetchSubtitles(item.id, wantLang);
        if (cancelled) return;
        setRawTracks(list);
      } catch {
        /* a video with no captions in this language is an ordinary outcome */
      } finally {
        if (!cancelled) setFetchingSubs(false);
      }
    };
    void load();

    return () => {
      cancelled = true;
    };
  }, [item.id, path, wantLang]);

  // Placement is a WebVTT cue setting, not something CSS can reach, so it is
  // written into the cues themselves. Re-cut whenever the choice changes.
  useEffect(() => {
    const line = SUB_PLACES.find((p) => p.value === subStyle.place)?.line ?? null;
    const urls: string[] = [];
    setTracks(
      rawTracks.map(([lang, text]) => {
        const url = URL.createObjectURL(
          new Blob([placeCues(text, line)], { type: "text/vtt" }),
        );
        urls.push(url);
        return [lang, url] as [string, string];
      }),
    );
    return () => {
      for (const u of urls) URL.revokeObjectURL(u);
    };
  }, [rawTracks, subStyle.place]);

  // Controls and edge arrows fade away while you are just watching.
  const [chromeVisible, setChromeVisible] = useState(true);
  const hideTimer = useRef<number | undefined>(undefined);
  const videoRef = useRef<HTMLVideoElement>(null);
  // Quality is the short side, so a portrait Short reads 1080p, not 1920p.
  const measure = useCallback(() => {
    const v = videoRef.current;
    if (!v?.videoHeight) return;
    setHeight(Math.min(v.videoWidth, v.videoHeight));
  }, []);
  const stageRef = useRef<HTMLDivElement>(null);
  const lastSave = useRef(0);

  useEffect(() => {
    setHeight(0);
    if (path) {
      setSrc(fileUrl(path));
      return;
    }
    let cancelled = false;
    setSrc(null);
    setError(null);
    resolveStream(item.id, maxHeight)
      .then((u) => !cancelled && setSrc(u))
      .catch((e) => !cancelled && setError(String(e)));
    return () => {
      cancelled = true;
    };
  }, [item.id, path, maxHeight]);


  const persist = useCallback(() => {
    const v = videoRef.current;
    if (!v || !Number.isFinite(v.duration) || v.duration <= 0) return;
    savePlayback(item.id, v.currentTime, v.duration).catch(() => {
      /* a lost position is not worth interrupting playback for */
    });
  }, [item.id]);

  // Save on the way out, including when switching to another video. Declared
  // before the teardown effect so React runs this cleanup first — the position
  // has to be read off the element before its source is dropped.
  useEffect(() => () => persist(), [persist]);

  useEffect(() => {
    const v = videoRef.current;
    return () => teardown(v);
  }, [src]);

  const onTimeUpdate = () => {
    // Frames are flowing, so whatever the media events claimed, we are not
    // buffering. A resume-seek can fire `waiting` after `playing` and leave the
    // spinner stuck otherwise.
    const v = videoRef.current;
    if (v && v.readyState >= 3 && !v.paused) setBuffering(false);

    const now = Date.now();
    if (now - lastSave.current < SAVE_EVERY_MS) return;
    lastSave.current = now;
    persist();
  };

  // Pick up where this video was left off, unless that was right at either end.
  const onLoadedMetadata = () => {
    const v = videoRef.current;
    const at = item.position ?? 0;
    if (!v || !Number.isFinite(v.duration)) return;
    if (at > RESUME_EDGE_S && at < v.duration - RESUME_EDGE_S) v.currentTime = at;
  };

  const leave = useCallback(() => onClose(), [onClose]);

  // Escape backs out, as it does everywhere else in the app.
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      // In fullscreen, Escape is the transport bar's to handle: it leaves
      // fullscreen. Closing the player as well would drop you all the way back
      // to the feed in one keypress.
      if (e.key === "Escape" && !document.fullscreenElement) leave();
      // Moving between videos. Shift with the arrows because the bare ones
      // seek, and shift with N and P because that is what YouTube uses.
      if (e.shiftKey && (e.key === "ArrowLeft" || e.key === "P" || e.key === "p")) onPrev?.();
      if (e.shiftKey && (e.key === "ArrowRight" || e.key === "N" || e.key === "n")) onNext?.();
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [leave, onPrev, onNext]);

  const edgeBtn =
    "absolute top-1/2 z-10 -translate-y-1/2 grid size-11 place-items-center rounded-full " +
    "bg-slate-950/55 text-2xl leading-none text-white backdrop-blur cursor-pointer " +
    "transition-opacity duration-200 hover:bg-slate-950/80 disabled:hidden " +
    (chromeVisible ? "opacity-100" : "pointer-events-none opacity-0");

  const showChrome = useCallback(() => {
    setChromeVisible(true);
    window.clearTimeout(hideTimer.current);
    hideTimer.current = window.setTimeout(() => {
      // Never hide while paused — there would be no way back to play.
      if (!videoRef.current?.paused) setChromeVisible(false);
    }, 2600);
  }, []);

  useEffect(() => {
    showChrome();
    return () => window.clearTimeout(hideTimer.current);
  }, [showChrome, src]);

  const navIcon =
    "grid h-[30px] w-[30px] shrink-0 place-items-center rounded-lg border border-slate-300 " +
    "cursor-pointer hover:border-sky-500 hover:text-sky-600 disabled:opacity-30 " +
    "disabled:cursor-not-allowed dark:border-slate-700 dark:hover:border-sky-500 " +
    "dark:hover:text-sky-400";

  const navBtn =
    "inline-flex h-[30px] items-center rounded-lg border border-slate-300 px-2.5 text-[12px] font-medium cursor-pointer " +
    "hover:border-sky-500 hover:text-sky-600 disabled:opacity-30 disabled:cursor-not-allowed " +
    "disabled:hover:border-slate-300 disabled:hover:text-inherit " +
    "dark:border-slate-700 dark:hover:border-sky-500 dark:hover:text-sky-400";

  return (
    <div className="fixed inset-0 z-50 flex flex-col bg-slate-100 dark:bg-slate-950">
      {titleBarInset && <div data-tauri-drag-region className="h-9 shrink-0" />}
      <header
        className="flex items-center gap-2 border-b border-slate-200 bg-white px-4 py-3
                   dark:border-slate-800 dark:bg-slate-900"
      >
        <button onClick={leave} title="Back to the feed (Esc)" aria-label="Back"
          className={navIcon}>
          <svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
            <path strokeLinecap="round" strokeLinejoin="round" d="M15 6l-6 6 6 6" />
          </svg>
        </button>

        <button onClick={onPrev} disabled={!onPrev} title="Previous video" className={navBtn}>
          ‹ Prev
        </button>
        <button onClick={onNext} disabled={!onNext} title="Next video" className={navBtn}>
          Next ›
        </button>
        <span className="font-mono text-[11px] tabular-nums text-slate-400 dark:text-slate-500">
          {index + 1}/{total}
        </span>

        {/* A tag rather than a caption: it goes somewhere. */}
        <div className="flex min-w-0 flex-1 items-center">
          <button
            onClick={onOpenChannel}
            title={`Show everything from ${item.channel_title}`}
            className="max-w-full cursor-pointer truncate rounded-full border border-slate-300
                       px-2.5 py-0.5 text-[12px] text-slate-500 transition-colors
                       hover:border-sky-500 hover:text-sky-600 dark:border-slate-700
                       dark:text-slate-400 dark:hover:border-sky-500 dark:hover:text-sky-400"
          >
            {item.channel_title}
          </button>
        </div>

        {streaming ? (
          error ? (
            <Badge tone="danger">Unavailable</Badge>
          ) : src && !buffering ? (
            <Badge tone="accent" title={`Streaming at ${height || "an unknown"}p`}>
              Streaming{height ? ` · ${height}p` : ""}
            </Badge>
          ) : (
            <Badge>Loading…</Badge>
          )
        ) : (
          height > 0 && (
            <Badge title={`This copy on your Mac is ${height}p`}>
              Downloaded · {height}p
            </Badge>
          )
        )}
      </header>

      <style>{`video::cue {
        font-size: ${subStyle.size}%;
        font-family: ${SUB_FONTS.find((f) => f.value === subStyle.font)?.stack ?? "sans-serif"};
      }`}</style>

      {/* Absolute fill + object-contain, so portrait Shorts and landscape
          videos are both letterboxed to the pane instead of overflowing it. */}
      <div
        ref={stageRef}
        onContextMenu={(e) => e.preventDefault()}
        onMouseMove={showChrome}
        onMouseLeave={() => !videoRef.current?.paused && setChromeVisible(false)}
        className={`relative min-h-0 flex-1 bg-slate-950 ${chromeVisible ? "" : "cursor-none"}`}
      >
        {/* Edge arrows, the way a player wants them: big targets on the left and
            right of the picture. They fade in on hover so they never sit on top
            of the video while you are watching it. */}
        <button
          onClick={onPrev}
          disabled={!onPrev}
          title="Previous video"
          aria-label="Previous video"
          className={`${edgeBtn} left-3`}
        >
          ‹
        </button>
        <button
          onClick={onNext}
          disabled={!onNext}
          title="Next video"
          aria-label="Next video"
          className={`${edgeBtn} right-3`}
        >
          ›
        </button>

        {/* Resolving the stream and buffering it are the same wait as far as
            you are concerned, so they get the same spinner in the same place. */}
        {(!src || buffering) && !error && (
          <div className="pointer-events-none absolute inset-0 z-30 grid place-items-center">
            <Spinner className="size-8 text-white/80" />
          </div>
        )}

        {src ? (
          <video
            ref={videoRef}
            key={src}
            src={src}
            autoPlay
            onContextMenu={(e) => e.preventDefault()}
            onClick={() => {
              const v = videoRef.current;
              if (!v) return;
              if (v.paused) void v.play().catch(() => {});
              else v.pause();
            }}
            onTimeUpdate={onTimeUpdate}
            onLoadedMetadata={onLoadedMetadata}
            onPause={persist}
            onEnded={() => {
              persist();
              // Only where there is somewhere to go: the last video in the
              // list simply stops.
              if (autoplayNext) onNext?.();
            }}
            onLoadStart={() => setBuffering(true)}
            onWaiting={() => setBuffering(true)}
            onStalled={() => setBuffering(true)}
            onCanPlay={() => setBuffering(false)}
            onPlaying={() => setBuffering(false)}
            onSeeked={() => setBuffering(false)}
            onResize={measure}
            onLoadedData={measure}
            className="absolute inset-0 size-full object-contain"
          >
            {tracks.map(([lang, url]) => (
              <track key={url} kind="subtitles" srcLang={lang} label={subtitleLabel(lang)} src={url} />
            ))}
          </video>
        ) : (
          error && (
            <div className="absolute inset-0 grid place-items-center px-6 text-center">
              <div className="max-w-sm">
                <p className="text-[13px] text-red-400">{error}</p>
                <button
                  onClick={() => openExternal(`https://www.youtube.com/watch?v=${item.id}`)}
                  className={`${BTN} mt-3 cursor-pointer py-1.5`}
                >
                  Open on YouTube instead
                </button>
              </div>
            </div>
          )
        )}
        {src && !error && (
          <div
            className={`transition-opacity duration-200 ${
              chromeVisible ? "opacity-100" : "pointer-events-none opacity-0"
            }`}
          >
            <PlayerControls
              videoRef={videoRef}
              stageRef={stageRef}
              onActivity={showChrome}
              subLang={subLang}
              onSubLang={onSubLang}
              subStyle={subStyle}
              onSubStyle={onSubStyle}
              subsLoading={fetchingSubs}
            />
          </div>
        )}
      </div>

      <footer
        className="max-h-52 shrink-0 overflow-y-auto border-t border-slate-200 bg-white px-4 py-3
                   dark:border-slate-800 dark:bg-slate-900"
      >
        <div className="flex items-start justify-between gap-3">
          {/* Title and figures on one line. The title opens the description
              rather than announcing itself as a link: it keeps its colour and
              stays unadorned, and the pointer says the rest. */}
          <div className="flex min-w-0 items-baseline gap-2">
            {item.description ? (
              <button
                onClick={() => setShowDescription(true)}
                title="Show the description"
                className="min-w-0 cursor-pointer truncate text-left text-[15px] font-semibold
                           tracking-tight"
              >
                {item.title}
              </button>
            ) : (
              <h2 className="min-w-0 truncate text-[15px] font-semibold tracking-tight">
                {item.title}
              </h2>
            )}
            <span className="shrink-0 text-[11px] text-slate-400 dark:text-slate-500">
              {[compactViews(item.views), relativeTime(item.published)]
                .filter(Boolean)
                .join(" · ")}
            </span>
          </div>
          <div className="flex shrink-0 items-center gap-2">
            {onDownload && (
              <button
                onClick={onDownload}
                disabled={downloading}
                title={downloading ? "Downloading…" : "Download for offline"}
                aria-label="Download"
                className={navIcon}
              >
                {downloading ? (
                  <Spinner className="size-4" />
                ) : (
                  <svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
                    <path strokeLinecap="round" strokeLinejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4M4 20h16" />
                  </svg>
                )}
              </button>
            )}
            {!streaming && (
              <button
                onClick={onDelete}
                title="Delete this download"
                aria-label="Delete download"
                className={`${navIcon} hover:border-red-500! hover:text-red-600!
                            dark:hover:border-red-500! dark:hover:text-red-400!`}
              >
                <svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
                  <path strokeLinecap="round" strokeLinejoin="round"
                    d="M4 7h16M9 7V5h6v2M6 7l1 13h10l1-13M10 11v6M14 11v6" />
                </svg>
              </button>
            )}
            <button
              onClick={() => openExternal(`https://www.youtube.com/watch?v=${item.id}`)}
              title="Open on YouTube"
              aria-label="Open on YouTube"
              className={navIcon}
            >
              <svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
                <path strokeLinecap="round" strokeLinejoin="round"
                  d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 01-1 1H5a1 1 0 01-1-1V7a1 1 0 011-1h5" />
              </svg>
            </button>
          </div>
        </div>

      </footer>

      {showDescription && (
        <Dialog title={item.title} onCancel={() => setShowDescription(false)} wide>
          <p className="whitespace-pre-wrap text-[12.5px] leading-relaxed">
            <Linked text={item.description} />
          </p>
        </Dialog>
      )}
    </div>
  );
}
