/**
 * The default board's shape, its dry corner, and the three defects around them
 * that only a photograph ever caught.
 *
 * This file exists beside `packs/californiaBoard.test.ts` rather than inside it
 * because everything here crosses a seam: the pack decides where the state
 * stops, `terrain.ts` decides what colour the ground at that elevation is, and
 * `scene.ts` decides how far back to stand. Each of the three below typechecked,
 * rendered without a console error and met every performance budget.
 *
 *   1. **A state that was not the shape of the state.** The land polygon closed
 *      with three ruled segments — one running due north along -117.45 for five
 *      degrees of latitude — so San Diego, the Peninsular Ranges, the Colorado
 *      Desert, the Salton basin and half the Mojave were all rendered as open
 *      ocean, and California sat on the water as a paper dart.
 *   2. **A desert with no second value in it.** `groundColor` saturates at
 *      150 m, so the Mojave floor at 620 m and the Sierra crest at 4,300 were
 *      painted exactly the same number and the only thing separating a granite
 *      skyline from a creosote flat was the Lambert term.
 *   3. **An opening pose that framed a third of the board.** `fov` is vertical,
 *      so the horizontal half-angle is a fact about the shape of the window: the
 *      pose that held the whole state on a laptop held its middle third on a
 *      phone, with the Sierra and the Colorado off both sides of the screen.
 */

import assert from "node:assert/strict";
import { describe, it } from "node:test";
import * as THREE from "three";

import CALIFORNIA_CITY from "../../cities/california.ts";
import SOCAL_CITY from "../../cities/socal.ts";
import { chapterFraming } from "../../engine/scene.ts";
import { createTerrain, createWater } from "../../engine/terrain.ts";
import type { City, LatLng } from "../../engine/types.ts";
import { World } from "../../engine/world.ts";

/**
 * A `World` whose heightfield is already up.
 *
 * `ready()` waits on a paint that never comes under the Node test runner, so
 * this takes the documented synchronous path: `lattice()` builds the field on
 * the calling thread when nobody awaited `ready()`.
 */
function builtWorld(city: City): World {
  const world = new World(city);
  world.lattice();
  return world;
}

/**
 * The Nevada line, as the pack draws it: one ruled segment from the corner at
 * Lake Tahoe to the Arizona/Nevada/California tri-point on the Colorado.
 */
function nevadaLineLng(lat: number): number {
  return -114.6339 - 1.3421 * (lat - 35.0016);
}

describe("the California silhouette", () => {
  const world = new World(CALIFORNIA_CITY);

  it("puts the state's own edges on three sides of the board", () => {
    // Inside, and every one of them was ocean on the board that shipped before.
    for (const [name, lat, lng] of [
      ["San Diego", 32.72, -117.16],
      ["the Laguna Mountains", 32.78, -116.4],
      ["the Imperial Valley", 32.85, -115.5],
      ["Palm Springs", 33.82, -116.54],
      ["Joshua Tree", 33.98, -116.1],
      ["the eastern Mojave", 34.9, -115.6],
      ["Death Valley", 36.25, -116.83],
      ["Needles", 34.8, -114.7],
    ] as [string, number, number][]) {
      assert.equal(world.isLand(lat, lng), true, `${name} should be on the board`);
    }

    // Outside, and each one is a different edge doing its job.
    for (const [name, lat, lng] of [
      ["Baja California, below the border", 32.3, -116.6],
      ["Arizona, across the Colorado at Yuma", 32.75, -114.3],
      ["Arizona, across the river at Parker", 34.2, -114.05],
      ["Nevada, east of the line at Las Vegas' latitude", 36.1, -115.2],
      ["Nevada, east of the line above Bishop", 37.4, -117.2],
      ["the Pacific, west of Point Conception", 34.3, -120.8],
    ] as [string, number, number][]) {
      assert.equal(world.isLand(lat, lng), false, `${name} should be off the board`);
    }
  });

  it("follows the Nevada line rather than a meridian", () => {
    // A degree of latitude apart, and the boundary moves 1.34° of longitude with
    // it. A vertical closure — which is what this used to be — passes the first
    // of these and fails the third.
    for (const lat of [35.4, 36.4, 37.4, 38.0]) {
      const edge = nevadaLineLng(lat);
      assert.equal(world.isLand(lat, edge - 0.25), true, `${lat}N inside the line`);
      assert.equal(world.isLand(lat, edge + 0.25), false, `${lat}N outside the line`);
    }
  });

  it("keeps the Salton Sea as inland water inside a land polygon", () => {
    assert.equal(CALIFORNIA_CITY.inlandWater.length, 1);
    // Water at the middle, land on both shores. A lake that has swallowed its
    // own basin and a lake that is not there at all both pass a length check.
    assert.equal(world.isLand(33.31, -115.84), false, "the middle of the sea");
    assert.equal(world.isLand(33.31, -116.15), true, "the western shore");
    assert.equal(world.isLand(33.31, -115.55), true, "the eastern shore");
  });

  it("stands real relief in the corner that used to be a blank flat", () => {
    const built = builtWorld(CALIFORNIA_CITY);
    // The named ranges, each of which is a chain and not a single bell.
    for (const [name, lat, lng, floor] of [
      ["San Gorgonio", 34.12, -116.86, 2_600],
      ["San Jacinto", 33.81, -116.67, 2_400],
      ["Telescope Peak", 36.2, -117.22, 2_400],
      ["the Providence Mountains", 34.9, -115.62, 1_400],
      ["the New York Mountains", 35.23, -115.32, 1_400],
      ["the Kingston Range", 35.72, -115.91, 1_400],
    ] as [string, number, number, number][]) {
      const metres = built.elevationAt(lat, lng);
      assert.ok(metres > floor, `${name} is only ${Math.round(metres)} m`);
    }
  });

  it("draws those ranges as ridges rather than as a field of domes", () => {
    const built = builtWorld(CALIFORNIA_CITY);
    /**
     * The test for a ridge is anisotropy: walking *along* a range front stays
     * high and walking *across* it falls away, and the ratio between the two is
     * the whole difference between the board that reads as basin and range and
     * the board that read as bubble wrap. A single radial bell — which is what
     * each of these used to be — scores exactly 1 here by construction, because
     * `(1 - d²)²` has no direction in it.
     *
     * The two step vectors are derived from the range's own end points rather
     * than written by hand, and longitude is squashed by `cos(centre latitude)`
     * on the way in and out, because that is what `elevationAt` does when it
     * measures a distance and a step that ignores it is 22% short east-west.
     */
    const SQUASH = Math.cos((CALIFORNIA_CITY.center.lat * Math.PI) / 180);
    const ranges: Array<{ name: string; from: LatLng; to: LatLng; step: number }> = [
      { name: "the Providence Mountains", from: [34.72, -115.78], to: [35.08, -115.46], step: 0.15 },
      { name: "the Old Woman Mountains", from: [34.3, -115.38], to: [34.68, -115.12], step: 0.15 },
      { name: "the Panamint Range", from: [35.92, -117.22], to: [36.56, -117.24], step: 0.18 },
    ];

    for (const { name, from, to, step } of ranges) {
      const mid: LatLng = [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2];
      const dLat = to[0] - from[0];
      const dLng = (to[1] - from[1]) * SQUASH;
      const length = Math.hypot(dLat, dLng);
      const along: LatLng = [(dLat / length) * step, ((dLng / length) * step) / SQUASH];
      const across: LatLng = [(-dLng / length) * step, ((dLat / length) * step) / SQUASH];

      const at = (offset: LatLng, sign: number): number =>
        built.elevationAt(mid[0] + offset[0] * sign, mid[1] + offset[1] * sign);
      const crest = built.elevationAt(mid[0], mid[1]);
      const alongLow = Math.min(at(along, 1), at(along, -1));
      const acrossLow = Math.min(at(across, 1), at(across, -1));

      assert.ok(crest > 800, `${name} is only ${Math.round(crest)} m at its middle`);
      assert.ok(
        alongLow > acrossLow * 1.6,
        `${name} falls to ${Math.round(alongLow)} m along the range and ` +
          `${Math.round(acrossLow)} m across it, which is a dome and not a ridge`,
      );
    }
  });

  it("leaves Badwater a salt pan rather than lifting it onto farmland", () => {
    const built = builtWorld(CALIFORNIA_CITY);
    // Under 3 m is `palette.sand`, which is the beach colour and is exactly what
    // the floor of Death Valley should be. The Black Mountains' radius is the
    // only thing holding this: at 0.18 the chain reached across the valley and
    // put the pan at 52 m, which is `flats` gold.
    assert.ok(built.elevationAt(36.25, -116.83) < 3, "Badwater has been filled in");
    // And it is a pan between two walls, not a plain.
    assert.ok(built.elevationAt(36.2, -117.22) > 2_400, "the Panamints");
    assert.ok(built.elevationAt(36.15, -116.66) > 900, "the Black Mountains");
  });
});

describe("the third colour stop", () => {
  /** Every emitted vertex colour, and the lattice index it came from. */
  function terrainColours(city: City): { world: World; colourAt: (lat: number, lng: number) => THREE.Color } {
    const world = builtWorld(city);
    /*
     * `createTerrain` returns a group. `CALIFORNIA_CITY` declares no `detail`
     * districts so this is the un-chunked single mesh — and even on a chunked
     * board every chunk shares one `position`/`color` attribute pair, so the
     * linear walk below would find the identical vertices in the identical
     * order whichever branch built it.
     */
    const mesh = createTerrain(world).children[0] as THREE.Mesh;
    const position = mesh.geometry.getAttribute("position");
    const colour = mesh.geometry.getAttribute("color");
    return {
      world,
      colourAt: (lat, lng) => {
        const [x, z] = world.project(lat, lng);
        let best = -1;
        let bestDistance = Infinity;
        for (let i = 0; i < position.count; i += 1) {
          const d = Math.hypot(position.getX(i) - x, position.getZ(i) - z);
          if (d < bestDistance) {
            bestDistance = d;
            best = i;
          }
        }
        return new THREE.Color(colour.getX(best), colour.getY(best), colour.getZ(best));
      },
    };
  }

  it("separates the Sierra crest from the Mojave floor, which used to be one value", () => {
    const { colourAt } = terrainColours(CALIFORNIA_CITY);
    // Both are unpainted ground — no park envelope reaches either — so before
    // `alpine` existed these two returned the identical `upland` hex however far
    // apart they are in altitude.
    const crest = colourAt(36.62, -118.29); // Mount Whitney, 4,300 m
    const desert = colourAt(34.9, -117.0); // the Mojave floor, about 600 m
    const brightness = (c: THREE.Color): number => (c.r + c.g + c.b) / 3;
    assert.ok(
      brightness(crest) > brightness(desert) * 1.15,
      `the crest (${brightness(crest).toFixed(3)}) is no lighter than the desert ` +
        `(${brightness(desert).toFixed(3)})`,
    );
    // Lighter *and* less saturated: bare granite is grey and a desert fan is
    // brown, and a ramp that only raised the value would wash the desert out
    // rather than change what it is.
    const chroma = (c: THREE.Color): number =>
      Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b);
    assert.ok(chroma(crest) < chroma(desert), "the crest is as warm as the desert");
  });

  it("leaves a pack that declares no `alpine` exactly as it was", () => {
    // The colour is optional for this reason: Southern California is 308 units
    // across with a 3,000 m wall on it and has never wanted a snow line, and a
    // tenth required palette entry would have made every existing pack answer
    // for one.
    assert.equal(SOCAL_CITY.palette?.alpine, undefined);
    const { colourAt } = terrainColours(SOCAL_CITY);
    const high = colourAt(34.29, -117.65); // Mount San Antonio, the board's roof
    const low = colourAt(34.05, -117.9); // the basin floor below it
    const brightness = (c: THREE.Color): number => (c.r + c.g + c.b) / 3;
    // Not identical — one is in a forest envelope and one is not — but the high
    // ground must not have been *lightened*, which is what a stop applied to a
    // pack that never asked for one would do.
    assert.ok(
      brightness(high) <= brightness(low) + 0.02,
      "Southern California's high ground picked up a snow line it never declared",
    );
  });
});

describe("the sea loses every depth argument it should never have won", () => {
  it("pushes the ocean behind the ground it is under", () => {
    /*
     * The sea sits 0.06 units below y=0 and the terrain's coastal rim 0.012
     * above it, and at a whole-board pose on the Bay Area that whole gap is a
     * fifth of one step of the depth buffer. Which surface wins was decided by
     * float error in a two-triangle plane eighteen board spans across, and what
     * it looked like was the ocean dithered over every flat piece of ground on
     * the board — on the two detailed boards, not on the state one, whose valley
     * floor stands at 28 m.
     *
     * Asserted on the material rather than on a picture because a picture is
     * what it took to find, and the whole point of the assertion is that the
     * next person does not need one.
     */
    const water = createWater(builtWorld(SOCAL_CITY));
    const sea = water.getObjectByName("sea");
    assert.ok(sea instanceof THREE.Mesh, "no sea in the water group");
    const material = (sea as THREE.Mesh).material as THREE.Material;
    assert.equal(material.polygonOffset, true, "the sea does not yield in depth");
    assert.ok(
      material.polygonOffsetUnits > 0 && material.polygonOffsetFactor > 0,
      "the sea's polygon offset pulls it toward the camera rather than away",
    );
    water.traverse((object) => {
      if (object instanceof THREE.Mesh) object.geometry.dispose();
    });
  });
});

describe("chapter framing on a window the pose was not written for", () => {
  // The California board: 428 units across, an opening pose reaching about 600,
  // an orbit ceiling at two board spans.
  const board = { boardSpan: 428, orbitMax: 856 };

  it("leaves a wide window alone", () => {
    for (const aspect of [1.6, 1.78, 2.1]) {
      assert.equal(chapterFraming({ aspect, reach: 600, ...board }), 1);
    }
  });

  it("stands back on a phone held upright", () => {
    const scale = chapterFraming({ aspect: 390 / 844, reach: 600, ...board });
    assert.ok(scale > 1.2, `only ${scale.toFixed(2)}× on a portrait screen`);
    // And never past the orbit's ceiling, because `OrbitControls` clamps to it
    // on the next update and a pose beyond it is a pose nobody wrote.
    assert.ok(600 * scale <= board.orbitMax + 1e-6, "the pose is outside maxDistance");
  });

  it("leaves a close-up alone on any window", () => {
    // Southern California's opening shot stands off about half a board span and
    // is deliberately inside its subject. Pulling it back is a different
    // photograph, not a correction.
    assert.equal(chapterFraming({ aspect: 390 / 844, reach: 150, boardSpan: 308, orbitMax: 616 }), 1);
    // As is a driving chapter on the state board.
    assert.equal(chapterFraming({ aspect: 390 / 844, reach: 133, ...board }), 1);
  });

  it("never returns something a camera cannot use", () => {
    for (const reach of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
      assert.equal(chapterFraming({ aspect: 0.46, reach, ...board }), 1);
    }
    for (const aspect of [0, -1, Number.NaN]) {
      const scale = chapterFraming({ aspect, reach: 600, ...board });
      assert.ok(Number.isFinite(scale) && scale >= 1, `aspect ${aspect} gave ${scale}`);
    }
  });
});
