/**
 * Deterministic road traffic over a serializable `TransportPack`.
 *
 * The simulation owns route progress, never render objects. It advances on a
 * fixed clock and exposes plain geographic poses, so a city scene can project
 * them through `World` while a server or replay runner can use the same code
 * without three.js. Long background-tab deltas are capped rather than replayed
 * as a burst; traffic resumes smoothly instead of teleporting through a route.
 */

import type {
  GeographicPoint,
  TransportPack,
  TransportRoute,
  TransportSegment,
} from "./types.ts";

const EARTH_RADIUS_M = 6_371_000;
const MPH_TO_MPS = 0.44704;
const FIXED_STEP = 1 / 20;

/**
 * The rolling radius of the vehicle every simulation in this directory drives,
 * in metres.
 *
 * It lives here — in the leaf module of `src/transport/`, imported by the
 * controller and by this file's own pose integrator — because it is the one
 * physical constant two independent simulations both need and neither owns.
 *
 * **It is not authored twice.** `MODEL_X_METRICS.wheelRadius` in
 * `assets/vehicles/modelX.ts` is the number of record, and this is the same
 * number restated on the far side of a boundary `src/transport/` cannot cross:
 * the asset imports three.js and nothing under `transport/` may. So the
 * agreement is enforced where every other cross-boundary agreement in this repo
 * is enforced — by a test. `src/test/vehicle/metreScale.test.ts` fails if the
 * two ever drift apart.
 *
 * This used to be `0.36`, hard-coded in two places and described as "a
 * representative tyre radius". It was 11 % small against the wheel the renderer
 * actually draws, which at 94 m to the scene unit is invisible and on a
 * forecourt at 1 m to the unit is a car whose wheels visibly spin faster than
 * the ground it covers.
 */
export const VEHICLE_WHEEL_RADIUS_M = 0.405;
const MAX_FRAME_DELTA = 0.25;

export interface RouteLeg {
  segment: TransportSegment;
  from: GeographicPoint;
  to: GeographicPoint;
  lengthM: number;
  startM: number;
  endM: number;
}

export interface RoutePath {
  route: TransportRoute;
  legs: readonly RouteLeg[];
  lengthM: number;
}

export interface RouteSample extends GeographicPoint {
  /** Compass bearing in degrees clockwise from true north. */
  headingDeg: number;
  segmentId: string;
  roadName: string;
  speedLimitMph: number;
}

export interface VehiclePose extends RouteSample {
  id: string;
  routeId: string;
  /** 0 is the median-side lane; positive values move toward the shoulder. */
  lane: number;
  direction: 1 | -1;
  speedMps: number;
  distanceM: number;
  progress: number;
  wheelRadians: number;
}

export interface VehicleSimulationOptions {
  routeId: string;
  count?: number;
  seed?: number;
  /** Time compression for the statewide board. Defaults to 900x. */
  timeScale?: number;
}

export interface LeadVehicleObservation {
  id: string;
  gapM: number;
  speedMps: number;
  lane: number;
}

interface VehicleState {
  pose: VehiclePose;
  cruise: number;
}

function radians(degrees: number): number {
  return (degrees * Math.PI) / 180;
}

/** Equirectangular distance; sub-metre agreement is unnecessary at this scale. */
export function distanceMetres(a: GeographicPoint, b: GeographicPoint): number {
  const meanLat = radians((a.lat + b.lat) / 2);
  const dy = radians(b.lat - a.lat);
  const dx = radians(b.lng - a.lng) * Math.cos(meanLat);
  return Math.hypot(dx, dy) * EARTH_RADIUS_M;
}

function bearing(a: GeographicPoint, b: GeographicPoint): number {
  const meanLat = radians((a.lat + b.lat) / 2);
  const north = b.lat - a.lat;
  const east = (b.lng - a.lng) * Math.cos(meanLat);
  return (Math.atan2(east, north) * 180) / Math.PI;
}

export function buildRoutePath(pack: TransportPack, routeId: string): RoutePath {
  const route = pack.routes.find((candidate) => candidate.id === routeId);
  if (!route) throw new Error(`transport: unknown route "${routeId}"`);
  const nodes = new Map(pack.nodes.map((node) => [node.id, node]));
  const segments = new Map(pack.segments.map((segment) => [segment.id, segment]));
  const legs: RouteLeg[] = [];
  let cursor = 0;

  for (const id of route.segmentIds) {
    const segment = segments.get(id);
    if (!segment) throw new Error(`transport: route "${routeId}" references missing segment "${id}"`);
    const from = nodes.get(segment.fromNodeId)?.position;
    const to = nodes.get(segment.toNodeId)?.position;
    if (!from || !to) throw new Error(`transport: segment "${id}" references a missing node`);
    const lengthM = distanceMetres(from, to);
    if (!(lengthM > 0)) throw new Error(`transport: segment "${id}" has no length`);
    legs.push({ segment, from, to, lengthM, startM: cursor, endM: cursor + lengthM });
    cursor += lengthM;
  }

  if (legs.length === 0) throw new Error(`transport: route "${routeId}" is empty`);
  return { route, legs, lengthM: cursor };
}

function wrap(value: number, modulus: number): number {
  return ((value % modulus) + modulus) % modulus;
}

export function sampleRoute(path: RoutePath, distanceM: number, direction: 1 | -1 = 1): RouteSample {
  const wrapped = wrap(distanceM, path.lengthM);
  // Preserve the authored endpoint when a caller deliberately samples an
  // exact positive route length. Simulation steps store their already-wrapped
  // zero and still loop normally; restored journey progress=1 must remain at
  // San Francisco instead of teleporting to Los Angeles before play resumes.
  const travelled = wrapped === 0 && distanceM > 0 && distanceM <= path.lengthM
    ? path.lengthM
    : wrapped;
  const leg = path.legs.find((candidate) => travelled <= candidate.endM) ?? path.legs.at(-1);
  if (!leg) throw new Error(`transport: route "${path.route.id}" has no legs`);
  const t = Math.max(0, Math.min(1, (travelled - leg.startM) / leg.lengthM));
  const from = direction === 1 ? leg.from : leg.to;
  const to = direction === 1 ? leg.to : leg.from;
  // `distanceM` is always measured from the route's declared start. Reverse
  // traffic advances that scalar downward, so its geographic interpolation is
  // still `t`; only its bearing is reversed. Mirroring `t` here makes a
  // southbound car move north while visually facing south.
  const u = t;
  return {
    lat: leg.from.lat + (leg.to.lat - leg.from.lat) * u,
    lng: leg.from.lng + (leg.to.lng - leg.from.lng) * u,
    headingDeg: bearing(from, to),
    segmentId: leg.segment.id,
    roadName: leg.segment.roadName,
    speedLimitMph: leg.segment.speedLimitMph,
  };
}

/** Small reproducible generator; simulation results never depend on `Math.random()`. */
function seeded(seed: number): () => number {
  let state = seed >>> 0;
  return () => {
    state += 0x6d2b79f5;
    let t = state;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296;
  };
}

export class VehicleSimulation {
  private readonly pack: TransportPack;
  private path: RoutePath;
  private readonly count: number;
  private readonly seed: number;
  private readonly timeScale: number;
  private readonly segments: ReadonlyMap<string, TransportSegment>;
  private accumulator = 0;
  private states: VehicleState[] = [];
  private poseView: VehiclePose[] = [];

  constructor(pack: TransportPack, options: VehicleSimulationOptions) {
    this.pack = pack;
    this.path = buildRoutePath(pack, options.routeId);
    this.count = Math.max(1, Math.min(64, Math.floor(options.count ?? 12)));
    this.seed = options.seed ?? 115;
    this.timeScale = Math.max(1, options.timeScale ?? 900);
    this.segments = new Map(pack.segments.map((segment) => [segment.id, segment]));
    this.reset();
  }

  routeId(): string {
    return this.path.route.id;
  }

  setRoute(routeId: string): void {
    if (routeId === this.path.route.id) return;
    this.path = buildRoutePath(this.pack, routeId);
    this.accumulator = 0;
    this.reset();
  }

  private reset(): void {
    const rand = seeded(this.seed ^ hash(this.path.route.id));
    this.states = Array.from({ length: this.count }, (_, index) => {
      // The first vehicle is the northbound follow-camera hero. Every third
      // background vehicle after it runs south so both carriageways stay alive.
      const direction: 1 | -1 = index > 0 && index % 3 === 0 ? -1 : 1;
      const distanceM = ((index + rand() * 0.6) / this.count) * this.path.lengthM;
      const sample = sampleRoute(this.path, distanceM, direction);
      const cruise = 0.86 + rand() * 0.12;
      const speedMps = sample.speedLimitMph * MPH_TO_MPS * cruise;
      const laneCount = this.segments.get(sample.segmentId)?.lanesPerDirection ?? 2;
      return {
        cruise,
        pose: {
          ...sample,
          id: index === 0 ? "model-x-hero" : `model-x-${String(index + 1).padStart(2, "0")}`,
          routeId: this.path.route.id,
          lane: index % Math.max(2, Math.min(3, laneCount)),
          direction,
          speedMps,
          distanceM,
          progress: distanceM / this.path.lengthM,
          wheelRadians: 0,
        },
      };
    });
    // Keep one stable array for render consumers. The pose objects within it
    // are already mutated in place by `step`, so a 60 fps scene should not pay
    // for a fresh wrapper array on every frame.
    this.poseView = this.states.map((state) => state.pose);
  }

  /** Advance by rendered seconds; internally every state change is a 20 Hz step. */
  tick(dt: number): void {
    if (!Number.isFinite(dt) || dt <= 0) return;
    this.accumulator += Math.min(dt, MAX_FRAME_DELTA);
    while (this.accumulator >= FIXED_STEP) {
      this.step(FIXED_STEP);
      this.accumulator -= FIXED_STEP;
    }
  }

  private step(dt: number): void {
    for (const state of this.states) {
      const previous = state.pose;
      const signed = previous.speedMps * this.timeScale * dt * previous.direction;
      const distanceM = wrap(previous.distanceM + signed, this.path.lengthM);
      const sample = sampleRoute(this.path, distanceM, previous.direction);
      const speedMps = sample.speedLimitMph * MPH_TO_MPS * state.cruise;
      const laneCount = this.segments.get(sample.segmentId)?.lanesPerDirection ?? 2;
      Object.assign(previous, sample, {
        lane: Math.min(previous.lane, Math.max(1, laneCount - 1)),
        speedMps,
        distanceM,
        progress: distanceM / this.path.lengthM,
        wheelRadians: wrap(
          previous.wheelRadians + Math.abs(signed) / VEHICLE_WHEEL_RADIUS_M,
          Math.PI * 2,
        ),
      });
    }
  }

  /** Stable objects, mutated in place; render layers may retain references. */
  poses(): readonly VehiclePose[] {
    return this.poseView;
  }

  /** Nearest same-lane vehicle ahead, suitable for deterministic ACC input. */
  leadVehicle(distanceM: number, direction: 1 | -1, lane = 0): LeadVehicleObservation | null {
    let nearest: LeadVehicleObservation | null = null;
    for (let index = 1; index < this.states.length; index += 1) {
      const pose = this.states[index]?.pose;
      if (!pose || pose.direction !== direction || pose.lane !== lane) continue;
      const gapM = direction === 1
        ? wrap(pose.distanceM - distanceM, this.path.lengthM)
        : wrap(distanceM - pose.distanceM, this.path.lengthM);
      if (gapM < 0.5 || (nearest && gapM >= nearest.gapM)) continue;
      nearest = { id: pose.id, gapM, speedMps: pose.speedMps, lane: pose.lane };
    }
    return nearest;
  }
}

function hash(value: string): number {
  let out = 2_166_136_261;
  for (let index = 0; index < value.length; index += 1) {
    out ^= value.charCodeAt(index);
    out = Math.imul(out, 16_777_619);
  }
  return out >>> 0;
}
