/**
 * Aerial perspective, and the guard that stops it moving San Francisco.
 *
 * The state board has no haze in it. Every distance in this engine is a
 * fraction of `boardSpan`, and on a board 1,063 km across the clear-day fog
 * plane lands 1,200 km out — so nothing on it is ever in front of anything
 * else. Converted to metres the three boards disagree by a factor of eight and
 * San Francisco is the one that is physically right: 86 km at 94.34 m to the
 * unit.
 *
 * ## The change that was proposed, and why it is not the change that landed
 *
 * The obvious fix is to state fog in physical metres and stop. It does not
 * survive the arithmetic and this file is where that is written down as a test
 * rather than as a comment. 86 km is 0.91 spans on the Bay Area board and
 * **0.085 spans on the extended state board**, where the camera orbits out to
 * 1,108 units. A literal metre fog puts California behind a wall 47 units from
 * the lens and there is no pose on that board from which the state is visible.
 *
 * What landed instead is a fraction: how much of the reach a board was authored
 * for the air at this altitude actually supports. It saturates at 1 at any
 * whole-board pose, which is what leaves the Bay Area untouched **by
 * construction rather than by measurement** — and the first test below is that
 * claim, made structural, because "we checked and it did not move" is a
 * property that decays the moment somebody edits the curve.
 */

import assert from "node:assert/strict";
import { describe, it } from "node:test";

import {
  aerialFog,
  aerialFogMoved,
  aerialReach,
  createAtmosphere,
  observe,
  PACIFIC_MARINE_LAYER,
} from "../../engine/atmosphere.ts";
import type { AerialView, Environment } from "../../engine/atmosphere.ts";
import type { LightingState } from "../../engine/types.ts";
import CALIFORNIA from "../../cities/california.ts";
import SF from "../../cities/sf.ts";
import SOCAL from "../../cities/socal.ts";
import type { City } from "../../engine/types.ts";
import { World } from "../../engine/world.ts";

/**
 * The board span and the exchange rate, from the pack itself.
 *
 * `boardSpan` is `max(width, height)` in scene units, computed exactly the way
 * `createScene` computes it — projected through the same `World`, so a change to
 * the projection cannot make this file agree with a scene that has moved on.
 */
function boardOf(city: City): { span: number; metresPerUnit: number } {
  const world = new World(city);
  const [westX, northZ] = world.project(city.bounds.maxLat, city.bounds.minLng);
  const [eastX, southZ] = world.project(city.bounds.minLat, city.bounds.maxLng);
  return {
    span: Math.max(Math.abs(eastX - westX), Math.abs(southZ - northZ)),
    metresPerUnit: world.metresPerUnit,
  };
}

/** `cityDaylight`'s clear pair: the ceiling this function may never exceed. */
function ceilingOf(span: number): { near: number; far: number } {
  return { near: span * 0.91, far: span * 2 };
}

/**
 * The pair a **deployed** board actually gets, which is not `cityDaylight`'s.
 *
 * `main.ts` hands `createAtmosphere` its own wider clear pair — `span * 1.15`
 * and `span * 3.9` — because `scene.ts` lets the orbit reach two board spans so
 * a visitor can get above the satellite dome, and at the narrower pair the board
 * sat at half fog from that pose. Asserting only against `cityDaylight`'s
 * numbers would test a formula no board on this page uses, and would have missed
 * the one real regression this change nearly shipped: the Bay Area's opening
 * pose saturates at 6.7 km against the narrow ceiling and at 12.4 km against the
 * wide one, and its camera is 11.3 km up.
 */
function deployedCeilingOf(span: number): { near: number; far: number } {
  return { near: span * 1.15, far: span * 3.9 };
}

/** The camera's distance to its target, in metres, at a pack's chapter. */
function chapterStandoffM(city: City, index: number): number {
  const chapter = city.chapters[index];
  assert.ok(chapter, `${city.id} has no chapter ${index}`);
  // Horizontal, so `metresPerUnit` and not `unitsToMetres` — only the board's
  // height is exaggerated, and dividing a stand-off by the exaggeration reports
  // every pose as many times closer than it is.
  const world = new World(city);
  return Math.hypot(chapter.focus.distance, chapter.focus.height) * world.metresPerUnit;
}

/** The camera's height above its target, in metres, at a pack's chapter. */
function chapterAltitudeM(city: City, index: number): number {
  const chapter = city.chapters[index];
  assert.ok(chapter, `${city.id} has no chapter ${index}`);
  const world = new World(city);
  // The pose's `height` is scene units above the ground under the target, and
  // the vertical exaggeration divides back out — the same conversion
  // `SceneHandle.cameraAltitudeMetres` does.
  return world.unitsToMetres(chapter.focus.height);
}

describe("San Francisco does not move", () => {
  it("renders its resting chapter with exactly the fog it had before", () => {
    const { span, metresPerUnit } = boardOf(SF);
    const ceiling = ceilingOf(span);
    const altitudeMetres = chapterAltitudeM(SF, 0);
    const fog = aerialFog({ ceiling, metresPerUnit, altitudeMetres });

    // Within 2%, and it is in fact within 0%: the Bay Area's opening pose is
    // 430 units up, which at 94.34 m to the unit is 40.6 km, and the curve
    // saturates by 6.7 km. The tolerance is there so a future adjustment to the
    // scale height is allowed to be an adjustment rather than a regression.
    assert.ok(
      Math.abs(fog.near - ceiling.near) / ceiling.near < 0.02,
      `SF fog near moved from ${ceiling.near.toFixed(1)} to ${fog.near.toFixed(1)}`,
    );
    assert.ok(
      Math.abs(fog.far - ceiling.far) / ceiling.far < 0.02,
      `SF fog far moved from ${ceiling.far.toFixed(1)} to ${fog.far.toFixed(1)}`,
    );
  });

  it("saturates every board's whole-board pose against the DEPLOYED ceiling", () => {
    /**
     * The guard that matters, and the one the narrower ceiling does not give.
     *
     * Every marketing still on lumbridgecorp.com is shot from a board's opening
     * chapter, and the promise of aerial perspective is that none of them is a
     * different photograph. Measured at the poses the packs actually carry:
     *
     *   california  67.6 km up, 1,551 km stand-off, 4,146 km authored reach
     *   socal       17.0 km up,   109 km stand-off,   597 km authored reach
     *   sf          11.3 km up,    71 km stand-off,   369 km authored reach
     *
     * The first two clear it on the air term alone. The Bay Area does not — 86 km
     * of clear-day visibility times e^(11.3/8.5) is 324 km against 369 needed —
     * and it is the stand-off clearance that carries it. That is exactly the
     * regression this assertion exists to catch: with the clearance at 2.4 the
     * Bay Area renders at 88% of its authored reach and the far corner of the
     * board picks up haze it has never had.
     */
    for (const city of [CALIFORNIA, SOCAL, SF]) {
      const { span, metresPerUnit } = boardOf(city);
      const ceiling = deployedCeilingOf(span);
      const fog = aerialFog({
        ceiling,
        metresPerUnit,
        altitudeMetres: chapterAltitudeM(city, 0),
        standoffMetres: chapterStandoffM(city, 0),
      });
      assert.equal(
        fog.far,
        ceiling.far,
        `${city.id}'s opening pose renders at ${((fog.far / ceiling.far) * 100).toFixed(1)}% ` +
          "of the reach it was authored with. Every still shot from this board just moved.",
      );
      assert.equal(fog.near, ceiling.near, `${city.id}'s fog near moved at the opening pose`);
    }
  });

  it("leaves every board's whole-board pose where it was", () => {
    // Not only San Francisco. The wide pose is the frame every marketing still
    // on lumbridgecorp.com is shot from, on all three boards, and the promise of
    // this change is that none of them is a different photograph.
    for (const city of [CALIFORNIA, SOCAL, SF]) {
      const { span, metresPerUnit } = boardOf(city);
      const ceiling = ceilingOf(span);
      const fog = aerialFog({
        ceiling,
        metresPerUnit,
        altitudeMetres: chapterAltitudeM(city, 0),
      });
      assert.equal(
        fog.far,
        ceiling.far,
        `${city.id}'s opening pose is no longer at the clear-day ceiling`,
      );
    }
  });
});

describe("the state board gets the aerial perspective it has none of", () => {
  const { span, metresPerUnit } = boardOf(CALIFORNIA);
  const ceiling = ceilingOf(span);

  it("closes the fog right in at four kilometres", () => {
    const fog = aerialFog({ ceiling, metresPerUnit, altitudeMetres: 4_000 });
    const spans = fog.near / span;
    assert.ok(
      spans < 0.2,
      `at 4 km the fog still starts ${spans.toFixed(3)} spans out, which on a ` +
        "1,063 km board is 200 km and is the whole defect this exists to fix",
    );
    // And it is haze, not a wall: the near plane must stay well inside the far.
    assert.ok(fog.near < fog.far * 0.6, "the fog closed to a single plane");
  });

  it("is back at the authored ceiling by fifty kilometres", () => {
    const fog = aerialFog({ ceiling, metresPerUnit, altitudeMetres: 50_000 });
    assert.ok(
      Math.abs(fog.near - span * 0.91) / (span * 0.91) < 0.05,
      `at 50 km the fog near is ${(fog.near / span).toFixed(3)} spans, not 0.91`,
    );
  });

  it("rises with altitude and never falls", () => {
    let last = -1;
    for (const h of [0, 500, 1_000, 2_000, 4_000, 8_500, 17_000, 30_000, 60_000, 200_000]) {
      const { far } = aerialFog({ ceiling, metresPerUnit, altitudeMetres: h });
      assert.ok(far >= last, `fog far fell from ${last.toFixed(1)} to ${far.toFixed(1)} at ${h} m`);
      last = far;
    }
  });
});

describe("the shape of the curve", () => {
  it("is exactly today's behaviour when nobody says where the camera is", () => {
    // The whole compatibility story in one assertion. Every caller that has not
    // been taught about altitude — every test, every offline boot, every future
    // renderer — passes `null` and gets the pack's own pair back untouched.
    const ceiling = { near: 210, far: 460 };
    const fog = aerialFog({ ceiling, metresPerUnit: 94.34, altitudeMetres: null });
    assert.deepEqual(fog, ceiling);
    assert.equal(aerialReach(null, 1_000_000), 1);
    for (const bad of [Number.NaN, Number.POSITIVE_INFINITY]) {
      assert.equal(aerialReach(bad, 1_000_000), 1, `altitude ${bad} must be treated as unknown`);
    }
  });

  it("never exceeds the ceiling, however high the camera goes", () => {
    // The ceiling is the contract. This function may only ever pull the fog in,
    // so no board can be made to render further than the pose it was tuned at —
    // which is what makes it safe to apply to three boards at once.
    const ceiling = { near: 210, far: 460 };
    for (const h of [0, 1e4, 1e6, 1e9]) {
      const fog = aerialFog({ ceiling, metresPerUnit: 94.34, altitudeMetres: h });
      assert.ok(fog.near <= ceiling.near + 1e-9, `near exceeded the ceiling at ${h} m`);
      assert.ok(fog.far <= ceiling.far + 1e-9, `far exceeded the ceiling at ${h} m`);
    }
  });

  it("never collapses the fog to a wall at ground level", () => {
    // The failure mode of an aerial-perspective term has to be haze. A camera
    // put at zero altitude by a controller bug must not render a board that is
    // one flat colour.
    const { span, metresPerUnit } = boardOf(CALIFORNIA);
    const fog = aerialFog({ ceiling: ceilingOf(span), metresPerUnit, altitudeMetres: 0 });
    assert.ok(fog.far * metresPerUnit > 50_000, `on the ground you can only see ${fog.far} units`);
  });

  it("never fogs the thing the camera is looking at", () => {
    // The clearance, stated as the property it buys rather than as its own
    // number: with `main.ts`'s 1.15/3.9 ratio the near plane lands at 1.77
    // stand-offs, so the subject of any shot is outside the fog entirely and the
    // haze begins somewhere behind it. A camera 223 km from a mountain on a
    // 1,063 km board is a map being read, not an observer who cannot see.
    const { span, metresPerUnit } = boardOf(CALIFORNIA);
    const standoffMetres = 500_000;
    const fog = aerialFog({
      ceiling: deployedCeilingOf(span),
      metresPerUnit,
      // Low enough that the air term cannot be what is being measured.
      altitudeMetres: 1_000,
      standoffMetres,
    });
    assert.ok(
      fog.near * metresPerUnit > standoffMetres,
      `the fog starts at ${((fog.near * metresPerUnit) / 1000).toFixed(0)} km with the ` +
        `subject at ${(standoffMetres / 1000).toFixed(0)} km — the shot is inside its own fog`,
    );
    // But it is aerial perspective and not a clear day: something three
    // stand-offs away has to be visibly hazed.
    assert.ok(fog.far * metresPerUnit < standoffMetres * 8, "the fog is out past anything on the board");
  });

  it("puts one scale height at a factor of e", () => {
    // The one physical claim in the module, asserted as physics rather than as a
    // number somebody liked: air thins as exp(-h/H), so a camera one scale
    // height up sees e times as far. Taken well below saturation so the clamp
    // is not what is being measured.
    // A million metres of authored reach, chosen so neither clamp is in play:
    // 86 km of it at the ground and 234 km at one scale height, both strictly
    // between the 2% floor and the ceiling of 1.
    const reachLow = aerialReach(0, 1_000_000);
    const reachHigh = aerialReach(8_500, 1_000_000);
    assert.ok(
      Math.abs(reachHigh / reachLow - Math.E) < 0.001,
      `one scale height gave ${(reachHigh / reachLow).toFixed(4)}x, not e`,
    );
  });
});

/**
 * The seam that keeps a camera out of the environment map.
 *
 * ## What this is guarding, in one paragraph
 *
 * `environmentRig.ts` decides whether to re-render and re-convolve the sky's
 * PMREM cubemap by fingerprinting the rig's **colours** — `sky.top`,
 * `sky.horizon`, the hemisphere pair, the ambient and the sun. Separately,
 * `interiors/daylight.ts` pins the sky's horizon stop to the fog colour on
 * purpose, because that is what makes the horizon a horizon instead of the seam
 * where a dome meets a haze. Put those two facts beside a fog that follows the
 * camera and there is a live wire: the first camera-dependent term that reaches
 * a *colour* puts a cubemap rebuild on every orbit step, and the symptom is a
 * board that halves its frame rate while drawing exactly the same triangles.
 *
 * Aerial perspective does not do that — `aerialReach` scales three distances and
 * touches nothing else — and this is that claim made structural rather than
 * remembered. The fix it forecloses is the tempting one: coarsening the
 * fingerprint until the rebuild stops hides one instance and leaves the
 * mechanism armed for the next feature that varies a colour.
 */
describe("the camera moves the fog and nothing else", () => {
  /** The deployed Bay Area rig: marine layer on, so obscuration is in play. */
  function bayArea() {
    const { span, metresPerUnit } = boardOf(SF);
    return createAtmosphere({
      lng: SF.center.lng,
      metresPerUnit,
      clearFog: deployedCeilingOf(span),
      minVisibilityM: span * metresPerUnit * 1.6,
      marineLayer: PACIFIC_MARINE_LAYER,
    });
  }

  /** The deployed state board: no marine layer, and the board with real haze. */
  function state() {
    const { span, metresPerUnit } = boardOf(CALIFORNIA);
    return createAtmosphere({
      lng: CALIFORNIA.center.lng,
      metresPerUnit,
      clearFog: deployedCeilingOf(span),
      minVisibilityM: span * metresPerUnit * 1.6,
      marineLayer: null,
    });
  }

  /**
   * Everything a `LightingState` carries except the two numbers a camera is
   * allowed to move. Compared as JSON so a field added later is compared too,
   * which is the point — a guard that has to be updated to notice a new field is
   * not a guard.
   */
  function everythingButTheDistances(l: LightingState): string {
    return JSON.stringify({
      sun: l.sun,
      hemisphere: l.hemisphere,
      ambient: l.ambient,
      sky: l.sky,
      fogColor: l.fog?.color ?? null,
      moon: l.moon ?? null,
    });
  }

  /**
   * Four hours that exercise every branch that could plausibly acquire a camera
   * term: full day, the golden hour, **dusk** — where the horizon pin is
   * load-bearing and the seam shows — and astronomical night, where the moon is
   * the key light and the fog is floored at a lifted blue.
   */
  const HOURS = [
    ["noon", "2026-08-22T20:00:00Z"],
    ["golden hour", "2026-08-23T02:00:00Z"],
    ["dusk", "2026-08-23T02:30:00Z"],
    ["night", "2026-08-23T09:00:00Z"],
  ] as const;

  /** A chase camera on the deck, a mid-board pose, and above the whole board. */
  const VIEWS: readonly (readonly [string, AerialView])[] = [
    ["ground", { altitudeMetres: 0, standoffMetres: 500 }],
    ["crow", { altitudeMetres: 1_200, standoffMetres: 4_000 }],
    ["low", { altitudeMetres: 4_000, standoffMetres: 20_000 }],
    ["high", { altitudeMetres: 40_000, standoffMetres: 200_000 }],
    ["unknown", { altitudeMetres: null }],
  ];

  it("leaves every colour in the rig bit-identical however far the camera climbs", () => {
    for (const [label, atmosphere] of [
      ["bay-area", bayArea()],
      ["california", state()],
    ] as const) {
      const city = label === "bay-area" ? SF : CALIFORNIA;
      for (const [hour, iso] of HOURS) {
        const env: Environment = observe(
          city.center.lat,
          city.center.lng,
          new Date(iso),
          null,
        );
        const reference = everythingButTheDistances(atmosphere.apply(env, VIEWS[0]![1]));
        for (const [pose, view] of VIEWS) {
          assert.equal(
            everythingButTheDistances(atmosphere.apply(env, view)),
            reference,
            `${label} at ${hour}: the "${pose}" camera changed something that is not a fog ` +
              "distance. Every field compared here is fingerprinted by " +
              "`environmentRig.ts`, so a camera-dependent colour rebuilds and re-convolves " +
              "the PMREM cubemap on every orbit step. Move the term out of the camera path " +
              "rather than coarsening `environmentKey`.",
          );
        }
      }
    }
  });

  it("does move the fog distances, or there is no feature here", () => {
    // The other half of the assertion above, and it has to be stated or the
    // first one passes perfectly on a rig that ignores the camera entirely.
    const atmosphere = state();
    const env = observe(
      CALIFORNIA.center.lat,
      CALIFORNIA.center.lng,
      new Date("2026-08-22T20:00:00Z"),
      null,
    );
    const low = atmosphere.apply(env, { altitudeMetres: 1_200, standoffMetres: 4_000 }).fog;
    const high = atmosphere.apply(env, { altitudeMetres: 40_000, standoffMetres: 200_000 }).fog;
    assert.ok(low && high);
    assert.ok(
      high.far > low.far * 4,
      `the state board sees ${low.far.toFixed(0)} units at 1.2 km and ` +
        `${high.far.toFixed(0)} at 40 km — aerial perspective has stopped working`,
    );
  });

  it("hands the camera path exactly the distances the clock path would write", () => {
    /*
     * `Atmosphere.aerial` is what a camera step calls now, and `apply` is what
     * the clock still calls. Two derivations of one fog is how a drag and a
     * clock tick start disagreeing about the weather, so `aerial` is defined as
     * a slice of `apply` and this is that definition held in place.
     */
    for (const [label, atmosphere] of [
      ["bay-area", bayArea()],
      ["california", state()],
    ] as const) {
      const city = label === "bay-area" ? SF : CALIFORNIA;
      for (const [hour, iso] of HOURS) {
        const env = observe(city.center.lat, city.center.lng, new Date(iso), null);
        for (const [pose, view] of VIEWS) {
          const full = atmosphere.apply(env, view).fog;
          assert.ok(full, `${label} lost its fog at ${hour}`);
          assert.deepEqual(
            atmosphere.aerial(env, view),
            { near: full.near, far: full.far },
            `${label} at ${hour}, "${pose}": the camera path and the clock path disagree`,
          );
        }
      }
    }
  });
});

/**
 * The 2% altitude throttle, as a property rather than as a comment in main.ts.
 *
 * A chapter flight would otherwise recompute sixty times a second for a number
 * the eye cannot resolve, and a camera sitting on the ground would recompute
 * on every sample of noise. Follow-camera descents still have to get through:
 * the first sample always counts, and a drop past 2% of the last altitude
 * counts, which is the whole of what putting fog on the tick is for.
 */
describe("aerial fog recomputes when altitude has actually moved", () => {
  it("always fires on the first sample of a board", () => {
    assert.equal(aerialFogMoved(12_000, -1), true);
    assert.equal(aerialFogMoved(0, Number.NaN), true);
  });

  it("ignores a move under two per cent of the last altitude", () => {
    assert.equal(aerialFogMoved(101, 100), false);
    assert.equal(aerialFogMoved(10_199, 10_000), false);
    assert.equal(aerialFogMoved(9_801, 10_000), false);
  });

  it("fires at two per cent and above", () => {
    assert.equal(aerialFogMoved(102, 100), true);
    assert.equal(aerialFogMoved(9_800, 10_000), true);
    assert.equal(aerialFogMoved(1_200, 40_000), true);
  });

  it("floors the threshold at a metre, so a camera on the ground is still", () => {
    assert.equal(aerialFogMoved(0.4, 0), false);
    assert.equal(aerialFogMoved(1, 0), true);
    assert.equal(aerialFogMoved(1.5, 0.5), true);
  });
});
