/**
 * The places this deployment will answer for, and the refusal of everywhere else.
 *
 * `TERA_ORIGIN_LAT/LNG` was one point, and the map has two cities six hundred
 * kilometres apart with genuinely different skies — the marine-layer comment in
 * `engine/atmosphere.ts` makes the point exactly: Los Angeles gets its own
 * weather, not San Francisco's fog. So weather and flights have to answer for a
 * *requested* place rather than for the box's one origin.
 *
 * ### Why this is an allowlist and not a lookup
 *
 * The obvious implementation — take `?lat=&lng=` and hand it to NWS — turns an
 * unauthenticated endpoint into a free geocoding proxy for the whole planet.
 * Two things go wrong with that, and neither is hypothetical. It is an
 * amplification vector: one cheap request here becomes one expensive request to
 * somebody else's public-good API, from an address they will blame. And it is
 * how a deployment's User-Agent gets blocked, because NWS's fair-use policy is
 * written against exactly this pattern and the contact string in
 * `TERA_WEATHER_CONTACT` is the operator's own name on the request.
 *
 * The rule here is therefore: **a caller's coordinate is never forwarded
 * upstream. It only selects among the points the operator configured.** A
 * request for Berkeley resolves to the San Francisco region and fetches San
 * Francisco's centre; a request for Fresno is refused with a 400 naming what is
 * served. The upstream key space is the region list, so it is bounded by the
 * environment file and cannot be grown by anybody sending requests — which is
 * the property that makes per-region caching, the NWS station cache and the
 * adsb.lol poll budget in `flights/adsb.ts` all bounded too.
 *
 * A coarse grid was the other candidate and was rejected: snapping to 0.5° still
 * leaves a caller able to name a hundred thousand distinct cells, which bounds
 * nothing that matters. Refusing is the honest answer, and a self-hoster who
 * wants their own city writes one line of `TERA_REGIONS`.
 */

/** Kilometres per degree of latitude. Good to a tenth of a percent anywhere. */
const KM_PER_DEGREE = 111.195;

/**
 * How far from a region's centre a request may land and still resolve there,
 * when the operator did not say.
 *
 * 120 km covers both shipped boards with room to spare — the far corner of the
 * Bay Area pack is 89 km from its centre and SoCal's is 97 km — while leaving
 * the two regions comfortably disjoint, since their centres are about 440 km
 * apart. It is deliberately not tight: the point of the radius is to refuse
 * somewhere this deployment has nothing to say about, not to police the edge of
 * the rendered board.
 */
export const DEFAULT_RADIUS_KM = 120;

export interface Region {
  /** Url-safe, and the same id the browser's city pack uses: `sf`, `socal`. */
  id: string;
  lat: number;
  lng: number;
  /** Kilometres. See `DEFAULT_RADIUS_KM`. */
  radiusKm: number;
}

/**
 * At least one region, always, with the first one being the default.
 *
 * A tuple rather than an array because "the default region" is read on every
 * request that omits a query, and a `Region | undefined` there would be a
 * falsehood the type system made everybody handle.
 */
export type RegionSet = [Region, ...Region[]];

/**
 * The two cities this repo ships, with the centres their packs declare —
 * `SAN_FRANCISCO_CITY.center` and `SOCAL_CITY.center` in `src/cities/`.
 *
 * They are restated rather than imported for the same reason `wire.ts` restates
 * `SimRoute`: a city pack is three thousand lines of coastline for a renderer
 * that owns three.js, and the API has one runtime dependency and intends to keep
 * it. Two numbers each, and the day a pack moves its centre the weather resolves
 * to a point a few kilometres off, which is a rounding error against a radius of
 * 120 km.
 */
const SHIPPED: Region[] = [
  { id: "sf", lat: 37.7749, lng: -122.4194, radiusKm: DEFAULT_RADIUS_KM },
  { id: "socal", lat: 33.82, lng: -118.05, radiusKm: DEFAULT_RADIUS_KM },
];

/**
 * `id:lat,lng` with an optional `:radiusKm`, which is the whole grammar.
 *
 * The decimal places are capped in the pattern rather than checked afterwards
 * because it is the same cap the query parser applies, and the two agreeing is
 * the point: a coordinate nobody could ask for is a coordinate nobody should be
 * able to configure either.
 */
const ENTRY = /^([a-z0-9][a-z0-9-]{0,31}):(-?\d{1,3}(?:\.\d{1,6})?),(-?\d{1,3}(?:\.\d{1,6})?)(?::(\d{1,4}(?:\.\d{1,3})?))?$/;

export interface LoadRegionsOptions {
  /** `TERA_REGIONS`, raw. Empty means "work it out from the defaults". */
  spec: string;
  /** `TERA_ORIGIN_LAT/LNG`, already read and defaulted. */
  origin: { lat: number; lng: number };
  /** Whether the operator actually wrote an origin, as opposed to inheriting SF. */
  originConfigured: boolean;
  degraded: string[];
}

/**
 * The region set for this box.
 *
 * Three cases, in order, and the ordering is what keeps every existing
 * deployment answering exactly as it did:
 *
 * 1. `TERA_REGIONS` is set — that list is the answer, in the operator's order.
 * 2. Otherwise the two shipped cities, because that is what the bundled map
 *    draws and serving only one of them is wrong for half the board.
 * 3. On top of case 2, an operator who pointed `TERA_ORIGIN_LAT/LNG` somewhere
 *    else gets that point as a region of its own, first in the list and
 *    therefore the default. A bare `GET /api/v1/weather` on their box answers
 *    for their origin, exactly as it did before this file existed. If their
 *    origin already falls inside a shipped region, that region moves to the
 *    front instead of being duplicated.
 *
 * Malformed entries are dropped with a line in `degraded` rather than being
 * fatal, and a spec in which *nothing* parses falls all the way back to case 2.
 * CONTRACT.md §5.1: a typo is a demotion, never a refusal to boot.
 */
export function loadRegions(opts: LoadRegionsOptions): RegionSet {
  const configured = parseSpec(opts.spec, opts.degraded);
  if (configured.length > 0) return asSet(configured, configured[0] as Region);

  const shipped = SHIPPED.map((region) => ({ ...region }));
  const containing = shipped.findIndex((region) => contains(region, opts.origin));

  if (containing > 0) {
    const moved = shipped[containing] as Region;
    shipped.splice(containing, 1);
    shipped.unshift(moved);
  } else if (containing === -1 && opts.originConfigured) {
    shipped.unshift({
      id: "origin",
      lat: opts.origin.lat,
      lng: opts.origin.lng,
      radiusKm: DEFAULT_RADIUS_KM,
    });
  }

  return asSet(shipped, shipped[0] as Region);
}

/**
 * `Region[]` to `RegionSet`, with the caller supplying the head it has already
 * proved is there. The alternative is a cast on the whole array, which would
 * also silence the empty case this type exists to rule out.
 */
function asSet(regions: Region[], head: Region): RegionSet {
  return [head, ...regions.slice(1)];
}

function parseSpec(spec: string, degraded: string[]): Region[] {
  const trimmed = spec.trim();
  if (trimmed === "") return [];

  const regions: Region[] = [];
  for (const raw of trimmed.split(/[;\n]/)) {
    const entry = raw.trim();
    if (entry === "") continue;

    const match = ENTRY.exec(entry);
    if (match === null) {
      degraded.push(
        `TERA_REGIONS entry "${entry}" is not \`id:lat,lng\` with an optional ` +
          `\`:radiusKm\` (try \`sf:37.7749,-122.4194\`); ignoring it.`,
      );
      continue;
    }

    const id = match[1] as string;
    const lat = Number(match[2]);
    const lng = Number(match[3]);
    const radiusKm = match[4] === undefined ? DEFAULT_RADIUS_KM : Number(match[4]);

    if (Math.abs(lat) > 90 || Math.abs(lng) > 180 || radiusKm <= 0) {
      degraded.push(`TERA_REGIONS entry "${entry}" is not a place on Earth; ignoring it.`);
      continue;
    }
    if (regions.some((region) => region.id === id)) {
      degraded.push(`TERA_REGIONS names "${id}" twice; keeping the first one.`);
      continue;
    }

    regions.push({ id, lat, lng, radiusKm });
  }

  if (regions.length === 0 && trimmed !== "") {
    degraded.push(
      "TERA_REGIONS was set but nothing in it parsed; serving the two cities the " +
        "map ships with instead.",
    );
  }
  return regions;
}

// ---- Answering a request --------------------------------------------------

/**
 * What a route hands in. `unknown` throughout because this is untrusted query
 * input: Fastify hands back a string for `?lat=1`, an **array** for
 * `?lat=1&lat=2`, and `undefined` for absent, and a signature that claimed
 * `string` would be a lie the first time somebody repeated a parameter.
 */
export interface RegionQuery {
  city?: unknown;
  lat?: unknown;
  lng?: unknown;
}

export type RegionResolution =
  | { ok: true; region: Region }
  /** Already a sentence. The route sends it as `ErrorBody.message`. */
  | { ok: false; message: string };

/** Six decimal places is about 0.1 m. Anything finer is a bug or a probe. */
const DEGREES = /^-?\d{1,3}(?:\.\d{1,6})?$/;

const CITY_ID = /^[a-z0-9][a-z0-9-]{0,31}$/;

/**
 * Turn a query into one of the configured regions, or into a refusal.
 *
 * Every branch that is not a resolved region is a **400**, not a degraded body.
 * That is the one place this file departs from the "degrade, never fail"
 * convention, and the distinction is who made the mistake: an upstream that is
 * down is not the caller's fault and must not become their problem, whereas
 * `?lat=banana` is a client bug that a 200 full of clear sky would hide until
 * somebody wondered why the fog never rolls in.
 */
export function resolveRegion(regions: RegionSet, query: RegionQuery): RegionResolution {
  const city = query.city;
  const hasCity = city !== undefined && city !== "";
  const hasLat = query.lat !== undefined && query.lat !== "";
  const hasLng = query.lng !== undefined && query.lng !== "";

  if (hasCity && (hasLat || hasLng)) {
    return { ok: false, message: "Ask with ?city= or with ?lat=&lng=, not both." };
  }

  if (hasCity) {
    if (typeof city !== "string" || !CITY_ID.test(city)) {
      return { ok: false, message: `city must be one of: ${served(regions)}.` };
    }
    const region = regions.find((candidate) => candidate.id === city);
    if (region === undefined) {
      return { ok: false, message: `This deployment serves: ${served(regions)}.` };
    }
    return { ok: true, region };
  }

  if (hasLat !== hasLng) {
    return { ok: false, message: "lat and lng have to be given together." };
  }

  if (!hasLat) return { ok: true, region: regions[0] };

  const lat = degrees(query.lat, 90);
  const lng = degrees(query.lng, 180);
  if (lat === null || lng === null) {
    return {
      ok: false,
      message:
        "lat and lng must be plain decimal degrees within ±90 and ±180, " +
        "with at most six decimal places.",
    };
  }

  const region = nearest(regions, lat, lng);
  if (region === null) {
    return {
      ok: false,
      message:
        `Nothing this deployment serves is near ${lat},${lng}. It serves: ` +
        `${served(regions)}. Ask for one of those by id, or add yours to ` +
        `TERA_REGIONS on the server.`,
    };
  }
  return { ok: true, region };
}

function served(regions: RegionSet): string {
  return regions.map((region) => region.id).join(", ");
}

function degrees(raw: unknown, limit: number): number | null {
  if (typeof raw !== "string") return null;
  const trimmed = raw.trim();
  // The pattern is doing the work `Number()` would do badly: it rejects `NaN`,
  // `Infinity`, `1e400`, `0x2f`, `37.7749deg` and the empty string, all of which
  // `Number()` either accepts or turns into a value that then has to be
  // re-checked. One regex, one meaning.
  if (!DEGREES.test(trimmed)) return null;
  const value = Number(trimmed);
  return Number.isFinite(value) && Math.abs(value) <= limit ? value : null;
}

/** The closest region that claims the point, or `null` if none of them does. */
function nearest(regions: RegionSet, lat: number, lng: number): Region | null {
  let best: Region | null = null;
  let bestKm = Infinity;
  for (const region of regions) {
    const km = distanceKm(region, lat, lng);
    if (km <= region.radiusKm && km < bestKm) {
      best = region;
      bestKm = km;
    }
  }
  return best;
}

/**
 * Equirectangular rather than haversine, because at these distances the error is
 * under half a percent and the comparison it feeds is against a radius chosen to
 * the nearest ten kilometres. Trigonometry accurate to the metre would be
 * decorating a threshold that is deliberately fuzzy.
 */
function distanceKm(region: Region, lat: number, lng: number): number {
  const dLat = lat - region.lat;
  const meanLat = (((lat + region.lat) / 2) * Math.PI) / 180;
  const dLng = (lng - region.lng) * Math.cos(meanLat);
  return Math.hypot(dLat, dLng) * KM_PER_DEGREE;
}

function contains(region: Region, point: { lat: number; lng: number }): boolean {
  return distanceKm(region, point.lat, point.lng) <= region.radiusKm;
}
