import assert from "node:assert/strict";
import { describe, it } from "node:test";
import * as THREE from "three";
import {
  AircraftController,
  ELECTRIC_AIRCRAFT_METRICS,
  aircraftChaseCameraPose,
  advanceAircraftFans,
  buildElectricAircraft,
  createElectricAircraftMaterials,
  disposeElectricAircraft,
  normalizeAircraftActions,
  replayAircraftInputs,
  setAircraftControlSurfaces,
  setAircraftFanRotation,
} from "../aircraft/index.ts";

const ROUTE = [
  { id: "los-angeles", lat: 34.0522, lng: -118.2437, altitudeM: 1_300 },
  { id: "san-francisco", lat: 37.7749, lng: -122.4194, altitudeM: 1_700 },
] as const;

describe("procedural electric aircraft", () => {
  it("builds an original metre-scale fixed wing facing -Z with articulated parts", () => {
    const rig = buildElectricAircraft();
    assert.equal(rig.root.name, "electric-aircraft");
    assert.equal(rig.root.userData.forwardAxis, "-Z");
    assert.equal(rig.root.userData.aircraftModel, "electric-vtail");
    assert.equal(rig.fans.length, 6);
    assert.equal(rig.ownsMaterials, true);
    assert.ok(ELECTRIC_AIRCRAFT_METRICS.wingspan > ELECTRIC_AIRCRAFT_METRICS.length);
    const bounds = new THREE.Box3().setFromObject(rig.root);
    const size = bounds.getSize(new THREE.Vector3());
    assert.ok(size.x > 10);
    assert.ok(size.z > 7);
    assert.ok(size.y > 1.5);
    assert.ok(rig.leftAileron.position.z <= 0.6, "left aileron remains attached to tapered trailing edge");
    assert.ok(rig.rightAileron.position.z <= 0.6, "right aileron remains attached to tapered trailing edge");
    const wing = rig.root.getObjectByName("electric-aircraft:wing");
    assert.ok(wing instanceof THREE.Mesh);
    const normals = wing.geometry.getAttribute("normal");
    assert.ok(normals && Array.from({ length: normals.count }, (_, index) => normals.getY(index)).some((y) => y > 0.5),
      "wing has a readable upper airfoil surface");
    assert.ok(normals && Array.from({ length: normals.count }, (_, index) => normals.getY(index)).some((y) => y < -0.5),
      "wing is a closed prism that remains visible from below");
    assert.ok(rig.root.getObjectByName("electric-aircraft:canopy-spine"));
    assert.ok(rig.root.getObjectByName("electric-aircraft:nav-left"));
    assert.ok(rig.root.getObjectByName("electric-aircraft:gear-door-left"));
    disposeElectricAircraft(rig);
    assert.equal(rig.root.children.length, 0);
  });

  it("distributes every fan blade evenly around its hub", () => {
    const rig = buildElectricAircraft();
    for (const fan of rig.fans) {
      const blades = fan.children.filter((child) => child.name.includes(":blade-"));
      assert.equal(blades.length, 5);
      const centroid = blades.reduce(
        (sum, blade) => sum.add(blade.position),
        new THREE.Vector3(),
      ).multiplyScalar(1 / blades.length);
      assert.ok(centroid.length() < 1e-12, `fan centroid ${centroid.toArray()}`);
      for (const blade of blades) {
        assert.ok(Math.abs(blade.position.length() - 0.25) < 1e-12);
        const radial = new THREE.Vector3(0, 1, 0).applyQuaternion(blade.quaternion);
        assert.ok(radial.angleTo(blade.position.clone().normalize()) < 1e-7);
      }
    }
    disposeElectricAircraft(rig);
  });

  it("animates opposing ailerons, V-tail surfaces, and electric fans", () => {
    const rig = buildElectricAircraft();
    setAircraftControlSurfaces(rig, { roll: 0.8, pitch: 0.5, yaw: -0.4 });
    assert.ok(rig.leftAileron.rotation.x > 0);
    assert.ok(rig.rightAileron.rotation.x < 0);
    assert.notEqual(rig.leftVTail.rotation.x, rig.rightVTail.rotation.x);
    setAircraftFanRotation(rig, 1);
    advanceAircraftFans(rig, 0.5);
    assert.equal(rig.fans[0]?.rotation.z, 1.5);
    assert.equal(rig.fans[1]?.rotation.z, 1.5);
    disposeElectricAircraft(rig);
  });

  it("does not dispose caller-owned materials", () => {
    const materials = createElectricAircraftMaterials();
    let disposals = 0;
    for (const material of Object.values(materials)) {
      material.addEventListener("dispose", () => { disposals += 1; });
    }
    const rig = buildElectricAircraft({ materials });
    assert.equal(rig.ownsMaterials, false);
    disposeElectricAircraft(rig);
    assert.equal(disposals, 0);
    for (const material of Object.values(materials)) material.dispose();
  });
});

describe("aircraft controller", () => {
  it("normalizes device-neutral flight axes", () => {
    assert.deepEqual(normalizeAircraftActions({
      throttle: 5,
      yaw: -4,
      pitch: Number.NaN,
      roll: 2,
      modeRequest: "manual",
      reset: true,
    }), {
      throttle: 1,
      yaw: -1,
      pitch: 0,
      roll: 1,
      modeRequest: "manual",
      reset: true,
    });
  });

  it("follows route and altitude deterministically in assisted mode", () => {
    const options = {
      route: ROUTE,
      initialPosition: ROUTE[0],
      initialHeadingDeg: 0,
      initialAltitudeM: 900,
      assistedAltitudeM: 1_300,
    };
    const a = new AircraftController(options);
    const b = new AircraftController(options);
    for (let index = 0; index < 600; index += 1) {
      a.stepFixed();
      b.stepFixed();
    }
    assert.deepEqual(a.snapshot(), b.snapshot());
    assert.equal(a.state().mode, "assisted");
    assert.equal(a.state().routeWaypointId, "san-francisco");
    assert.ok(a.state().altitudeM > 900);
    assert.ok(a.state().headingDeg > 180, "aircraft turns northwest toward San Francisco");
  });

  it("takes manual input immediately and resumes assistance without a state jump", () => {
    const controller = new AircraftController({ route: ROUTE });
    for (let index = 0; index < 120; index += 1) {
      controller.stepFixed({ throttle: 0.8, roll: 0.7, pitch: -0.3 });
    }
    assert.equal(controller.state().mode, "manual");
    const before = controller.snapshot();
    controller.stepFixed({ modeRequest: "assisted", roll: 0.8 });
    assert.equal(controller.state().mode, "manual", "manual controls beat simultaneous resume");
    controller.stepFixed({ modeRequest: "assisted" });
    assert.equal(controller.state().mode, "assisted");
    assert.ok(Math.abs(controller.state().rollDeg - before.rollDeg) < 3);
    assert.ok(Math.abs(controller.state().pitchDeg - before.pitchDeg) < 3);
  });

  it("enforces geographic, altitude, and speed boundaries", () => {
    const controller = new AircraftController({
      mode: "manual",
      initialPosition: { lat: 100, lng: -200 },
      initialAltitudeM: 100_000,
      initialSpeedMps: 1_000,
      maximumSpeedMps: 80,
    });
    assert.equal(controller.state().lat, 42.1);
    assert.equal(controller.state().lng, -124.6);
    assert.equal(controller.state().altitudeM, 6_000);
    assert.equal(controller.state().speedMps, 80);
    for (let index = 0; index < 180; index += 1) {
      controller.stepFixed({ throttle: 1, pitch: 1, roll: 1 });
    }
    assert.ok(controller.state().lat >= 32.4 && controller.state().lat <= 42.1);
    assert.ok(controller.state().lng >= -124.6 && controller.state().lng <= -114);
    assert.ok(controller.state().altitudeM >= 75 && controller.state().altitudeM <= 6_000);
    assert.ok(controller.state().speedMps <= 80);
    assert.equal(controller.state().envelopeContact, true);
  });

  it("models deterministic wind, energy use, lift telemetry, and stalls", () => {
    const options = {
      mode: "manual" as const,
      initialSpeedMps: 20,
      minimumSpeedMps: 18,
      stallSpeedMps: 25,
      windNorthMps: 7,
      windEastMps: -4,
      turbulenceMps: 2,
      batteryCapacityWh: 2_000,
      fixedStepSeconds: 0.05,
    };
    const a = new AircraftController(options);
    const b = new AircraftController(options);
    for (let index = 0; index < 200; index += 1) {
      a.stepFixed({ throttle: 0.15, pitch: 0.7, roll: 0.2 });
      b.stepFixed({ throttle: 0.15, pitch: 0.7, roll: 0.2 });
    }
    assert.deepEqual(a.snapshot(), b.snapshot());
    assert.equal(a.state().stalled, true);
    assert.ok(a.state().angleOfAttackDeg > 0);
    assert.ok(a.state().verticalSpeedMps < 0, "a deep low-speed stall loses altitude");
    assert.ok(a.state().energyUsedWh > 0);
    assert.ok(a.state().batteryWh < 2_000);
    assert.notEqual(a.state().windNorthMps, 7, "deterministic turbulence perturbs scenario wind");
    assert.ok(Number.isFinite(a.state().loadFactorG));
  });

  it("uses deterministic terrain contact and reports a hard landing", () => {
    const controller = new AircraftController({
      mode: "manual",
      envelope: {
        minLat: 30,
        maxLat: 45,
        minLng: -130,
        maxLng: -110,
        minAltitudeM: 0,
        maxAltitudeM: 6_000,
      },
      initialAltitudeM: 104,
      initialSpeedMps: 22,
      minimumSpeedMps: 18,
      stallSpeedMps: 26,
      terrainElevationM: () => 100,
      fixedStepSeconds: 0.1,
    });
    let touched = false;
    let hard = false;
    for (let index = 0; index < 120; index += 1) {
      controller.stepFixed({ throttle: 0, pitch: -1 });
      touched ||= controller.state().onGround;
      hard ||= controller.state().hardLanding;
    }
    assert.equal(touched, true);
    assert.equal(hard, true);
    assert.equal(controller.state().groundClearanceM, 0);
    assert.equal(controller.state().altitudeM, 100);
    assert.equal(controller.state().verticalSpeedMps, 0);
  });

  it("resets exactly, caps sleeping-tab time, and replays bit-for-bit", () => {
    const options = { route: ROUTE, initialAltitudeM: 1_200, initialSpeedMps: 48 } as const;
    const controller = new AircraftController(options);
    const spawn = controller.snapshot();
    assert.ok(controller.tick(600) <= 15);
    controller.stepFixed({ throttle: 1, roll: -1 });
    controller.stepFixed({ reset: true });
    assert.deepEqual(controller.snapshot(), spawn);

    const frames = [
      { steps: 90, actions: { throttle: 0.9, roll: 0.4 } },
      { steps: 1, actions: { modeRequest: "assisted" as const } },
      { steps: 180 },
    ];
    assert.deepEqual(
      replayAircraftInputs(options, frames),
      replayAircraftInputs(options, frames),
    );
  });

  it("derives a finite geographic chase camera behind the aircraft", () => {
    const controller = new AircraftController({ initialHeadingDeg: 0 });
    const camera = aircraftChaseCameraPose(controller.state());
    assert.ok(camera.position.lat < controller.state().lat);
    assert.ok(camera.target.lat > controller.state().lat);
    assert.ok(camera.position.altitudeM > controller.state().altitudeM);
    assert.ok(Object.values(camera.position).every(Number.isFinite));
    assert.ok(Object.values(camera.target).every(Number.isFinite));
  });

  it("rejects malformed route and envelope configuration", () => {
    assert.throws(() => new AircraftController({
      route: [{ id: "bad", lat: Number.NaN, lng: 0, altitudeM: 1_000 }],
    }), /waypoints/);
    assert.throws(() => new AircraftController({
      route: [{ id: "outside-california", lat: 45, lng: -118, altitudeM: 1_000 }],
    }), /waypoints/);
    assert.throws(() => new AircraftController({
      route: [{ id: "above-envelope", lat: 37, lng: -122, altitudeM: 8_000 }],
    }), /waypoints/);
    assert.throws(() => new AircraftController({
      envelope: {
        minLat: 40,
        maxLat: 30,
        minLng: -124,
        maxLng: -114,
        minAltitudeM: 10,
        maxAltitudeM: 100,
      },
    }), /envelope/);
    assert.throws(() => new AircraftController({
      terrainElevationM: 3 as unknown as (lat: number, lng: number) => number,
    }), /terrain/);
  });
});
