// Jellyfin upstream config + the one function that talks to it.
//
// All knowledge of the Jellyfin instance — its URL, its access token, the Jellyfin user the token belongs to
// and the DeviceId sessions are keyed by — lives here, mirroring officer-invoiceshelf: the platform API is a
// thin auth+forward proxy and holds NO Jellyfin credentials.
//
// The server is CONFIGURED BY THE OWNER FROM THE UI and stored encrypted in `jellyfin_servers` (see
// databases/officer_db/src/jellyfin/queries.ts). Nothing in this file reads process.env — Bun auto-loads
// `.env` into every process started in the platform directory, so a token there would also be sitting in
// `officer`'s own environment: a credential held by the one process that has no code to use it.
//
// Three things about Jellyfin's API are load-bearing:
//
//   1. Auth is ONE header — `Authorization: MediaBrowser Token="…", Client=…, Device=…, DeviceId=…,
//      Version=…`. The older `X-Emby-Token` still works in 10.11 but carries no client identity, and
//      playback reporting correlates sessions by DeviceId, so the full header is what we send.
//   2. The DeviceId must be STABLE per server. A fresh one per request fills Jellyfin's dashboard with
//      hundreds of "devices" and detaches each progress report from the session that started playback.
//   3. Almost every per-user route needs the user id in the PATH (`/Users/{id}/Views`), and it is not
//      derivable from the token without a round trip — which is why it is pinned on the row at sign-in.

import { getActiveJellyfinCredentials } from 'officerdb';

/** Everything needed to make one call. A candidate being validated has this and nothing else yet. */
export type UpstreamTarget = {
  base: string;
  token: string;
  jellyfinUserId: string;
  deviceId: string;
};

/**
 * A stored server, which is where every real call goes.
 *
 * `id` and `label` ride along because the owner has several instances registered (this machine runs four)
 * and only one selected: a probe has to be recorded against the row it actually reached, and a log line
 * saying which instance answered is the difference between "Jellyfin is broken" and "you are looking at
 * the albums server".
 */
export type UpstreamConfig = UpstreamTarget & { id: number; label: string };

/** Trailing slashes off, so `${base}/Items/...` never doubles the separator. */
export const normalizeBase = (url: string): string => url.trim().replace(/\/+$/, '');

// A library grid is a burst of requests and each one needs the token, so the row is cached rather than
// re-read per request. Writes invalidate immediately; the TTL only covers someone editing the row in psql,
// which then takes effect within a minute instead of needing a restart.
const TTL_MS = 60_000;
const cache = new Map<number, { cfg: UpstreamConfig | null; at: number }>();

/**
 * The owner's SELECTED server, or null when /jellyfin has none yet — the sidecar then answers 503, and the
 * UI turns that into the setup form rather than a screen of empty shelves.
 */
export async function getConfig(userId: number): Promise<UpstreamConfig | null> {
  const hit = cache.get(userId);
  if (hit && Date.now() - hit.at < TTL_MS) return hit.cfg;

  const creds = await getActiveJellyfinCredentials(userId);
  const cfg = creds
    ? {
        id: creds.id,
        label: creds.label,
        base: normalizeBase(creds.url),
        token: creds.accessToken,
        jellyfinUserId: creds.jellyfinUserId,
        deviceId: creds.deviceId,
      }
    : null;
  cache.set(userId, { cfg, at: Date.now() });
  return cfg;
}

/** Drop the cached row — called by the config routes after any add, edit, switch or removal. */
export function invalidateConfig(userId: number): void {
  cache.delete(userId);
}

/** Carries the upstream status so callers can tell a rejected token from an unreachable instance. */
export class UpstreamError extends Error {
  constructor(
    message: string,
    readonly status: number,
  ) {
    super(message);
  }
}

const CLIENT = 'Officer';
const DEVICE = 'Officer Web';
const CLIENT_VERSION = '1.0.0';

/**
 * The `Authorization` header Jellyfin expects, with or without a token.
 *
 * Signing in has to send the same header MINUS the token: Jellyfin refuses AuthenticateByName outright
 * without a client identity, which is the single most common reason a hand-rolled login gets a 400.
 */
export function authHeader(deviceId: string, token?: string): string {
  const parts = [`Client="${CLIENT}"`, `Device="${DEVICE}"`, `DeviceId="${deviceId}"`, `Version="${CLIENT_VERSION}"`];
  if (token) parts.push(`Token="${token}"`);
  return `MediaBrowser ${parts.join(', ')}`;
}

export type AuthResult = { token: string; userId: string; username: string | null; serverName: string | null };

/**
 * Trade a username and password for an access token.
 *
 * The password is used for this one call and never stored — only the token it returns is persisted, the
 * same trade officer-invoiceshelf makes. Jellyfin CAN issue API keys from its dashboard, but those are
 * server-wide admin keys with no user identity, and every per-user route (resume points, watched state,
 * favourites) needs a user. So we sign in as the owner rather than key the server.
 *
 * Each sign-in mints a NEW token; the previous one is deliberately left alone, because Officer cannot know
 * whether the owner also uses it somewhere else. Old ones are revoked from Jellyfin's own Devices screen.
 */
export async function authenticate(
  base: string,
  deviceId: string,
  username: string,
  password: string,
): Promise<AuthResult> {
  const res = await fetch(`${base}/Users/AuthenticateByName`, {
    method: 'POST',
    headers: {
      Authorization: authHeader(deviceId),
      'Content-Type': 'application/json',
      Accept: 'application/json',
    },
    // `Pw` is the plaintext field. `Password` is the legacy SHA1 one and is ignored by modern servers.
    body: JSON.stringify({ Username: username, Pw: password }),
    redirect: 'manual',
  });
  if (!res.ok) {
    const detail = res.status === 401 ? 'wrong username or password' : `sign-in failed with ${res.status}`;
    throw new UpstreamError(detail, res.status);
  }
  const payload = (await res.json()) as {
    AccessToken?: unknown;
    User?: { Id?: unknown; Name?: unknown };
    ServerId?: unknown;
  };
  if (typeof payload.AccessToken !== 'string' || !payload.AccessToken) {
    throw new UpstreamError('sign-in returned no access token', res.status);
  }
  if (typeof payload.User?.Id !== 'string' || !payload.User.Id) {
    throw new UpstreamError('sign-in returned no user id', res.status);
  }
  return {
    token: payload.AccessToken,
    userId: payload.User.Id,
    username: typeof payload.User.Name === 'string' ? payload.User.Name : null,
    serverName: null,
  };
}

export type PublicInfo = { serverName: string | null; version: string | null };

/**
 * The unauthenticated identity endpoint, used before any credential exists so the setup form can confirm
 * the URL points at a Jellyfin at all — and name it — before asking for a password.
 */
export async function publicInfo(base: string): Promise<PublicInfo> {
  const res = await fetch(`${base}/System/Info/Public`, { headers: { Accept: 'application/json' } });
  if (!res.ok) throw new UpstreamError(`not a reachable Jellyfin (${res.status})`, res.status);
  const payload = (await res.json()) as { ServerName?: unknown; Version?: unknown };
  return {
    serverName: typeof payload.ServerName === 'string' ? payload.ServerName : null,
    version: typeof payload.Version === 'string' ? payload.Version : null,
  };
}

type CallOptions = {
  /** Absolute path on the Jellyfin host, e.g. `/Items`. */
  path: string;
  method?: string;
  /** Raw search string including the leading `?`, or empty. */
  query?: string;
  body?: BodyInit | null;
  contentType?: string | null;
  /** Defaults to `application/json`; image and video routes ask for the bytes instead. */
  accept?: string;
  /** Forwarded verbatim for seeking — Jellyfin answers 206 for both direct play and HLS segments. */
  range?: string | null;
};

/** The single door to Jellyfin. Everything the sidecar fetches goes through here. */
export async function callUpstream(cfg: UpstreamTarget, opts: CallOptions): Promise<Response> {
  const headers: Record<string, string> = {
    Authorization: authHeader(cfg.deviceId, cfg.token),
    Accept: opts.accept ?? 'application/json',
  };
  if (opts.contentType) headers['Content-Type'] = opts.contentType;
  if (opts.range) headers.Range = opts.range;

  const res = await fetch(`${cfg.base}${opts.path}${opts.query ?? ''}`, {
    method: opts.method ?? 'GET',
    headers,
    body: opts.body ?? undefined,
    redirect: 'manual',
  });

  if (res.status === 401) {
    console.warn(`[jellyfin] ${opts.method ?? 'GET'} ${opts.path} → 401 (token rejected)`);
  }
  return res;
}

export type ProbeResult = { ok: boolean; version: string | null; serverName: string | null; error?: string };

/**
 * Is the stored token still good?
 *
 * `/System/Info` (no `/Public`) is the right probe precisely because it is authenticated: the public one
 * answers 200 to a revoked token, so a probe against it would call a dead connection healthy.
 */
export async function probe(cfg: UpstreamTarget): Promise<ProbeResult> {
  try {
    const res = await callUpstream(cfg, { path: '/System/Info' });
    if (res.status === 401) return { ok: false, version: null, serverName: null, error: 'access token rejected' };
    if (!res.ok) return { ok: false, version: null, serverName: null, error: `server answered ${res.status}` };
    const payload = (await res.json()) as { Version?: unknown; ServerName?: unknown };
    return {
      ok: true,
      version: typeof payload.Version === 'string' ? payload.Version : null,
      serverName: typeof payload.ServerName === 'string' ? payload.ServerName : null,
    };
  } catch (err) {
    return { ok: false, version: null, serverName: null, error: err instanceof Error ? err.message : 'unreachable' };
  }
}
