/**
 * Who is asking — in three modes, two of which most deployments never turn on.
 *
 * - **`none`** is the default and the one the acceptance test runs. Everything
 *   this box serves is public, nobody has an account anywhere, and a private
 *   office simply does not exist as far as the API is concerned.
 * - **`sso`** is what Lumbridge's own deployment uses. This world holds **no
 *   credentials**: it is handed an entry URL to send people to and a
 *   revalidate URL to ask about a token, and the answer comes back from the
 *   thing that issued the session. Reusing the pattern already running on the
 *   fleet means there is no second identity system to keep secure, and both ends
 *   are env vars, which is what a dev kit needs. CONTRACT.md §6.
 * - **`jwt`** verifies a token here, for a deployment that would rather not make
 *   an outbound call per request. See `jwt.ts` for why HS256 is the primary path.
 *
 * `TERA_AUTH_MODE=password` is a fourth thing an operator can write and
 * deliberately **not** a fourth branch below: it resolves to `jwt` in
 * `config.ts`, and the only difference is that this box also signs the token
 * itself, in `issueSessionToken`. A self-hoster with nowhere to get a token from
 * needed a way to sign in; giving them a second authorisation path to audit was
 * not worth it, so they get the same one with a local issuer attached.
 *
 * Enforcement is on the server in all of them. A viewer object that says
 * `authenticated: false` is the only thing a route ever sees, and the route
 * answers 404 — never 403 — so the endpoint cannot be used to enumerate what
 * exists.
 */

import { createHash, createHmac } from "node:crypto";
import type { FastifyRequest } from "fastify";
import type { AuthConfig } from "../config.ts";
import { verifyJwt } from "./jwt.ts";

export interface Viewer {
  authenticated: boolean;
  /** Stable subject id where one is known. Never a token, never an email. */
  subject: string | null;
  /**
   * The god tier. Decided here, from `TERA_ADMIN_SUBJECTS` and nothing else:
   * the client is *told* whether it is an admin, it never asserts it, and no
   * request header, query parameter or claim in the token can turn this on.
   * Anonymous is never an admin, and neither is an authenticated subject the
   * operator did not list — including the local `password` account, which gets
   * no automatic grant precisely so that there is one path to godmode and it
   * is legible in the env file.
   */
  admin: boolean;
}

export interface AuthService {
  resolve(req: FastifyRequest): Promise<Viewer>;
}

const ANONYMOUS: Viewer = { authenticated: false, subject: null, admin: false };

/** Positive revalidations are held briefly; negative ones are not held at all. */
const SESSION_TTL_MS = 60_000;

/**
 * Whether a subject holds the god tier, by the one rule there is.
 *
 * Exported because `routes/session.ts` has to answer the POST that signs
 * somebody in *before* any token has been round-tripped through `resolve()`,
 * and its answer must be the value the next `GET /api/v1/session` produces for
 * the same account. Two expressions of the same rule is exactly how a UI ends
 * up drawing controls the server will refuse to honour, so there is one
 * function and both callers go through it.
 *
 * The grant comes in on `AuthConfig` rather than as a second argument to
 * `createAuth` so that the list arrives by the same route as every other thing
 * an operator configured, read once in `config.ts` and never from `process.env`
 * down here.
 */
export function grantsAdmin(config: AuthConfig, subject: string | null): boolean {
  // `*` means "everyone who is authenticated", so it grants even where the
  // issuer handed us no `sub` to match against. It is a development switch and
  // it announces itself in `degraded`; see `loadAdmins` in config.ts.
  if (config.admins.everyone) return true;
  if (subject === null) return false;
  // Exact match. The trimming happened once, at load.
  return config.admins.subjects.includes(subject);
}

export function createAuth(config: AuthConfig): AuthService {
  const sessions = new Map<string, { viewer: Viewer; checkedAt: number }>();

  async function revalidate(token: string): Promise<Viewer> {
    // The cache is keyed on a hash so that a heap dump, a debugger or a stray
    // log line never contains a usable session token.
    //
    // What it holds is the whole viewer, `admin` included, for up to
    // SESSION_TTL_MS. That is safe to hold because the grant is a pure function
    // of the subject and of `TERA_ADMIN_SUBJECTS`, and the environment is read
    // exactly once, at boot: the only way to change who is an admin is to edit
    // the env file and restart, and a restart is a new process with an empty
    // map. There is no sequence of operator actions that leaves a stale
    // `admin: true` being served. What the sixty seconds does cost is the other
    // direction — a session revoked upstream keeps working for up to a minute,
    // admin sessions along with everything else — and a minute of staleness on
    // a positive revalidation is the trade this cache exists to make.
    const key = createHash("sha256").update(token).digest("hex");
    const hit = sessions.get(key);
    if (hit !== undefined && Date.now() - hit.checkedAt < SESSION_TTL_MS) return hit.viewer;

    let viewer = ANONYMOUS;
    try {
      const res = await fetch(config.revalidateUrl, {
        headers: { authorization: `Bearer ${token}`, accept: "application/json" },
        signal: AbortSignal.timeout(4000),
      });
      if (res.ok) {
        const body = (await res.json().catch(() => null)) as { sub?: unknown } | null;
        const sub = typeof body?.sub === "string" ? body.sub : null;
        viewer = { authenticated: true, subject: sub, admin: grantsAdmin(config, sub) };
      }
    } catch {
      // An unreachable identity service means nobody is authenticated. That is
      // the safe direction, and it is why this returns a viewer rather than
      // throwing: the route still answers, it just answers 404.
      return ANONYMOUS;
    }

    if (viewer.authenticated) sessions.set(key, { viewer, checkedAt: Date.now() });
    return viewer;
  }

  return {
    async resolve(req: FastifyRequest): Promise<Viewer> {
      if (config.mode === "none") return ANONYMOUS;

      const token = bearerToken(req) ?? cookieToken(req, config.cookieName);
      if (token === null) return ANONYMOUS;

      if (config.mode === "sso") return revalidate(token);

      const claims = await verifyJwt(token, config);
      if (claims === null) return ANONYMOUS;
      const subject = typeof claims.sub === "string" ? claims.sub : null;
      return { authenticated: true, subject, admin: grantsAdmin(config, subject) };
    },
  };
}

// ---- Issuing a session ----------------------------------------------------

/**
 * Sign the session token `resolve()` will later verify.
 *
 * Signing and verification live in the same directory on purpose: the claims
 * this writes are exactly the claims `jwt.ts` checks, including the `iss` and
 * `aud` an operator configured, and a token that this server issues but cannot
 * accept is the failure mode worth designing against. `alg` is pinned to HS256
 * to match `verifyHs256`, which will not accept anything else.
 */
export function issueSessionToken(
  config: AuthConfig,
  subject: string,
  ttlSeconds: number,
): string {
  const now = Math.floor(Date.now() / 1000);
  const claims: Record<string, unknown> = { sub: subject, iat: now, exp: now + ttlSeconds };
  if (config.issuer !== "") claims.iss = config.issuer;
  if (config.audience !== "") claims.aud = config.audience;

  const signed = `${base64url({ alg: "HS256", typ: "JWT" })}.${base64url(claims)}`;
  const signature = createHmac("sha256", config.jwtSecret).update(signed).digest("base64url");
  return `${signed}.${signature}`;
}

/**
 * The `Set-Cookie` value for a signed-in browser.
 *
 * `HttpOnly` because no script has any business reading a bearer token — it is
 * the difference between an XSS bug that defaces a page and one that walks off
 * with a session. `Secure` unconditionally, including in development: browsers
 * treat `localhost` and `127.0.0.1` as trustworthy origins and will store a
 * Secure cookie there, so there is no dev exemption to add and therefore no dev
 * exemption to accidentally ship. `SameSite=Lax` because nothing here is a
 * cross-site POST, and `Lax` is what stops another origin's form from acting as
 * the signed-in user.
 */
export function sessionCookie(config: AuthConfig, token: string, ttlSeconds: number): string {
  return cookie(config.cookieName, encodeURIComponent(token), Math.max(0, Math.floor(ttlSeconds)));
}

/**
 * Clearing has to repeat every attribute that was set. A browser matches a
 * replacement cookie on name, domain and path, so dropping `Path=/` here would
 * leave the original in place and sign nobody out.
 */
export function clearedSessionCookie(config: AuthConfig): string {
  return cookie(config.cookieName, "", 0);
}

function cookie(name: string, value: string, maxAge: number): string {
  return `${name}=${value}; Max-Age=${maxAge}; Path=/; HttpOnly; Secure; SameSite=Lax`;
}

function base64url(value: unknown): string {
  return Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
}

// ---- Reading a session ----------------------------------------------------

function bearerToken(req: FastifyRequest): string | null {
  const header = req.headers.authorization;
  if (typeof header !== "string") return null;
  const match = /^Bearer\s+(.+)$/i.exec(header.trim());
  return match?.[1] ?? null;
}

/**
 * Cookies are parsed by hand rather than with a plugin. One header, one split,
 * and the alternative is a dependency whose entire job is this function.
 *
 * The decode is guarded because `decodeURIComponent` throws on malformed
 * percent-encoding, and that throw was the one error path in this file that
 * escaped: everything else here is written to hand back `ANONYMOUS` rather than
 * raise, on the reasoning at the top of `resolve()`. A single request carrying
 * `Cookie: tera_session=%zz` turned `GET /api/v1/session` — and the private
 * office check that shares this code path — into a 500, from an unauthenticated
 * caller, with one header. A cookie that is not valid percent-encoding is not a
 * token this box issued, so the honest answer is "no token".
 */
function cookieToken(req: FastifyRequest, name: string): string | null {
  const header = req.headers.cookie;
  if (typeof header !== "string") return null;
  for (const pair of header.split(";")) {
    const eq = pair.indexOf("=");
    if (eq === -1) continue;
    if (pair.slice(0, eq).trim() !== name) continue;
    const value = pair.slice(eq + 1).trim();
    if (value === "") return null;
    try {
      return decodeURIComponent(value);
    } catch {
      return null;
    }
  }
  return null;
}
