import type { JellyfinServer } from 'officerdb';
import {
  createJellyfinServer,
  deleteJellyfinServer,
  getJellyfinCredentials,
  listJellyfinServers,
  recordJellyfinProbe,
  setActiveJellyfinServer,
  updateJellyfinServer,
} from 'officerdb';
import { UpstreamError, authenticate, invalidateConfig, normalizeBase, probe, publicInfo } from './upstream';

// `/_config` — the Jellyfin server registry, driven from the app.
//
// The access token is WRITE-ONLY across this boundary, and it is never even written directly: the owner
// supplies a URL, a username and a password, and the sidecar trades them for a token through
// `POST /Users/AuthenticateByName`. The password lives for the duration of that one call. The list route
// reports label, URL, account name, server name and version — it has no field that could carry a token,
// masked or otherwise.
//
// A save is validated against the live instance before it is stored, in two steps that answer two different
// questions: `/System/Info/Public` says "this URL is a Jellyfin, and it is called X", and the sign-in says
// "these credentials work on it". Reporting them separately is what makes a typo'd port distinguishable from
// a wrong password in the setup form.

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

/** What the browser is allowed to know about the registry. Never includes a token. */
async function serverList(userId: number): Promise<Response> {
  const servers = await listJellyfinServers(userId);
  const active = servers.find((server) => server.isActive) ?? null;
  return Response.json({ configured: !!active, activeId: active?.id ?? null, servers });
}

/** Record that the instance answered, so the UI can tell "never connected" from "was working, now isn't". */
export async function noteProbe(userId: number, id: number, version: string | null): Promise<void> {
  await recordJellyfinProbe(userId, id, version).catch(() => {
    /* a stale lastSeenAt is not worth failing a request over */
  });
}

type ServerBody = { label?: unknown; url?: unknown; username?: unknown; password?: unknown };

const readBody = async (req: Request): Promise<ServerBody> =>
  ((await req.json().catch(() => null)) as ServerBody | null) ?? {};

const readLabel = (body: ServerBody): string => (typeof body.label === 'string' ? body.label.trim() : '');
const readUrl = (body: ServerBody): string => (typeof body.url === 'string' ? normalizeBase(body.url) : '');
const readUsername = (body: ServerBody): string => (typeof body.username === 'string' ? body.username.trim() : '');
const readPassword = (body: ServerBody): string => (typeof body.password === 'string' ? body.password : '');

const isHttpUrl = (url: string): boolean => /^https?:\/\//i.test(url);

/** Sign-in and reachability failures, in the words of what the owner actually did. */
function upstreamError(err: unknown): string {
  if (err instanceof UpstreamError) return err.message;
  return `could not reach that URL (${err instanceof Error ? err.message : String(err)})`;
}

const duplicateLabel = (err: unknown): boolean => String(err).includes('uq_jellyfin_servers_user_label');

/**
 * Add a server: the URL is identified, the credentials are traded for a token, then the token is stored
 * encrypted and the password is dropped.
 *
 * A fresh `DeviceId` is minted here and only here. It is what Jellyfin keys sessions and remembered devices
 * by, so it belongs to the ROW, not to a request — see schema/jellyfin.ts.
 */
async function addServer(req: Request, userId: number): Promise<Response> {
  const body = await readBody(req);
  const url = readUrl(body);
  const username = readUsername(body);
  const password = readPassword(body);
  let label = readLabel(body);

  if (!url) return bad('url is required');
  if (!isHttpUrl(url)) return bad('url must start with http:// or https://');
  if (!username) return bad('username is required');
  if (!password) return bad('password is required');

  let info;
  try {
    info = await publicInfo(url);
  } catch (err) {
    return bad(upstreamError(err));
  }

  const deviceId = crypto.randomUUID();

  let auth;
  try {
    auth = await authenticate(url, deviceId, username, password);
  } catch (err) {
    return bad(upstreamError(err));
  }

  // An unlabelled server takes the instance's own name, which is nearly always what the owner would type.
  // Falling back to the host keeps the switcher readable when the server is unnamed.
  if (!label) label = info.serverName ?? new URL(url).host;

  // The first server wins the selection: a registry with rows but nothing selected reads as "not connected".
  const existing = await listJellyfinServers(userId);
  const activate = existing.length === 0;

  try {
    const server = await createJellyfinServer({
      userId,
      label,
      url,
      accessToken: auth.token,
      jellyfinUserId: auth.userId,
      jellyfinUsername: auth.username ?? username,
      deviceId,
      serverName: info.serverName,
      version: info.version,
      activate,
    });
    invalidateConfig(userId);
    return Response.json({ server });
  } catch (err) {
    if (duplicateLabel(err)) return bad(`you already have a server called "${label}"`);
    throw err;
  }
}

/**
 * Edit one server. No password means "keep the stored token", so a rename never needs one re-typed.
 *
 * Re-authenticating deliberately keeps the row's existing `deviceId`: the point of signing in again is
 * usually that the token was revoked, and reusing the device id keeps the new session attached to the same
 * entry in Jellyfin's device list instead of adding a second ghost.
 */
async function editServer(req: Request, userId: number, id: number): Promise<Response> {
  const body = await readBody(req);
  const label = readLabel(body);
  const url = readUrl(body);
  const username = readUsername(body);
  const password = readPassword(body);

  const current = await getJellyfinCredentials(userId, id);
  if (!current) return bad('no such server', 404);
  if (url && !isHttpUrl(url)) return bad('url must start with http:// or https://');

  const base = url || normalizeBase(current.url);

  let accessToken: string | undefined;
  let jellyfinUserId: string | undefined;
  let jellyfinUsername: string | null | undefined;
  let serverName: string | null | undefined;
  let version: string | null | undefined;

  if (password) {
    if (!username) return bad('username is required to sign in again');
    try {
      const info = await publicInfo(base);
      const auth = await authenticate(base, current.deviceId, username, password);
      accessToken = auth.token;
      jellyfinUserId = auth.userId;
      jellyfinUsername = auth.username ?? username;
      serverName = info.serverName;
      version = info.version;
    } catch (err) {
      return bad(upstreamError(err));
    }
  } else if (url && base !== normalizeBase(current.url)) {
    // Moving a server to a new URL without re-authenticating is allowed — a reverse proxy in front of the
    // same instance keeps the token valid — but it has to be checked, because pointing at a DIFFERENT
    // Jellyfin would leave a row whose token belongs to another server.
    const result = await probe({ ...current, base, token: current.accessToken });
    if (!result.ok) return bad(result.error ?? 'that URL did not accept the stored token');
    serverName = result.serverName;
    version = result.version;
  }

  try {
    const server = await updateJellyfinServer(userId, id, {
      label: label || undefined,
      url: url || undefined,
      accessToken,
      jellyfinUserId,
      jellyfinUsername,
      serverName,
      version,
    });
    if (!server) return bad('no such server', 404);
    invalidateConfig(userId);
    return Response.json({ server });
  } catch (err) {
    if (duplicateLabel(err)) return bad(`you already have a server called "${label}"`);
    throw err;
  }
}

/** Check one stored server without switching to it — what the "test" button on each row calls. */
async function testServer(userId: number, id: number): Promise<Response> {
  const creds = await getJellyfinCredentials(userId, id);
  if (!creds) return bad('no such server', 404);
  const started = Date.now();
  const result = await probe({ ...creds, base: normalizeBase(creds.url), token: creds.accessToken });
  const ms = Date.now() - started;
  if (!result.ok) return Response.json({ ok: false, error: result.error, ms }, { status: 502 });
  await noteProbe(userId, id, result.version);
  return Response.json({ ok: true, version: result.version, serverName: result.serverName, ms });
}

export type { JellyfinServer };

/** `subpath` is '' for /_config, or '/<id>', '/<id>/activate', '/<id>/test'. */
export async function handleConfigRoute(req: Request, userId: number, subpath: string): Promise<Response> {
  const [, rawId, action] = subpath.split('/');

  if (!rawId) {
    if (req.method === 'GET') return serverList(userId);
    if (req.method === 'POST' || req.method === 'PUT') return addServer(req, userId);
    return bad('method not allowed', 405);
  }

  const id = Number(rawId);
  if (!Number.isInteger(id) || id <= 0) return bad('invalid server id', 404);

  if (action === 'activate') {
    if (req.method !== 'POST') return bad('method not allowed', 405);
    const server = await setActiveJellyfinServer(userId, id);
    if (!server) return bad('no such server', 404);
    invalidateConfig(userId);
    return serverList(userId);
  }

  // GET only. It probes one server and reports whether it answered — nothing is written, and nothing is
  // switched (that is `activate`, below). It accepted POST as well until 2026-08-06, which made the
  // method meaningless for deciding whether this is a read; the permission model needs that answer.
  if (action === 'test') {
    if (req.method !== 'GET') return bad('method not allowed', 405);
    return testServer(userId, id);
  }

  if (action) return bad('not found', 404);

  if (req.method === 'PATCH' || req.method === 'PUT') return editServer(req, userId, id);

  if (req.method === 'DELETE') {
    const removed = await deleteJellyfinServer(userId, id);
    if (!removed) return bad('no such server', 404);
    invalidateConfig(userId);
    return serverList(userId);
  }

  return bad('method not allowed', 405);
}
