/**
 * The sky moving between snapshots, which is the difference between a live map
 * and a photograph of one.
 *
 * ## The defect
 *
 * `WireAircraft` carried a position and nothing else: no ground speed, no
 * vertical rate. So `createFlightLayer` could only interpolate between the last
 * two observations it had been handed — every aircraft replayed a leg it had
 * already flown, arrived at the newest known point, and then **sat perfectly
 * still** for the five to fifteen seconds until the next one landed. Nobody
 * could catch it, because the layer rendered correctly the whole time: a still
 * frame of a stuck sky and a still frame of a flying one are the same picture,
 * and every existing assertion in the suite passed on the broken build.
 *
 * It was found by taking two screenshots eight seconds apart with a **frozen**
 * `/api/v1/flights` body — the shape of every live deployment between server
 * cache refreshes — and measuring the aircraft: pixel-identical. The first test
 * below is that experiment, with the pixels replaced by `mesh.position`.
 *
 * ## What is asserted, and what is deliberately not
 *
 * Everything here is observed through the scene graph or through an exported
 * pure function, the same rule `flights.test.ts` set. `reckonForward` is the
 * arithmetic — heading conventions, the cosine on longitude, the clamps — and is
 * checked in degrees, where a sign error is legible. The layer is checked
 * through `mesh.position`, because "does the aeroplane move" is a question about
 * where it is drawn.
 *
 * The clock is `performance.now`, replaced with a counter for the file exactly
 * as `flights.test.ts` does it: the intervals under test are tens of seconds
 * long and the layer reads the clock fresh on every call.
 */

import assert from "node:assert/strict";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { after, before, describe, it } from "node:test";
import * as THREE from "three";
import { readDump1090 } from "../../../server/src/flights/adsb.ts";
import {
  aircraftDetail,
  createFlightLayer,
  reckonForward,
  sampleRoute,
  type FlightLayer,
} from "../../engine/flights.ts";
import type { Aircraft } from "../../engine/types.ts";

// ---- The clock -------------------------------------------------------------

let clockMs = 0;
const realNow = performance.now;
before(() => {
  performance.now = () => clockMs;
});
after(() => {
  performance.now = realNow;
});
function at(seconds: number): void {
  clockMs = seconds * 1000;
}

// ---- The board -------------------------------------------------------------

/** Metres in a degree of latitude, restated rather than imported. See the header. */
const METRES_PER_DEGREE_LAT = 111_320;

/**
 * A flat stand-in for `World` — the layer's whole contact with one is `project`
 * and `metres`, and a real heightfield is half a million samples of nothing to
 * do with any of it. `skyTraffic.test.ts` uses the same trick.
 *
 * **Unlike that one, this projection is geodetically honest**, and it has to be:
 * every assertion below is a distance, and the dead-reckoner converts metres to
 * degrees with the real 111,320 m per degree. A stand-in that put a round 1,000
 * units on a degree of latitude would make one scene unit 111.32 m rather than
 * the 100 it claimed, and every expected distance here would be 10% out — which
 * looks exactly like a broken reckoner and is not one. So the scales are
 * derived from the same constant the code uses, and the longitude axis is
 * squashed by the cosine of the reference latitude the way `World` does it.
 *
 * One scene unit is therefore 100 m, and every distance below reads as metres
 * divided by a hundred.
 */
const METRES_PER_UNIT = 100;
const REFERENCE_LAT = 37.6;
const UNITS_PER_DEGREE_LAT = METRES_PER_DEGREE_LAT / METRES_PER_UNIT;
const UNITS_PER_DEGREE_LNG = UNITS_PER_DEGREE_LAT * Math.cos((REFERENCE_LAT * Math.PI) / 180);
const flatWorld = {
  project: (lat: number, lng: number) => [lng * UNITS_PER_DEGREE_LNG, -lat * UNITS_PER_DEGREE_LAT],
  metres: (m: number) => m / METRES_PER_UNIT,
  metresPerUnit: METRES_PER_UNIT,
} as unknown as Parameters<typeof createFlightLayer>[0];

function meshOf(layer: FlightLayer): THREE.Mesh {
  const mesh = layer.group.children.find((c): c is THREE.Mesh => c.type === "Mesh");
  assert.ok(mesh, "the layer has no aircraft mesh");
  return mesh;
}

/** Ground distance between two scene positions, in scene units. */
function apart(a: THREE.Vector3, b: THREE.Vector3): number {
  return Math.hypot(a.x - b.x, a.z - b.z);
}

/** An airliner heading due north at 250 m/s, level, over the flat board. */
function jet(over: Partial<Aircraft> = {}): Aircraft {
  return {
    id: "ual505",
    callsign: "UAL505",
    lat: 37.6,
    lng: -122.4,
    altitude: 9000,
    heading: 0,
    groundSpeed: 250,
    verticalRate: 0,
    ...over,
  };
}

// ---- The arithmetic --------------------------------------------------------

describe("reckonForward", () => {
  const state = { lat: 37.6, lng: -122.4, altitude: 9000, heading: 0, speed: 250, climb: 0 };

  it("flies north on heading 000 and changes no longitude", () => {
    const after10 = reckonForward(state, 10);
    assert.equal(after10.lng, state.lng, "a northbound aircraft drifted east or west");
    const metres = (after10.lat - state.lat) * METRES_PER_DEGREE_LAT;
    assert.ok(Math.abs(metres - 2500) < 1, `flew ${metres.toFixed(1)} m in ten seconds, not 2500`);
  });

  /**
   * The cosine on longitude, which is the one term that can be silently
   * omitted: leave it out and every eastbound aircraft over California flies at
   * 79% of its reported speed, uniformly, in a direction nobody is measuring.
   */
  it("covers more degrees of longitude than of latitude for the same speed", () => {
    const east = reckonForward({ ...state, heading: 90 }, 10);
    assert.ok(Math.abs(east.lat - state.lat) < 1e-9, "an eastbound aircraft drifted north");
    const degrees = east.lng - state.lng;
    const expected = 2500 / (METRES_PER_DEGREE_LAT * Math.cos((37.6 * Math.PI) / 180));
    assert.ok(
      Math.abs(degrees - expected) / expected < 1e-6,
      `${degrees} degrees of longitude, expected ${expected}`,
    );
    // And the ground distance is the same as the northbound leg's, which is the
    // property the cosine exists to preserve.
    const north = reckonForward(state, 10);
    const northM = (north.lat - state.lat) * METRES_PER_DEGREE_LAT;
    const eastM = degrees * METRES_PER_DEGREE_LAT * Math.cos((37.6 * Math.PI) / 180);
    assert.ok(Math.abs(northM - eastM) < 0.01, "north and east legs are not the same length");
  });

  it("turns clockwise: 090 is east and 270 is west", () => {
    assert.ok(reckonForward({ ...state, heading: 90 }, 10).lng > state.lng, "090 went west");
    assert.ok(reckonForward({ ...state, heading: 270 }, 10).lng < state.lng, "270 went east");
    assert.ok(reckonForward({ ...state, heading: 180 }, 10).lat < state.lat, "180 went north");
  });

  it("climbs and descends at the stated rate, and never through the ground", () => {
    assert.equal(reckonForward({ ...state, climb: 5 }, 10).altitude, 9050);
    const dived = reckonForward({ ...state, altitude: 300, climb: -20 }, 60);
    assert.equal(dived.altitude, 0, "an aircraft was reckoned below the terrain");
  });

  /**
   * The propagation is clamped at a minute, because a position and a velocity
   * describe the next few seconds well and the next ten minutes not at all — an
   * airliner turns, descends and lands, and none of that is in the two numbers
   * this had to work from. Past the clamp it holds station rather than flying
   * off the board on data the rest of the file has already stopped believing.
   */
  it("stops reckoning after a minute rather than flying forever", () => {
    const minute = reckonForward(state, 60);
    const hour = reckonForward(state, 3600);
    assert.deepEqual(hour, minute, "an aircraft kept flying on a fix an hour old");
  });

  it("does not run backwards when a clock does", () => {
    assert.deepEqual(reckonForward(state, -30), {
      lat: state.lat,
      lng: state.lng,
      altitude: state.altitude,
    });
  });
});

// ---- The regression itself -------------------------------------------------

/**
 * A frozen live body, polled at 1 Hz, which is what every deployment serves
 * between cache refreshes. The layer skips a repeated position without
 * recording it — see `flights.test.ts` — so this is precisely the state in
 * which the old code had nothing left to interpolate and stood still.
 */
describe("a frozen snapshot of a moving aircraft", () => {
  function hold(layer: FlightLayer, a: Aircraft, from: number, until: number) {
    for (let t = from; t < until; t += 1) {
      at(t);
      layer.update([a]);
    }
  }

  it("keeps flying between refreshes instead of standing still", () => {
    const layer = createFlightLayer(flatWorld);
    at(0);
    layer.update([jet()]);
    const mesh = meshOf(layer);
    const start = mesh.position.clone();

    hold(layer, jet(), 1, 10);
    at(10);
    layer.tick();

    // Ten seconds at 250 m/s is 2,500 m, which on this board is 25 units.
    const flown = apart(mesh.position, start);
    assert.ok(
      Math.abs(flown - 25) < 0.5,
      `the aircraft covered ${flown.toFixed(2)} units in ten seconds, not 25`,
    );
    // North is -z on every board in this repo.
    assert.ok(mesh.position.z < start.z, "a northbound aircraft flew south");
  });

  /**
   * The other half of the claim, and the reason the fields are optional: a
   * source that does not say how fast something is going gets the old
   * behaviour, exactly. Guessing a speed for a ground vehicle or a
   * position-only TIS-B target would be inventing motion, which is a worse lie
   * than showing none — and it is the behaviour `flights.test.ts` pins.
   */
  it("still stands still when the source reported no speed", () => {
    const layer = createFlightLayer(flatWorld);
    const parked: Aircraft = { ...jet(), groundSpeed: undefined, verticalRate: undefined };
    at(0);
    layer.update([parked]);
    const mesh = meshOf(layer);
    const start = mesh.position.clone();
    hold(layer, parked, 1, 10);
    at(10);
    layer.tick();
    assert.equal(apart(mesh.position, start), 0, "an aircraft with no reported speed moved");
  });

  /** A ground vehicle reports `gs: 0.0`, and zero is a fact, not a measurement gap. */
  it("holds a target whose reported speed is zero", () => {
    const layer = createFlightLayer(flatWorld);
    const still = jet({ groundSpeed: 0, altitude: 0 });
    at(0);
    layer.update([still]);
    const mesh = meshOf(layer);
    const start = mesh.position.clone();
    at(20);
    layer.tick();
    assert.equal(apart(mesh.position, start), 0, "a parked aircraft taxied on its own");
  });

  it("climbs between refreshes as well as advancing", () => {
    const layer = createFlightLayer(flatWorld);
    const climbing = jet({ altitude: 1000, verticalRate: 10 });
    at(0);
    layer.update([climbing]);
    const mesh = meshOf(layer);
    const start = mesh.position.y;
    at(10);
    layer.tick();
    // 10 m/s for 10 s is 100 m, which `flatWorld.metres` puts at one unit.
    assert.ok(
      Math.abs(mesh.position.y - start - 1) < 0.02,
      `climbed ${(mesh.position.y - start).toFixed(3)} units, not 1`,
    );
  });
});

/**
 * The trail buffer is preallocated for `MAX_TRACKS × TRAIL_POINTS` segments,
 * and a reckoned track draws **one more segment than an interpolated one** —
 * its newest observation is history rather than a destination, so the spine
 * runs through it and on to the head. That is one extra vertex pair per track,
 * which a sky at the ceiling would run off the end of.
 *
 * `rebuildTrails` already clamps rather than overflowing — a crowded sky draws
 * shorter trails, each still joined to its dart — but nothing asserted it for
 * the longer spine, and running off a `Float32Array` is silent: the writes go
 * nowhere and the draw range says everything is fine.
 */
it("keeps a crowded reckoned sky inside the preallocated trail buffer", () => {
  const layer = createFlightLayer(flatWorld);
  const line = layer.group.getObjectByName("flight-trails") as THREE.LineSegments;
  const capacity = (line.geometry.attributes.position as THREE.BufferAttribute).count;
  const flock = (step: number) =>
    Array.from({ length: 220 }, (_, i) =>
      jet({
        id: `ac${i}`,
        lat: 37.4 + (i % 20) * 0.01 + step,
        lng: -122.6 + Math.floor(i / 20) * 0.01,
      }),
    );
  for (let poll = 0; poll < 90; poll += 1) {
    at(poll * 2);
    layer.update(flock(poll * 0.002));
  }
  assert.ok(
    line.geometry.drawRange.count <= capacity,
    `${line.geometry.drawRange.count} vertices drawn from a buffer of ${capacity}`,
  );
  assert.ok(line.geometry.drawRange.count > 0, "a full sky drew no trails at all");
});

// ---- Landing on the truth --------------------------------------------------

describe("a fresh observation arriving", () => {
  /**
   * The correction must be a slide and not a jump, and this is the assertion
   * that says which. The reckoner is always a little wrong — the aircraft
   * banked, the wind changed, the fix was already stale — so an observation
   * lands with the drawn aeroplane a few hundred metres from where the feed
   * says it is. Snapping is a visible twitch on every aircraft on every
   * refresh; over a live feed that is one flinch every five to fifteen seconds,
   * forever.
   */
  it("eases onto a position that disagrees with the reckoning", () => {
    const layer = createFlightLayer(flatWorld);
    at(0);
    layer.update([jet()]);
    const mesh = meshOf(layer);

    // Ten seconds of flying, and then a fix 500 m east of the reckoned track —
    // the aircraft was drifting, or the receiver was.
    at(10);
    const drifted = jet({
      lat: 37.6 + 2500 / METRES_PER_DEGREE_LAT,
      lng: -122.4 + 500 / (METRES_PER_DEGREE_LAT * Math.cos((37.6 * Math.PI) / 180)),
    });
    layer.update([drifted]);
    const truthX = flatWorld.project(drifted.lat, drifted.lng)[0];

    const missBefore = Math.abs(mesh.position.x - truthX);
    assert.ok(missBefore > 1, `the aircraft snapped onto the fix (${missBefore.toFixed(2)} units)`);

    // …and closes on it without another observation, which is what "slide"
    // means. Three seconds is one time constant, so most of it is gone.
    at(13);
    layer.tick();
    const missAfter = Math.abs(mesh.position.x - truthX);
    assert.ok(missAfter < missBefore * 0.5, "the error was carried rather than corrected");

    at(25);
    layer.tick();
    assert.ok(
      Math.abs(mesh.position.x - truthX) < 0.05,
      "the correction never finished; the track is permanently offset",
    );
  });

  /**
   * A fix is already stale when it arrives — the receiver's last message, plus
   * the server's cache TTL, plus the browser's hold. Adopting one as though it
   * described this instant draws the whole sky that far behind, uniformly,
   * which is the sort of error nobody notices because everything is wrong
   * together.
   */
  it("advances a stale fix to the present before adopting it", () => {
    const fresh = createFlightLayer(flatWorld);
    const stale = createFlightLayer(flatWorld);
    at(0);
    fresh.update([jet()]);
    stale.update([jet({ ageSeconds: 8 })]);
    at(0);
    fresh.tick();
    stale.tick();

    const ahead = meshOf(fresh).position.z - meshOf(stale).position.z;
    // Eight seconds at 250 m/s is 2,000 m: 20 units, northbound, so -z.
    assert.ok(
      Math.abs(ahead - 20) < 0.2,
      `the stale fix was placed ${ahead.toFixed(2)} units ahead, not 20`,
    );
  });

  /**
   * The teleport guard has to survive all of this. A simulated route reaching
   * the end of its leg reappears at the start, several hundred units away, and
   * `flights.ts` documents at length what happens when that is mistaken for
   * flying. It must not be eased onto either: a three-second slide across the
   * board with a trail attached is worse than the jump it replaced.
   */
  it("still jumps rather than sliding when a route wraps", () => {
    const layer = createFlightLayer(flatWorld);
    at(0);
    layer.update([jet()]);
    at(10);
    layer.update([jet({ lat: 37.6 + 2500 / METRES_PER_DEGREE_LAT })]);
    at(20);
    // Half a degree of latitude in one refresh: 556 km, i.e. a wrap.
    const wrapped = jet({ lat: 37.1 });
    layer.update([wrapped]);
    at(20);
    layer.tick();

    const mesh = meshOf(layer);
    const [x, z] = flatWorld.project(wrapped.lat, wrapped.lng);
    assert.ok(
      apart(mesh.position, new THREE.Vector3(x, mesh.position.y, z)) < 0.01,
      "a wrapped route was eased across the board instead of restarting",
    );
  });
});

// ---- The simulator behaves the same way ------------------------------------

describe("the bundled simulator", () => {
  /**
   * A keyless clone and a live deployment must move through the same code, or
   * only one of them is ever looked at — which is the arrangement that let the
   * live sky sit still for as long as it did. `sampleRoute` therefore reports a
   * velocity, derived from the leg rather than invented.
   */
  it("reports a ground speed consistent with its own leg", () => {
    const route = {
      callsign: "NIMBUS 4",
      from: [37.95, -122.36] as [number, number],
      to: [37.66, -122.4] as [number, number],
      fromAlt: 2400,
      toAlt: 500,
      duration: 190,
    };
    const a = sampleRoute(route, 0.25);
    assert.ok(a.groundSpeed !== undefined, "a simulated aircraft has no speed");

    const dLat = (route.to[0] - route.from[0]) * METRES_PER_DEGREE_LAT;
    const dLng =
      (route.to[1] - route.from[1]) * METRES_PER_DEGREE_LAT * Math.cos((a.lat * Math.PI) / 180);
    const expected = Math.hypot(dLat, dLng) / route.duration;
    assert.ok(
      Math.abs((a.groundSpeed ?? 0) - expected) / expected < 0.01,
      `${a.groundSpeed} m/s does not match the leg's ${expected} m/s`,
    );
    // Descending, so the rate is negative, and it eases off toward the end.
    assert.ok((a.verticalRate ?? 0) < 0, "an arrival was reported as climbing");
    assert.ok(
      Math.abs(a.verticalRate ?? 0) > Math.abs(sampleRoute(route, 0.9).verticalRate ?? 0),
      "the eased descent does not shallow out",
    );
  });
});

// ---- The card --------------------------------------------------------------

describe("the detail card", () => {
  /**
   * The registration and the type were the stated reason to want a commercial
   * feed. Both community feeds have carried them on every row all along, under
   * the same ODbL as the position, and the server was dropping them on the
   * floor — so an anonymous visitor clicking a dart now reads what the aircraft
   * is rather than a hex address.
   */
  it("carries the registration and the ICAO type", () => {
    const card = aircraftDetail(jet(), {
      icao24: "a923cd",
      registration: " N68834 ",
      type: "B739",
      observed: true,
    });
    assert.equal(card.registration, "N68834");
    assert.equal(card.type, "B739");
    assert.equal(card.icao24, "a923cd");
    // Knots, because that is the unit a speed over the ground is read in.
    assert.equal(card.groundSpeedKt, Math.round(250 / 0.514_444));
  });

  it("says nothing rather than something empty", () => {
    const card = aircraftDetail(jet({ groundSpeed: undefined, verticalRate: undefined }), {
      registration: "   ",
    });
    assert.equal(card.registration, null);
    assert.equal(card.type, null);
    assert.equal(card.groundSpeedKt, null, "a card claimed a speed nobody reported");
    assert.equal(card.verticalRateFpm, null);
  });
});

// ---- The server's half of the wire -----------------------------------------

/**
 * The unit conversions, through a real entry point.
 *
 * `readDump1090` parses exactly the envelope both hosted feeds serve, so a
 * file on disk exercises the same `normalise` the network path does. The
 * conversions are the part worth pinning: knots and feet per minute are what
 * the feed publishes and metres per second is what the wire carries, and a
 * factor that is wrong by 1.9 produces a sky that renders perfectly and is
 * wrong everywhere.
 *
 * The row is a real one, copied from `api.adsb.lol/v2/point` while this was
 * being written.
 */
describe("the ADS-B adapter", () => {
  const dir = mkdtempSync(join(tmpdir(), "tera-adsb-"));

  async function parse(rows: unknown[]) {
    const path = join(dir, `${Math.random().toString(36).slice(2)}.json`);
    writeFileSync(path, JSON.stringify({ now: 1_787_388_360, ac: rows }));
    const snapshot = await readDump1090(path);
    assert.ok(snapshot, "the adapter refused a well-formed envelope");
    return snapshot;
  }

  it("converts knots and feet per minute to SI, and keeps the identifiers", async () => {
    const snapshot = await parse([
      {
        hex: "a923cd",
        flight: "UAL505  ",
        r: "N68834",
        t: "B739",
        gs: 249.2,
        track: 357.7,
        baro_rate: 1344,
        alt_baro: 4950,
        lat: 37.62,
        lon: -122.38,
        seen_pos: 0.183,
      },
    ]);
    const [a] = snapshot.aircraft;
    assert.ok(a);
    assert.ok(Math.abs((a.groundSpeed ?? 0) - 249.2 * 0.514_444) < 1e-6, "ground speed in knots");
    assert.ok(Math.abs((a.verticalRate ?? 0) - 1344 * 0.00508) < 1e-6, "climb in feet per minute");
    assert.equal(a.registration, "N68834");
    assert.equal(a.type, "B739");
    assert.equal(a.ageSeconds, 0.183);
    assert.equal(a.callsign, "UAL505");
  });

  /**
   * The gate that matters most. A ground vehicle reports `gs: 0.0` with a null
   * track, and `heading` falls back to `0` for a row with no track — harmless
   * for something that is not moving, and a claim that a baggage tug is
   * taxiing due north the moment anything advances it. No track, no speed.
   */
  it("carries no speed for a row with no track, whatever the speed said", async () => {
    const snapshot = await parse([
      { hex: "a0b820", gs: 0, alt_baro: "ground", lat: 37.61, lon: -122.39 },
      { hex: "abcdef", gs: 180, alt_baro: 3000, lat: 37.61, lon: -122.39 },
      { hex: "beef00", gs: 0, track: 90, alt_baro: 3000, lat: 37.61, lon: -122.39 },
    ]);
    for (const a of snapshot.aircraft) {
      assert.equal(a.groundSpeed, undefined, `${a.id} was given a speed it did not report`);
    }
  });

  it("falls back to the geometric climb rate when there is no barometric one", async () => {
    const snapshot = await parse([
      { hex: "a1f5ff", geom_rate: -640, track: 180, gs: 300, alt_baro: 8000, lat: 37.6, lon: -122.4 },
    ]);
    const [a] = snapshot.aircraft;
    assert.ok(a);
    assert.ok((a.verticalRate ?? 0) < 0, "a descent was reported as a climb");
    assert.ok(Math.abs((a.verticalRate ?? 0) - -640 * 0.00508) < 1e-6);
  });
});
