/**
 * The environment rig: lifecycle, caching and the contract it must not break.
 *
 * There is no GL context under `node --test`, so the renderer here is a fake —
 * but a fake of a very specific kind. `PMREMGenerator` never touches WebGL
 * directly: it allocates plain `WebGLRenderTarget` objects, builds plain
 * meshes, and reaches the GPU only through `renderer.render`,
 * `renderer.setRenderTarget` and a handful of state accessors. Stubbing those
 * out runs the *real* generator, the real target allocation and the real
 * blur chain, and leaves only the pixels unwritten. So these tests exercise the
 * actual code path the browser takes, which is the difference between testing
 * the rig and testing a mock of it.
 *
 * The one assertion that needs the fake to be more than a no-op is the dispose
 * check: the render targets the rig allocates are private to it, so they are
 * captured on their way through `setRenderTarget` and identified afterwards by
 * the texture the scene ended up holding.
 */

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 { createEnvironmentRig } from "../../engine/environmentRig.ts";
import type { LightingState } from "../../engine/types.ts";

const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");

// ---- A renderer that allocates but does not draw ---------------------------

interface FakeRenderer {
  renders: number;
  targets: Set<THREE.WebGLRenderTarget>;
  failNext: boolean;
  as(): THREE.WebGLRenderer;
}

function fakeRenderer(): FakeRenderer {
  const targets = new Set<THREE.WebGLRenderTarget>();
  const state = {
    renders: 0,
    targets,
    failNext: false,
    as(): THREE.WebGLRenderer {
      return stub as unknown as THREE.WebGLRenderer;
    },
  };

  const stub = {
    autoClear: true,
    toneMapping: THREE.NoToneMapping,
    xr: { enabled: false },
    state: { buffers: { depth: { getReversed: () => false } } },
    getRenderTarget: () => null,
    getActiveCubeFace: () => 0,
    getActiveMipmapLevel: () => 0,
    getClearColor: (target: THREE.Color) => target,
    getClearAlpha: () => 1,
    setClearColor: () => {},
    setClearAlpha: () => {},
    clearDepth: () => {},
    compile: () => {},
    setRenderTarget(target: THREE.WebGLRenderTarget | null) {
      if (target) targets.add(target);
    },
    render() {
      if (state.failNext) {
        state.failNext = false;
        throw new Error("simulated context loss");
      }
      state.renders++;
    },
  };

  return state;
}

// ---- Lighting states -------------------------------------------------------

function lightingState(overrides: Partial<LightingState> = {}): LightingState {
  return {
    sun: { direction: [0.31, 0.86, 0.4], color: 0xfff3e0, intensity: 2.35 },
    hemisphere: { sky: 0xdcecf7, ground: 0x6b6f5e, intensity: 0.92 },
    ambient: { color: 0xffffff, intensity: 0.24 },
    sky: { top: 0x8fb8d8, horizon: 0xd9e6ee },
    fog: { color: 0xd9e6ee, near: 210, far: 460 },
    ...overrides,
  };
}

/** The render target the scene's environment texture came out of. */
function targetOf(fake: FakeRenderer, scene: THREE.Scene): THREE.WebGLRenderTarget {
  for (const target of fake.targets) {
    if (target.texture === scene.environment) return target;
  }
  throw new Error("no allocated render target owns the scene's environment texture");
}

// ---- Lifecycle -------------------------------------------------------------

for (const kind of ["office", "city"] as const) {
  test(`apply(..., "${kind}") leaves a texture on the scene and dispose frees it`, () => {
    const fake = fakeRenderer();
    const rig = createEnvironmentRig(fake.as());
    const scene = new THREE.Scene();

    rig.apply(scene, lightingState(), kind);

    assert.ok(
      scene.environment instanceof THREE.Texture,
      "apply must leave a real texture on the scene, not a placeholder",
    );
    assert.equal(scene.environmentIntensity, 1);
    assert.ok(fake.renders > 0, "the PMREM chain never rendered");

    // The PMREM target, identified by the texture the scene is holding.
    const target = targetOf(fake, scene);
    let freed = false;
    target.addEventListener("dispose", () => {
      freed = true;
    });

    rig.dispose();

    assert.equal(scene.environment, null, "dispose must take the environment back off the scene");
    assert.ok(freed, "dispose must free the PMREM render target, not just drop the reference");
  });
}

test("apply adds nothing to the scene graph", () => {
  const fake = fakeRenderer();
  const rig = createEnvironmentRig(fake.as());
  const scene = new THREE.Scene();
  scene.add(new THREE.Object3D());

  rig.apply(scene, lightingState(), "office");
  rig.apply(scene, lightingState(), "city");

  // The environment is a property, not a child. A rig that parented its probe
  // room into the caller's scene would light the office with a nine-quad box
  // floating inside it.
  assert.equal(scene.children.length, 1);
  rig.dispose();
});

test("dispose clears every scene the rig ever wrote to", () => {
  const fake = fakeRenderer();
  const rig = createEnvironmentRig(fake.as());
  const a = new THREE.Scene();
  const b = new THREE.Scene();

  rig.apply(a, lightingState(), "city");
  rig.apply(b, lightingState(), "city");
  assert.ok(a.environment);
  assert.ok(b.environment);

  rig.dispose();
  assert.equal(a.environment, null);
  assert.equal(b.environment, null);
});

test("apply after dispose is inert rather than fatal", () => {
  const fake = fakeRenderer();
  const rig = createEnvironmentRig(fake.as());
  const scene = new THREE.Scene();
  rig.dispose();

  rig.apply(scene, lightingState(), "city");
  assert.equal(scene.environment, null);
  rig.dispose();
});

// ---- Caching ---------------------------------------------------------------

test("an unchanged lighting state does not rebuild", () => {
  const fake = fakeRenderer();
  const rig = createEnvironmentRig(fake.as());
  const scene = new THREE.Scene();

  rig.apply(scene, lightingState(), "city");
  const first = scene.environment;
  const after = fake.renders;

  for (let i = 0; i < 20; i++) rig.apply(scene, lightingState(), "city");

  assert.equal(scene.environment, first, "the environment texture was replaced for no reason");
  assert.equal(fake.renders, after, "the PMREM chain ran again for an unchanged sky");
  rig.dispose();
});

test("a change below the quantisation step does not rebuild", () => {
  const fake = fakeRenderer();
  const rig = createEnvironmentRig(fake.as());
  const scene = new THREE.Scene();

  rig.apply(scene, lightingState(), "city");
  const first = scene.environment;

  // `Atmosphere` interpolates continuously, so every field of a LightingState
  // moves a fraction every frame. Rebuilding on that would run the whole chain
  // sixty times a second to produce sixty indistinguishable environments.
  rig.apply(
    scene,
    lightingState({
      sun: { direction: [0.3104, 0.8601, 0.4002], color: 0xfff3e1, intensity: 2.352 },
    }),
    "city",
  );

  assert.equal(scene.environment, first);
  rig.dispose();
});

test("a real change of hour rebuilds, and frees what it replaced", () => {
  const fake = fakeRenderer();
  const rig = createEnvironmentRig(fake.as());
  const scene = new THREE.Scene();

  rig.apply(scene, lightingState(), "city");
  const noon = targetOf(fake, scene);
  let freed = false;
  noon.addEventListener("dispose", () => {
    freed = true;
  });

  rig.apply(
    scene,
    lightingState({
      sun: { direction: [0.86, 0.06, -0.5], color: 0xc2795c, intensity: 0.58 },
      sky: { top: 0x2a4275, horizon: 0x9a6a63 },
    }),
    "city",
  );

  assert.notEqual(scene.environment, noon.texture, "sunset must not reflect noon's sky");
  assert.ok(freed, "the replaced PMREM target leaked");
  rig.dispose();
});

test("city and office are cached separately and do not evict each other", () => {
  const fake = fakeRenderer();
  const rig = createEnvironmentRig(fake.as());
  const city = new THREE.Scene();
  const office = new THREE.Scene();

  rig.apply(city, lightingState(), "city");
  const cityEnv = city.environment;
  rig.apply(office, lightingState(), "office");
  const officeEnv = office.environment;

  assert.notEqual(cityEnv, officeEnv, "a room and a sky must not be the same environment");

  const before = fake.renders;
  rig.apply(city, lightingState(), "city");
  assert.equal(city.environment, cityEnv);
  assert.equal(fake.renders, before, "the office build evicted the city's cached environment");
  rig.dispose();
});

test("rebuilding one kind does not repoint scenes on the other", () => {
  // A page holds both at once — CONTRACT.md §1 keeps the city alive and paused
  // while an office is on screen — so the loop that repoints scenes after a
  // rebuild has to know which kind each scene is on. Getting this wrong lights
  // an office through a wall with the city's sunset.
  const fake = fakeRenderer();
  const rig = createEnvironmentRig(fake.as());
  const city = new THREE.Scene();
  const office = new THREE.Scene();

  rig.apply(city, lightingState(), "city");
  rig.apply(office, lightingState(), "office");
  const officeEnv = office.environment;

  rig.apply(
    city,
    lightingState({
      sun: { direction: [0.86, 0.06, -0.5], color: 0xc2795c, intensity: 0.58 },
      sky: { top: 0x2a4275, horizon: 0x9a6a63 },
    }),
    "city",
  );

  assert.equal(office.environment, officeEnv, "the office was handed the city's new sky");
  assert.notEqual(city.environment, officeEnv);
  rig.dispose();
});

test("a scene left holding a freed target is repointed at the replacement", () => {
  // The other half of the same loop: two city scenes, one rebuild, and the one
  // that did not ask must not be left pointing at a disposed render target.
  const fake = fakeRenderer();
  const rig = createEnvironmentRig(fake.as());
  const a = new THREE.Scene();
  const b = new THREE.Scene();

  rig.apply(a, lightingState(), "city");
  rig.apply(b, lightingState(), "city");

  rig.apply(
    a,
    lightingState({ sun: { direction: [0.86, 0.06, -0.5], color: 0xc2795c, intensity: 0.58 } }),
    "city",
  );

  assert.equal(b.environment, a.environment, "the second city scene kept a freed texture");
  rig.dispose();
});

// ---- Degradation -----------------------------------------------------------

test("a renderer that throws costs the environment, not the frame", () => {
  const fake = fakeRenderer();
  const rig = createEnvironmentRig(fake.as());
  const scene = new THREE.Scene();

  const warnings: unknown[] = [];
  const warn = console.warn;
  console.warn = (...args: unknown[]) => warnings.push(args);
  try {
    fake.failNext = true;
    rig.apply(scene, lightingState(), "city");
  } finally {
    console.warn = warn;
  }

  assert.equal(scene.environment, null, "a failed build must not leave a half-made environment");
  assert.equal(warnings.length, 1, "a missing environment is undiagnosable from the picture alone");

  // And it recovers: the next apply tries again rather than latching off.
  rig.apply(scene, lightingState(), "city");
  const recovered: unknown = scene.environment;
  assert.ok(recovered instanceof THREE.Texture);
  rig.dispose();
});

// ---- CONTRACT §4 -----------------------------------------------------------

test("the rig constructs no light of any kind", () => {
  // The literal form of the rule, checked against the source, because the whole
  // value of CONTRACT.md §4 is that there is exactly one light owner and this is
  // the module most likely to be tempted into becoming a second one.
  const source = readFileSync(path.join(ROOT, "src/engine/environmentRig.ts"), "utf8");
  const lights = source.match(
    /new\s+THREE\.(Directional|Point|Spot|Rect(Area)?|Hemisphere|Ambient|Light\b)/g,
  );
  assert.equal(lights, null, `environmentRig constructs lights: ${lights?.join(", ")}`);
  // And no import of three's fixed studio room either — the office probe has to
  // stay derived from the LightingState, or the reflections stop knowing what
  // time it is. (The prose above the code discusses `RoomEnvironment` by name,
  // hence matching the import statement rather than the word.)
  assert.doesNotMatch(source, /^\s*import[^\n]*RoomEnvironment/m);
});

// ---- The prefetch hazard ---------------------------------------------------

/**
 * A board being built off-stage must not relight the board on screen.
 *
 * ## The mechanism
 *
 * `createScene` calls `options.environment?.apply(scene, opening, "city")` at
 * `scene.ts:811` — unconditionally, and two hundred lines above the
 * `if (options.present !== false) stage.setScene(stageScene)` at `:1331` that
 * decides whether anybody is going to look at this scene at all. `main.ts`
 * builds one rig on one renderer for the life of the page, so an off-stage
 * board and the visible one are the same rig.
 *
 * The rig caches exactly one PMREM target per kind, keyed on a fingerprint of
 * the lighting. On a key **miss** it does three things, in this order: renders
 * and convolves a new probe on the shared renderer, `dispose()`s the target the
 * visible board is currently using, and then walks `applied` reassigning every
 * scene of that kind to the new texture.
 *
 * That is correct and necessary when the miss comes from time passing. It is a
 * defect when the miss comes from a board **nobody is looking at**: a board
 * prefetched for another part of the state is observed at its own centre, which
 * across California is about forty minutes of apparent solar time, so its sun
 * direction — and therefore its key — *will* differ. The visible board's sky
 * would change because something invisible was loaded behind it.
 *
 * It is harmless today only because every build is followed within about
 * 800 ms by the swap that makes it the visible board. It stops being harmless
 * the moment anything builds a board it does not intend to show.
 *
 * ## What this asserts
 *
 * The property, at the rig's own boundary: applying to a scene while telling the
 * rig it is off-stage must not disturb any scene already applied. Written before
 * the fix, and it failed before the fix, which is the only way to know a
 * regression test is testing anything.
 */
test("an off-stage apply does not relight the scene already on screen", () => {
  const fake = fakeRenderer();
  const rig = createEnvironmentRig(fake.as());

  const visible = new THREE.Scene();
  rig.apply(visible, lightingState(), "city");
  const wasShowing = visible.environment;
  const wasTarget = targetOf(fake, visible);
  assert.ok(wasShowing, "the visible board never got an environment");

  // A board prefetched for elsewhere in the state: same kind, different sun.
  // This is the key miss that does the damage.
  const offstage = new THREE.Scene();
  rig.apply(offstage, lightingState({ sun: { direction: [0.62, 0.51, -0.6], color: 0xffd9a8, intensity: 1.7 } }), "city", {
    offstage: true,
  });

  assert.equal(
    visible.environment,
    wasShowing,
    "building a board off-stage reassigned the visible board's environment",
  );
  assert.equal(
    wasTarget.texture,
    wasShowing,
    "building a board off-stage disposed the texture the visible board is using",
  );

  rig.dispose();
});

/**
 * The other half, and the reason the fix is a deferral rather than a refusal:
 * the off-stage board must still end up correctly lit when it is presented. A
 * fix that simply skipped the apply forever would give the incoming board no
 * environment at all, which is the "plastic" look this rig exists to cure.
 *
 * Two cases, and they are different on purpose. With nothing cached there is
 * nothing to borrow, so the build happens at presentation — which is correct,
 * and is the same one build the board would have paid anyway. With a probe
 * already cached on the same key, the off-stage board borrows it and
 * presentation costs no convolution at all, which is the case that actually
 * matters: it is what a prefetched board hits when the visitor arrives.
 */
test("an off-stage board is lit when it is finally presented", () => {
  const fake = fakeRenderer();
  const rig = createEnvironmentRig(fake.as());

  const offstage = new THREE.Scene();
  rig.apply(offstage, lightingState(), "city", { offstage: true });
  rig.apply(offstage, lightingState(), "city");
  assert.ok(offstage.environment, "a presented board has no environment");

  rig.dispose();
});

test("a prefetched board borrows the cached probe and presents for free", () => {
  const fake = fakeRenderer();
  const rig = createEnvironmentRig(fake.as());

  const visible = new THREE.Scene();
  rig.apply(visible, lightingState(), "city");
  const built = fake.renders;
  assert.ok(built > 0, "the first apply did not build anything");

  // The board built behind it, at the same hour, is the common case.
  const prefetched = new THREE.Scene();
  rig.apply(prefetched, lightingState(), "city", { offstage: true });
  assert.equal(prefetched.environment, visible.environment, "the probe was not borrowed");
  assert.equal(fake.renders, built, "an off-stage apply convolved a new probe");

  // And arriving costs nothing.
  rig.apply(prefetched, lightingState(), "city");
  assert.equal(fake.renders, built, "presenting a cache hit convolved a new probe");
  assert.ok(prefetched.environment, "the presented board lost its environment");

  rig.dispose();
});

/**
 * The failure mode the fix must not introduce: an off-stage board whose sun
 * differs must not go dark. It borrows the cached probe — slightly wrong for its
 * own hour, and invisible, and corrected the instant it is shown.
 */
test("an off-stage board at a different hour borrows rather than going unlit", () => {
  const fake = fakeRenderer();
  const rig = createEnvironmentRig(fake.as());

  const visible = new THREE.Scene();
  rig.apply(visible, lightingState(), "city");

  const elsewhere = new THREE.Scene();
  rig.apply(
    elsewhere,
    lightingState({ sun: { direction: [0.62, 0.51, -0.6], color: 0xffd9a8, intensity: 1.7 } }),
    "city",
    { offstage: true },
  );
  assert.ok(elsewhere.environment, "an off-stage board at another hour was left with no environment");
  assert.equal(elsewhere.environment, visible.environment, "it did not borrow the cached probe");

  rig.dispose();
});
