/**
 * What this visitor may do — resolved once at boot, read everywhere after.
 *
 * There are three kinds of person in front of this map. Someone who has not
 * signed in (**anon**) gets the public city and a public office: the shell, the
 * furniture, the named viewpoints, nobody home. Someone signed in (**member**)
 * gets the live feeds and the people in the room. An administrator (**god**)
 * gets those plus the instruments — the time scrubber and the debug readouts —
 * which are development tools that happen to be shipped.
 *
 * This module exists so `main.ts` never has to think about auth again. Before
 * it, the app carried two loose booleans (`canEnterOffice`, `signInUrl`) and the
 * rule that produced them was inline in the boot path; every new capability
 * meant another boolean and another chance to get the rule subtly wrong. One
 * function, one value, one place to read the reasoning.
 *
 * ## These capabilities are UI, not security
 *
 * Say it plainly, because the shape of this file invites the opposite reading:
 * **nothing here is a boundary.** It is a set of decisions about what to draw.
 * Anyone can open the console and set `can.liveData` to true.
 *
 * The two halves of that are genuinely different and it matters which is which:
 *
 * - `timeControl` and `debug` are *purely* client-side. Scrubbing the clock
 *   changes a `Date` that is fed to `observe()` in this browser and moves a sun
 *   this browser is drawing. There is no server to enforce anything against, so
 *   hiding the control here **is** the whole enforcement, and that is fine and
 *   honest: the worst a determined visitor achieves is a sunset at 2 p.m. on
 *   their own screen. Nothing leaks.
 *
 * - `liveData` and `officeDepth` are **not** enforced here even slightly. The
 *   API returns nothing — no markers, and a 404 rather than a 403 for a private
 *   office pack (CONTRACT.md §6) — to a caller it does not recognise. That
 *   refusal is the security. What this module does is stop the app from asking
 *   for something it will not get and from rendering an empty room as though it
 *   were an empty office. It is a convenience laid on top of a server-side rule,
 *   never a substitute for one. If you are ever tempted to move an access check
 *   *out* of the API and into here because it is easier, that is the moment this
 *   file has been misread.
 *
 * ## Why an unreachable API means `member` and not `god`
 *
 * A clean clone with no server is the repo's flagship case (CONTRACT.md §0) and
 * it has to be a good experience, so it gets `member`: live-shaped UI over the
 * bundled sample data, the whole office, no sign-in prompt for a door that does
 * not exist. It deliberately does **not** get `god`. The self-host promise is
 * "clone it and it works", not "clone it and you are an administrator of a
 * deployment you did not configure" — and the difference stops mattering only
 * until someone puts a static build in front of an API they do not control, at
 * which point a client that awards itself godmode whenever it cannot reach the
 * server has turned a network failure into a privilege escalation.
 *
 * Godmode comes from an explicit server-side grant. Always. Absence of an answer
 * is not an answer.
 */

import { authFetch } from "./session.ts";

/** Where the API lives, per CONTRACT.md §5. Same-origin, behind the site's own proxy. */
const BASE = "/api/v1";

/**
 * How long either probe may take before it counts as no answer.
 *
 * Boot awaits this, so an unbounded wait is not "eventually correct", it is a
 * map that never appears. A black-holed port — a firewall dropping packets
 * rather than refusing the connection — hangs `fetch` indefinitely, and that is
 * exactly the deployment mistake most likely to be made by the person this
 * timeout protects.
 */
const TIMEOUT_MS = 4000;

export type Tier = "anon" | "member" | "god";

export interface Capabilities {
  /** Step into the office at all. True for everyone; `officeDepth` is what differs. */
  enterOffice: boolean;
  /** "public" = shell, furniture and named views, nobody home. "full" = presence and occupants. */
  officeDepth: "public" | "full";
  /** Scrub the clock and the date. God only — see the note about why this is honest. */
  timeControl: boolean;
  /**
   * The real sky — observed weather and observed aircraft — rather than the
   * synthetic one.
   *
   * **Public, including to a visitor who has not signed in.** It was briefly
   * `tier !== "anon"`, on the reasoning that live feeds are what an account
   * buys you. That reasoning does not survive contact with what the data
   * actually is: the cloud cover over San Francisco is a public observation
   * from a government sensor, and the aircraft are broadcasting their positions
   * unencrypted to anyone with a forty-dollar receiver. Neither is a thing an
   * account can grant you access to, because neither is withheld from anyone.
   *
   * What it cost was the only moment that makes this project land — fog rolling
   * off the Pacific onto a city you recognise, at the real time of day, on a
   * first visit. Gating that behind a sign-in traded the whole first impression
   * for a rule with nothing behind it.
   */
  liveEnvironment: boolean;
  /**
   * Markers: whatever this deployment has decided its map is *about*.
   *
   * Separate from `liveEnvironment` because it is the one feed that can carry
   * something private. The sky is the same for everybody; a marker set is a
   * company's pipeline, or a person's job search, and whether it is public is a
   * property of the deployment rather than of this file. The **server** decides
   * — `TERA_MARKERS_ACCESS`, which defaults to `members` so that a self-hoster
   * who wires real data up gets the safe answer without having chosen it — and
   * this flag only reports what the server already said. Setting it true here
   * against a server set to `members` earns a 401 and nothing else.
   */
  liveMarkers: boolean;
  /**
   * Read this deployment's **real** device route, rather than the simulator
   * bundled in the tab.
   *
   * `false` for an anonymous visitor, and that is not a restriction on what
   * they see — it is what makes the studio work for them at all. The route is
   * `members`-only and answers 401 to an anonymous GET, which is correct: the
   * mics and the camera in it are hardware in somebody's room. What was wrong
   * was the *client*, which asked anyway. `serverHasDevices` was the only gate
   * on the API strategy, so on the production box — where `/health` reports
   * `devices: "sim"` — an anonymous visitor took the API path, was refused,
   * rendered every instrument permanently at rest, and backed off exponentially
   * against a request it could never pass. The panel beside it promised a
   * locally simulated studio.
   *
   * So this is the second half of a decision that already had a first half.
   * `Feeds.devices` asks "has this deployment got a device source at all"; this
   * asks "may *this viewer* read it". Both have to be true before a request is
   * worth making, and when either is false the answer is the same and it is a
   * good one: run the fixed-step simulator in this tab, which is what
   * `adapter.ts`, `routes/devices.ts` and `devicePanel.ts` have all documented
   * as the anonymous experience since they were written.
   */
  liveDevices: boolean;
  /** Debug overlays: frame time, draw calls, chapter poses, the solar readout. */
  debug: boolean;
}

/**
 * Which of the feeds this deployment has actually wired.
 *
 * A capability says what a *visitor* may have; this says what the *server* has,
 * and the app needs both before it opens a socket. `can.liveData` is true for
 * every member of every deployment, including the overwhelming majority that
 * have `weather: "none"` — so gating on the capability alone starts a
 * ten-minute weather poll against a box that will answer 404 to all of it,
 * forever, on every tab that is open.
 *
 * Each field is `true` when `/health` named a source other than `"none"`, which
 * is deliberately coarser than the string. The app does not care whether the
 * weather comes from NWS or met.no; it cares whether asking is pointless.
 * `flights: "sim"` counts as wired, because the server's synchronised plan is
 * worth fetching even though it is not observed — `TrafficSource.live()` is the
 * thing that knows the difference, and it says `false` for it.
 */
export interface Feeds {
  weather: boolean;
  flights: boolean;
  /**
   * Device state for the studios.
   *
   * `false` on a zero-config box and on every clone, which is the default and
   * is not a gap: `src/devices/adapter.ts` reads this and runs the bundled
   * simulator in the tab instead, so the studio is alive either way. What the
   * flag actually prevents is a poll against a box that will answer 404 to all
   * of it, forever, on every open tab.
   */
  devices: boolean;
  /**
   * A satellite catalogue. Off on almost every box, including this repo's own
   * default — see `loadSatellites` in the server's `config.ts` for why a clone
   * does not start pulling CelesTrak the moment it boots.
   */
  satellites: boolean;
  markers: boolean;
  /**
   * A wildfire projection, from `TERA_FIRES_SOURCE`.
   *
   * Off on a clone and off on this repo's own default, which is the important
   * half: a board with no fire feed behind it must draw nothing and say nothing
   * rather than poll `/fires` every ten minutes forever to be told the same
   * empty body. There is deliberately no `can.` twin — a wildfire is a public
   * agency record and an account cannot grant you one — and there is
   * deliberately no synthetic fallback anywhere beneath it. An invented
   * aeroplane is a plausible aeroplane; an invented fire is a claim that a named
   * place is burning, made to somebody who may live there.
   */
  fires: boolean;
  /**
   * The two sky projections, from `TERA_RADAR_SOURCE` and `TERA_BIRDS_SOURCE`.
   *
   * **Optional, unlike every field above**, and that is the one thing to
   * understand about them: `sources.radar` and `sources.birds` are newer than
   * some servers this client will meet, and `undefined` has to mean the same as
   * `false` — do not ask. A required boolean here would have read a body from an
   * older box as a definite "no", which is the same answer by luck rather than
   * by construction, and would have made every existing `Feeds` literal in the
   * tests a compile error for no gain.
   *
   * Both are off on this repo's default and on every clone, and both stay
   * honest when they are: `promoteRadar(null)` and `promoteBirds(null)` write
   * "no feed is configured — that is a fact about this box, not about the sky",
   * which is the sentence a blank panel could not say.
   */
  radar?: boolean;
  birds?: boolean;
}

export interface Access {
  tier: Tier;
  subject: string | null;
  /** Where to send someone who is not signed in. `null` means this deployment has no door. */
  signInUrl: string | null;
  can: Capabilities;
  /**
   * What `/health` said is wired, or `null` when nothing answered — which is
   * the zero-config case, and means every feed is the bundled sample.
   *
   * It rides along here rather than being fetched again by whoever wants it
   * because this module has already paid for the round trip: `/health` is the
   * first thing boot asks for, and a second identical GET a moment later to
   * read a different field of the same body is a request nobody needs to make.
   */
  feeds: Feeds | null;
  /**
   * Every demotion this deployment made, in the server's own words.
   *
   * `/api/v1/health` has carried this since the config learned to demote rather
   * than to die (CONTRACT.md §5.1), and until now **nothing in `src/` read
   * it**: it was built, served, logged and then dropped on the floor by the one
   * consumer that could put it in front of a person. So an operator whose
   * `TERA_WEATHER_CONTACT` was missing saw a permanently clear sky, with the
   * sentence explaining exactly that sitting in a JSON body one fetch away.
   *
   * It rides along here because this module has already paid for the round
   * trip — `/health` is the first thing boot asks for — and a second identical
   * GET to read a different field of the same body is a request nobody needs to
   * make. Empty on a fully-configured box, and empty when nothing answered:
   * a deployment that does not exist has not demoted anything.
   *
   * The interface shows it to admin-tier viewers. It names environment
   * variables and internal source ids, which is diagnostic detail rather than a
   * secret — but it is also noise to everybody who cannot act on it.
   *
   * **Optional in the type and always present in practice**: every `Access`
   * `resolveAccess` returns carries one, empty when there is nothing to report.
   * The `?` is there only so that a hand-written pre-boot literal — the closed
   * default `main.ts` holds before `resolveAccess()` settles — does not have to
   * restate an empty array to keep compiling. Read it as `access.degraded ?? []`
   * and the two cases are the same case.
   */
  degraded?: string[];
}

/**
 * The table. One place, so "what does a member actually get?" is answered by
 * reading five lines rather than by grepping for `tier ===` across the app.
 *
 * `enterOffice` is true for all three on purpose. An earlier cut of this made
 * the office a members-only destination and the anonymous view of the site was
 * a map with a greyed-out button on it — the single most interesting thing this
 * project does, visible only as something you cannot have. The public office is
 * the same room with the occupancy layer off, and it costs nothing to show,
 * because the floorplan is a data file in this bundle and not a secret.
 */
export function capabilitiesFor(tier: Tier): Capabilities {
  return {
    enterOffice: true,
    officeDepth: tier === "anon" ? "public" : "full",
    timeControl: tier === "god",
    liveEnvironment: true,
    // Asked for by everyone; granted by the server or not. See the field's own
    // note — the client requesting a marker set it may not have is a 401, which
    // is the correct place for that decision to be enforced and the only place
    // it can be enforced at all.
    liveMarkers: true,
    // The one feed where an anonymous "no" is better than an anonymous "ask".
    // See the field. The refusal is real, it is correct, and the simulator on
    // the other side of it is a working studio rather than a consolation.
    liveDevices: tier !== "anon",
    debug: tier === "god",
  };
}

/**
 * Ask the deployment what it is, then ask it who you are.
 *
 * **The rule is the auth mode, not the presence of a login form.** This is a
 * bug that has already been fixed once in this repo and the way it was written
 * is worth keeping in front of anyone editing this function. The old line was:
 *
 *     canEnterOffice = s.authenticated || !s.passwordLogin;
 *
 * which reads as "if this box cannot sign anyone in, it must be open". True for
 * `auth: none`. Dangerously false for `sso` and `jwt`, where `POST
 * /api/v1/session` is 404 precisely *because* credentials are issued somewhere
 * else — so on an SSO deployment that line handed every anonymous visitor the
 * private view while the config still said the deployment was private.
 *
 * So the mode comes from `/api/v1/health`, which already reports it, and only
 * `none` means open. Everything else is a private deployment and has to be told
 * affirmatively who you are.
 *
 * The two failure paths land in deliberately different places, and the asymmetry
 * is the entire point:
 *
 * - **No answer from `/health`** — no API, no deployment-level auth to honour,
 *   the self-host default. `member`, no sign-in link.
 * - **`/health` answered and named a mode, then `/session` failed** — this is a
 *   configured private deployment having a bad minute. Fail *closed*: `anon`.
 *   An API that has already told you it has auth is not an API you may assume is
 *   open.
 */
export async function resolveAccess(fetcher: typeof fetch = authFetch): Promise<Access> {
  const health = await getJson<{
    auth?: { mode?: unknown; entryUrl?: unknown };
    sources?: unknown;
    degraded?: unknown;
  }>(fetcher, "/health");

  // Something is mounted at `/api/v1` and it is unwell. That is not the same
  // fact as "there is no API", and collapsing the two is how a deployment that
  // says it is private comes up open: `tera-api` restarts, Caddy answers 502 for
  // the eight seconds it takes, and every anonymous visitor in that window would
  // otherwise be told they are a member — badge, full-depth office, and a
  // markers request the server is about to refuse anyway. A 5xx is an answer,
  // so it is treated like a failed `/session`: closed, and no sign-in link,
  // because we do not yet know which door this deployment uses.
  if (health.kind === "broken") return access("anon", null, null);


  // Nothing answered. Clone-and-run: full experience, no door, no godmode.
  if (health.kind === "gone") return access("member", null, null);

  const body = health.body;
  const mode = typeof body.auth?.mode === "string" ? body.auth.mode : "none";
  const entryUrl = entryHref(body.auth?.entryUrl);
  const feeds = feedsFrom(body.sources);
  const degraded = degradedFrom(body.degraded);

  // A box with auth switched off is a self-host that chose to stay open. Same
  // deal as no API at all, and for the same reason it is `member` and not `god`.
  if (mode === "none") return access("member", null, null, feeds, degraded);

  const fetched = await getJson<{
    authenticated?: unknown;
    subject?: unknown;
    passwordLogin?: unknown;
    admin?: unknown;
  }>(fetcher, "/session");
  // Every way `/session` can fail is the same way here — this deployment has
  // already said it has auth, so anything short of an affirmative answer is
  // `anon`. The three-way split above exists for `/health`, where the question
  // is whether there is an API at all; by this line that question is settled.
  const session = fetched.kind === "ok" ? fetched.body : null;

  const authenticated = session !== null && session.authenticated === true;
  const passwordLogin = session !== null && session.passwordLogin === true;
  /**
   * Read defensively, because `admin` is newer than some servers this client
   * will meet. A deployment that has not been updated omits the field, `typeof`
   * says `undefined`, and its signed-in users are members — which is the only
   * safe direction for a missing field to fall. Never infer godmode from
   * silence; see the module header.
   */
  const admin = session !== null && typeof session.admin === "boolean" ? session.admin : false;
  const subject = session !== null && typeof session.subject === "string" ? session.subject : null;

  /**
   * The door, in order of how likely it is to actually work.
   *
   * `entryUrl` is the identity provider naming itself, so it wins. Otherwise the
   * local form, but *only* on a server that said it can process one: `login.html`
   * ships in this bundle and so is never a 404, which makes the failure mode
   * worse rather than better — a page that renders, takes an email and a
   * password, and posts them to an endpoint that answers 404 because this
   * deployment issues credentials elsewhere. An inert state that says "sign in
   * required" is more honest than a form that cannot succeed.
   *
   * A `/session` that did not answer counts as no local form for the same
   * reason. `GET /session` is public and always answers on a healthy box; if it
   * did not, the login POST is not going to fare better.
   */
  const signInUrl = entryUrl ?? (passwordLogin ? "/login.html" : null);

  if (!authenticated) return access("anon", null, signInUrl, feeds, degraded);
  return access(admin ? "god" : "member", subject, signInUrl, feeds, degraded);
}

function access(
  tier: Tier,
  subject: string | null,
  signInUrl: string | null,
  feeds: Feeds | null = null,
  degraded: string[] = [],
): Access {
  return { tier, subject, signInUrl, can: capabilitiesFor(tier), feeds, degraded };
}

/**
 * `/health`'s `sources` block, read as one yes/no answer per feed.
 *
 * Defensively, like `admin` above and for the same reason: this field is newer
 * than some servers this client will meet, and a missing one has to fall the
 * safe way. Here "safe" is `false` — no feed, no request — because the bundled
 * sample set is a working map and a poll against a server that never heard of
 * the route is not.
 */
function feedsFrom(raw: unknown): Feeds {
  const sources = (typeof raw === "object" && raw !== null ? raw : {}) as Record<string, unknown>;
  const wired = (key: string) => typeof sources[key] === "string" && sources[key] !== "none";
  return {
    weather: wired("weather"),
    flights: wired("flights"),
    satellites: wired("satellites"),
    markers: wired("markers"),
    devices: wired("devices"),
    fires: wired("fires"),
    radar: wired("radar"),
    birds: wired("birds"),
  };
}

/**
 * `/health`'s `degraded` block, read as sentences.
 *
 * Filtered rather than cast, and for a reason beyond tidiness: these strings go
 * into the interface, so a body carrying numbers, objects or `null` in that
 * array would put `[object Object]` in front of an operator who is already
 * looking at this list because something is wrong. Anything that is not a
 * string is not a sentence and is dropped.
 *
 * Bounded as well. The list is one line per demotion and a fully-configured box
 * has none, so a body with thousands in it is a server this client should not
 * be rendering unboundedly — the same disposition `presence/store.ts` takes to
 * a roster with fifty thousand rows in it.
 */
function degradedFrom(raw: unknown): string[] {
  if (!Array.isArray(raw)) return [];
  return raw.filter((line): line is string => typeof line === "string").slice(0, MAX_DEGRADED);
}

/** More demotions than any real configuration can produce. See `degradedFrom`. */
const MAX_DEGRADED = 32;

/**
 * The three answers a request to `/api/v1` can carry, which is one more than
 * this used to have.
 *
 * `null` for everything was the right shape while the only question was "is
 * there an API". It stopped being the right shape once the answer decided
 * whether an anonymous visitor is a member: a 502 while `tera-api` restarts and
 * a bare static host with no API behind it are the same `null` and the opposite
 * conclusion. So there are three, and no more than three — a 404 and a DNS
 * failure still land together, because no caller branches on the difference.
 */
type Fetched<T> =
  /** 2xx, JSON, parsed. */
  | { kind: "ok"; body: T }
  /** Nothing is mounted here: transport failure, 404, or a static host's HTML shell. */
  | { kind: "gone" }
  /** Something is mounted here and it is failing: 5xx. */
  | { kind: "broken" };

/**
 * One GET, sorted into one of the three.
 *
 * Still deliberately coarse, in the spirit of `adapters/http.ts`: a timeout, a
 * CORS refusal and a DNS failure are all `gone`, and a taxonomy of failures
 * nobody reads is a taxonomy nobody maintains. The one distinction that earns
 * its keep is 5xx, because it is the only status that means "the thing exists".
 *
 * The content-type check is not pedantry. A static host serving this bundle
 * answers an unknown path with `index.html` and a 200, so without it `/health`
 * "succeeds", `res.json()` throws on a `<!doctype html>`, and the throw happens
 * to land in the right place — which is a correct outcome arrived at by
 * accident. Checking makes it a decision.
 */
async function getJson<T>(fetcher: typeof fetch, path: string): Promise<Fetched<T>> {
  try {
    const res = await fetcher(`${BASE}${path}`, {
      signal: AbortSignal.timeout(TIMEOUT_MS),
      headers: { accept: "application/json" },
    });
    if (res.status >= 500) return { kind: "broken" };
    if (!res.ok) return { kind: "gone" };
    if (!(res.headers.get("content-type") ?? "").includes("json")) return { kind: "gone" };
    return { kind: "ok", body: (await res.json()) as T };
  } catch {
    // Includes a body that claimed JSON and was not. A malformed answer from a
    // live server is closer to a broken server than to an absent one, but it is
    // indistinguishable here from a socket that died mid-read, and `gone` is
    // what the zero-config case needs. The status check above is the line that
    // actually catches a sick API.
    return { kind: "gone" };
  }
}

/**
 * `entryUrl` as something safe to put in an `href`.
 *
 * It arrives from `/api/v1/health`, which is to say from whatever this browser
 * is pointed at, and it lands in `a.href` in two places in `main.ts`. A CSP of
 * `script-src 'self' 'unsafe-inline'` — which is what `deploy/STATIC.md`
 * recommends and what the Lumbridge vhost serves — does **not** block a
 * `javascript:` URL from navigating, so an operator who pastes an untrusted
 * `TERA_AUTH_ENTRY_URL`, or an API that has been taken over, gets script
 * execution in the origin where the sso bearer token lives.
 *
 * Rejecting it once here beats validating at each sink, and the accepted set is
 * deliberately narrow: an absolute `http`/`https` URL, or a path on this origin.
 * Anything else — `javascript:`, `data:`, `blob:`, a protocol-relative `//host`
 * that silently leaves the origin — is not a sign-in page, and the honest
 * outcome for a deployment whose door is unusable is no door at all.
 */
function entryHref(raw: unknown): string | null {
  if (typeof raw !== "string" || raw === "") return null;
  /**
   * Before `new URL`, because `new URL` is what hides this one.
   *
   * A protocol-relative `//evil.example/login` inherits the page's scheme, so
   * `url.protocol` comes back `https:` and the check below waves it through —
   * the comment above listed it among the rejected set and it was not among the
   * rejected set. It is not the `javascript:` case and it is not script
   * execution; it is a value an operator pasted, or an API answered with, being
   * turned into a link off this origin that says "Sign in" on it. A host that
   * wants to be honoured can write its scheme.
   */
  if (/^\s*\/\//.test(raw)) return null;
  try {
    const url = new URL(raw, window.location.origin);
    if (url.protocol !== "https:" && url.protocol !== "http:") return null;
    return url.href;
  } catch {
    return null;
  }
}
