// The gearbox of a `Vehicle`, as an aspect on the same node. It is deliberately NOT in the engine:
// picking a gear is policy, not physics — two RPM thresholds and a latency timer — and every game
// wants its own. The engine keeps only the physical half (the clutch's stiffness and the coupled
// engine/wheel solve), and is told one ratio and one clutch scalar per frame.
//
//   car.aspect(Gearbox, { ratios: [2.66, 1.78, 1.3, 1.0, 0.74], reverse: -2.9 })
//
// A `Vehicle` with no `Gearbox` attaches this one with its defaults, so a car that never mentions
// gears still drives.
//
// Custom behaviour is a subclass overriding ONE method:
//
//   class SportBox extends Gearbox {
//     select(v: Vehicle): number { return v.rpm > 6200 ? this.gear + 1 : this.gear }
//   }
//
// The clutch is two things multiplied. The SHIFT envelope (`shiftTime`/`clutchTime`) stays with the
// base class on purpose: a forgotten ramp breaks a car silently — no drive at all, or a slammed
// clutch. The TAKE-UP (`takeUp`) is the left-foot half, and is overridable for a car that launches
// unusually — a torque converter, a rally launch control.

import { Aspect } from "../core/Aspect"
import type { FieldMeta } from "../core/fields"
import type { Node } from "./Node"
import type { Vehicle } from "./Vehicle"

// How far above idle counts as "revs to spare". A simulated engine cannot fall below its idle — the
// physics clamps it — so being AT idle is the observable form of "the wheels are dragging me under".
// This is the tolerance for reading that clamp, not a tuning knob.
const IDLE_MARGIN = 1.05

// The car this gearbox belongs to. Read through the aspect accessor rather than node.get(Vehicle),
// so this file needs Vehicle only as a TYPE and the two do not import each other at runtime.
const carOf = (n: Node): Vehicle | null => (n as unknown as { vehicle?: Vehicle }).vehicle ?? null

export class Gearbox extends Aspect<"gearbox", Node> {
  static readonly aspect = "gearbox"

  /** Forward gear ratios, first gear first. Engine RPM = wheel speed × ratio × the differential. */
  ratios: number[] = [ 2.66, 1.78, 1.3, 1.0, 0.74 ]
  /** Reverse ratio — negative, because a ratio's SIGN is what reverse means to the engine. */
  reverse = -2.9
  /** Shift up when the engine passes this fraction of its redline. */
  shiftUp = 0.67
  /** Shift down when it falls below this fraction. */
  shiftDown = 0.33
  /** How long a shift takes, in seconds — the clutch is out for this long. */
  shiftTime = 0.2
  /** How long the clutch takes to re-engage afterwards. */
  clutchTime = 0.3
  /** How long to wait after a shift before another is allowed — without it a car sitting on a
   *  threshold hunts between two gears. */
  latency = 0.5

  /** How far the clutch bites the instant drive is asked for from rest, 0..1. It only has to COUPLE
   *  the shafts — how much torque that passes is the engine's business, so a feathered throttle
   *  still pulls away gently at the same bite point. */
  bite = 0.4

  static fields: FieldMeta<Gearbox> = {
    reverse: { max: -0.1, step: 0.1 },
    shiftUp: { min: 0.1, max: 1, step: 0.01 },
    shiftDown: { min: 0, max: 0.9, step: 0.01 },
    shiftTime: { min: 0, max: 3, step: 0.05 },
    clutchTime: { min: 0, max: 3, step: 0.05 },
    latency: { min: 0, max: 3, step: 0.05 },
    bite: { min: 0, max: 1, step: 0.05 },
    ratios: { hidden: true },
  }

  /** Current gear: −1 reverse, 0 neutral, 1… forward. Read it for a HUD. */
  gear = 0
  /** True while a shift is in progress — the clutch is out and the engine is not driving the wheels. */
  get shifting(): boolean { return this._shiftLeft > 0 }

  private _shiftLeft = 0      // seconds of clutch-out left
  private _engageLeft = 0     // seconds of clutch re-engagement left
  private _waitLeft = 0       // anti-hunting
  private _take = 0           // how far the CAR's own state wants the clutch in, latched per frame

  /** EARLY phase: the pair has to be latched before this frame's step consumes it. */
  updateBefore(dt: number): void {
    const v = carOf(this.node)
    if (!v || !v.id) return

    this._waitLeft = Math.max(0, this._waitLeft - dt)

    if (this._shiftLeft > 0) {
      // Mid-shift: clutch fully out, and the moment it ends the re-engagement ramp starts.
      this._shiftLeft = Math.max(0, this._shiftLeft - dt)
      if (this._shiftLeft === 0) this._engageLeft = this.clutchTime
    } else {
      const want = this.select(v)
      if (want !== this.gear) this.shift(want)
      else this._engageLeft = Math.max(0, this._engageLeft - dt)
    }

    // The pedal only moves while no shift is in flight. During one the revs say nothing about
    // whether the wheels can sustain them: an engine still spinning down from the last gear reads as
    // "revs to spare" and would drop the clutch straight back in — enough to shove a stopped car
    // several km/h with no throttle at all. Freezing it is what a driver does anyway; the foot is
    // already down, and the envelope owns the clutch until the gear is in.
    if (this._shiftLeft === 0 && this._engageLeft === 0) {
      this._take = Math.max(0, Math.min(1, this.takeUp(v, dt)))
    }

    v._setTransmission(this.ratio, this.clutch)
  }

  /**
   * The driver's left foot: where the clutch pedal is, ignoring shifts. Returns the new engagement.
   *
   * A driver does not compute anything. They watch the tacho: **revs sinking to idle means the
   * wheels are about to drag the engine under, and that is when you push the clutch in.** That
   * single test replaces every ratio and threshold — the crossover it finds is the exact speed where
   * engine braking stops and the engine would start PUSHING instead, and it finds it per gear
   * without being told the gear.
   *
   * The pedal comes back UP only for the throttle. Healthy revs are not on their own a reason to
   * engage: a car standing still shows healthy revs too — the wheels simply are not turning to say
   * otherwise — and a clutch dropped on that evidence lurches the car off with no throttle at all.
   *
   * Three things fall out of it for free:
   *
   * - **A braked car stops.** The engine cannot stall, so a clutch left in at 0 km/h feeds idle
   *   torque to the wheels forever and the car creeps against its own brakes. Here the revs pin at
   *   idle, so the pedal goes down and the brakes have nothing to fight.
   * - **Reverse is not a special case.** No ratio takes part, so it needs no separate number — which
   *   is exactly why a fixed road-speed threshold got reverse wrong.
   * - **Hill starts judder instead of stalling.** Let the pedal out, the load pulls the revs down,
   *   the rule pushes it straight back in, and the clutch sits slipping at the bite point until the
   *   car moves. So the test deliberately ignores the throttle — gating it on "no throttle" would
   *   stall the car under load. Throttle only holds the floor at `bite`.
   *
   * The cost is honest and small: once the pedal is down the revs sit at idle and cannot rise on
   * their own, so **coasting downhill gives no engine braking until you touch the throttle** — which
   * is precisely what a car with the clutch in does. Slipping it permanently to keep listening would
   * bring the creep straight back.
   */
  protected takeUp(v: Vehicle, dt: number): number {
    if (this.gear === 0) return 0
    const idle = v.engine.idleRpm ?? 1000
    const step = dt / Math.max(this.clutchTime, dt)
    const asked = v.throttle !== 0
    let take = this._take
    if (v.rpm <= idle * IDLE_MARGIN) take -= step   // being dragged under: pedal goes down
    else if (asked) take += step                    // revs to spare AND somewhere to go: let it up
    // Asking for drive also holds the pedal at the bite point however far down the rule wants it;
    // that is what makes a launch — and a hill start — slip instead of collapsing to nothing.
    return asked ? Math.max(take, this.bite) : take
  }

  /**
   * Which gear the car should be in. The default is an ordinary automatic: pull away from neutral in
   * the direction of the throttle, shift up past `shiftUp` of the redline and down below `shiftDown`,
   * and refuse to shift again for `latency` seconds. Override this and nothing else.
   */
  select(v: Vehicle): number {
    const throttle = v.throttle
    // Neutral, or asked to go the other way: engage once the car has (nearly) stopped.
    if (this.gear === 0 || throttle * this.gear < 0) {
      if (Math.abs(v.speed) > 1 && this.gear !== 0) return this.gear
      return throttle > 0 ? 1 : throttle < 0 ? -1 : this.gear
    }
    if (this.gear < 0 || this._waitLeft > 0 || this.shifting) return this.gear
    const rpm = v.rpm
    const max = v.engine.maxRpm ?? 6000
    // Upshifting needs COUPLED revs. With the clutch out the engine is free, so a blip at a
    // standstill reads as 6000 rpm and the box would climb to top gear without the car moving —
    // then launch in it. Downshifting stays open either way: it can only take you to a lower gear,
    // which is where a car with dying revs belongs.
    if (rpm > max * this.shiftUp && this.clutch >= 1 && this.gear < this.ratios.length) return this.gear + 1
    if (rpm < max * this.shiftDown && this.gear > 1) return this.gear - 1
    return this.gear
  }

  /** Change gear now, with the clutch envelope. Safe to call from `select()` or from game code
   *  (a sequential manual is `if (Input.key('KeyE')) car.gearbox.shift(car.gearbox.gear + 1)`). */
  shift(gear: number): void {
    const clamped = Math.max(-1, Math.min(this.ratios.length, Math.round(gear)))
    if (clamped === this.gear) return
    // The envelope is for swapping one ENGAGED gear for another. If nothing is coupled there is
    // nothing to disengage, so it is skipped: neutral at either end, and equally a car sitting still
    // with the pedal already down — picking reverse there should pull away the moment it is asked
    // to, not sit out a shift it never had to make.
    const swapping = this.gear !== 0 && clamped !== 0 && this._take > 0
    this.gear = clamped
    this._shiftLeft = swapping ? this.shiftTime : 0
    this._engageLeft = swapping ? this.clutchTime : 0
    this._waitLeft = this.latency + this._shiftLeft
  }

  /** The ratio the engine should be running: 0 in neutral, negative in reverse. */
  get ratio(): number {
    if (this.gear === 0) return 0
    if (this.gear < 0) return this.reverse
    return this.ratios[this.gear - 1] ?? 1
  }

  /**
   * The clutch scalar 0..1 the engine is given: the SHIFT envelope (out during a shift, ramping back
   * over `clutchTime`) times the driver's pedal (`takeUp`). Two independent reasons for the clutch
   * to be out, so they multiply: mid-shift is out however healthy the revs are, and an engine being
   * dragged to idle is out whatever gear it just picked.
   */
  get clutch(): number {
    if (this._shiftLeft > 0) return 0
    const envelope =
      this._engageLeft > 0 && this.clutchTime > 0 ? 1 - this._engageLeft / this.clutchTime : 1
    return envelope * this._take
  }
}
