/**
 * SFO, and the airport kit under it.
 *
 * Every defect this file guards against typechecked, threw nothing, and cost
 * nothing in the performance budget. Each one was found by rendering the board
 * and looking at it, and each assertion below is the cheapest arithmetic
 * statement of what the picture showed:
 *
 *   1. **A field that was not there.** `terrain.ts` builds its relief mesh at
 *      `world.metres(e) + 0.012` — a bias of its own — so an airport plate laid
 *      a hundredth of a unit above `groundAt` is laid *under the ground*. SFO
 *      rendered as four runways floating on bare terrain with a magenta sliver
 *      of fill visible only where it overhung the bay. Nothing warned.
 *   2. **Taxiways wound face-down.** A strip whose two rails are emitted
 *      right-before-left has reversed winding, `computeVertexNormals` points
 *      every normal at the ground, and a one-sided material draws nothing. Six
 *      taxiways were simply absent.
 *   3. **Every building mirrored.** `rotateY` takes the angle whose sine and
 *      cosine are the *scene-space* along-vector, so the bearing is
 *      `atan2(x, z)` and not `atan2(x, −z)`. With the negation SFO's terminal
 *      horseshoe was built on 153.5° instead of 26.5° — not rotated, reflected —
 *      and the aircraft on stand, which never went through that function, parked
 *      in a tidy row beside nothing.
 *   4. **Two control towers.** The pack already carried a labelled `SFO Control
 *      Tower` landmark, which is what puts SFO on the minimap, and the airport
 *      declared one as well. They stood four hundred metres apart.
 *
 * The geography assertions are a different kind. A runway on the wrong bearing
 * is not a bug in any code — it is a number somebody typed — and the only thing
 * that catches it is stating the published figure next to the authored one.
 */

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

import SF_CITY, { AIRPORTS, SFO, SJC, PENINSULA } from "../../cities/sf.ts";
import {
  createAirports,
  parallelTaxiway,
  runwayThresholds,
  type Airport,
  type Runway,
} from "../../engine/airports.ts";
import type { LatLng } from "../../engine/types.ts";
import { World } from "../../engine/world.ts";

const METRES_PER_DEGREE_LAT = 111_320;
const DEG = Math.PI / 180;

/** Metres between two coordinates, flat-earth, which is right at this scale. */
function metresBetween(a: LatLng, b: LatLng): number {
  const north = (a[0] - b[0]) * METRES_PER_DEGREE_LAT;
  const east = (a[1] - b[1]) * METRES_PER_DEGREE_LAT * Math.cos(a[0] * DEG);
  return Math.hypot(north, east);
}

/** True bearing from `a` to `b`, degrees clockwise from north. */
function bearing(a: LatLng, b: LatLng): number {
  const north = (b[0] - a[0]) * METRES_PER_DEGREE_LAT;
  const east = (b[1] - a[1]) * METRES_PER_DEGREE_LAT * Math.cos(a[0] * DEG);
  return ((Math.atan2(east, north) / DEG) + 360) % 360;
}

/** Perpendicular distance in metres from a runway's centreline to a point. */
function offsetFromCentreline(runway: Runway, point: LatLng): number {
  const north = (point[0] - runway.lat) * METRES_PER_DEGREE_LAT;
  const east = (point[1] - runway.lng) * METRES_PER_DEGREE_LAT * Math.cos(runway.lat * DEG);
  // The runway's right-hand normal: its heading turned a quarter clockwise.
  const rightEast = Math.cos(runway.heading * DEG);
  const rightNorth = -Math.sin(runway.heading * DEG);
  return east * rightEast + north * rightNorth;
}

function runwayById(airport: Airport, id: string): Runway {
  const runway = airport.runways.find((candidate) => candidate.id === id);
  assert.ok(runway, `${airport.id} has no runway ${id}`);
  return runway;
}

/**
 * San Francisco's real projection with flat ground, which is what SFO stands
 * on: the field is bay fill and `elevationAt` returns exactly zero across all
 * of it. A tidy 1:1 world would hide every scale mistake in the kit.
 */
function bayWorld(): World {
  const world = new World(SF_CITY);
  return Object.assign(Object.create(Object.getPrototypeOf(world) as object), world, {
    groundAt: () => 0,
    elevationSampled: () => 0,
  }) as World;
}

describe("SFO — the numbers a picture cannot check", () => {
  it("lays both crossing pairs on their true bearings, not their painted ones", () => {
    // A designator is magnetic and rounded to ten degrees; San Francisco's
    // declination is ~13.5° east. Building from "28" or "01" puts the whole
    // airport thirteen degrees out.
    assert.equal(runwayById(SFO, "10L/28R").heading, 118.6);
    assert.equal(runwayById(SFO, "10R/28L").heading, 118.6);
    assert.equal(runwayById(SFO, "1L/19R").heading, 26.5);
    assert.equal(runwayById(SFO, "1R/19L").heading, 26.5);
  });

  it("crosses the two pairs at very close to a right angle", () => {
    const crossing = Math.abs(
      runwayById(SFO, "10L/28R").heading - runwayById(SFO, "1L/19R").heading,
    );
    // 92.1°. An airfield whose runways cross at seventy degrees is a different
    // airport, and reads as a generic one.
    assert.ok(
      Math.abs(crossing - 90) <= 4,
      `SFO's pairs cross at ${crossing.toFixed(1)}°, which is not SFO`,
    );
  });

  it("holds each runway to its published length", () => {
    // Threshold to threshold, metres, from the published feet.
    for (const [id, feet] of [
      ["10L/28R", 11_870],
      ["10R/28L", 11_381],
      ["1L/19R", 7_650],
      ["1R/19L", 8_650],
    ] as const) {
      const runway = runwayById(SFO, id);
      const metres = feet * 0.3048;
      assert.ok(
        Math.abs(runway.length - metres) < 12,
        `${id} is ${runway.length} m against ${metres.toFixed(0)} m`,
      );
      // And the derived thresholds agree with the declared length, which is what
      // makes `runwayThresholds` safe for anything downstream to build on.
      const { low, high } = runwayThresholds(runway);
      assert.ok(Math.abs(metresBetween(low, high) - runway.length) < 2);
      assert.ok(Math.abs(bearing(low, high) - runway.heading) < 0.1);
    }
  });

  it("keeps the two famous parallel separations", () => {
    // 750 ft between the 28s: the closest parallel pair in the United States
    // used for simultaneous approaches, and the reason every SFO arrival in low
    // cloud is a single-file arrival. 700 ft between the 01s.
    const tenRight = offsetFromCentreline(runwayById(SFO, "10L/28R"), [
      runwayById(SFO, "10R/28L").lat,
      runwayById(SFO, "10R/28L").lng,
    ]);
    assert.ok(
      Math.abs(tenRight - 750 * 0.3048) < 8,
      `10L/28R to 10R/28L is ${tenRight.toFixed(0)} m, not 229 m`,
    );
    const oneRight = offsetFromCentreline(runwayById(SFO, "1L/19R"), [
      runwayById(SFO, "1R/19L").lat,
      runwayById(SFO, "1R/19L").lng,
    ]);
    assert.ok(
      Math.abs(oneRight - 700 * 0.3048) < 8,
      `1L/19R to 1R/19L is ${oneRight.toFixed(0)} m, not 213 m`,
    );
    // Sign matters as much as magnitude: 10R is south-south-west of 10L and 1R
    // is east-south-east of 1L. A mirrored pair is a plausible airport in the
    // wrong place.
    assert.ok(tenRight > 0 && oneRight > 0, "both right-hand runways are on the right");
  });

  it("builds the 28 thresholds out onto the mud, and not past it", () => {
    const world = new World(SF_CITY);
    for (const id of ["10L/28R", "10R/28L"] as const) {
      const { high } = runwayThresholds(runwayById(SFO, id));
      assert.equal(world.isLand(high[0], high[1]), true, `${id}'s east threshold is in the bay`);
      // Within 250 m of the traced bay edge. `PENINSULA` was drawn with "SFO's
      // bay edge — the runways are built out onto the mud" on the vertex, and
      // this is the assertion that the runways now honour it rather than
      // stopping half a mile short.
      let nearest = Infinity;
      for (const vertex of PENINSULA) nearest = Math.min(nearest, metresBetween(high, vertex));
      assert.ok(nearest < 900, `${id}'s east threshold is ${nearest.toFixed(0)} m from any shore vertex`);
    }
  });
});

describe("every airport on the Bay Area board sits somewhere legal", () => {
  const world = new World(SF_CITY);

  /** Every authored coordinate an airport puts on the ground. */
  function coordinates(airport: Airport): LatLng[] {
    const out: LatLng[] = [[airport.lat, airport.lng]];
    for (const runway of airport.runways) {
      const { low, high } = runwayThresholds(runway);
      out.push(low, high, [runway.lat, runway.lng]);
    }
    for (const point of airport.field ?? []) out.push(point);
    for (const apron of airport.aprons ?? []) out.push(...apron.polygon);
    for (const taxiway of airport.taxiways ?? []) out.push(...taxiway.path);
    for (const terminal of airport.terminals ?? []) out.push([terminal.lat, terminal.lng]);
    return out;
  }

  for (const airport of AIRPORTS) {
    it(`${airport.id} is entirely on land`, () => {
      for (const [lat, lng] of coordinates(airport)) {
        assert.equal(world.isLand(lat, lng), true, `${airport.id}: ${lat},${lng} is water`);
      }
    });

    it(`${airport.id} has no district or park scattered over it`, () => {
      // `blocks.ts` skips no lot for an airport — it has never heard of one — so
      // the only thing keeping houses off a runway is the district polygon
      // stopping short of it. `millbrae-burlingame` says so in a comment; this
      // is the assertion that makes the comment true, and it covers the whole
      // graded plate rather than only its corners.
      const field = airport.field;
      assert.ok(field, `${airport.id} declares no field outline`);
      for (let lat = 37.34; lat < 37.65; lat += 0.0006) {
        for (let lng = -122.4; lng < -121.9; lng += 0.0006) {
          if (!world.pointInPolygon(lat, lng, field)) continue;
          for (const district of SF_CITY.districts) {
            assert.equal(
              world.pointInPolygon(lat, lng, district.polygon),
              false,
              `${airport.id}: district ${district.id} reaches ${lat.toFixed(4)},${lng.toFixed(4)}`,
            );
          }
          assert.equal(world.inPark(lat, lng), false, `${airport.id}: park over ${lat},${lng}`);
        }
      }
    });
  }

  it("draws exactly one control tower at SFO", () => {
    // The pack's landmark is the tower, because `label: true` is what puts SFO
    // on the minimap and `minimap.ts` reads `city.landmarks`, not the airport.
    const towers = SF_CITY.landmarks.filter((landmark) => /SFO/.test(landmark.name));
    assert.equal(towers.length, 1);
    assert.equal(SFO.tower, undefined, "SFO declares a second tower on top of its landmark");
    // And it stands with the terminals rather than out on the field.
    const tower = towers[0]!;
    let nearest = Infinity;
    for (const terminal of SFO.terminals ?? []) {
      nearest = Math.min(nearest, metresBetween([tower.lat, tower.lng], [terminal.lat, terminal.lng]));
    }
    assert.ok(nearest < 400, `the tower is ${nearest.toFixed(0)} m from the nearest terminal`);
  });
});

describe("the pack stays data, and stays derivable", () => {
  it("keeps SFO's parallel taxiways aligned with the runways they parallel", () => {
    // The coordinates are typed rather than computed, because a city pack must
    // not import three.js. This is what stops them drifting: each one still has
    // to be what `parallelTaxiway` would produce for the runway it belongs to.
    const spec: Array<[string, string, 1 | -1, number, number]> = [
      ["A", "10L/28R", -1, 165, 120],
      ["B", "10R/28L", 1, 165, 120],
      ["F", "1L/19R", -1, 110, 100],
      ["Z", "1R/19L", 1, 165, 100],
    ];
    for (const [id, runwayId, side, offset, trim] of spec) {
      const authored = (SFO.taxiways ?? []).find((taxiway) => taxiway.id === id);
      assert.ok(authored, `SFO has no taxiway ${id}`);
      const derived = parallelTaxiway(runwayById(SFO, runwayId), side, offset, trim).path;
      assert.equal(authored.path.length, derived.length);
      authored.path.forEach((point, index) => {
        const want = derived[index]!;
        // Five decimal places of latitude is about a metre, which is the
        // rounding in the pack and nothing else.
        assert.ok(
          metresBetween(point, want) < 2,
          `taxiway ${id} point ${index} is ${metresBetween(point, want).toFixed(1)} m off`,
        );
      });
    }
  });

  it("survives the JSON round trip a served pack would take", () => {
    // CONTRACT.md §2: a pack hand-written as a module and one arriving over HTTP
    // have to be literally the same thing. An `undefined`-valued key is the way
    // that quietly stops being true, and `City` is posted to the terrain worker
    // as a structured clone besides.
    for (const airport of AIRPORTS) {
      assert.deepEqual(JSON.parse(JSON.stringify(airport)), airport);
      assert.doesNotThrow(() => structuredClone(airport));
    }
  });
});

describe("the airport kit's geometry", () => {
  const world = bayWorld();
  const group = createAirports(world, AIRPORTS);

  /** Every mesh in the group, with its triangle count. */
  function meshes(): Array<{ name: string; triangles: number; mesh: THREE.Mesh }> {
    const out: Array<{ name: string; triangles: number; mesh: THREE.Mesh }> = [];
    group.traverse((object) => {
      const mesh = object as THREE.Mesh & { isMesh?: boolean; isInstancedMesh?: boolean; count?: number };
      if (!mesh.isMesh) return;
      const geometry = mesh.geometry;
      const indices = geometry.index?.count ?? geometry.getAttribute("position").count;
      const instances = mesh.isInstancedMesh ? (mesh.count ?? 1) : 1;
      out.push({ name: mesh.name, triangles: (indices / 3) * instances, mesh });
    });
    return out;
  }

  it("costs a handful of draw calls no matter how many airports a board has", () => {
    // Buckets are shared **across** airports rather than per airport, so the
    // count is a function of how many kinds of surface an airport has and not of
    // how many airports there are. Two fields here; SoCal will have six.
    assert.ok(meshes().length <= 12, `${meshes().length} draw calls for two airports`);
  });

  it("stays far inside its triangle allowance", () => {
    const total = meshes().reduce((sum, entry) => sum + entry.triangles, 0);
    // The allowance for this round was 35,000 on the Bay Area cell. Runways are
    // quads and the only thing here that costs anything is the aircraft on
    // stand, which are worth it.
    assert.ok(total < 6_000, `the Bay Area's airports are ${total} triangles`);
    const runways = meshes().find((entry) => entry.name === "airports:runway");
    assert.ok(runways);
    assert.equal(runways.triangles, AIRPORTS.reduce((n, a) => n + a.runways.length, 0) * 2);
  });

  it("faces every paved surface at the sky", () => {
    // The taxiways were built right-rail-first and every triangle's normal
    // pointed at the ground, so a one-sided material drew nothing at all: no
    // warning, no black stripe, just no taxiways. This is that regression.
    for (const { name, mesh } of meshes()) {
      if (!/field|apron|taxiway|runway|markings/.test(name)) continue;
      const normals = mesh.geometry.getAttribute("normal");
      assert.ok(normals, `${name} has no normals and cannot merge`);
      for (let i = 0; i < normals.count; i += 1) {
        assert.ok(normals.getY(i) > 0.9, `${name} vertex ${i} faces ${normals.getY(i).toFixed(2)}`);
      }
    }
  });

  it("lays the field above the bias terrain.ts gives its own mesh", () => {
    // `terrain.ts` pushes its relief to `world.metres(e) + 0.012`. An airport at
    // `groundAt` plus a hundredth is an airport under the ground.
    const field = meshes().find((entry) => entry.name === "airports:field");
    assert.ok(field);
    field.mesh.geometry.computeBoundingBox();
    const y = field.mesh.geometry.boundingBox!.min.y;
    assert.ok(y > 0.012, `the field plate sits at ${y}, under terrain's own 0.012 bias`);
    // And the paint is above the concrete, which is above the field.
    const order = ["airports:field", "airports:apron", "airports:taxiway", "airports:runway", "airports:markings"];
    let previous = -Infinity;
    for (const name of order) {
      const entry = meshes().find((candidate) => candidate.name === name);
      assert.ok(entry, `${name} was not built`);
      entry.mesh.geometry.computeBoundingBox();
      const min = entry.mesh.geometry.boundingBox!.min.y;
      assert.ok(min > previous, `${name} is not above the layer below it`);
      previous = min;
    }
  });

  it("turns a terminal to its heading rather than mirroring it", () => {
    // `rotateY` wants `atan2(x, z)` of the scene-space along-vector. With the
    // sign of z flipped a building is not rotated but *reflected*, and SFO's
    // horseshoe came out on 153.5° instead of 26.5°. The test builds one
    // terminal on a known bearing and measures the box that comes back.
    const single: Airport = {
      id: "TEST",
      name: "one shed",
      lat: 37.6189,
      lng: -122.375,
      elevation: 0,
      runways: [],
      terminals: [
        // Long and thin on purpose: the two furthest vertices of a box are its
        // diagonal corners, so a stubby shed measures a couple of degrees off
        // its own axis for reasons that have nothing to do with the bug.
        { id: "shed", lat: 37.6189, lng: -122.375, length: 2400, width: 18, height: 4, heading: 26.5 },
      ],
    };
    const built = createAirports(world, [single]);
    const shed = [...built.children].find((child) => child.name === "airports:terminal") as THREE.Mesh;
    assert.ok(shed, "no terminal was built");
    const positions = shed.geometry.getAttribute("position");
    // The two vertices furthest apart lie on the long axis, so the bearing
    // between them is the building's.
    let best = -1;
    let a = new THREE.Vector3();
    let b = new THREE.Vector3();
    const p = new THREE.Vector3();
    const q = new THREE.Vector3();
    for (let i = 0; i < positions.count; i += 1) {
      p.fromBufferAttribute(positions, i);
      for (let j = i + 1; j < positions.count; j += 1) {
        q.fromBufferAttribute(positions, j);
        const d = p.distanceTo(q);
        if (d > best) {
          best = d;
          a = p.clone();
          b = q.clone();
        }
      }
    }
    // Scene space runs x east and z south, so north is −z.
    const along = b.clone().sub(a);
    const measured = ((Math.atan2(along.x, -along.z) / DEG) + 360) % 360;
    // The long axis is a line, not a ray, so either end is correct.
    const error = Math.min(Math.abs(measured - 26.5), Math.abs(measured - 206.5));
    // The mirror this catches is 127° wrong, so a degree of slack for the
    // diagonal costs the test nothing.
    assert.ok(error < 1.5, `the shed was built on ${measured.toFixed(1)}° rather than 26.5°`);
  });

  it("parks aircraft against the terminals that declared gates", () => {
    const stands = meshes().find((entry) => entry.name === "airports:stands");
    assert.ok(stands, "nothing is parked at either airport");
    const expected = AIRPORTS.flatMap((airport) => airport.terminals ?? []).reduce(
      (sum, terminal) => sum + (terminal.gates?.count ?? 0),
      0,
    );
    const instanced = stands.mesh as THREE.InstancedMesh;
    assert.equal(instanced.count, expected);
    // Each one within a wingspan or two of the building it belongs to, which is
    // the check that caught the mirrored terminals: the stands were exactly
    // where they should be and the buildings were not.
    const position = new THREE.Vector3();
    const matrix = new THREE.Matrix4();
    for (let i = 0; i < instanced.count; i += 1) {
      instanced.getMatrixAt(i, matrix);
      position.setFromMatrixPosition(matrix);
      let nearest = Infinity;
      for (const airport of AIRPORTS) {
        for (const terminal of airport.terminals ?? []) {
          if (!terminal.gates) continue;
          const [x, z] = world.project(terminal.lat, terminal.lng);
          nearest = Math.min(nearest, Math.hypot(position.x - x, position.z - z));
        }
      }
      // Half the longest terminal plus the stand depth, in scene units.
      assert.ok(nearest < 4.2, `stand ${i} is ${nearest.toFixed(2)} units from any terminal`);
    }
  });

  it("draws nothing at all for a board with no airports", () => {
    const empty = createAirports(world, []);
    assert.equal(empty.children.length, 0);
    assert.equal(empty.name, "airports");
  });

  it("is unbothered by a Node run with no canvas", () => {
    // `markingsAtlas` needs a 2D context and there is none here. The paint has
    // to fall back to a flat colour rather than throwing, because these tests
    // and the office's server-side pack checks both run without a DOM.
    const markings = meshes().find((entry) => entry.name === "airports:markings");
    assert.ok(markings);
    const material = markings.mesh.material as THREE.MeshBasicMaterial;
    assert.equal(material.map, null);
    assert.equal(material.toneMapped, false);
  });
});

describe("SJC", () => {
  it("puts both parallels on one bearing 700 ft apart", () => {
    assert.equal(SJC.runways.length, 2);
    for (const runway of SJC.runways) assert.equal(runway.heading, 131.5);
    const separation = offsetFromCentreline(SJC.runways[0]!, [
      SJC.runways[1]!.lat,
      SJC.runways[1]!.lng,
    ]);
    assert.ok(Math.abs(separation - 700 * 0.3048) < 8, `SJC's parallels are ${separation.toFixed(0)} m apart`);
  });

  it("keeps its plate off the freeway this pack draws beside it", () => {
    // `BAYSHORE_101` passes about 240 m north-east of runway 12L here, so the
    // graded plate runs further to the south-west than to the north-east. A
    // symmetric one had US-101 drawn across the middle of the airport.
    const world = new World(SF_CITY);
    for (const [lat, lng] of SF_CITY.roads.flatMap((road) => road.path)) {
      if (lat < 37.34 || lat > 37.38) continue;
      assert.equal(
        world.pointInPolygon(lat, lng, SJC.field!),
        false,
        `a road vertex at ${lat},${lng} is inside SJC's plate`,
      );
    }
  });
});
