import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import {
  subtitleStreamsOf,
  subtitleTrackLabel,
  resolveSubtitleTracks,
  reconcileSelectedSubtitle,
  videoCrossOriginMode,
  nativeSubtitleTracks,
  nativeSubtitleArrayIndex,
  type SubtitleStreamLike,
} from "./subtitleTracks";

/**
 * Subtitles on the Linux / WebKitGTK HTML5 `<video>` path.
 *
 * TRACES: UR-020 | DR-023 | UT-143, UT-144
 *
 * The bug this guards: VideoPlayer rendered no `<track>` children at all (the
 * block was commented out "to debug playback issues"), so
 * `Html5PlayerAdapter.selectSubtitle()` walked an empty `textTracks` list and
 * the subtitle menu was inert on Linux. The reason it had to be disabled is
 * visible in the original markup — `src={getSubtitleUrl(track.index)}` bound the
 * *Promise* returned by an async function to the attribute, so every track's src
 * stringified to "[object Promise]", an unloadable resource hanging off the
 * media element.
 *
 * So the fix has two halves and both are tested here: URLs must be resolved into
 * plain strings *before* they reach the markup, and the markup must actually
 * render the tracks (with the `data-stream-index` the adapter matches on).
 */

const SUBS: SubtitleStreamLike[] = [
  { index: 2, kind: "subtitle", language: "eng", displayTitle: "English (SRT)", isDefault: true },
  { index: 3, kind: "subtitle", language: "fre", displayTitle: "French", isDefault: false },
];

const STREAMS: SubtitleStreamLike[] = [
  { index: 0, kind: "video", language: null, displayTitle: "1080p" },
  { index: 1, kind: "audio", language: "eng", displayTitle: "English AAC" },
  ...SUBS,
];

const url = (i: number) => `http://jelly.example/Videos/x/Subtitles/${i}/0/subtitles.vtt?api_key=k`;

describe("subtitleStreamsOf", () => {
  it("keeps only subtitle streams, in stream order", () => {
    expect(subtitleStreamsOf(STREAMS).map((s) => s.index)).toEqual([2, 3]);
  });

  it("tolerates missing media streams", () => {
    expect(subtitleStreamsOf(null)).toEqual([]);
    expect(subtitleStreamsOf(undefined)).toEqual([]);
  });

  /**
   * A subtitle the app cannot draw must not reach the picker. Image-based
   * tracks (PGS/DVD/DVB) are bitmaps: the only way to show one is for the server
   * to composite it into the video, which this app deliberately never asks for
   * (DR-176). Offering it anyway produced the reported symptom's twin — a menu
   * entry that selects, ticks, and shows nothing.
   *
   * The verdict is the backend's (`supportsExternalDelivery`); the codec
   * vocabulary behind it stays in Rust.
   *
   * TRACES: UR-020 | DR-176 | UT-168
   */
  it("drops subtitles the backend says it cannot deliver as a sidecar", () => {
    const streams: SubtitleStreamLike[] = [
      { index: 2, kind: "subtitle", displayTitle: "English PGS SDH", supportsExternalDelivery: false },
      { index: 3, kind: "subtitle", displayTitle: "English Text SDH", supportsExternalDelivery: true },
    ];

    expect(subtitleStreamsOf(streams).map((s) => s.index)).toEqual([3]);
  });

  /**
   * Only an explicit "no" hides a track. A stream that carries no verdict at all
   * predates the field (or came from somewhere that does not set it), and
   * hiding those would silently empty the menu for sources that work today.
   *
   * TRACES: UR-020 | DR-176 | UT-168
   */
  it("keeps subtitles that carry no verdict", () => {
    const streams: SubtitleStreamLike[] = [
      { index: 2, kind: "subtitle", displayTitle: "English" },
      { index: 3, kind: "subtitle", displayTitle: "French", supportsExternalDelivery: null },
    ];

    expect(subtitleStreamsOf(streams).map((s) => s.index)).toEqual([2, 3]);
  });

  /**
   * The same list feeds the `<track>` children and the native play request, so
   * an undeliverable track must not even have its URL fetched — that request is
   * the one that 404s, and the sideloaded track it would produce is the dead
   * entry all over again.
   *
   * TRACES: UR-020 | DR-176 | UT-168
   */
  it("never resolves a URL for a subtitle it dropped", async () => {
    const asked: number[] = [];
    const tracks = await resolveSubtitleTracks(
      [
        { index: 2, kind: "subtitle", displayTitle: "PGS", supportsExternalDelivery: false },
        { index: 3, kind: "subtitle", displayTitle: "SRT", supportsExternalDelivery: true },
      ],
      async (index) => {
        asked.push(index);
        return url(index);
      },
    );

    expect(asked).toEqual([3]);
    expect(tracks.map((t) => t.streamIndex)).toEqual([3]);
  });
});

describe("subtitleTrackLabel", () => {
  it("prefers the display title, then language, then the index", () => {
    expect(subtitleTrackLabel({ index: 2, displayTitle: "English (SRT)", language: "eng" })).toBe("English (SRT)");
    expect(subtitleTrackLabel({ index: 2, displayTitle: null, language: "eng" })).toBe("eng");
    expect(subtitleTrackLabel({ index: 2 })).toBe("Track 2");
  });
});

describe("resolveSubtitleTracks", () => {
  it("resolves real string URLs — never a Promise — for every subtitle stream", async () => {
    const tracks = await resolveSubtitleTracks(STREAMS, async (i) => url(i));

    expect(tracks).toHaveLength(2);
    for (const track of tracks) {
      expect(typeof track.url).toBe("string");
      // The exact regression: a Promise bound to src stringifies to this.
      expect(String(track.url)).not.toContain("[object Promise]");
      expect(track.url).toContain("subtitles.vtt");
    }
    // The adapter matches <track> elements by data-stream-index, so the stream
    // index has to survive resolution.
    expect(tracks.map((t) => t.streamIndex)).toEqual([2, 3]);
    expect(tracks.map((t) => t.label)).toEqual(["English (SRT)", "French"]);
    expect(tracks.map((t) => t.srclang)).toEqual(["eng", "fre"]);
    expect(tracks[0].isDefault).toBe(true);
  });

  it("drops tracks whose URL cannot be built instead of rendering a dead src", async () => {
    const tracks = await resolveSubtitleTracks(SUBS, async (i) => {
      if (i === 2) throw new Error("no repository");
      return url(i);
    });
    expect(tracks.map((t) => t.streamIndex)).toEqual([3]);
  });

  it("drops empty and non-string URLs", async () => {
    const tracks = await resolveSubtitleTracks(SUBS, async (i) =>
      i === 2 ? "  " : (undefined as unknown as string),
    );
    expect(tracks).toEqual([]);
  });

  it("returns nothing when there are no subtitle streams", async () => {
    expect(await resolveSubtitleTracks([STREAMS[0]], async (i) => url(i))).toEqual([]);
    expect(await resolveSubtitleTracks(null, async (i) => url(i))).toEqual([]);
  });
});

describe("reconcileSelectedSubtitle", () => {
  it("starts off (null) and keeps 'off' selectable", async () => {
    const tracks = await resolveSubtitleTracks(SUBS, async (i) => url(i));
    expect(reconcileSelectedSubtitle(tracks, null)).toBeNull();
  });

  it("keeps a selection that is still renderable", async () => {
    const tracks = await resolveSubtitleTracks(SUBS, async (i) => url(i));
    expect(reconcileSelectedSubtitle(tracks, 3)).toBe(3);
  });

  it("falls back to off when the selected track is gone (new item / failed URL)", async () => {
    const tracks = await resolveSubtitleTracks(SUBS, async (i) => url(i));
    expect(reconcileSelectedSubtitle(tracks, 9)).toBeNull();
    expect(reconcileSelectedSubtitle([], 3)).toBeNull();
  });

  it("never auto-selects the server's default track", async () => {
    // The menu opens on "Off" and a <track default> would auto-show, so the UI
    // would claim subtitles are off while they are burned over the picture.
    const tracks = await resolveSubtitleTracks(SUBS, async (i) => url(i));
    expect(tracks[0].isDefault).toBe(true);
    expect(reconcileSelectedSubtitle(tracks, null)).toBeNull();
  });
});

describe("videoCrossOriginMode", () => {
  it("opts into CORS for a server stream that has subtitles", () => {
    expect(videoCrossOriginMode("http://jelly.example/Videos/x/master.m3u8", 2)).toBe("anonymous");
    expect(videoCrossOriginMode("https://jelly.example/Videos/x/stream.mp4", 1)).toBe("anonymous");
  });

  it("leaves a local/offline source alone so playback cannot regress", () => {
    expect(videoCrossOriginMode("asset://localhost/movie.mkv", 2)).toBeUndefined();
    expect(videoCrossOriginMode("file:///home/u/movie.mkv", 2)).toBeUndefined();
  });

  it("stays out of the way when there is nothing to load", () => {
    expect(videoCrossOriginMode("http://jelly.example/x.m3u8", 0)).toBeUndefined();
    expect(videoCrossOriginMode("", 0)).toBeUndefined();
  });

  it("is decided by inputs known at first render, so it cannot flip mid-load", () => {
    // Same answer before and after the async URL resolution completes.
    const before = videoCrossOriginMode("http://jelly.example/x.m3u8", SUBS.length);
    const after = videoCrossOriginMode("http://jelly.example/x.m3u8", SUBS.length);
    expect(before).toBe(after);
  });
});

/**
 * Subtitles on the Android / ExoPlayer native path.
 *
 * TRACES: UR-020 | IR-016, JA-008 | UT-147
 *
 * The bug this guards: VideoPlayer built a fully-resolved subtitle array in
 * onMount and then never sent it — `commands.playerPlayItem({...})` passed only
 * streamUrl/title/id/videoCodec/needsTranscoding — so every MediaItem reached
 * ExoPlayer with zero SubtitleConfigurations and `setSubtitleTrack(n)` logged
 * "Invalid subtitle track index".
 *
 * And the second half: `setSubtitleTrack(n)` indexes ExoPlayer's *text track
 * groups*, i.e. the position of the sideloaded configuration — not the Jellyfin
 * stream index. The menu used to pass its own row position, which is a position
 * in the *unresolved* stream list; the moment one subtitle URL failed to
 * resolve, the two lists diverged and every track below the gap selected the
 * wrong subtitle.
 */
describe("nativeSubtitleTracks", () => {
  it("maps to the wire shape Rust deserializes and Kotlin parses", async () => {
    const resolved = await resolveSubtitleTracks(SUBS, async (i) => url(i));
    const payload = nativeSubtitleTracks(resolved);

    expect(payload).toHaveLength(2);
    // Kotlin reads url/language/label/mime_type; Rust's SubtitleTrack keeps
    // snake_case for exactly that reason, and so does the generated binding.
    for (const track of payload) {
      expect(Object.keys(track).sort()).toEqual(
        ["index", "label", "language", "mime_type", "url"].sort(),
      );
      expect(track).not.toHaveProperty("mimeType");
      expect(track.mime_type).toBe("text/vtt");
    }
    // Jellyfin serves every subtitle stream as WebVTT here, and the stream index
    // rides along so the UI can keep talking in stream indices.
    expect(payload.map((t) => t.index)).toEqual([2, 3]);
    expect(payload[0].url).toContain("subtitles.vtt");
    expect(payload[0].language).toBe("eng");
    expect(payload[0].label).toBe("English (SRT)");
  });

  it("preserves stream order, because that order is the selection index", async () => {
    const resolved = await resolveSubtitleTracks(STREAMS, async (i) => url(i));
    expect(nativeSubtitleTracks(resolved).map((t) => t.index)).toEqual(
      resolved.map((t) => t.streamIndex),
    );
  });

  it("has nothing to send when no subtitle URL resolved", async () => {
    expect(nativeSubtitleTracks(await resolveSubtitleTracks(SUBS, async () => ""))).toEqual([]);
    expect(nativeSubtitleTracks([])).toEqual([]);
  });

  it("carries a null language/label through rather than inventing one", () => {
    const payload = nativeSubtitleTracks([
      { streamIndex: 5, url: "u.vtt", srclang: "und", label: "Track 5", isDefault: false },
    ]);
    expect(payload[0].language).toBeNull();
    expect(payload[0].label).toBe("Track 5");
  });
});

describe("nativeSubtitleArrayIndex", () => {
  it("returns the position in the list that was actually sent, not the stream index", async () => {
    const resolved = await resolveSubtitleTracks(SUBS, async (i) => url(i));
    expect(nativeSubtitleArrayIndex(resolved, 2)).toBe(0);
    expect(nativeSubtitleArrayIndex(resolved, 3)).toBe(1);
  });

  it("stays aligned when a subtitle URL failed to resolve (the mis-selection bug)", async () => {
    // Stream 2 has no URL, so it is not among the sideloaded configurations.
    // The menu's own row for stream 3 is position 1, but ExoPlayer only has one
    // text track group — position 0. Sending 1 would select nothing.
    const resolved = await resolveSubtitleTracks(SUBS, async (i) => {
      if (i === 2) throw new Error("no repository");
      return url(i);
    });
    expect(resolved).toHaveLength(1);
    expect(nativeSubtitleArrayIndex(resolved, 3)).toBe(0);
  });

  it("maps 'Off' to null so the backend disables text instead of selecting track 0", async () => {
    const resolved = await resolveSubtitleTracks(SUBS, async (i) => url(i));
    expect(nativeSubtitleArrayIndex(resolved, null)).toBeNull();
  });

  it("maps a track that was never sent to null rather than to a wrong position", async () => {
    const resolved = await resolveSubtitleTracks(SUBS, async (i) => url(i));
    expect(nativeSubtitleArrayIndex(resolved, 99)).toBeNull();
    expect(nativeSubtitleArrayIndex([], 3)).toBeNull();
  });
});

describe("VideoPlayer markup (the regression that made the menu inert)", () => {
  const source = readFileSync(
    resolve(__dirname, "VideoPlayer.svelte"),
    "utf-8",
  );

  it("renders <track> elements instead of leaving them commented out", () => {
    expect(source).not.toContain("Temporarily disabled to debug playback issues");
    expect(source).toMatch(/<track\b/);
    expect(source).toContain('kind="subtitles"');
  });

  /** The rendered element, not a `<track>` mentioned in prose. */
  const trackElement = source.slice(source.search(/<track\s/), source.search(/<track\s/) + 400);

  it("keeps data-stream-index — Html5PlayerAdapter.selectSubtitle matches on it", () => {
    expect(trackElement).toContain("data-stream-index");
  });

  it("never binds the async getSubtitleUrl() Promise to src", () => {
    expect(source).not.toMatch(/src=\{\s*getSubtitleUrl\(/);
  });

  it("does not mark any track default (a default track auto-shows)", () => {
    expect(trackElement).not.toMatch(/\bdefault=/);
  });
});

/**
 * The half of the Android fix that lives in the component: the resolved list has
 * to actually be handed to `playerPlayItem`, and the index sent to the backend
 * has to be computed from that same list.
 *
 * TRACES: UR-020 | IR-016 | UT-147
 */
describe("VideoPlayer -> playerPlayItem (the tracks that were built and thrown away)", () => {
  const source = readFileSync(resolve(__dirname, "VideoPlayer.svelte"), "utf-8");

  /** The playerPlayItem({...}) argument object. */
  const playItemCall = (() => {
    const start = source.indexOf("commands.playerPlayItem(");
    expect(start).toBeGreaterThan(-1);
    return source.slice(start, source.indexOf("});", start) + 3);
  })();

  it("sends the subtitle tracks it resolved", () => {
    expect(playItemCall).toMatch(/\bsubtitles:/);
  });

  it("selects by position in the sent list, not by the menu's row number", () => {
    expect(source).toContain("nativeSubtitleArrayIndex");
    // The old code forwarded the `{#each}` index straight to the backend.
    expect(source).not.toMatch(/playerSetSubtitleTrack\(\s*arrayIndex\s*\)/);
  });
});
