// Subtitle plumbing for the Linux / WebKitGTK HTML5 `<video>` playback path.
//
// Extracted from VideoPlayer.svelte so it is unit-testable, and because the
// original inline version hid a fatal mistake in plain sight: `getSubtitleUrl()`
// is async, so `src={getSubtitleUrl(track.index)}` bound a *Promise* to the
// attribute and every `<track>` pointed at "[object Promise]". The whole block
// was commented out rather than fixed, which left `<video>` with no text tracks
// at all — `Html5PlayerAdapter.selectSubtitle()` then iterated an empty
// `textTracks` list and the subtitle menu silently did nothing.
//
// The rule this module enforces: URLs are resolved to plain strings *here*, off
// the render path, and only tracks that actually resolved are handed to the
// markup.
//
// The Android / ExoPlayer native path shares this module (see
// nativeSubtitleTracks / nativeSubtitleArrayIndex at the bottom): it needs the
// exact same "resolve the URLs first, keep only what resolved" list, just handed
// to Rust instead of to `<track>` elements.
//
// TRACES: UR-020 | DR-023, IR-016 | UT-143, UT-144, UT-147

import type { SubtitleTrack } from "$lib/api/bindings";

/**
 * The subset of `MediaStream` (from the generated bindings) this module needs.
 * Kept structural so tests do not have to build full binding objects.
 */
export interface SubtitleStreamLike {
  index: number;
  kind?: string | null;
  language?: string | null;
  displayTitle?: string | null;
  isDefault?: boolean;
  isForced?: boolean;
  /**
   * The backend's verdict on whether this track can arrive as a sidecar the app
   * renders itself. `false` means only the server could have shown it, by
   * burning it into the picture — which the app never asks for. Absent means no
   * verdict was given, which is not the same as "no".
   */
  supportsExternalDelivery?: boolean | null;
}

/** A subtitle stream whose URL resolved — i.e. one we can actually render. */
export interface RenderableSubtitleTrack {
  /** Jellyfin media-stream index; the adapter matches `data-stream-index`. */
  streamIndex: number;
  /** Fully resolved WebVTT URL. Always a string, never a Promise. */
  url: string;
  srclang: string;
  label: string;
  /** Server's "default" flag — shown in the menu, never auto-enabled. */
  isDefault: boolean;
}

/**
 * Subtitle streams of a media item that the app can actually show, in stream
 * order. This is the one list behind everything: the picker, the `<track>`
 * children, and the array sent to the native backend.
 *
 * Image-based subtitles (PGS/DVD/DVB) are filtered out here rather than at each
 * consumer. They are bitmaps — a client can only display one if the server
 * composites it into the video, and the app deliberately asks for no burn-in at
 * all (DR-176), so such a track is one it can never draw. Leaving it in the
 * picker produced a control that ticked and showed nothing.
 *
 * The judgement is the backend's: `supportsExternalDelivery` arrives already
 * decided, because *which formats are bitmaps* is domain vocabulary and belongs
 * in Rust. Only an explicit `false` drops a stream; a stream carrying no verdict
 * is kept, so a source that never sets the field behaves exactly as before.
 *
 * Generic in the stream type so callers keep their own richer fields (the menu
 * reads `codec` off the result).
 *
 * TRACES: UR-020 | DR-176 | UT-168
 */
export function subtitleStreamsOf<T extends SubtitleStreamLike>(
  streams: readonly T[] | null | undefined,
): T[] {
  if (!streams) return [];
  return streams.filter((s) => s.kind === "subtitle" && s.supportsExternalDelivery !== false);
}

/** Human label for a subtitle stream, matching the menu's own fallback chain. */
export function subtitleTrackLabel(stream: SubtitleStreamLike): string {
  return stream.displayTitle || stream.language || `Track ${stream.index}`;
}

/** A src we are willing to put on a `<track>`: a non-blank plain string. */
function isRenderableUrl(url: unknown): url is string {
  return typeof url === "string" && url.trim().length > 0;
}

/**
 * Resolve every subtitle stream's URL and return only the tracks that can be
 * rendered. `resolveUrl` failures are swallowed per track: one unavailable
 * subtitle must not cost the user the others, and a dead `src` on a media
 * element is exactly what made this block get disabled in the first place.
 */
export async function resolveSubtitleTracks(
  streams: readonly SubtitleStreamLike[] | null | undefined,
  resolveUrl: (streamIndex: number) => Promise<string>,
): Promise<RenderableSubtitleTrack[]> {
  const subtitles = subtitleStreamsOf(streams);
  if (subtitles.length === 0) return [];

  const resolved = await Promise.all(
    subtitles.map(async (stream) => {
      try {
        const url = await resolveUrl(stream.index);
        if (!isRenderableUrl(url)) return null;
        return {
          streamIndex: stream.index,
          url,
          srclang: stream.language || "und",
          label: subtitleTrackLabel(stream),
          isDefault: stream.isDefault === true,
        } satisfies RenderableSubtitleTrack;
      } catch {
        return null;
      }
    }),
  );

  return resolved.filter((t): t is RenderableSubtitleTrack => t !== null);
}

/**
 * The selection to keep once the rendered track list changes.
 *
 * Subtitles are OFF unless the user turns them on: `null` in, `null` out. The
 * server's `isDefault` flag is deliberately NOT promoted to a selection (and the
 * markup deliberately omits the `default` attribute, which would auto-show the
 * track) — the menu opens on "Off", so auto-enabling would make the UI lie about
 * what is on screen, and it would change behaviour for every user who has never
 * asked for subtitles.
 *
 * A selection that is no longer renderable (new item, or a URL that failed to
 * resolve) collapses to off, so the menu's checkmark can never point at a track
 * that does not exist on the element.
 */
export function reconcileSelectedSubtitle(
  tracks: readonly RenderableSubtitleTrack[],
  selected: number | null,
): number | null {
  if (selected === null) return null;
  return tracks.some((t) => t.streamIndex === selected) ? selected : null;
}

function originOf(url: string): string | null {
  try {
    const parsed = new URL(url);
    if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
    return parsed.origin;
  } catch {
    return null;
  }
}

/**
 * The `crossorigin` value for the `<video>` element, or undefined for none.
 *
 * Text-track fetches are CORS-enabled per the HTML spec and use the *media
 * element's* CORS setting, so a cross-origin `<track>` never loads unless the
 * element opts in. The webview page's origin is `tauri://localhost`, so every
 * subtitle served by Jellyfin is cross-origin.
 *
 * Opting in is only safe when the media itself comes from an http(s) server —
 * the same Jellyfin that already answers hls.js' cross-origin XHRs, so we know
 * it sends the headers. For a local/offline source (`file:`/`asset:`) we leave
 * the attribute off: subtitles staying dark there is the status quo, whereas
 * forcing CORS onto the video fetch could break playback outright.
 *
 * Deliberately keyed on the *count of subtitle streams* rather than on the
 * resolved tracks: both inputs are known at first render, so the attribute is
 * decided before the element starts loading and never flips underneath an
 * in-flight media fetch.
 */
export function videoCrossOriginMode(
  streamUrl: string,
  subtitleStreamCount: number,
): "anonymous" | undefined {
  if (subtitleStreamCount <= 0) return undefined;
  return originOf(streamUrl) ? "anonymous" : undefined;
}

// ===== Native (Android / ExoPlayer) path ====================================
//
// The HTML5 element gets `<track>` children; the native backend instead gets the
// list *up front*, as part of the play request, because ExoPlayer sideloads
// subtitles as `MediaItem.SubtitleConfiguration`s that must exist before
// `prepare()`. There is no "add a subtitle later" — a track absent from the
// MediaItem simply does not exist as far as the player is concerned.

/**
 * Map resolved tracks onto the wire shape `PlayItemRequest.subtitles` carries.
 *
 * The element type is the *generated* `SubtitleTrack` binding on purpose, so
 * `bun run check` fails if the Rust struct's field names ever move. In
 * particular `mime_type` is snake_case and must stay that way: the very same
 * bytes are re-serialized across JNI in `player/android/mod.rs`, and
 * `JellyTauPlayer.load()` reads `optString("mime_type")`. Renaming it to
 * `mimeType` would not error anywhere — Kotlin would just silently fall back to
 * its default MIME type for every track.
 *
 * Jellyfin is asked for every subtitle stream as WebVTT (see
 * `getSubtitleUrl(..., "vtt")`), so the MIME type is fixed rather than derived
 * from the source subtitle codec.
 *
 * TRACES: UR-020 | IR-016, JA-008 | UT-147
 */
export function nativeSubtitleTracks(
  tracks: readonly RenderableSubtitleTrack[],
): SubtitleTrack[] {
  return tracks.map((track) => ({
    index: track.streamIndex,
    url: track.url,
    // `srclang` carries "und" for a stream with no language, which is the right
    // value for a `<track>` but is not a language the native side should claim.
    language: track.srclang === "und" ? null : track.srclang,
    label: track.label,
    mime_type: "text/vtt",
  }));
}

/**
 * The argument for `player_set_subtitle_track` on the native backend.
 *
 * 🔴 This is **not** the Jellyfin stream index.
 * `JellyTauPlayer.setSubtitleTrack(n)` filters ExoPlayer's track groups down to
 * `C.TRACK_TYPE_TEXT` and indexes that list with `n`, so `n` is the *position of
 * the sideloaded subtitle configuration* — which is the position in the array
 * that `nativeSubtitleTracks()` produced and `playerPlayItem` sent.
 *
 * The menu's own row number is not that position: the menu lists every subtitle
 * *stream*, while only the streams whose URL resolved are sent. One failed URL
 * and everything below it selects the wrong subtitle. So the index is looked up
 * in the sent list instead of being passed down from the `{#each}`.
 *
 * `null` (the menu's "Off") stays `null`, which the backend turns into -1 and
 * Kotlin turns into "disable text tracks". A stream that was never sent also
 * maps to `null`: disabling subtitles is a truthful outcome, whereas guessing a
 * position would show the user a different language than the one they clicked.
 *
 * TRACES: UR-020 | IR-016 | UT-147
 */
export function nativeSubtitleArrayIndex(
  tracks: readonly RenderableSubtitleTrack[],
  streamIndex: number | null,
): number | null {
  if (streamIndex === null) return null;
  const position = tracks.findIndex((t) => t.streamIndex === streamIndex);
  return position === -1 ? null : position;
}
