/**
 * The road-traffic rendering layer.
 *
 * Simulation remains geographic and three-free in `transport/vehicleSim.ts`.
 * This layer projects those poses onto one `World`, draws one articulated hero
 * car, and batches every background car into instanced asset parts. Route
 * switching and follow-camera state are imperative because they are viewer
 * choices, not properties of the open transport pack.
 */

import * as THREE from "three";
import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import {
  buildHeadlampPool,
  buildLumbridgeEV,
  disposeModelX,
  modelXInstanceParts,
  setModelXSteering,
  setModelXWheelRotation,
} from "../assets/vehicles/index.ts";
import type { TransportPack } from "../transport/types.ts";
import {
  NEUTRAL_VEHICLE_ACTIONS,
  VehicleController,
  normalizeVehicleActions,
  type VehicleActionSnapshot,
  type VehicleControllerState,
} from "../transport/vehicleController.ts";
import { VehicleSimulation, type VehiclePose } from "../transport/vehicleSim.ts";
import { onNightLevel } from "./nightlights.ts";
import type { World } from "./world.ts";

export interface RoadTrafficOptions {
  pack: TransportPack;
  routeId: string;
  count?: number;
  seed?: number;
  /** Vehicle metres to scene units. State-scale cars are intentional glyphs. */
  scale?: number;
  /**
   * Route-distance compression for playable corridor travel. Defaults to 900.
   *
   * The counterpart is `METRE_SCALE_VEHICLE_OPTIONS` in
   * `transport/exteriorVehicle.ts`, which is this same controller at 1 for the
   * apron outside a studio. One state machine, one dial; see the note on
   * `VehicleControllerOptions.travelScale` for what the dial does and does not
   * touch.
   */
  travelScale?: number;
}

export interface RoadTrafficLayer {
  group: THREE.Group;
  routeId(): string;
  setRoute(routeId: string): void;
  setFollowing(following: boolean): void;
  following(): boolean;
  setVehicleActions(actions: Partial<VehicleActionSnapshot>): void;
  setCameraMode(mode: VehicleCameraMode): void;
  cameraMode(): VehicleCameraMode;
  hero(): Readonly<VehicleControllerState>;
  tick(dt: number): void;
  dispose(): void;
}

export type VehicleCameraMode = "chase" | "driver";

/**
 * How far the hero's headlamps throw, in vehicle metres.
 *
 * Picked against the *road* rather than against a photometry table, because this
 * corridor is a diorama: its carriageway is about 5.7 m wide beside a car that is
 * honestly 5.04 m long, so the world under the vehicle is drawn at roughly half
 * the vehicle's own scale. Three carriageway widths is what a low beam looks like
 * from the chase camera; the 60 m a real low beam actually throws came out as a
 * pool running off the top of the frame.
 */
const HEADLAMP_REACH_M = 26;

interface BatchPart {
  mesh: THREE.InstancedMesh;
  local: THREE.Matrix4;
}

type RenderPose = Pick<VehiclePose, "lat" | "lng" | "headingDeg" | "wheelRadians"> & {
  lane?: number;
  lateralOffsetM?: number;
};

export function createRoadTrafficLayer(
  world: World,
  camera: THREE.PerspectiveCamera,
  controls: OrbitControls,
  options: RoadTrafficOptions,
): RoadTrafficLayer {
  const group = new THREE.Group();
  group.name = "road-traffic";
  /*
   * A real `World` has `metresPerUnit`. The tests stub one that does not, and
   * they pin the glyph scale of 0.18 — a diorama car on the atlas corridor.
   * On the merged board a 0.18-unit car is 345 m long on a 44 m road, so the
   * default follows the board: one asset-metre per true metre.
   */
  const mpu = world.metresPerUnit;
  const trueMetre = Number.isFinite(mpu) && mpu > 0;
  const heroScale = options.scale ?? (trueMetre ? 1 / mpu : 0.18);
  // Background traffic is deliberately quieter. One oversized convoy reads as
  // map symbols; one detailed hero against smaller traffic reads as a camera.
  const backgroundScale = heroScale * 0.62;
  const count = Math.max(2, Math.min(40, Math.floor(options.count ?? 14)));
  const simulation = new VehicleSimulation(options.pack, {
    routeId: options.routeId,
    count,
    seed: options.seed,
  });
  const controller = new VehicleController(options.pack, {
    routeId: options.routeId,
    mode: "assisted",
    initialSpeedMps: 24,
    travelScale: options.travelScale ?? 900,
  });

  // One articulated, higher-detail car for the follow camera.
  const heroRig = buildLumbridgeEV({ detail: "follow" });
  heroRig.root.name = "model-x-hero";
  group.add(heroRig.root);

  /**
   * The hero's headlamps, on the road rather than in the rig.
   *
   * Only the hero gets one, and that is a judgement about what the picture is
   * of rather than a saving: this camera is *behind* one car, so its beam is the
   * subject and every other car's is either pointing away from the frame or is
   * a background vehicle a few pixels long. A pool per background car would be
   * fourteen more transparent draw calls for something nobody can resolve.
   *
   * It is parented to the rig, so it inherits the car's position, heading and
   * scale for free and can never lag the body it belongs to by a frame.
   *
   * The seam is `onNightLevel`. `scene.ts` hands the solar elevation to
   * `nightlights.ts` and to nothing else, and a traffic layer has no clock; that
   * module publishes the same 0..1 it is already driving the city's own lights
   * with, so the headlamps come up on exactly the curve the street lamps do.
   * A pool of light on a road at nine in the morning is a bug, and this is what
   * makes the day frame and the night frame differ by nothing but the hour.
   */
  const headlamps = buildHeadlampPool({ reachM: HEADLAMP_REACH_M });
  heroRig.root.add(headlamps.mesh);
  const unsubscribeNight = onNightLevel((level) => headlamps.setIntensity(level));

  // Background traffic is one draw call per asset part, not per car. The
  // neutral prototype itself is never attached to the scene.
  const backgroundPrototype = buildLumbridgeEV({ detail: "corridor" });
  const backgroundCount = count - 1;
  const batches: BatchPart[] = modelXInstanceParts(backgroundPrototype).map((part) => {
    const material = Array.isArray(part.material) ? part.material[0] : part.material;
    if (!material) throw new Error(`road traffic: asset part "${part.name}" has no material`);
    const mesh = new THREE.InstancedMesh(part.geometry, material, backgroundCount);
    mesh.name = `traffic:${part.name}`;
    mesh.castShadow = part.castShadow;
    mesh.receiveShadow = part.receiveShadow;
    mesh.frustumCulled = false;
    group.add(mesh);
    return { mesh, local: part.matrix };
  });

  const rootMatrix = new THREE.Matrix4();
  const instanceMatrix = new THREE.Matrix4();
  const position = new THREE.Vector3();
  const quaternion = new THREE.Quaternion();
  const scaleVector = new THREE.Vector3(backgroundScale, backgroundScale, backgroundScale);
  const yawEuler = new THREE.Euler(0, 0, 0, "YXZ");
  const followPosition = new THREE.Vector3();
  const followTarget = new THREE.Vector3();
  const followOffset = new THREE.Vector3();
  let isFollowing = false;
  let activeCameraMode: VehicleCameraMode = "chase";
  let vehicleActions: VehicleActionSnapshot = { ...NEUTRAL_VEHICLE_ACTIONS };

  function scenePose(pose: RenderPose, out: THREE.Vector3): THREE.Vector3 {
    const [x, z] = world.project(pose.lat, pose.lng);
    const heading = (pose.headingDeg * Math.PI) / 180;
    // Lane 0 hugs the median. Southbound traffic's right side naturally moves
    // to the other carriageway because its heading is reversed.
    const laneOffset = trueMetre
      ? (2.2 + (pose.lane ?? 0) * 3.7 + (pose.lateralOffsetM ?? 0)) / mpu
      : 0.29 + (pose.lane ?? 0) * 0.34 + (pose.lateralOffsetM ?? 0) * 0.03;
    out.set(
      x + Math.cos(heading) * laneOffset,
      // The asset origin is on the tyre contact plane. Keep only a tiny lift
      // above the generated road to avoid z-fighting; `0.22` here made the
      // vehicle hover more than its own rendered height at corridor scale.
      world.groundAt(pose.lat, pose.lng) + (trueMetre ? 0.06 / mpu : 0.155),
      z + Math.sin(heading) * laneOffset,
    );
    return out;
  }

  function applyRoot(pose: RenderPose, steering = 0): void {
    scenePose(pose, position);
    heroRig.root.position.copy(position);
    heroRig.root.rotation.set(0, (-pose.headingDeg * Math.PI) / 180, 0);
    heroRig.root.scale.setScalar(heroScale);
    setModelXWheelRotation(heroRig, -pose.wheelRadians);
    setModelXSteering(heroRig, steering);
  }

  function applyBatches(poses: readonly VehiclePose[]): void {
    for (let index = 1; index < poses.length; index += 1) {
      const pose = poses[index];
      if (!pose) continue;
      scenePose(pose, position);
      yawEuler.set(0, (-pose.headingDeg * Math.PI) / 180, 0);
      quaternion.setFromEuler(yawEuler);
      rootMatrix.compose(position, quaternion, scaleVector);
      for (const batch of batches) {
        instanceMatrix.multiplyMatrices(rootMatrix, batch.local);
        batch.mesh.setMatrixAt(index - 1, instanceMatrix);
      }
    }
    for (const batch of batches) batch.mesh.instanceMatrix.needsUpdate = true;
  }

  function applyFollow(pose: RenderPose, dt: number): void {
    const heading = (pose.headingDeg * Math.PI) / 180;
    const forwardX = Math.sin(heading);
    const forwardZ = -Math.cos(heading);
    if (activeCameraMode === "driver") {
      // A hood/driver-height view. The procedural corridor asset has no cabin
      // texture to hide, so the camera sits just above its glass and looks far
      // enough ahead that route curvature reads before the car reaches it.
      const up = trueMetre ? 1.2 / mpu : 0.32;
      const look = trueMetre ? 12 / mpu : 2.2;
      const lookUp = trueMetre ? 1.1 / mpu : 0.14;
      const nose = trueMetre ? 0.6 / mpu : 0.03;
      followTarget
        .copy(heroRig.root.position)
        .add(followOffset.set(forwardX * look, lookUp, forwardZ * look));
      followPosition
        .copy(heroRig.root.position)
        .add(followOffset.set(forwardX * nose, up, forwardZ * nose));
    } else {
      const rightX = Math.cos(heading);
      const rightZ = Math.sin(heading);
      const back = trueMetre ? 14 / mpu : 0.72;
      const up = trueMetre ? 8 / mpu : 0.92;
      const side = trueMetre ? 3.5 / mpu : 0.2;
      const look = trueMetre ? 6 / mpu : 0.32;
      const lookUp = trueMetre ? 1.6 / mpu : 0.1;
      followTarget
        .copy(heroRig.root.position)
        .add(followOffset.set(forwardX * look, lookUp, forwardZ * look));
      followPosition
        .copy(heroRig.root.position)
        .add(
          followOffset.set(
            -forwardX * back - rightX * side,
            up,
            -forwardZ * back - rightZ * side,
          ),
        );
    }
    const blend = 1 - Math.exp(-Math.max(0, dt) * 4.5);
    camera.position.lerp(followPosition, blend);
    controls.target.lerp(followTarget, blend);
    camera.lookAt(controls.target);
  }

  function refresh(dt: number): void {
    const poses = simulation.poses();
    const hero = controller.state();
    applyRoot(hero, hero.steering * 0.55);
    applyBatches(poses);
    if (isFollowing) applyFollow(hero, dt);
  }

  refresh(0);

  return {
    group,
    routeId: () => simulation.routeId(),
    setRoute(routeId) {
      simulation.setRoute(routeId);
      controller.setRoute(routeId);
      refresh(0);
    },
    setFollowing(following) {
      isFollowing = following;
      controls.enabled = !following;
      if (following) refresh(1);
    },
    following: () => isFollowing,
    setVehicleActions(actions) {
      vehicleActions = normalizeVehicleActions(actions);
    },
    setCameraMode(mode) {
      activeCameraMode = mode;
      if (isFollowing) refresh(1);
    },
    cameraMode: () => activeCameraMode,
    hero: () => controller.state(),
    tick(dt) {
      simulation.tick(dt);
      const hero = controller.state();
      const lead = simulation.leadVehicle(hero.distanceM, hero.direction, 0);
      controller.setTrafficContext(lead
        ? { leadGapM: lead.gapM, leadSpeedMps: lead.speedMps }
        : null);
      controller.tick(dt, vehicleActions);
      // Mode/reset requests are edges. Analogue axes remain held until the
      // input adapter publishes a changed snapshot.
      vehicleActions.modeRequest = "none";
      vehicleActions.reset = false;
      refresh(dt);
    },
    dispose() {
      controls.enabled = true;
      unsubscribeNight();
      for (const batch of batches) group.remove(batch.mesh);
      group.remove(heroRig.root);
      // Before `disposeModelX` walks the rig: the pool is parented to it, and
      // that walk would otherwise dispose the geometry and leave the canvas
      // texture behind, which is the leak this repo has already paid for once.
      heroRig.root.remove(headlamps.mesh);
      headlamps.dispose();
      disposeModelX(backgroundPrototype);
      disposeModelX(heroRig);
    },
  };
}
