/**
 * LANE-RENDER — the Bitstream, made visible.
 *
 * Isometric factory view. Sim is truth: we render snapshots and never simulate.
 * Composition: registry (assets + MODELBEAST hot-swap) -> ground / belts / entities /
 * belt items / ghost layers -> ortho iso camera.
 */
import * as THREE from 'three';
import type { Dir, GameData, MachineDef, Renderer, SimSnapshot, Vec2 } from '../contracts';
import { AssetRegistry } from './registry';
import { CAM_DIST, CameraRig, WORLD_HALF } from './camera';
import { createGround, createVignette } from './ground';
import { EntityLayer } from './entities';
import { BeltLayer } from './belts';
import { BeltItemLayer } from './beltitems';
import { GhostLayer } from './ghost';
import { DevScene, devSceneEnabled, devSceneStress } from './devscene';
import { Showroom, showroomEnabled } from './showroom';
import { BeltTopology } from './topology';
import { SeamLayer, loadSeams } from './seams';
import { RelicLayer } from './relics';
import { WildlifeLayer, harassedIn } from './wildlife';
import { Juice } from './juice';
import { BreakRoom } from './breakroom';
import { WORLD } from './palette';
import { centerOf } from './coords';

const KEY_INTENSITY = 1.9;
const FILL_INTENSITY = 0.7;
const AMBIENT_INTENSITY = 1.1;
/** ACES rolls mid-tones down hard; this lifts the factory back off a near-black ground. */
const BASE_EXPOSURE = 1.35;

export function createRenderer(): Renderer {
  let gl: THREE.WebGLRenderer;
  let scene: THREE.Scene;
  let rig: CameraRig;
  let registry: AssetRegistry;
  let entities: EntityLayer;
  let belts: BeltLayer;
  let cargo: BeltItemLayer;
  let ghost: GhostLayer;
  let relics: RelicLayer;
  let wildlife: WildlifeLayer;
  let juice: Juice;
  let breakroom: BreakRoom;
  let seamLayer: SeamLayer | null = null;
  let dev: DevScene | null = null;
  let showroom: Showroom | null = null;
  let host: HTMLElement;
  let defs: Map<string, MachineDef>;
  /** Shipment-flash detection: last frame's total shipped count, and the shipper tiles. */
  let lastShipped = -1;
  const shipperCentres: Vec2[] = [];
  const itemColors = new Map<string, number>();
  const lastPerItem = new Map<string, number>();
  /** True on any dev scaffold page (devscene/showroom): enables stats + the debug hook. */
  let debug = false;
  const topo = new BeltTopology();

  const raycaster = new THREE.Raycaster();
  const groundPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
  const ndc = new THREE.Vector2();
  const hit = new THREE.Vector3();
  const clock = new THREE.Clock();
  let dim = 1; // smoothed brownout level
  /** Dev-only frame-cost sampler. Measures CPU ms per frame, which — unlike frame
   *  RATE — stays honest even when the tab is backgrounded and rAF is throttled. */
  const stats = { ms: 0, avg: 0, frames: 0 };

  /**
   * Which entity the inspector has open, if any.
   *
   * BRIDGE: `Renderer` has no inspect channel — `setGhost`'s mode is only build|remove,
   * and main.ts calls it with 'build' during inspect, so nothing about the inspected
   * entity reaches this lane. The typed selection DOES carry it (`{mode:'inspect',
   * entity}`), and the bus is the designated cross-lane global, so we read it here.
   * Same shape as the '__remove' sentinel bridge: it works, it's documented on both
   * sides, and it wants a real contract hook. Filed in NOTES.
   */
  function inspectedEntity(): number | null {
    const bus = (window as unknown as { __fktryBus?: { selection?: () => unknown } }).__fktryBus;
    const sel = bus?.selection?.() as { mode?: string; entity?: number } | null | undefined;
    return sel && sel.mode === 'inspect' && typeof sel.entity === 'number' ? sel.entity : null;
  }

  /**
   * A rise in `shippedTotal` is a shipment — flash the uplinks in the shipped item's
   * colour. Derived from the snapshot because render() never sees events.
   */
  function shipmentFlash(view: SimSnapshot): void {
    let total = 0;
    let hottest: string | null = null;
    let best = 0;
    for (const k in view.shippedTotal) {
      const n = view.shippedTotal[k];
      total += n;
      const prev = lastPerItem.get(k) ?? 0;
      if (n - prev > best) { best = n - prev; hottest = k; }
      lastPerItem.set(k, n);
    }
    if (lastShipped < 0) { lastShipped = total; return; } // first frame: prime, don't flash
    if (total > lastShipped) {
      shipperCentres.length = 0;
      for (const e of view.entities) {
        const d = defs.get(e.def);
        if (d?.kind !== 'shipper') continue;
        const c = centerOf(e.pos, d, e.dir);
        shipperCentres.push({ x: c.x, y: c.z });
      }
      const col = (hottest !== null ? itemColors.get(hottest) : undefined) ?? 0xff7a3f;
      for (const c of shipperCentres) juice.shipFlash(c.x, c.y, col);
    }
    lastShipped = total;
  }

  /** True when the pointer is over the arcade cabinet. */
  function cabinetHit(clientX: number, clientY: number): boolean {
    if (!breakroom) return false;
    const rect = gl.domElement.getBoundingClientRect();
    ndc.x = ((clientX - rect.left) / rect.width) * 2 - 1;
    ndc.y = -((clientY - rect.top) / rect.height) * 2 + 1;
    raycaster.setFromCamera(ndc, rig.camera);
    return raycaster.intersectObjects(breakroom.clickable, false).length > 0;
  }

  /**
   * The cabinet is a world object, so it gets world-object interaction: pointer cursor on
   * hover, click to open. Listeners live on the CANVAS and stop propagation on a hit, so
   * main.ts's #game handler never sees the click — otherwise clicking the cabinet while
   * holding a machine would also place one on the tile behind it.
   */
  function installCabinetPointer(container: HTMLElement): void {
    const canvas = gl.domElement;
    canvas.addEventListener('pointermove', (e) => {
      const over = cabinetHit(e.clientX, e.clientY);
      // Only claim the cursor when nothing else is driving it (build/remove modes have
      // their own affordance via the ghost).
      canvas.style.cursor = over ? 'pointer' : '';
      if (over) container.title = 'GLYTCH';
      else if (container.title) container.title = '';
    });
    canvas.addEventListener(
      'pointerdown',
      (e) => {
        if (e.button !== 0 || !cabinetHit(e.clientX, e.clientY)) return;
        e.stopPropagation();
        e.preventDefault();
        window.open('./match.html', '_blank', 'noopener');
      },
      { capture: true },
    );
  }

  return {
    async init(container: HTMLElement, data: GameData) {
      defs = new Map(data.machines.map((m) => [m.id, m]));
      host = container;

      gl = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
      gl.setPixelRatio(Math.min(devicePixelRatio, 2));
      gl.setSize(container.clientWidth, container.clientHeight);
      gl.shadowMap.enabled = true;
      gl.shadowMap.type = THREE.PCFSoftShadowMap;
      gl.toneMapping = THREE.ACESFilmicToneMapping;
      container.appendChild(gl.domElement);
      container.appendChild(createVignette());

      scene = new THREE.Scene();
      scene.background = new THREE.Color(WORLD.bg);
      // Fog distances are measured from the CAMERA, which an ortho iso rig parks
      // CAM_DIST away from everything — so the band starts past the focal point and
      // only bites at the far reaches. Absolute values here would fog the whole world
      // into the background.
      scene.fog = new THREE.Fog(WORLD.bg, CAM_DIST + 12, CAM_DIST + 78);
      scene.add(createGround());

      const key = new THREE.DirectionalLight(0xbfc8ff, KEY_INTENSITY);
      key.position.set(18, 30, 12);
      key.castShadow = true;
      key.shadow.mapSize.set(2048, 2048);
      key.shadow.camera.near = 1;
      key.shadow.camera.far = 90;
      const s = 26;
      key.shadow.camera.left = -s;
      key.shadow.camera.right = s;
      key.shadow.camera.top = s;
      key.shadow.camera.bottom = -s;
      key.shadow.bias = -0.0012;
      scene.add(key);
      // Cool bounce from below-ish so machine flanks don't go pure black.
      const fill = new THREE.DirectionalLight(0x4a3f7a, FILL_INTENSITY);
      fill.position.set(-14, 8, -16);
      scene.add(fill);
      scene.add(new THREE.AmbientLight(0x8f8fbf, AMBIENT_INTENSITY));

      rig = new CameraRig(container, gl.domElement);

      registry = new AssetRegistry();
      await registry.init(data);

      juice = new Juice();
      belts = new BeltLayer(registry, defs, topo);
      entities = new EntityLayer(registry, defs, juice);
      cargo = new BeltItemLayer(data, topo);
      ghost = new GhostLayer(registry, defs, data);
      relics = new RelicLayer();
      wildlife = new WildlifeLayer();
      scene.add(
        belts.group, entities.group, cargo.group, ghost.group, ghost.aura.group,
        relics.group, wildlife.group, juice.group,
      );

      // THE FLOOR: seam regions from data — era skins the crust, the ore glints.
      seamLayer = new SeamLayer(loadSeams(), data);
      scene.add(seamLayer.mesh);

      // THE BREAK ROOM: the GLYTCH cabinet, a short walk south-east of the start zone.
      // Position is chosen to land in CLICKABLE screen space at the default framing —
      // at (-5,-5) it rendered underneath `#screen`, the decorative overlay that still
      // takes pointer events, so the cabinet was both invisible and unclickable (see the
      // B8 picking findings in NOTES).
      breakroom = new BreakRoom(6, 12);
      scene.add(breakroom.group);
      installCabinetPointer(container);

      // Item colours for the shipment flash, and the shipper tiles (recomputed on demand).
      for (const it of data.items) itemColors.set(it.id, new THREE.Color(it.color).getHex());

      debug = devSceneEnabled() || showroomEnabled();

      // B8 review finding: the start zone must clear the build bar. The bar sits at
      // screen-bottom, and in this iso rig screen-down = world +x/+z, so biasing the
      // camera target south-east lifts world origin into the upper play area where a
      // fresh game's first machines get placed. (Showroom/devscene set their own frame.)
      if (!showroomEnabled()) rig.frame(3, 6, 15);

      if (showroomEnabled()) {
        showroom = new Showroom(data, registry);
        scene.add(showroom.decor);
        showroom.mountLabels(container);
        rig.frame(showroom.view.x, showroom.view.z, showroom.view.zoom);
        console.info(
          `[render] showroom: ${data.machines.length} machines, ${data.items.length} items —` +
            ' drop a GLB into public/assets/models/<asset>.glb and it lands here in ~10s',
        );
      }

      if (devSceneEnabled()) {
        const d = new DevScene(data, devSceneStress());
        if (d.usable) {
          dev = d;
          console.info('[render] devscene active — mock line until LANE-SIM lands entities');
        }
      }

      // The literal `import.meta.env.DEV` is what makes this disappear from the bundle:
      // Vite substitutes `false` and Rollup drops the branch. Testing a `debug` VARIABLE
      // here instead left the whole hook — internals list and all — as dead code in
      // dist/. Keep the check inline; verified by grepping the bundle.
      if (import.meta.env.DEV && (devSceneEnabled() || showroomEnabled())) {
        (window as unknown as { __render: unknown }).__render = {
          scene, camera: rig.camera, rig, stats, gl, dev, showroom, topo, cargo, entities, ghost,
        };
      }

      const onResize = () => {
        gl.setSize(container.clientWidth, container.clientHeight);
        rig.resize();
      };
      addEventListener('resize', onResize);
      new ResizeObserver(onResize).observe(container);
    },

    render(snap: SimSnapshot, alpha: number) {
      if (!gl) return;
      const t0 = debug ? performance.now() : 0;
      // Clamp the frame delta. A backgrounded tab, an alt-tab, a GC pause or the first
      // frame after load all hand back a delta of SECONDS (measured 63.7s), which
      // integrates particles to infinity and kills every effect whose lifetime is
      // shorter than the hitch — juice simply never appeared. main.ts clamps the sim's
      // accumulator for the same reason; this is the renderer's half.
      const dt = Math.min(0.05, clock.getDelta());
      const t = clock.elapsedTime;

      // The showroom is a standalone page, so it owns the frame outright. The devscene
      // is only a stand-in and yields the instant the real sim has anything to show.
      let view = snap;
      if (showroom) {
        view = showroom.snapshot(snap.tick);
        showroom.frame(t);
      } else if (dev && snap.entities.length === 0) {
        view = dev.snapshot(snap.tick);
      }

      rig.update(dt);
      // Topology is shared by belts (which piece to draw) and cargo (which path the
      // items take), so it is rebuilt once here rather than twice downstream.
      topo.rebuild(view, defs);
      belts.sync(view, t);
      entities.sync(view, t, dt, harassedIn(view));
      cargo.sync(view, alpha, t);
      ghost.update(view, t, inspectedEntity());
      relics.sync(view.relics, t);
      wildlife.sync(view.wildlife, t);
      seamLayer?.frame(t);
      breakroom.frame(t);
      shipmentFlash(view);
      juice.update(dt, rig.camera);

      // Brownout: drop the whole scene ~30% and flicker. Driven through tone-mapping
      // exposure so emissives dim too — a brownout that left the glow at full would
      // read as a lighting bug rather than a power failure.
      const target = view.bandwidth.brownout ? 0.7 : 1;
      dim += (target - dim) * Math.min(1, dt * 6);
      const flicker = view.bandwidth.brownout
        ? 0.88 + Math.sin(t * 37) * 0.08 + (Math.sin(t * 13.3) > 0.72 ? -0.22 : 0)
        : 1;
      gl.toneMappingExposure = BASE_EXPOSURE * dim * flicker;

      gl.render(scene, rig.camera);
      showroom?.updateLabels(rig.camera, host);

      if (debug) {
        stats.ms = performance.now() - t0;
        stats.frames++;
        stats.avg += (stats.ms - stats.avg) * 0.05; // rolling
      }
    },

    pickTile(clientX: number, clientY: number): Vec2 | null {
      if (!gl) return null;
      const rect = gl.domElement.getBoundingClientRect();
      ndc.x = ((clientX - rect.left) / rect.width) * 2 - 1;
      ndc.y = -((clientY - rect.top) / rect.height) * 2 + 1;
      raycaster.setFromCamera(ndc, rig.camera);
      if (!raycaster.ray.intersectPlane(groundPlane, hit)) return null;
      const x = Math.floor(hit.x);
      const y = Math.floor(hit.z);
      if (x < -WORLD_HALF || x >= WORLD_HALF || y < -WORLD_HALF || y >= WORLD_HALF) return null;
      return { x, y };
    },

    setGhost(def: string | null, pos: Vec2 | null, dir: Dir, mode: 'build' | 'remove' = 'build') {
      ghost?.set(def, pos, dir, mode);
    },
  };
}
