/**
 * The two properties that decide whether the live thing in this sky exists for
 * a visitor: **can they see it**, and **can they hit it**.
 *
 * Both used to be false on the deployed build, and neither failure could be
 * caught by any test in the repo, because both are about *apparent* size. The
 * aircraft were positioned correctly, oriented correctly, coloured correctly and
 * drawn at two thirds of a pixel — 0.42 scene units on a board the camera stands
 * eleven hundred units back from. Every existing assertion about the flight
 * layer passed on that build. A person looking at it saw an empty sky with a
 * couple of smudges on the monitor.
 *
 * So what is asserted here is the *arithmetic that survives* the picture:
 *
 *   - `glyphScale` is what turns a camera distance into a legible size, and the
 *     thing worth pinning is not the constant but the **identity**: whatever
 *     scale comes back, the glyph's share of the frame is the floor. A future
 *     refactor that halves the constant and doubles the divisor would leave
 *     every number in the function looking sensible and every aeroplane at half
 *     the size, and that is exactly what this catches.
 *   - `AIRLINER_LENGTH` is the unit that identity is measured in, so it is held
 *     against the geometry's own bounding box rather than against itself. The
 *     constant and the mesh drifting apart is a one-character mistake in
 *     `aircraftGeometry.ts` that nothing else would notice.
 *   - The pick target is a **sphere around the glyph** rather than the glyph's
 *     triangles, which is the whole of "clicking a plane works for a stranger".
 *     It is asserted through `Raycaster` — the same class `scenekit.ts` uses —
 *     rather than by reaching into `Mesh.raycast`, because the question is
 *     whether a ray *near* an aeroplane resolves to it.
 *   - A track that is being held through a dropped refresh but is no longer
 *     drawn is no longer clickable. `Raycaster` does not consult `visible`, so
 *     this is the one place in the layer where "not drawn" and "not there" have
 *     to be said twice.
 *
 * The world here is the flat two-line stand-in the integration suite already
 * uses: this layer's whole contact with a `World` is `project` and `metres`, and
 * a real heightfield is half a million samples of nothing to do with any of it.
 */

import assert from "node:assert/strict";
import { describe, it } from "node:test";
import * as THREE from "three";
import { AIRLINER_LENGTH, airlinerGeometry } from "../../engine/aircraftGeometry.ts";
import { createFlightLayer, glyphScale } from "../../engine/flights.ts";
import type { Aircraft } from "../../engine/types.ts";

/** Enough of a `World` for the flight layer: a projection and a vertical scale. */
const flatWorld = {
  project: (lat: number, lng: number) => [lng * 100, -lat * 100],
  metres: (m: number) => m / 100,
  metresPerUnit: 100,
} as unknown as Parameters<typeof createFlightLayer>[0];

/**
 * The floor `flights.ts` states in its own comments, written out again.
 *
 * Restated rather than exported for the same reason `flights.test.ts` restates
 * `TRAIL_POINTS`: a test that imports the constant it is checking asserts only
 * that the code agrees with itself, and would follow a typo into production.
 */
const MIN_SCREEN_FRACTION = 0.016;

/** A camera's vertical view extent at a distance, in world units. */
function frustumHeight(distance: number, fovDegrees: number): number {
  return 2 * distance * Math.tan((fovDegrees * Math.PI) / 360);
}

function jet(id: string, lat: number, lng: number, altitude = 9_000): Aircraft {
  return { id, callsign: id.toUpperCase(), lat, lng, altitude, heading: 90 };
}

describe("the legibility floor", () => {
  it("holds an aeroplane at the same share of the frame however far away it is", () => {
    // A chapter's standoff, a board span, and the far end of the orbit over the
    // California corridor — the three distances a visitor actually looks from.
    for (const distance of [120, 580, 1_160]) {
      for (const fov of [42, 60]) {
        const share = (glyphScale(distance, fov) * AIRLINER_LENGTH) / frustumHeight(distance, fov);
        assert.ok(
          Math.abs(share - MIN_SCREEN_FRACTION) < 1e-9,
          `at ${distance} units and ${fov}° the glyph is ${share} of the frame, not ${MIN_SCREEN_FRACTION}`,
        );
      }
    }
  });

  it("is a floor and not a fit: close up, the authored geometry wins", () => {
    // Inside about 34 units the aeroplane is already legible at the size
    // `aircraftGeometry.ts` drew it, and enlarging it there would park a
    // state-sized airliner over a downtown.
    assert.equal(glyphScale(1, 42), 1);
    assert.equal(glyphScale(20, 42), 1);
    assert.ok(glyphScale(60, 42) > 1, "past a chapter's standoff it has to grow");
    // Monotonic, so the scale never jumps as the camera pulls back.
    let previous = 0;
    for (let d = 1; d < 1_200; d += 37) {
      const scale = glyphScale(d, 42);
      assert.ok(scale >= previous, `scale went backwards at ${d}`);
      previous = scale;
    }
  });

  it("survives a camera that is not ready to be asked", () => {
    // A chase camera sitting on the aircraft, and a caller mid-setup. Neither is
    // a reason for the sky to vanish or to fill with NaN matrices.
    for (const bad of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
      assert.equal(glyphScale(bad, 42), 1, `distance ${bad}`);
      assert.equal(glyphScale(500, bad), 1, `fov ${bad}`);
    }
    assert.equal(glyphScale(500, 180), 1, "a degenerate field of view is not a size");
  });

  it("measures the aeroplane the geometry actually built", () => {
    const geo = airlinerGeometry();
    geo.computeBoundingBox();
    const box = geo.boundingBox;
    assert.ok(box, "the airliner has no bounding box");
    const length = box.max.z - box.min.z;
    assert.ok(
      Math.abs(length - AIRLINER_LENGTH) < 1e-6,
      `the constant says ${AIRLINER_LENGTH} and the mesh is ${length} long`,
    );
    // Every extremity inside one glyph length of the origin, which is what makes
    // that length usable as the pick radius as well as as the size.
    const reach = Math.max(
      box.max.length(),
      box.min.length(),
      new THREE.Vector3(box.max.x, box.max.y, box.min.z).length(),
    );
    assert.ok(reach < AIRLINER_LENGTH, `an extremity reaches ${reach}, outside the pick sphere`);
    geo.dispose();
  });
});

describe("pointing at an aeroplane", () => {
  /**
   * A camera looking down the −Z axis from above, and a ray through a point
   * offset from the aeroplane by `offsetUnits` in world X.
   *
   * Built with `Raycaster.set` rather than `setFromCamera` so the offset is in
   * world units and the assertion is about the *size of the target* rather than
   * about a projection matrix.
   */
  function rayAt(x: number, y: number, z: number): THREE.Raycaster {
    return new THREE.Raycaster(new THREE.Vector3(x, y, z + 50), new THREE.Vector3(0, 0, -1));
  }

  it("hits a sphere around the glyph rather than its wings", () => {
    const layer = createFlightLayer(flatWorld);
    try {
      layer.update([jet("a1b2c3", 0, 0)]);
      const mesh = layer.pickables[0];
      assert.ok(mesh, "no pick target for an aircraft that is in the sky");
      mesh.updateMatrixWorld(true);
      const at = mesh.position;

      // Dead centre, and then off to one side by two thirds of a glyph length —
      // which is past the wingtips and past the nose, and is empty space as far
      // as the aeroplane's triangles are concerned.
      for (const offset of [0, 0.66 * AIRLINER_LENGTH]) {
        const hits = rayAt(at.x + offset, at.y, at.z).intersectObjects(layer.pickables, false);
        assert.equal(hits.length, 1, `nothing under a pointer ${offset} units from the aeroplane`);
        assert.equal(hits[0]?.object.userData.aircraftId, "a1b2c3");
        assert.ok(Number.isFinite(hits[0]?.distance), "a hit with no distance cannot be sorted");
      }

      // Well outside it, though, is still empty sky. The target is generous, not
      // unbounded: a card that opens when the pointer is nowhere near an
      // aeroplane is a different bug with the same cause.
      const miss = rayAt(at.x + 3 * AIRLINER_LENGTH, at.y, at.z).intersectObjects(
        layer.pickables,
        false,
      );
      assert.equal(miss.length, 0, "the whole sky is not an aeroplane");
    } finally {
      layer.dispose();
    }
  });

  it("grows the target with the glyph, so a distant aeroplane is no harder to hit", () => {
    const layer = createFlightLayer(flatWorld);
    try {
      layer.update([jet("a1b2c3", 0, 0)]);
      const mesh = layer.pickables[0] as THREE.Mesh;
      assert.ok(mesh);
      // What `tick` does when the camera is a long way off. The scale is the one
      // channel the pick radius reads, so setting it is the whole of the test.
      mesh.scale.setScalar(12);
      mesh.updateMatrixWorld(true);
      const at = mesh.position;
      const hits = rayAt(at.x + 8 * AIRLINER_LENGTH, at.y, at.z).intersectObjects(
        layer.pickables,
        false,
      );
      assert.equal(hits.length, 1, "a scaled-up glyph must scale its target with it");
    } finally {
      layer.dispose();
    }
  });

  it("stops being clickable at the moment it stops being drawn", () => {
    /**
     * A track missing from a snapshot is held for `TRACK_GRACE_SECONDS` so a
     * dropped ADS-B refresh does not throw away a minute of trail. `tick` stops
     * *drawing* it once it has finished arriving where it was last seen — and
     * `Raycaster` never looks at `visible`, so without an explicit removal the
     * card goes on opening from a patch of empty sky for half a minute.
     */
    const realNow = performance.now;
    let clockMs = 0;
    performance.now = () => clockMs;
    const layer = createFlightLayer(flatWorld);
    try {
      layer.update([jet("a1b2c3", 0, 0), jet("ddeeff", 0.2, 0.2)]);
      clockMs = 10_000;
      layer.update([jet("a1b2c3", 0.1, 0.1), jet("ddeeff", 0.3, 0.3)]);
      assert.equal(layer.pickables.length, 2);

      // One target goes quiet. It keeps flying to where it was last seen…
      clockMs = 20_000;
      layer.update([jet("a1b2c3", 0.2, 0.2)]);
      assert.equal(layer.pickables.length, 2, "a target absent for one refresh is still in the sky");

      // …and then it is not drawn any more, so it is not a pick target either.
      clockMs = 45_000;
      layer.update([jet("a1b2c3", 0.3, 0.3)]);
      assert.deepEqual(
        layer.pickables.map((object) => object.userData.aircraftId),
        ["a1b2c3"],
        "an aeroplane nobody can see must not open a card",
      );

      // And it comes back, rather than being permanently unclickable.
      clockMs = 55_000;
      layer.update([jet("a1b2c3", 0.4, 0.4), jet("ddeeff", 0.4, 0.4)]);
      assert.equal(layer.pickables.length, 2);
      assert.equal(
        new Set(layer.pickables).size,
        2,
        "a returning aeroplane must not be listed twice",
      );
    } finally {
      layer.dispose();
      performance.now = realNow;
    }
  });
});
