/**
 * `SatelliteCatalogue`: the propagation, and the units it is easy to get wrong.
 *
 * SGP4 itself is `satellite.js`'s problem and is not re-tested here — it is a
 * direct translation of Vallado's reference implementation and has its own
 * conformance suite. What *is* tested is the chain around it, which is where
 * every bug in a satellite layer actually lives: ECI to ECF needs the sidereal
 * angle for the right instant, `ecfToLookAngles` wants the observer in **radians**
 * and kilometres, and getting either wrong produces angles that are plausible to
 * look at and completely false.
 *
 * The check that catches all of it is the **slant range**. It is a physical
 * consequence of the geometry rather than a number copied from somewhere: an
 * object above the horizon can be no closer than its own altitude (straight
 * overhead) and no further than the horizon-grazing chord, and that is a tight
 * window — roughly 400–2,400 km for the ISS. Feed the observer degrees instead
 * of radians and the ranges leave it immediately.
 *
 * The layer itself is not tested. It is three.js buffer writes with no branch
 * worth pinning, and testing it would mean standing up a GL context to assert
 * that a float landed in an array.
 */

import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
  dotPixels,
  nextOverCalifornia,
  SatelliteCatalogue,
  type SatelliteElements,
  type SatelliteFix,
} from "../engine/satellites.ts";

/** San Francisco, which is `SAN_FRANCISCO.center` and is the default board. */
const SF = { lat: 37.7749, lng: -122.4194 };

/**
 * A real ISS element set. Chosen because the ISS is the one object whose orbit
 * everybody can check independently — 51.6° inclination, ~420 km, ~92 minutes —
 * and because at that inclination it genuinely passes over San Francisco
 * several times a day, which is what makes the visibility test below meaningful
 * rather than vacuous.
 */
const ISS: SatelliteElements = {
  noradId: 25544,
  name: "ISS (ZARYA)",
  group: "station",
  line1: "1 25544U 98067A   26037.51782528 -.00002182  00000-0 -11606-4 0  2927",
  line2: "2 25544  51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537",
};

/**
 * Near the element set's own epoch — day 37 of 2026. A TLE is good for days
 * either side of its epoch and degrades after that, so a test that propagated
 * one six months forward would be measuring the decay of the model rather than
 * the correctness of this module.
 */
const NEAR_EPOCH = new Date(Date.UTC(2026, 1, 6, 12, 0, 0));

/**
 * Bounds on how far away something above the horizon can be, in kilometres.
 *
 * The lower bound is the orbit's own altitude, less a margin for the ellipsoid
 * and for the object being a little low. The upper bound is the slant range to
 * an object on the horizon at this altitude, which is about 2,340 km for the
 * ISS; 2,600 leaves room without admitting anything absurd.
 */
const MIN_RANGE_KM = 350;
const MAX_RANGE_KM = 2600;

/** Walks the whole catalogue, however many budgeted calls that takes. */
function sweep(catalogue: SatelliteCatalogue, when: Date) {
  // The budget is two milliseconds a call and this catalogue holds one object,
  // so one call is a full pass — but looping to `size` keeps the helper honest
  // if a test ever hands it a larger set.
  let fixes = catalogue.fixes(when);
  for (let i = 0; i < catalogue.size; i += 1) fixes = catalogue.fixes(when);
  return fixes;
}

describe("reading element sets", () => {
  it("keeps the ones it can read", () => {
    assert.equal(new SatelliteCatalogue([ISS], SF).size, 1);
  });

  it("skips a malformed set rather than throwing", () => {
    const broken: SatelliteElements = { ...ISS, line1: "1 nonsense", line2: "2 nonsense" };
    const catalogue = new SatelliteCatalogue([broken, ISS], SF);
    assert.equal(catalogue.size, 1, "the good set should survive its neighbour");
  });

  it("reports nothing at all for an empty catalogue, and does not divide by zero", () => {
    const catalogue = new SatelliteCatalogue([], SF);
    assert.equal(catalogue.size, 0);
    assert.deepEqual(catalogue.fixes(NEAR_EPOCH), []);
  });
});

describe("the look angles", () => {
  /**
   * A day of the ISS over San Francisco, five minutes at a time.
   *
   * Sampled rather than asserted at one instant because a single sample proves
   * nothing: the ISS is below the horizon from any one place about ninety-five
   * per cent of the time, so a test pinned to one moment would almost certainly
   * be asserting on an empty array and would pass with the propagation deleted.
   */
  function passesOverADay() {
    const catalogue = new SatelliteCatalogue([ISS], SF);
    const seen: { elevation: number; azimuth: number; rangeKm: number; shadow: number }[] = [];
    for (let minute = 0; minute < 24 * 60; minute += 5) {
      const when = new Date(NEAR_EPOCH.getTime() + minute * 60_000);
      for (const fix of sweep(catalogue, when)) seen.push(fix);
    }
    return seen;
  }

  it("puts the ISS over San Francisco several times a day", () => {
    const seen = passesOverADay();
    // At 51.6° inclination and ~92 minutes, several passes a day is arithmetic,
    // not luck. Zero would mean the propagation or the observer is wrong.
    assert.ok(seen.length > 5, `only ${seen.length} five-minute samples were above the horizon`);
  });

  it("never reports something below the horizon", () => {
    for (const fix of passesOverADay()) {
      assert.ok(fix.elevation >= 0, `elevation ${fix.elevation} rad is under the horizon`);
    }
  });

  it("keeps elevation inside a quarter turn and azimuth inside a full one", () => {
    for (const fix of passesOverADay()) {
      assert.ok(fix.elevation <= Math.PI / 2 + 1e-6, `elevation ${fix.elevation} is past zenith`);
      assert.ok(Math.abs(fix.azimuth) <= 2 * Math.PI, `azimuth ${fix.azimuth} is off the compass`);
    }
  });

  /** The one that catches degrees-for-radians. See the note at the top. */
  it("reports a slant range the geometry actually permits", () => {
    const seen = passesOverADay();
    assert.ok(seen.length > 0);
    for (const fix of seen) {
      assert.ok(
        fix.rangeKm >= MIN_RANGE_KM && fix.rangeKm <= MAX_RANGE_KM,
        `range ${Math.round(fix.rangeKm)} km is outside ${MIN_RANGE_KM}–${MAX_RANGE_KM} km, ` +
          `which is not a range a 420 km orbit can be seen at`,
      );
    }
  });

  it("reports a shadow fraction, not a boolean and not a stray number", () => {
    for (const fix of passesOverADay()) {
      assert.ok(fix.shadow >= 0 && fix.shadow <= 1, `shadow ${fix.shadow} is not a fraction`);
    }
  });

  it("is a pure function of the instant it is given", () => {
    const a = new SatelliteCatalogue([ISS], SF);
    const b = new SatelliteCatalogue([ISS], SF);
    // Two viewers on two machines must agree, which is the whole reason the
    // server sends elements rather than positions.
    assert.deepEqual(sweep(a, NEAR_EPOCH), sweep(b, NEAR_EPOCH));
  });

  it("moves when the clock does", () => {
    const catalogue = new SatelliteCatalogue([ISS], SF);
    // A minute apart, so any instant where it is up at both ends has visibly
    // moved: the ISS crosses the sky in about ten.
    let differed = false;
    for (let minute = 0; minute < 24 * 60 && !differed; minute += 5) {
      const at = new Date(NEAR_EPOCH.getTime() + minute * 60_000);
      const later = new Date(at.getTime() + 60_000);
      const [before] = sweep(catalogue, at);
      const [after] = sweep(catalogue, later);
      if (before && after) differed = before.azimuth !== after.azimuth;
    }
    assert.ok(differed, "the sky never changed across a minute");
  });
});

describe("the observer", () => {
  /**
   * The bug this exists for is a real one and it is invisible on screen: reusing
   * one catalogue across a city switch computes the second board's sky from the
   * first board's coordinates. Everything still renders, and every angle is
   * wrong. `main.ts` rebuilds per city because of this.
   */
  it("is where the catalogue was told it is", () => {
    const sf = new SatelliteCatalogue([ISS], SF);
    const antipode = new SatelliteCatalogue([ISS], { lat: -37.7749, lng: 57.5806 });

    let disagreed = false;
    for (let minute = 0; minute < 24 * 60 && !disagreed; minute += 5) {
      const when = new Date(NEAR_EPOCH.getTime() + minute * 60_000);
      const here = sweep(sf, when);
      const there = sweep(antipode, when);
      // Two observers on opposite sides of the earth cannot both be looking at
      // the same low-orbit object.
      if (here.length > 0 && there.length > 0) {
        disagreed = here[0]?.azimuth !== there[0]?.azimuth;
      }
      if (here.length !== there.length) disagreed = true;
    }
    assert.ok(disagreed, "the observer coordinate made no difference to the answer");
  });
});

/*
 * The sizing rule, which is the whole of "satellites are objects and not a
 * lattice".
 *
 * The layer itself is still not tested — see the file header — but this is not
 * the layer, it is the one piece of arithmetic between a fix and how large it is
 * drawn, and it is the piece that regresses silently. Every dot used to be
 * exactly 3.5 pixels and exactly square; a few hundred identical squares on a
 * sphere sample the pixel grid as a regular lattice, which is the moire a
 * photograph of production at dusk showed. If this collapses back to a constant
 * nothing fails, nothing warns, and the sky quietly becomes graph paper again.
 */
describe("how large a satellite is drawn", () => {
  const lit = (rangeKm: number) => dotPixels({ rangeKm, shadow: 0 });

  it("is not one number: a near object is drawn larger than a far one", () => {
    // A station at 400 km, a Starlink overhead at 550, one low on the horizon at
    // 2,000, and a navigation satellite at 20,000. The spread is the point.
    assert.ok(lit(400) > lit(550), "a low pass must be larger than a Starlink overhead");
    assert.ok(lit(550) > lit(2000), "and one overhead larger than one near the horizon");
    assert.ok(lit(2000) > lit(20_000), "and a low-orbit object larger than a navigation bird");
  });

  it("stays inside a range a naked-eye pass could plausibly occupy", () => {
    for (const range of [200, 400, 550, 800, 1500, 2400, 20_000, 36_000]) {
      const size = lit(range);
      assert.ok(size >= 2.1 && size <= 6, `${range} km came out at ${size} pixels`);
    }
  });

  it("is monotonic in range, so nothing grows as it recedes", () => {
    let previous = Infinity;
    for (let km = 200; km <= 40_000; km += 200) {
      const size = lit(km);
      assert.ok(size <= previous + 1e-12, `size grew with range at ${km} km`);
      previous = size;
    }
  });

  it("shrinks an eclipsed object, which is the same fact its alpha already states", () => {
    /*
     * A point source at the threshold of vision blooms: a bright one occupies
     * more of a sensor than a faint one at the same true angular size. So the
     * shadow term reinforces `SHADOW_ALPHA` rather than repeating it — an object
     * in the earth's shadow is both fainter and smaller, which is how the end of
     * a Starlink train reads as fading out rather than as switching off.
     */
    assert.ok(dotPixels({ rangeKm: 550, shadow: 1 }) < dotPixels({ rangeKm: 550, shadow: 0 }));
    const penumbra = dotPixels({ rangeKm: 550, shadow: 0.5 });
    assert.ok(penumbra < dotPixels({ rangeKm: 550, shadow: 0 }));
    assert.ok(penumbra > dotPixels({ rangeKm: 550, shadow: 1 }));
  });

  it("never returns something a vertex shader cannot use", () => {
    for (const fix of [
      { rangeKm: 0, shadow: 0 },
      { rangeKm: -1, shadow: 0 },
      { rangeKm: 550, shadow: -5 },
      { rangeKm: 550, shadow: 9 },
    ]) {
      const size = dotPixels(fix);
      assert.ok(Number.isFinite(size) && size > 0, `${JSON.stringify(fix)} gave ${size}`);
    }
  });
});

const CA = { minLat: 32.5, maxLat: 42.05, minLng: -124.5, maxLng: -114 };

function stubFix(over: Partial<SatelliteFix> & Pick<SatelliteFix, "noradId" | "lat" | "lng">): SatelliteFix {
  return {
    name: `SAT-${over.noradId}`,
    group: "starlink",
    azimuth: 0,
    elevation: 1,
    rangeKm: 600,
    shadow: 0,
    ...over,
  };
}

describe("nadir possession", () => {
  it("reports a sub-satellite point when the ISS is above the horizon", () => {
    const seen = (() => {
      const catalogue = new SatelliteCatalogue([ISS], SF);
      const out: SatelliteFix[] = [];
      for (let minute = 0; minute < 24 * 60; minute += 5) {
        const when = new Date(NEAR_EPOCH.getTime() + minute * 60_000);
        for (const fix of sweep(catalogue, when)) out.push(fix);
      }
      return out;
    })();
    assert.ok(seen.length > 0);
    for (const fix of seen) {
      assert.ok(fix.lat >= -90 && fix.lat <= 90, `lat ${fix.lat}`);
      assert.ok(fix.lng >= -180 && fix.lng <= 180, `lng ${fix.lng}`);
    }
  });

  it("hands off to the nearest nadir still over California", () => {
    const la = stubFix({ noradId: 20, lat: 34.05, lng: -118.25 });
    const sf = stubFix({ noradId: 10, lat: 37.62, lng: -122.38 });
    const utah = stubFix({ noradId: 5, lat: 40.76, lng: -111.89 });
    assert.equal(nextOverCalifornia([la, sf, utah], CA)?.noradId, 10);
    assert.equal(nextOverCalifornia([la, sf, utah], CA, { lat: 33.94, lng: -118.41 })?.noradId, 20);
    assert.equal(nextOverCalifornia([utah], CA), null);
  });
});
