/**
 * Turning the sun into a building's frame.
 *
 * This is eleven lines of trigonometry with four chances to get a sign wrong,
 * and every one of them produces a scene that renders perfectly and is lit from
 * the wrong side. There is no visual tell: an office lit from the east at
 * sunset looks exactly as plausible as one lit from the west, unless you happen
 * to know which wall the pack put its windows in.
 *
 * So the cases below are all stated as compass facts — "the sun is due east, the
 * building faces east, therefore the sun is straight ahead" — rather than as
 * expected numbers, because a number copied out of a failing run is not a test.
 *
 * The frame, for reading these: **−Z is north, +X is east**, in both the world
 * and the building. `heading` is the bearing the building's −Z points along.
 */

import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { intoBuildingFrame, officeDaylight, smokeCaption } from "../interiors/daylight.ts";
import type { LightingState } from "../engine/types.ts";
import type { OfficeSite } from "../interiors/types.ts";

/** Unit vector pointing at a compass bearing, in the frame described above. */
function bearing(deg: number): [number, number, number] {
  const r = (deg * Math.PI) / 180;
  return [Math.sin(r), 0, -Math.cos(r)];
}

/** The bearing a frame-vector points at, back out again. */
function bearingOf([x, , z]: [number, number, number]): number {
  return (((Math.atan2(x, -z) * 180) / Math.PI) + 360) % 360;
}

function close(actual: number, expected: number, message: string) {
  const delta = Math.abs(((actual - expected + 540) % 360) - 180);
  assert.ok(delta < 1e-6, `${message}: got ${actual}, expected ${expected}`);
}

describe("rotating the sun into the building's frame", () => {
  it("changes nothing for a building whose north really is north", () => {
    for (const deg of [0, 45, 90, 180, 270]) {
      const v = bearing(deg);
      assert.deepEqual(intoBuildingFrame(v, 0), v);
    }
  });

  it("puts a sun dead ahead when the building faces it", () => {
    // Building faces east; sun is due east. In the building's own frame that is
    // straight out of the front, which is bearing 0 — its own "north".
    close(bearingOf(intoBuildingFrame(bearing(90), 90)), 0, "sun should be ahead");
  });

  it("puts a sun behind when the building faces away from it", () => {
    // Building faces north, sun due south.
    close(bearingOf(intoBuildingFrame(bearing(180), 0)), 180, "sun should be behind");
    // Building faces south, sun due north — the same physical arrangement.
    close(bearingOf(intoBuildingFrame(bearing(0), 180)), 180, "sun should be behind");
  });

  it("subtracts the heading, rather than adding it", () => {
    // The sign error that produces a plausible-looking, mirrored building. A sun
    // at 90 seen from a building facing 30 is 60 off its nose, not 120.
    close(bearingOf(intoBuildingFrame(bearing(90), 30)), 60, "heading should subtract");
  });

  it("leaves the sun's height alone", () => {
    // Rotating about the vertical cannot change how high the sun is. A rig that
    // got this wrong would have the sun rise and set as the building turned.
    const up: [number, number, number] = [0.3, 0.9, 0.31];
    for (const heading of [0, 37, 90, 205, 359]) {
      assert.equal(intoBuildingFrame(up, heading)[1], 0.9);
    }
  });

  it("preserves length, so the direction stays a direction", () => {
    const v: [number, number, number] = [0.48, 0.72, -0.5];
    const length = Math.hypot(...v);
    for (const heading of [17, 205, 300]) {
      const out = intoBuildingFrame(v, heading);
      assert.ok(Math.abs(Math.hypot(...out) - length) < 1e-12);
    }
  });

  it("round-trips: rotating by h and then by -h is the identity", () => {
    const v: [number, number, number] = [0.2, 0.83, -0.52];
    const there = intoBuildingFrame(v, 205);
    const back = intoBuildingFrame(there, -205);
    for (let i = 0; i < 3; i += 1) {
      assert.ok(Math.abs((back[i] as number) - (v[i] as number)) < 1e-12);
    }
  });
});

describe("moving the weather outdoors", () => {
  const site: OfficeSite = { lat: 37.79, lng: -122.4, elevation: 188, heading: 0 };

  const cityState: LightingState = {
    sun: { direction: [0.3, 0.9, 0.31], color: 0xffffff, intensity: 1 },
    hemisphere: { sky: 0x8899aa, ground: 0x404040, intensity: 1 },
    ambient: { color: 0xffffff, intensity: 0.3 },
    sky: { top: 0x223344, horizon: 0x99aabb },
    // A city fog, in city units: this would sit well inside a 50 m room.
    fog: { color: 0xaabbcc, near: 1150, far: 2800 },
  };

  /**
   * The failure this guards is not subtle once you see it and is invisible until
   * you do: an office rendered with a fog that starts 1,150 *metres* away is
   * fine, and one rendered with a fog that starts at 1,150 *scene units* of a
   * city is fine too — but an office is 1 unit to the metre, so a city fog
   * dropped into one greys out the far wall and everybody standing at it.
   */
  it("starts the fog outside the building", () => {
    const out = officeDaylight(cityState, site);
    assert.ok(out.fog, "an office with a site keeps its fog");
    assert.ok(out.fog.near > 60, `fog starts at ${out.fog.near} m, inside the building`);
    assert.ok(out.fog.far > out.fog.near);
  });

  it("keeps the colour the atmosphere chose", () => {
    assert.equal(officeDaylight(cityState, site).fog?.color, 0xaabbcc);
  });

  /**
   * The sky is a screen-space gradient and the ground plane converges on the fog
   * colour, so the two meet at whatever screen row the world horizon lands on —
   * which moves as the camera orbits. Unless they meet with the *same* colour,
   * that line is a visible step across the frame.
   */
  it("pins the sky's horizon to the fog colour, so the join is invisible", () => {
    const out = officeDaylight(cityState, site);
    assert.equal(out.sky?.horizon, cityState.fog?.color);
  });

  it("leaves the top of the sky to the atmosphere", () => {
    assert.equal(officeDaylight(cityState, site).sky?.top, cityState.sky?.top);
  });

  it("keeps a skyless state skyless", () => {
    assert.equal(officeDaylight({ ...cityState, sky: null }, site).sky, null);
  });

  it("keeps a fogless state fogless", () => {
    const clear = officeDaylight({ ...cityState, fog: null }, site);
    assert.equal(clear.fog, null);
  });

  it("does not mutate what it was given", () => {
    const before = JSON.stringify(cityState);
    officeDaylight(cityState, { ...site, heading: 205 });
    assert.equal(JSON.stringify(cityState), before);
  });
});

// ---- Smoke ----------------------------------------------------------------

/**
 * The haze the fires drive, and the one thing it must never do.
 *
 * `smokeLoad` reaches this function from a fire layer reading a feed off another
 * machine, so the zero case is the case that matters: on a day when nothing is
 * burning — which, on the SoCal board, is most days and was today — the office
 * has to be bit-identical to what it was before any of this existed. A haze that
 * creeps in at a load of nothing is the same failure as an orange glyph over a
 * city where nothing is on fire, one layer further in.
 */
describe("office daylight under smoke", () => {
  const site: OfficeSite = { lat: 34.0395, lng: -118.2288, elevation: 79, heading: 36 };
  const clear: LightingState = {
    sun: { direction: [0.3, 0.8, -0.5], color: 0xfff3e0, intensity: 2.4 },
    hemisphere: { sky: 0x8899aa, ground: 0x404040, intensity: 1 },
    ambient: { color: 0xffffff, intensity: 0.3 },
    sky: { top: 0x223344, horizon: 0x99aabb },
    fog: { color: 0xaabbcc, near: 900, far: 60000 },
  };

  it("is bit-identical to a clear day at a load of zero", () => {
    const base = officeDaylight(clear, site);
    // A non-finite load is *no* smoke rather than maximum smoke. It can only be
    // a bug upstream, and the safe rendering of a bug is the ordinary sky.
    for (const nothing of [0, -1, Number.NaN, Number.NEGATIVE_INFINITY, Number.POSITIVE_INFINITY]) {
      const out = officeDaylight(clear, site, nothing);
      assert.equal(out.fog?.color, base.fog?.color, `${nothing}`);
      assert.equal(out.fog?.near, base.fog?.near, `${nothing}`);
      assert.equal(out.fog?.far, base.fog?.far, `${nothing}`);
      assert.equal(out.sun.color, base.sun.color, `${nothing}`);
      assert.equal(out.sun.intensity, base.sun.intensity, `${nothing}`);
      // And the answer it always gave, restated so a changed default is caught
      // here rather than in a screenshot.
      assert.equal(out.fog?.color, 0xaabbcc);
      assert.equal(out.fog?.near, 150);
      assert.equal(out.sun.intensity, 2.4);
    }
    assert.equal(smokeCaption(0), null);
    assert.equal(smokeCaption(Number.NaN), null);
  });

  it("moves the haze colour, the haze distance and the sun together, monotonically", () => {
    const loads = [0, 0.25, 0.5, 0.75, 1];
    const near: number[] = [];
    const sunIntensity: number[] = [];
    const fogWarmth: number[] = [];
    const sunBlue: number[] = [];
    for (const load of loads) {
      const out = officeDaylight(clear, site, load);
      near.push(out.fog!.near);
      sunIntensity.push(out.sun.intensity);
      // Brown is warm, not dark: what rises is red *against* blue, and the base
      // fog is a cool grey-blue whose red channel falls as it browns.
      fogWarmth.push(((out.fog!.color >> 16) & 0xff) - (out.fog!.color & 0xff));
      sunBlue.push(out.sun.color & 0xff);
    }
    for (let index = 1; index < loads.length; index += 1) {
      // The haze comes closer.
      assert.ok(near[index]! < near[index - 1]!, `near ${near.join()}`);
      // The sun dims.
      assert.ok(sunIntensity[index]! < sunIntensity[index - 1]!, `sun ${sunIntensity.join()}`);
      // The air goes browner.
      assert.ok(fogWarmth[index]! > fogWarmth[index - 1]!, `fog ${fogWarmth.join()}`);
      // ...and less blue in the sun, which is the half that reads as fire.
      assert.ok(sunBlue[index]! < sunBlue[index - 1]!, `sun blue ${sunBlue.join()}`);
    }
    // Capped rather than saturating: a full load is a bad day, not a blackout.
    assert.ok(sunIntensity[4]! > clear.sun.intensity * 0.5, `${sunIntensity[4]}`);
    assert.ok(near[4]! > 40, `${near[4]}`);
  });

  it("clamps a load above one rather than running past the cap", () => {
    const full = officeDaylight(clear, site, 1);
    for (const over of [1.0000001, 1.5, 40, 1e9]) {
      assert.deepEqual(officeDaylight(clear, site, over).fog, full.fog, `${over}`);
      assert.deepEqual(officeDaylight(clear, site, over).sun, full.sun, `${over}`);
    }
  });

  it("never draws a brown sky without a sentence under it", () => {
    for (const load of [0.05, 0.4, 0.9, 1]) {
      const caption = smokeCaption(load);
      assert.ok(caption, `${load}`);
      // It must say the number is not a measurement of this building's air.
      assert.match(caption, /not a measurement/i);
      assert.match(caption, /fires currently on the board/i);
    }
  });

  it("leaves a fogless rig fogless, smoke or no smoke", () => {
    assert.equal(officeDaylight({ ...clear, fog: null }, site, 1).fog, null);
  });
});
