/**
 * The Southland fields, and the terrain they stand on.
 *
 * Every defect this file guards typechecked, threw nothing and cost nothing in
 * the performance budget. Each was found by rendering the board and looking at
 * it, and each assertion is the cheapest arithmetic statement of what the
 * picture showed:
 *
 *   1. **Burbank on a hillside.** `airports.ts` grades a field to the highest
 *      ground it covers, which is what grading is. The pack's `Mount Thom` sat
 *      at 34.205/−118.33 with a 4.4 km falloff — half a kilometre north of
 *      runway 08/26 — and put 478 m of mountain on the 26 threshold against
 *      191 m on the 08 threshold. The plate graded to the high end and Hollywood
 *      Burbank rendered as a green table floating over its own city, with a
 *      shadow under the south fence. Photographed before and after.
 *   2. **Long Beach on Signal Hill.** Same failure, smaller: a field rectangle
 *      reaching −118.169 caught the flank of a real 111 m hill 1.9 km away and
 *      lifted the whole plate a hundred metres.
 *   3. **Houses on every runway.** `blocks.ts` has never heard of an airport.
 *      All six of these sit inside a district polygon, and the only thing
 *      keeping tract housing off a runway is the polygon stopping short — a
 *      notch for four of them, a hole reached by a corridor for Long Beach and
 *      Ontario. This sweeps the whole of every field rather than its corners,
 *      because checking the corners is exactly what lets a subdivision land in
 *      the middle of one.
 *   4. **Runways on the painted numbers.** The board used to carry LAX as two
 *      hand-typed roads lying due east–west. LAX's runways are on 82.9° true;
 *      due east–west is seven degrees and four hundred metres out, and it is the
 *      kind of wrong that anybody who has flown into LAX sees at once.
 *
 * 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 SOCAL_CITY, { AIRPORTS, LAX, BUR, VNY, LGB, SNA, ONT, ROADS } from "../../cities/socal.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;

/**
 * Magnetic declination over the Los Angeles basin, degrees east.
 *
 * The number that turns a painted designator into a true bearing, and the
 * reason none of the headings below is a multiple of ten.
 */
const DECLINATION = 11.8;

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);
}

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);
  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;
}

describe("LAX — the numbers a picture cannot check", () => {
  it("lays all four parallels on one true bearing, not on their painted ones", () => {
    // A designator is magnetic and rounded to ten degrees. 82.9 true minus the
    // basin's 11.8° east declination is 71.1 magnetic, which rounds to "07" —
    // and the south pair is 07L/25R and 07R/25L. The FAA does not allow four
    // parallels to share a number, so the north pair takes the next one down and
    // is called 06/24 while lying on exactly the same bearing. Any reading that
    // makes the 24s a different heading from the 25s is wrong about LAX.
    for (const runway of LAX.runways) assert.equal(runway.heading, 82.9);
    const magnetic = 82.9 - DECLINATION;
    assert.ok(Math.abs(magnetic - 70) < 5, `82.9 true is ${magnetic.toFixed(1)} magnetic`);
  });

  it("holds each runway to its published length", () => {
    for (const [id, feet] of [
      ["06L/24R", 8_926],
      ["06R/24L", 10_285],
      ["07L/25R", 12_091],
      ["07R/25L", 11_095],
    ] as const) {
      const runway = runwayById(LAX, id);
      const metres = feet * 0.3048;
      assert.ok(
        Math.abs(runway.length - metres) < 12,
        `${id} is ${runway.length} m against ${metres.toFixed(0)} m`,
      );
    }
  });

  it("keeps the two complexes their real distance apart, and on the right sides", () => {
    // 700 ft inside the north complex, 800 ft inside the south, and about
    // 1,250 m of airport between 24L and 25R — which is what the horseshoe fills.
    const north = offsetFromCentreline(runwayById(LAX, "06L/24R"), [
      runwayById(LAX, "06R/24L").lat,
      runwayById(LAX, "06R/24L").lng,
    ]);
    assert.ok(Math.abs(north - 700 * 0.3048) < 8, `06L to 06R is ${north.toFixed(0)} m`);
    const south = offsetFromCentreline(runwayById(LAX, "07L/25R"), [
      runwayById(LAX, "07R/25L").lat,
      runwayById(LAX, "07R/25L").lng,
    ]);
    assert.ok(Math.abs(south - 800 * 0.3048) < 8, `07L to 07R is ${south.toFixed(0)} m`);
    // Sign matters as much as magnitude: right of the 07 direction is south, and
    // every "R" runway at LAX is the southern one of its pair. A mirrored
    // complex is a plausible airport in the wrong place.
    assert.ok(north > 0 && south > 0, "both right-hand runways are on the right");
    const between = offsetFromCentreline(runwayById(LAX, "06R/24L"), [
      runwayById(LAX, "07L/25R").lat,
      runwayById(LAX, "07L/25R").lng,
    ]);
    assert.ok(
      Math.abs(between - 1250) < 60,
      `the terminal gap is ${between.toFixed(0)} m, not ~1,250`,
    );
  });

  it("puts the horseshoe between the complexes with its stands on the outside", () => {
    const arms = ["north-arm", "south-arm"] as const;
    for (const id of arms) {
      const terminal = (LAX.terminals ?? []).find((t) => t.id === id);
      assert.ok(terminal, `LAX has no ${id}`);
      assert.ok(terminal.gates, `${id} has no stands, which is what makes it an airport`);
      const across = offsetFromCentreline(runwayById(LAX, "06R/24L"), [terminal.lat, terminal.lng]);
      assert.ok(across > 0 && across < 1250, `${id} is not between the complexes`);
    }
    // North arm's stands face north, south arm's face south: away from the
    // court, which holds roadway and cars and nothing that needs a wingspan.
    assert.equal((LAX.terminals ?? []).find((t) => t.id === "north-arm")?.gates?.side, -1);
    assert.equal((LAX.terminals ?? []).find((t) => t.id === "south-arm")?.gates?.side, 1);
  });
});

describe("every field's bearings agree with its designators", () => {
  for (const airport of AIRPORTS) {
    it(`${airport.id} is on true bearings, not painted ones`, () => {
      for (const runway of airport.runways) {
        const designator = runway.designators?.[0];
        assert.ok(designator, `${airport.id} ${runway.id} has no designators`);
        const painted = Number.parseInt(designator, 10) * 10;
        // LAX's north complex is the documented exception, and it is a rule
        // rather than an error: the FAA does not let four parallel runways share
        // a number, so the pair that is not on the magnetic figure takes the next
        // one down. 06L/24R and 06R/24L lie on exactly the 07s' bearing.
        const renumbered = airport === LAX && designator.startsWith("06") ? 10 : 0;
        const magnetic = runway.heading - DECLINATION;
        // Five degrees is the rounding a designator already carries; anything
        // outside it is a runway pointing somewhere else.
        assert.ok(
          Math.abs(((magnetic - painted - renumbered + 540) % 360) - 180) < 5,
          `${airport.id} ${runway.id}: ${runway.heading}° true is ${magnetic.toFixed(1)}° magnetic, ` +
            `which is not "${designator}"`,
        );
        // And it is never a round number, which is the mistake this catches:
        // building from the painted figure lays the field a declination out.
        assert.notEqual(runway.heading % 10, 0, `${airport.id} ${runway.id} is on a magnetic heading`);
      }
    });

    it(`${airport.id}'s thresholds agree with its declared lengths`, () => {
      for (const runway of airport.runways) {
        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);
      }
    });
  }
});

describe("the pack stays data, and stays derivable", () => {
  it("keeps every parallel taxiway aligned with the runway it parallels", () => {
    // 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.
    const spec: Array<[Airport, string, string, 1 | -1, number, number]> = [
      [LAX, "B", "06L/24R", -1, 150, 90],
      [LAX, "C", "06R/24L", 1, 140, 90],
      [LAX, "D", "07L/25R", -1, 140, 90],
      [LAX, "E", "07R/25L", 1, 150, 90],
      [BUR, "A", "15/33", -1, 140, 90],
      [BUR, "C", "08/26", 1, 130, 80],
      [VNY, "A", "16R/34L", -1, 140, 80],
      [LGB, "D", "12/30", 1, 140, 100],
      [LGB, "B", "08L/26R", -1, 130, 80],
      [SNA, "A", "02L/20R", 1, 140, 80],
      [ONT, "A", "08L/26R", 1, 140, 100],
      [ONT, "B", "08R/26L", -1, 140, 100],
    ];
    for (const [airport, id, runwayId, side, offset, trim] of spec) {
      const authored = (airport.taxiways ?? []).find((taxiway) => taxiway.id === id);
      assert.ok(authored, `${airport.id} has no taxiway ${id}`);
      const derived = parallelTaxiway(runwayById(airport, runwayId), side, offset, trim).path;
      assert.equal(authored.path.length, derived.length);
      authored.path.forEach((point, index) => {
        const want = derived[index]!;
        assert.ok(
          metresBetween(point, want) < 2,
          `${airport.id} 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.
    for (const airport of AIRPORTS) {
      assert.deepEqual(JSON.parse(JSON.stringify(airport)), airport);
      assert.doesNotThrow(() => structuredClone(airport));
    }
  });

  it("no longer draws a runway as a road, and no road crosses a field", () => {
    // Two hand-typed strips on 33.9535 and 33.9405 due east-west used to stand in
    // for LAX. A road ribbon drapes 0.14 units above the terrain and a runway
    // quad lies flush on it, so a board carrying both floats a dark stripe over
    // every runway. The sweep is every hundred metres along every road rather
    // than every vertex, because a freeway with one vertex either side of an
    // airport still has tarmac drawn across it — which is how I-405 was found
    // running over the 25R touchdown zone.
    const world = new World(SOCAL_CITY);
    for (const road of ROADS) {
      for (let index = 1; index < road.path.length; index += 1) {
        const from = road.path[index - 1]!;
        const to = road.path[index]!;
        const steps = Math.max(1, Math.ceil(metresBetween(from, to) / 100));
        for (let step = 0; step <= steps; step += 1) {
          const lat = from[0] + (to[0] - from[0]) * (step / steps);
          const lng = from[1] + (to[1] - from[1]) * (step / steps);
          for (const airport of AIRPORTS) {
            assert.equal(
              world.pointInPolygon(lat, lng, airport.field!), false,
              `a road runs across ${airport.id} at ${lat.toFixed(4)},${lng.toFixed(4)}`,
            );
          }
        }
      }
    }
  });
});

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

  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 on land, on the board, and inside its own fence`, () => {
      const field = airport.field;
      assert.ok(field, `${airport.id} declares no field outline`);
      for (const point of coordinates(airport)) {
        assert.equal(world.isLand(point[0], point[1]), true, `${airport.id}: ${point} is water`);
      }
      const bounds = SOCAL_CITY.bounds;
      assert.ok(
        airport.lat > bounds.minLat && airport.lat < bounds.maxLat &&
        airport.lng > bounds.minLng && airport.lng < bounds.maxLng,
        `${airport.id} is off the board`,
      );
      // Everything the kit draws has to be inside the graded plate, or it is
      // drawn at plate height over ground that is somewhere else — and a plate
      // graded from an apron corner that hangs off the field is graded from
      // ground the field does not cover. Van Nuys's east ramp did exactly that.
      const inside: LatLng[] = [];
      for (const runway of airport.runways) {
        const { low, high } = runwayThresholds(runway);
        inside.push(low, high);
      }
      for (const apron of airport.aprons ?? []) inside.push(...apron.polygon);
      for (const taxiway of airport.taxiways ?? []) inside.push(...taxiway.path);
      for (const terminal of airport.terminals ?? []) inside.push([terminal.lat, terminal.lng]);
      for (const point of inside) {
        assert.equal(
          world.pointInPolygon(point[0], point[1], field), true,
          `${airport.id}: ${point[0].toFixed(5)},${point[1].toFixed(5)} is outside the field`,
        );
      }
    });

    it(`${airport.id} has no district or park scattered over it`, () => {
      const field = airport.field!;
      const lats = field.map((point) => point[0]);
      const lngs = field.map((point) => point[1]);
      for (let lat = Math.min(...lats); lat <= Math.max(...lats); lat += 0.0004) {
        for (let lng = Math.min(...lngs); lng <= Math.max(...lngs); lng += 0.0004) {
          if (!world.pointInPolygon(lat, lng, field)) continue;
          for (const district of SOCAL_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 LAX", () => {
    // The pack's landmark is the tower, because `label: true` is what puts LAX
    // on the minimap and `minimap.ts` reads `city.landmarks`, not `city.airports`.
    const towers = SOCAL_CITY.landmarks.filter((landmark) => /LAX Control Tower/.test(landmark.name));
    assert.equal(towers.length, 1);
    assert.equal(LAX.tower, undefined, "LAX declares a second tower on top of its landmark");
    for (const airport of AIRPORTS) assert.equal(airport.tower, undefined);
    const tower = towers[0]!;
    let nearest = Infinity;
    for (const terminal of LAX.terminals ?? []) {
      nearest = Math.min(nearest, metresBetween([tower.lat, tower.lng], [terminal.lat, terminal.lng]));
    }
    assert.ok(nearest < 500, `the tower is ${nearest.toFixed(0)} m from the nearest terminal`);
    // And the flat two-kilometre pad that used to stand in for the airport is
    // gone, or it would be drawn on top of its own field.
    assert.equal(SOCAL_CITY.landmarks.some((landmark) => landmark.name === "LAX"), false);
  });
});

describe("no field is graded onto a hillside", () => {
  /**
   * The Burbank guard, and the reason `Mount Thom` moved.
   *
   * `airports.ts` lays the whole plate at the highest ground the airport
   * covers, so a field that reaches onto rising ground stands proud of its own
   * city by the difference. Ten scene units of relief here is 1.15 km; the bound
   * below is 0.6 units, about 70 real metres at this board's 3.4× exaggeration,
   * which is a graded platform rather than a table.
   */
  const world = new World(SOCAL_CITY);
  const LIMIT = 0.6;
  for (const airport of AIRPORTS) {
    it(`${airport.id} covers less than ${LIMIT} units of relief`, () => {
      const field = airport.field!;
      const lats = field.map((point) => point[0]);
      const lngs = field.map((point) => point[1]);
      let low = Infinity;
      let high = -Infinity;
      for (let lat = Math.min(...lats); lat <= Math.max(...lats); lat += 0.0004) {
        for (let lng = Math.min(...lngs); lng <= Math.max(...lngs); lng += 0.0004) {
          if (!world.pointInPolygon(lat, lng, field)) continue;
          const ground = world.groundAt(lat, lng);
          if (ground < low) low = ground;
          if (ground > high) high = ground;
        }
      }
      assert.ok(
        high - low < LIMIT,
        `${airport.id} spans ${(high - low).toFixed(3)} units of ground; its plate would float`,
      );
    });
  }
});

describe("what the six airports cost", () => {
  /**
   * The SoCal board's real projection with flat ground. A 1:1 fake world would
   * hide every scale mistake in the kit, and flat ground is what makes the
   * triangle count reproducible.
   */
  function socalWorld(): World {
    const world = new World(SOCAL_CITY);
    return Object.assign(Object.create(Object.getPrototypeOf(world) as object), world, {
      groundAt: () => 0,
      elevationSampled: () => 0,
    }) as World;
  }

  it("draws all six for a few thousand triangles in a handful of draw calls", () => {
    const group = createAirports(socalWorld(), AIRPORTS);
    let triangles = 0;
    let draws = 0;
    group.traverse((object) => {
      const mesh = object as THREE.Mesh & { isMesh?: boolean; isInstancedMesh?: boolean; count?: number };
      if (!mesh.isMesh) return;
      const indices = mesh.geometry.index?.count ?? mesh.geometry.getAttribute("position").count;
      const instances = mesh.isInstancedMesh ? (mesh.count ?? 1) : 1;
      triangles += (indices / 3) * instances;
      draws += 1;
    });
    // Measured at 6,428 triangles in 8 draw calls. The allowance for this
    // workstream was 35,000 on the SoCal cell; the headroom is not an invitation,
    // and a change that doubles this is a change worth arguing for.
    assert.ok(triangles < 12_000, `the Southland fields cost ${triangles} triangles`);
    assert.ok(draws <= 12, `the Southland fields cost ${draws} draw calls`);
    // Most of that is aeroplanes on stand, which is the single biggest thing
    // making an airport read as an airport rather than as a car park.
    const stands = group.getObjectByName("airports:stands") as THREE.InstancedMesh | undefined;
    assert.ok(stands, "nothing is parked at any gate on this board");
  });
});
