/**
 * The visible terrain is decimated where the ground is flat, and this is what
 * holds the decimation honest.
 *
 * Everything `lodPatches` does is invisible by construction and therefore
 * invisible to review: a wrong tolerance, a wrong diagonal or a dropped land
 * test all produce a mesh that builds, renders and passes every other test in
 * this directory, and shows up only as a board that has quietly lost its
 * coastline or grown a crack. The four facts below are the ones the pictures
 * were checked against, and each of them is a number.
 *
 * The boards are synthetic and small — twenty cells a side — for the reason
 * `seaAndTerrain.test.ts` gives: none of this is about California, and a real
 * pack would couple a render test to a city's coastline.
 */

import assert from "node:assert/strict";
import test from "node:test";
import * as THREE from "three";

import { setReconcile } from "../../cities/reconcile.ts";
import { createTerrain } from "../../engine/terrain.ts";
import type { City, ScenePalette } from "../../engine/types.ts";
import { World } from "../../engine/world.ts";

/** A square island in the middle of a one-degree board, at 0.05° per cell. */
/*
 * Every fixture in this file is a synthetic one-hill board built to isolate one
 * LOD guard, and `exaggeration` is derived from a pack's own blended peak — so
 * left on it would rescale a deliberately gentle 40 m swell into something the
 * height guard fires on, and the colour guard this file is measuring would be
 * swamped by it. The reconciliation is a statement about the three real packs;
 * these are not packs.
 */
setReconcile(false);

const BASE: Omit<City, "hills"> = {
  id: "lod-board",
  name: "LOD Board",
  center: { lat: 37, lng: -122 },
  bounds: { minLat: 36.5, maxLat: 37.5, minLng: -122.5, maxLng: -121.5 },
  latScale: 100,
  verticalExaggeration: 2,
  cellLat: 0.05,
  cellLng: 0.05,
  coastFalloff: 0.02,
  /*
   * The island's rim sits half a cell outside the lattice corners it wants, so
   * its land cells run 4..15 on both axes. That is deliberate: the patch levels
   * are aligned to their own multiple, and an island whose interior straddled
   * the alignment would make this file a test of where the coast happens to
   * fall rather than of whether flat ground collapses.
   */
  landmasses: [
    [
      [36.65, -122.35],
      [37.35, -122.35],
      [37.35, -121.65],
      [36.65, -121.65],
    ],
  ],
  parks: [],
  inlandWater: [],
  districts: [],
  landmarks: [],
  bridges: [],
  roads: [],
  chapters: [],
};

/** Flat: no hills at all, so the whole island is one plane at sea level. */
const FLAT: City = { ...BASE, hills: [] };

/**
 * Rough: a hill every other cell, which is the frequency the lattice itself is
 * sized for. Nothing here may collapse, because a bilinear patch across two
 * cells of this is wrong by most of a hill.
 */
const ROUGH: City = {
  ...BASE,
  hills: (() => {
    const hills: City["hills"] = [];
    for (let i = 0; i < 6; i++) {
      for (let j = 0; j < 6; j++) {
        hills.push({
          name: `h${i}-${j}`,
          lat: 36.75 + i * 0.1,
          lng: -122.25 + j * 0.1,
          elevation: 600,
          radius: 0.05,
        });
      }
    }
    return hills;
  })(),
};

/**
 * A board that **is** chunked: five degrees a side, so its ground spans more
 * than one `TERRAIN_CHUNK_METRES` cell on both axes, with an island that leaves
 * some cells empty.
 *
 * The one thing that makes it chunked is the `detail: true` district.
 * `createTerrain` gates the split on `city.districts.some(d => d.detail)`, which
 * `cities/unify.ts` is the only place in the repo that writes — so this fixture
 * is standing in for the merged board and every other board in this file is
 * standing in for the five that must not move.
 *
 * The district itself is never built here; `createTerrain` reads nothing from it
 * but the flag.
 */
const CHUNKED: City = {
  ...BASE,
  id: "chunked-board",
  bounds: { minLat: 34.5, maxLat: 39.5, minLng: -124.5, maxLng: -119.5 },
  center: { lat: 37, lng: -122 },
  cellLat: 0.25,
  cellLng: 0.25,
  landmasses: [
    [
      [35.4, -123.6],
      [38.6, -123.6],
      [38.6, -120.4],
      [35.4, -120.4],
    ],
  ],
  hills: [
    { name: "north-ridge", lat: 38, lng: -123, elevation: 1_400, radius: 0.5 },
    { name: "south-ridge", lat: 36, lng: -121.4, elevation: 900, radius: 0.6 },
  ],
  districts: [
    {
      id: "metro",
      name: "Metro",
      polygon: [
        [36.9, -122.1],
        [37.1, -122.1],
        [37.1, -121.9],
        [36.9, -121.9],
      ],
      gridAngle: 0,
      minHeight: 10,
      maxHeight: 60,
      towerChance: 0,
      palette: "downtown",
      detail: true,
    },
  ],
};

async function board(city: City): Promise<World> {
  const world = new World(city);
  assert.equal(await world.ready(), true, "the synthetic board failed to build a heightfield");
  return world;
}

/** Triangles the surface would have had if every land cell were drawn alone. */
function cellByCellTriangles(world: World): number {
  const { latSteps, lngSteps, land } = world.lattice();
  const w = lngSteps + 1;
  let cells = 0;
  for (let i = 0; i < latSteps; i++) {
    for (let j = 0; j < lngSteps; j++) {
      const a = i * w + j;
      if (land[a] && land[a + 1] && land[a + w] && land[a + w + 1]) cells++;
    }
  }
  return cells * 2;
}

/** The ground the cell-by-cell surface covered, in square scene units. */
function cellByCellArea(world: World): number {
  const { latSteps, lngSteps, lats, lngs, land } = world.lattice();
  const w = lngSteps + 1;
  let area = 0;
  for (let i = 0; i < latSteps; i++) {
    for (let j = 0; j < lngSteps; j++) {
      const a = i * w + j;
      if (!land[a] || !land[a + 1] || !land[a + w] || !land[a + w + 1]) continue;
      const [x0, z0] = world.project(lats[i] as number, lngs[j] as number);
      const [x1, z1] = world.project(lats[i + 1] as number, lngs[j + 1] as number);
      area += Math.abs((x1 - x0) * (z1 - z0));
    }
  }
  return area;
}

/**
 * The footprint of a range of the index, in square scene units.
 *
 * Area rather than a cell list because that is the property the decimation has
 * to preserve exactly: the patches cover the same ground, they just cover it
 * with fewer triangles. A merge that swallowed a coastal cell, or a T-junction
 * that left a gap, changes this number and nothing else.
 */
function footprint(geo: THREE.BufferGeometry, start: number, count: number): number {
  const index = geo.getIndex() as THREE.BufferAttribute;
  const pos = geo.getAttribute("position") as THREE.BufferAttribute;
  let area = 0;
  for (let at = start; at < start + count; at += 3) {
    const a = index.getX(at);
    const b = index.getX(at + 1);
    const c = index.getX(at + 2);
    // Twice the signed area of the triangle projected onto the ground plane.
    area += Math.abs(
      (pos.getX(b) - pos.getX(a)) * (pos.getZ(c) - pos.getZ(a)) -
        (pos.getX(c) - pos.getX(a)) * (pos.getZ(b) - pos.getZ(a)),
    ) / 2;
  }
  return area;
}

/**
 * The one mesh an un-chunked board's terrain group holds.
 *
 * `createTerrain` returns a `THREE.Group` since the merged board's surface was
 * split into frustum-cullable chunks. Every board in this file is synthetic and
 * declares no `detail` districts, so every one of them takes the un-chunked
 * branch and the group holds exactly one mesh — today's geometry, today's
 * `seen`/`cast` packing, today's two shadow hooks. That is deliberate: these
 * assertions were always about that path, and now they say so.
 */
function surface(group: THREE.Object3D): THREE.Mesh {
  assert.equal(group.children.length, 1, "a board with no detail districts must not be chunked");
  return group.children[0] as THREE.Mesh;
}

function visibleTriangles(mesh: THREE.Mesh): number {
  return mesh.geometry.drawRange.count / 3;
}

function casterTriangles(mesh: THREE.Mesh): number {
  const geo = mesh.geometry;
  return ((geo.getIndex() as THREE.BufferAttribute).count - geo.drawRange.count) / 3;
}

test("flat ground collapses and cell-scale relief does not", async () => {
  const flat = await board(FLAT);
  const rough = await board(ROUGH);
  const flatMesh = surface(createTerrain(flat));
  const roughMesh = surface(createTerrain(rough));

  const flatBase = cellByCellTriangles(flat);
  const roughBase = cellByCellTriangles(rough);
  assert.ok(flatBase > 200, `the flat board is too small to be a test: ${flatBase} triangles`);

  /*
   * A plane is a plane at any resolution, so the flat island must come out at
   * the coarsest level the patch list allows — a sixteenth of the cell-by-cell
   * count in the interior, plus whatever the coast leaves unaligned.
   */
  assert.ok(
    visibleTriangles(flatMesh) < flatBase / 4,
    `flat ground kept ${visibleTriangles(flatMesh)} of ${flatBase} triangles`,
  );

  /*
   * And the opposite, which is the half that a too-loose tolerance would break
   * silently: ground that moves every cell has to keep every cell. This is the
   * failure that turns a mountain range into a bump map, and it is the reason
   * the tolerance is a measured number rather than a large one.
   */
  assert.ok(
    visibleTriangles(roughMesh) > roughBase * 0.9,
    `relief at lattice frequency was decimated to ${visibleTriangles(roughMesh)} of ${roughBase}`,
  );
});

test("the collapsed surface covers exactly the ground the cells covered", async () => {
  for (const city of [FLAT, ROUGH]) {
    const world = await board(city);
    const mesh = surface(createTerrain(world));
    const drawn = footprint(mesh.geometry, mesh.geometry.drawRange.start, mesh.geometry.drawRange.count);
    const expected = cellByCellArea(world);
    /*
     * The coastline is the whole point of this assertion. A patch is only
     * collapsed when every one of its lattice points is on land, so the set of
     * ground covered is unchanged down to the last stair-step — and if a merge
     * ever reached across the shore, or a T-junction left a hole, the area is
     * where it shows.
     */
    assert.ok(
      Math.abs(drawn - expected) < expected * 1e-6,
      `${city.id} covers ${drawn} square units against ${expected}`,
    );
  }
});

test("no point of the collapsed surface strays from the heightfield", async () => {
  const world = await board(ROUGH);
  const mesh = surface(createTerrain(world));
  mesh.updateMatrixWorld(true);
  const { latSteps, lngSteps, lats, lngs, height, land } = world.lattice();
  const w = lngSteps + 1;

  const raycaster = new THREE.Raycaster();
  const down = new THREE.Vector3(0, -1, 0);
  const from = new THREE.Vector3();
  let worst = 0;
  let sampled = 0;

  for (let i = 0; i <= latSteps; i++) {
    for (let j = 0; j <= lngSteps; j++) {
      const k = i * w + j;
      if (!land[k]) continue;
      const [x, z] = world.project(lats[i] as number, lngs[j] as number);
      // Nudged inward, because a ray down the exact rim of the mesh is a
      // coin toss between hitting the edge triangle and missing the board.
      from.set(x + 1e-4, 10_000, z + 1e-4);
      raycaster.set(from, down);
      const hit = raycaster.intersectObject(mesh, false)[0];
      if (!hit) continue;
      sampled++;
      worst = Math.max(worst, Math.abs(hit.point.y - world.metres(height[k] as number)));
    }
  }

  assert.ok(sampled > 100, `only ${sampled} lattice points landed on the surface`);
  /*
   * `LOD_HEIGHT_TOLERANCE` is 0.1 scene units and the surface sits 0.012 above
   * the heightfield to clear the shore plate, so 0.12 is the tolerance plus
   * that lift plus a rounding allowance. This is the assertion that a raised
   * tolerance has to walk past: the decimation may not move the ground.
   */
  assert.ok(worst < 0.12, `the surface strays ${worst} scene units from the heightfield`);
});

test("a colour boundary the height test cannot see stops the merge", async () => {
  /*
   * The coast is flat and its colour is not. `groundColor` ramps `sand` into
   * `flats` over the first three metres of elevation, which is a band the
   * coastal falloff makes tens of cells wide and which no height tolerance
   * loose enough to be useful can protect. So the same board is built twice:
   * once with a palette whose beach and flats are the same colour, and once
   * with them far apart. The second must keep more triangles, and the only
   * mechanism that can produce that difference is the colour guard.
   */
  const beach: Partial<ScenePalette> = { sand: 0xffffff, flats: 0x000000 };
  const plain: Partial<ScenePalette> = { sand: 0x9d9c93, flats: 0x9d9c93 };
  // A single broad, low hill: the island climbs through the sand ramp gently
  // enough that the height test is happy everywhere.
  const gentle: City["hills"] = [
    { name: "swell", lat: 37, lng: -122, elevation: 40, radius: 0.4 },
  ];

  const flatColoured = await board({ ...BASE, hills: gentle, palette: plain });
  const rampColoured = await board({ ...BASE, hills: gentle, palette: beach });
  const a = visibleTriangles(surface(createTerrain(flatColoured)));
  const b = visibleTriangles(surface(createTerrain(rampColoured)));
  assert.ok(b > a, `the colour guard changed nothing: ${b} triangles against ${a}`);
});

test("the shadow caster is coarser than the surface and stands on the same ground", async () => {
  const world = await board(ROUGH);
  const mesh = surface(createTerrain(world));
  const geo = mesh.geometry;

  const seen = visibleTriangles(mesh);
  const cast = casterTriangles(mesh);
  assert.ok(cast > 0, "the relief stopped casting a shadow");
  /*
   * The caster's floor is `SHADOW_CASTER_STRIDE`, so on ground rough enough to
   * defeat every merge it is a quarter of the surface and never more. A caster
   * that came out the same size as the surface would mean the stride had been
   * lost and the depth pass was paying full price for the board.
   */
  assert.ok(cast <= seen / 3, `the caster kept ${cast} triangles against ${seen} visible`);

  // Same board, so the same island: the caster may be blockier at the rim, but
  // it may not be somewhere else.
  const seenArea = footprint(geo, geo.drawRange.start, geo.drawRange.count);
  const castArea = footprint(geo, geo.drawRange.count, (geo.getIndex() as THREE.BufferAttribute).count - geo.drawRange.count);
  assert.ok(
    castArea <= seenArea * 1.0001 && castArea > seenArea * 0.5,
    `the caster covers ${castArea} square units against the surface's ${seenArea}`,
  );
});

// ---- The frustum-cullable split -------------------------------------------

/*
 * Everything below is about the merged board's terrain being several objects
 * rather than one, and none of it was asserted anywhere before: three culls per
 * *object*, so a single mesh whose bounding sphere contains California is
 * submitted at every pose. The split is a re-index and nothing else — same
 * vertices, same resolution, same normals — which is what makes these
 * assertions checkable as identities rather than as tolerances.
 */

function chunksOf(group: THREE.Object3D): THREE.Mesh[] {
  return group.children.filter((c) => c.name === "terrainChunk") as THREE.Mesh[];
}

function casterOf(group: THREE.Object3D): THREE.Mesh {
  const found = group.children.filter((c) => c.name === "terrainCaster");
  assert.equal(found.length, 1, `a chunked board must have exactly one caster, not ${found.length}`);
  return found[0] as THREE.Mesh;
}

test("a board with detail districts is split into cullable chunks and one caster", async () => {
  const world = await board(CHUNKED);
  const group = createTerrain(world);
  const chunks = chunksOf(group);
  casterOf(group);

  /*
   * Bounded on both sides, and the upper bound is the point. A draw call is the
   * scarce resource on the merged board — 420 against a 460 cap — so a grid
   * that got finer would spend the budget before it saved a triangle.
   * `MAX_TERRAIN_CHUNKS` is that bound stated as a number and this is what
   * holds a future pack to it.
   */
  assert.ok(
    chunks.length >= 2,
    `a board this size must chunk; it produced ${chunks.length} of them`,
  );
  assert.ok(
    chunks.length <= 20,
    `${chunks.length} chunks is more draw calls than the grid is allowed to spend`,
  );
  assert.equal(group.children.length, chunks.length + 1, "something else is in the terrain group");
});

test("the chunks together cover exactly the ground one mesh covered", async () => {
  const world = await board(CHUNKED);
  const chunks = chunksOf(createTerrain(world));

  let drawn = 0;
  for (const chunk of chunks) {
    const index = chunk.geometry.getIndex() as THREE.BufferAttribute;
    // The whole index, not a draw range: for a chunk the index *is* the range.
    drawn += footprint(chunk.geometry, 0, index.count);
  }
  const expected = cellByCellArea(world);
  /*
   * The assertion that matters most in this file, and the one the single-mesh
   * version of this test could not make: a patch dropped by the binning, or
   * filed into two cells at once, shows here and nowhere else. It would not
   * show in a picture either — a missing patch at 186 km to the chunk is a hole
   * somewhere in the Central Valley that nobody is looking at. Do not loosen
   * the tolerance.
   */
  assert.ok(
    Math.abs(drawn - expected) < expected * 1e-6,
    `the chunks cover ${drawn} square units against ${expected}`,
  );
});

test("every chunk carries its own bounding volume, and it is a small one", async () => {
  const world = await board(CHUNKED);
  const group = createTerrain(world);
  const caster = casterOf(group);
  const boardSphere = caster.geometry.boundingSphere as THREE.Sphere;
  assert.ok(boardSphere, "the caster must carry the whole board's sphere");

  for (const chunk of chunksOf(group)) {
    const sphere = chunk.geometry.boundingSphere;
    assert.ok(sphere, "a chunk with no assigned sphere is a chunk three will compute one for");
    /*
     * This is the regression test for the failure that has no symptom.
     * `computeBoundingSphere` walks the *position attribute*, not the index, and
     * every chunk shares one position pool covering the whole board — so a
     * chunk left to compute its own sphere gets the board's, is culled by
     * nothing, and silently restores the cost this whole split exists to
     * remove, plus sixteen extra draw calls. It looks identical on screen.
     */
    assert.ok(
      sphere.radius < boardSphere.radius * 0.6,
      `a chunk's sphere has radius ${sphere.radius} against the board's ${boardSphere.radius}: ` +
        "it was computed over the shared position pool rather than assigned",
    );
  }
});

test("the chunks and the caster share one vertex buffer and one material", async () => {
  const world = await board(CHUNKED);
  const group = createTerrain(world);
  const caster = casterOf(group);
  const position = caster.geometry.getAttribute("position");
  const colour = caster.geometry.getAttribute("color");
  const normal = caster.geometry.getAttribute("normal");

  for (const chunk of chunksOf(group)) {
    // Identity, not equality. Duplicated vertex data would be a second upload
    // of the same megabytes, and normals recomputed per chunk would put a
    // lighting crease down every seam in the grid.
    assert.equal(chunk.geometry.getAttribute("position"), position);
    assert.equal(chunk.geometry.getAttribute("color"), colour);
    assert.equal(chunk.geometry.getAttribute("normal"), normal);
  }

  const materials = new Set(group.children.map((c) => (c as THREE.Mesh).material));
  assert.equal(materials.size, 1, "one material, or the split costs a program switch per chunk");
  // `MeshStandardMaterial` since the ground gained a surface response, and the
  // cast is restated rather than left saying Lambert: the one shared material
  // is now a patched standard one, and the point of this assertion is that all
  // eighteen objects still hold the *same* one.
  const material = caster.material as THREE.MeshStandardMaterial;
  assert.equal(material.shadowSide, THREE.BackSide, "the acne cure did not survive the split");
  /*
   * The detail map is on the shared material or it is on nothing. A per-chunk
   * material would be seventeen program switches, and a caster with a cheaper
   * material would be two — the `materials.size` assertion above is what
   * forbids both, and this is the thing that would have tempted someone to
   * break it.
   */
  assert.ok(material.normalMap, "the shared ground material lost its detail relief");
});

test("the chunks are culled and the caster deliberately is not", async () => {
  const world = await board(CHUNKED);
  const group = createTerrain(world);

  for (const chunk of chunksOf(group)) {
    // A chunk in the depth pass would double-count: the caster already covers
    // the whole board at a quarter of the triangles.
    assert.equal(chunk.castShadow, false);
    assert.equal(chunk.receiveShadow, true);
    assert.equal(chunk.frustumCulled, true, "a chunk that is not culled is the whole cost back");
  }

  const caster = casterOf(group);
  assert.equal(caster.castShadow, true);
  assert.equal(caster.receiveShadow, false);
  /*
   * `WebGLShadowMap.renderObject` gates the depth pass on `!object.
   * frustumCulled || _frustum.intersectsObject(object)` — the same machinery
   * as the colour pass. A culled caster drops the ridge that is off screen and
   * legitimately shadows into frame, so the shadow moves when the camera turns.
   */
  assert.equal(caster.frustumCulled, false, "shadows would depend on where the camera looks");
});

test("the caster draws nothing in the colour pass and everything in the depth pass", async () => {
  const world = await board(CHUNKED);
  const caster = casterOf(createTerrain(world));
  const geo = caster.geometry;
  const index = geo.getIndex() as THREE.BufferAttribute;
  assert.ok(index.count > 0, "the relief stopped casting a shadow");

  /*
   * Zero, not `Infinity`. `WebGLRenderer.renderBufferDirect` early-returns on a
   * draw count of `< 0` or `Infinity` and **not** on 0, so this mesh costs
   * exactly one zero-triangle draw call per frame at every pose — which is the
   * price of a caster that is never culled, and it is one draw call.
   */
  assert.equal(geo.drawRange.count, 0, "the caster is drawing in the colour pass");

  const nothing = null as never;
  caster.onBeforeShadow(
    nothing, nothing, nothing, nothing,
    geo, caster.material as THREE.Material, nothing,
  );
  assert.equal(geo.drawRange.start, 0);
  assert.equal(geo.drawRange.count, index.count, "the depth pass is drawing part of the caster");

  caster.onAfterShadow(
    nothing, nothing, nothing, nothing,
    geo, caster.material as THREE.Material, nothing,
  );
  assert.equal(geo.drawRange.count, 0, "the caster leaked into the colour pass");
});
