/**
 * The environment, read once, with every default chosen so that reading nothing
 * still produces a working server.
 *
 * This module is where CONTRACT.md §5.1 is enforced, and it is worth stating the
 * rule in one sentence because two independent designs got it wrong the same
 * way: **a source configured without what it needs is demoted, not fatal.** The
 * acceptance test for this whole repo is a stranger with no keys, and a boot
 * that throws because `TERA_WEATHER_CONTACT` is unset fails it. Every demotion
 * appends one loud sentence to `degraded`, which `index.ts` logs and
 * `/api/v1/health` serves, so the state is visible without being terminal.
 *
 * The same applies to malformed values: `TERA_PORT=banana` warns and falls back
 * to 8431. A process that will not start is worse than a process that starts on
 * the default port and says so.
 *
 * Prefix is `TERA_*` throughout, with no exceptions and no legacy aliases.
 */

import { readFileSync } from "node:fs";
import { parseScryptHash, type ScryptHash } from "./auth/password.ts";
import { loadRegions, type RegionSet } from "./regions.ts";
import { isSafeIceUrl } from "../../src/media/iceValidation.ts";
import { adsbAttribution, checkAdsbEndpoint, FIRST_PARTY_RECEIVER } from "./flights/licence.ts";
import type {
  AuthMode,
  BirdsSourceId,
  DevicesSourceId,
  FiresSourceId,
  FlightsSourceId,
  MarkersSourceId,
  RadarSourceId,
  SatellitesSourceId,
  WeatherSourceId,
} from "../../src/server/wire.ts";

export interface WeatherConfig {
  source: WeatherSourceId;
  /** Sent as the User-Agent to sources that require an identifiable caller. */
  contact: string;
  ttlSeconds: number;
}

export interface FlightsConfig {
  source: FlightsSourceId;
  /**
   * Base URL for the `adsb` source, **validated and normalised**.
   *
   * Empty on every other source, including a source that was demoted to `sim`
   * because its endpoint failed the licence gate. That is deliberate: a refused
   * URL does not survive into the config, so no later code can fetch it by
   * accident and no later reader can mistake it for one this box vouches for.
   * `flights/licence.ts` is the gate and explains what it is protecting.
   */
  endpoint: string;
  /**
   * The credit lines a live body carries, derived from the endpoint's host.
   *
   * Not a constant, and not written next to the fetch. The whole point of
   * computing it here is that there is no way for the credit and the source to
   * disagree — which they did, for every value of `TERA_ADSB_ENDPOINT` that was
   * not adsb.lol.
   */
  attribution: string[];
  /**
   * Whether this source's terms let the box re-serve the bytes to third
   * parties. Gates public caching on `/api/v1/flights`.
   */
  redistributable: boolean;
  /** The licence id behind `redistributable`, or `null` when nothing is live. */
  licence: string | null;
  /**
   * Sent as the User-Agent on the `adsb` fetch, and required by the feed.
   *
   * adsb.lol answers `403 "User-Agent too generic; include valid contact info."`
   * to a request that does not identify its caller, which is a policy their
   * hosted endpoint applies and not something this build can opt out of. It
   * falls back to `TERA_WEATHER_CONTACT` for the same reason the satellites
   * contact does: an operator has one contact address, not four.
   */
  contact: string;
  /** Radius in nautical miles for the `adsb` source. */
  radiusNm: number;
  /** Path to a local dump1090 `aircraft.json`. */
  dump1090Path: string;
  /** Phase origin for the simulated plan. Fixed; see `flights/plan.ts`. */
  epochMs: number;
  seed: number;
  ttlSeconds: number;
}

export interface SatellitesConfig {
  source: SatellitesSourceId;
  /**
   * Sent as the User-Agent. Not required — CelesTrak does not demand one the way
   * NWS does — but sending it is the same courtesy, and it is what lets them
   * mail an operator who is hammering them instead of just blocking the address.
   */
  contact: string;
  ttlSeconds: number;
}

/**
 * Where wildfire data comes from, and how long it may be held.
 *
 * `url` is the base of a **projection endpoint on another machine** — not a
 * database path, and deliberately not one. The upstream store is keyed on a
 * private home and carries four columns computed from the distance to it; the
 * machine that owns those columns is the machine that filters them out, so what
 * this box can even ask for is already safe. `fires/cloud1.ts` has the argument
 * in full.
 */
export interface FiresConfig {
  source: FiresSourceId;
  /** Base URL, no trailing slash. Empty on every source but `cloud1`. */
  url: string;
  /**
   * Sent as `x-tera-key` when set. Optional, because the transport is a tailnet
   * and the body is public-domain agency data — the secret was the distance, and
   * it is already gone. Set it anyway on a box that can be reached from more
   * than one tailnet.
   */
  key: string;
  ttlSeconds: number;
  /** How many hours of satellite overpasses to ask for. Clamped at the source. */
  detectionWindowHours: number;
}

/**
 * One sky feed — radar or birds — pointed at a cloud-1 projection, or at
 * nothing.
 *
 * Identical in shape to `FiresConfig` minus the window, because both feeds have
 * the same relationship to their upstream: no database on this box, one base URL
 * on somebody else's tailnet, an optional key, and a source that is `none` until
 * an operator says otherwise. `source` is kept generic over the two id types so
 * that neither feed can be handed the other's.
 */
export interface SkyConfig {
  source: RadarSourceId & BirdsSourceId;
  /** Base URL, no trailing slash. Empty on every source but `cloud1`. */
  url: string;
  /** Sent as `x-tera-key` when set. See `FiresConfig.key`. */
  key: string;
  ttlSeconds: number;
}

/**
 * A first-party hardware bridge — an operator's own studio, over their own
 * network.
 *
 * Read-only by construction: there is no command path through it and
 * `devices/firstParty.ts` refuses one out loud rather than by omission. A POST
 * from a public web page that unmutes a microphone in an occupied room is a
 * different product decision and it has not been made.
 */
export interface StudioConfig {
  /** Base URL of the bridge, no trailing slash. Empty when unconfigured. */
  url: string;
  /** Sent as `x-studio-key`. */
  key: string;
  /**
   * How long a snapshot is held here, in seconds.
   *
   * Deliberately much longer than `TERA_DEVICES_TTL`, and that gap is the whole
   * reason this is its own number. The upstream's `/state` cold-probes three
   * machines over SSH — half a second, measured — and it holds its own ten-second
   * cache. Polling it at the five-second device TTL, per open tab, would fan SSH
   * out to three machines the operator actually uses, forever.
   */
  ttlSeconds: number;
}

export interface DevicesConfig {
  source: DevicesSourceId;
  /**
   * How long a device snapshot may be held before it is asked for again.
   *
   * Short, and shorter than weather by two orders of magnitude, because the two
   * are different kinds of fact: cloud cover moves over ten minutes and a mute
   * button moves when somebody presses it. This is also the TTL the browser is
   * told to poll on, so it is the floor on how long a viewer waits to see the
   * result of somebody else's command.
   *
   * It is deliberately **not** a public cache lifetime. Nothing on the devices
   * routes is ever publicly cached — see `routes/devices.ts`.
   */
  ttlSeconds: number;
  /**
   * The simulator's seed, so a deployment can be reproduced.
   *
   * The same seed and the same declarations give the same sequence of readings
   * on every box, which is what makes a bug report about a level meter
   * actionable and what lets the arena wrap this exact simulator and replay a
   * rollout. `src/devices/sim.ts` owns the arithmetic.
   */
  seed: number;
}

export interface MarkersConfig {
  source: MarkersSourceId;
  /** Path to the JSON snapshot written by the sync oneshot. */
  file: string;
  /**
   * Provenance values the public-shape gate will serve. Anything else is
   * refused row by row. See CONTRACT.md §8 and `markers/gate.ts`.
   */
  provenanceAllowlist: string[];
  ttlSeconds: number;
  /**
   * Who may read the feed. `TERA_MARKERS_ACCESS`, `members` by default.
   *
   * The default is the safe answer rather than the common one, deliberately.
   * A marker set is the one feed here that can carry something private — a
   * company's pipeline, a person's job search — and the failure mode of getting
   * it wrong is silent: nothing errors, nothing looks broken, the data is just
   * readable by the internet. So an operator who wires real data up gets
   * `members` without having chosen it, and has to say `public` out loud to
   * publish it. The weather and the aircraft need no such switch, because a
   * government sensor reading and an unencrypted ADS-B broadcast are not
   * withheld from anyone by anybody.
   */
  access: "members" | "public";
}

/**
 * The one account `TERA_AUTH_MODE=password` will sign in, once the environment
 * has been read and found to contain a usable credential. `null` everywhere
 * else, and `routes/session.ts` reads that null as "this box cannot sign anyone
 * in" and answers 404 on the login endpoint.
 */
export interface PasswordLogin {
  username: string;
  /** Parsed at boot so a typo in the hash is a health line, not a login that never works. */
  hash: ScryptHash;
  /** Lifetime of the token this box issues for itself, in seconds. */
  sessionTtlSeconds: number;
  /** Failed attempts one IP may make inside `rateWindowSeconds` before 429s start. */
  rateAttempts: number;
  rateWindowSeconds: number;
}

/**
 * Who holds the god tier, resolved from `TERA_ADMIN_SUBJECTS`. See `loadAdmins`
 * for the rules; the shape exists so that "everyone" is a state the type system
 * knows about rather than a magic string left sitting in `subjects`.
 */
export interface AdminGrant {
  /** `TERA_ADMIN_SUBJECTS=*`. Development only; see `loadAdmins`. */
  everyone: boolean;
  /** Exact subject ids, trimmed. Empty with `everyone: false` means no admins. */
  subjects: string[];
}

export interface AuthConfig {
  mode: AuthMode;
  /** Where a browser sends someone to sign in. `sso` mode only. */
  entryUrl: string;
  /** Server-side token check. `sso` mode only; this box holds no credentials. */
  revalidateUrl: string;
  /** Cookie a browser session arrives in, when it is not an Authorization header. */
  cookieName: string;
  /** HS256 shared secret. Primary, because the fleet's issuer signs HS256. */
  jwtSecret: string;
  /** Set to `jwks` to verify asymmetric signatures instead. */
  jwtVerify: "hs256" | "jwks";
  jwksUrl: string;
  issuer: string;
  audience: string;
  /** Set only by `TERA_AUTH_MODE=password`; see `PasswordLogin` and `loadPasswordLogin`. */
  passwordLogin: PasswordLogin | null;
  /** Subjects the server will call admins. Unset means nobody; see `loadAdmins`. */
  admins: AdminGrant;
}

export interface IceConfig {
  configured: boolean;
  urls: string[];
  /** Server-only coturn REST shared secret. Never serialize this config. */
  sharedSecret: string;
  credentialTtlSeconds: number;
  rateAttempts: number;
  rateWindowSeconds: number;
}

export interface Config {
  host: string;
  port: number;
  logLevel: string;
  version: string;
  /**
   * The default place this box serves, in degrees.
   *
   * Kept because it is what a self-hoster already wrote in their env file, and
   * because `regions[0]` is derived from it. Nothing per-request reads it any
   * more: weather and flights answer for a *resolved region*, since one origin
   * cannot describe two cities six hundred kilometres apart. See `regions.ts`.
   */
  origin: { lat: number; lng: number };
  /** Every place this box will answer for. The first one is the default. */
  regions: RegionSet;
  /** Allowed CORS origins. Empty means same-origin only, which is the default. */
  corsOrigins: string[];
  /** `max-age` for routes that opt in to public caching. */
  publicMaxAge: number;
  weather: WeatherConfig;
  flights: FlightsConfig;
  satellites: SatellitesConfig;
  fires: FiresConfig;
  /**
   * The two sky feeds, both off by default and both harmless when they are.
   *
   * They are separate sections rather than one `sky` because they are separate
   * upstreams with separate cadences — a NEXRAD composite advances every ten
   * minutes and a BirdCast granule is written once and never changes — and a
   * shared TTL would be wrong for one of them on every request.
   */
  radar: SkyConfig;
  birds: SkyConfig;
  markers: MarkersConfig;
  devices: DevicesConfig;
  /** The first-party device bridge. Read by `devices/firstParty.ts` only. */
  studio: StudioConfig;
  offices: { dir: string };
  /**
   * Where the rosters are. Separate from `offices.dir` because the two hold
   * different kinds of secret — see `presence/store.ts`. Unset is the default
   * and means nobody is in any building, which renders correctly.
   */
  presence: { dir: string };
  auth: AuthConfig;
  ice: IceConfig;
  /** One sentence per demotion. Empty on a fully-configured box. */
  degraded: string[];
}

type Env = Record<string, string | undefined>;

export function loadConfig(env: Env = process.env): Config {
  const degraded: string[] = [];

  const weather = loadWeather(env, degraded);
  const flights = loadFlights(env, degraded);
  const satellites = loadSatellites(env, degraded);
  const fires = loadFires(env, degraded);
  const radar = loadSky(env, "RADAR", "radar", 300, degraded);
  const birds = loadSky(env, "BIRDS", "migration", 600, degraded);
  const studio = loadStudio(env);
  const devices = loadDevices(env, studio, degraded);
  const auth = loadAuth(env, degraded);
  const ice = loadIce(env, degraded);
  // After auth, because a marker feed with nobody able to sign in is worth a
  // sentence and the sentence is only true once `mode` has finished demoting.
  const markers = loadMarkers(env, auth.mode, degraded);

  const origin = {
    lat: num(env, "TERA_ORIGIN_LAT", 37.7749, degraded),
    lng: num(env, "TERA_ORIGIN_LNG", -122.4194, degraded),
  };

  return {
    host: str(env, "TERA_HOST", "127.0.0.1"),
    port: num(env, "TERA_PORT", 8431, degraded),
    logLevel: str(env, "TERA_LOG_LEVEL", "info"),
    version: readVersion(),
    origin,
    regions: loadRegions({
      spec: str(env, "TERA_REGIONS", ""),
      origin,
      originConfigured:
        str(env, "TERA_ORIGIN_LAT", "") !== "" || str(env, "TERA_ORIGIN_LNG", "") !== "",
      degraded,
    }),
    corsOrigins: list(env, "TERA_CORS_ORIGIN"),
    publicMaxAge: num(env, "TERA_PUBLIC_MAX_AGE", 60, degraded),
    weather,
    flights,
    satellites,
    fires,
    radar,
    birds,
    markers,
    devices,
    studio,
    offices: { dir: str(env, "TERA_OFFICES_DIR", "") },
    presence: { dir: str(env, "TERA_PRESENCE_DIR", "") },
    auth,
    ice,
    degraded,
  };
}

// ---- Sections -------------------------------------------------------------

function loadIce(env: Env, degraded: string[]): IceConfig {
  const rawUrls = env.TERA_ICE_URLS;
  const rawSecret = env.TERA_TURN_SHARED_SECRET;
  const absent = (rawUrls === undefined || rawUrls.trim() === "") &&
    (rawSecret === undefined || rawSecret === "");
  const disabled: IceConfig = {
    configured: false,
    urls: [],
    sharedSecret: "",
    credentialTtlSeconds: 300,
    rateAttempts: 30,
    rateWindowSeconds: 60,
  };
  if (absent) return disabled;

  const urls = (rawUrls ?? "").split(",").map((value) => value.trim()).filter(Boolean);
  const secret = rawSecret ?? "";
  const ttl = strictInteger(env.TERA_TURN_CREDENTIAL_TTL, 300, 60, 3_600);
  const attempts = strictInteger(env.TERA_ICE_RATE_ATTEMPTS, 30, 1, 300);
  const windowSeconds = strictInteger(env.TERA_ICE_RATE_WINDOW, 60, 1, 3_600);
  const urlsValid = urls.length > 0 && urls.length <= 8 && new Set(urls).size === urls.length &&
    urls.every(isSafeIceUrl) && urls.some((url) => url.startsWith("turn:") || url.startsWith("turns:"));
  const secretValid = secret.length >= 32 && secret.length <= 4_096 && secret === secret.trim() &&
    !/[\u0000-\u001f\u007f]/.test(secret);

  if (!urlsValid || !secretValid || ttl === null || attempts === null || windowSeconds === null) {
    degraded.push("ICE credential service is disabled because its TURN configuration is incomplete or invalid.");
    return disabled;
  }
  return {
    configured: true,
    urls,
    sharedSecret: secret,
    credentialTtlSeconds: ttl,
    rateAttempts: attempts,
    rateWindowSeconds: windowSeconds,
  };
}

function strictInteger(raw: string | undefined, fallback: number, minimum: number, maximum: number): number | null {
  if (raw === undefined || raw === "") return fallback;
  if (!/^[0-9]+$/.test(raw)) return null;
  const parsed = Number(raw);
  return Number.isSafeInteger(parsed) && parsed >= minimum && parsed <= maximum ? parsed : null;
}

const WEATHER_SOURCES: WeatherSourceId[] = ["none", "nws", "metno", "openmeteo"];

function loadWeather(env: Env, degraded: string[]): WeatherConfig {
  const ttlSeconds = num(env, "TERA_WEATHER_TTL", 600, degraded);
  const contact = str(env, "TERA_WEATHER_CONTACT", "");
  const asked = str(env, "TERA_WEATHER_SOURCE", "none");

  let source = oneOf(asked, WEATHER_SOURCES);
  if (source === null) {
    degraded.push(
      `TERA_WEATHER_SOURCE="${asked}" is not one of ${WEATHER_SOURCES.join(", ")}; ` +
        `serving synthetic weather instead.`,
    );
    source = "none";
  }

  // NWS and MET Norway both require a contact string in the User-Agent and are
  // entitled to block a caller who does not send one. Calling them anyway with a
  // generic agent is the rude failure mode; refusing to boot is the useless one.
  if ((source === "nws" || source === "metno") && contact === "") {
    degraded.push(
      `TERA_WEATHER_SOURCE=${source} needs TERA_WEATHER_CONTACT (an email or URL ` +
        `the operator can be reached at) — ${source} requires an identifiable ` +
        `caller. Demoted to synthetic weather; the server is otherwise fine.`,
    );
    source = "none";
  }

  if (source === "openmeteo") {
    // Not a demotion — a warning that stays on the record. Open-Meteo's *data*
    // is CC-BY 4.0, but its free tier is non-commercial, so this is opt-in and
    // never a default. CONTRACT.md §5.2.
    degraded.push(
      "TERA_WEATHER_SOURCE=openmeteo: Open-Meteo's free tier is non-commercial. " +
        "Fine for a self-hosted map; check your terms before putting it behind a " +
        "product page.",
    );
  }

  return { source, contact, ttlSeconds };
}

const FLIGHT_SOURCES: FlightsSourceId[] = ["sim", "adsb", "dump1090"];

/**
 * The simulated plan's phase origin. Deliberately a constant rather than boot
 * time: `t0 = Date.now()` would put every aircraft back at the start of its leg
 * on every restart, and a fleet of jets teleporting to their departure gates is
 * a very visible way to announce a deploy.
 */
const PLAN_EPOCH_MS = Date.UTC(2026, 0, 1);

/**
 * The feed pointed at when `TERA_FLIGHTS_SOURCE=adsb` and nothing else is said.
 *
 * It is on the allowlist, so the default configuration passes its own gate —
 * which is the only kind of default worth shipping, and is asserted in
 * `test/adsbLicence.test.ts` so it stays that way.
 */
const DEFAULT_ADSB_ENDPOINT = "https://api.adsb.lol";

function loadFlights(env: Env, degraded: string[]): FlightsConfig {
  const asked = str(env, "TERA_FLIGHTS_SOURCE", "sim");
  let source = oneOf(asked, FLIGHT_SOURCES);
  if (source === null) {
    degraded.push(
      `TERA_FLIGHTS_SOURCE="${asked}" is not one of ${FLIGHT_SOURCES.join(", ")}; ` +
        `serving the simulated plan instead.`,
    );
    source = "sim";
  }

  const dump1090Path = str(env, "TERA_DUMP1090_PATH", "");
  if (source === "dump1090" && dump1090Path === "") {
    degraded.push(
      "TERA_FLIGHTS_SOURCE=dump1090 needs TERA_DUMP1090_PATH pointing at your " +
        "receiver's aircraft.json. Demoted to the simulated plan.",
    );
    source = "sim";
  }

  const askedEndpoint = str(env, "TERA_ADSB_ENDPOINT", DEFAULT_ADSB_ENDPOINT);
  let endpoint = "";
  let attribution: string[] = [];
  let redistributable = false;
  let licence: string | null = null;

  if (source === "adsb") {
    // The licence gate. Everything the wire will say about this source is
    // decided here, from the host, before a single request goes out.
    const verdict = checkAdsbEndpoint(askedEndpoint);
    if (verdict.ok) {
      endpoint = verdict.endpoint;
      attribution = verdict.attribution;
      redistributable = verdict.terms.redistributable;
      licence = verdict.terms.licence;
      if (verdict.caveat !== null) degraded.push(verdict.caveat);
    } else {
      degraded.push(
        `TERA_ADSB_ENDPOINT="${askedEndpoint}" ${verdict.reason}. Demoted to the simulated ` +
          "plan: this box will not republish a feed whose terms it cannot name, and it will " +
          "not credit one feed for another feed's data.",
      );
      source = "sim";
    }
  }

  if (source === "dump1090") {
    // The same claim the loopback entry makes, from the same table, because a
    // receiver's own aircraft.json and a receiver's own HTTP port are the same
    // data arriving by different doors and must not be credited differently.
    attribution = adsbAttribution(FIRST_PARTY_RECEIVER);
    redistributable = FIRST_PARTY_RECEIVER.redistributable;
    licence = FIRST_PARTY_RECEIVER.licence;
  }

  return {
    source,
    endpoint,
    attribution,
    redistributable,
    licence,
    contact: str(env, "TERA_FLIGHTS_CONTACT", str(env, "TERA_WEATHER_CONTACT", "")),
    radiusNm: radius(num(env, "TERA_ADSB_RADIUS_NM", 40, degraded), degraded),
    dump1090Path,
    epochMs: num(env, "TERA_FLIGHTS_EPOCH_MS", PLAN_EPOCH_MS, degraded),
    seed: num(env, "TERA_FLIGHTS_SEED", 4711, degraded),
    ttlSeconds: num(env, "TERA_FLIGHTS_TTL", 300, degraded),
  };
}

/**
 * The largest circle the hosted feeds will answer for: adsb.lol and
 * airplanes.live both cap `/v2/point/:lat/:lng/:radius` at 250 nautical miles
 * and reject anything larger outright. Clamping here rather than letting the
 * request fail keeps the failure legible — an operator who typed 2500 gets a map
 * with aircraft on it and a sentence in `degraded`, not a silently empty sky.
 */
const MAX_ADSB_RADIUS_NM = 250;

function radius(asked: number, degraded: string[]): number {
  if (asked >= 1 && asked <= MAX_ADSB_RADIUS_NM) return asked;
  const clamped = Math.min(MAX_ADSB_RADIUS_NM, Math.max(1, asked));
  degraded.push(
    `TERA_ADSB_RADIUS_NM=${asked} is outside the 1–${MAX_ADSB_RADIUS_NM} nautical ` +
      `miles the hosted feeds answer for; using ${clamped}.`,
  );
  return clamped;
}

const DEVICE_SOURCES: DevicesSourceId[] = ["none", "sim", "homeassistant", "first-party"];

/**
 * `none` by default, and the default is the honest one rather than the
 * impressive one.
 *
 * A box that has not been told about any hardware has no hardware. It serves an
 * empty array and the studio's panels say so, which is the correct picture of a
 * deployment nobody has wired anything into — the same posture
 * `TERA_WEATHER_SOURCE` takes for exactly the reason CONTRACT.md §5.1 gives.
 * `sim` is one variable away and is what the reference deployment runs: a
 * deterministic state machine, `synthetic: true` on every reading it produces,
 * and every panel that draws it carries the declaration's own disclosure
 * sentence.
 *
 * `homeassistant` is in the union and is not implemented. That is deliberate
 * and it demotes loudly rather than silently serving simulated readings under a
 * name that promises real ones — a source that quietly downgraded from a real
 * bridge to a simulator would be the exact `first-party-sensor`/`simulated`
 * confusion `DeviceProvenance` exists to prevent, and it would do it in the one
 * direction that matters.
 */
function loadDevices(env: Env, studio: StudioConfig, degraded: string[]): DevicesConfig {
  const asked = str(env, "TERA_DEVICES_SOURCE", "none");
  let source = oneOf(asked, DEVICE_SOURCES);
  if (source === null) {
    degraded.push(
      `TERA_DEVICES_SOURCE="${asked}" is not one of ${DEVICE_SOURCES.join(", ")}; ` +
        `serving no device state at all.`,
    );
    source = "none";
  }

  if (source === "homeassistant") {
    degraded.push(
      "TERA_DEVICES_SOURCE=homeassistant is named in the wire contract and is not " +
        "implemented in this build. Demoted to none rather than to sim: serving " +
        "invented readings under a source that promises a real bridge is the one " +
        "mistake this field exists to prevent.",
    );
    source = "none";
  }

  // A bridge with nowhere to point is not a bridge. Demoted to `none` and never
  // to `sim`, for the same reason `homeassistant` is: `first-party` is a promise
  // that a real room is being read, and quietly answering it with a state
  // machine is the one substitution `DeviceProvenance` exists to prevent.
  if (source === "first-party" && studio.url === "") {
    degraded.push(
      "TERA_DEVICES_SOURCE=first-party needs TERA_STUDIO_URL. Demoted to none — a bridge " +
        "with no upstream must not be answered with simulated readings under a source " +
        "that promises real hardware.",
    );
    source = "none";
  }

  return {
    source,
    ttlSeconds: num(env, "TERA_DEVICES_TTL", 5, degraded),
    seed: num(env, "TERA_DEVICES_SEED", 8731, degraded),
  };
}

/**
 * The first-party bridge's own settings, read before `loadDevices` so that the
 * demotion above can see whether there is anything to point at.
 *
 * It takes no `degraded` list on purpose: an unset `TERA_STUDIO_URL` on a box
 * that never asked for `first-party` is not a demotion, it is the default. The
 * one sentence that is owed is written by `loadDevices`, where the mismatch
 * actually exists.
 */
function loadStudio(env: Env): StudioConfig {
  return {
    url: str(env, "TERA_STUDIO_URL", "").replace(/\/+$/, ""),
    key: str(env, "TERA_STUDIO_KEY", ""),
    ttlSeconds: num(env, "TERA_STUDIO_TTL", 30, []),
  };
}

const FIRE_SOURCES: FiresSourceId[] = ["none", "cloud1"];

/**
 * Off by default, like satellites and for a stronger version of the same reason.
 *
 * A zero-config clone must make no outbound requests at all
 * (`scripts/check-zero-config-boot.mjs`), and this one would be an outbound
 * request to a *private* machine on somebody else's tailnet. There is no public
 * default to fall back to and there should not be: a stranger's board shows no
 * fires and says so, which is the truth about what that box knows.
 *
 * `none` serves a real, empty body — never an invented fire. The asymmetry with
 * the flight plan is deliberate and `SatellitesBody` states it: an invented
 * aeroplane is a plausible aeroplane, and an invented wildfire is a claim that a
 * named place is burning.
 */
function loadFires(env: Env, degraded: string[]): FiresConfig {
  const asked = str(env, "TERA_FIRES_SOURCE", "none");
  let source = oneOf(asked, FIRE_SOURCES);
  if (source === null) {
    degraded.push(
      `TERA_FIRES_SOURCE="${asked}" is not one of ${FIRE_SOURCES.join(", ")}; ` +
        "serving no fires.",
    );
    source = "none";
  }

  // Note what is deliberately NOT here: an unset `TERA_FIRES_SOURCE` appends
  // nothing to `degraded`. Off by default is a *choice this repo made*, not a
  // default that needs configuring to work, and `degraded` is documented as one
  // sentence per demotion. `scripts/check-zero-config-boot.mjs` enforces the
  // distinction by refusing to pass with any demotion at all on an empty
  // environment, which is the right gate: a stranger's clone is not misconfigured.
  //
  // What tells a viewer that a quiet board is quiet because nobody asked, rather
  // than because nothing is burning, is `sources.fires` on the health body and
  // `FiresBody.fetchedAt` — which is the Unix epoch on a box that has never
  // fetched, and is stated on the board itself. That is a stronger signal than a
  // log line, because it is in front of the person looking at the picture.
  const url = str(env, "TERA_FIRES_URL", "").replace(/\/+$/, "");
  if (source === "cloud1" && url === "") {
    degraded.push(
      "TERA_FIRES_SOURCE=cloud1 needs TERA_FIRES_URL, the base of the projection endpoint. " +
        "Serving no fires: an empty board is the honest answer, and the board says how old " +
        "its last answer is.",
    );
    source = "none";
  }

  return {
    source,
    url,
    key: str(env, "TERA_FIRES_KEY", ""),
    ttlSeconds: num(env, "TERA_FIRES_TTL", 600, degraded),
    detectionWindowHours: num(env, "TERA_FIRES_DETECTION_HOURS", 24, degraded),
  };
}

/**
 * One sky feed, off by default, for exactly the reason fires are.
 *
 * A zero-config clone must make no outbound requests at all
 * (`scripts/check-zero-config-boot.mjs`), and both of these would be requests to
 * a *private* machine on somebody else's tailnet. There is no public default to
 * fall back to and there should not be: a stranger's board draws no rain and no
 * birds, which is the truth about what that box knows.
 *
 * `none` serves a real, empty body — never an invented shower and never an
 * invented flock. The asymmetry with the simulated flight plan is the one
 * `SatellitesBody` states: an invented aeroplane is a plausible aeroplane, and
 * an invented thunderstorm is a claim that it is raining on somebody who can
 * look out of the window.
 *
 * Note what is deliberately NOT here: an unset `TERA_RADAR_SOURCE` appends
 * nothing to `degraded`. Off by default is a choice this repo made, not a
 * default that needs configuring, and `check-zero-config-boot.mjs` refuses to
 * pass with *any* demotion on an empty environment. What tells a viewer that a
 * quiet board is quiet because nobody asked is `sources.radar` / `sources.birds`
 * on the health body and the epoch-zero `fetchedAt` on the body itself.
 */
function loadSky(
  env: Env,
  prefix: "RADAR" | "BIRDS",
  noun: string,
  defaultTtl: number,
  degraded: string[],
): SkyConfig {
  const asked = str(env, `TERA_${prefix}_SOURCE`, "none");
  let source = oneOf(asked, SKY_SOURCES);
  if (source === null) {
    degraded.push(
      `TERA_${prefix}_SOURCE="${asked}" is not one of ${SKY_SOURCES.join(", ")}; ` +
        `serving no ${noun}.`,
    );
    source = "none";
  }

  const url = str(env, `TERA_${prefix}_URL`, "").replace(/\/+$/, "");
  if (source === "cloud1" && url === "") {
    degraded.push(
      `TERA_${prefix}_SOURCE=cloud1 needs TERA_${prefix}_URL, the base of the projection ` +
        `endpoint. Serving no ${noun}: an empty sky is the honest answer, and the board says ` +
        "how old its last answer is.",
    );
    source = "none";
  }

  return {
    source,
    url,
    key: str(env, `TERA_${prefix}_KEY`, ""),
    ttlSeconds: num(env, `TERA_${prefix}_TTL`, defaultTtl, degraded),
  };
}

/** Both sky feeds take the same two ids, which is why one list serves both. */
const SKY_SOURCES: (RadarSourceId & BirdsSourceId)[] = ["none", "cloud1"];

const SATELLITE_SOURCES: SatellitesSourceId[] = ["none", "celestrak"];

/**
 * Off by default, which is the opposite of the flight plan and is the right way
 * round for this one.
 *
 * The simulated sky costs nothing and touches no network, so it can be the
 * default. A satellite catalogue is somebody else's multi-megabyte file, fetched
 * from a service run by one person, and a repo whose every clone starts pulling
 * it the moment `npm run dev` finishes is a repo that has volunteered CelesTrak
 * to host its onboarding. An operator who wants the layer says so.
 *
 * The zero-config boot check (`scripts/check-zero-config-boot.mjs`) depends on
 * this too: a box with no environment at all must make no outbound requests.
 */
function loadSatellites(env: Env, degraded: string[]): SatellitesConfig {
  const asked = str(env, "TERA_SATELLITES_SOURCE", "none");
  let source = oneOf(asked, SATELLITE_SOURCES);
  if (source === null) {
    degraded.push(
      `TERA_SATELLITES_SOURCE="${asked}" is not one of ${SATELLITE_SOURCES.join(", ")}; ` +
        `serving no satellites.`,
    );
    source = "none";
  }

  return {
    source,
    // Falls back to the weather contact, because it is the same operator and the
    // same courtesy, and making somebody type their email into two variables to
    // get one User-Agent is a way of ensuring one of them stays empty.
    contact: str(env, "TERA_SATELLITES_CONTACT", str(env, "TERA_WEATHER_CONTACT", "")),
    ttlSeconds: num(env, "TERA_SATELLITES_TTL", 21_600, degraded),
  };
}

const MARKER_SOURCES: MarkersSourceId[] = ["none", "file"];

/** CONTRACT.md §8. Adding to this list is a licence decision, not a config tweak. */
export const DEFAULT_PROVENANCE_ALLOWLIST = ["us-census", "hand-placed", "synthetic"];

function loadMarkers(env: Env, authMode: AuthMode, degraded: string[]): MarkersConfig {
  const asked = str(env, "TERA_MARKERS_SOURCE", "none");
  let source = oneOf(asked, MARKER_SOURCES);
  if (source === null) {
    degraded.push(
      `TERA_MARKERS_SOURCE="${asked}" is not one of ${MARKER_SOURCES.join(", ")}; ` +
        `serving no markers.`,
    );
    source = "none";
  }

  const file = str(env, "TERA_MARKERS_FILE", "");
  if (source === "file" && file === "") {
    degraded.push(
      "TERA_MARKERS_SOURCE=file needs TERA_MARKERS_FILE. Serving no markers.",
    );
    source = "none";
  }

  // Not a demotion — the feed stays configured and the route keeps refusing
  // correctly — but an operator who mounted a marker file on a box where nobody
  // can sign in has built something no browser will ever see, and one sentence
  // now is cheaper than an afternoon. `routes/markers.ts` has the reasoning for
  // why the feed is members-only with no public escape hatch.
  const askedAccess = str(env, "TERA_MARKERS_ACCESS", "members");
  const access = askedAccess === "public" ? "public" : "members";
  if (askedAccess !== "members" && askedAccess !== "public") {
    degraded.push(
      `TERA_MARKERS_ACCESS="${askedAccess}" is not one of members, public; ` +
        "keeping the feed members-only.",
    );
  }
  if (source === "file" && access === "public") {
    // Not a demotion — a deliberate choice, announced. It goes in `degraded[]`
    // because that array is what `/api/v1/health` publishes, and an operator
    // reading their own health endpoint should be able to see that this box is
    // serving its marker set to the world without going and reading the env
    // file. The same reasoning as `TERA_ADMIN_SUBJECTS=*`.
    degraded.push(
      "TERA_MARKERS_ACCESS=public: the marker feed is served to anonymous " +
        "callers. Correct for a public map; wrong for anything private.",
    );
  }
  // Members-only and nobody can ever be a member is the combination that answers
  // 401 to the entire internet, which looks like an outage rather than a policy.
  if (source === "file" && access === "members" && authMode === "none") {
    degraded.push(
      "TERA_MARKERS_SOURCE=file needs an authentication mode: the marker feed is " +
        "refused to anonymous callers, and with TERA_AUTH_MODE=none nobody is ever " +
        "anything else, so it will answer 401 to everybody. Set TERA_AUTH_MODE, or " +
        "TERA_MARKERS_ACCESS=public if the map is meant to be open.",
    );
  }

  const allowlist = list(env, "TERA_MARKERS_PROVENANCE_ALLOWLIST");
  return {
    source,
    file,
    provenanceAllowlist: allowlist.length > 0 ? allowlist : DEFAULT_PROVENANCE_ALLOWLIST,
    ttlSeconds: num(env, "TERA_MARKERS_TTL", 300, degraded),
    access,
  };
}

/**
 * `password` is a mode an operator asks for, not a mode the rest of the server
 * ever sees. It resolves to `jwt` below; see `loadPasswordLogin` for why.
 */
type ConfiguredAuthMode = AuthMode | "password";

const AUTH_MODES: ConfiguredAuthMode[] = ["none", "sso", "jwt", "password"];

function loadAuth(env: Env, degraded: string[]): AuthConfig {
  const asked = str(env, "TERA_AUTH_MODE", "none");
  let mode: ConfiguredAuthMode | null = oneOf(asked, AUTH_MODES);
  if (mode === null) {
    degraded.push(
      `TERA_AUTH_MODE="${asked}" is not one of ${AUTH_MODES.join(", ")}; ` +
        `running open (mode=none).`,
    );
    mode = "none";
  }

  const entryUrl = str(env, "TERA_AUTH_ENTRY_URL", "");
  const revalidateUrl = str(env, "TERA_AUTH_REVALIDATE_URL", "");
  const jwtSecret = str(env, "TERA_AUTH_JWT_SECRET", "");
  const jwksUrl = str(env, "TERA_AUTH_JWKS_URL", "");
  let jwtVerify: "hs256" | "jwks" =
    str(env, "TERA_AUTH_JWT_VERIFY", "hs256") === "jwks" ? "jwks" : "hs256";

  // A demotion here has teeth: it takes private offices with it, which is the
  // safe direction. Unverifiable credentials must never mean "let them in".
  if (mode === "sso" && revalidateUrl === "") {
    degraded.push(
      "TERA_AUTH_MODE=sso needs TERA_AUTH_REVALIDATE_URL — this box holds no " +
        "credentials and cannot check a session without somewhere to ask. " +
        "Demoted to mode=none; private offices will answer 404 to everyone.",
    );
    mode = "none";
  }
  if (mode === "jwt" && jwtVerify === "hs256" && jwtSecret === "") {
    degraded.push(
      "TERA_AUTH_MODE=jwt needs TERA_AUTH_JWT_SECRET (or TERA_AUTH_JWT_VERIFY=jwks " +
        "with TERA_AUTH_JWKS_URL). Demoted to mode=none; private offices will " +
        "answer 404 to everyone.",
    );
    mode = "none";
  }
  if (mode === "jwt" && jwtVerify === "jwks" && jwksUrl === "") {
    degraded.push(
      "TERA_AUTH_JWT_VERIFY=jwks needs TERA_AUTH_JWKS_URL. Demoted to mode=none.",
    );
    mode = "none";
  }

  // `password` collapses into `jwt` here, and that is the whole design rather
  // than an implementation detail. `routes/session.ts` signs exactly the token
  // `jwt` mode already verifies, so a browser that signed in on this box and a
  // browser carrying a token from the fleet's issuer arrive at `offices.ts`
  // through the same `resolve()` — one authorisation path that can be reasoned
  // about, rather than two that have to be kept in agreement. What `password`
  // adds is an issuer, not a verifier.
  let passwordLogin: PasswordLogin | null = null;
  if (mode === "password") {
    passwordLogin = loadPasswordLogin(env, jwtSecret, degraded);
    if (passwordLogin === null) {
      mode = "none";
    } else {
      mode = "jwt";
      if (jwtVerify === "jwks") {
        // Verifying against someone else's public keys while signing with a
        // local secret means this box would reject its own sessions.
        degraded.push(
          "TERA_AUTH_MODE=password issues its own HS256 tokens, so " +
            "TERA_AUTH_JWT_VERIFY=jwks was ignored; this box verifies the " +
            "sessions it signs.",
        );
        jwtVerify = "hs256";
      }
    }
  }

  return {
    mode,
    entryUrl,
    revalidateUrl,
    cookieName: str(env, "TERA_AUTH_COOKIE", "tera_session"),
    jwtSecret,
    jwtVerify,
    jwksUrl,
    issuer: str(env, "TERA_AUTH_JWT_ISSUER", ""),
    audience: str(env, "TERA_AUTH_JWT_AUDIENCE", ""),
    passwordLogin,
    // Last, because it wants the mode *after* every demotion above has run: a
    // list of admins on a box that just demoted to mode=none is worth a
    // sentence, and the sentence is only true once `mode` has settled.
    admins: loadAdmins(env, mode, degraded),
  };
}

/** `TERA_ADMIN_SUBJECTS=*`; see `loadAdmins` for why this is a dev switch. */
const ADMIN_WILDCARD = "*";

/**
 * Who gets the god tier — the time controls, the debug panel, and whatever else
 * ends up behind `Viewer.admin`.
 *
 * `TERA_ADMIN_SUBJECTS` is a comma-separated list of **subject ids**: the `sub`
 * claim this box verifies, not an email and not a display name. Under
 * `TERA_AUTH_MODE=password` the subject is `TERA_AUTH_PASSWORD_USER`, so the
 * single self-hosted account becomes an admin by naming it here — and only by
 * naming it here. Password mode is deliberately not auto-admin: one grant path,
 * written down in the environment, is the property worth having.
 *
 * **Unset means no admins, and that is the safe default.** A box handed nothing
 * serves the public map to everyone and the god tier to nobody. Matching is
 * exact after trimming, and case-sensitive: subject ids are opaque strings an
 * issuer minted, `user_01H…` and `USER_01H…` can be two different accounts, and
 * folding case here would silently widen a grant to an id nobody configured.
 *
 * The one wildcard is `*`, which makes **every authenticated subject an admin**.
 * It is here so a self-hoster poking at this on a laptop does not have to go
 * find their own subject id first. It is a development switch, it must never
 * reach a deployment env file, and it pushes a line into `degraded` so that
 * `/api/v1/health` says out loud that the box is handing out godmode.
 *
 * That loudness is the whole point, and it is paid for. lumbridge-v4 shipped
 * `ADMIN_EMAILS` with `admin@lumbridgecorp.com` as a committed default while
 * nobody had ever registered that address — a standing offer of admin to
 * whoever signed up for it first, invisible because nothing anywhere announced
 * it. A grant nobody can see is a grant nobody revokes. Hence: no committed
 * defaults, no implicit grants, and the one blanket switch reports itself.
 *
 * Note what is *not* here: no count and no list ever reaches the wire.
 * `routes/health.ts` serves `degraded`, and these sentences name the variable,
 * never its contents.
 */
function loadAdmins(env: Env, mode: AuthMode, degraded: string[]): AdminGrant {
  const configured = list(env, "TERA_ADMIN_SUBJECTS");
  const everyone = configured.includes(ADMIN_WILDCARD);
  const subjects = configured.filter((subject) => subject !== ADMIN_WILDCARD);

  if (everyone) {
    degraded.push(
      "TERA_ADMIN_SUBJECTS=* grants the admin tier to every authenticated " +
        "subject on this box, time controls and debug included. That is a " +
        "development switch; anywhere reachable from outside, list the subject " +
        "ids instead.",
    );
  }

  // Not a demotion of this setting — nothing falls back — but a line worth
  // printing, because admin is a property of an *authenticated* viewer and
  // mode=none never produces one. An operator who listed admins here and reads
  // `degraded` learns in one sentence why nobody is getting them.
  if ((everyone || subjects.length > 0) && mode === "none") {
    degraded.push(
      "TERA_ADMIN_SUBJECTS is set, but authentication resolved to mode=none: " +
        "nobody can sign in, so nobody is an admin. Set TERA_AUTH_MODE, and " +
        "check the lines above for an auth demotion that got you here.",
    );
  }

  return { everyone, subjects };
}

/**
 * The credential for `TERA_AUTH_MODE=password`, or `null` with a loud line if
 * the environment did not supply a usable one.
 *
 * Every failure here is a demotion to `mode=none` rather than a refusal to boot,
 * which is the rule the whole file follows — but note which direction the
 * demotion runs: mode=none makes every private office answer 404 to everybody,
 * including the operator. Nobody is let in by a misconfiguration.
 *
 * `TERA_AUTH_PASSWORD_HASH` is a hash and there is deliberately no plaintext
 * equivalent to set. `auth/password.ts` carries the command that produces one.
 */
function loadPasswordLogin(
  env: Env,
  jwtSecret: string,
  degraded: string[],
): PasswordLogin | null {
  const username = str(env, "TERA_AUTH_PASSWORD_USER", "");
  const encoded = str(env, "TERA_AUTH_PASSWORD_HASH", "");

  if (username === "" || encoded === "") {
    degraded.push(
      "TERA_AUTH_MODE=password needs TERA_AUTH_PASSWORD_USER and " +
        "TERA_AUTH_PASSWORD_HASH (a scrypt hash — see server/src/auth/password.ts " +
        "for the one-liner that prints one). Demoted to mode=none; private " +
        "offices will answer 404 to everyone.",
    );
    return null;
  }

  if (jwtSecret === "") {
    degraded.push(
      "TERA_AUTH_MODE=password needs TERA_AUTH_JWT_SECRET to sign the session " +
        "cookie it issues — try `openssl rand -base64 48`. Demoted to mode=none.",
    );
    return null;
  }

  const hash = parseScryptHash(encoded);
  if (hash === null) {
    degraded.push(
      "TERA_AUTH_PASSWORD_HASH is not a hash this server can read; it should " +
        "look like `scrypt$16384$8$1$<salt>$<key>`. Demoted to mode=none.",
    );
    return null;
  }

  return {
    username,
    hash,
    sessionTtlSeconds: num(env, "TERA_AUTH_SESSION_TTL", 43_200, degraded),
    rateAttempts: num(env, "TERA_AUTH_LOGIN_ATTEMPTS", 8, degraded),
    rateWindowSeconds: num(env, "TERA_AUTH_LOGIN_WINDOW", 300, degraded),
  };
}

// ---- Readers --------------------------------------------------------------

function str(env: Env, key: string, fallback: string): string {
  const raw = env[key];
  if (raw === undefined) return fallback;
  const trimmed = raw.trim();
  return trimmed === "" ? fallback : trimmed;
}

function num(env: Env, key: string, fallback: number, degraded: string[]): number {
  const raw = env[key];
  if (raw === undefined || raw.trim() === "") return fallback;
  const parsed = Number(raw);
  if (!Number.isFinite(parsed)) {
    degraded.push(`${key}="${raw}" is not a number; using ${fallback}.`);
    return fallback;
  }
  return parsed;
}

/** Comma-separated, whitespace-tolerant, empties dropped. */
function list(env: Env, key: string): string[] {
  return str(env, key, "")
    .split(",")
    .map((s) => s.trim())
    .filter((s) => s !== "");
}

function oneOf<T extends string>(value: string, allowed: T[]): T | null {
  return allowed.includes(value as T) ? (value as T) : null;
}

/**
 * The version served by `/health`, read from `package.json` so it cannot drift
 * from the thing that is actually deployed. A missing or unreadable file is not
 * worth dying over — nothing depends on this but a human reading a health check.
 */
function readVersion(): string {
  try {
    const url = new URL("../package.json", import.meta.url);
    const parsed: unknown = JSON.parse(readFileSync(url, "utf8"));
    if (parsed !== null && typeof parsed === "object" && "version" in parsed) {
      const v = (parsed as { version: unknown }).version;
      if (typeof v === "string") return v;
    }
  } catch {
    // Fall through.
  }
  return "0.0.0";
}
