// Pure navigation/grouping logic for the series detail page.
//
// Extracted from `/library/[id]/+page.svelte` so it can be unit-tested: the
// series Play button used to resolve `$libraryItems[0]` — the first *season* by
// SortName — and navigate to `/player/<seasonId>`, which the player route
// bounced back to `/library/<seasonId>`. Play on a series therefore played
// nothing and landed on the season-1 page.
//
// Note what is NOT here: *which* episode is current. That is domain policy and
// lives in Rust (`repository_get_series_current_episode`); this module only
// renders and routes around the answer.
//
// TRACES: UR-062 | DR-102, DR-103, DR-142
import type { MediaItem } from "$lib/api/types";

export interface SeasonData {
  season: MediaItem;
  episodes: MediaItem[];
}

/** Jellyfin files specials under season 0. */
const SPECIALS_SEASON = 0;

/** Sort key for a season number: specials come after every numbered season. */
function seasonRank(seasonNumber: number | null | undefined): number {
  if (seasonNumber == null) return Number.MAX_SAFE_INTEGER - 1;
  return seasonNumber === SPECIALS_SEASON ? Number.MAX_SAFE_INTEGER : seasonNumber;
}

/**
 * The in-page anchor for a season, so a season link scrolls the series' single
 * continuous episode list instead of opening a page of its own.
 */
export function seasonAnchorId(seasonNumber: number | null | undefined): string {
  return `season-${seasonNumber ?? 0}`;
}

/**
 * Where a link naming a season should actually go: the series, anchored at that
 * season. Returns `null` when the season carries no `seriesId` (a deep link into
 * a stale cache), in which case the caller must keep rendering something rather
 * than strand the user.
 */
export function seasonRedirectTarget(season: MediaItem): string | null {
  if (!season.seriesId) return null;
  const seasonNumber = season.indexNumber ?? season.parentIndexNumber;
  return `/library/${season.seriesId}#${seasonAnchorId(seasonNumber)}`;
}

/**
 * Where an episode link should go: the episode in the context of its series
 * (ux-flows §5B.1 — an episode is never browsed as a bare Episode page).
 * Falls back to the bare item page only when the series is unknown.
 */
export function episodeFocusHref(episode: MediaItem): string {
  if (!episode.seriesId) return `/library/${episode.id}`;
  return `/library/${episode.seriesId}?episode=${episode.id}`;
}

/**
 * Where a bare `/library/<episodeId>` should actually land — the same rule
 * seasons follow (DR-103). An episode is never a page of its own, so a deep
 * link, a stale bookmark, or any caller that missed `episodeFocusHref` is
 * redirected into the series' Episode Focus View.
 *
 * Returns `null` for an episode with no `seriesId` (a deep link into a stale
 * cache): there is nothing to redirect *to*, so the caller renders the Focus
 * View series-less rather than stranding the user (ux-flows §5B.1).
 */
export function episodeRedirectTarget(episode: MediaItem): string | null {
  if (!episode.seriesId) return null;
  return episodeFocusHref(episode);
}

/**
 * Where the series hero button goes.
 *
 * The Episode Focus View, not the player: ux-flows §5B.5 makes Play on a
 * *container* navigation and Play on a *leaf* the commitment. Returns `null`
 * when there is no current episode (an empty series), so the caller can hide
 * the button rather than link nowhere.
 */
export function seriesPlayHref(seriesId: string, current: MediaItem | null): string | null {
  if (!current) return null;
  return `/library/${seriesId}?episode=${current.id}`;
}

/** Fraction of an episode already watched, 0 when unknown. */
function progressFraction(episode: MediaItem): number {
  const position = episode.userData?.playbackPositionMs ?? 0;
  if (!episode.durationMs || position <= 0) return 0;
  return position / episode.durationMs;
}

/**
 * Label for the series hero button — it names the episode it will open, so the
 * viewer knows where the button leads before pressing it.
 */
export function seriesPlayLabel(current: MediaItem | null): string {
  if (!current) return "Play";

  const fraction = progressFraction(current);
  const verb = fraction > 0.01 && fraction < 0.95 ? "Resume" : "Play";

  if (current.parentIndexNumber == null || current.indexNumber == null) return verb;
  return `${verb} S${current.parentIndexNumber}E${current.indexNumber}`;
}

/**
 * Group a series' episodes under its season headers.
 *
 * The episodes arrive from Rust already in series order; this only decides which
 * header each one renders beneath, and synthesizes a header for any season the
 * server did not return one for (a flat series, or a season fetch that failed).
 * Seasons with no episodes are dropped — an empty accordion row is noise.
 */
export function groupEpisodesBySeason(
  seasons: MediaItem[],
  episodes: MediaItem[]
): SeasonData[] {
  const headerFor = new Map<number, MediaItem>();
  for (const season of seasons) {
    const number = season.indexNumber ?? season.parentIndexNumber;
    if (number != null && !headerFor.has(number)) headerFor.set(number, season);
  }

  const grouped = new Map<number, MediaItem[]>();
  for (const episode of episodes) {
    const number = episode.parentIndexNumber ?? 1;
    const bucket = grouped.get(number);
    if (bucket) bucket.push(episode);
    else grouped.set(number, [episode]);
  }

  return [...grouped.entries()]
    .sort(([a], [b]) => seasonRank(a) - seasonRank(b))
    .map(([number, seasonEpisodes]) => ({
      season:
        headerFor.get(number) ??
        ({
          ...seasonEpisodes[0],
          id: `synthetic-season-${number}`,
          kind: "season",
          indexNumber: number,
          name: number === SPECIALS_SEASON ? "Specials" : `Season ${number}`,
          overview: null,
        } as MediaItem),
      episodes: seasonEpisodes,
    }));
}

/**
 * Which seasons start expanded.
 *
 * Only the one the viewer is in. A ten-season show otherwise renders every
 * episode of every season at once, burying the one episode they came for. A
 * `?episode=` deep link expands that episode's season as well, and a show with
 * no resolved current episode falls back to its first season so the page is
 * never entirely collapsed.
 *
 * Returns season ids (not numbers) so the caller can key state per section,
 * including the synthesized headers.
 */
export function initialExpandedSeasons(
  seasons: SeasonData[],
  currentEpisodeId: string | null | undefined,
  focusedEpisodeId?: string | null
): Set<string> {
  if (seasons.length === 0) return new Set();

  const expanded = new Set<string>();
  for (const id of [currentEpisodeId, focusedEpisodeId]) {
    if (!id) continue;
    const owner = seasons.find((s) => s.episodes.some((e) => e.id === id));
    if (owner) expanded.add(owner.season.id);
  }

  // Nothing matched — open the first season rather than nothing at all.
  if (expanded.size === 0) expanded.add(seasons[0].season.id);

  return expanded;
}
