// A character controller, as an aspect on a 3D Node, backed by Jolt's CharacterVirtual — a kinematic
// collide-and-slide controller (NOT a rigid body: no bouncing/tipping, crisp control, built-in stair
// stepping + slope handling). Requires a Shape (a capsule is recommended) and does NOT use Physics.
//
//   const hero = new Mesh(capsuleGeometry(), material)
//     .aspect(Shape, { capsule: { halfHeight: 0.6, radius: 0.3 } })
//     .aspect(CharacterController)
//   setLoop(() => hero.controller.move((Input.key('KeyD') ? 1 : 0) - (Input.key('KeyA') ? 1 : 0) * SPEED,
//                                      (Input.key('KeyS') ? 1 : 0) - (Input.key('KeyW') ? 1 : 0) * SPEED))
//   Input.on('keydown', e => { if (e.code === 'Space' && hero.controller.grounded) hero.controller.velocityY = 7 })
//
// The two halves of the velocity have different NATURES, so they have different channels:
//
//   • HORIZONTAL — `move(x, z)`, world units/s, a PER-FRAME COMMAND. A walking character's horizontal
//     velocity is muscle-driven and not conserved: stop pushing, stop moving. So the command expires
//     each frame — no call this frame means standing still, and releasing the keys needs no explicit
//     zero. It is latched for the whole frame, so every physics sub-step of that frame sees it.
//   • VERTICAL — `velocityY`, LATCHED ballistic state. Gravity integrates into it in the engine;
//     you seed it for a jump / dash / bounce pad / explosion (`velocityY = 7`, `velocityY += 3`).
//     There is deliberately no ground check: coyote time, double jumps and wall jumps all need
//     the caller's own condition.
//
// `velocity` reads back what the solver ENDED UP with after the last step (post-collision) — walk into
// a wall and it reads ~0 even though you commanded 5. Everything runs on the engine's fixed clock, so
// the aspect has no per-frame update: a character costs zero JS work per frame.
//
// FREE MODE — `gravityScale = 0` turns the character into a swimmer / flyer / drone: no gravity, and
// (because Jolt would otherwise snap it down to the floor it passes over) no stick-to-floor and no
// stair walking either. There the vertical is muscle-driven too, so `move()` takes all three
// components and the whole velocity becomes a per-frame command.
//
// ROTATION AND SCALE stay the node's: the engine writes only the position, so a rig can be yawed and
// scaled directly instead of needing a child node for the visuals (a capsule is symmetric about its up
// axis, so its yaw is physically irrelevant either way).
//
// CROUCHING goes through the Shape aspect (`node.aspect(Shape, { capsule: … })`) — see `resizing`.
//
// Note: it collides with solid bodies but PASSES THROUGH triggers, and (v1) is not itself detected by
// triggers and isn't pointer-pickable while active.

import { Aspect } from "../core/Aspect"
import type { FieldMeta } from "../core/fields"
import { Vec3, cx, cy, cz, type Vec2Like, type Vec3Like } from "../math/vec"
import type { CompAxis, CompWriter } from "../core/compWrite"
import { Shape } from "./Shape"
import type { Node } from "./Node"

/** Where the character's feet are, as reported by the solver after the last step. */
export type GroundState = "ground" | "slope" | "unsupported" | "air"

// Index = JPH::EGroundState (0 OnGround / 1 OnSteepGround / 2 NotSupported / 3 InAir).
const GROUND_STATES: readonly GroundState[] = ["ground", "slope", "unsupported", "air"]

const scratch = new Float32Array(3)
let warnedFreeMove = false

export class CharacterController extends Aspect<"controller", Node> implements CompWriter {
  static readonly aspect = "controller"

  // Both tunables are accessor-backed (they write through to the engine), and describeFields only
  // enumerates own enumerable fields — so the inspector is told about them explicitly. The default
  // VALUES still come from a fresh instance through the getters; this only supplies the keys.
  static fields: FieldMeta<CharacterController> = {
    gravityScale: { editor: "number", min: 0, step: 0.1, label: "Gravity scale" },
    maxSlope: { editor: "number", min: 0, max: 89, step: 1, label: "Max slope°" },
  }

  private _charId = 0
  private _gravityScale = 2
  private _maxSlope = 45

  onAttach(): void {
    if (!_creator.physicsHasSupport || !_creator.physicsHasSupport()) return
    const shape = this.node.get(Shape)
    if (!shape) {
      throw new Error("CharacterController requires a Shape aspect (a capsule is recommended) — add it first: node.aspect(Shape, { capsule: {…} }).aspect(CharacterController)")
    }
    if (shape._isTriangleMesh) {
      throw new Error("CharacterController needs a convex Shape (capsule recommended) — { mesh: true } is a triangle mesh; use { mesh: 'convex' } or a capsule")
    }
    const shapeId = shape._claim() // the character owns the shape; it is not a rigid body
    // Read the options through their PUBLIC accessors, never the `_backing` fields: chisel drops an
    // accessor pair whose name is only ever an object-literal key, and then
    // `aspect(CharacterController, { gravityScale: 0 })` silently leaves the default in place (the
    // character keeps falling instead of entering free mode). One property read here keeps the pair.
    this._charId = _creator.characterCreate(this.node.id, shapeId, this.maxSlope)
    if (!this._charId) return
    _creator.characterSetGravityScale(this._charId, this.gravityScale)
    this.node._xf = this._charId   // routes node.position / .x / .y / .z / .matrix writes to the character
    this.node._xfKind = 1
  }

  onDetach(): void {
    if (this._charId) { _creator.characterDestroy(this._charId); this._charId = 0 }
    this.node._xf = 0
    this.node._xfKind = 0
    this.node.get(Shape)?._recreatePickBody()   // hands the routing back to the pick body
  }

  /** Native character id (0 until attached / no physics support). */
  get id(): number { return this._charId }

  /**
   * This frame's movement command, in world units/s — NOT normalized, NOT a per-frame displacement
   * (that is what Unity's `Move` takes; passing `v * dt` here gives a character 60× too slow).
   *
   * Two components = horizontal `(x, z)`, the everyday call. Three = the whole velocity, for free mode
   * (`gravityScale = 0`); with gravity on, the vertical component fights the ballistic one and the
   * character barely falls, so that combination warns.
   *
   * Sticky only within the frame: the command expires once the engine consumes it. It also **takes the
   * axis back from a latched `velocity`** — commanding is claiming ownership, which is what keeps the
   * two horizontal sources from ever fighting.
   */
  move(x: number, z: number): void
  move(v: Vec2Like | Vec3Like): void
  move(x: number | Vec2Like | Vec3Like, z?: number): void {
    if (!this._charId) return
    if (typeof x === "number") { _creator.characterMove(this._charId, x, z ?? 0); return }
    // cz() is typed for 3-component inputs; a Vec2 legitimately has no `z` and reads undefined here.
    const third = cz(x as Vec3Like)           // undefined for a Vec2 / 2-tuple / 2-long typed array
    if (third === undefined) { _creator.characterMove(this._charId, cx(x), cy(x)); return }
    if (this._gravityScale !== 0 && !warnedFreeMove) {
      warnedFreeMove = true
      console.warn("CharacterController.move(): the 3-component form is the FREE-mode command (swimming / flying) and expects gravityScale = 0. With gravity on it overwrites the falling speed every frame, so the character will hang in the air. Use move(x, z) and velocityY for a walking character.")
    }
    _creator.characterMoveFree(this._charId, cx(x), cy(x), third)
  }

  /** Vertical velocity (world units/s) — LATCHED: gravity works on it, you seed it. `= 7` to jump,
   *  `+= 3` to stack an explosion on top of the current motion. No ground check: guard it yourself
   *  with `grounded` (or don't, for a double jump). Overridden every frame by a 3-component `move()`.
   *  `velocity.y = 7` is the same channel — pick whichever reads better. */
  get velocityY(): number {
    if (!this._charId) return 0
    _creator.characterGetVelocity(this._charId, scratch)
    return scratch[1]
  }
  set velocityY(v: number) {
    if (this._charId) _creator.characterSetVerticalVelocity(this._charId, v)
  }

  /** Multiplier over the world gravity (`Physics.configure({ gravity })`); default 2, for the snappier
   *  fall games want. **0 = free mode**: no gravity, no stick-to-floor, no stair stepping — a swimmer
   *  or a drone, driven by the 3-component `move()`. */
  get gravityScale(): number { return this._gravityScale }
  set gravityScale(v: number) {
    this._gravityScale = v
    if (this._charId) _creator.characterSetGravityScale(this._charId, v)
  }

  /** Max ground slope (degrees) the character treats as walkable; default 45. Live. */
  get maxSlope(): number { return this._maxSlope }
  set maxSlope(v: number) {
    this._maxSlope = v
    if (this._charId) _creator.characterSetMaxSlope(this._charId, v)
  }

  /**
   * True while a requested collider resize hasn't taken — you asked to stand up and there is something
   * overhead. Resizing goes through the `Shape` aspect itself:
   *
   *     hero.aspect(Shape, { capsule: CROUCHED })   // always fits — you are shrinking
   *     hero.aspect(Shape, { capsule: STANDING })   // may be refused under a low ceiling
   *     if (hero.controller.resizing) …             // still crouched; call it again next frame
   *
   * A refusal changes nothing, so the retry is just the same call again — and it is an exact headroom
   * test against the real capsule, unlike a hand-rolled raycast (a ray is a line; a capsule has girth).
   * The FEET stay planted across a resize, so the character neither hovers nor sinks.
   */
  get resizing(): boolean {
    return this.node.get(Shape)?._resizeRefused ?? false
  }

  /** True while standing on walkable ground. */
  get grounded(): boolean {
    return this._charId ? _creator.characterGetGroundState(this._charId) === 0 : false
  }

  /** Where the feet are after the last step: on walkable ground, on too-steep ground, touching
   *  something that can't support it, or in the air. */
  get groundState(): GroundState {
    return this._charId ? (GROUND_STATES[_creator.characterGetGroundState(this._charId)] ?? "air") : "air"
  }

  /**
   * READ — the velocity the solver ended up with after the most recent step (world units/s, fresh
   * Vec3): what HAPPENED, not what you asked for. Walking into a wall reads ~0, sliding along one
   * reads the tangent. Unlike Unity's synchronous `Move`, our step runs later in the frame, so a read
   * is up to one frame old — irrelevant for animation speed, fall damage or "am I blocked", which is
   * what it is for. (Exact wall contact would need a contact normal; the engine has none yet.)
   *
   * WRITE — LATCH the whole velocity: a knockback, a wall jump, a launch pad. Unlike `move()` it does
   * not expire, so the character keeps flying, and gravity still pulls the vertical down into a real
   * ballistic arc. It stays until `move()` takes the axis back — so a game simply doesn't call
   * `move()` while the throw lasts, and ends it on its own terms:
   *
   *     hero.controller.velocity = [dir.x * 12, 6, dir.z * 12]   // hit by the blast
   *     // …in the loop:
   *     if (thrown) { if (hero.controller.grounded) thrown = false }   // landing ends it
   *     else hero.controller.move(ix * SPEED, iz * SPEED)              // …and this reclaims the axis
   *
   * Note nothing clears the latch by itself, landing included — so a thrown character that never gets
   * a `move()` keeps sliding along the ground (a kinematic controller has no friction).
   *
   * Reading is not the inverse of writing: `c.velocity = c.velocity` is NOT a no-op, because the read
   * reports the measured result (against a wall it is ~0 and would cancel the throw). In flight the
   * two agree closely, so read-modify-write mid-air behaves as expected.
   *
   * `c.velocity.y = 7` (the direct spelling) is a jump — the compiler routes it to the exact
   * `velocityY` channel via `_writeComp` below, no vector allocated — and `c.velocity.x = 3` latches
   * the whole vector with the measured velocity filling in the other two, i.e. the read-modify-write
   * above spelled naturally. A STORED copy is still a copy: `const v = c.velocity; v.y = 7` does nothing.
   */
  get velocity(): Vec3 {
    if (!this._charId) return new Vec3(0, 0, 0)
    _creator.characterGetVelocity(this._charId, scratch)
    return new Vec3(scratch[0], scratch[1], scratch[2])
  }
  set velocity(v: Vec3Like) {
    if (this._charId) _creator.characterSetVelocity(this._charId, cx(v), cy(v), cz(v))
  }

  /** Compile-time list (chisel reads it, then strips it): the getters whose `c.<getter>.<axis> = v`
   *  spelling is routed to `_writeComp` below. See core/compWrite.ts. */
  static _comps = [ "velocity" ]

  /** @internal `c.velocity.<axis> = v` compiles to this: the vertical axis is the precise `velocityY`
   *  channel, any other latches the whole vector with the measured rest. */
  _writeComp(_prop: string, axis: CompAxis, v: number): void {
    if (!this._charId) return
    if (axis === "y") { _creator.characterSetVerticalVelocity(this._charId, v); return }
    _creator.characterGetVelocity(this._charId, scratch)
    if (axis === "x") scratch[0] = v
    else if (axis === "z") scratch[2] = v
    _creator.characterSetVelocity(this._charId, scratch[0], scratch[1], scratch[2])
  }
}
