import { notify, remove } from "./notifications";
import i18n from "../i18n";
import { reportError } from "./errorDialog";
import { activeAccountId, ACTIVE_ACCOUNT_KEY } from "./storage";

export interface Me {
  id: number;
  email: string;
  display_name: string | null;
  avatar_url: string | null;
  role: string;
  is_demo: boolean;
  has_google: boolean;
  has_password: boolean;
  google_enabled: boolean;
  plex_enabled: boolean;
  can_read: boolean;
  can_write: boolean;
  pending_invites: number;
  preferences: Record<string, any>;
}

export interface DemoWhitelistEntry {
  id: number;
  email: string;
  note: string | null;
  added_by: string | null;
  created_at: string | null;
}

export interface Invite {
  id: number;
  email: string;
  status: "pending" | "approved" | "denied";
  note: string | null;
  requested_at: string | null;
  decided_at: string | null;
  decided_by: string | null;
}

export interface Tag {
  id: number;
  name: string;
  color: string | null;
  category: string;
  system: boolean;
  channel_count: number;
}

export interface Video {
  id: string;
  title: string | null;
  channel_id: string;
  channel_title: string | null;
  channel_thumbnail: string | null;
  channel_url: string;
  published_at: string | null;
  thumbnail_url: string | null;
  duration_seconds: number | null;
  view_count: number | null;
  is_short: boolean;
  live_status: string;
  status: string;
  position_seconds: number;
  saved: boolean; // is the video in the user's built-in Watch later playlist
  watch_url: string;
}

export interface VideoDetail {
  id: string;
  description: string | null;
  like_count: number | null;
  in_db: boolean;
  channel_id: string | null;
  channel_title: string | null;
  published_at: string | null;
  view_count: number | null;
  duration_seconds: number | null;
}

interface ChannelLink {
  title: string | null;
  url: string;
}

export interface ChannelDetail {
  id: string;
  title: string | null;
  handle: string | null;
  description: string | null;
  thumbnail_url: string | null;
  banner_url: string | null;
  subscriber_count: number | null;
  video_count: number | null;
  total_view_count: number | null;
  published_at: string | null;
  external_links: ChannelLink[];
  country: string | null;
  default_language: string | null;
  topic_categories: string[];
  keywords: string | null;
  subscribed: boolean;
  explored: boolean;
  blocked: boolean;
  stored_videos: number;
  from_explore: boolean;
  details_synced: boolean;
}

export interface ExploreResult {
  channel_id: string;
  ingested: number;
  next_page_token: string | null;
}

export interface FeedResponse {
  items: Video[];
  has_more: boolean;
  // Opaque keyset cursor for the next page, or null when this is the last page.
  next_cursor: string | null;
  limit: number;
  // Only set by the live YouTube search: "scrape" (InnerTube, no API quota) or "api"
  // (search.list, 100 units/page) — lets the UI drop the quota warning in scrape mode.
  source?: "scrape" | "api";
}

export interface Playlist {
  id: number;
  name: string;
  kind: string; // "user" | "watch_later"
  source: string; // "local" | "youtube"
  yt_playlist_id: string | null;
  dirty: boolean; // linked local playlist has edits not yet pushed to YouTube
  item_count: number;
  total_duration_seconds: number;
  cover_thumbnail: string | null;
  has_video?: boolean; // only set when listed with ?contains=<video_id>
}

export interface PlaylistDetail {
  id: number;
  name: string;
  kind: string;
  source: string;
  yt_playlist_id: string | null;
  dirty: boolean;
  items: Video[];
}

export interface PushPlan {
  action: "create" | "update";
  to_insert: number;
  to_delete: number;
  to_reorder: number;
  yt_extra: number; // items on YouTube that the push would remove (divergence)
  units_estimate: number;
  remaining_today: number;
  affordable: boolean;
}

export interface PushResult {
  created: number;
  inserted: number;
  deleted: number;
  reordered: number;
  failures: string[];
  yt_playlist_id: string | null;
}

export interface FeedFilters {
  tags: number[];
  tagMode: "or" | "and";
  q: string;
  sort: string;
  // Re-roll token for the "shuffle" sort; bumped by the reshuffle button so the
  // feed re-queries with a fresh order. Ignored by every other sort.
  seed?: number;
  // "my" = only your subscriptions (default); "all" = the whole shared catalog.
  scope: "my" | "all";
  includeNormal: boolean;
  includeShorts: boolean;
  includeLive: boolean;
  show: string;
  // Library (scope "all") only — provenance filter: "organic" (default) hides search-
  // discovered videos, "all" mixes them in, "search" shows only them. Ignored for "my".
  librarySource?: "organic" | "all" | "search" | "plex";
  channelId?: string;
  channelName?: string;
  maxAgeDays?: number;
  minDuration?: number;
  maxDuration?: number;
  dateFrom?: string;
  dateTo?: string;
}

// A saved "smart view": a per-user named snapshot of FeedFilters (see SavedViewsWidget).
export interface SavedView {
  id: number;
  name: string;
  filters: FeedFilters;
  position: number;
  is_default: boolean;
}

export interface VersionInfo {
  app_version: string;
  git_sha: string;
  build_date: string | null;
  db_revision: string | null;
}

class HttpError extends Error {
  status: number;
  detail?: string; // server-provided reason (FastAPI's `detail`), when present
  detailData?: any; // structured detail ({ code, reason, ... }) when the server sent an object
  constructor(status: number, detail?: string, detailData?: any) {
    super(detail || `HTTP ${status}`);
    this.status = status;
    this.detail = detail;
    this.detailData = detailData;
  }
}

// Localize a STRUCTURED server error ({ code, reason, ... }) into the user's language. FastAPI
// usually returns a plain string `detail`, but some routes (e.g. download quota/edit) return a
// structured object with an i18n reason key + numbers so HU/DE users don't get hardcoded English
// with a dot-decimal separator. Falls back to a generic message for unknown shapes.
function localizeDetail(d: any): string {
  const t = i18n.t.bind(i18n);
  if (d?.code === "quota") {
    if (d.reason === "max_bytes") {
      const gb = new Intl.NumberFormat(i18n.language, { maximumFractionDigits: 1 }).format(
        (d.limit || 0) / 1_073_741_824,
      );
      return t("downloads.errors.quota.max_bytes", { size: gb });
    }
    if (d.reason === "max_jobs") return t("downloads.errors.quota.max_jobs", { limit: d.limit });
    return t("downloads.errors.quota.generic");
  }
  if (d?.code === "edit") return t(`downloads.errors.edit.${d.reason}`, t("downloads.errors.edit.generic"));
  return t("errors.generic");
}

// A single, self-resolving "connection lost" status: while the server is unreachable we
// show one sticky, transient (non-persisted) notice; the moment any request reaches the
// server again we remove it. This makes good on its "clears once it's back" copy and keeps
// bursts of concurrent failures from stacking up — there's only ever one live handle.
let connectivityNotifId: number | null = null;
function markConnectivityLost(): void {
  if (connectivityNotifId !== null) return;
  connectivityNotifId = notify({
    level: "error",
    title: i18n.t("errors.offline.title"),
    message: i18n.t("errors.offline.body"),
    transient: true,
  });
}
function markConnectivityRestored(): void {
  if (connectivityNotifId === null) return;
  remove(connectivityNotifId);
  connectivityNotifId = null;
}

// Gateway statuses that mean "the upstream connection failed", not "the app rejected the
// request" — typically a reverse-proxy ↔ uvicorn keepalive connection reset before the
// request was processed. Safe to retry an idempotent call once on a fresh connection.
const RETRIABLE_GATEWAY = new Set([502, 503, 504]);
const delay = (ms: number) => new Promise((res) => window.setTimeout(res, ms));

// `idempotent`: may this request be replayed after a transient gateway/network failure?
// GETs always can; non-GET callers opt in (e.g. the player's progress/state writes, which
// are safe to repeat). Used to recover from the keepalive race without surfacing a 502.
interface ReqConfig {
  idempotent?: boolean;
  // Suppress the global error modal for 4xx/5xx so the caller can show the error inline
  // (used by the auth forms — a wrong password shouldn't pop a blocking dialog).
  quiet?: boolean;
}

// Set by the app shell. Invoked whenever any request returns 401 so a session that ended
// server-side (account suspended/deleted while the tab was open) can drop the user to the login
// page. The handler itself guards against firing when we were never signed in, so it's safe to
// call for every 401. (The bootstrap /api/me probe no longer 401s — it returns 200 with a null
// user when logged out — but other protected endpoints still do.)
let onUnauthorized: (() => void) | null = null;
export function setUnauthorizedHandler(fn: (() => void) | null): void {
  onUnauthorized = fn;
}

// Headers for install-wizard calls: the one-time setup token plus the JSON content type (req
// replaces — not merges — its default headers when opts.headers is given, so include both).
const setupHeaders = (token: string) => ({
  "Content-Type": "application/json",
  "X-Setup-Token": token,
});

// --- Per-tab active account --------------------------------------------------------------
// The signed session cookie is the browser's account "wallet"; which account a given TAB acts
// as is chosen here and sent per-request, so two tabs can run two accounts at once. Stored in
// sessionStorage (per-tab, survives F5, not shared across tabs). null = use the session default.
// The storage key + reader live in storage.ts (so its per-account helpers can use them too).
const ACTIVE_ACCOUNT_HEADER = "X-Siftlode-Account";

export const getActiveAccount = activeAccountId;

// Fire-and-forget POST that survives page unload (keepalive) — shared by the resume-position
// beacons (YT + Plex players). A normal fetch is cancelled on unload and React effect cleanup
// doesn't run on a full reload, so without keepalive the last position (esp. right after a seek)
// would be lost.
function beacon(url: string, body: Record<string, unknown>): void {
  const active = getActiveAccount();
  try {
    void fetch(url, {
      method: "POST",
      keepalive: true,
      credentials: "include",
      headers: {
        "Content-Type": "application/json",
        ...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}),
      },
      body: JSON.stringify(body),
    }).catch(() => {});
  } catch {
    /* ignore */
  }
}

export function setActiveAccount(id: number): void {
  try {
    sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, String(id));
  } catch {
    /* ignore */
  }
}
export function clearActiveAccount(): void {
  try {
    sessionStorage.removeItem(ACTIVE_ACCOUNT_KEY);
  } catch {
    /* ignore */
  }
}

async function req(url: string, opts: RequestInit = {}, cfg: ReqConfig = {}): Promise<any> {
  const method = opts.method ?? "GET";
  const canRetry = cfg.idempotent ?? method === "GET";
  let attempt = 0;

  for (;;) {
    let r: Response;
    try {
      const active = getActiveAccount();
      r = await fetch(url, {
        credentials: "include",
        headers: {
          "Content-Type": "application/json",
          // Per-tab identity override (only setup calls pass their own headers, and those are
          // pre-auth, so this is safe to put in the defaults that `...opts` may replace).
          ...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}),
        },
        ...opts,
      });
    } catch (e) {
      // Network-level failure (incl. a keepalive connection reset surfacing as a TypeError).
      if (canRetry && attempt === 0) {
        attempt++;
        await delay(250);
        continue;
      }
      markConnectivityLost();
      throw e;
    }

    // A gateway error usually means the upstream connection was reset before our request
    // was processed — retry idempotent calls once on a fresh connection before surfacing it.
    if (!r.ok && canRetry && attempt === 0 && RETRIABLE_GATEWAY.has(r.status)) {
      attempt++;
      await delay(250);
      continue;
    }

    // The server answered (anything that isn't a gateway error means it's reachable) —
    // clear a standing "connection lost" status if one is showing.
    if (!RETRIABLE_GATEWAY.has(r.status)) markConnectivityRestored();

    if (!r.ok) {
      // Capture the server's reason (FastAPI returns `{ detail }`) so callers can react.
      let detail: string | undefined;
      let detailData: any;
      try {
        const body = await r.json();
        if (body && typeof body.detail === "string") detail = body.detail;
        else if (body && body.detail && typeof body.detail === "object") {
          detailData = body.detail;
          detail = localizeDetail(body.detail); // structured error → localized message
        }
      } catch {
        /* no JSON body */
      }
      // 502/503/504 mean the gateway couldn't reach the app (restarting / connection reset),
      // not a request the app refused — treat them like a network blip with the soft,
      // self-clearing toast rather than a blocking modal the user must acknowledge.
      // A genuine 500 (real fault, carries a JSON detail) still gets the modal, as do
      // 400/409/422 (validation/conflict). 401/403/404 are caller-handled control flow.
      if (RETRIABLE_GATEWAY.has(r.status)) {
        markConnectivityLost();
      } else if (r.status === 401) {
        // Session ended server-side (e.g. the account was suspended or deleted mid-visit). Let the
        // app shell decide what to do (drop to the login page if we were signed in). Not a modal.
        onUnauthorized?.();
      } else if (cfg.quiet) {
        /* caller handles the error inline — no global modal */
      } else if (r.status >= 500) {
        reportError(detail || `${i18n.t("errors.server")} (${r.status})`);
      } else if (r.status === 400 || r.status === 409 || r.status === 422) {
        reportError(detail);
      }
      throw new HttpError(r.status, detail, detailData);
    }
    return r.status === 204 ? null : r.json();
  }
}

// The filter half of the query (everything except paging/sort), shared by the feed and
// the facet-count endpoint so both see exactly the same filter context.
function filterParams(f: FeedFilters): URLSearchParams {
  const p = new URLSearchParams();
  f.tags.forEach((t) => p.append("tags", String(t)));
  p.set("tag_mode", f.tagMode);
  if (f.q) p.set("q", f.q);
  p.set("scope", f.scope);
  p.set("show_normal", String(f.includeNormal));
  p.set("include_shorts", String(f.includeShorts));
  p.set("include_live", String(f.includeLive));
  p.set("show", f.show);
  if (f.channelId) p.set("channel_id", f.channelId);
  if (f.maxAgeDays) p.set("max_age_days", String(f.maxAgeDays));
  if (f.dateFrom) p.set("published_after", f.dateFrom);
  if (f.dateTo) p.set("published_before", f.dateTo);
  if (f.minDuration != null) p.set("min_duration", String(f.minDuration));
  if (f.maxDuration != null) p.set("max_duration", String(f.maxDuration));
  // Only relevant in Library scope; the backend ignores it for "my". Default = hide search.
  p.set("library_source", f.librarySource ?? "organic");
  return p;
}

function feedQuery(f: FeedFilters, cursor: string | null, limit: number): string {
  const p = filterParams(f);
  p.set("sort", f.sort);
  if (f.sort === "shuffle" && f.seed) p.set("seed", String(f.seed));
  if (cursor) p.set("cursor", cursor);
  p.set("limit", String(limit));
  return p.toString();
}

export interface SyncStatus {
  subscriptions: number;
  channels_total: number;
  channels_backfilling: number;
  videos_total: number;
  pending_enrich: number;
  quota_used_today: number;
  quota_remaining_today: number;
  paused: boolean;
  is_admin: boolean;
}

export interface MyStatus {
  channels_total: number;
  channels_details_synced: number;
  channels_recent_synced: number;
  channels_deep_done: number;
  channels_recent_pending: number;
  channels_deep_pending: number;
  channels_deep_requested: number;
  deep_pending_count: number;
  deep_eta_seconds: number;
  my_videos: number;
  total_videos: number;
  quota_used_today: number;
  quota_remaining_today: number;
  paused: boolean;
  sync_active: boolean; // a channel-sync job (backfill/rss) is running right now
}

export interface MyUsage {
  today: number;
  last_7d: number;
  last_30d: number;
  all_time: number;
  by_action: Record<string, number>;
}

export interface AdminQuotaRow {
  user_id: number | null;
  email: string;
  total: number;
  by_action: Record<string, number>;
}

export interface AdminQuota {
  range_days: number;
  rows: AdminQuotaRow[];
  daily: { day: string; total: number }[];
}

export interface ManagedChannel {
  id: string;
  title: string | null;
  handle: string | null;
  thumbnail_url: string | null;
  subscriber_count: number | null;
  video_count: number | null;
  stored_videos: number;
  last_video_at: string | null;
  total_duration_seconds: number;
  count_normal: number;
  count_short: number;
  count_live: number;
  priority: number;
  hidden: boolean;
  deep_requested: boolean;
  deep_in_queue: boolean;
  tag_ids: number[];
  details_synced: boolean;
  recent_synced: boolean;
  backfill_done: boolean;
}

// A channel that shows up in the user's playlists but that they don't subscribe to —
// surfaced in the Channel manager's Discovery tab so they can subscribe in one click.
export interface DiscoveredChannel {
  id: string;
  title: string | null;
  handle: string | null;
  thumbnail_url: string | null;
  subscriber_count: number | null;
  video_count: number | null;
  playlist_video_count: number; // how many of the user's playlist videos are from here
  playlist_count: number; // across how many of their playlists
  details_synced: boolean;
}

export interface SchedulerJob {
  id: string;
  interval_minutes: number;
  next_run: string | null;
  running: boolean;
  status: "ok" | "error" | "skipped" | null;
  last_started: string | null;
  last_finished: string | null;
  last_result: string | null;
  last_error: string | null;
  // Live progress while running (null when idle or for jobs that don't report it).
  // total is null for indeterminate progress (e.g. enrichment drains an unknown queue).
  progress: { current: number; total: number | null; phase: string | null } | null;
}

export interface SchedulerStatus {
  running_here: boolean;
  enabled: boolean;
  paused: boolean;
  jobs: SchedulerJob[];
  queue: {
    channels_recent_pending: number;
    channels_deep_pending: number;
    deep_eta_seconds: number;
    videos_pending_enrich: number;
    videos_pending_shorts: number;
    videos_live_refresh: number;
  };
  quota: {
    used_today: number;
    remaining_today: number;
    daily_budget: number;
    backfill_reserve: number;
  };
  maintenance: {
    revalidate_batch: number;
    revalidate_batch_default: number;
    min: number;
    max: number;
  };
}

export interface Account {
  id: number;
  email: string;
  display_name: string | null;
  avatar_url: string | null;
  active: boolean;
}

// A durable, server-backed notification (the inbox center). Distinct from the client-side
// transient toast/bell, which lives only in localStorage.
export interface AppNotification {
  id: number;
  type: string;
  title: string;
  body: string | null;
  data: Record<string, any> | null;
  read: boolean;
  dismissed: boolean;
  created_at: string | null;
}

export interface MessageUser {
  id: number;
  name: string;
  avatar_url: string | null;
}

export interface Message {
  id: number;
  kind: "user" | "system";
  sender_id: number | null;
  recipient_id: number;
  body: string | null; // plaintext, system messages only
  ciphertext: string | null; // base64, E2EE user messages only
  iv: string | null;
  read_at: string | null;
  created_at: string | null;
}

export interface Conversation {
  partner: MessageUser;
  last_message: Message;
  unread: number;
}

// My own E2EE key bundle. When configured, the wrapped blob + params let a device unlock.
export interface MyKeyBundle {
  configured: boolean;
  public_key?: string;
  wrapped_private_key?: string;
  salt?: string;
  wrap_iv?: string;
  key_check?: string;
}

export interface KeySetup {
  public_key: string;
  wrapped_private_key: string;
  salt: string;
  wrap_iv: string;
  key_check: string;
}

// Admin Configuration page: one DB-overridable setting (registry entry on the backend).
export interface ConfigItem {
  key: string;
  type: "int" | "str" | "bool";
  group: string;
  secret: boolean;
  min: number | null;
  max: number | null;
  is_set: boolean; // a DB override row exists (else using the env/config default)
  allow_empty?: boolean; // if true, a blank field stores "" (explicit empty) rather than resetting
  value?: string | number | boolean; // non-secret: effective value
  default?: string | number | boolean; // non-secret: env/config default
  default_is_set?: boolean; // secret: whether an env default exists
}
export interface SystemConfigData {
  groups: Record<string, ConfigItem[]>;
  secrets_manageable: boolean;
}

// Admin: result of testing the Plex server connection (also drives the library-picker).
interface PlexSection {
  key: string;
  title: string;
  type: string; // "movie" | "show"
  count?: number;
}
export interface PlexTestResult {
  ok: boolean;
  server_name: string;
  version?: string;
  sections: PlexSection[];
  // Whether a sample media file resolved + was readable through the local mount (bind-mount +
  // path-map check). checked=false when the library is empty (nothing to probe yet).
  media_check?: {
    checked: boolean;
    ok?: boolean;
    plex_path?: string;
    local_path?: string;
  };
}

// Plex browse/search/drill-down (the mirrored catalog rendered as feed-style cards).
export interface PlexLibrary {
  key: string;
  title: string;
  kind: "movie" | "show";
  count: number;
}
export interface PlexCard {
  id: string;
  type: "movie" | "show" | "episode";
  title: string;
  year?: number | null;
  duration_seconds?: number | null;
  thumb: string;
  playable?: string; // direct | remux | transcode
  status?: string; // new | watched | hidden
  position_seconds?: number;
  season_count?: number | null; // show
  season_number?: number | null; // episode
  episode_number?: number | null; // episode
  show_title?: string | null; // episode (in a mixed playlist)
  show_id?: string | null; // episode: the show's rating_key (for grouping a playlist by show/season)
  summary?: string | null; // episode
}
// Unified cross-library browse (movies + shows mixed). `episodes` is populated only on a search that
// also matches episodes (the grouped "Episodes" section).
export interface PlexUnifiedResult {
  scope: string; // movie | show | both
  total: number;
  offset: number;
  limit: number;
  items: PlexCard[];
  episodes: PlexCard[];
}
export interface PlexSeasonDetail {
  id: string; // season rating_key
  season_number: number | null;
  title: string;
  thumb: string;
  episode_count: number;
  status: string; // aggregate: new | in_progress | watched
  resume?: PlexCard | null; // on-deck episode (last in-progress, else first unwatched)
  first?: PlexCard | null; // first episode (Play from the start)
  episodes: PlexCard[];
}
export interface PlexShowDetail {
  show: {
    id: string;
    title: string;
    summary?: string | null;
    year?: number | null;
    thumb: string;
    art: string;
    library?: string | null; // Plex section key (for the admin collection editor)
    content_rating?: string | null;
    rating?: number | null;
    genres: string[];
    studio?: string | null;
    season_count?: number | null;
    imdb_rating?: number | null;
    imdb_id?: string | null;
    imdb_url?: string | null;
    cast: PlexCastMember[];
    status: string; // aggregate across all episodes
    resume?: PlexCard | null;
    first?: PlexCard | null;
    episode_count: number;
    collection_keys: string[];
  };
  seasons: PlexSeasonDetail[];
  related: PlexCard[];
}

export interface PlexMarker {
  type: "intro" | "credits";
  start_s: number;
  end_s: number;
}
export interface PlexItemDetail {
  id: string;
  kind: "movie" | "episode";
  title: string;
  summary?: string | null;
  year?: number | null;
  duration_seconds: number | null;
  playable: string; // direct | remux | transcode
  thumb: string;
  art: string;
  library?: string | null; // Plex section key (for the admin collection editor)
  cast: PlexCastMember[];
  imdb_rating?: number | null;
  imdb_id?: string | null;
  imdb_url?: string | null;
  content_rating?: string | null;
  genres: string[];
  directors: string[];
  studio?: string | null;
  tagline?: string | null;
  markers: PlexMarker[];
  audio_streams: { ord: number; label: string; language?: string | null; default: boolean }[];
  // `text`=true → offered as a WebVTT <track>; image subs (PGS/VobSub) are text=false (hidden).
  subtitle_streams: { ord: number; label: string; language?: string | null; codec?: string; text: boolean }[];
  status: string;
  position_seconds: number;
  show_title?: string | null;
  season_number?: number | null;
  episode_number?: number | null;
  prev_id?: string | null;
  next_id?: string | null;
  // `source` buckets the strip so each type can be shown/hidden independently on the info page:
  // "collection" (genuine franchise), "imdb"/"tmdb"/"tvdb"/"trakt" (external auto-lists), "smart".
  collections?: { id: string; title: string; source: string; items: PlexCard[] }[];
}
export interface PlexCollection {
  id: string;
  title: string;
  summary?: string | null;
  thumb?: string | null;
  child_count?: number | null;
  smart: boolean;
  source?: string;
  editable: boolean;
  can_edit?: boolean; // effective: admin-marked editable AND a plain (non-smart/non-auto) collection
}
export interface PlexPlaylist {
  id: number;
  title: string;
  item_count: number;
  thumb?: string | null;
  has_item?: boolean; // only when the list was fetched with ?contains=<rating_key>
  group_in?: number; // only when fetched with ?contains_group=<rk,rk,…>: how many of the group it holds
}
export interface PlexPlaylistDetail {
  id: number;
  title: string;
  items: PlexCard[];
}
export interface PlexCastMember {
  name: string;
  role?: string | null;
  thumb?: string | null; // proxied person-image url, or null when Plex has no photo
}
// The expanded Plex browse filters (movie libraries). Persisted per-account as JSON.
export interface PlexFilters {
  genres: string[];
  genreMode?: "any" | "all"; // how multiple genres combine (default any)
  contentRatings: string[];
  yearMin?: number | null;
  yearMax?: number | null;
  ratingMin?: number | null;
  durationMin?: number | null; // seconds
  durationMax?: number | null;
  addedWithin?: string; // "" | "24h" | "1w" | "1m" | "6m" | "1y"
  directors: string[]; // AND — titles featuring ALL selected
  actors: string[]; // AND — titles featuring ALL selected
  studios: string[]; // OR — from any selected studio
  collection?: string | null; // a single Plex collection (rating_key)
  collectionTitle?: string | null; // its title, for the active-filter chip
  sortDir?: "asc" | "desc"; // sort direction (default desc)
}
export const EMPTY_PLEX_FILTERS: PlexFilters = {
  genres: [],
  contentRatings: [],
  directors: [],
  actors: [],
  studios: [],
};
export function plexFilterCount(f: PlexFilters): number {
  return (
    f.genres.length +
    f.contentRatings.length +
    (f.yearMin != null || f.yearMax != null ? 1 : 0) +
    (f.ratingMin != null ? 1 : 0) +
    (f.durationMin != null || f.durationMax != null ? 1 : 0) +
    (f.addedWithin ? 1 : 0) +
    f.directors.length +
    f.actors.length +
    f.studios.length +
    (f.collection ? 1 : 0)
  );
}
// Serialize the shared PlexFilters metadata fields onto a query string. Used by both the library grid
// and the facets request so the two endpoints always agree on the filter set (paging/sort/sort_dir are
// each caller's own concern and stay out of here).
function appendPlexFilters(u: URLSearchParams, f: PlexFilters): void {
  if (f.genres?.length) u.set("genres", f.genres.join(","));
  if (f.genreMode === "all") u.set("genre_mode", "all");
  if (f.contentRatings?.length) u.set("content_ratings", f.contentRatings.join(","));
  if (f.yearMin != null) u.set("year_min", String(f.yearMin));
  if (f.yearMax != null) u.set("year_max", String(f.yearMax));
  if (f.ratingMin != null) u.set("rating_min", String(f.ratingMin));
  if (f.durationMin != null) u.set("duration_min", String(f.durationMin));
  if (f.durationMax != null) u.set("duration_max", String(f.durationMax));
  if (f.addedWithin) u.set("added_within", f.addedWithin);
  if (f.directors?.length) u.set("directors", f.directors.join(","));
  if (f.actors?.length) u.set("actors", f.actors.join(","));
  if (f.studios?.length) u.set("studios", f.studios.join(","));
  if (f.collection) u.set("collection", f.collection);
}
export interface PlexFacets {
  genres: { value: string; count: number }[];
  content_ratings: { value: string; count: number }[];
  year_min: number | null;
  year_max: number | null;
  rating_max: number | null;
  duration_min: number | null;
  duration_max: number | null;
}
export interface PlexPlaySession {
  mode: "direct" | "hls";
  url: string;
  start_s: number;
}

// Two-way Plex watch-state sync link status (owner account; Phase A).
export interface PlexWatchLink {
  linked: boolean;
  sync_enabled: boolean;
  uses_admin: boolean;
  initial_import_done: boolean;
  last_watch_sync_at: string | null;
}
export interface PlexWatchImport {
  watched: number;
  in_progress: number;
  scanned: number;
}

// Admin Users & roles page.
export interface AdminUserRow {
  id: number;
  email: string;
  display_name: string | null;
  role: string; // "user" | "admin"
  is_active: boolean;
  is_suspended: boolean;
  email_verified: boolean;
  is_demo: boolean;
  has_password: boolean;
  has_google: boolean;
  created_at: string | null;
}

export interface AuditRow {
  id: number;
  created_at: string | null;
  actor: string | null; // email of the acting admin; null = System (scheduler/background)
  action: string; // canonical key, localized on the client via auditActions.<action>
  target_type: string | null;
  target_id: string | null;
  summary: string | null;
  before: Record<string, unknown> | null;
  after: Record<string, unknown> | null;
}
export interface AuditListResult {
  items: AuditRow[];
  total: number; // total rows in the log (may exceed `items` if capped)
  returned: number;
}

// --- download center ---
export interface DownloadSpec {
  mode: "av" | "v" | "a"; // audio+video | video-only | audio-only
  max_height: number | null;
  container: string | null;
  audio_format: string | null;
  vcodec: string | null;
  embed_subs: boolean;
  embed_chapters: boolean;
  embed_thumbnail: boolean;
  sponsorblock: boolean;
}

export interface DownloadProfile {
  id: number;
  name: string;
  spec: DownloadSpec;
  is_builtin: boolean;
  sort_order: number;
}

interface DownloadAsset {
  id: number;
  status: string;
  title: string | null;
  uploader: string | null;
  uploader_url: string | null; // yt-dlp channel/uploader page URL (auto; null for e.g. reddit)
  upload_date: string | null;
  duration_s: number | null;
  size_bytes: number | null;
  width: number | null;
  height: number | null;
  container: string | null;
  thumbnail_url: string | null;
  expires_at: string | null;
}

export type DownloadStatus =
  | "queued"
  | "running"
  | "paused"
  | "done"
  | "error"
  | "canceled";

// Phase-2 editor recipe. A single `trim` (one output file) or a `segments` cut-list joined into
// one file. `accurate` re-encodes for a frame-accurate cut (a crop always re-encodes).
export interface EditSpec {
  trim?: { start_s: number; end_s?: number };
  segments?: { start_s: number; end_s: number }[];
  crop?: { x: number; y: number; w: number; h: number };
  accurate?: boolean;
}

export interface StoryboardMeta {
  available: boolean;
  cols?: number;
  rows?: number;
  count?: number;
  interval_s?: number;
  url?: string;
}

export interface DownloadJob {
  id: number;
  status: DownloadStatus;
  progress: number;
  speed_bps: number | null;
  eta_s: number | null;
  phase: string | null;
  error: string | null;
  queue_pos: number;
  created_at: string | null;
  source_kind: string;
  source_ref: string;
  source_url: string | null; // clean "downloaded from" URL (null for edit clips)
  poster_url: string | null; // locally-generated poster, fallback when the source had no thumbnail
  profile_id: number | null;
  spec: DownloadSpec;
  display_name: string | null;
  display_uploader: string | null; // user override of the channel name
  display_uploader_url: string | null; // user override of the channel link
  extra_links: string[]; // extra reference URLs (beyond source_url)
  job_kind?: string; // "download" (default) | "edit"
  source_job_id?: number | null; // parent download an edit was cut from
  edit_spec?: EditSpec | null;
  can_download: boolean;
  expired: boolean;
  asset: DownloadAsset | null;
  shared?: boolean;
  user_email?: string;
}

export interface ShareRecipient {
  id: number;
  email: string;
  display_name: string | null;
}

export interface ShareLink {
  id: number;
  token: string;
  url: string; // path like "/watch/<token>" — prepend window.location.origin to share
  allow_download: boolean;
  has_password: boolean;
  expires_at: string | null;
  view_count: number;
  created_at: string | null;
}

export interface DownloadUsage {
  footprint_bytes: number;
  active_jobs: number;
  running_jobs: number;
  max_bytes: number;
  max_concurrent: number;
  max_jobs: number;
  unlimited: boolean;
}

export interface AdminStorage {
  ready_files: number;
  total_bytes: number;
  total_assets: number;
  total_cap_bytes: number;
  per_user: { user_id: number; email: string | null; footprint_bytes: number }[];
}

export interface AdminDownloadQuota {
  user_id: number;
  custom: boolean;
  max_bytes: number;
  max_concurrent: number;
  max_jobs: number;
  unlimited: boolean;
}

export const api = {
  // Bootstrap probe: 200 always. Returns the user when signed in, or null when logged out
  // (the endpoint answers `{authenticated:false}` rather than 401, so the public landing never
  // logs a failed request to the console). React Query treats the null as data, not an error.
  me: async (): Promise<Me | null> => {
    const r = await req("/api/me");
    return r && r.authenticated ? (r as Me) : null;
  },
  accounts: (): Promise<Account[]> => req("/api/me/accounts"),
  deleteAccount: (): Promise<{ deleted: boolean }> =>
    req("/api/me/account", { method: "DELETE" }),
  switchAccount: (userId: number): Promise<{ ok: boolean; user_id: number }> =>
    req("/api/me/switch", { method: "POST", body: JSON.stringify({ user_id: userId }) }),
  // Signs the current tab's active account out of the browser wallet (per-tab aware via the
  // X-Siftlode-Account header that req() attaches).
  logout: (): Promise<{ ok: boolean; switched: boolean }> =>
    req("/auth/logout", { method: "POST" }),
  version: (): Promise<VersionInfo> => req("/api/version"),
  tags: (): Promise<Tag[]> => req("/api/tags"),
  status: (): Promise<SyncStatus> => req("/api/sync/status"),
  feed: (f: FeedFilters, cursor: string | null, limit: number): Promise<FeedResponse> =>
    req(`/api/feed?${feedQuery(f, cursor, limit)}`),
  feedCount: (f: FeedFilters): Promise<{ count: number }> =>
    req(`/api/feed/count?${filterParams(f).toString()}`),
  // Live YouTube search: results are materialised into the catalog and returned as feed cards.
  // `quiet` so the YT-search panel can render quota/limit errors (incl. 429) inline instead of
  // popping the global modal. `cursor` is the next-page token (an InnerTube continuation token
  // in scrape mode, or a Data API pageToken in api mode); the response's `source` says which.
  searchYoutube: (q: string, cursor: string | null, limit?: number): Promise<FeedResponse> => {
    const p = new URLSearchParams({ q });
    if (cursor) p.set("cursor", cursor);
    if (limit) p.set("limit", String(limit));
    return req(`/api/search/youtube?${p.toString()}`, {}, { quiet: true });
  },
  // Discard a set of live-search results "as if never added" (drops your search-finds + deletes
  // the now-orphaned, un-kept videos). Returns how many videos were removed.
  clearSearch: (videoIds: string[]): Promise<{ removed: number }> =>
    req("/api/search/clear", { method: "POST", body: JSON.stringify({ video_ids: videoIds }) }),
  facets: (f: FeedFilters): Promise<{ counts: Record<string, number> }> =>
    req(`/api/facets?${filterParams(f).toString()}`),
  // idempotent: setting a state / saving a progress checkpoint is safe to replay, so these
  // recover from a transient gateway/keepalive 502 instead of surfacing it (see req()).
  setState: (id: string, status: string) =>
    req(
      `/api/videos/${id}/state`,
      { method: "POST", body: JSON.stringify({ status }) },
      { idempotent: true }
    ),
  clearState: (id: string) =>
    req(`/api/videos/${id}/state`, { method: "DELETE" }, { idempotent: true }),
  saveProgress: (id: string, positionSeconds: number, durationSeconds: number) =>
    req(
      `/api/videos/${id}/progress`,
      {
        method: "POST",
        body: JSON.stringify({
          position_seconds: Math.floor(positionSeconds),
          duration_seconds: Math.floor(durationSeconds),
        }),
      },
      { idempotent: true }
    ),
  // Fire-and-forget YT progress save that survives page unload (F5/close/navigate).
  saveProgressBeacon: (id: string, positionSeconds: number, durationSeconds: number): void =>
    beacon(`/api/videos/${encodeURIComponent(id)}/progress`, {
      position_seconds: Math.floor(positionSeconds),
      duration_seconds: Math.floor(durationSeconds),
    }),
  videoDetail: (id: string): Promise<VideoDetail> => req(`/api/videos/${id}`),
  pauseSync: () => req("/api/sync/pause", { method: "POST" }),
  resumeSync: () => req("/api/sync/resume", { method: "POST" }),
  // Overwriting preferences is idempotent, so let it retry on a transient gateway blip.
  savePrefs: (p: Record<string, any>) =>
    req("/api/me/preferences", { method: "PUT", body: JSON.stringify(p) }, { idempotent: true }),

  // --- channel manager ---
  myStatus: (): Promise<MyStatus> => req("/api/sync/my-status"),
  channels: (): Promise<ManagedChannel[]> => req("/api/channels"),
  updateChannel: (
    id: string,
    patch: { priority?: number; hidden?: boolean; deep_requested?: boolean }
  ) => req(`/api/channels/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
  resetChannelBackfill: (id: string) =>
    req(`/api/channels/${id}/reset-backfill`, { method: "POST" }),
  deepAll: (on = true) =>
    req(`/api/sync/deep-all?on=${on}`, { method: "POST" }),
  attachChannelTag: (id: string, tagId: number) =>
    req(`/api/channels/${id}/tags`, { method: "POST", body: JSON.stringify({ tag_id: tagId }) }),
  detachChannelTag: (id: string, tagId: number) =>
    req(`/api/channels/${id}/tags/${tagId}`, { method: "DELETE" }),
  unsubscribeChannel: (id: string) =>
    req(`/api/channels/${id}/subscription`, { method: "DELETE" }),
  discoveredChannels: (): Promise<DiscoveredChannel[]> =>
    req("/api/channels/discovery"),
  subscribeChannel: (id: string) =>
    req(`/api/channels/${id}/subscribe`, { method: "POST" }),
  syncSubscriptions: () => req("/api/sync/subscriptions", { method: "POST" }),
  // --- channel page (About detail + ephemeral exploration of un-subscribed channels) ---
  channelDetail: (id: string): Promise<ChannelDetail> => req(`/api/channels/${id}`),
  // Open an un-subscribed channel for browsing: ingest a page of its uploads (newest-first,
  // marked via_explore) + record the exploration. `pageToken` continues into older uploads
  // ("load more"). `quiet` so a quota-reserve 429 / load error renders inline on the page.
  exploreChannel: (id: string, pageToken?: string | null): Promise<ExploreResult> => {
    const p = new URLSearchParams();
    if (pageToken) p.set("page_token", pageToken);
    const qs = p.toString();
    return req(
      `/api/channels/${id}/explore${qs ? `?${qs}` : ""}`,
      { method: "POST" },
      { quiet: true }
    );
  },
  // --- per-user channel blocklist (excluded from live search + feed/explore) ---
  blockedChannels: (): Promise<{ id: string; title: string | null; handle: string | null; thumbnail_url: string | null }[]> =>
    req("/api/channels/blocked/list"),
  blockChannel: (id: string) => req(`/api/channels/${id}/block`, { method: "POST" }),
  unblockChannel: (id: string) => req(`/api/channels/${id}/block`, { method: "DELETE" }),
  // Admin: reclaim all un-kept discovery content (search results + explored channels) now.
  purgeDiscovery: (): Promise<Record<string, number>> =>
    req("/api/admin/purge-discovery", { method: "POST" }),

  // --- playlists ---
  playlists: (containsVideoId?: string): Promise<Playlist[]> =>
    req(`/api/playlists${containsVideoId ? `?contains=${containsVideoId}` : ""}`),
  playlist: (id: number): Promise<PlaylistDetail> => req(`/api/playlists/${id}`),
  createPlaylist: (name: string): Promise<Playlist> =>
    req("/api/playlists", { method: "POST", body: JSON.stringify({ name }) }),
  renamePlaylist: (id: number, name: string): Promise<Playlist> =>
    req(`/api/playlists/${id}`, { method: "PATCH", body: JSON.stringify({ name }) }),
  deletePlaylist: (id: number, onYoutube = false) =>
    req(`/api/playlists/${id}${onYoutube ? "?on_youtube=true" : ""}`, { method: "DELETE" }),
  addToPlaylist: (id: number, videoId: string) =>
    req(`/api/playlists/${id}/items`, {
      method: "POST",
      body: JSON.stringify({ video_id: videoId }),
    }),
  removeFromPlaylist: (id: number, videoId: string) =>
    req(`/api/playlists/${id}/items/${videoId}`, { method: "DELETE" }),
  reorderPlaylist: (id: number, videoIds: string[]) =>
    req(`/api/playlists/${id}/order`, {
      method: "PUT",
      body: JSON.stringify({ video_ids: videoIds }),
    }),
  // Bookmark shortcut for the built-in Watch later playlist.
  watchLaterAdd: (videoId: string) =>
    req("/api/playlists/watch-later", {
      method: "POST",
      body: JSON.stringify({ video_id: videoId }),
    }),
  watchLaterRemove: (videoId: string) =>
    req(`/api/playlists/watch-later/${videoId}`, { method: "DELETE" }),
  syncYoutubePlaylists: (): Promise<{ synced: number; reason?: string }> =>
    req("/api/playlists/sync-youtube", { method: "POST" }),
  revertPlaylist: (id: number): Promise<{ items: number }> =>
    req(`/api/playlists/${id}/revert-youtube`, { method: "POST" }),
  playlistPushPlan: (id: number): Promise<PushPlan> =>
    req(`/api/playlists/${id}/push-plan`),
  pushPlaylist: (id: number): Promise<PushResult> =>
    req(`/api/playlists/${id}/push`, { method: "POST" }),

  // --- quota usage ---
  myUsage: (): Promise<MyUsage> => req("/api/quota/my-usage"),
  adminQuota: (days = 30): Promise<AdminQuota> => req(`/api/quota/admin?days=${days}`),
  // --- admin: system configuration ---
  adminConfig: (): Promise<SystemConfigData> => req("/api/admin/config"),
  setConfig: (key: string, value: string | number | boolean): Promise<SystemConfigData> =>
    req(`/api/admin/config/${key}`, { method: "PATCH", body: JSON.stringify({ value }) }),
  resetConfig: (key: string): Promise<SystemConfigData> =>
    req(`/api/admin/config/${key}`, { method: "DELETE" }),
  testEmail: (): Promise<{ sent: boolean; to: string }> =>
    req("/api/admin/config/test-email", { method: "POST" }),
  testPlex: (): Promise<PlexTestResult> => req("/api/plex/test", { method: "POST" }),
  syncPlex: (): Promise<Record<string, unknown>> => req("/api/plex/sync", { method: "POST" }),
  plexLibraries: (): Promise<{ enabled: boolean; libraries: PlexLibrary[] }> =>
    req("/api/plex/libraries"),
  plexLibrary: (p: {
    scope: string; // movie | show | both
    q?: string;
    sort?: string;
    show?: string;
    offset?: number;
    limit?: number;
    filters?: PlexFilters;
  }): Promise<PlexUnifiedResult> => {
    const u = new URLSearchParams({ scope: p.scope });
    if (p.q) u.set("q", p.q);
    if (p.sort) u.set("sort", p.sort);
    if (p.show && p.show !== "all") u.set("show", p.show);
    if (p.offset) u.set("offset", String(p.offset));
    if (p.limit) u.set("limit", String(p.limit));
    const f = p.filters;
    if (f) {
      appendPlexFilters(u, f);
      u.set("sort_dir", f.sortDir ?? "asc"); // default ascending
    }
    return req(`/api/plex/library?${u.toString()}`);
  },
  // Facets ADAPT to the active filters + watch-state (faceted search) — pass them so each group
  // narrows the others and the bounds match the visible result set.
  plexFacets: (scope: string, f?: PlexFilters, show?: string): Promise<PlexFacets> => {
    const u = new URLSearchParams({ scope });
    if (show && show !== "all") u.set("show", show);
    if (f) appendPlexFilters(u, f);
    return req(`/api/plex/facets?${u.toString()}`);
  },
  // With a library plex_key → that library's collections (editor). Without one → the union across all
  // enabled libraries (the unified-scope sidebar picker).
  plexCollections: (library?: string, q?: string): Promise<{ collections: PlexCollection[] }> => {
    const u = new URLSearchParams();
    if (library) u.set("library", library);
    if (q) u.set("q", q);
    const qs = u.toString();
    return req(`/api/plex/collections${qs ? `?${qs}` : ""}`);
  },
  // --- Collection editing (admin only; writes back to Plex) ---
  plexCreateCollection: (library: string, title: string, item_rating_key: string): Promise<PlexCollection> =>
    req(`/api/plex/collections`, { method: "POST", body: JSON.stringify({ library, title, item_rating_key }) }),
  plexCollectionAddItem: (rk: string, itemRk: string): Promise<PlexCollection> =>
    req(`/api/plex/collections/${encodeURIComponent(rk)}/items/${encodeURIComponent(itemRk)}`, { method: "POST" }),
  plexCollectionRemoveItem: (rk: string, itemRk: string): Promise<PlexCollection> =>
    req(`/api/plex/collections/${encodeURIComponent(rk)}/items/${encodeURIComponent(itemRk)}`, { method: "DELETE" }),
  plexRenameCollection: (rk: string, title: string): Promise<PlexCollection> =>
    req(`/api/plex/collections/${encodeURIComponent(rk)}`, { method: "PATCH", body: JSON.stringify({ title }) }),
  plexDeleteCollection: (rk: string): Promise<{ deleted: string }> =>
    req(`/api/plex/collections/${encodeURIComponent(rk)}`, { method: "DELETE" }),
  plexSetCollectionEditable: (rk: string, editable: boolean): Promise<PlexCollection> =>
    req(`/api/plex/collections/${encodeURIComponent(rk)}/editable`, { method: "POST", body: JSON.stringify({ editable }) }),
  // --- Playlists (Siftlode-native, per-user, ordered) ---
  // `contains`=single rating_key → per-playlist has_item; `containsGroup`=a set of rating_keys (a whole
  // season/show) → per-playlist group_in + a top-level group_size (for the bulk add-to-playlist dialog).
  plexPlaylists: (
    contains?: string,
    containsGroup?: string[],
  ): Promise<{ playlists: PlexPlaylist[]; group_size?: number }> => {
    const p = new URLSearchParams();
    if (contains) p.set("contains", contains);
    if (containsGroup) p.set("contains_group", containsGroup.join(","));
    const qs = p.toString();
    return req(`/api/plex/playlists${qs ? `?${qs}` : ""}`);
  },
  plexPlaylist: (id: number): Promise<PlexPlaylistDetail> => req(`/api/plex/playlists/${id}`),
  plexCreatePlaylist: (title: string, item_rating_key?: string): Promise<PlexPlaylist> =>
    req(`/api/plex/playlists`, { method: "POST", body: JSON.stringify({ title, item_rating_key }) }),
  plexPlaylistAddBulk: (id: number, itemRks: string[]): Promise<{ added: number; item_count: number }> =>
    req(`/api/plex/playlists/${id}/items/bulk`, { method: "POST", body: JSON.stringify({ item_rating_keys: itemRks }) }),
  plexPlaylistRemoveBulk: (id: number, itemRks: string[]): Promise<{ removed: number; item_count: number }> =>
    req(`/api/plex/playlists/${id}/items/remove-bulk`, { method: "POST", body: JSON.stringify({ item_rating_keys: itemRks }) }),
  plexRenamePlaylist: (id: number, title: string): Promise<PlexPlaylist> =>
    req(`/api/plex/playlists/${id}`, { method: "PATCH", body: JSON.stringify({ title }) }),
  plexDeletePlaylist: (id: number): Promise<{ deleted: number }> =>
    req(`/api/plex/playlists/${id}`, { method: "DELETE" }),
  plexPlaylistAddItem: (id: number, itemRk: string): Promise<{ ok: boolean }> =>
    req(`/api/plex/playlists/${id}/items`, { method: "POST", body: JSON.stringify({ item_rating_key: itemRk }) }),
  plexPlaylistRemoveItem: (id: number, itemRk: string): Promise<{ ok: boolean }> =>
    req(`/api/plex/playlists/${id}/items/${encodeURIComponent(itemRk)}`, { method: "DELETE" }),
  plexReorderPlaylist: (id: number, itemRks: string[]): Promise<{ ok: boolean }> =>
    req(`/api/plex/playlists/${id}/order`, { method: "PUT", body: JSON.stringify({ item_rating_keys: itemRks }) }),
  plexShow: (id: string): Promise<PlexShowDetail> =>
    req(`/api/plex/show/${encodeURIComponent(id)}`),
  // Mark a whole show / season watched or unwatched (all its episodes) for the current user.
  plexShowState: (rk: string, watched: boolean): Promise<{ changed: number; watched: boolean }> =>
    req(`/api/plex/show/${encodeURIComponent(rk)}/state`, { method: "POST", body: JSON.stringify({ watched }) }),
  plexSeasonState: (rk: string, watched: boolean): Promise<{ changed: number; watched: boolean }> =>
    req(`/api/plex/season/${encodeURIComponent(rk)}/state`, { method: "POST", body: JSON.stringify({ watched }) }),
  plexItem: (id: string): Promise<PlexItemDetail> =>
    req(`/api/plex/item/${encodeURIComponent(id)}`),
  plexSession: (id: string, start = 0, audio?: number | null, aoff = 0, multi = false): Promise<PlexPlaySession> => {
    const p = new URLSearchParams({ start: String(Math.max(0, Math.floor(start))) });
    if (audio != null) p.set("audio", String(audio));
    if (aoff) p.set("aoff", String(aoff));
    if (multi) p.set("multi", "1"); // ≥2 audio tracks → multi-rendition HLS (client-side audio switch)
    return req(`/api/plex/stream/${encodeURIComponent(id)}/session?${p.toString()}`, { method: "POST" });
  },
  // WebVTT URL for a text subtitle track (used directly as a <track src>). Same-origin → cookie-authed.
  // `offset` = the session's real media start (0 for direct-play); the backend shifts the absolute
  // cue times onto the HLS session's zero-based clock so subtitles line up with speech after a seek.
  plexSubtitleUrl: (id: string, ord: number, offset = 0): string =>
    `/api/plex/subtitle/${encodeURIComponent(id)}/${ord}${offset ? `?offset=${offset}` : ""}`,
  // `final` marks a settled checkpoint (pause / page-hide / leaving the player) as opposed to the
  // periodic 10s tick — the backend mirrors the resume position to a linked Plex account only on a
  // final checkpoint, so a single watch doesn't spray Plex with timeline updates.
  plexProgress: (
    id: string,
    position_seconds: number,
    duration_seconds: number,
    final = false,
  ): Promise<unknown> =>
    req(`/api/plex/item/${encodeURIComponent(id)}/progress`, {
      method: "POST",
      body: JSON.stringify({ position_seconds, duration_seconds, final }),
    }),
  // Fire-and-forget progress save that SURVIVES page unload (F5/close/navigate): a normal fetch is
  // cancelled on unload and React effect cleanup doesn't run on a full reload, so the last position
  // (esp. right after a seek) would be lost. `keepalive` lets the POST complete during unload; we
  // replicate req()'s credentials + account header (no retry — there's no page left to retry on).
  plexProgressBeacon: (
    id: string,
    position_seconds: number,
    duration_seconds: number,
    final = false,
  ): void =>
    beacon(`/api/plex/item/${encodeURIComponent(id)}/progress`, {
      position_seconds,
      duration_seconds,
      final,
    }),
  plexSetState: (id: string, status: "new" | "watched" | "hidden"): Promise<unknown> =>
    req(`/api/plex/item/${encodeURIComponent(id)}/state`, {
      method: "POST",
      body: JSON.stringify({ status }),
    }),
  // Two-way Plex watch-sync (Phase A: owner link + one-time "Plex is master" import).
  plexWatchLink: (): Promise<PlexWatchLink> => req("/api/plex/watch/link"),
  plexWatchSetLink: (enabled: boolean): Promise<PlexWatchLink & { import: PlexWatchImport | null }> =>
    req("/api/plex/watch/link", { method: "POST", body: JSON.stringify({ enabled }) }),
  plexWatchImport: (): Promise<PlexWatchLink & { import: PlexWatchImport }> =>
    req("/api/plex/watch/import", { method: "POST" }),
  plexStreamFileUrl: (id: string): string => `/api/plex/stream/${encodeURIComponent(id)}/file`,
  plexDownloadUrl: (id: string): string =>
    `/api/plex/stream/${encodeURIComponent(id)}/file?download=1`,
  plexImageUrl: (id: string, variant?: "thumb" | "art"): string =>
    `/api/plex/image/${encodeURIComponent(id)}${variant === "art" ? "?variant=art" : ""}`,
  // --- admin: users & roles ---
  adminAudit: (limit = 500): Promise<AuditListResult> => req(`/api/admin/audit?limit=${limit}`),
  clearAudit: (): Promise<{ cleared: number }> => req("/api/admin/audit", { method: "DELETE" }),
  adminUsers: (): Promise<AdminUserRow[]> => req("/api/admin/users"),
  setUserRole: (id: number, role: "user" | "admin"): Promise<AdminUserRow> =>
    req(`/api/admin/users/${id}/role`, { method: "PATCH", body: JSON.stringify({ role }) }),
  setUserSuspended: (id: number, suspended: boolean): Promise<AdminUserRow> =>
    req(`/api/admin/users/${id}/suspend`, { method: "PATCH", body: JSON.stringify({ suspended }) }),
  adminDeleteUser: (id: number): Promise<{ deleted: number }> =>
    req(`/api/admin/users/${id}`, { method: "DELETE" }),
  schedulerStatus: (): Promise<SchedulerStatus> => req("/api/admin/scheduler"),
  updateSchedulerJob: (jobId: string, intervalMinutes: number): Promise<{ id: string; interval_minutes: number }> =>
    req(`/api/admin/scheduler/jobs/${jobId}`, {
      method: "PATCH",
      body: JSON.stringify({ interval_minutes: intervalMinutes }),
    }),
  runSchedulerJob: (jobId: string): Promise<{ id: string; started: boolean }> =>
    req(`/api/admin/scheduler/jobs/${jobId}/run`, { method: "POST" }),
  runAllSchedulerJobs: (): Promise<{ started: string[] }> =>
    req("/api/admin/scheduler/run-all", { method: "POST" }),
  updateMaintenanceBatch: (revalidateBatch: number): Promise<{ revalidate_batch: number }> =>
    req("/api/admin/scheduler/maintenance", {
      method: "PATCH",
      body: JSON.stringify({ revalidate_batch: revalidateBatch }),
    }),

  // Public: which sign-in options this instance offers (e.g. hide Google when not configured).
  authConfig: (): Promise<{
    google_enabled: boolean;
    allow_registration: boolean;
    operator_contact: string | null;
  }> => req("/auth/config"),

  // --- first-run install wizard (token from the setup URL printed to the container logs) ---
  setupStatus: (): Promise<{ configured: boolean }> => req("/api/setup/status"),
  setupInfo: (token: string): Promise<{ secrets_manageable: boolean }> =>
    req("/api/setup/info", { headers: setupHeaders(token) }, { quiet: true }),
  setupAdmin: (token: string, email: string, password: string): Promise<{ ok: boolean }> =>
    req("/api/setup/admin", { method: "POST", headers: setupHeaders(token), body: JSON.stringify({ email, password }) }, { quiet: true }),
  setupConfig: (token: string, values: Record<string, unknown>): Promise<{ saved: string[] }> =>
    req("/api/setup/config", { method: "POST", headers: setupHeaders(token), body: JSON.stringify(values) }, { quiet: true }),
  setupTestEmail: (token: string, to: string): Promise<{ ok: boolean }> =>
    req("/api/setup/test-email", { method: "POST", headers: setupHeaders(token), body: JSON.stringify({ to }) }, { quiet: true }),
  setupFinish: (token: string): Promise<{ ok: boolean }> =>
    req("/api/setup/finish", { method: "POST", headers: setupHeaders(token) }, { quiet: true }),
  // --- auth: email + password (errors handled inline via quiet) ---
  register: (email: string, password: string): Promise<{ status: string }> =>
    req("/auth/register", { method: "POST", body: JSON.stringify({ email, password }) }, { quiet: true }),
  passwordLogin: (email: string, password: string): Promise<{ ok: boolean }> =>
    req("/auth/password-login", { method: "POST", body: JSON.stringify({ email, password }) }, { quiet: true }),
  requestPasswordReset: (email: string): Promise<{ status: string }> =>
    req("/auth/password-reset/request", { method: "POST", body: JSON.stringify({ email }) }, { quiet: true }),
  confirmPasswordReset: (token: string, password: string): Promise<{ ok: boolean }> =>
    req("/auth/password-reset/confirm", { method: "POST", body: JSON.stringify({ token, password }) }, { quiet: true }),
  // Confirm an email-verification token the SPA read from the URL fragment (SB3).
  verifyEmail: (token: string): Promise<{ ok: boolean }> =>
    req("/auth/verify", { method: "POST", body: JSON.stringify({ token }) }, { quiet: true }),
  // SA4: sign every OTHER session for this account out (keeps the current one).
  logoutOthers: (): Promise<{ ok: boolean }> => req("/auth/logout-others", { method: "POST" }),
  // Set or change the signed-in account's password (errors shown inline via quiet).
  setPassword: (password: string, currentPassword?: string): Promise<{ ok: boolean }> =>
    req(
      "/auth/set-password",
      { method: "POST", body: JSON.stringify({ password, current_password: currentPassword }) },
      { quiet: true }
    ),
  // --- onboarding / admin ---
  requestAccess: (email: string): Promise<{ status: string }> =>
    req("/auth/request-access", { method: "POST", body: JSON.stringify({ email }) }),
  // Hidden demo entry: a whitelisted email logs into the shared demo account, no Google
  // sign-in. Returns whether a session was established.
  demoLogin: (email: string): Promise<{ authenticated: boolean }> =>
    req("/auth/demo", { method: "POST", body: JSON.stringify({ email }) }),
  adminDemoWhitelist: (): Promise<DemoWhitelistEntry[]> =>
    req("/api/admin/demo/whitelist"),
  addDemoWhitelist: (email: string): Promise<DemoWhitelistEntry> =>
    req("/api/admin/demo/whitelist", { method: "POST", body: JSON.stringify({ email }) }),
  removeDemoWhitelist: (id: number) =>
    req(`/api/admin/demo/whitelist/${id}`, { method: "DELETE" }),
  resetDemo: (): Promise<{ reset: boolean; playlists_seeded: number }> =>
    req("/api/admin/demo/reset", { method: "POST" }),
  adminInvites: (status?: string): Promise<Invite[]> =>
    req(`/api/admin/invites${status ? `?status=${status}` : ""}`),
  approveInvite: (id: number) =>
    req(`/api/admin/invites/${id}/approve`, { method: "POST" }),
  denyInvite: (id: number) => req(`/api/admin/invites/${id}/deny`, { method: "POST" }),
  addInvite: (email: string) =>
    req("/api/admin/invites", { method: "POST", body: JSON.stringify({ email }) }),

  // --- notification inbox (durable, server-backed) ---
  notifications: (includeDismissed = false): Promise<{ items: AppNotification[]; total: number }> =>
    req(`/api/me/notifications?include_dismissed=${includeDismissed}`),
  notificationUnreadCount: (): Promise<{ count: number }> =>
    req("/api/me/notifications/unread_count"),
  markNotificationRead: (id: number) =>
    req(`/api/me/notifications/${id}/read`, { method: "POST" }),
  markAllNotificationsRead: () =>
    req("/api/me/notifications/read_all", { method: "POST" }),
  dismissNotification: (id: number) =>
    req(`/api/me/notifications/${id}/dismiss`, { method: "POST" }),
  clearNotifications: () => req("/api/me/notifications/clear", { method: "POST" }),

  // --- saved smart views (named FeedFilters snapshots) ---
  savedViews: (): Promise<SavedView[]> => req("/api/saved-views"),
  createView: (name: string, filters: FeedFilters): Promise<SavedView> =>
    req("/api/saved-views", { method: "POST", body: JSON.stringify({ name, filters }) }),
  updateView: (
    id: number,
    patch: Partial<{ name: string; filters: FeedFilters; is_default: boolean }>,
  ): Promise<SavedView> =>
    req(`/api/saved-views/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
  deleteView: (id: number): Promise<{ deleted: number }> =>
    req(`/api/saved-views/${id}`, { method: "DELETE" }),
  reorderViews: (ids: number[]): Promise<{ ok: boolean }> =>
    req("/api/saved-views/reorder", { method: "POST", body: JSON.stringify({ ids }) }),

  // --- direct messages (end-to-end encrypted) ---
  messageMyKey: (): Promise<MyKeyBundle> => req("/api/messages/keys/me"),
  setupMessageKey: (k: KeySetup): Promise<{ configured: boolean }> =>
    req("/api/messages/keys", { method: "POST", body: JSON.stringify(k) }),
  messagePublicKey: (userId: number): Promise<{ user_id: number; public_key: string }> =>
    req(`/api/messages/keys/${userId}`),
  messageUsers: (): Promise<MessageUser[]> => req("/api/messages/users"),
  conversations: (): Promise<{ items: Conversation[] }> => req("/api/messages/conversations"),
  thread: (partnerId: number): Promise<{ partner: MessageUser; items: Message[] }> =>
    req(`/api/messages/conversations/${partnerId}`),
  sendMessage: (recipientId: number, ciphertext: string, iv: string): Promise<Message> =>
    req("/api/messages", {
      method: "POST",
      body: JSON.stringify({ recipient_id: recipientId, ciphertext, iv }),
    }),
  messageUnreadCount: (): Promise<{ count: number }> => req("/api/messages/unread_count"),

  // --- user tags ---
  createTag: (t: { name: string; color?: string; category?: string }) =>
    req("/api/tags", { method: "POST", body: JSON.stringify(t) }),
  updateTag: (id: number, patch: { name?: string; color?: string }) =>
    req(`/api/tags/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
  deleteTag: (id: number) => req(`/api/tags/${id}`, { method: "DELETE" }),

  // --- download center ---
  downloadProfiles: (): Promise<DownloadProfile[]> => req("/api/downloads/profiles"),
  createDownloadProfile: (p: { name: string; spec: DownloadSpec }): Promise<DownloadProfile> =>
    req("/api/downloads/profiles", { method: "POST", body: JSON.stringify(p) }),
  updateDownloadProfile: (
    id: number,
    patch: { name?: string; spec?: DownloadSpec }
  ): Promise<DownloadProfile> =>
    req(`/api/downloads/profiles/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
  deleteDownloadProfile: (id: number) =>
    req(`/api/downloads/profiles/${id}`, { method: "DELETE" }),

  enqueueDownload: (body: {
    source: string;
    profile_id?: number;
    spec?: DownloadSpec;
    display_name?: string;
  }): Promise<DownloadJob> =>
    req("/api/downloads", { method: "POST", body: JSON.stringify(body) }),
  enqueueEdit: (body: {
    source_job_id: number;
    edit_spec: EditSpec;
    display_name?: string;
  }): Promise<DownloadJob> =>
    req("/api/downloads/edit", { method: "POST", body: JSON.stringify(body) }),
  downloadStoryboard: (id: number): Promise<StoryboardMeta> =>
    req(`/api/downloads/${id}/storyboard`),
  downloads: (): Promise<DownloadJob[]> => req("/api/downloads"),
  downloadsShared: (): Promise<DownloadJob[]> => req("/api/downloads/shared"),
  // Remove a shared-with-me item from your list (deletes only your share grant, not the file).
  removeSharedDownload: (jobId: number): Promise<{ removed: number }> =>
    req(`/api/downloads/shared/${jobId}`, { method: "DELETE" }),
  downloadUsage: (): Promise<DownloadUsage> => req("/api/downloads/usage"),
  downloadIndex: (): Promise<Record<string, DownloadStatus>> => req("/api/downloads/index"),
  pauseDownload: (id: number): Promise<DownloadJob> =>
    req(`/api/downloads/${id}/pause`, { method: "POST" }, { idempotent: true }),
  resumeDownload: (id: number): Promise<DownloadJob> =>
    req(`/api/downloads/${id}/resume`, { method: "POST" }, { idempotent: true }),
  cancelDownload: (id: number): Promise<DownloadJob> =>
    req(`/api/downloads/${id}/cancel`, { method: "POST" }, { idempotent: true }),
  deleteDownload: (id: number) => req(`/api/downloads/${id}`, { method: "DELETE" }),
  // Update a download's display metadata (title / channel name+link / extra reference URLs).
  updateDownloadMeta: (
    id: number,
    meta: {
      display_name?: string;
      display_uploader?: string;
      display_uploader_url?: string;
      extra_links?: string[];
    }
  ): Promise<DownloadJob> =>
    req(`/api/downloads/${id}`, { method: "PATCH", body: JSON.stringify(meta) }),
  shareDownload: (id: number, email: string): Promise<{ shared_with: string }> =>
    req(`/api/downloads/${id}/share`, { method: "POST", body: JSON.stringify({ email }) }),
  unshareDownload: (id: number, email: string) =>
    req(`/api/downloads/${id}/share/${encodeURIComponent(email)}`, { method: "DELETE" }),
  // Registered users this user can share with (for the internal picker).
  shareRecipients: (): Promise<ShareRecipient[]> => req("/api/downloads/recipients"),
  // Public watch links (share by link).
  downloadLinks: (jobId: number): Promise<ShareLink[]> => req(`/api/downloads/${jobId}/links`),
  createDownloadLink: (
    jobId: number,
    body: { allow_download?: boolean; expires_days?: number | null; password?: string }
  ): Promise<ShareLink> =>
    req(`/api/downloads/${jobId}/links`, { method: "POST", body: JSON.stringify(body) }),
  updateDownloadLink: (
    linkId: number,
    patch: { allow_download?: boolean; expires_days?: number | null; password?: string }
  ): Promise<ShareLink> =>
    req(`/api/downloads/links/${linkId}`, { method: "PATCH", body: JSON.stringify(patch) }),
  revokeDownloadLink: (linkId: number): Promise<{ deleted: number }> =>
    req(`/api/downloads/links/${linkId}`, { method: "DELETE" }),
  // A plain <a> navigation can't send the X-Siftlode-Account header, so pass the active
  // account via ?account= (same wallet-gated selection the WebSocket uses) — otherwise the
  // download resolves to the session-default account and 404s for a per-tab account's file.
  downloadFileUrl: (id: number): string => {
    const a = getActiveAccount();
    return a != null ? `/api/downloads/${id}/file?account=${a}` : `/api/downloads/${id}/file`;
  },
  // Filmstrip sprite (an <img>/background src → direct nav, so it needs the ?account= wallet gate).
  storyboardImageUrl: (id: number): string => {
    const a = getActiveAccount();
    return a != null
      ? `/api/downloads/${id}/storyboard.jpg?account=${a}`
      : `/api/downloads/${id}/storyboard.jpg`;
  },

  // admin
  adminDownloads: (): Promise<DownloadJob[]> => req("/api/admin/downloads"),
  adminDownloadStorage: (): Promise<AdminStorage> => req("/api/admin/downloads/storage"),
  adminGetDownloadQuota: (userId: number): Promise<AdminDownloadQuota> =>
    req(`/api/admin/downloads/quota/${userId}`),
  adminSetDownloadQuota: (
    userId: number,
    patch: Partial<Pick<AdminDownloadQuota, "max_bytes" | "max_concurrent" | "max_jobs" | "unlimited">>
  ): Promise<AdminDownloadQuota> =>
    req(`/api/admin/downloads/quota/${userId}`, { method: "PUT", body: JSON.stringify(patch) }),
  adminResetDownloadQuota: (userId: number): Promise<AdminDownloadQuota> =>
    req(`/api/admin/downloads/quota/${userId}`, { method: "DELETE" }),
};

export { HttpError };
