// Immich upstream config for the officer-photos sidecar.
//
// All knowledge of the Immich instance — its URL and its API key — lives here. The platform API is a thin
// auth+forward proxy and holds NO Immich credentials.
//
// The instance is CONFIGURED BY THE OWNER FROM THE UI and stored encrypted in `photos_config` (see
// databases/officer_db/src/photos/queries.ts). It is deliberately no longer read from the environment:
// Bun auto-loads `.env` into every process started in the platform directory, so an `IMMICH_API_KEY` there
// was also sitting in `officer`'s own `process.env` — a credential held by the one process that has no code
// to use it and the largest attack surface in the system. Nothing in this file reads process.env.
//
// Two things about Immich's API are load-bearing:
//
//   1. Auth is the `x-api-key` header. Immich keys are SCOPED — a key created without a permission gets a
//      403 on that route, not a 401, so a partial key looks like a broken feature rather than a bad
//      credential. Create the key with all permissions unless there is a reason not to.
//   2. NEVER forward Cookie, Origin or Referer. Immich accepts a session cookie as an alternative
//      credential, and a browser-shaped request reaching it with the owner's Officer cookies attached is
//      exactly the confusion this sidecar exists to prevent. Bun's fetch adds none of them on its own and
//      nothing below adds them; the platform proxy forwards only content-type, range and if-none-match.

import { getActivePhotosCredentials } from 'officerdb';

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

/**
 * A stored account, which is where every real call goes.
 *
 * `id` and `label` ride along because the owner can have several accounts registered and only one selected:
 * a probe has to be recorded against the row it actually reached, and a log line saying which library
 * answered is the difference between "photos is broken" and "you are looking at the other account".
 */
export type UpstreamConfig = UpstreamTarget & { id: number; label: string };

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

// A thumbnail grid is a hundred requests in a second and each one needs the key, 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 account, or null when /photos has no account yet — the sidecar then answers 503,
 * and the UI turns that into the setup form rather than a wall of empty grids.
 */
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 getActivePhotosCredentials(userId);
  const cfg = creds ? { id: creds.id, label: creds.label, base: normalizeBase(creds.url), key: creds.apiKey } : 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 credential from an unreachable instance. */
export class UpstreamError extends Error {
  constructor(
    message: string,
    readonly status: number,
  ) {
    super(message);
  }
}

/** Immich errors as `{ message }`; fall back to the status when the body is not that (a proxy's HTML, say). */
async function upstreamMessage(res: Response, fallback: string): Promise<string> {
  const body = (await res.json().catch(() => null)) as { message?: unknown } | null;
  const message = typeof body?.message === 'string' ? body.message : '';
  return message ? `${message} (${res.status})` : `${fallback} with ${res.status}`;
}

/**
 * Trade an email and password for a SESSION token.
 *
 * A session is not an API key and the difference is load-bearing in both directions: sessions are listed as
 * devices, can be revoked from a phone, and authenticate with `Authorization: Bearer` rather than the
 * `x-api-key` header everything else here uses — but they are also the ONLY credential Immich will elevate.
 * `POST /auth/session/unlock` and `POST /sessions` both open with `if (!auth.session) throw` (verified against
 * the running 3.1.0 image), so an API key can never reach the locked folder and can never mint a session to
 * get there. That is why this is exported rather than inlined into mintApiKey.
 *
 * The password is used here and nowhere else. Callers hold the returned token or discard it; nothing stores it.
 */
export async function login(base: string, email: string, password: string): Promise<string> {
  const res = await fetch(`${base}/api/auth/login`, {
    method: 'POST',
    headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, password }),
    redirect: 'manual',
  });
  // Immich says why in the body, and its two 401s mean different things — "Incorrect email or password" versus
  // "Password login has been disabled" on an OAuth-only instance. Guessing from the status would flatten them.
  if (!res.ok) throw new UpstreamError(await upstreamMessage(res, 'sign-in failed'), res.status);

  const session = (await res.json()) as { accessToken?: unknown };
  if (typeof session.accessToken !== 'string' || !session.accessToken) {
    throw new UpstreamError('sign-in returned no access token', res.status);
  }
  return session.accessToken;
}

/**
 * End a session. Best effort by design: a session we cannot close is untidy, not a failure of whatever the
 * caller was actually doing. Skipping it entirely would leave a phantom "device" in the owner's Immich
 * account for every sign-in Officer makes.
 */
export async function logout(base: string, token: string): Promise<void> {
  await fetch(`${base}/api/auth/logout`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${token}` },
    redirect: 'manual',
  }).catch(() => {});
}

/**
 * Trade an email and password for an API key, so connecting Officer does not mean a trip to Immich's own
 * settings screen.
 *
 * `/api-keys` mints the durable credential. `permissions` is required and NOT optional; `all` is only
 * grantable because a session has every permission — an API key can never mint a key stronger than itself
 * (`Cannot grant permissions you do not have`). Asking for `all` here is deliberate: Immich keys are scoped,
 * and an under-scoped one returns 403 per route, which reads as a broken feature.
 *
 * The session exists only to hold the key and is closed in the `finally`. The password is used here and
 * nowhere else; only the key survives this function.
 */
export async function mintApiKey(base: string, email: string, password: string): Promise<string> {
  const accessToken = await login(base, email, password);
  const bearer = { Authorization: `Bearer ${accessToken}` };

  try {
    const created = await fetch(`${base}/api/api-keys`, {
      method: 'POST',
      headers: { ...bearer, Accept: 'application/json', 'Content-Type': 'application/json' },
      body: JSON.stringify({ name: 'Officer', permissions: ['all'] }),
      redirect: 'manual',
    });
    if (!created.ok) {
      throw new UpstreamError(await upstreamMessage(created, 'could not create an API key'), created.status);
    }

    const payload = (await created.json()) as { secret?: unknown };
    if (typeof payload.secret !== 'string' || !payload.secret) {
      throw new UpstreamError('Immich created a key but returned no secret', created.status);
    }
    return payload.secret;
  } finally {
    await logout(base, accessToken);
  }
}

type CallOptions = {
  /** Absolute path on the Immich host, e.g. `/api/albums`. */
  path: string;
  method?: string;
  /** Raw search string including the leading `?`, or empty. */
  query?: string;
  /**
   * A string for the JSON callers, or the forwarder's already-buffered request body.
   *
   * This used to be a stream, for the obvious reason that a phone's video should not sit in this
   * process's heap. It was changed back deliberately: a request body still being written when Immich
   * answers loses the answer entirely — Node replaces the app's response with a bodyless 400 and closes
   * — which is what made every large upload fail inexplicably. `readBodyCapped` in routes.ts has the
   * measurements and the alternatives that were tried; the heap cost is the accepted price.
   *
   * A buffer also carries a Content-Length, so the request to Immich is no longer chunked.
   */
  body?: BodyInit | null;
  contentType?: string | null;
  /** Byte range for thumbnail/original/video reads, forwarded verbatim. */
  range?: string | null;
  ifNoneMatch?: string | null;
  /** Set false for the version/ping routes, which Immich serves unauthenticated. */
  withKey?: boolean;
  /**
   * A session token to authenticate as, INSTEAD of the stored API key.
   *
   * Only the locked-folder surface sets this (see locked.ts). Elevation lives on a session, so a request that
   * must see `visibility: 'locked'` assets has to carry one; everything else stays on the key. The two are
   * mutually exclusive on purpose — sending both would leave which credential Immich actually used up to its
   * header precedence rather than to this file.
   */
  bearer?: string;
};

/** The single door to Immich. Everything the sidecar fetches goes through here. */
export async function callUpstream(cfg: UpstreamTarget, opts: CallOptions): Promise<Response> {
  const headers: Record<string, string> = { Accept: 'application/json' };

  if (opts.bearer) headers.Authorization = `Bearer ${opts.bearer}`;
  else if (opts.withKey !== false) headers['x-api-key'] = cfg.key;
  if (opts.contentType) headers['Content-Type'] = opts.contentType;
  if (opts.range) headers.Range = opts.range;
  if (opts.ifNoneMatch) headers['If-None-Match'] = opts.ifNoneMatch;

  // `duplex: 'half'` is required by the fetch spec whenever the body is a stream, and is absent from
  // TypeScript's RequestInit — hence the widened type, same as createSidecarProxy does. Harmless for the
  // string bodies.
  const init: RequestInit & { duplex?: 'half' } = {
    method: opts.method ?? 'GET',
    headers,
    body: opts.body ?? undefined,
    duplex: 'half',
    // A redirect from an API route means something has gone wrong with auth; surface it rather than
    // following it into an HTML page.
    redirect: 'manual',
  };

  const res = await fetch(`${cfg.base}${opts.path}${opts.query ?? ''}`, init);

  if (res.status === 401 || res.status === 403) {
    // 403 is the interesting one: the key is valid but lacks the permission this route needs.
    console.warn(`[photos] ${opts.method ?? 'GET'} ${opts.path} → ${res.status} (key rejected or under-scoped)`);
  }

  return res;
}
