// Rigid body as an aspect on a 3D Node, backed by a JoltPhysics body in creator-gl. Requires a Shape
// aspect on the same node (its geometry becomes the body's collision shape).
//
//   Physics.configure({ gravity: [0, -9.81, 0] })          // once, before creating bodies
//   ground.aspect(Shape, { box: [20, 0.5, 20] }).aspect(Physics, { motion: 'static' })
//   crate.aspect(Shape, {}).aspect(Physics, { mass: 2 })   // auto box from the mesh
//   crate.physics.applyImpulse([0, 6, 0])
//   crate.addEventListener('enter', other => …)            // contact / trigger overlap began
//
// For a `dynamic` body, physics OWNS the transform — do NOT set node.position per frame; drive it via
// velocity / applyImpulse. Reading node.position is free (native already wrote it). A `position` write
// is a TELEPORT that reaches the body (Shape routes it), which is how a kinematic body is placed too.

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

export type MotionType = "static" | "dynamic" | "kinematic"
const MOTION: Record<MotionType, number> = { static: 0, kinematic: 1, dynamic: 2 }
// Jolt's angular velocity is rad/s; every angle the SDK exposes is in degrees (like `eulerAngles`).
const DEG2RAD = Math.PI / 180
const RAD2DEG = 180 / Math.PI
// Scratch for velocity reads (one per module, never handed out) — a read allocates the Vec3 only.
const scratch = new Float32Array(3)

export interface PhysicsConfig {
  /** Gravity in world units/s² (Y-up: down is negative). Default [0, -9.81, 0]. */
  gravity?: Vec3Like
  /** Max simultaneous bodies (resizes the world; only before any body exists). Default 4096. */
  maxBodies?: number
}

/** Closest raycast hit. */
export interface RayHit {
  node: Node | null
  point: Vec3
  normal: Vec3
  fraction: number
}

/**
 * What a body's friction already is the moment the host creates it. This is part of the ABI, not an
 * assumption: bridges.d.ts states it, so every host owes us this value. Knowing it lets attach skip
 * a bridge call per body whenever nobody named a surface.
 */
export const DEFAULT_FRICTION = 0.6

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

  /** Render interpolation of body transforms between the fixed 60 Hz steps (global; default on). Turn
   *  off to save per-frame transform writes when you have many moving bodies / don't need >60 Hz
   *  smoothness — bodies then advance in discrete steps. */
  private static _interpolation = true
  static get interpolation(): boolean { return Physics._interpolation }
  static set interpolation(on: boolean) { Physics._interpolation = on; _creator.setInterpolation(on) }

  /** Default "dynamic". */
  motion: MotionType = "dynamic"
  /** Mass in kg for dynamic bodies (default 1). Ignored for static/kinematic. */
  mass = 1
  /**
   * Surface friction: 0 = ice, ~1 = grippy asphalt, and values above 1 are allowed (rubber on
   * tarmac).
   *
   * The engine COMBINES the two touching bodies as `sqrt(a * b)`, so the LOWER value dominates and
   * the floor caps everything standing on it. The default **0.6** is a neutral solid surface (the
   * raw Jolt default of 0.2 is ice by game standards); dry tarmac a car should corner on wants a
   * `1`, ice `0.02`. A Vehicle's tires read the GROUND body's value, so this is the single number
   * that decides how well a car corners.
   *
   * Change it at runtime by re-configuring — `floor.aspect(Physics, { friction: 0.02 })` (an ice
   * patch) — or by assigning `floor.physics.friction = 0.02`; either way the setter pushes the new
   * surface to the live body at once. (chisel keeps an instance setter whose name appears as an
   * object-literal key anywhere in the bundle, so the config-object route survives tree-shaking
   * even when no code reads `.friction`.)
   */
  get friction(): number { return this._friction }
  set friction(v: number) {
    this._friction = v
    // Unconditional on a live body, unlike attach: it may be on its way BACK to the default from
    // something else, and skipping that would leave the ice patch in place.
    if (this._bodyId) _creator.physicsSetFriction?.(this._bodyId, v)
  }
  private _friction = DEFAULT_FRICTION

  /** Scene-editor inspector: `motion` as a dropdown (a string default alone infers a text box). */
  static fields: FieldMeta<Physics> = {
    motion: { options: [ "static", "kinematic", "dynamic" ] },
    mass: { min: 0, step: 0.1 },
    friction: { min: 0, max: 2, step: 0.05 },
  }

  private _bodyId = 0

  onAttach(): void {
    if (!_creator.physicsHasSupport || !_creator.physicsHasSupport()) return
    const shape = this.node.get(Shape)
    if (!shape) {
      throw new Error("Physics requires a Shape aspect — add it first: node.aspect(Shape, {…}).aspect(Physics, {…})")
    }
    if (this.motion === "dynamic" && shape._isTriangleMesh) {
      throw new Error("Physics: a dynamic body cannot use Shape { mesh: true } (triangle meshes are static/kinematic only) — use { mesh: 'convex' } or a primitive shape")
    }
    const shapeId = shape._claim() // drop the pick-only body; reuse its shape
    this._bodyId = _creator.physicsCreateBody(this.node.id, shapeId, MOTION[this.motion], this.mass, false, false)
    // A fresh body already carries DEFAULT_FRICTION host-side, so only a NAMED surface has to
    // travel — the common case costs no bridge call at all.
    if (this._friction !== DEFAULT_FRICTION) _creator.physicsSetFriction?.(this._bodyId, this._friction)
    shape._ownBody(this._bodyId)
  }

  onDetach(): void {
    if (this._bodyId) { _creator.physicsRemoveBody(this._bodyId); this._bodyId = 0 }
    const shape = this.node.get(Shape)
    if (shape) {
      shape._ownBody(0)          // the Shape held OUR body id — forget it, or its own detach removes it twice (native crash)
      shape._recreatePickBody()  // keep the node pickable if the Shape stays
    }
  }

  /** Native Jolt body id. 0 until attached, or if the build has no physics support. */
  get id(): number { return this._bodyId }

  /** Linear velocity in world units/second (fresh Vec3 on read; `velocity.y = 5` written directly on
   *  the aspect is compiled to a component write — see `_writeComp`). */
  get velocity(): Vec3 {
    if (!this._bodyId) return new Vec3(0, 0, 0)
    _creator.physicsGetLinearVelocity(this._bodyId, scratch)
    return new Vec3(scratch[0], scratch[1], scratch[2])
  }
  set velocity(v: Vec3Like) {
    if (this._bodyId) _creator.physicsSetLinearVelocity(this._bodyId, cx(v), cy(v), cz(v))
  }

  /**
   * Angular velocity — **degrees/second about each world axis** (the SDK's angle unit everywhere;
   * the engine works in radians and converts here). Fresh Vec3 on read.
   *
   * This is the only handle on a body's spin, and placing an object usually needs it: a `position`
   * write is a pure teleport, so a crate that was tumbling keeps tumbling at its new home. Putting
   * something down is `body.velocity = [0,0,0]; body.angularVelocity = [0,0,0]; node.position = p`.
   */
  get angularVelocity(): Vec3 {
    if (!this._bodyId || !_creator.physicsGetAngularVelocity) return new Vec3(0, 0, 0)
    _creator.physicsGetAngularVelocity(this._bodyId, scratch)
    return new Vec3(scratch[0] * RAD2DEG, scratch[1] * RAD2DEG, scratch[2] * RAD2DEG)
  }
  set angularVelocity(v: Vec3Like) {
    if (this._bodyId && _creator.physicsSetAngularVelocity) {
      _creator.physicsSetAngularVelocity(this._bodyId, cx(v) * DEG2RAD, cy(v) * DEG2RAD, cz(v) * DEG2RAD)
    }
  }

  /** @internal chisel `comp_write` (see core/compWrite.ts): `body.velocity.y = v` / `body.angularVelocity.z
   *  = v` written directly on the aspect compile to a call here — one read into the scratch, one set, no
   *  vector. The list is compile-time data. */
  static _comps = [ "velocity", "angularVelocity" ]
  _writeComp(prop: string, axis: CompAxis, v: number): void {
    if (!this._bodyId || axis === "w") return
    const i = axis === "x" ? 0 : axis === "y" ? 1 : 2
    if (prop === "velocity") {
      _creator.physicsGetLinearVelocity(this._bodyId, scratch)
      scratch[i] = v
      _creator.physicsSetLinearVelocity(this._bodyId, scratch[0], scratch[1], scratch[2])
    } else if (_creator.physicsGetAngularVelocity && _creator.physicsSetAngularVelocity) {
      _creator.physicsGetAngularVelocity(this._bodyId, scratch)
      scratch[i] = v * DEG2RAD
      _creator.physicsSetAngularVelocity(this._bodyId, scratch[0], scratch[1], scratch[2])
    }
  }

  /** Apply an instantaneous impulse (kg·m/s) and wake the body. */
  applyImpulse(v: Vec3Like): this {
    if (this._bodyId) _creator.physicsApplyImpulse(this._bodyId, cx(v), cy(v), cz(v))
    return this
  }

  /** The same impulse applied at a WORLD-SPACE point instead of the centre of mass: the lever arm
   *  becomes angular impulse, so the body spins as well as moves (a bullet hitting a crate off
   *  centre, a blast lifting it by its base). Falls back to the central impulse on an older host. */
  applyImpulseAt(v: Vec3Like, point: Vec3Like): this {
    if (!this._bodyId) return this
    if (_creator.physicsApplyImpulseAt) {
      _creator.physicsApplyImpulseAt(this._bodyId, cx(v), cy(v), cz(v), cx(point), cy(point), cz(point))
    } else {
      _creator.physicsApplyImpulse(this._bodyId, cx(v), cy(v), cz(v))
    }
    return this
  }

  // No moveTo(): `node.position = p` IS the move. Shape hands the body id to the node, so a position
  // write reaches the body — one way to place anything, whether or not physics is involved.

  // --- world (static) -------------------------------------------------------------------------
  /** Configure gravity / limits. Call once before creating bodies. */
  static configure(config: PhysicsConfig = {}): void {
    const g = config.gravity ?? [0, -9.81, 0]
    _creator.physicsConfigure(cx(g), cy(g), cz(g), config.maxBodies ?? 0)
  }

  /** Whether this build has physics support (CREATOR_GL_PHYSICS). */
  static get supported(): boolean {
    return !!_creator.physicsHasSupport && _creator.physicsHasSupport()
  }

  /** Closest pickable body hit by the ray from `origin` along `dir` (up to `maxDist`), or null. */
  static raycast(origin: Vec3Like, dir: Vec3Like, maxDist = 1000): RayHit | null {
    const out = new Float32Array(7)
    const id = _creator.physicsRaycast(cx(origin), cy(origin), cz(origin), cx(dir), cy(dir), cz(dir), maxDist, out)
    if (!id) return null
    return {
      node: nodeRegistry.get(id) ?? null,
      point: new Vec3(out[0], out[1], out[2]),
      normal: new Vec3(out[3], out[4], out[5]),
      fraction: out[6],
    }
  }
}
