// A car, as an aspect on a 3D Node, backed by Jolt's wheeled-vehicle constraint in creator-gl. The
// node is the CHASSIS: it needs a Shape (its collision body) and must NOT also carry a Physics aspect
// — Vehicle owns its rigid body, the way CharacterController owns its controller.
//
//   const car = new Node('car')
//     .add(body, wheelFL.aspect(Wheel), wheelFR.aspect(Wheel),
//                wheelRL.aspect(Wheel), wheelRR.aspect(Wheel))
//     .aspect(Shape, { box: [0.9, 0.4, 2.1] })
//     .aspect(Vehicle, { mass: 1200 })
//
//   setLoop(() => {
//     const gas = (Input.key('KeyW') ? 1 : 0) - (Input.key('KeyS') ? 1 : 0)
//     car.vehicle.drive(gas, (Input.key('KeyD') ? 1 : 0) - (Input.key('KeyA') ? 1 : 0), 0, Input.key('Space') ? 1 : 0)
//     hud.text = `${Math.round(car.vehicle.speed * 3.6)} km/h  gear ${car.gearbox.gear}`
//   })
//
// This aspect is the car as a WHOLE: mass, engine, steering, aero. Anything per-wheel lives on the
// `Wheel` aspect of the wheel's own node (which is where Jolt keeps it too), and the gearbox is the
// `Gearbox` aspect — policy, not physics. Vehicle collects every Wheel under itself, groups them into
// axles by Z, and installs a default Gearbox if the node has none.
//
// Chassis space is forward −Z / up +Y — the SDK's own convention, the one `node.forward` reports: if
// the model faces the other way, rotate the MODEL (a child), never the chassis node. Physics owns the
// chassis transform: drive it with `drive()`, never per-frame `node.position` writes.
//
// Layering, and why it is where it is: docs/vehicle-rework-plan.md.

import { Aspect } from "../core/Aspect"
import type { FieldMeta } from "../core/fields"
import { Mat4 } from "../math/mat4"
import { Vec3, cx, cy, cz, type Vec3Like } from "../math/vec"
import { cw, type QuatLike } from "../math/quat"
import { Gizmos } from "../scene/gizmos"
import { glState } from "./state"
import { Gearbox } from "./Gearbox"
import { Node } from "./Node"
import { DEFAULT_FRICTION, Physics } from "./Physics"
import { Shape } from "./Shape"
import { STATE_HEADER, WHEEL_STATE, Wheel } from "./Wheel"

/** Which axle the engine drives. A wheel's own `drive` overrides it. */
export type DriveLayout = "rear" | "front" | "all"

/**
 * How an axle's two wheels share torque.
 *  - `lsd` — limited slip (Jolt's 1.4 ratio), the sane default.
 *  - `open` — fully open: a lifted or spinning wheel takes ALL the torque.
 *  - `locked` — both wheels forced to the same speed. Breaks traction predictably: the drift diff.
 *  A number is the raw max/min wheel-speed ratio (> 1; smaller is more locked).
 */
export type DifferentialMode = "lsd" | "open" | "locked"

export interface EngineConfig {
  /** Peak torque in N·m (default 500). The single biggest "how fast is it" knob. */
  torque?: number
  /** Redline in RPM (default 6000). */
  maxRpm?: number
  /** Idle RPM (default 1000) — the engine never falls below it. */
  idleRpm?: number
  /**
   * The engine's CHARACTER: a flat list `[rpm, torque, rpm, torque, …]` in NORMALISED units — x is
   * a fraction of the redline, y a fraction of `torque`. Left out, Jolt's own broad curve is used.
   *
   * `[0, 0.8, 0.66, 1, 1, 0.8]` is an ordinary petrol engine (peak two thirds up, falling to the
   * redline); `[0, 1, 1, 1]` is flat, which is what an electric motor feels like.
   */
  curve?: number[]
  /** Moment of inertia in kg·m² (default 0.5) — how eagerly it revs when the clutch is out. */
  inertia?: number
  /** Engine braking: angular damping of the engine itself (default 0.2). */
  braking?: number
}

export interface SteeringConfig {
  /** Steering lock in degrees at a standstill (default 35). */
  lock?: number
  /**
   * The lock still available, in degrees, once the car reaches `speed` (default 10) — what stops a
   * car over-driving its own front tires.
   *
   * A tire makes its peak grip at a few degrees of slip angle, and the steering angle a corner needs
   * falls with the SQUARE of speed. Holding full lock at 100 km/h therefore asks the front for
   * several times what it can give: the tires saturate, the car ploughs on, and the wheel stops
   * answering. In between the lock eases on `(1 − v/speed)²`, which tracks the real curve closely.
   * `0` turns the taper off — full lock at any speed.
   */
  atSpeed?: number
  /** The speed in m/s at which the lock has fallen to `atSpeed` (default 30 ≈ 108 km/h). */
  speed?: number
  /** How fast the front wheels may turn, in degrees per second (default 220). A keyboard asks for
   *  full lock in one frame, which no steering wheel can do, and that step alone snaps a tire past
   *  its peak. `0` = instantly. */
  rate?: number
}

/**
 * Aerodynamics. Both numbers are quoted the same way — **a fraction of the car's own weight at
 * 30 m/s (108 km/h)** — and both grow with the SQUARE of speed from there, so `0.5` at 30 m/s is
 * `2.0` at 60 m/s. One mental model for both, and no drag coefficients or frontal areas to invent.
 */
export interface AeroConfig {
  /**
   * Downforce (default 0 — a car with no wing). The knob that decides whether a car corners better
   * or worse the faster it goes: tire grip is a constant, but the force a corner demands grows with
   * v², so with no downforce every car runs out of road at speed. `0.6` roughly doubles a car's
   * cornering at 150 km/h; a road car is nearer `0.1`, a race car well past `1`.
   *
   * It presses along the CHASSIS' own down axis (that is what keeps a car planted over a crest) and
   * only while a wheel is on the ground — a car on its roof is never launched by its own wing.
   */
  downforce?: number
  /** Air resistance (default 0.03 — an ordinary car). What gives a car a top speed at all. */
  drag?: number
}

/** Blob v5 — see bridges.d.ts for the field-by-field layout. */
const BLOB_VERSION = 5
const BLOB_HEADER = 21
const BLOB_WHEEL = 15
const DIFFERENTIALS: Record<DifferentialMode, number> = { lsd: 1.4, open: 0, locked: 1 }
const GRAVITY = 9.81          // only used to place the wheels at their resting height
const AXLE_EPS = 0.35         // wheels within this many metres in Z are the same axle
const clamp = (v: number, lo: number, hi: number): number => (v < lo ? lo : v > hi ? hi : v)

export class Vehicle extends Aspect<"vehicle", Node> {
  static readonly aspect = "vehicle"

  /** Chassis mass in kg (default 1200). Wheels are massless in this model. */
  mass = 1200
  /** Centre of mass height in chassis space (metres, negative = lower). Left out, it drops to halfway
   *  down the chassis shape — a car with its mass at the shape's centre tips over. */
  centerOfMass?: number
  /** Which axle the engine drives (default "rear"). A wheel's own `drive` overrides it. */
  driveAxle: DriveLayout = "rear"
  /** Differential lock across each driven axle. `locked` is the drift setup — both wheels turn
   *  together, so the pair lets go as a pair. Live: re-configure the aspect to switch it mid-drive. */
  differential: DifferentialMode | number = "lsd"
  /**
   * Anti-roll bars between each axle's wheels — what stops a car rolling onto its inside wheels in a
   * fast corner. `true` (default) is a bar 0.01 as stiff as the wheel's own spring; a number is that
   * ratio directly, and `0` / `false` removes the bars.
   *
   * **The numbers are far smaller than they look and the gain is steep** — Jolt's bar feeds
   * `lengthDifference × stiffness × dt` as a bias into the suspension's own soft constraint, so 4%
   * of the spring already flattens the body. Measured on a 1250 kg car: `0` → 1.3° of roll at 0.86 g,
   * `0.01` → 0.6°, `0.15` → 0.2° (flat), `0.5` → the car HOPS.
   *
   * This is the knob for how much a car visibly LEANS, not for wheel lift: bars change how far the
   * body tips, not how much load transfers. For lift, lower `centerOfMass`.
   */
  antiRoll: boolean | number = true
  /** Pitch/roll limit in degrees (default 60) before the constraint rights the car. 180 turns it off. */
  maxTilt = 60
  /** Friction of the CHASSIS body itself — how it slides when it lands on its roof or side. Nothing
   *  to do with the tires; those are the `Wheel`'s `tire.grip` × the ground's friction. */
  friction = DEFAULT_FRICTION
  /** Strength of the clutch when fully engaged (default 10). Small values slip; large ones weld the
   *  engine to the wheels. The `Gearbox` scales it 0..1 while shifting. */
  clutchStrength = 10

  engine: EngineConfig = { torque: 500, maxRpm: 6000, idleRpm: 1000, inertia: 0.5, braking: 0.2 }
  steering: SteeringConfig = { lock: 35, atSpeed: 10, speed: 30, rate: 220 }
  aero: AeroConfig = { downforce: 0, drag: 0.03 }

  /** Scene-editor inspector: the flat, tunable half. The grouped objects are edited in code. */
  static fields: FieldMeta<Vehicle> = {
    mass: { min: 1, step: 10 },
    driveAxle: { label: "Drive", options: [ "rear", "front", "all" ] },
    differential: { options: [ "lsd", "open", "locked" ] },
    antiRoll: { label: "Anti-roll" },
    maxTilt: { label: "Max tilt", min: 0, max: 180, step: 5 },
    friction: { label: "Chassis friction", min: 0, max: 2, step: 0.05 },
    clutchStrength: { label: "Clutch", min: 0.5, max: 100, step: 0.5 },
    engine: { hidden: true },
    steering: { hidden: true },
    aero: { hidden: true },
  }
  static editor = { rebuild: true }

  private _id = 0
  private _bodyId = 0
  private _builtMass = 0
  private _wheels: Wheel[] = []
  private _state = new Float32Array(STATE_HEADER)
  private _stateFrame = -1
  private _throttle = 0
  private _steer = 0
  private _brake = 0
  private _handbrake = 0

  onAttach(): void {
    if (!_creator.physicsHasSupport || !_creator.physicsHasSupport()) return
    if (!_creator.vehicleCreate) {
      console.warn("Vehicle needs a newer host (no vehicleCreate) — the car will not move")
      return
    }
    const shape = this.node.get(Shape)
    if (!shape) {
      throw new Error("Vehicle requires a Shape aspect (the chassis collider) — add it first: node.aspect(Shape, { box: […] }).aspect(Vehicle, {…})")
    }
    if (shape._isTriangleMesh) {
      throw new Error("Vehicle: the chassis cannot use Shape { mesh: true } (a triangle mesh can't be a dynamic body) — use { mesh: 'convex' } or a box")
    }
    if (this.node.has(Physics)) {
      throw new Error("Vehicle owns its own rigid body — remove the Physics aspect from the chassis node (put `mass` on the Vehicle instead)")
    }

    this._wheels = this._collectWheels()
    if (this._wheels.length === 0) {
      throw new Error("Vehicle found no wheels — put a Wheel aspect on each wheel node under the chassis: wheelFL.aspect(Wheel)")
    }

    const shapeId = shape._claim()  // the vehicle owns the shape; drop the pick-only body
    this._id = _creator.vehicleCreate(this.node.id, shapeId, this._buildSettings())
    if (!this._id) {
      console.warn(`Vehicle: the engine refused the settings for "${this.node.name}" — check the wheel list`)
      shape._recreatePickBody()
      return
    }
    this._bodyId = _creator.vehicleBodyId ? _creator.vehicleBodyId(this._id) : 0
    // The chassis body owns the node's transform, so route position writes to it (see Node._xf) —
    // `car.position = p` places the car instead of being silently overwritten by the next sync. It is
    // the WEAK form of the operation: it keeps the car's velocity and its roll, so a respawn still
    // wants `reset(p)`, which also uprights it and clears engine, gearbox and wheel spin.
    this.node._xf = this._bodyId
    this.node._xfKind = this._bodyId ? 2 : 0
    if (this._bodyId && this.friction !== DEFAULT_FRICTION) {
      _creator.physicsSetFriction?.(this._bodyId, this.friction)
    }
    this._builtMass = this.mass
    this._state = new Float32Array(STATE_HEADER + this._wheels.length * WHEEL_STATE)
    // A car with no gearbox still has to drive: install the default automatic.
    if (!this.node.has(Gearbox)) this.node.aspect(Gearbox)
  }

  onDetach(): void {
    if (this._id) { _creator.vehicleDestroy(this._id); this._id = 0; this._bodyId = 0 }
    for (const w of this._wheels) w._vehicle = null
    this._wheels = []
    this.node._xf = 0
    this.node._xfKind = 0
    this.node.get(Shape)?._recreatePickBody()   // hands the routing back to the pick body
  }

  /**
   * Re-configuring an attached vehicle retunes it in place — the way a drift button switches the
   * differential without the car so much as blinking:
   *
   *   car.aspect(Vehicle, { differential: drift ? 'locked' : 'lsd' })
   *
   * Live: `differential`, `engine`, `steering`, `aero`, `maxTilt`, `friction`, `clutchStrength`, and
   * every `Wheel`'s `tire.grip` and brake torques. Everything else describes the car's STRUCTURE —
   * `mass`, `centerOfMass`, which wheels exist and where, their suspension, `driveAxle`, `antiRoll` — and
   * is baked into the constraint at attach; to change one of those, re-attach the aspect.
   */
  onReconfigure(): void {
    if (!this._id) return
    if (this.mass !== this._builtMass) {
      console.warn(`Vehicle: mass is fixed once the body exists (still ${this._builtMass} kg) — re-add the aspect to change it`)
      this.mass = this._builtMass
    }
    _creator.vehicleSetTuning?.(this._id, this._buildSettings())
    if (this._bodyId) _creator.physicsSetFriction?.(this._bodyId, this.friction)
  }

  /** Native vehicle id (0 until attached / no physics support). */
  get id(): number { return this._id }
  /** The chassis rigid-body id — the same handle the plain body calls take. */
  get bodyId(): number { return this._bodyId }
  /** The wheels, in blob order (front axle first). */
  get wheels(): readonly Wheel[] { return this._wheels }

  // --- driving --------------------------------------------------------------------------------

  /**
   * The driver's controls, all in [-1, 1] (brakes in [0, 1]). Sticky: the last values keep applying
   * until the next call, so a held key does not need re-sending. `forward < 0` asks for reverse; the
   * `Gearbox` engages it once the car has stopped.
   */
  drive(forward: number, steer = 0, brake = 0, handbrake = 0): this {
    if (this._id) {
      this._throttle = clamp(forward, -1, 1)
      this._steer = clamp(steer, -1, 1)
      this._brake = clamp(brake, 0, 1)
      this._handbrake = clamp(handbrake, 0, 1)
      _creator.vehicleSetInput(this._id, this._throttle, this._steer, this._brake, this._handbrake)
    }
    return this
  }

  /** Teleport upright and clear all motion (velocity, engine, gearbox, wheel spin). Defaults to the
   *  chassis node's current pose — `reset()` alone un-flips a car where it lies. */
  reset(position?: Vec3Like, rotation?: QuatLike): this {
    if (!this._id) return this
    const p = position ?? this.node.worldPosition
    // No rotation given: keep the heading, drop the roll/pitch — the point of reset is to land upright.
    const wq = this.node.worldQuaternion
    const q: QuatLike = rotation ?? [ 0, wq.y, 0, wq.w ]
    _creator.vehicleReset(this._id, cx(p), cy(p), cz(p), cx(q), cy(q), cz(q), cw(q))
    this._stateFrame = -1
    const box = this.node.get(Gearbox)
    if (box) box.gear = 0
    return this
  }

  /** Push the chassis (a boost pad, an explosion) — an instantaneous impulse in kg·m/s. */
  applyImpulse(v: Vec3Like): this {
    if (this._bodyId) _creator.physicsApplyImpulse(this._bodyId, cx(v), cy(v), cz(v))
    return this
  }

  /** @internal The Gearbox's latched pair: the one ratio the engine runs, and the clutch envelope. */
  _setTransmission(ratio: number, clutch: number): void {
    if (this._id) _creator.vehicleSetTransmission(this._id, ratio, clamp(clutch, 0, 1))
  }

  // --- readouts (one native read per frame, shared by the car and every wheel) -------------------

  private _read(): Float32Array {
    if (this._id && this._stateFrame !== glState.frame) {
      _creator.vehicleGetState(this._id, this._state)
      this._stateFrame = glState.frame
    }
    return this._state
  }

  /** @internal One value out of wheel `index`'s slice — see WHEEL_STATE in Wheel.ts. */
  _wheelState(index: number, k: number): number {
    const s = this._read()
    const at = STATE_HEADER + index * WHEEL_STATE + k
    return at < s.length ? s[at]! : 0
  }

  /** Speed along the car's forward axis in m/s — negative when reversing (× 3.6 for km/h). */
  get speed(): number { return this._read()[0]! }
  /** Engine revolutions per minute. */
  get rpm(): number { return this._read()[1]! }
  /** How many wheels are touching the ground. */
  get wheelsOnGround(): number { return this._read()[2]! }
  /** Any wheel on the ground — false while airborne. */
  get grounded(): boolean { return this._read()[2]! > 0 }
  /** Chassis velocity in world units/s (fresh Vec3). */
  get velocity(): Vec3 { const s = this._read(); return new Vec3(s[3]!, s[4]!, s[5]!) }
  /** The last throttle handed to `drive()` — what the gearbox reads to know which way you want to go. */
  get throttle(): number { return this._throttle }
  /** The last steer input handed to `drive()`, [-1, 1]. */
  get steerInput(): number { return this._steer }
  /** The last foot-brake input handed to `drive()`, [0, 1] — the pedal, not the resulting force. */
  get brake(): number { return this._brake }
  /** The last handbrake input handed to `drive()`, [0, 1]. */
  get handbrake(): number { return this._handbrake }
  /** Current gear from the attached `Gearbox`: −1 reverse, 0 neutral, 1… forward. */
  get gear(): number { return this.node.get(Gearbox)?.gear ?? 0 }
  /** The steering lock, in degrees, the car is allowed at its current speed — the full lock at rest,
   *  falling to `steering.atSpeed`. The same curve the engine applies; useful on a HUD. */
  get steerLock(): number {
    const lock = this.steering.lock ?? 35
    const atSpeed = Math.max(0, this.steering.atSpeed ?? 10)
    if (atSpeed <= 0 || atSpeed >= lock) return lock
    const full = this.steering.speed ?? 30
    const t = full > 0 ? Math.min(Math.abs(this.speed) / full, 1) : 1
    return atSpeed + (lock - atSpeed) * (1 - t) * (1 - t)
  }

  // --- setup ----------------------------------------------------------------------------------

  /** Every Wheel under the chassis, ordered front axle first, with its axle group resolved. */
  private _collectWheels(): Wheel[] {
    const found: Wheel[] = []
    this.node.traverse((n) => { const w = n.get(Wheel); if (w) found.push(w) })
    if (found.length === 0) return found

    const chassisInv = this.node.worldMatrix.invert()
    const centres = found.map((w) => chassisInv.mul(w.node.worldMatrix).position)

    // Axles: sort by Z (forward is −Z, so the front axle is the most NEGATIVE) and start a new group
    // whenever the gap is more than AXLE_EPS.
    const order = centres.map((_, i) => i).sort((a, b) => centres[a]!.z - centres[b]!.z)
    const axleOf = new Array<number>(found.length).fill(0)
    let axle = 0
    let ref = centres[order[0]!]!.z
    for (const i of order) {
      if (Math.abs(centres[i]!.z - ref) > AXLE_EPS) { axle++; ref = centres[i]!.z }
      axleOf[i] = found[i]!.axle ?? axle
    }
    const maxAxle = Math.max(...axleOf)

    // Bind each wheel in front-to-back order, resolving what it did not say.
    const wheels: Wheel[] = []
    for (const i of order) {
      const w = found[i]!
      const a = axleOf[i]!
      const measured = w._measure()
      w.radius ??= measured.radius
      w.width ??= measured.width
      w.steer ??= a === 0
      w.drive ??= this.driveAxle === "all" || (this.driveAxle === "front" ? a === 0 : a === maxAxle)
      w.handbrake ??= !w.steer
      w.axle = a
      const rest = centres[i]!
      // Where the suspension hangs from, so the wheel CENTRE ends up where the author put it: the
      // spring sags by g/(2πf)² under its own load (mass-independent), and that sag is what separates
      // the resting length from full droop.
      const travel = w.suspension.travel ?? 0.4
      const stiffness = w.suspension.stiffness ?? 1.4
      const sag = Math.min(GRAVITY / ((2 * Math.PI * stiffness) ** 2), travel * 0.6)
      const attach = new Vec3(rest.x, rest.y + (travel - sag), rest.z)
      w._bind(this, wheels.length, rest, attach)
      wheels.push(w)
    }
    return wheels
  }

  /** Pack the whole configuration into the one blob the host ABI takes (see bridges.d.ts). */
  private _buildSettings(): Float32Array {
    const curve = this.engine.curve ?? []
    const curveCount = Math.floor(curve.length / 2)
    const diff = typeof this.differential === "number"
      ? this.differential
      : DIFFERENTIALS[this.differential] ?? DIFFERENTIALS.lsd

    const out = new Float32Array(BLOB_HEADER + curveCount * 2 + this._wheels.length * BLOB_WHEEL)
    out[0] = BLOB_VERSION
    out[1] = this.mass
    out[2] = this.centerOfMass === undefined ? 1 : 0
    out[3] = this.centerOfMass ?? 0
    out[4] = this.engine.torque ?? 500
    out[5] = this.engine.maxRpm ?? 6000
    out[6] = this.engine.idleRpm ?? 1000
    out[7] = this.engine.inertia ?? 0.5
    out[8] = this.engine.braking ?? 0.2
    out[9] = this.clutchStrength
    out[10] = diff
    out[11] = this.antiRoll === true ? 0.01 : this.antiRoll === false ? 0 : Math.max(0, this.antiRoll)
    out[12] = this.maxTilt
    out[13] = this.steering.lock ?? 35
    out[14] = Math.max(0, this.steering.atSpeed ?? 10)
    out[15] = this.steering.speed ?? 30
    out[16] = Math.max(0, this.steering.rate ?? 220)
    out[17] = Math.max(0, this.aero.downforce ?? 0)
    out[18] = Math.max(0, this.aero.drag ?? 0.03)
    out[19] = this._wheels.length
    out[20] = curveCount
    let at = BLOB_HEADER
    for (let i = 0; i < curveCount * 2; i++) out[at++] = curve[i]!
    for (const w of this._wheels) {
      out[at++] = w._attach.x
      out[at++] = w._attach.y
      out[at++] = w._attach.z
      out[at++] = w.radius ?? 0.35
      out[at++] = w.width ?? 0.25
      out[at++] = w.steer ? (this.steering.lock ?? 35) : 0
      out[at++] = w.drive ? 1 : 0
      out[at++] = w.brake ? w.brakeTorque : 0
      out[at++] = w.handbrake ? w.brakeTorque * 3 : 0
      out[at++] = w.axle ?? 0
      out[at++] = w.suspension.travel ?? 0.4
      out[at++] = w.suspension.stiffness ?? 1.4
      out[at++] = w.suspension.damping ?? 0.5
      out[at++] = w.tire.grip ?? 1
      out[at++] = w.tire.curve === "arcade" ? 1 : 0
    }
    return out
  }

  /** Edit mode: which way the chassis faces. Each wheel draws its own circle and travel range — see
   *  `Wheel.rebuild()`. Play mode never calls this. */
  rebuild(): void {
    const w = new Mat4(this.node.worldMatrix)
    const P = (x: number, y: number, z: number): Vec3 => w.transformPoint([ x, y, z ])
    Gizmos.polyline([ P(0, 0, 0), P(0, 0, -1.2), P(-0.15, 0, -1.0), P(0, 0, -1.2), P(0.15, 0, -1.0) ], { color: "#ffffff" })
  }
}
