/**
 * `/api/v1/session` — the only place this box issues a credential rather than
 * checking one.
 *
 * It exists because neither committed mode lets a human sign in: `none` is open
 * and `jwt` verifies a token somebody else already issued. `TERA_AUTH_MODE=password`
 * fills that gap for a self-hoster with no identity provider, and it fills it by
 * signing the same HS256 token `jwt` mode verifies and putting it in a cookie.
 * `offices.ts` is unchanged and unaware; there is still exactly one code path
 * that decides whether a private office exists for you.
 *
 * Three deliberate refusals to be helpful, all of them about not answering
 * questions an anonymous caller should not get answers to:
 *
 * - A wrong username and a wrong password produce the same status, the same body
 *   and the same amount of work. `credentialsMatch` in `auth/password.ts` is
 *   where that is enforced.
 * - When this box cannot sign anyone in, `POST` answers **404 with the body an
 *   unrouted path gets**, not "password login is disabled". Whether an operator
 *   configured a local account is not public information.
 * - A private office still answers 404 rather than 403, before and after signing
 *   in. That rule lives in `offices.ts` and nothing here weakens it.
 */

import type { FastifyInstance } from "fastify";
import {
  clearedSessionCookie,
  grantsAdmin,
  issueSessionToken,
  sessionCookie,
  type Viewer,
} from "../auth/index.ts";
import { MAX_PASSWORD_LENGTH, credentialsMatch } from "../auth/password.ts";
import type { ErrorBody } from "../../../src/server/wire.ts";
import type { Services } from "../services.ts";

/**
 * What the client gets to know. `passwordLogin` is the field the UI gates on:
 * it says a login form would do something here, which is not a secret — the
 * login page is served to anyone — while `authenticated` says whether this
 * particular caller needs one.
 */
export interface SessionBody {
  authenticated: boolean;
  /** The signed-in subject, or `null`. Never a token. */
  subject: string | null;
  /**
   * The god tier, decided by `TERA_ADMIN_SUBJECTS` on the server. The client
   * reads it to decide what to draw — the time scrubber, the debug panel — and
   * that is all it is for. It is not a capability: everything gated on it is
   * gated again where it is enforced, because a boolean that arrived over the
   * wire is a rendering hint and nothing more.
   */
  admin: boolean;
  /** Whether `POST` to this endpoint can sign somebody in on this deployment. */
  passwordLogin: boolean;
}

/** Byte-identical to what the not-found handler serves for a path with no route. */
const NOT_FOUND: ErrorBody = { error: "not_found", message: "No such route." };

/**
 * One message for every rejected credential. It says nothing about which half
 * was wrong, and the same object is sent for a username that does not exist.
 */
const REJECTED: ErrorBody = {
  error: "unauthorized",
  message: "Those credentials were not accepted.",
};

/**
 * `unauthorized` rather than a token of its own because the error union in
 * `wire.ts` is closed and deliberately short. The 429 and `Retry-After` carry
 * the distinction, and a client that only reads `error` treats a rate-limited
 * attempt as a failed one, which is the right thing for it to do.
 */
const RATE_LIMITED: ErrorBody = {
  error: "unauthorized",
  message: "Too many sign-in attempts. Try again shortly.",
};

const MAX_USERNAME_LENGTH = 256;

/**
 * What `DELETE` reports. Signing out drops the tier with the session, and it
 * has to be said explicitly rather than left to the client: a page that cached
 * `admin: true` and only ever hears "authenticated: false" would keep drawing
 * the god-only controls until the next reload.
 */
const SIGNED_OUT: Viewer = { authenticated: false, subject: null, admin: false };

export function registerSession(app: FastifyInstance, services: Services): void {
  const { auth } = services.config;
  // Per app instance rather than per module, so two servers in one process —
  // which is what the tests are — cannot share a limiter.
  const limiter = createLimiter(
    auth.passwordLogin?.rateAttempts ?? 8,
    auth.passwordLogin?.rateWindowSeconds ?? 300,
  );

  app.get("/api/v1/session", async (req) => {
    const viewer = await services.auth.resolve(req);
    return body(viewer, auth.passwordLogin !== null);
  });

  app.post("/api/v1/session", async (req, reply) => {
    const login = auth.passwordLogin;
    if (login === null) return reply.code(404).send(NOT_FOUND);

    const retryAfter = limiter.check(req.ip);
    if (retryAfter !== null) {
      // Before reading the body and before hashing anything: the point of the
      // limit is that a flood of attempts costs the box nothing.
      return reply.code(429).header("retry-after", String(retryAfter)).send(RATE_LIMITED);
    }

    const supplied = credentials(req.body);
    if (supplied === null) {
      // A malformed body is a failed attempt, not a 400. Answering differently
      // would let an attacker probe the endpoint without spending attempts.
      limiter.fail(req.ip);
      return reply.code(401).send(REJECTED);
    }

    const ok = await credentialsMatch(
      login.username,
      login.hash,
      supplied.username,
      supplied.password,
    );
    if (!ok) {
      limiter.fail(req.ip);
      return reply.code(401).send(REJECTED);
    }

    limiter.succeed(req.ip);
    const token = issueSessionToken(auth, login.username, login.sessionTtlSeconds);
    reply.header("set-cookie", sessionCookie(auth, token, login.sessionTtlSeconds));
    // The real grant for the account that just signed in, not `false` and not a
    // guess. `issueSessionToken` put `login.username` in the `sub` claim, so
    // `grantsAdmin` is being asked the same question about the same string that
    // `resolve()` will ask on the very next request with this cookie — a login
    // that answered differently from the GET a moment later would be a flicker
    // nobody could reproduce.
    return body(
      { authenticated: true, subject: login.username, admin: grantsAdmin(auth, login.username) },
      true,
    );
  });

  // Signing out is available in every mode, including the ones where this box
  // never issued the cookie. Refusing to clear a cookie tells the caller
  // something about how the deployment is configured and helps nobody.
  app.delete("/api/v1/session", async (_req, reply) => {
    reply.header("set-cookie", clearedSessionCookie(auth));
    return body(SIGNED_OUT, auth.passwordLogin !== null);
  });
}

/**
 * The one place the session shape is written. All three handlers go through it,
 * so `admin` cannot be present on one response and missing from another — which
 * is the bug a client's `s.admin === true` would read as "demoted" and act on.
 */
function body(viewer: Viewer, passwordLogin: boolean): SessionBody {
  return {
    authenticated: viewer.authenticated,
    subject: viewer.subject,
    admin: viewer.admin,
    passwordLogin,
  };
}

/**
 * Read the two fields, or decide there was nothing to read.
 *
 * Length caps are here rather than at the hash: a request body is up to a
 * megabyte and there is no reason to hand any of it to a key-derivation
 * function on an unauthenticated route.
 */
function credentials(raw: unknown): { username: string; password: string } | null {
  if (raw === null || typeof raw !== "object") return null;
  const { username, password } = raw as { username?: unknown; password?: unknown };
  if (typeof username !== "string" || typeof password !== "string") return null;
  if (username === "" || password === "") return null;
  if (username.length > MAX_USERNAME_LENGTH || password.length > MAX_PASSWORD_LENGTH) return null;
  return { username, password };
}

// ---- Rate limiting --------------------------------------------------------

interface Bucket {
  failures: number;
  windowEndsAt: number;
}

/**
 * A fixed window of failures per client address, held in memory.
 *
 * In memory because there is one process and one box (CONTRACT.md §5) and a
 * Redis to lose is worse than a limiter that resets on deploy. Keyed on
 * `req.ip`, which is the real client because Caddy is the only thing that can
 * reach the socket and `trustProxy` is on — nothing else can forge the header.
 *
 * Only failures count. A working session should not be able to lock its own
 * owner out by reloading, and an attacker who is already succeeding is not
 * someone a rate limit is going to help with.
 */
function createLimiter(attempts: number, windowSeconds: number) {
  const buckets = new Map<string, Bucket>();
  const limit = Math.max(1, Math.floor(attempts));
  const windowMs = Math.max(1, Math.floor(windowSeconds)) * 1000;

  /** Bounded so a flood from many addresses cannot grow the map without limit. */
  const MAX_TRACKED = 4096;

  function sweep(now: number): void {
    for (const [key, bucket] of buckets) {
      if (bucket.windowEndsAt <= now) buckets.delete(key);
    }
    // Still full after dropping the expired ones: that is a flood rather than
    // accumulation, and clearing is the right trade. The worst case is that some
    // attackers get their attempts back; the alternative is unbounded memory.
    if (buckets.size >= MAX_TRACKED) buckets.clear();
  }

  return {
    /** Seconds the caller should wait, or `null` when it may try now. */
    check(ip: string): number | null {
      const now = Date.now();
      const bucket = buckets.get(ip);
      if (bucket === undefined || bucket.windowEndsAt <= now) return null;
      if (bucket.failures < limit) return null;
      return Math.max(1, Math.ceil((bucket.windowEndsAt - now) / 1000));
    },

    fail(ip: string): void {
      const now = Date.now();
      if (buckets.size >= MAX_TRACKED) sweep(now);
      const bucket = buckets.get(ip);
      if (bucket === undefined || bucket.windowEndsAt <= now) {
        buckets.set(ip, { failures: 1, windowEndsAt: now + windowMs });
        return;
      }
      bucket.failures += 1;
    },

    succeed(ip: string): void {
      buckets.delete(ip);
    },
  };
}
