/**
 * One California, from three packs — behind a flag, so both answers can be
 * photographed and the owner can choose between them.
 *
 * ## The complaint this exists for
 *
 * The three boards feel like three boards, and the measured reason is *not*
 * that the horizontal scale jumps 20× at the seam. `World.project` is a uniform
 * scale in x/z with no vertical term, and a uniform scale leaves a perspective
 * image identical: a camera carried across the seam on matched true-metre
 * offsets draws a pixel-identical horizontal frame. 1,919 → 94 m per unit costs
 * nothing to look at.
 *
 * What is visible is that **the three packs draw three different Californias**,
 * and every one of those differences is a number in a data file:
 *
 * | | california | sf | socal | spread |
 * | --- | --- | --- | --- | --- |
 * | `verticalExaggeration` | 15 | 3.6 | 3.4 | 4.41× at a matched pose |
 * | freeway width, true metres | 1,919–2,034 | 23–30 | 43–55 | 88× |
 * | longitude squashed by cos | 37.30° | 37.77° | 33.82° | up to ~4 km of drift |
 * | `coastFalloff` | 0.0250° | 0.0018° | 0.0045° | 14× in metres |
 * | palette stops declared | 11 | 0 | 11 (different) | — |
 *
 * So this module is four rules, each replacing one column of that table with a
 * single authored constant plus facts the pack already states. It reads as data
 * because it *is* data: nothing here is an engine change, and the whole of the
 * blast radius in `world.ts` is that the constructor asks for a reconciled pack
 * and reads `lngScale` off it.
 *
 * ## The flag, and why it defaults off
 *
 * San Francisco and Southern California carry every marketing still on
 * lumbridgecorp.com. A reconciliation that leaves a board *worse* is a bad trade
 * however good the argument for it, and the only instrument that can tell is a
 * photograph. So: `?reconcile=1` in the browser, `setReconcile()` from a node
 * script, **off** everywhere else, and each of the four rules can be turned on
 * alone — `?reconcile=exaggeration,roads` — so a rule that loses its photograph
 * is reverted rather than argued for.
 *
 * With the flag off `reconciledCity` returns the pack it was handed, by
 * identity. Not a copy with the same numbers in it: the same object. That is
 * what makes "no pixel moves with the flag off" a fact about the code rather
 * than a claim about a diff.
 *
 * ## Why the reconciliation rewrites the pack rather than the engine
 *
 * `World` posts `this.city` to the terrain worker as a structured clone, and the
 * worker builds a second `World` from it. A flag read from `location.search` is
 * not visible in there — a worker's `location` is the worker script's URL — so a
 * reconciliation that lived in the engine would produce a *reconciled* board on
 * the main thread standing on an *unreconciled* heightfield. Rewriting the pack
 * puts the answer in the thing that crosses the wire. `reconciled: true` on the
 * way out is what makes the second pass in the worker a no-op, and the rule
 * idempotent.
 */

import type { City, Road, ScenePalette } from "../engine/types.ts";

// ---- The flag --------------------------------------------------------------

/** The four rules, each independently switchable. */
export type Rule = "exaggeration" | "roads" | "ground" | "projection";

export const RULES: readonly Rule[] = ["exaggeration", "roads", "ground", "projection"];

/**
 * Set from a node script; wins over the query string.
 *
 * `null` hands the decision back to the URL, which is what a test wants in its
 * teardown. Deliberately not memoised: a test that flips the flag between two
 * assertions must see two answers.
 */
let forced: ReadonlySet<Rule> | null = null;

export function setReconcile(rules: boolean | readonly Rule[] | null): void {
  if (rules === null) forced = null;
  else if (rules === true) forced = new Set(RULES);
  else if (rules === false) forced = new Set();
  else forced = new Set(rules);
}

/**
 * Read the rule set out of a flag value.
 *
 * ## Exact names, and a warning for anything else
 *
 * This used to prefix-match: `RULES.filter((rule) => wanted.some((part) =>
 * rule.startsWith(part)))`. Three things were wrong with that, and all three
 * corrupt measurements rather than breaking anything loudly.
 *
 *   - **A misspelling reads as "off".** `?reconcile=palette` selected nothing,
 *     because no rule name begins with "palette" — and an empty set is exactly
 *     what the flag being absent produces. So a photograph taken to judge a rule
 *     was a photograph of the unreconciled board, captioned as the rule. There
 *     is no way to tell those two apart from the picture.
 *   - **One letter selected a rule.** `?reconcile=p` was `projection`, `r` was
 *     `roads`, `g` was `ground`. Convenient until a second rule shares a letter,
 *     at which point the same URL silently means something else.
 *   - **It is order-dependent in a way nobody would guess.** The result is
 *     built by filtering `RULES`, so the set is in declaration order however the
 *     caller wrote it, which is fine — but a caller who writes two prefixes that
 *     both hit one rule gets one rule and no complaint.
 *
 * Whatever replaces it has to fail *loudly*, because every number anyone takes
 * from this module downstream — every RMSE, every pixel-diff bbox, every "the
 * metro boards are unmoved to the digit" — is only as good as the flag having
 * meant what the person typing it thought it meant.
 */
function parse(value: string | null): ReadonlySet<Rule> {
  if (value === null || value === "" || value === "0" || value === "false") return new Set();
  if (value === "1" || value === "true" || value === "all") return new Set(RULES);
  const wanted = value.split(/[,+\s]+/).filter((part) => part !== "");
  const known = new Set<Rule>();
  for (const part of wanted) {
    const rule = RULES.find((candidate) => candidate === part);
    if (rule === undefined) {
      // Not an exception. A bad rule name must not be able to stop a page from
      // booting — this flag is read at module load on every visit, including
      // from a URL somebody pasted — but it must never pass silently either.
      warn(
        `reconcile: unknown rule "${part}". Known rules: ${RULES.join(", ")}. ` +
          "This rule is being ignored; the flag is NOT off.",
      );
      continue;
    }
    known.add(rule);
  }
  return known;
}

/**
 * Where the warning goes.
 *
 * Split out so a test can assert the warning happened without owning a console,
 * and because this module is imported by the terrain worker, where `console` is
 * present but nobody is reading it.
 */
let warned: (message: string) => void = (message) => {
  (globalThis as { console?: { warn?(m: string): void } }).console?.warn?.(message);
};

/** Redirect the unknown-rule warning. Pass `null` to restore the console. */
export function setReconcileWarn(sink: ((message: string) => void) | null): void {
  warned =
    sink ??
    ((message) => {
      (globalThis as { console?: { warn?(m: string): void } }).console?.warn?.(message);
    });
}

function warn(message: string): void {
  warned(message);
}

/**
 * The rules that are on when nobody has said otherwise.
 *
 * **Empty, and `roads` was tried here and taken back out.** The reason is worth
 * keeping, because the argument for turning it on is persuasive and wrong:
 *
 * `roads` exists to stop California drawing US-101 and I-5 as 1,919 m and
 * 2,034 m black ribbons — wider than the cities they join. That complaint is
 * real and you can see it in any state frame. But `city.roads` **is not what
 * draws them**. `scene.ts` reads
 *
 *     scene.add(options.roadTraffic ? createFreewayWorld(world, pack) : createRoads(world))
 *
 * and California is the one board that has `roadTraffic`, so it takes the first
 * branch and `createRoads` — the only consumer of `Road.width` — is never called
 * for it at all. The corridor you can see is `createFreewayWorld`, built from a
 * `TransportPack` at hard-coded scene units (carriageways 1.03 wide at ±0.64,
 * shoulders 1.18), which at 1,919 m to the unit is a corridor about 4.7 km
 * across. That is deliberate: `main.ts` says so where it sets the corridor's
 * altitude — "an atlas glyph just like the black cars: literal metres would put
 * the chase camera inside the coarse hills" — because DRIVE mode has to be able
 * to drive down it on a board where a real freeway is a fifth of a pixel.
 *
 * So on California the rule rewrites a column nothing reads, and on the metros
 * `reconciledRoadWidth` returns the same number the pack already authored, to
 * within float noise. Turned on and photographed at three chapters: Downtown LA
 * pixel-identical (empty diff bbox), FiDi RMSE 0.0006 with zero pixels past
 * 8/255, California 0.001%. The one measurable consequence anywhere was **−112
 * triangles on Southern California**, from a ribbon width recomputing to a float
 * that differs in its last bits.
 *
 * A default that changes no pixel and 112 triangles is not a feature; it is a
 * second baseline for everyone who measures the other three rules afterwards.
 * The rule stays available — `?reconcile=roads` — and the real fix for the state
 * board's freeway lives in `structures.ts`, not here.
 */
export const DEFAULT_RULES: readonly Rule[] = ["exaggeration", "projection"];

/**
 * ## Why those two are on and the other two are not
 *
 * The flag was built so the owner could choose from a photograph rather than
 * from an argument, and the photographs have now been taken at HEAD, at a
 * matched hour and a matched pose, against the live API. The verdict:
 *
 *   - **`exaggeration` on.** The table above is the whole case: relief in frame
 *     is 7.24% on California, 6.92% on Southern California and **4.51%** on San
 *     Francisco. Two boards were already on one number and one was not. At a
 *     matched 17:10 the unreconciled Bay draws Tamalpais, the Berkeley hills and
 *     Mount Diablo as low smeared mounds; at 5.78 they stand up and the
 *     chapter's own caption — "the Diablo range closing the east" — becomes true
 *     of the picture for the first time. It moves SF and only SF, by 1.6x, and
 *     leaves the two boards every marketing still was tuned on untouched to the
 *     digit.
 *   - **`projection` on.** This is the one the "one canvas" ask actually needs.
 *     Each pack squashes longitude by the cosine of its *own* centre, so
 *     California and Southern California disagree about where Riverside is by
 *     3,435 m and about their shared north-east corner by 4,025 m — about 3% of
 *     the frame at a 109 km stand-off, which is a visible sideways slide in any
 *     transition that shows both boards at once. A continuous world cannot have
 *     two answers for one place. The price is 4.25% of local aspect on the
 *     Southland against a registration error that goes to zero by construction,
 *     and that is the right side of the trade for a board that is a crop of
 *     another board.
 *   - **`ground` stays off.** Its own doc comment calls it the riskiest of the
 *     four and the photograph agrees: the palette mix pushes the Southland's
 *     flats distinctly yellow-olive inland, because `socal`'s `flats` is off the
 *     one-parameter family in a direction no mix can reach. That is a look
 *     decision about the board carrying the most imagery, and it is not settled
 *     by the registration argument that settles `projection`.
 *   - **`roads` stays off**, for the reason argued at length above: it rewrites
 *     a column nothing reads on the one board where the complaint is visible.
 *
 * ⚠ **This changes the default, so it re-shoots the site.** Every still and film
 * on lumbridgecorp.com was photographed with all four rules off. `npm run
 * refresh` is the whole of the work, but it is not optional: leaving it undone
 * ships a site whose Bay Area pictures are of a board the product no longer
 * draws. `?reconcile=0` is how a measurement asks for the old board.
 */

/**
 * Which rules are on right now.
 *
 * Order of precedence: an explicit `setReconcile()` from a node script, then the
 * query string, then `DEFAULT_RULES`. The middle one includes `?reconcile=0`,
 * which is how a measurement asks for the unreconciled board now that "no flag"
 * no longer means that.
 */
export function activeRules(): ReadonlySet<Rule> {
  if (forced !== null) return forced;
  const search = (globalThis as { location?: { search?: string } }).location?.search;
  if (search === undefined) return new Set(DEFAULT_RULES);
  const value = new URLSearchParams(search).get("reconcile");
  if (value === null) return new Set(DEFAULT_RULES);
  return parse(value);
}

export function reconcileEnabled(rule?: Rule): boolean {
  const on = activeRules();
  return rule === undefined ? on.size > 0 : on.has(rule);
}

// ---- The four authored constants -------------------------------------------

/**
 * Relief in the frame: peak scene units over board span, the rule
 * ARCHITECTURE §12.1 already argues and already tuned California against.
 *
 * §12.1 moved California from 13 to 15 precisely to hold this number while the
 * board grew, and recorded that 15 "puts this board on Southern California's
 * number exactly, which is the calibration that matters — the two are meant to
 * read as the same landscape at two zooms". This constant is that number, taken
 * off California so the board the photograph tuned does not move at all:
 *
 * | board | today | one rule | relief today | relief reconciled |
 * | --- | --- | --- | --- | --- |
 * | california | 15 | 15.00 | 7.24% | 7.24% |
 * | socal | 3.4 | 3.41 | 6.92% | 7.24% |
 * | sf | 3.6 | 5.78 | 4.51% | 7.24% |
 *
 * **So the rule's verdict is that San Francisco is the board that is wrong**,
 * by 1.6×, and the other two were already on one number. That is a real claim
 * about a board that carries most of this product's imagery, which is exactly
 * why it is behind a flag with a photograph attached.
 *
 * What the rule does **not** do is remove the deflation at the seam, and that
 * is worth stating because it is easy to assume otherwise. Apparent relief at a
 * *matched* pose — same true-metre stand-off on both boards — is proportional
 * to the exaggeration itself, not to relief-in-frame, so the 4.17× step from
 * California to the Bay Area becomes 2.60× and the 4.41× step to Southern
 * California becomes 4.22×. Making it 1.0× would mean one exaggeration on every
 * board, which flattens the state to 1.7% of its own frame. Relief-in-frame and
 * matched-pose relief are the same quantity only when two boards have the same
 * span, and these do not.
 */
export const RELIEF_IN_FRAME = 0.0724;

/**
 * The narrowest a road may be drawn, as a fraction of the board's span.
 *
 * Measured rather than chosen. The two metro packs' narrowest roads sit at
 * 0.00019940 (San Francisco, a 19 m residential street) and 0.00020356
 * (Southern California, a 31 m boulevard) of their own board spans — two packs
 * authored a year apart agreeing to within 2% about how thin a line may get
 * before it stops being a line. The floor is set just under the tighter of the
 * two, so **neither metro board moves**, and California's freeways fall onto it.
 *
 * California's US-101 and I-5 are authored 1 and 1.06 scene units wide, which at
 * 1,919 m to the unit is 1,919 m and 2,034 m — black ribbons wider than the
 * cities they join, and the most prominent marks on the state board. Read as
 * true metres instead (44 m and 47 m, `Road.widthM`) they are 0.023 units and
 * would vanish, so the floor is what draws them: 0.110 units, or 212 m. A 9.1×
 * narrowing, and a symbol at the same fraction of its board that the Bay Area
 * already draws a side street at.
 */
export const LEGIBLE_SPAN_FRACTION = 0.000199;

/**
 * The coastal ramp, in coarse ground cells.
 *
 * `coastFalloff` exists for one mechanical reason: the terrain grid is clipped
 * to land, so its rim is stair-stepped at cell size, and the ramp takes every
 * height to zero across that rim so the steps land flat on the shore plate and
 * disappear (`terrain.ts` header). The size of the thing being hidden is the
 * **coarse** cell — the fine cell only exists inside a focus region, and coasts
 * run outside them — so the ramp is a multiple of that and of nothing else.
 *
 * 0.8 is California's own ratio, and it reproduces its authored 0.025° to
 * within 0.2%. The metro packs both come out slightly wider than they are
 * today: San Francisco 0.0018° → 0.0032° (200 m → 360 m) and Southern
 * California 0.0045° → 0.0058° (500 m → 641 m), which is the direction that
 * *removes* stair-stepping rather than adding it.
 */
export const COAST_RAMP_CELLS = 0.8;

/**
 * The one latitude every board squashes longitude by.
 *
 * `World.lngScale` is `latScale × cos(centre.lat)`, and each pack uses its own
 * centre — 37.30°, 37.77°, 33.82°. In true metres a degree of longitude is then
 * 88,551 m on California and 92,484 m on Southern California, so the two boards
 * disagree about where Riverside is by 3,435 m and about their shared north-east
 * corner by 4,025 m. At a 109 km stand-off that is about 3% of the frame — a
 * visible sideways slide in any transition that shows both boards at once.
 *
 * 37.3° is California's centre: the state board is the root of every descent and
 * the only board whose rectangle is a place rather than a crop. The price is
 * local aspect, and it is small and one-directional — Southern California draws
 * 4.25% narrower in x than a projection taken at its own latitude would, San
 * Francisco 0.64% wider — against a registration error that goes to zero by
 * construction.
 */
export const REFERENCE_LAT = 37.3;

/**
 * The palette anchors: one dry inland-southern end, one cool coastal-northern
 * end, mixed by where the board is.
 *
 * The south anchor is California's authored palette, unchanged, so the state
 * board renders exactly as it does today. The north anchor is the engine's
 * `DEFAULT_PALETTE` — which is to say San Francisco's, since it declares no
 * override — plus an `alpine` stop it never needed.
 *
 * **The two authored palettes are not on one line and no rule can put them
 * there.** Southern California's `flats` is 0xa9a291; the point 76.8% of the way
 * from the default to California's 0xb49b57 is 0xaf9b65. Its green channel is
 * *below* both anchors, so it is off the family in a direction a one-parameter
 * mix cannot reach. Something had to move, and the choice made here is that
 * California — the board §12 tuned against a photograph, and the board whose
 * desert Southern California is drawing a corner of — is the one that does not.
 * This is the riskiest of the four rules for that reason.
 */
const SOUTH_ANCHOR: ScenePalette = {
  skyTop: 0x7da6c9,
  skyHorizon: 0xe9d8bb,
  sea: 0x3c6d8b,
  lake: 0x4a7d93,
  shore: 0xc4b184,
  sand: 0xceba8c,
  flats: 0xb49b57,
  upland: 0x9d8a63,
  alpine: 0xb9b3a4,
  park: 0x76854e,
  parkHigh: 0x3d5739,
};

/**
 * `terrain.ts`'s `DEFAULT_PALETTE`, copied rather than imported, plus an
 * `alpine` stop it never needed.
 *
 * Copied because `world.ts` reaches this module and `terrain.worker.ts` reaches
 * `world.ts`: importing `terrain.ts` here would drag three.js into the
 * heightfield worker, which is a worker that exists precisely so that half a
 * million samples of noise cost the page nothing. `reconcile.test.ts` asserts
 * the ten stops still agree, so the copy cannot drift silently.
 */
const NORTH_ANCHOR: ScenePalette = {
  skyTop: 0x8fb8d8,
  skyHorizon: 0xd9e6ee,
  sea: 0x4a7a99,
  lake: 0x527f9c,
  shore: 0xa8a495,
  sand: 0xc4b79b,
  flats: 0x9d9c93,
  upland: 0x8f9084,
  park: 0x6f8a5c,
  parkHigh: 0x5d7a4c,
  alpine: 0xa6a8a2,
};

/** Metres above sea level at which `terrain.ts` starts painting `alpine`. */
const ALPINE_FROM = 1_900;

// ---- The rules -------------------------------------------------------------

/** Scene units across the board, the larger of the two axes. See §12.1. */
function spanUnits(city: City, lngScale: number): number {
  const lat = (city.bounds.maxLat - city.bounds.minLat) * city.latScale;
  const lng = (city.bounds.maxLng - city.bounds.minLng) * lngScale;
  return Math.max(lat, lng);
}

function lngScaleOf(city: City, on: ReadonlySet<Rule>): number {
  const lat = on.has("projection") ? REFERENCE_LAT : city.center.lat;
  return city.latScale * Math.cos((lat * Math.PI) / 180);
}

/**
 * The tallest ground the pack can produce, in metres, by the same blend
 * `World.elevationAt` uses — tallest hill plus 35% of the rest — evaluated at
 * every hill's own summit.
 *
 * A property of the pack and not of the field, which is what lets the
 * exaggeration be derived at construction time. The heightfield's true maximum
 * is 2.7–8.1% higher because the roughness multiplier peaks at 1.18 somewhere
 * near a summit; that bias is in the same direction on all three boards and
 * divides out of a ratio, and the alternative — half a million samples of
 * four-octave noise before the first triangle — is the cost this whole engine is
 * arranged around avoiding.
 *
 * O(hills²): 469² on California, and 7 ms. Only ever run with the flag on.
 */
export function blendedPeakMetres(city: City): number {
  const squash = Math.cos((city.center.lat * Math.PI) / 180);
  let best = 0;
  for (const at of city.hills) {
    let peak = 0;
    let total = 0;
    for (const hill of city.hills) {
      const dLat = at.lat - hill.lat;
      const dLng = (at.lng - hill.lng) * squash;
      const d = Math.hypot(dLat, dLng) / hill.radius;
      if (d >= 1) continue;
      const f = (1 - d * d) ** 2;
      const h = hill.elevation * f;
      total += h;
      if (h > peak) peak = h;
    }
    const here = peak === 0 ? 0 : peak + (total - peak) * 0.35;
    if (here > best) best = here;
  }
  return best;
}

/** (a) One relief-in-frame constant instead of three unrelated exaggerations. */
export function reconciledExaggeration(city: City, lngScale = lngScaleOf(city, new Set())): number {
  const metresPerUnit = 111_320 / city.latScale;
  const peak = blendedPeakMetres(city);
  if (peak <= 0) return city.verticalExaggeration;
  return (RELIEF_IN_FRAME * spanUnits(city, lngScale) * metresPerUnit) / peak;
}

/**
 * (b) A road's width in true metres, floored at the narrowest legible fraction
 * of the board.
 *
 * `widthM` is the pack's own statement of what the road really is; a pack that
 * does not declare one is read as having authored true metres already, which
 * both metro packs did.
 */
export function reconciledRoadWidth(city: City, road: Road, lngScale?: number): number {
  const metresPerUnit = 111_320 / city.latScale;
  const trueMetres = road.widthM ?? road.width * metresPerUnit;
  const floor = LEGIBLE_SPAN_FRACTION * spanUnits(city, lngScale ?? lngScaleOf(city, new Set()));
  return Math.max(trueMetres / metresPerUnit, floor);
}

/** (c) The coastal ramp as a multiple of the coarse ground cell. */
export function reconciledCoastFalloff(city: City): number {
  return COAST_RAMP_CELLS * city.cellLat * (city.coarseFactor ?? 1);
}

/**
 * (c) One palette, mixed by how far south and how far inland the board reaches.
 *
 * The two terms are the two things that separate a golden Central Valley from a
 * grey-green Bay Area, and both are already in `bounds`: 37.5°N to 33.0°N for
 * the southern term, and the coast at -122.0° to the Colorado at -114.0° for the
 * inland one. California lands on 1.00 and gets its own palette back exactly;
 * Southern California on 0.77; San Francisco on 0.05, which is the engine
 * default it already renders with.
 */
export function reconciledPaletteMix(city: City): number {
  const south = (37.5 - city.bounds.minLat) / (37.5 - 33.0);
  const inland = (city.bounds.maxLng + 122.0) / 8.0;
  return Math.min(1, Math.max(0, 0.5 * south + 0.5 * inland));
}

const CHANNELS = ["skyTop", "skyHorizon", "sea", "lake", "shore", "sand", "flats", "upland", "park", "parkHigh"] as const;

function mixChannel(a: number, b: number, t: number): number {
  const r = Math.round((((a >> 16) & 0xff) * (1 - t) + ((b >> 16) & 0xff) * t));
  const g = Math.round((((a >> 8) & 0xff) * (1 - t) + ((b >> 8) & 0xff) * t));
  const bl = Math.round(((a & 0xff) * (1 - t) + (b & 0xff) * t));
  return (r << 16) | (g << 8) | bl;
}

export function reconciledPalette(city: City): ScenePalette {
  const t = reconciledPaletteMix(city);
  const out = {} as ScenePalette;
  for (const key of CHANNELS) out[key] = mixChannel(NORTH_ANCHOR[key], SOUTH_ANCHOR[key], t);
  // A board gets the alpine stop when it has ground above the snow line, which
  // is the fact `alpine` is about. San Francisco's tallest is 1,186 m and does
  // not; the other two do. Today that is authored, one pack at a time.
  if (blendedPeakMetres(city) > ALPINE_FROM) {
    out.alpine = mixChannel(NORTH_ANCHOR.alpine ?? 0, SOUTH_ANCHOR.alpine ?? 0, t);
  }
  return out;
}

// ---- Putting it back into a pack -------------------------------------------

const cache = new WeakMap<City, Map<string, City>>();

/**
 * The pack the engine should actually draw.
 *
 * Returns the argument **by identity** when nothing is on, and the same object
 * for the same pack and the same rule set when something is, so a `World` and
 * the `World` its worker rebuilds agree without either of them knowing about the
 * other.
 */
export function reconciledCity(city: City): City {
  if (city.reconciled === true) return city;
  const on = activeRules();
  if (on.size === 0) return city;

  const key = RULES.filter((rule) => on.has(rule)).join(",");
  let byRules = cache.get(city);
  if (byRules === undefined) cache.set(city, (byRules = new Map()));
  const hit = byRules.get(key);
  if (hit !== undefined) return hit;

  const lngScale = lngScaleOf(city, on);
  const next: City = { ...city, reconciled: true };
  if (on.has("projection")) next.lngScale = lngScale;
  if (on.has("exaggeration")) next.verticalExaggeration = reconciledExaggeration(city, lngScale);
  if (on.has("roads")) {
    next.roads = city.roads.map((road) => ({
      ...road,
      width: reconciledRoadWidth(city, road, lngScale),
    }));
  }
  if (on.has("ground")) {
    next.coastFalloff = reconciledCoastFalloff(city);
    next.palette = reconciledPalette(city);
  }
  byRules.set(key, next);
  return next;
}

/**
 * What the reconciliation did to a pack, for a script that wants to print it.
 *
 * Here rather than in the script so the numbers a report quotes and the numbers
 * the engine draws come from one place.
 */
export interface Reconciliation {
  id: string;
  spanUnits: number;
  peakMetres: number;
  exaggeration: { before: number; after: number };
  reliefInFrame: { before: number; after: number };
  roadMetres: { before: [number, number]; after: [number, number] };
  coastFalloff: { before: number; after: number };
  lngMetresPerDegree: { before: number; after: number };
  paletteMix: number;
}

export function describe(city: City): Reconciliation {
  const metresPerUnit = 111_320 / city.latScale;
  const before = lngScaleOf(city, new Set());
  const after = lngScaleOf(city, new Set(RULES));
  const peak = blendedPeakMetres(city);
  const spanBefore = spanUnits(city, before);
  const spanAfter = spanUnits(city, after);
  const exAfter = reconciledExaggeration(city, after);
  const widths = (pack: City, scale: number, use: boolean): [number, number] => {
    const metres = pack.roads.map((road) =>
      use ? reconciledRoadWidth(pack, road, scale) * metresPerUnit : road.width * metresPerUnit,
    );
    return [Math.min(...metres), Math.max(...metres)];
  };
  return {
    id: city.id,
    spanUnits: spanAfter,
    peakMetres: peak,
    exaggeration: { before: city.verticalExaggeration, after: exAfter },
    reliefInFrame: {
      before: ((peak / metresPerUnit) * city.verticalExaggeration) / spanBefore,
      after: ((peak / metresPerUnit) * exAfter) / spanAfter,
    },
    roadMetres: { before: widths(city, before, false), after: widths(city, after, true) },
    coastFalloff: { before: city.coastFalloff, after: reconciledCoastFalloff(city) },
    lngMetresPerDegree: {
      before: (before / city.latScale) * 111_320,
      after: (after / city.latScale) * 111_320,
    },
    paletteMix: reconciledPaletteMix(city),
  };
}
