// Shared types, constants and pure helpers for the /jellyfin workspace panels.
//
// The types mirror Jellyfin's `BaseItemDto` rather than reshaping it, because the sidecar's `/_officer/*`
// façade is a pass-through for the item shapes themselves — what it adds is the user id, the Fields list and
// the playback negotiation, not a different vocabulary. Every field here is optional in Jellyfin's own schema
// and is optional here too: an Episode has no `CollectionType`, a Movie has no `SeriesId`, and a library
// mid-scan can return an item with nothing but an Id.
//
// VIDEO ONLY. The owner's music is Officer's own /music player, and this machine runs separate Jellyfin
// instances for albums and DJ sets; the sidecar already filters music libraries out of `/views` and `/home`.

export const JELLYFIN_SECTIONS = [
  { id: 'home', label: 'Home' },
  { id: 'library', label: 'Library' },
  { id: 'search', label: 'Search' },
  { id: 'settings', label: 'Connection' },
] as const;

export type JellyfinSectionId = (typeof JELLYFIN_SECTIONS)[number]['id'];

/** Where /jellyfin lands, and where an unrecognised section redirects to. */
export const DEFAULT_JELLYFIN_SECTION: JellyfinSectionId = 'home';

export const isJellyfinSection = (value: string | undefined): value is JellyfinSectionId =>
  JELLYFIN_SECTIONS.some((s) => s.id === value);

/** The one place the section URL is spelled, so nav, guard and deep links cannot drift apart. */
export const jellyfinSectionPath = (id: JellyfinSectionId) => `/jellyfin/${id}`;

/** Which library the browse grid is showing — `?view=<id>`. */
export const VIEW_PARAM = 'view';
/** Which item is open in the detail sheet — `?item=<id>`. */
export const ITEM_PARAM = 'item';
/** Which item is playing — `?play=<id>`. Separate from `item` so closing the player returns to the detail. */
export const PLAY_PARAM = 'play';
/** The search text — `?q=…`. */
export const QUERY_PARAM = 'q';

/**
 * The href that opens an item, keeping the page you opened it from.
 *
 * Selection is a query parameter rather than a route because the detail sheet is an overlay ON the grid: the
 * grid stays mounted and scrolled behind it, and closing is dropping one parameter. Building the href from the
 * CURRENT params is what makes that work in both directions — open an episode from a search and closing it
 * puts the search back, unchanged.
 */
export function itemHref(pathname: string, params: URLSearchParams, id: string, play = false): string {
  const next = new URLSearchParams(params);
  next.set(play ? PLAY_PARAM : ITEM_PARAM, id);
  if (play) next.delete(ITEM_PARAM);
  return `${pathname}?${next}`;
}

/** The href that closes whichever overlay is open, leaving everything else about the page alone. */
export function closeHref(pathname: string, params: URLSearchParams, which: typeof ITEM_PARAM | typeof PLAY_PARAM) {
  const next = new URLSearchParams(params);
  next.delete(which);
  const qs = next.toString();
  return qs ? `${pathname}?${qs}` : pathname;
}

// ── Wire types ────────────────────────────────────────────────────────────────────────────────────

export type UserItemData = {
  PlaybackPositionTicks?: number;
  PlayCount?: number;
  IsFavorite?: boolean;
  Played?: boolean;
  PlayedPercentage?: number | null;
  UnplayedItemCount?: number;
};

export type MediaStream = {
  Type?: string;
  Index?: number;
  Codec?: string | null;
  Language?: string | null;
  DisplayTitle?: string | null;
  Height?: number | null;
  Width?: number | null;
  Channels?: number | null;
  IsDefault?: boolean;
  IsForced?: boolean;
};

export type MediaSource = {
  Id?: string;
  Name?: string | null;
  Container?: string | null;
  Size?: number | null;
  RunTimeTicks?: number | null;
  Bitrate?: number | null;
  MediaStreams?: MediaStream[];
  SupportsDirectPlay?: boolean;
  SupportsDirectStream?: boolean;
};

export type Person = { Id?: string; Name?: string; Role?: string | null; Type?: string; PrimaryImageTag?: string };

export type JellyItem = {
  Id: string;
  Name?: string;
  Type?: string;
  CollectionType?: string | null;
  ProductionYear?: number | null;
  PremiereDate?: string | null;
  EndDate?: string | null;
  Status?: string | null;
  Overview?: string | null;
  Taglines?: string[];
  Genres?: string[];
  OfficialRating?: string | null;
  CommunityRating?: number | null;
  CriticRating?: number | null;
  RunTimeTicks?: number | null;
  IndexNumber?: number | null;
  ParentIndexNumber?: number | null;
  SeriesId?: string | null;
  SeriesName?: string | null;
  SeasonId?: string | null;
  SeasonName?: string | null;
  ParentId?: string | null;
  ChildCount?: number | null;
  RecursiveItemCount?: number | null;
  PrimaryImageAspectRatio?: number | null;
  ImageTags?: Record<string, string>;
  BackdropImageTags?: string[];
  ParentBackdropItemId?: string | null;
  ParentBackdropImageTags?: string[];
  SeriesPrimaryImageTag?: string | null;
  Studios?: { Id?: string; Name?: string }[];
  People?: Person[];
  MediaSources?: MediaSource[];
  MediaStreams?: MediaStream[];
  UserData?: UserItemData;
};

export type ItemsResponse = { Items?: JellyItem[]; TotalRecordCount?: number };

export type HomeResponse = {
  views: JellyItem[];
  resume: JellyItem[];
  nextUp: JellyItem[];
  latest: { viewId: string; viewName: string; collectionType: string | null; items: JellyItem[] }[];
};

export type AudioTrack = {
  index: number;
  label: string;
  language: string | null;
  codec: string | null;
  channels: number | null;
  isDefault: boolean;
};

export type SubtitleTrack = {
  index: number;
  label: string;
  language: string | null;
  codec: string | null;
  isDefault: boolean;
  isForced: boolean;
  /** A `/_jf` path to a WebVTT file, or null when this track cannot be delivered as one. */
  url: string | null;
  /** Why `url` is null, in words the picker shows. Null when the track is fine. */
  unavailable: string | null;
};

export type QualityRung = { label: string; videoBitrate: number; maxWidth: number };

/** What `/_officer/items/{id}/playback` answers — the sidecar's own shape, not Jellyfin's. */
export type PlaybackPlan = {
  playMethod: 'DirectPlay' | 'DirectStream' | 'Transcode';
  url: string;
  /** A direct file seeks in the browser; a live transcode does not, and the player restarts it at an offset. */
  seekable: boolean;
  hlsUrl: string | null;
  playSessionId: string | null;
  mediaSourceId: string | null;
  mediaSource: MediaSource;
  startSeconds: number;
  runtimeSeconds: number | null;
  audio: AudioTrack[];
  subtitles: SubtitleTrack[];
  /** Echoed back so the picker shows what is actually playing, not what was last clicked. */
  audioStreamIndex: number | null;
  /** Null means Auto — the server's own copy-or-encode decision rather than a rung the owner picked. */
  maxStreamingBitrate: number | null;
  qualityRungs: QualityRung[];
};

// ── Media URLs ────────────────────────────────────────────────────────────────────────────────────
//
// An <img> cannot send an Authorization header, so media URLs carry `?token=` instead — the escape hatch
// userMiddleware already supports for <img>/<audio>/<video>, and the one the music and photos apps use. These
// point at `/_jf`, the authenticated byte pass-through, which keeps Jellyfin's own path shapes: an HLS
// playlist references its segments relatively, so any renaming here would mean rewriting m3u8 bodies.

export const JELLYFIN_BYTES = '/api/jellyfin/_jf';

const withToken = (url: string, token: string | null) =>
  token ? `${url}${url.includes('?') ? '&' : '?'}token=${encodeURIComponent(token)}` : url;

/** A `/_jf/...` path from a playback plan, made absolute and authenticated for a `<video>` element. */
export const streamUrl = (path: string, token: string | null): string => withToken(`/api/jellyfin${path}`, token);

type ImageKind = 'Primary' | 'Backdrop' | 'Thumb' | 'Logo';

type ImageOptions = { tag?: string | null; maxWidth?: number; maxHeight?: number; index?: number };

export function imageUrl(id: string, kind: ImageKind, token: string | null, options: ImageOptions = {}): string {
  const params = new URLSearchParams();
  // The tag is the image's content hash. Including it is what makes these URLs safely cacheable forever —
  // without it, a replaced poster keeps showing the old one until the browser cache expires.
  if (options.tag) params.set('tag', options.tag);
  if (options.maxWidth) params.set('maxWidth', String(options.maxWidth));
  if (options.maxHeight) params.set('maxHeight', String(options.maxHeight));
  params.set('quality', '90');
  const suffix = options.index != null ? `/${options.index}` : '';
  return withToken(`${JELLYFIN_BYTES}/Items/${id}/Images/${kind}${suffix}?${params}`, token);
}

/**
 * The poster for a grid tile, falling back the way Jellyfin's own clients do.
 *
 * An episode usually has no poster of its own — it has a Thumb, and its series has the Primary. Falling back
 * to the series poster is what stops a season view from being a wall of grey rectangles.
 */
export function posterUrl(item: JellyItem, token: string | null, maxWidth = 400): string | null {
  const primary = item.ImageTags?.Primary;
  if (primary) return imageUrl(item.Id, 'Primary', token, { tag: primary, maxWidth });
  if (item.SeriesPrimaryImageTag && item.SeriesId) {
    return imageUrl(item.SeriesId, 'Primary', token, { tag: item.SeriesPrimaryImageTag, maxWidth });
  }
  const thumb = item.ImageTags?.Thumb;
  if (thumb) return imageUrl(item.Id, 'Thumb', token, { tag: thumb, maxWidth });
  return null;
}

/** The wide still for a Continue Watching card: the episode's own thumb, else a backdrop, else the poster. */
export function thumbUrl(item: JellyItem, token: string | null, maxWidth = 520): string | null {
  const thumb = item.ImageTags?.Thumb;
  if (thumb) return imageUrl(item.Id, 'Thumb', token, { tag: thumb, maxWidth });
  const backdrop = item.BackdropImageTags?.[0];
  if (backdrop) return imageUrl(item.Id, 'Backdrop', token, { tag: backdrop, maxWidth, index: 0 });
  const parentBackdrop = item.ParentBackdropImageTags?.[0];
  if (parentBackdrop && item.ParentBackdropItemId) {
    return imageUrl(item.ParentBackdropItemId, 'Backdrop', token, { tag: parentBackdrop, maxWidth, index: 0 });
  }
  return posterUrl(item, token, maxWidth);
}

export function backdropUrl(item: JellyItem, token: string | null, maxWidth = 1600): string | null {
  const backdrop = item.BackdropImageTags?.[0];
  if (backdrop) return imageUrl(item.Id, 'Backdrop', token, { tag: backdrop, maxWidth, index: 0 });
  const parentBackdrop = item.ParentBackdropImageTags?.[0];
  if (parentBackdrop && item.ParentBackdropItemId) {
    return imageUrl(item.ParentBackdropItemId, 'Backdrop', token, { tag: parentBackdrop, maxWidth, index: 0 });
  }
  return null;
}

// ── Formatting ────────────────────────────────────────────────────────────────────────────────────

export const TICKS_PER_SECOND = 10_000_000;

export const ticksToSeconds = (ticks: number | null | undefined): number => (ticks ? ticks / TICKS_PER_SECOND : 0);

/** Runtime as "1h 42m" / "48m". Blank rather than "0m" when unknown — a wrong number reads as a real one. */
export function formatRuntime(ticks: number | null | undefined): string {
  const total = Math.round(ticksToSeconds(ticks) / 60);
  if (!total) return '';
  const hours = Math.floor(total / 60);
  const minutes = total % 60;
  return hours ? `${hours}h ${minutes}m` : `${minutes}m`;
}

/** Clock time for the player scrubber — "12:34" under an hour, "1:02:03" over it. */
export function formatClock(seconds: number): string {
  if (!Number.isFinite(seconds) || seconds < 0) return '0:00';
  const total = Math.floor(seconds);
  const h = Math.floor(total / 3600);
  const m = Math.floor((total % 3600) / 60);
  const s = total % 60;
  const pad = (n: number) => String(n).padStart(2, '0');
  return h ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
}

export function formatBytes(bytes: number | null | undefined): string {
  if (!bytes) return '—';
  const units = ['B', 'KB', 'MB', 'GB', 'TB'];
  let value = bytes;
  let unit = 0;
  while (value >= 1024 && unit < units.length - 1) {
    value /= 1024;
    unit += 1;
  }
  return `${value.toFixed(value < 10 && unit > 0 ? 1 : 0)} ${units[unit]}`;
}

export const formatShortDate = (iso: string | null | undefined): string =>
  iso ? new Date(iso).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' }) : '—';

/** How far through an item is, 0–100. Jellyfin sends this only sometimes, so it is also derived. */
export function progressPercent(item: JellyItem): number {
  const data = item.UserData;
  if (!data) return 0;
  if (typeof data.PlayedPercentage === 'number') return Math.min(100, Math.max(0, data.PlayedPercentage));
  const position = ticksToSeconds(data.PlaybackPositionTicks);
  const runtime = ticksToSeconds(item.RunTimeTicks);
  return runtime > 0 && position > 0 ? Math.min(100, (position / runtime) * 100) : 0;
}

/** "S2:E5 · Episode name" for an episode, the plain name for anything else. */
export function episodeLabel(item: JellyItem): string {
  if (item.Type !== 'Episode') return item.Name ?? 'Untitled';
  const season = item.ParentIndexNumber != null ? `S${item.ParentIndexNumber}` : '';
  const episode = item.IndexNumber != null ? `E${item.IndexNumber}` : '';
  const number = [season, episode].filter(Boolean).join(':');
  return number ? `${number} · ${item.Name ?? ''}`.trim() : (item.Name ?? 'Untitled');
}

/** The line under a title in a grid: the series for an episode, otherwise the year. */
export function subtitleFor(item: JellyItem): string {
  if (item.Type === 'Episode') return item.SeriesName ?? '';
  if (item.Type === 'Season') return item.SeriesName ?? '';
  return item.ProductionYear ? String(item.ProductionYear) : '';
}

/** Which grids get a 2:3 poster and which get a 16:9 still. Episodes are wide; everything else is tall. */
export const isWideItem = (item: JellyItem): boolean => item.Type === 'Episode';
