import type { UpstreamConfig } from './upstream';
import { DEVICE_PROFILE } from './profile';
import { callUpstream } from './upstream';

// The two halves of the Jellyfin surface Officer exposes.
//
// `/_officer/*` — a small hand-written façade. Every route here exists because the raw Jellyfin call needs
// something the browser should not have to know: the user id in the path, a Fields list that decides whether
// a grid has posters, or a PlaybackInfo negotiation. It is JSON in, JSON out.
//
// `/_jf/*` — a transparent, authenticated, GET-only pass-through for BYTES: images, video, HLS playlists and
// segments, subtitles. It exists because HLS cannot be faked. A master playlist references its variant
// playlists and every segment RELATIVELY, so the URLs a browser derives from it must resolve back onto the
// same prefix; any façade that renamed those paths would have to rewrite the playlists on the way out, and
// rewriting m3u8 bodies to keep a prettier URL scheme is a trade nobody should take.
//
// The pass-through is deny-by-default over a prefix allow-list. Jellyfin's API is the whole server —
// `/System/*`, `/ScheduledTasks/*`, `/Plugins/*`, user administration, the setup wizard — and the token this
// sidecar holds belongs to a real account, so an "everything under /_jf" proxy would put library scanning and
// user creation one URL away from the browser.
//
// One thing here is a security fix rather than plumbing: `TranscodingUrl` comes back from Jellyfin with
// `api_key=<the access token>` embedded in its query string. That URL is handed to the video element, so it
// would put the token in the DOM, in history and in any log that records URLs. It is stripped in
// `sanitizeUpstreamPath` before anything is returned, and the pass-through re-adds the credential as a header
// where it belongs.

/** Ticks are 100-nanosecond units. Jellyfin speaks these everywhere; the UI speaks seconds. */
const TICKS_PER_SECOND = 10_000_000;

const json = (data: unknown, status = 200) => Response.json(data, { status });
const bad = (error: string, status = 400) => json({ error }, status);

/** Pass a Jellyfin JSON response through, keeping its status so a 404 upstream stays a 404 here. */
async function relayJson(res: Response): Promise<Response> {
  const text = await res.text();
  return new Response(text, {
    status: res.status,
    headers: { 'Content-Type': res.headers.get('content-type') ?? 'application/json' },
  });
}

/** GET a Jellyfin path as parsed JSON, or throw with the upstream status attached. */
async function fetchJson<T>(cfg: UpstreamConfig, path: string, params: Record<string, string | undefined>): Promise<T> {
  const res = await callUpstream(cfg, { path, query: buildQuery(params) });
  if (!res.ok) throw new Error(`${path} → ${res.status}`);
  return (await res.json()) as T;
}

function buildQuery(params: Record<string, string | undefined>): string {
  const search = new URLSearchParams();
  for (const [key, value] of Object.entries(params)) {
    if (value != null && value !== '') search.set(key, value);
  }
  const qs = search.toString();
  return qs ? `?${qs}` : '';
}

// The Fields lists. A grid without ImageTags renders grey rectangles, which is the single most common way a
// Jellyfin client looks broken, so the poster-bearing fields are not optional anywhere.
const GRID_FIELDS = 'PrimaryImageAspectRatio,BasicSyncInfo,ProductionYear,Status,EndDate';
const DETAIL_FIELDS =
  'Overview,Genres,Studios,People,Taglines,ProductionYear,OfficialRating,CommunityRating,MediaSources,MediaStreams,Chapters,ExternalUrls,RemoteTrailers,PrimaryImageAspectRatio,ParentId,SeriesStudio';

/**
 * The libraries worth showing.
 *
 * Music is filtered OUT on purpose and not as a stylistic choice: the owner's audio lives in Officer's own
 * /music player, and this machine runs separate Jellyfin instances for albums and DJ sets. A Jellyfin panel
 * that also listed those would be two music libraries competing for the same job.
 */
const VIDEO_COLLECTION_TYPES = new Set(['movies', 'tvshows', 'homevideos', 'musicvideos', 'boxsets', 'playlists']);

type JellyfinItem = { Id?: string; Name?: string; CollectionType?: string; Type?: string };
type ItemsResponse = { Items?: JellyfinItem[]; TotalRecordCount?: number };

async function videoViews(cfg: UpstreamConfig): Promise<JellyfinItem[]> {
  const data = await fetchJson<ItemsResponse>(cfg, `/Users/${cfg.jellyfinUserId}/Views`, {});
  return (data.Items ?? []).filter((view) => !view.CollectionType || VIDEO_COLLECTION_TYPES.has(view.CollectionType));
}

/**
 * The home screen in ONE round trip: libraries, what is half-watched, what is next, and the newest thing in
 * each library. Jellyfin needs four different calls for that, and issuing them from the browser would mean
 * four proxy hops and a screen that assembles itself in visible stages.
 *
 * Latest-per-library is fetched concurrently and failures are swallowed per library: one library that is
 * mid-scan should cost its own shelf, not the whole page.
 */
async function home(cfg: UpstreamConfig): Promise<Response> {
  const views = await videoViews(cfg);

  const [resume, nextUp, latest] = await Promise.all([
    fetchJson<ItemsResponse>(cfg, '/UserItems/Resume', {
      userId: cfg.jellyfinUserId,
      limit: '12',
      mediaTypes: 'Video',
      fields: GRID_FIELDS,
      enableTotalRecordCount: 'false',
    }).catch(() => ({ Items: [] })),
    fetchJson<ItemsResponse>(cfg, '/Shows/NextUp', {
      userId: cfg.jellyfinUserId,
      limit: '12',
      fields: GRID_FIELDS,
      enableTotalRecordCount: 'false',
    }).catch(() => ({ Items: [] })),
    Promise.all(
      views.map(async (view) => ({
        viewId: view.Id ?? '',
        viewName: view.Name ?? '',
        collectionType: view.CollectionType ?? null,
        items: await fetchJson<JellyfinItem[]>(cfg, `/Users/${cfg.jellyfinUserId}/Items/Latest`, {
          parentId: view.Id,
          limit: '12',
          fields: GRID_FIELDS,
        }).catch(() => []),
      })),
    ),
  ]);

  return json({ views, resume: resume.Items ?? [], nextUp: nextUp.Items ?? [], latest });
}

// What a browse grid is allowed to ask for. Deny-by-default again — `/Items` accepts filters that reach
// outside the library (`path`, `userId`) and this list is what keeps the query the UI's business only.
const ITEM_QUERY_PARAMS = [
  'parentId',
  'includeItemTypes',
  'excludeItemTypes',
  'recursive',
  'sortBy',
  'sortOrder',
  'startIndex',
  'limit',
  'searchTerm',
  'filters',
  'genres',
  'genreIds',
  'years',
  'officialRatings',
  'tags',
  'studioIds',
  'personIds',
  'isPlayed',
  'isFavorite',
  'nameStartsWith',
  'imageTypeLimit',
  'enableImageTypes',
  'collapseBoxSetItems',
] as const;

function passthroughItemQuery(url: URL): Record<string, string | undefined> {
  const params: Record<string, string | undefined> = {};
  for (const key of ITEM_QUERY_PARAMS) {
    const value = url.searchParams.get(key);
    if (value != null) params[key] = value;
  }
  return params;
}

/** A browse grid. Defaults to recursive video items so a library id is enough to get something sensible. */
async function items(cfg: UpstreamConfig, url: URL): Promise<Response> {
  const params = passthroughItemQuery(url);
  const res = await callUpstream(cfg, {
    path: '/Items',
    query: buildQuery({
      userId: cfg.jellyfinUserId,
      recursive: params.recursive ?? 'true',
      includeItemTypes: params.includeItemTypes ?? 'Movie,Series,Video',
      sortBy: params.sortBy ?? 'SortName',
      sortOrder: params.sortOrder ?? 'Ascending',
      limit: params.limit ?? '100',
      fields: GRID_FIELDS,
      imageTypeLimit: '1',
      enableImageTypes: 'Primary,Backdrop,Thumb,Logo',
      ...params,
    }),
  });
  return relayJson(res);
}

/** One item, with everything the detail page draws — including MediaSources, which drive the codec line. */
async function item(cfg: UpstreamConfig, id: string): Promise<Response> {
  const res = await callUpstream(cfg, {
    path: `/Items/${encodeURIComponent(id)}`,
    query: buildQuery({ userId: cfg.jellyfinUserId, fields: DETAIL_FIELDS }),
  });
  return relayJson(res);
}

type SourceStream = {
  Type?: string;
  Index?: number;
  Codec?: string;
  Profile?: string;
  Level?: number;
  Width?: number;
  Height?: number;
  BitRate?: number;
  Channels?: number;
  Language?: string | null;
  DisplayTitle?: string | null;
  Title?: string | null;
  IsDefault?: boolean;
  IsForced?: boolean;
  IsExternal?: boolean;
  IsTextSubtitleStream?: boolean;
  /** Jellyfin's own verdict for THIS profile: External (a file we can fetch), Embed, Encode, or None. */
  DeliveryMethod?: string | null;
  DeliveryUrl?: string | null;
};

type MediaSource = {
  Id?: string;
  Container?: string;
  RunTimeTicks?: number;
  SupportsDirectPlay?: boolean;
  SupportsDirectStream?: boolean;
  DirectStreamUrl?: string;
  TranscodingUrl?: string;
  MediaStreams?: SourceStream[];
};
type PlaybackInfoResponse = { MediaSources?: MediaSource[]; PlaySessionId?: string; ErrorCode?: string | null };

/**
 * Strip the credential Jellyfin embeds in the URLs it hands back.
 *
 * `TranscodingUrl` arrives as `/videos/…/main.m3u8?…&api_key=<access token>&…`. Returning it untouched would
 * publish the token to the browser. The pass-through supplies the token as a header on every hop, so the
 * parameter is not merely unsafe, it is redundant.
 */
function sanitizeUpstreamPath(raw: string): string {
  const [path, search = ''] = raw.split('?');
  const params = new URLSearchParams(search);
  params.delete('api_key');
  params.delete('ApiKey');
  params.delete('X-Emby-Token');
  const qs = params.toString();
  return `${path}${qs ? `?${qs}` : ''}`;
}

/**
 * A ceiling high enough to never be the binding constraint on a stream copy. It matches the profile's
 * `MaxStreamingBitrate`: the request has to name a number, and for a copy the right number is "more than
 * whatever this file is", since Jellyfin refuses to copy a stream that exceeds the requested bitrate.
 */
const COPY_CEILING_BITRATE = 120_000_000;

/** What a real CPU re-encode targets. 1080p at 12 Mbit is visually generous for h264 at the crf Jellyfin uses. */
const ENCODE_BITRATE = 12_000_000;
const ENCODE_MAX_WIDTH = 1920;

/**
 * The rungs the quality picker offers, widest first. A bitrate alone is not a quality: Jellyfin's own
 * `ResolutionNormalizer` would pair a low one with a resolution of its choosing, and the whole 416-pixel
 * incident was that mapping being applied to a number nobody meant. Naming both here is what makes
 * "720p" mean 720p.
 */
export const QUALITY_RUNGS = [
  { label: '1080p', videoBitrate: 12_000_000, maxWidth: 1920 },
  { label: '720p', videoBitrate: 6_000_000, maxWidth: 1280 },
  { label: '480p', videoBitrate: 2_500_000, maxWidth: 854 },
  { label: '360p', videoBitrate: 1_000_000, maxWidth: 640 },
] as const;

/**
 * Decide the bitrate and resolution cap to ask for, from what is actually in the file.
 *
 * With no `requested` ceiling — the "Auto" the player sends by default — the two cases pull in opposite
 * directions, which is why this is not one constant:
 *
 *   video can be copied → any cap is a reason for Jellyfin to REFUSE the copy (it will not copy a stream
 *                         above the requested bitrate, or wider than a requested `maxWidth`), so ask for a
 *                         ceiling nothing hits and no width at all. The bytes are passed through untouched.
 *   video is re-encoded → a cap is the only thing keeping ffmpeg tractable. There is no /dev/dri in the
 *                         Jellyfin container, so 4K HDR HEVC is being decoded, tonemapped and encoded on the
 *                         CPU; downscaling to 1080p is what makes that keep up with playback.
 *
 * The copy test mirrors the profile's own conditions rather than a new opinion: h264, not 10-bit, level ≤ 5.2.
 *
 * A `requested` ceiling overrides both, and deliberately defeats the copy: picking 480p on a file that would
 * have passed through untouched is asking for a re-encode, and that is the entire point of picking it.
 */
export type TranscodeQuality = { videoBitrate: number; maxWidth?: number };

function transcodeQuality(source: MediaSource, requested?: number): TranscodeQuality {
  if (requested && requested < COPY_CEILING_BITRATE) {
    const rung = QUALITY_RUNGS.find((r) => r.videoBitrate <= requested) ?? QUALITY_RUNGS[QUALITY_RUNGS.length - 1]!;
    return { videoBitrate: rung.videoBitrate, maxWidth: rung.maxWidth };
  }

  const video = source.MediaStreams?.find((stream) => stream.Type === 'Video');
  const copyable =
    video?.Codec?.toLowerCase() === 'h264' && video.Profile?.toLowerCase() !== 'high 10' && (video.Level ?? 0) <= 52;

  if (copyable) return { videoBitrate: COPY_CEILING_BITRATE };
  return { videoBitrate: ENCODE_BITRATE, maxWidth: ENCODE_MAX_WIDTH };
}

/** `DisplayTitle` is what Jellyfin shows in its own picker; the rest is a legible fallback when it is absent. */
function trackLabel(stream: SourceStream, fallback: string): string {
  const parts = [stream.DisplayTitle || stream.Title, stream.Language, stream.Codec?.toUpperCase()].filter(Boolean);
  return (parts[0] as string) || parts.join(' · ') || fallback;
}

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 can show. Null when the track is fine. */
  unavailable: string | null;
};

/**
 * Turn the source's streams into the two pickers the player offers.
 *
 * Subtitles are the interesting half. Jellyfin has already decided, for OUR profile, how each one could be
 * delivered, and `DeliveryMethod: 'External'` with a `DeliveryUrl` means "fetch this as a file" — which for
 * the profile's declared formats is WebVTT the `<track>` element reads directly. That is the only method
 * taken. `Encode` means burning the subtitle into the picture, which forces a full video re-encode of a file
 * that may have needed nothing but a remux, so an image-based track (PGS, VOBSUB) is reported as unavailable
 * WITH ITS REASON rather than quietly dropped — "why is this subtitle missing" should be answerable from the
 * picker instead of from the server logs.
 */
function describeTracks(source: MediaSource): { audio: AudioTrack[]; subtitles: SubtitleTrack[] } {
  const streams = source.MediaStreams ?? [];

  const audio = streams
    .filter((stream) => stream.Type === 'Audio' && stream.Index !== undefined)
    .map((stream) => ({
      index: stream.Index!,
      label: trackLabel(stream, `Track ${stream.Index}`),
      language: stream.Language ?? null,
      codec: stream.Codec ?? null,
      channels: stream.Channels ?? null,
      isDefault: stream.IsDefault === true,
    }));

  const subtitles = streams
    .filter((stream) => stream.Type === 'Subtitle' && stream.Index !== undefined)
    .map((stream) => {
      const external = stream.DeliveryMethod === 'External' && !!stream.DeliveryUrl;
      const graphical = stream.IsTextSubtitleStream === false;
      return {
        index: stream.Index!,
        label: trackLabel(stream, `Subtitle ${stream.Index}`),
        language: stream.Language ?? null,
        codec: stream.Codec ?? null,
        isDefault: stream.IsDefault === true,
        isForced: stream.IsForced === true,
        url: external ? `/_jf${sanitizeUpstreamPath(stream.DeliveryUrl!)}` : null,
        unavailable: external
          ? null
          : graphical
            ? 'image-based subtitles can only be burned into the video, which forces a full re-encode'
            : `the server offered no external file for this track (delivery: ${stream.DeliveryMethod ?? 'none'})`,
      };
    });

  return { audio: disambiguate(audio), subtitles: disambiguate(subtitles) };
}

/**
 * Make every label in a list distinguishable.
 *
 * Two subtitle tracks on the same file genuinely do come back as "English - ASS" and "English - ASS" — one is
 * usually dialogue and the other signs, and Jellyfin's own `DisplayTitle` does not say which. A picker with
 * two identical rows is a coin flip, so colliding labels get their stream index, which at least makes the
 * choice repeatable.
 */
function disambiguate<T extends { index: number; label: string }>(tracks: T[]): T[] {
  const counts = new Map<string, number>();
  for (const track of tracks) counts.set(track.label, (counts.get(track.label) ?? 0) + 1);
  return tracks.map((track) =>
    counts.get(track.label)! > 1 ? { ...track, label: `${track.label} #${track.index}` } : track,
  );
}

/**
 * Fix up the HLS URL Jellyfin handed back so it carries the same quality decision as the progressive one.
 *
 * `TranscodingUrl` is built by the server from our `DeviceProfile`, which is why it is taken rather than
 * reinvented: it already carries the segment container, the audio codec list, the play session and the
 * transcode reasons, and every one of those is the server's business rather than ours. What it gets wrong for
 * this machine is exactly two values — it resolves `VideoBitrate` from `MaxStreamingBitrate` (~119 Mbit, the
 * ceiling that exists to let a stream COPY through) and never sets a width. For a file that can be copied that
 * is right. For a file that must be re-encoded it asks a CPU-only container to encode 4K, which cannot keep up
 * with playback. So those two are overwritten from `transcodeQuality`, and nothing else is touched.
 *
 * Verified against a real answer from this server: the URL comes back with `VideoBitrate=119616000` and no
 * width, on an item whose transcode reason was `ContainerNotSupported` alone.
 */
function hlsPath(source: MediaSource, quality: TranscodeQuality): string {
  const sanitized = sanitizeUpstreamPath(source.TranscodingUrl!);
  const [path, search = ''] = sanitized.split('?');
  const params = new URLSearchParams(search);

  // Jellyfin's own casing here is PascalCase, and its query parsing is case-insensitive — but a duplicate key
  // under a different case would still be two values, so delete before setting rather than trusting that.
  for (const key of [...params.keys()]) {
    const lower = key.toLowerCase();
    if (lower === 'videobitrate' || lower === 'maxwidth' || lower === 'maxheight') params.delete(key);
  }
  params.set('VideoBitrate', String(quality.videoBitrate));
  if (quality.maxWidth) params.set('MaxWidth', String(quality.maxWidth));

  return `${path}?${params.toString()}`;
}

/**
 * Build the PROGRESSIVE transcode URL — one continuous mp4 the `<video>` element can play with no library.
 *
 * This is the FALLBACK now, not the primary: the player prefers `hlsUrl`, whose playlist covers the whole
 * runtime and therefore seeks properly. This path stays for the browser that cannot do MSE at all (iOS
 * Safari's native HLS covers itself, but a stripped-down webview may have neither), and it is why the plan
 * still carries a plain `url`.
 *
 * What it costs is honest and worth stating: a live transcode has no length and no byte ranges, so the
 * browser cannot seek it. The player seeks by asking for a NEW stream at an offset — which is what
 * `startTimeTicks` is for here, and why the UI re-requests playback on every scrub when it falls back here.
 *
 * `allowVideoStreamCopy` is what keeps this cheap for the common case. An mkv whose video is already h264
 * gets REMUXED, not re-encoded: the container changes, the video bytes are copied. That matters on this
 * machine, where the Jellyfin container has no /dev/dri and every real encode is on the CPU.
 *
 * `videoBitrate` IS NOT OPTIONAL, and omitting it is not "let the server choose". Jellyfin resolves a missing
 * bitrate to 0 and then runs `ResolutionNormalizer` over it, which maps a bitrate to a resolution — 0 lands on
 * the bottom rung. Verified on this machine: without it, a 3840x1600 source transcoded through
 * `scale=…min(max(iw,ih*a),416)…` with `-maxrate 0`, i.e. 416 pixels wide. That is the whole of the "why does
 * it look so bad" question, and it is a silent, plausible-looking picture rather than an error.
 */
type ProgressiveParams = {
  id: string;
  source: MediaSource;
  playSessionId: string | null;
  startSeconds: number;
  quality: TranscodeQuality;
};

function progressivePath({ id, source, playSessionId, startSeconds, quality }: ProgressiveParams): string {
  return `/Videos/${encodeURIComponent(id)}/stream.mp4${buildQuery({
    static: 'false',
    container: 'mp4',
    mediaSourceId: source.Id,
    playSessionId: playSessionId ?? undefined,
    videoCodec: 'h264',
    audioCodec: 'aac',
    audioBitrate: '192000',
    maxAudioChannels: '2',
    allowVideoStreamCopy: 'true',
    allowAudioStreamCopy: 'true',
    videoBitrate: String(quality.videoBitrate),
    maxWidth: quality.maxWidth ? String(quality.maxWidth) : undefined,
    // No `subtitleMethod=Encode` here on purpose: burning subtitles in forces a full video re-encode, which
    // throws away the stream copy above and pins the CPU on a file that needed nothing but a remux. Subtitles
    // are a deliberate later feature, chosen per-item, not a default that quietly costs that much.
    startTimeTicks: startSeconds > 0 ? String(Math.round(startSeconds * TICKS_PER_SECOND)) : undefined,
  })}`;
}

/**
 * Negotiate playback: ask the server what it can do with this file for this profile, and turn the answer into
 * one URL the player can use.
 *
 * The three outcomes are direct play (the original container is browser-playable), direct stream (remuxed on
 * the fly, still `/Videos/{id}/stream`) and transcode. They are reported explicitly rather than inferred,
 * because "why is my CPU pinned" is a question the UI should be able to answer.
 *
 * `hlsUrl` is returned alongside whenever Jellyfin offered one, and is what the player actually uses for a
 * transcode — hls.js is vendored for it (`apps/Jellyfin/vendor/`). `url` remains the progressive fallback for
 * a browser with no MSE, so both are carried and the player chooses.
 */
async function playbackInfo(cfg: UpstreamConfig, id: string, req: Request, url: URL): Promise<Response> {
  const startSeconds = Number(url.searchParams.get('startSeconds') ?? '0');
  const body = (await req.json().catch(() => ({}))) as {
    mediaSourceId?: string;
    audioStreamIndex?: number;
    subtitleStreamIndex?: number;
    maxStreamingBitrate?: number;
  };

  const res = await callUpstream(cfg, {
    path: `/Items/${encodeURIComponent(id)}/PlaybackInfo`,
    method: 'POST',
    query: buildQuery({ userId: cfg.jellyfinUserId }),
    contentType: 'application/json',
    body: JSON.stringify({
      DeviceProfile: DEVICE_PROFILE,
      UserId: cfg.jellyfinUserId,
      MaxStreamingBitrate: body.maxStreamingBitrate ?? DEVICE_PROFILE.MaxStreamingBitrate,
      StartTimeTicks: Math.max(0, Math.round(startSeconds * TICKS_PER_SECOND)),
      MediaSourceId: body.mediaSourceId,
      AudioStreamIndex: body.audioStreamIndex,
      SubtitleStreamIndex: body.subtitleStreamIndex,
      EnableDirectPlay: true,
      EnableDirectStream: true,
      EnableTranscoding: true,
      AllowVideoStreamCopy: true,
      AllowAudioStreamCopy: true,
      // Without this the transcode is only prepared, not started, and the first segment request 404s.
      AutoOpenLiveStream: true,
    }),
  });

  if (!res.ok) return relayJson(res);

  const info = (await res.json()) as PlaybackInfoResponse;
  const source = info.MediaSources?.find((s) => s.Id === body.mediaSourceId) ?? info.MediaSources?.[0];
  if (!source) return bad('the server returned no playable source for this item', 502);

  const playSessionId = info.PlaySessionId ?? null;
  const direct = !!(source.SupportsDirectPlay || source.SupportsDirectStream);
  const quality = transcodeQuality(source, body.maxStreamingBitrate);

  const staticPath = `/Videos/${encodeURIComponent(id)}/stream${buildQuery({
    static: 'true',
    mediaSourceId: source.Id,
    playSessionId: playSessionId ?? undefined,
  })}`;

  const path = direct
    ? source.SupportsDirectPlay
      ? staticPath
      : (source.DirectStreamUrl && sanitizeUpstreamPath(source.DirectStreamUrl)) || staticPath
    : // A `TranscodingUrl` is Jellyfin agreeing to transcode. We ask for the same thing progressively
      // instead of taking its HLS URL, but its absence still means "refused", so it stays the signal.
      source.TranscodingUrl
      ? progressivePath({ id, source, playSessionId, startSeconds, quality })
      : null;

  if (!path) {
    return bad(info.ErrorCode ? `playback refused: ${info.ErrorCode}` : 'the server offered no way to play this', 502);
  }

  return json({
    playMethod: direct ? (source.SupportsDirectPlay ? 'DirectPlay' : 'DirectStream') : 'Transcode',
    // Relative to the panel's mount, so the browser prefixes /api/jellyfin and the pass-through does the rest.
    url: `/_jf${path}`,
    // A direct file is byte-range seekable; a live transcode is not, and the player has to restart the stream
    // at an offset instead. This flag is the difference, and it is the one thing the player cannot guess.
    seekable: direct,
    // The seekable form of a transcode: the playlist spans the whole runtime, so a scrub is a segment request
    // rather than a new ffmpeg. Preferred by the player whenever it is present and MSE is available.
    hlsUrl: source.TranscodingUrl ? `/_jf${hlsPath(source, quality)}` : null,
    playSessionId,
    mediaSourceId: source.Id ?? null,
    mediaSource: source,
    startSeconds,
    runtimeSeconds: source.RunTimeTicks ? source.RunTimeTicks / TICKS_PER_SECOND : null,
    // The pickers. Audio needs a re-negotiation to change (it is muxed into the stream); subtitles do not,
    // because an external VTT is a separate file the <track> element fetches on its own.
    ...describeTracks(source),
    audioStreamIndex: body.audioStreamIndex ?? null,
    /** Null means Auto — the copy-or-encode decision above, rather than a rung the owner picked. */
    maxStreamingBitrate: body.maxStreamingBitrate ?? null,
    qualityRungs: QUALITY_RUNGS,
  });
}

/** Playback reporting — start, heartbeat, stop. Bodies are the upstream's own shapes, forwarded as sent. */
async function reportPlaystate(cfg: UpstreamConfig, action: string, req: Request): Promise<Response> {
  const path =
    action === 'playing'
      ? '/Sessions/Playing'
      : action === 'progress'
        ? '/Sessions/Playing/Progress'
        : action === 'stopped'
          ? '/Sessions/Playing/Stopped'
          : null;
  if (!path) return bad('not found', 404);

  const body = await req.text();
  const res = await callUpstream(cfg, { path, method: 'POST', contentType: 'application/json', body });
  // 204 with no body is the normal answer; relaying it as JSON would invent content that is not there.
  return new Response(null, { status: res.status });
}

/** Watched and favourite marks. POST sets, DELETE clears — the same shape Jellyfin's own routes use. */
async function userItemFlag(cfg: UpstreamConfig, kind: 'played' | 'favorite', id: string, method: string) {
  const base = kind === 'played' ? '/UserPlayedItems' : '/UserFavoriteItems';
  const res = await callUpstream(cfg, {
    path: `${base}/${encodeURIComponent(id)}`,
    method: method === 'DELETE' ? 'DELETE' : 'POST',
    query: buildQuery({ userId: cfg.jellyfinUserId }),
  });
  return relayJson(res);
}

/** The `/_officer/*` façade. Returns null when nothing matched, so index.ts can answer a single 404. */
export async function handleOfficerRoute(cfg: UpstreamConfig, req: Request, url: URL): Promise<Response | null> {
  const [head, a, b] = url.pathname.slice('/_officer/'.length).split('/');
  const method = req.method;

  if (head === 'home' && method === 'GET') return home(cfg);

  if (head === 'views' && method === 'GET') return json({ views: await videoViews(cfg) });

  if (head === 'items' && !a && method === 'GET') return items(cfg, url);

  if (head === 'items' && a) {
    if (!b && method === 'GET') return item(cfg, a);
    if (b === 'similar' && method === 'GET') {
      const res = await callUpstream(cfg, {
        path: `/Items/${encodeURIComponent(a)}/Similar`,
        query: buildQuery({ userId: cfg.jellyfinUserId, limit: '12', fields: GRID_FIELDS }),
      });
      return relayJson(res);
    }
    if (b === 'playback' && method === 'POST') return playbackInfo(cfg, a, req, url);
    if ((b === 'played' || b === 'favorite') && (method === 'POST' || method === 'DELETE')) {
      return userItemFlag(cfg, b, a, method);
    }
  }

  if (head === 'shows' && a && method === 'GET') {
    if (b === 'seasons') {
      const res = await callUpstream(cfg, {
        path: `/Shows/${encodeURIComponent(a)}/Seasons`,
        query: buildQuery({ userId: cfg.jellyfinUserId, fields: GRID_FIELDS }),
      });
      return relayJson(res);
    }
    if (b === 'episodes') {
      const res = await callUpstream(cfg, {
        path: `/Shows/${encodeURIComponent(a)}/Episodes`,
        query: buildQuery({
          userId: cfg.jellyfinUserId,
          seasonId: url.searchParams.get('seasonId') ?? undefined,
          fields: `${GRID_FIELDS},Overview,MediaSources`,
        }),
      });
      return relayJson(res);
    }
  }

  if (head === 'resume' && method === 'GET') {
    const res = await callUpstream(cfg, {
      path: '/UserItems/Resume',
      query: buildQuery({ userId: cfg.jellyfinUserId, limit: '24', mediaTypes: 'Video', fields: GRID_FIELDS }),
    });
    return relayJson(res);
  }

  if (head === 'nextup' && method === 'GET') {
    const res = await callUpstream(cfg, {
      path: '/Shows/NextUp',
      query: buildQuery({ userId: cfg.jellyfinUserId, limit: '24', fields: GRID_FIELDS }),
    });
    return relayJson(res);
  }

  if (head === 'search' && method === 'GET') {
    const term = url.searchParams.get('q')?.trim();
    if (!term) return json({ Items: [], TotalRecordCount: 0 });
    const res = await callUpstream(cfg, {
      path: '/Items',
      query: buildQuery({
        userId: cfg.jellyfinUserId,
        searchTerm: term,
        recursive: 'true',
        includeItemTypes: 'Movie,Series,Episode,Video,BoxSet,Person',
        limit: '48',
        fields: GRID_FIELDS,
      }),
    });
    return relayJson(res);
  }

  if (head === 'genres' && method === 'GET') {
    const res = await callUpstream(cfg, {
      path: '/Genres',
      query: buildQuery({ userId: cfg.jellyfinUserId, parentId: url.searchParams.get('parentId') ?? undefined }),
    });
    return relayJson(res);
  }

  if (head === 'sessions' && a && method === 'POST') return reportPlaystate(cfg, a, req);

  return null;
}

// The pass-through allow-list. Anything not matching one of these is refused, including every administrative
// route. Matched case-insensitively because Jellyfin's own HLS playlists reference `/videos/…` in lower case
// while its OpenAPI document says `/Videos/…`, and a browser resolving a relative segment URL will send back
// whatever the playlist said.
const BYTES_PREFIXES = [
  'items/', // artwork: /Items/{id}/Images/{type}
  'videos/', // direct stream, HLS playlists and segments, subtitles
  'users/', // /Users/{id}/Images/Primary — the account avatar
  'persons/', // cast portraits
  'studios/',
  'genres/',
  'musicgenres/',
];

const isAllowedBytesPath = (path: string): boolean => {
  const lower = path.toLowerCase();
  return BYTES_PREFIXES.some((prefix) => lower.startsWith(prefix));
};

/**
 * `/_jf/*` — bytes only, GET only, allow-listed.
 *
 * `Accept: * / *` matters: the default JSON Accept makes Jellyfin answer some image routes with a JSON error
 * instead of the picture. Range is forwarded so seeking works, and the response headers are relayed nearly
 * whole — content-range, etag and cache-control are what make the `<video>` element and the browser cache
 * behave, and dropping them turns seeking into a re-download.
 */
export async function handleBytesRoute(cfg: UpstreamConfig, req: Request, url: URL): Promise<Response | null> {
  if (req.method !== 'GET' && req.method !== 'HEAD') return null;

  const subpath = url.pathname.slice('/_jf/'.length);
  if (!subpath || subpath.includes('..')) return null;
  if (!isAllowedBytesPath(subpath)) return null;

  const upstream = await callUpstream(cfg, {
    path: `/${subpath}`,
    method: req.method,
    query: url.search,
    accept: '*/*',
    range: req.headers.get('range'),
  });

  const headers = new Headers();
  for (const header of [
    'content-type',
    'content-length',
    'content-range',
    'accept-ranges',
    'etag',
    'last-modified',
    'cache-control',
  ] as const) {
    const value = upstream.headers.get(header);
    if (value) headers.set(header, value);
  }

  return new Response(upstream.body, { status: upstream.status, headers });
}
