/**
 * The night sky: the cloud deck's level, and the moon as a drawn object.
 *
 * Both of these are judged by a picture and neither can be asserted from one, so
 * what is pinned here is the arithmetic the picture turns on. The defect this
 * suite is downstream of is worth restating, because it is the reason a test
 * that only checks "the layer exists" would have passed on the broken build:
 *
 * > At 22:30 PDT over San Francisco, on a keyless clone, the cloud deck lifted a
 * > night frame's mean luminance from 25.3 to 44.3 out of 255 **without adding a
 * > single readable shape**. Its shaded flank was measurably brighter than its
 * > moonward one. Every existing assertion about that layer passed.
 *
 * So the three things asserted are the three things that were wrong:
 *
 *   1. The deck's level after dark is a fraction of what it is at noon, rather
 *      than being inflated by a hemisphere intensity that `atmosphere.ts`
 *      deliberately *raises* at night to keep the world off black.
 *   2. The moonward flank of a cloud top is brighter than the flank facing away,
 *      which is what "modelled" means and what a flat wash is missing.
 *   3. Daylight did not move. The day was never the defect and a fix that
 *      changed it would be a different regression in the same file.
 *
 * And the moon is asserted where it is computed rather than where it is drawn:
 * a disc in a sky-dome fragment shader cannot be inspected without a GL context,
 * but the record `atmosphere.ts` hands the shader is a plain object with a
 * position, a phase and a bright-limb axis in it, and every way that record can
 * be wrong is visible from the outside.
 */

import assert from "node:assert/strict";
import { describe, it } from "node:test";
import * as THREE from "three";
import {
  createAtmosphere,
  observe,
  PACIFIC_MARINE_LAYER,
  nightFactor,
} from "../../engine/atmosphere.ts";
import type { LightingMoon } from "../../engine/types.ts";

/** San Francisco, which is the board the marine layer was written for. */
const SF = { lat: 37.7749, lng: -122.4194 };

/** Rec.709 luminance, the same one `clouds.ts` derives its levels with. */
function luminance(hex: number): number {
  const c = new THREE.Color().setHex(hex);
  return 0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b;
}

const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
const smoothstep = (e0: number, e1: number, x: number) => {
  const t = clamp((x - e0) / (e1 - e0), 0, 1);
  return t * t * (3 - 2 * t);
};

/*
 * `clouds.ts`'s own arithmetic, restated.
 *
 * Restated rather than imported because `createCloudLayer` needs a `World`, a
 * WebGL context and a canvas to hand back a `setLighting` at all, and none of
 * those has an opinion about the numbers. What is under test is the *shape* of
 * the relationship — night is a fraction of day, lit beats shade — and both
 * halves would have to be edited together for this suite to go quiet while the
 * picture went wrong, which is the bar a restatement has to clear.
 */
const SKY_DAY_REFERENCE = 0.838;
const SKY_DAY_GAIN = 0.772;
const NIGHT_SKY_LOW = 0.09;
const NIGHT_SKY_HIGH = 0.42;
const NIGHT_TOP_FLOOR = 0.01;
const NIGHT_TOP_KEY = 0.155;
const NIGHT_SHADE_SKY = 0.1;
const NIGHT_SHADE_AMBIENT = 0.1;

interface Deck {
  /** Sky-dome luminance, the term the whole normalisation now rides on. */
  skyLum: number;
  night: number;
  /** Luminance of the moonward (or sunward) flank. */
  lit: number;
  /** Luminance of the flank facing away. */
  shade: number;
}

function deckAt(when: Date, marine = PACIFIC_MARINE_LAYER): Deck {
  const atmosphere = createAtmosphere({
    lng: SF.lng,
    metresPerUnit: 94,
    marineLayer: marine,
  });
  const state = atmosphere.apply(observe(SF.lat, SF.lng, when));
  const skyLum = luminance(state.hemisphere.sky);
  const day = clamp(skyLum / SKY_DAY_REFERENCE, 0, 1);
  const night = 1 - smoothstep(NIGHT_SKY_LOW, NIGHT_SKY_HIGH, skyLum);
  const key = clamp(state.sun.intensity / 2.1, 0, 1);

  const litScale =
    (1 - night) * (0.06 + SKY_DAY_GAIN * day) + night * (NIGHT_TOP_FLOOR + NIGHT_TOP_KEY * key);
  const lit = luminance(state.sun.color) * litScale;

  const shade =
    skyLum * state.hemisphere.intensity * ((1 - night) * 0.55 + night * NIGHT_SHADE_SKY) +
    luminance(state.ambient.color) *
      state.ambient.intensity *
      ((1 - night) * 0.7 + night * NIGHT_SHADE_AMBIENT);

  return { skyLum, night, lit, shade };
}

/** 22:30 PDT on 23 August 2026 — the exact frame the defect was measured in. */
const THE_NIGHT = new Date("2026-08-23T05:30:00Z");
/** 13:00 PDT the same day. */
const THE_NOON = new Date("2026-08-23T20:00:00Z");

describe("the cloud deck after dark", () => {
  it("is a small fraction of its daylight level, not most of it", () => {
    const night = deckAt(THE_NIGHT);
    const noon = deckAt(THE_NOON);
    assert.ok(night.night > 0.99, "the test's own night frame must actually be night");
    assert.ok(
      night.lit < noon.lit * 0.05,
      `a night cloud top must be a small fraction of a noon one: ${night.lit} vs ${noon.lit}`,
    );
    assert.ok(
      night.shade < noon.shade * 0.1,
      `and so must its shaded flank: ${night.shade} vs ${noon.shade}`,
    );
  });

  it("has a light direction in it — the moonward flank beats the one facing away", () => {
    /*
     * This is the assertion that would have failed on the old build. Measured
     * there: lit 0.072 against shade 0.084, i.e. the side facing the moon was
     * *darker* than the side facing away, which is not a lighting bug so much as
     * the absence of lighting. A deck with no direction in it is the flat milky
     * wash the file header calls blue soup.
     */
    const night = deckAt(THE_NIGHT);
    assert.ok(
      night.lit > night.shade,
      `the moonward flank must be the brighter one: lit ${night.lit}, shade ${night.shade}`,
    );
  });

  it("still lands above black on a moonless overcast night", () => {
    /*
     * The opposite failure, and the one the file's own comment warns about:
     * `0.06 is not black`. `NIGHT_TOP_FLOOR` exists so that a deck with no moon
     * on it is dark rather than absent, and a layer that vanishes is a different
     * bug report about the same file.
     */
    const night = deckAt(THE_NIGHT);
    assert.ok(night.lit > 0, "a night deck must still be drawn");
    assert.ok(night.shade > 0, "and its shaded flank must still be drawn");
  });

  it("leaves daylight where it was, across the whole daylit range", () => {
    /*
     * The previous normalisation, verbatim, against the new one. The day was
     * right and the change was aimed entirely at the other side of
     * `NIGHT_SKY_HIGH`; 0.05 is the tolerance the two constants were fitted to
     * over four places and two seasons, on a multiplier that runs 0.06 to 0.86.
     */
    const atmosphere = createAtmosphere({
      lng: SF.lng,
      metresPerUnit: 94,
      marineLayer: PACIFIC_MARINE_LAYER,
    });
    let worst = 0;
    for (let i = 0; i < 96; i++) {
      const when = new Date(Date.UTC(2026, 7, 23) + i * 900_000);
      const state = atmosphere.apply(observe(SF.lat, SF.lng, when));
      const skyLum = luminance(state.hemisphere.sky);
      const night = 1 - smoothstep(NIGHT_SKY_LOW, NIGHT_SKY_HIGH, skyLum);
      if (night > 0.02) continue; // the night path is meant to differ; that is the point
      const before = 0.06 + 0.92 * clamp((skyLum * state.hemisphere.intensity) / 0.96, 0, 1);
      const after = 0.06 + SKY_DAY_GAIN * clamp(skyLum / SKY_DAY_REFERENCE, 0, 1);
      worst = Math.max(worst, Math.abs(before - after));
    }
    assert.ok(worst < 0.05, `daylight moved by ${worst}, which is more than a fit's worth`);
  });

  it("hands over at dusk rather than at a threshold", () => {
    // Three quarters of an hour either side of the handover must be different
    // numbers, or the deck steps rather than fades and the step is visible.
    const levels = [];
    for (const hour of [2, 3, 4, 5]) {
      levels.push(deckAt(new Date(Date.UTC(2026, 7, 23, hour, 30))).night);
    }
    for (let i = 1; i < levels.length; i++) {
      assert.ok(levels[i]! >= levels[i - 1]!, "the night term must rise monotonically through dusk");
    }
    assert.ok(levels[0]! < 0.9 && levels[3]! > 0.99, "and it must actually traverse the range");
  });
});

describe("the moon, as a thing to draw", () => {
  const atmosphere = createAtmosphere({
    lng: SF.lng,
    metresPerUnit: 94,
    marineLayer: PACIFIC_MARINE_LAYER,
  });
  const moonAt = (when: Date): LightingMoon | null =>
    atmosphere.apply(observe(SF.lat, SF.lng, when)).moon ?? null;

  it("is absent while it is below the horizon, rather than drawn underground", () => {
    /*
     * Over one synodic month the moon is below the horizon for about half of
     * every day, so a scan that never returns `null` would mean the horizon test
     * is not running at all — and a moon drawn under the board is the failure
     * `satellites.ts` records under HORIZON_FADE_DEG, one layer over.
     */
    let down = 0;
    let up = 0;
    for (let i = 0; i < 24 * 30; i++) {
      const when = new Date(Date.UTC(2026, 7, 1) + i * 3_600_000);
      const moon = moonAt(when);
      if (moon === null) down += 1;
      else up += 1;
    }
    assert.ok(down > 200, `the moon must set: only ${down} of 720 hours had it down`);
    assert.ok(up > 200, `and rise: only ${up} of 720 hours had it up`);
  });

  it("hands over a unit direction and a bright limb perpendicular to it", () => {
    /*
     * Both are consumed by a fragment shader that projects a view direction onto
     * them, and a non-unit or non-orthogonal pair puts the terminator at the
     * wrong place on the disc rather than throwing anywhere. This is the failure
     * that cannot be seen except in a picture of a crescent facing the wrong way.
     */
    let checked = 0;
    for (let i = 0; i < 24 * 30; i++) {
      const moon = moonAt(new Date(Date.UTC(2026, 7, 1) + i * 3_600_000));
      if (moon === null) continue;
      checked += 1;
      const d = new THREE.Vector3().fromArray(moon.direction);
      const limb = new THREE.Vector3().fromArray(moon.brightLimb);
      assert.ok(Math.abs(d.length() - 1) < 1e-9, `direction was not a unit vector: ${d.length()}`);
      assert.ok(Math.abs(limb.length() - 1) < 1e-9, `bright limb was not a unit vector`);
      assert.ok(Math.abs(d.dot(limb)) < 1e-9, `bright limb was not perpendicular: ${d.dot(limb)}`);
      assert.ok(Number.isFinite(moon.angularRadius) && moon.angularRadius > 0);
      assert.ok(moon.illuminated >= 0 && moon.illuminated <= 1);
      assert.ok(moon.visibility >= 0 && moon.visibility <= 1);
    }
    assert.ok(checked > 200, "the scan must have found a moon to check");
  });

  it("points its lit limb away from the sun's own direction, never at it", () => {
    /*
     * The one error anybody who has ever looked up will spot instantly. The
     * bright limb is the sun projected onto the plane of the disc, so its dot
     * with the sun's true direction has to be positive — a crescent whose horns
     * point toward the sun is the picture of a mistake.
     */
    let checked = 0;
    for (let i = 0; i < 24 * 30; i++) {
      const when = new Date(Date.UTC(2026, 7, 1) + i * 3_600_000);
      const env = observe(SF.lat, SF.lng, when);
      const moon = moonAt(when);
      if (moon === null) continue;
      // The sun in the same scene frame: azimuth clockwise from north, north -Z.
      const el = (env.sun.elevation * Math.PI) / 180;
      const az = (env.sun.azimuth * Math.PI) / 180;
      const sun = new THREE.Vector3(
        Math.cos(el) * Math.sin(az),
        Math.sin(el),
        -Math.cos(el) * Math.cos(az),
      );
      const limb = new THREE.Vector3().fromArray(moon.brightLimb);
      // Skip the two degenerate instants — full and new — where the projection
      // has no length and any axis is as right as any other.
      if (moon.illuminated > 0.995 || moon.illuminated < 0.005) continue;
      checked += 1;
      assert.ok(
        limb.dot(sun) > -1e-9,
        `the lit limb faced away from the sun at ${when.toISOString()}`,
      );
    }
    assert.ok(checked > 200, "the scan must have found a phase to check");
  });

  it("is faint in daylight and full-strength at night, without ever disappearing while up", () => {
    let sawDaylight = false;
    let sawNight = false;
    for (let i = 0; i < 24 * 30; i++) {
      const when = new Date(Date.UTC(2026, 7, 1) + i * 3_600_000);
      const env = observe(SF.lat, SF.lng, when);
      const moon = moonAt(when);
      if (moon === null || env.moon.elevation < 20) continue;
      if (nightFactor(env.sun.elevation) === 0) {
        sawDaylight = true;
        assert.ok(moon.visibility > 0, "a daytime moon well up must still be drawn, faintly");
        assert.ok(moon.visibility < 0.35, `and faintly: ${moon.visibility}`);
      }
      if (nightFactor(env.sun.elevation) > 0.99) sawNight = true;
    }
    assert.ok(sawDaylight, "the scan must have found a daytime moon");
    assert.ok(sawNight, "and a night one");
  });

  it("is absent from a rig that models no sky at all", () => {
    /*
     * An office has walls and no ephemeris, and `LightingMoon` is optional
     * precisely so that a hand-built rig is not obliged to invent one. The
     * consumer reads `state.moon ?? null` and draws nothing, which is the same
     * answer as a moon below the horizon.
     */
    const bare = { sun: { direction: [0, 1, 0], color: 0xffffff, intensity: 1 } };
    assert.equal((bare as { moon?: unknown }).moon ?? null, null);
  });
});
