import assert from "node:assert/strict";
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, it } from "node:test";
import * as THREE from "three";
import {
  MODEL_X_METRICS,
  buildModelX,
  disposeModelX,
  modelXInstanceParts,
} from "../../assets/vehicles/index.ts";
import { CALIFORNIA_TRANSPORT } from "../../transport/california.ts";
import {
  METRE_SCALE_VEHICLE_OPTIONS,
  apronMetrics,
  arrivalGroundFor,
  GROUND_ARRIVAL_MAX_ELEVATION_M,
  metreScaleVehicleOptions,
  PARK_JITTER,
  parkPose,
} from "../../transport/exteriorVehicle.ts";
import {
  VehicleController,
  replayVehicleInputs,
  type TimedVehicleInputFrame,
} from "../../transport/vehicleController.ts";
import { VEHICLE_WHEEL_RADIUS_M } from "../../transport/vehicleSim.ts";

const TRANSPORT_DIR = fileURLToPath(new URL("../../transport/", import.meta.url));

describe("the vehicle against the world it stands in", () => {
  it("turns its wheels at the radius the renderer actually draws", () => {
    // The agreement this pins used to be a comment reading "0.36 m is a
    // representative Model X tyre radius", hard-coded in two simulations while
    // the asset's own wheel was 0.405. It cannot be a shared import: the asset
    // imports three.js and nothing under `src/transport/` may, so the far side
    // of that boundary is enforced here instead — the same way a pack's site
    // identity is enforced rather than typed.
    assert.equal(VEHICLE_WHEEL_RADIUS_M, MODEL_X_METRICS.wheelRadius);
  });

  it("rolls one metre of road for one metre of tread by default", () => {
    const controller = new VehicleController(CALIFORNIA_TRANSPORT, {
      ...metreScaleVehicleOptions("la-sf-us-101"),
      mode: "manual",
      initialSpeedMps: 10,
      fixedStepSeconds: 0.1,
    });
    const before = controller.state().wheelRadians;
    controller.stepFixed({ throttle: 0 });
    const travelled = controller.state().distanceM;
    const turned = controller.state().wheelRadians - before;
    // At travelScale 1 the route distance IS the physical distance, so the
    // wheel angle is the arc that distance subtends on the tyre. An 11 % error
    // here is invisible at 94 m to the scene unit and is a car whose wheels
    // visibly spin on a forecourt at 1 m to the unit.
    assert.ok(Math.abs(turned - travelled / MODEL_X_METRICS.wheelRadius) < 1e-9);
  });

  it("stands at metre scale on the apron and at glyph scale on the board", () => {
    const rig = buildModelX({ detail: "corridor" });
    rig.root.updateMatrixWorld(true);
    const box = new THREE.Box3().setFromObject(rig.root);
    const size = box.getSize(new THREE.Vector3());
    // The exterior adds the rig at scale 1, so these are the metres a viewer
    // standing next to it measures. `roadTraffic.ts` multiplies the same asset
    // by 0.18 because one scene unit there is 94 m.
    assert.ok(Math.abs(size.x - MODEL_X_METRICS.width) < 0.02, `width ${size.x}`);
    assert.ok(Math.abs(size.z - MODEL_X_METRICS.length) < 0.05, `length ${size.z}`);
    assert.ok(Math.abs(size.y - MODEL_X_METRICS.height) < 0.02, `height ${size.y}`);
    disposeModelX(rig);
  });

  it("keeps the freeway corridor's instanced batch count exactly where it was", () => {
    // `engine/roadTraffic.ts` builds one `InstancedMesh` per part of the
    // corridor prototype, and the city board is at 90 % of its triangle cap.
    // Eighteen is what it was before this workstream and it must be what it is
    // after: nothing here may cost the board a draw call.
    const prototype = buildModelX({ detail: "corridor" });
    assert.equal(modelXInstanceParts(prototype).length, 18);
    disposeModelX(prototype);
  });
});

describe("metre-scale controller options", () => {
  it("survives the controller's own clamps unchanged", () => {
    // `resolveOptions` clamps every one of these, and a preset that silently
    // landed on a clamp bound would be a preset that does not mean what it says.
    const controller = new VehicleController(CALIFORNIA_TRANSPORT, {
      ...metreScaleVehicleOptions("la-sf-us-101"),
      mode: "manual",
      fixedStepSeconds: 0.1,
    });
    for (let i = 0; i < 600; i += 1) controller.stepFixed({ throttle: 1 });
    const state = controller.state();
    assert.ok(
      Math.abs(state.speedMps - METRE_SCALE_VEHICLE_OPTIONS.maximumSpeedMps) < 0.5,
      `flat out at ${state.speedMps} m/s against a ${METRE_SCALE_VEHICLE_OPTIONS.maximumSpeedMps} ceiling`,
    );
  });

  it("puts the guardrail where a forecourt's edge is, not a motorway's", () => {
    const controller = new VehicleController(CALIFORNIA_TRANSPORT, {
      ...metreScaleVehicleOptions("la-sf-us-101"),
      mode: "manual",
      initialSpeedMps: 12,
      fixedStepSeconds: 0.1,
    });
    for (let i = 0; i < 400; i += 1) controller.stepFixed({ throttle: 1, steering: 1 });
    assert.ok(
      Math.abs(controller.state().lateralOffsetM) <=
        METRE_SCALE_VEHICLE_OPTIONS.guardrailOffsetM + 1e-9,
    );
    assert.equal(controller.state().guardrailContact, true);
  });

  it("advances route distance 900 times faster at board scale than at metre scale", () => {
    function distanceAfter(travelScale: number): number {
      const controller = new VehicleController(CALIFORNIA_TRANSPORT, {
        routeId: "la-sf-us-101",
        mode: "manual",
        initialSpeedMps: 20,
        fixedStepSeconds: 0.1,
        travelScale,
      });
      for (let i = 0; i < 50; i += 1) controller.stepFixed({ throttle: 0.5 });
      return controller.state().distanceM;
    }
    const metre = distanceAfter(METRE_SCALE_VEHICLE_OPTIONS.travelScale);
    const board = distanceAfter(900);
    assert.equal(METRE_SCALE_VEHICLE_OPTIONS.travelScale, 1);
    assert.ok(Math.abs(board / metre - 900) < 1e-6, `ratio ${board / metre}`);
  });

  it("lets a caller override one dial without restating the rest", () => {
    const options = metreScaleVehicleOptions("la-sf-i-5", { maximumSpeedMps: 8 });
    assert.equal(options.routeId, "la-sf-i-5");
    assert.equal(options.maximumSpeedMps, 8);
    assert.equal(options.travelScale, 1);
    assert.equal(options.wheelRadiusM, MODEL_X_METRICS.wheelRadius);
  });

  it("imports no renderer anywhere under src/transport/", () => {
    // The arena imports these modules under Node's type stripping and the
    // server could too. A stray `import * as THREE` would break both, and it is
    // the kind of import that arrives by autocomplete rather than by decision.
    for (const entry of readdirSync(TRANSPORT_DIR)) {
      if (!entry.endsWith(".ts")) continue;
      const source = readFileSync(join(TRANSPORT_DIR, entry), "utf8");
      assert.ok(
        !/from\s+["']three/.test(source),
        `${entry} imports three.js and nothing under transport/ may`,
      );
    }
  });
});

describe("the bay is sized from the car", () => {
  it("fits the vehicle with room to open a door", () => {
    const metrics = apronMetrics(MODEL_X_METRICS);
    assert.ok(metrics.stallWidth > MODEL_X_METRICS.width);
    assert.ok(metrics.stallLength > MODEL_X_METRICS.length);
    // Enough clearance each side to actually get out, which is the number a
    // marked bay exists to guarantee.
    assert.ok((metrics.stallWidth - MODEL_X_METRICS.width) / 2 >= 0.4);
    // The pad is bigger than the bay, and the bay is inside it.
    assert.ok(metrics.padWidth > metrics.stallWidth);
    assert.ok(metrics.padDepth > metrics.stallLength);
    // The charge post stands clear of the bay rather than inside it.
    assert.ok(Math.abs(metrics.postOffsetX) > metrics.stallWidth / 2);
    assert.ok(Math.abs(metrics.postOffsetX) + metrics.postWidth / 2 < metrics.padWidth / 2);
  });

  it("grows with the vehicle rather than being authored twice", () => {
    const small = apronMetrics({ length: 3.6, width: 1.6 });
    const large = apronMetrics({ length: 5.6, width: 2.4 });
    assert.ok(large.stallWidth > small.stallWidth);
    assert.ok(large.stallLength > small.stallLength);
    assert.ok(large.padWidth > small.padWidth);
  });

  it("reads a tower as air and everything with a kerb as ground", () => {
    // The shipped elevations, and both edges of the threshold. See
    // `ArrivalGround`: this decides *whether* there is an apron, not which kind.
    assert.equal(arrivalGroundFor(188), "air");
    assert.equal(arrivalGroundFor(4), "ground");
    assert.equal(arrivalGroundFor(1.2), "ground");
    assert.equal(arrivalGroundFor(0), "ground");
    assert.equal(arrivalGroundFor(GROUND_ARRIVAL_MAX_ELEVATION_M), "ground");
    assert.equal(arrivalGroundFor(GROUND_ARRIVAL_MAX_ELEVATION_M + 0.01), "air");
    // A pack that failed to state its elevation gets a kerb, because a bay
    // nobody walks past is a cheaper mistake than a car in mid-air.
    assert.equal(arrivalGroundFor(Number.NaN), "ground");
  });
});

describe("parking a car off the line", () => {
  it("stays inside the tolerance the anchor promises", () => {
    const arrival = { position: { x: 22.6, z: -3.4 }, rotation: -Math.PI / 2 };
    for (let i = 0; i < 5_000; i += 1) {
      const pose = parkPose(arrival, Math.random);
      const offset = Math.hypot(pose.x - arrival.position.x, pose.z - arrival.position.z);
      assert.ok(offset <= Math.hypot(PARK_JITTER.lateralM, PARK_JITTER.longitudinalM) + 1e-9);
      assert.ok(Math.abs(pose.yaw - arrival.rotation) <= PARK_JITTER.yawRad + 1e-9);
    }
  });

  it("nudges the car along its own bay rather than across the plan", () => {
    // A bay at 90° whose jitter ran in plan axes would push the car sideways
    // out of the painted rectangle it is meant to be sitting slightly askew in.
    const arrival = { position: { x: 0, z: 0 }, rotation: Math.PI / 2 };
    // rand() = 1 gives full positive lateral, then full positive longitudinal.
    const pose = parkPose(arrival, () => 1);
    // Yaw +90° turns the vehicle's right (+X) onto −Z and its forward (−Z) onto
    // −X, so a bay-frame nudge shows up entirely in the rotated axes.
    assert.ok(Math.abs(pose.z + PARK_JITTER.lateralM) < 1e-9, `z ${pose.z}`);
    assert.ok(Math.abs(pose.x + PARK_JITTER.longitudinalM) < 1e-9, `x ${pose.x}`);
  });

  it("is a function of the generator alone, so a seed reproduces a car park", () => {
    const arrival = { position: { x: 3, z: 9 }, rotation: 0.4 };
    const make = () => {
      let a = 1234 >>> 0;
      return () => {
        a = (a + 0x6d2b79f5) >>> 0;
        let t = Math.imul(a ^ (a >>> 15), 1 | a);
        t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
        return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
      };
    };
    assert.deepEqual(parkPose(arrival, make()), parkPose(arrival, make()));
  });
});

/**
 * The corridor must behave identically after this workstream.
 *
 * These are the exact figures the pre-workstream controller produced for this
 * input recording, captured before a line of it changed. They are route
 * *position* — the distance, the projected coordinate and the progress — which
 * is what "the freeway is unchanged" means and what a viewer sees.
 *
 * `wheelRadians` is deliberately not in here and is deliberately different: the
 * rolling radius moved from 0.36 to the asset's own 0.405, which is the whole
 * point of the metre-scale fix above. Nothing about where the car is on the
 * road changed, and the wheel now turns at the rate the road it covers implies.
 */
const FREEWAY_GOLDEN = {
  "la-sf-us-101": {
    distanceM: 34721.28615877114,
    lat: 34.13149371451207,
    lng: -118.6087188177537,
    progress: 0.05681942405244269,
    speedMps: 12.413457136746057,
    lateralOffsetM: -1.2242248525368438,
    headingDeg: -79.78730586053224,
  },
  "la-sf-i-5": {
    distanceM: 34721.286158771254,
    lat: 34.30464099897842,
    lng: -118.46596865860532,
    progress: 0.05852269146886045,
    speedMps: 12.413457136746057,
    lateralOffsetM: -1.2242248525368438,
    headingDeg: -40.55395656958177,
  },
} as const;

const FREEWAY_RECORDING: readonly TimedVehicleInputFrame[] = [
  { steps: 90, actions: { throttle: 1 } },
  { steps: 45, actions: { throttle: 0.6, steering: 0.35 } },
  { steps: 30, actions: { brake: 0.8 } },
  { steps: 60, actions: { modeRequest: "assisted" } },
  { steps: 75, actions: { throttle: 0.4, steering: -0.5 } },
];

describe("freeway behaviour is unchanged", () => {
  for (const [routeId, expected] of Object.entries(FREEWAY_GOLDEN)) {
    it(`replays ${routeId} at travelScale 900 to the same route position`, () => {
      const { final } = replayVehicleInputs(
        CALIFORNIA_TRANSPORT,
        { routeId, mode: "manual", travelScale: 900 },
        FREEWAY_RECORDING,
      );
      assert.equal(final.distanceM, expected.distanceM);
      assert.equal(final.lat, expected.lat);
      assert.equal(final.lng, expected.lng);
      assert.equal(final.progress, expected.progress);
      assert.equal(final.speedMps, expected.speedMps);
      assert.equal(final.lateralOffsetM, expected.lateralOffsetM);
      assert.equal(final.headingDeg, expected.headingDeg);
    });
  }

  it("still defaults travelScale to 1, so nothing inherits the corridor's 900", () => {
    const controller = new VehicleController(CALIFORNIA_TRANSPORT, {
      routeId: "la-sf-us-101",
      mode: "manual",
      initialSpeedMps: 20,
      fixedStepSeconds: 0.1,
    });
    controller.stepFixed({ throttle: 0 });
    assert.ok(controller.state().distanceM < 3, "an unscaled step is metres, not kilometres");
  });
});
