/**
 * The tone curve, and the light table that is tuned against it.
 *
 * These two things are one change and are tested in one file on purpose. Turning
 * on ACES without re-tuning `atmosphere.ts` produces a world that is correctly
 * *shaped* and too dark; re-tuning `atmosphere.ts` without ACES produces a world
 * that clips even harder than it did. Either half on its own is a regression, so
 * the assertions below fail if either half is reverted alone.
 *
 * `createStage` itself cannot be called here — it constructs a real
 * `WebGLRenderer`, and `node --test` has no GL context and no canvas. What can
 * be checked, and is, is (1) that the three renderer properties are actually
 * assigned in the source, which is the thing a careless merge would drop, (2)
 * that the constants they are assigned from still mean what the rest of the
 * repo assumes, and (3) that the light table's *shape* still matches the curve.
 */

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
import * as THREE from "three";

import { createAtmosphere, type Environment } from "../../engine/atmosphere.ts";
import type { MoonPosition } from "../../engine/atmosphere.ts";
import { DEFAULT_TONE_MAPPING_EXPOSURE } from "../../engine/stage.ts";
import type { SolarPosition } from "../../engine/solar.ts";

const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
const STAGE_SOURCE = readFileSync(path.join(ROOT, "src/engine/stage.ts"), "utf8");

// ---- The renderer configuration -------------------------------------------

test("the stage configures ACES, an exposure and an explicit output colour space", () => {
  assert.match(
    STAGE_SOURCE,
    /renderer\.toneMapping\s*=\s*THREE\.ACESFilmicToneMapping/,
    "the renderer must tone map; NoToneMapping is saturate() and clips every value above 1.0",
  );
  assert.match(
    STAGE_SOURCE,
    /renderer\.toneMappingExposure\s*=/,
    "the exposure must be assigned, not inherited",
  );
  assert.match(
    STAGE_SOURCE,
    /renderer\.outputColorSpace\s*=\s*THREE\.SRGBColorSpace/,
    "the output transfer function must be stated rather than relying on a library default",
  );
  assert.match(
    STAGE_SOURCE,
    /exposure\?:\s*number/,
    "StageOptions must expose the exposure, so a capture or a test can drive it",
  );
});

test("the three constants the stage names still exist in three", () => {
  // A rename upstream would leave the assignments above compiling against
  // `undefined` and silently restore the clipping renderer.
  assert.equal(typeof THREE.ACESFilmicToneMapping, "number");
  assert.notEqual(THREE.ACESFilmicToneMapping, THREE.NoToneMapping);
  assert.equal(THREE.SRGBColorSpace, "srgb");
});

// ---- What the exposure means ----------------------------------------------

/**
 * Three's own ACES fit, transcribed from `tonemapping_pars_fragment.glsl.js`.
 *
 * Duplicated here deliberately. The point of these assertions is not to check
 * that three's shader does what three's shader does — it is to check that the
 * *exposure this repo chose* lands the values this repo actually renders in the
 * places they need to be, and that requires evaluating the curve on the CPU.
 */
function rrtAndOdtFit(v: number): number {
  const a = v * (v + 0.0245786) - 0.000090537;
  const b = v * (0.983729 * v + 0.432951) + 0.238081;
  return a / b;
}

/** Linear radiance in, display-linear out. Neutral colours only, so no matrices. */
function aces(linear: number, exposure = DEFAULT_TONE_MAPPING_EXPOSURE): number {
  return Math.min(1, Math.max(0, rrtAndOdtFit((linear * exposure) / 0.6)));
}

/** Display-linear to what the panel shows, so thresholds can be read as levels. */
function srgb(v: number): number {
  return v <= 0.0031308 ? v * 12.92 : 1.055 * Math.pow(v, 1 / 2.4) - 0.055;
}

test("the chosen exposure keeps mid grey near the middle", () => {
  // An 18% card is the definition of a neutral exposure. A little above 0.5 is
  // the deliberate lift documented on the constant; a long way from it means
  // somebody has turned this into a brightness slider.
  const grey = srgb(aces(0.18));
  assert.ok(grey > 0.5 && grey < 0.58, `18% grey displayed at ${grey.toFixed(3)}`);
});

test("values above 1.0 stay separable, which is the whole reason for the change", () => {
  // These are the numbers the library actually drives: `lightDiffuser` glows at
  // 0.85, `screenContent` at 0.9, `deviceIndicator` at 1.0, and the office
  // assets reach 3.2. Under NoToneMapping every one of them displayed as 1.0.
  const levels = [0.85, 1, 1.6, 2.35, 3.2, 6].map((v) => aces(v));
  for (let i = 1; i < levels.length; i++) {
    const previous = levels[i - 1] ?? 0;
    const current = levels[i] ?? 0;
    assert.ok(current > previous, `radiance step ${i} did not brighten`);
    assert.ok(current < 1, `radiance step ${i} clipped at 1.0`);
    assert.ok(
      current - previous > 0.002,
      `radiance step ${i} moved by ${(current - previous).toFixed(4)}, which is not a visible difference`,
    );
  }
});

test("the curve is monotonic across the whole range it is fed", () => {
  let previous = -1;
  for (let linear = 0; linear <= 12; linear += 0.05) {
    const value = aces(linear);
    assert.ok(value >= previous, `not monotonic at ${linear.toFixed(2)}`);
    previous = value;
  }
});

test("the exposure is a photographic dial, not a brightness control", () => {
  assert.ok(
    DEFAULT_TONE_MAPPING_EXPOSURE > 0.7 && DEFAULT_TONE_MAPPING_EXPOSURE < 1.8,
    `exposure ${DEFAULT_TONE_MAPPING_EXPOSURE} is outside the range the light table is tuned for`,
  );
});

// ---- The light table, tuned against that curve -----------------------------

const NO_MOON: MoonPosition = {
  azimuth: 0,
  elevation: -40,
  illuminated: 0,
  phase: 0,
  distanceKm: 384_400,
};

function sun(elevation: number): SolarPosition {
  return { azimuth: 180, elevation, declination: 0, equationOfTime: 0 };
}

function environment(elevation: number): Environment {
  return { time: new Date("2026-06-21T20:00:00Z"), sun: sun(elevation), moon: NO_MOON, weather: null };
}

function rig(elevation: number) {
  // A city-scale atmosphere with the moon switched off, so what comes back is
  // the keyframe table plus the night floor and nothing else.
  const atmosphere = createAtmosphere({ lng: -122.4, metresPerUnit: 94, moonlight: null });
  return atmosphere.apply(environment(elevation));
}

test("the day stops raise the key and lower the fill", () => {
  const noon = rig(65);
  // Contrast, not brightness: a shoulder means the sun no longer has to be held
  // back to keep a lit wall off pure white, so the key went up and the fill came
  // down. If someone restores the old table these three flip together.
  assert.ok(noon.sun.intensity >= 2.5, `peak sun ${noon.sun.intensity} is below the retuned key`);
  assert.ok(
    noon.hemisphere.intensity <= 1.0,
    `peak hemisphere ${noon.hemisphere.intensity} is above the retuned fill`,
  );
  assert.ok(
    noon.ambient.intensity <= 0.25,
    `peak ambient ${noon.ambient.intensity} is above the retuned fill`,
  );

  // The ratio is the thing that reads as modelling. Under the old table it was
  // 2.35 / 1.10 = 2.1; it must not go back there.
  const keyToFill = noon.sun.intensity / noon.hemisphere.intensity;
  assert.ok(keyToFill > 2.4, `key-to-fill ratio ${keyToFill.toFixed(2)} is too flat`);
});

test("the night floor sits high enough to survive the ACES toe", () => {
  const night = rig(-18);
  // The toe costs roughly 18% of the display value of a moonless night. The
  // floor was raised by a third in linear light to pay for it, and these are the
  // floors themselves rather than the keyframe rows, because the floor binds.
  assert.ok(
    night.hemisphere.intensity >= 1.0,
    `night hemisphere ${night.hemisphere.intensity} is back below the raised floor`,
  );
  assert.ok(
    night.ambient.intensity >= 0.28,
    `night ambient ${night.ambient.intensity} is back below the raised floor`,
  );
  // And still a night: the fill is a fraction of noon's key, not a match for it.
  assert.ok(night.sun.intensity < 0.5, "the night sidelight has become a sun");
});

test("the sun brightens monotonically as it rises", () => {
  let previous = -1;
  for (const elevation of [-18, -12, -6, -0.4, 3, 8, 25, 65]) {
    const intensity = rig(elevation).sun.intensity;
    assert.ok(intensity > previous, `sun intensity fell between stops at ${elevation} degrees`);
    previous = intensity;
  }
});

test("the sky colours were left alone, because they are not tone mapped", () => {
  // The sky is a world-space `ShaderMaterial` dome marked `toneMapped = false`,
  // so it writes its components straight out, and fog mixes after the tone map
  // from an already-encoded uniform.
  // So the one thing the re-tune must NOT have touched is the sky, and the noon
  // stop still reproduces the city's own declared daylight colours.
  const noon = rig(25);
  assert.ok(noon.sky);
  assert.equal(noon.sky?.top, 0x8fb8d8);
  assert.equal(noon.sky?.horizon, 0xd9e6ee);
});
