import type { UpstreamConfig } from './upstream';
import { relayResponse } from './routes';
import { UpstreamError, callUpstream, login, logout } from './upstream';

// `/_locked` — Immich's private folder, which is the one part of the library the stored API key cannot reach.
//
// WHY THIS IS A SEPARATE SURFACE RATHER THAN A FEW MORE ENTRIES IN routes.ts
//
// Immich gates locked assets on ELEVATION, and elevation lives on a session. Verified against the running
// 3.1.0 image rather than inferred:
//
//   utils/access.js          requireElevatedPermission → `if (!auth.session?.hasElevatedPermission) throw`
//   services/auth.service.js unlockSession → `if (!auth.session) throw 'only be used with a session token'`
//   services/session.service.js create → the same guard, so a key cannot even mint a session to escape this
//   repositories/access.repository.js checkOwnerAccess → `.$if(!hasElevatedPermission, visibility != locked)`
//
// An API key has no session, so `auth.session` is undefined and the check fails unconditionally. No key
// permission, scope or allow-list entry changes that. The last of those four is the one that decides the shape
// of this file: the elevation test is inside the generic owner-access check, so it covers `AssetView` and
// `AssetDownload` too — a locked asset's THUMBNAIL and ORIGINAL are gated, not merely its listings.
//
// So reading the private folder needs a session, a session needs a password, and Officer deliberately does not
// keep one (see upstream.ts: the password is used to mint a key and discarded with the stack frame). The
// resolution here is that the session is minted at unlock, held IN MEMORY for the elevation window, and closed
// on lock or idle. Nothing new is written to `photos_config`, so a full compromise of Officer's database still
// does not open the private folder — the PIN and the password are never at rest here.
//
// The cost is that unlocking asks for the Immich password as well as the PIN. That is a real cost and the
// alternative is a stored session token, which would make unlock PIN-only. It was left unbuilt because it is a
// standing security-posture change and this is not: everything below is forgotten when the process restarts.
//
// The general allow-list in routes.ts still refuses `auth/*` wholesale. This file does not widen it — it
// reaches four specific auth routes through named endpoints that each do one thing, which is the narrowing the
// request asked for rather than a door left open for anything under `auth/`.

/** What the API key alone can do, which is more than expected and worth being precise about. */
const PIN_PATH = '/api/auth/pin-code';

/**
 * Immich holds elevation for 15 minutes and slides it forward while a session keeps working. We do not mirror
 * that arithmetic — Immich stays the authority and a 401 collapses our copy — but a session nobody has used
 * for longer than the window can never be elevated again, so it is closed rather than left open indefinitely.
 */
const IDLE_MS = 20 * 60 * 1000;

/** Immich's own rule, checked here so a typo is a clear message instead of a zod dump from upstream. */
const PIN_RE = /^\d{6}$/;

type Elevation = { accountId: number; base: string; token: string; lastUsedAt: number };

// Keyed by Officer user. The value never leaves this module and is never persisted.
const elevations = new Map<number, Elevation>();

/** Close a session upstream and forget it. Safe to call when there is nothing to forget. */
async function drop(userId: number): Promise<void> {
  const current = elevations.get(userId);
  if (!current) return;
  elevations.delete(userId);
  await logout(current.base, current.token);
}

/**
 * The live session for this user, or null.
 *
 * Switching Immich accounts while unlocked has to invalidate it: the session belongs to the account it was
 * minted against, and serving the previous library's private folder after a switch would be the worst possible
 * version of the caching bug the config routes already guard against.
 */
function current(userId: number, cfg: UpstreamConfig): Elevation | null {
  const found = elevations.get(userId);
  if (!found) return null;

  if (found.accountId !== cfg.id || Date.now() - found.lastUsedAt > IDLE_MS) {
    void drop(userId);
    return null;
  }
  return found;
}

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

/** 423 is the signal the UI turns back into the unlock prompt; 401 would read as "signed out of Officer". */
const locked = (error = 'the private folder is locked') => Response.json({ error, unlocked: false }, { status: 423 });

type Body = { pinCode?: unknown; newPinCode?: unknown; password?: unknown };

const readBody = async (req: Request): Promise<Body> => ((await req.json().catch(() => null)) as Body | null) ?? {};
const str = (value: unknown): string => (typeof value === 'string' ? value.trim() : '');

/** Immich answers `{ message }`; anything else means we never really reached it. */
async function message(res: Response, fallback: string): Promise<string> {
  const body = (await res.json().catch(() => null)) as { message?: unknown } | null;
  return typeof body?.message === 'string' ? body.message : fallback;
}

/**
 * The email to sign in as, taken from the key rather than from the caller.
 *
 * The client knows the password but has no reason to know which Immich account the stored key belongs to, and
 * asking it to send an email would let a wrong one turn a valid password into "sign-in failed".
 */
async function ownerEmail(cfg: UpstreamConfig): Promise<string> {
  const res = await callUpstream(cfg, { path: '/api/users/me' });
  if (!res.ok) throw new UpstreamError(await message(res, 'could not identify the account'), res.status);

  const me = (await res.json()) as { email?: unknown };
  if (typeof me.email !== 'string' || !me.email) throw new UpstreamError('the account has no email', 500);
  return me.email;
}

/**
 * `GET /_locked/status` — whether a PIN exists, and whether this user is currently unlocked.
 *
 * Answered through the session when there is one, because only then does Immich report `isElevated` and
 * `pinExpiresAt` truthfully; the API key can still answer the two questions that do not involve a session.
 * `hasPassword` is here so a password-less OAuth account can be told that unlocking is impossible rather than
 * being handed a form that cannot work.
 */
async function status(cfg: UpstreamConfig, userId: number): Promise<Response> {
  const session = current(userId, cfg);
  const res = await callUpstream(cfg, { path: '/api/auth/status', bearer: session?.token });
  if (!res.ok) return bad(await message(res, 'could not read the account status'), res.status);

  const info = (await res.json()) as {
    pinCode?: boolean;
    password?: boolean;
    isElevated?: boolean;
    pinExpiresAt?: string;
  };

  // Immich disagreeing with us is Immich winning: the window lapsed underneath a session we still hold.
  if (session && !info.isElevated) await drop(userId);

  return Response.json({
    hasPin: !!info.pinCode,
    hasPassword: !!info.password,
    unlocked: !!info.isElevated,
    expiresAt: info.pinExpiresAt ?? null,
  });
}

/**
 * `POST /_locked/unlock` — sign in, elevate, keep the session.
 *
 * A wrong PIN has to close the session it just opened. Immich lists every sign-in as a device, so leaking one
 * per failed attempt would turn a mistyped PIN into a growing list of phantom devices in the owner's account.
 */
async function unlock(req: Request, cfg: UpstreamConfig, userId: number): Promise<Response> {
  const body = await readBody(req);
  const password = typeof body.password === 'string' ? body.password : '';
  const pinCode = str(body.pinCode);

  if (!password) return bad('the Immich account password is required to unlock');
  if (!PIN_RE.test(pinCode)) return bad('the PIN is six digits');

  let token: string;
  try {
    token = await login(cfg.base, await ownerEmail(cfg), password);
  } catch (err) {
    if (err instanceof UpstreamError) return bad(err.message, err.status === 401 ? 401 : 400);
    return bad(`could not reach the instance (${String(err)})`, 502);
  }

  const res = await callUpstream(cfg, {
    path: '/api/auth/session/unlock',
    method: 'POST',
    bearer: token,
    body: JSON.stringify({ pinCode }),
    contentType: 'application/json',
  });
  if (!res.ok) {
    await logout(cfg.base, token);
    return bad(await message(res, 'the PIN was rejected'), res.status === 400 ? 400 : res.status);
  }

  // Replace rather than accumulate: unlocking twice should not leave the first session open.
  await drop(userId);
  elevations.set(userId, { accountId: cfg.id, base: cfg.base, token, lastUsedAt: Date.now() });

  return Response.json({ unlocked: true });
}

/** `POST /_locked/lock` — give up elevation immediately rather than waiting for it to lapse. */
async function lock(cfg: UpstreamConfig, userId: number): Promise<Response> {
  const session = current(userId, cfg);
  if (session) {
    await callUpstream(cfg, { path: '/api/auth/session/lock', method: 'POST', bearer: session.token });
    await drop(userId);
  }
  return Response.json({ unlocked: false });
}

/**
 * `/_locked/pin` — set up, change and remove the PIN, all on the API key.
 *
 * These genuinely do not need a session: `setupPinCode` and `validatePinCode` read `auth.user.id` and never
 * touch `auth.session` (auth.service.js), so the key is enough. That is what makes the private folder
 * something Officer can offer end to end instead of sending the owner to Immich's own settings screen first.
 */
async function pin(req: Request, cfg: UpstreamConfig, userId: number): Promise<Response> {
  const body = await readBody(req);
  const pinCode = str(body.pinCode);
  const newPinCode = str(body.newPinCode);
  const password = typeof body.password === 'string' ? body.password : '';

  const call = (method: string, payload: Record<string, string>) =>
    callUpstream(cfg, { path: PIN_PATH, method, body: JSON.stringify(payload), contentType: 'application/json' });

  if (req.method === 'POST') {
    if (!PIN_RE.test(pinCode)) return bad('the PIN is six digits');
    const res = await call('POST', { pinCode });
    return res.ok ? Response.json({ hasPin: true }) : bad(await message(res, 'could not set the PIN'), res.status);
  }

  // Change and reset both prove intent with the OLD pin or the account password, so the two are read together.
  const proof: Record<string, string> | null = password ? { password } : pinCode ? { pinCode } : null;
  if (!proof) return bad('the current PIN, or the account password, is required');

  if (req.method === 'PUT') {
    if (!PIN_RE.test(newPinCode)) return bad('the new PIN is six digits');
    const res = await call('PUT', { ...proof, newPinCode });
    return res.ok ? Response.json({ hasPin: true }) : bad(await message(res, 'could not change the PIN'), res.status);
  }

  const res = await call('DELETE', proof);
  if (!res.ok) return bad(await message(res, 'could not remove the PIN'), res.status);

  // Immich drops elevation from every session when the PIN is reset; our copy has to go with it.
  await drop(userId);
  return Response.json({ hasPin: false, unlocked: false });
}

/**
 * The elevated forward, deliberately a fraction of the main allow-list.
 *
 * Three resources, because three are enough to see the folder and move things in and out of it: read the
 * assets and their bytes, list them, and change their visibility. DELETE is absent on purpose — emptying the
 * trash or destroying an asset is reachable on the ordinary surface once it is out of the folder, and an
 * irreversible route is not worth putting behind a credential the owner cannot see.
 */
const ELEVATED: Record<string, readonly string[]> = {
  assets: ['GET', 'PUT'],
  search: ['POST'],
  timeline: ['GET'],
};

/** `/_locked/api/<path>` — the same shape as `/_officer/<path>`, but on the session and far narrower. */
async function forward(req: Request, url: URL, cfg: UpstreamConfig, userId: number, rest: string): Promise<Response> {
  // The allow-list is checked BEFORE the session, and the order is deliberate. A resource this surface will
  // never carry has to say so whether or not the folder happens to be open — answering 423 would invite a
  // client to prompt for a password and a PIN to reach something that stays a 404 afterwards.
  const [resource] = rest.split('/');
  const allowed = resource ? ELEVATED[resource] : undefined;
  if (!allowed) return Response.json({ error: 'not found' }, { status: 404 });
  if (!allowed.includes(req.method)) return bad(`${req.method} not allowed on ${resource}`, 405);

  const session = current(userId, cfg);
  if (!session) return locked();

  // Officer's own JWT arrives in `?token=` because an <img> cannot send a header. Forwarding it would write the
  // owner's session credential into Immich's access log for every locked thumbnail.
  const query = new URLSearchParams(url.search);
  query.delete('token');
  const search = query.toString();

  const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
  const res = await callUpstream(cfg, {
    path: `/api/${rest}`,
    method: req.method,
    query: search ? `?${search}` : '',
    // Streamed, not buffered — same reason as the unlocked forwarder in routes.ts.
    body: hasBody ? req.body : null,
    contentType: req.headers.get('content-type'),
    range: req.headers.get('range'),
    ifNoneMatch: req.headers.get('if-none-match'),
    bearer: session.token,
  });

  // The window closed underneath us. Collapse to locked so the UI prompts again instead of showing an error.
  if (res.status === 401) {
    await drop(userId);
    return locked('the private folder locked itself again');
  }

  session.lastUsedAt = Date.now();
  return relayResponse(res);
}

/** `subpath` is the part after `/_locked`, e.g. '/status' or '/api/assets/<id>/thumbnail'. */
export async function handleLockedRoute(
  req: Request,
  cfg: UpstreamConfig,
  userId: number,
  subpath: string,
): Promise<Response> {
  const url = new URL(req.url);
  const [, head, ...tail] = subpath.split('/');

  if (head === 'api') return forward(req, url, cfg, userId, tail.join('/'));

  if (head === 'status') {
    return req.method === 'GET' ? status(cfg, userId) : bad('method not allowed', 405);
  }

  if (head === 'unlock') {
    return req.method === 'POST' ? unlock(req, cfg, userId) : bad('method not allowed', 405);
  }

  if (head === 'lock') {
    return req.method === 'POST' ? lock(cfg, userId) : bad('method not allowed', 405);
  }

  if (head === 'pin') {
    const allowed = req.method === 'POST' || req.method === 'PUT' || req.method === 'DELETE';
    return allowed ? pin(req, cfg, userId) : bad('method not allowed', 405);
  }

  return Response.json({ error: 'not found' }, { status: 404 });
}

/**
 * Close sessions nobody came back for.
 *
 * Without this an owner who unlocks and then closes the tab leaves a signed-in device in their Immich account
 * until the sidecar restarts. Elevation is long gone by then, so the session is useless to us and untidy there.
 */
setInterval(() => {
  const now = Date.now();
  for (const [userId, session] of elevations) {
    if (now - session.lastUsedAt > IDLE_MS) void drop(userId);
  }
}, 60_000).unref();
