/**
 * The built city, plus the handful of landmarks placed by hand.
 *
 * Two things make this read as a city rather than as noise:
 *
 *   - **Buildings step along a block lattice** in each district's own grid
 *     bearing. An earlier version rejection-sampled uniformly inside each
 *     district and it looked like rubble, because a city is not a Poisson
 *     process. In San Francisco the lattice also reproduces the 46° between
 *     the grid north of Market and the grid south of it, and Market Street
 *     falls out as a seam rather than having to be drawn.
 *   - **Buildings stand on the terrain.** Every base is sampled from
 *     `world.groundAt`, so Nob Hill's low-rises tower over taller blocks in the
 *     flats below — which is true of that city, and which a flat map gets
 *     exactly backwards.
 *
 * Everything is instanced: one draw call for the whole city.
 */

import * as THREE from "three";
import { deviceProfile } from "./stage.ts";
import type { District } from "./types.ts";
import { seededRandom, type World } from "./world.ts";

/** Lot size in scene units, and how many lots sit between cross-streets. */
const LOT = 0.42; // ~40 m at SF's scale
const BLOCK_LOTS = 4; // 3 made streets a third of the city's surface

/**
 * The ground size at which a lot stops being a city block.
 *
 * `LOT` is fixed in **scene units**, which is right — the three boards are 1003,
 * 308 and 284 units across and are looked at from comparable standoffs, so a
 * lot that is legible on one is legible on the others. But it means a lot is
 * 40 m in San Francisco, 164 m in Southern California and **806 m** on the
 * statewide California board, and two things that are correct for a city are
 * wrong at 806 m:
 *
 *   - **The street lattice.** Skipping every fourth row and column leaves 44%
 *     of a district unbuilt. At 40 m those gaps are streets. At 806 m they are
 *     eight-hundred-metre voids, and Los Angeles came out as a chequerboard of
 *     separate white squares rather than as a city — the one thing the state
 *     board most needed it to be. Above the threshold the lots tile, and the
 *     district's `coverage` roll does all the thinning, which reads as urban
 *     fabric because its gaps are irregular.
 *   - **Casting shadows.** A shadow caster pays for itself twice, once in the
 *     shadow pass and once in the beauty pass. A 40 m building on a San
 *     Francisco hillside throws a shadow you can see; a 60 m building on a
 *     806 m lot throws about one pixel, and paying a second pass over a hundred
 *     thousand triangles for it — on the board with the tightest budget of the
 *     three — is not a trade anyone would make on purpose. Lambert still shades
 *     the four walls, which is all the state camera can resolve anyway.
 *   - **The underside.** Same measurement, same argument; see the geometry.
 *
 * 260 m is comfortably above Southern California's 164 and far below
 * California's 806, so neither of the detailed boards changes at all.
 */
const NEIGHBOURHOOD_LOT_METRES = 260;

/**
 * Whether the anonymous city goes into the shadow map, given the lot size and
 * the device.
 *
 * Two independent reasons to say no, and they are answering different
 * questions, which is why this is one function and not one flag.
 *
 * **The lot.** See `NEIGHBOURHOOD_LOT_METRES`: a 60 m building on an 806 m lot
 * throws about a pixel of shadow, and paying a whole second pass over a hundred
 * thousand triangles for a pixel is not a trade anyone would make on purpose.
 *
 * **The device.** This is the new half, and it is the only lever in the engine
 * that removes about 44% of a phone's per-frame geometry. Measured on this
 * repo's Radeon with `EXT_disjoint_timer_query_webgl2`, at the whole-board pose:
 *
 * | board | triangles with | without | GPU frame |
 * |---|---|---|---|
 * | bay-area | 2,265,056 | 1,267,448 | 1.22 ms → 0.93 ms |
 * | socal | 1,417,648 | 772,444 | 0.91 ms → 0.69 ms |
 *
 * — because the shadow pass costs *geometry submission*, not rasterisation. The
 * same measurement is why the shadow map's **size** is not the lever it looks
 * like: 4096, 2048, 1024 and 256 texel maps all render the bay-area frame in
 * 1.21–1.31 ms, inside the run-to-run noise of a 1.2 ms frame. On a tile-based
 * mobile GPU, where binning cost tracks geometry and parameter-buffer traffic,
 * dropping a million triangles is worth strictly more than it is here.
 *
 * What it costs, measured as a picture pair at 16:30 PDT rather than argued:
 * at the whole-board pose it is **invisible** — mean frame luminance 120.23 with
 * and 120.27 without, 0.03% — because `scene.ts` hands the kit a shadow extent
 * of 0.75 board spans, which for the Bay Area's 1003 units is a 1504-unit box,
 * about 69 m to a texel at 2048, and a building footprint is one texel or less.
 * At the FiDi chapter pose it is visible (105.32 → 108.64, +3.2%): the towers
 * stop shadowing the ground and each other. So this is a *pose-dependent*
 * saving, which is precisely why it is gated on the handheld and not shipped
 * flat — a phone is the device that cannot afford the geometry and the device
 * whose screen is least able to resolve what the geometry bought.
 *
 * Terrain keeps self-shadowing on a phone either way: its caster is a decimated
 * copy swung in by `drawRange`, a quarter of its triangles, and it measured free
 * (1.24 ms against 1.22). Landmarks keep casting too — there are a handful of
 * them and their silhouettes are the point. Office scenes are untouched: at
 * 1 unit = 1 m the desk shadows are the whole read of depth, and `stage.ts`
 * already argues that.
 *
 * The `handheld` argument is injectable so a test can ask both questions
 * without a `window`; the default is the live device.
 */
export function blocksCastShadow(lotIsABlock: boolean, handheld = isHandheld()): boolean {
  return lotIsABlock && !handheld;
}

/** `deviceProfile()` reads `window`; the typecheck and the tests do not have one. */
function isHandheld(): boolean {
  if (typeof window === "undefined") return false;
  return deviceProfile().handheld;
}

const PALETTES = {
  downtown: [0xb9c3cc, 0xa8b4c0, 0xc7cfd6, 0x9dabb8, 0xd2d8dd, 0x8f9eaa],
  residential: [0xe8e2d6, 0xdcd3c4, 0xefe9dd, 0xd6cdbc, 0xe3d9c8, 0xcfc4b2, 0xf0ece2],
  industrial: [0xbdb5a8, 0xa89f92, 0xcac2b4, 0xb0a89a, 0x9c9488],
} satisfies Record<District["palette"], number[]>;

/**
 * How commercial each palette's buildings are, 0..1.
 *
 * Read only by `nightlights.ts`, and the reason a night city looks like a city
 * rather than like a uniform field of dots: an office floor is a continuous
 * band of large windows with half of them left on all night, and a house is two
 * small warm rectangles that go out. The number is the same fact the palette
 * already encodes, which is why it is derived from it rather than authored
 * again per district.
 */
const COMMERCIAL = {
  downtown: 1,
  residential: 0.12,
  industrial: 0.45,
} satisfies Record<District["palette"], number>;

/**
 * The name of the per-instance attribute `createBlocks` leaves on its geometry:
 * `[commercial, seed]`.
 *
 * A vertex attribute rather than a field on `userData` because the only
 * consumer is a shader, and this puts the data where the GPU already wants it.
 * `createBlocks` writes it because `createBlocks` is what knows which district
 * a given instance came out of; nothing else can recover that from the mesh.
 */
export const FACADE_ATTRIBUTE = "aFacade";

/** An independent stream for the facades; see where it is drawn from. */
const FACADE_SEED = 20_261;

interface Box {
  x: number;
  z: number;
  y: number;
  w: number;
  d: number;
  h: number;
  rot: number;
  color: THREE.Color;
  commercial: number;
}

/**
 * A named building's claim on the anonymous city scatter, in scene units.
 *
 * Circles are deliberately conservative. Anonymous buildings rotate with
 * their districts and named glyphs rotate with their streets; a circle is the
 * one cheap overlap test that cannot leave a corner poking through, and there
 * are only a handful of reservations to test.
 */
export interface BuildingReservation {
  x: number;
  z: number;
  radius: number;
}

function polygonBounds(poly: [number, number][]) {
  let minLat = Infinity;
  let maxLat = -Infinity;
  let minLng = Infinity;
  let maxLng = -Infinity;
  for (const [lat, lng] of poly) {
    if (lat < minLat) minLat = lat;
    if (lat > maxLat) maxLat = lat;
    if (lng < minLng) minLng = lng;
    if (lng > maxLng) maxLng = lng;
  }
  return { minLat, maxLat, minLng, maxLng };
}

export function createBlocks(
  world: World,
  reservations: readonly BuildingReservation[] = [],
): THREE.InstancedMesh {
  const boxes: Box[] = [];
  let seedBase = 1337;

  // See `NEIGHBOURHOOD_LOT_METRES`. One measurement, two decisions, and both of
  // them are about how much ground a lot covers rather than about which board
  // this is — a self-hoster's pack gets the same treatment without naming it.
  const lotMetres = LOT * world.metresPerUnit;
  const lotIsABlock = lotMetres <= NEIGHBOURHOOD_LOT_METRES;

  for (const district of world.city.districts) {
    const rand = seededRandom(seedBase);
    seedBase += 7919;

    const palette = PALETTES[district.palette];
    const commercial = COMMERCIAL[district.palette];
    const angle = district.gridAngle;
    const coverage = district.coverage ?? 0.88;

    // The district's extent in scene space, padded so the rotated lattice
    // still covers the corners once it is turned.
    const b = polygonBounds(district.polygon);
    const corners = [
      world.project(b.minLat, b.minLng),
      world.project(b.minLat, b.maxLng),
      world.project(b.maxLat, b.minLng),
      world.project(b.maxLat, b.maxLng),
    ];
    const xs = corners.map((c) => c[0]);
    const zs = corners.map((c) => c[1]);
    const cx = (Math.min(...xs) + Math.max(...xs)) / 2;
    const cz = (Math.min(...zs) + Math.max(...zs)) / 2;
    const reach = Math.hypot(Math.max(...xs) - cx, Math.max(...zs) - cz) + LOT;

    const cos = Math.cos(angle);
    const sin = Math.sin(angle);
    const steps = Math.ceil(reach / LOT);

    for (let iu = -steps; iu <= steps; iu++) {
      if (lotIsABlock && ((iu % BLOCK_LOTS) + BLOCK_LOTS) % BLOCK_LOTS === 0) continue; // street
      for (let iv = -steps; iv <= steps; iv++) {
        if (lotIsABlock && ((iv % BLOCK_LOTS) + BLOCK_LOTS) % BLOCK_LOTS === 0) continue; // street

        const u = (iu + (rand() - 0.5) * 0.34) * LOT;
        const v = (iv + (rand() - 0.5) * 0.34) * LOT;
        const x = cx + u * cos - v * sin;
        const z = cz + u * sin + v * cos;

        const [lat, lng] = world.unproject(x, z);
        if (!world.pointInPolygon(lat, lng, district.polygon)) continue;
        /**
         * Land and parks come off the lattice; the district polygon does not.
         *
         * The three tests used to be three exhaustive polygon walks each, and
         * on the Bay Area's 186k candidate lots that was 240 ms of the boot's
         * main thread — the largest single item in it, spent re-deriving what
         * the heightfield Worker had already worked out for the whole board.
         * `isLandSampled` and `inParkSampled` read that answer and fall through
         * to the exact test only on a lattice cell that straddles the edge, so
         * the coastline and the park boundaries are still decided by the
         * polygons; see `World.sampled`. Same 186k lots, 19 ms.
         *
         * The district stays exact because there is no mask for it: districts
         * are not a property of the lattice, they overlap, and San Francisco
         * declares fifty-two of them. It is also the cheap one — the polygons
         * are a dozen vertices and the bounding box rejects almost everything,
         * which is 47 ms against the coastline's 235.
         */
        if (!world.isLandSampled(lat, lng)) continue;
        if (world.inParkSampled(lat, lng)) continue;
        if (rand() > coverage) continue; // yards, car parks, the unbuilt lots

        // Cubed, so tall buildings stay rare and the skyline keeps a
        // silhouette instead of turning into a plateau.
        const roll = rand();
        const isTower = rand() < district.towerChance;
        const t = isTower ? 0.55 + roll * 0.45 : roll ** 3;
        const heightM = district.minHeight + t * (district.maxHeight - district.minHeight);

        // Towers take several lots. A 260 m tower on one 40 m lot is a 25:1
        // needle, and downtown came out looking like a bed of nails; real
        // towers assemble their sites, and Salesforce Tower is about 5:1.
        const fill = isTower ? 1.5 + rand() * 0.7 : 0.78 + rand() * 0.18;

        const width = LOT * fill;
        const depth = LOT * fill * (0.85 + rand() * 0.3);
        const rotation = angle + (rand() - 0.5) * 0.03;
        const color = new THREE.Color(
          palette[Math.floor(rand() * palette.length)] ?? 0xd9d3c6,
        );

        /**
         * Keep a named building legible instead of drawing it inside a random
         * one at the same address.
         *
         * Every random property is drawn before this test. The sequence is
         * load-bearing: skipping those calls for one reserved lot would
         * reshuffle every anonymous building after it and turn a local change
         * into a whole new skyline.
         */
        const radius = Math.hypot(width, depth) / 2;
        if (
          reservations.some(
            (reserved) => Math.hypot(x - reserved.x, z - reserved.z) < radius + reserved.radius,
          )
        ) {
          continue;
        }

        boxes.push({
          x,
          z,
          y: world.groundAt(lat, lng),
          w: width,
          d: depth,
          h: world.metres(heightM),
          rot: rotation,
          color,
          // A tower is an office whatever district it landed in.
          commercial: Math.min(1, commercial + (isTower ? 0.4 : 0)),
        });
      }
    }
  }

  const geometry = new THREE.BoxGeometry(1, 1, 1);
  geometry.translate(0, 0.5, 0); // pivot at the base, so y is ground level
  if (!lotIsABlock) {
    /**
     * Drop the underside at neighbourhood scale.
     *
     * `BoxGeometry` lays its groups out px, nx, py, ny, pz, nz, two triangles
     * each, so the six indices from 18 are the floor. A building sits on the
     * ground and that face is never visible — except on San Francisco's
     * steepest blocks, where a 40 m lot spanning a 3.6×-exaggerated hillside can
     * leave a corner clear of the terrain and you would see straight through the
     * hole. So this is tied to the same measurement as the street lattice and
     * the shadow pass: at 806 m to the lot the ground under a building is flat
     * to within a hair and nothing can get beneath it, and one sixth of the
     * board's largest triangle consumer goes back to the budget.
     */
    const index = geometry.getIndex();
    if (index) {
      const kept = Array.from(index.array).filter((_, at) => at < 18 || at >= 24);
      geometry.setIndex(kept);
    }
  }

  // The per-instance facade data, drawn from a stream of its own.
  //
  // The obvious place for the seed is inside the placement loop, next to every
  // other `rand()` — and putting it there would have been a mistake, because a
  // scatter's draw sequence is load-bearing. One extra call shifts every
  // subsequent draw, and the whole city would have rebuilt itself the first
  // time anyone lit a window. A second stream costs nothing, is just as
  // deterministic across reloads, and leaves the skyline exactly where it was.
  const windows = seededRandom(FACADE_SEED);
  const facade = new Float32Array(boxes.length * 2);
  boxes.forEach((b, i) => {
    facade[i * 2] = b.commercial;
    facade[i * 2 + 1] = windows();
  });
  geometry.setAttribute(FACADE_ATTRIBUTE, new THREE.InstancedBufferAttribute(facade, 2));

  const mesh = new THREE.InstancedMesh(geometry, new THREE.MeshLambertMaterial(), boxes.length);
  mesh.name = "blocks";
  mesh.castShadow = blocksCastShadow(lotIsABlock);
  mesh.receiveShadow = true;

  const matrix = new THREE.Matrix4();
  const quat = new THREE.Quaternion();
  const pos = new THREE.Vector3();
  const scl = new THREE.Vector3();
  const up = new THREE.Vector3(0, 1, 0);

  boxes.forEach((b, i) => {
    pos.set(b.x, b.y, b.z);
    quat.setFromAxisAngle(up, b.rot);
    scl.set(b.w, b.h, b.d);
    matrix.compose(pos, quat, scl);
    mesh.setMatrixAt(i, matrix);
    mesh.setColorAt(i, b.color);
  });
  mesh.instanceMatrix.needsUpdate = true;
  if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;

  return mesh;
}

/**
 * The named buildings. Separate meshes because the eye goes looking for these
 * specific silhouettes — a pyramid at Montgomery, a white finger on Telegraph
 * Hill, the red tripod on the ridge — and a box would not do.
 */
export function createLandmarks(
  world: World,
  reservations: readonly BuildingReservation[] = [],
): THREE.Group {
  const group = new THREE.Group();
  group.name = "landmarks";

  for (const lm of world.city.landmarks) {
    const [x, z] = world.project(lm.lat, lm.lng);
    // A richer stable glyph at this address supersedes the coarse landmark
    // primitive. Drawing both would hide the glyph inside the old mesh and
    // leave two different sources claiming the same real building.
    if (
      reservations.some(
        (reserved) => Math.hypot(x - reserved.x, z - reserved.z) < reserved.radius,
      )
    ) {
      continue;
    }
    const base = world.groundAt(lm.lat, lm.lng);
    const h = world.metres(lm.height);
    const w = lm.footprint * world.lngScale * 2;

    let geo: THREE.BufferGeometry;
    switch (lm.shape) {
      case "pyramid":
        geo = new THREE.ConeGeometry(w * 0.72, h, 4);
        geo.translate(0, h / 2, 0);
        geo.rotateY(Math.PI / 4);
        break;
      case "cylinder":
        geo = new THREE.CylinderGeometry(w * 0.6, w * 0.68, h, 20);
        geo.translate(0, h / 2, 0);
        break;
      case "tower":
        geo = new THREE.CylinderGeometry(w * 0.42, w * 0.62, h, 4);
        geo.rotateY(Math.PI / 4);
        geo.translate(0, h / 2, 0);
        break;
      default:
        geo = new THREE.BoxGeometry(w, h, w);
        geo.translate(0, h / 2, 0);
    }

    const mesh = new THREE.Mesh(
      geo,
      new THREE.MeshLambertMaterial({ color: lm.color ?? 0xaebac6 }),
    );
    mesh.position.set(x, base, z);
    mesh.castShadow = true;
    mesh.receiveShadow = true;
    mesh.userData.landmark = lm;
    group.add(mesh);
  }

  return group;
}
