// A rigid body, as an aspect on a Node2D, backed by Box2D v3 in creator-2d. Mirrors the 3D Physics
// aspect. Requires a Shape2D aspect on the same node (its geometry becomes the body's solid fixture).
//
//   ground.aspect(Shape2D, { box: [500, 8] }).aspect(Physics2D, { motion: 'static' })
//   crate.aspect(Shape2D, {}).aspect(Physics2D, { mass: 4, friction: 0.7, bounce: 0.1 })
//   crate.physics.applyImpulse([0, 400])
//   crate.addEventListener('enter', (other, c) => spark(c.point, c.normal))
//
// Physics2D.configure() is OPTIONAL — the world is created on the first body with sane defaults, and
// calling configure later just changes gravity (it used to destroy and rebuild the world, orphaning
// every handle that was already out).
//
// For a `dynamic` body, physics OWNS the transform — don't set node.position per frame; drive it via
// velocity / applyImpulse. A `position` write is a TELEPORT that reaches the body (Node2D routes it),
// which is also how a kinematic body is placed. Reading node.position always works.
//
// Keep bodies on ROOT nodes: a body parented to a moving node does not follow it (see
// docs/2d-physics-plan.md §6).

import { Aspect } from "../core/Aspect"
import { Vec2, cx, cy, type Vec2Like } from "../math/vec"
import { node2dRegistry, type Node2D } from "./Node2D"
import { Shape2D } from "./Shape2D"
import {
  PhysicsGroup2D, _addGroup, _watchGroupChanges, _unwatchGroupChanges,
  categoryOf, solidMask, defaultGroup,
} from "./groups2d"

export type MotionType = "static" | "kinematic" | "dynamic"
const MOTION: Record<MotionType, number> = { static: 0, kinematic: 1, dynamic: 2 }

/** A neutral solid surface. Deliberately the same default as 3D `Physics.friction`. */
export const DEFAULT_FRICTION = 0.6

export interface PhysicsConfig {
  /** Gravity in world units/s² (Y-up: down is negative). Default [0, -980]. */
  gravity?: Vec2Like
  /** Pixels per meter — tunes Box2D's internal tolerances; you still author in world units. Default 64.
   *  Only applies before the world exists (it is a global solver tolerance). */
  pixelsPerMeter?: number
  /** Solver sub-steps per fixed step. Default 4. */
  subSteps?: number
}

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

// Resolve a native entity id to its Node2D (0 = none).
const node2dById = (id: number): Node2D | null => (id ? node2dRegistry.get(id) ?? null : null)

export class Physics2D extends Aspect<"physics", Node2D> {
  static readonly aspect = "physics"

  private _motion: MotionType = "dynamic"
  /** Static (walls, floors), kinematic (script-driven platforms), or dynamic (fully simulated).
   *  Default "dynamic", and LIVE: freeze a crate into `'static'`, hand a ragdoll to `'dynamic'`. */
  get motion(): MotionType { return this._motion }
  set motion(m: MotionType) {
    this._motion = m
    this.node._physicsDriven = m !== "static"
    if (this._h) _creator2d.physicsSetMotionType(this._h, MOTION[m])
  }
  /** Lock rotation (essential for platformer characters). */
  fixedRotation = false
  /** Continuous collision for fast-moving bodies (projectiles). */
  bullet = false

  private _mass = 0            // 0 = derive from the collider area
  private _friction = DEFAULT_FRICTION
  private _bounce = 0
  private _gravityScale = 1
  private _linearDamping = 0
  private _angularDamping = 0
  private _group: PhysicsGroup2D | undefined
  private _repush = (): void => {
    if (this._h) _creator2d.physicsSetFilter(this._h, categoryOf(this._group), solidMask(this._group))
  }
  private _h = 0   // native body id (0 = none / no physics support)

  /** Which collision group this body belongs to (see Physics2D.addGroup). Live. */
  get group(): PhysicsGroup2D | undefined { return this._group }
  set group(g: PhysicsGroup2D | undefined) {
    this._group = g
    this.node.get(Shape2D)?._setFilter(categoryOf(g), solidMask(g))
    this._repush()
  }

  /** Mass in world units. Omitted (or 0) = derived from the collider's AREA, so a big crate really is
   *  heavier than a small one. Setting it scales the shape-derived mass data, keeping the rotational
   *  inertia in the same ratio — a lopsided collider still tips the way its geometry says. */
  get mass(): number { return this._h ? _creator2d.physicsGetMass(this._h) : this._mass }
  set mass(v: number) { this._mass = v; if (this._h) _creator2d.physicsSetMass(this._h, v) }

  /** Surface friction. 0 is frictionless, ~1 grippy; two bodies COMBINE as sqrt(a×b), so the lower
   *  value wins the argument. Default 0.6 — a neutral solid, matching 3D. */
  get friction(): number { return this._friction }
  set friction(v: number) { this._friction = v; this._pushMaterial() }

  /** Bounciness, 0..1 — the fraction of the approach speed returned on impact. Two bodies combine as
   *  MAX (the bouncier one wins — the opposite of friction), and below ~64 units/s no bounce is
   *  applied at all, which is what stops a ball jittering on the floor forever. */
  get bounce(): number { return this._bounce }
  set bounce(v: number) { this._bounce = v; this._pushMaterial() }

  // The live setters go to the BODY, but a later collider rebuild re-creates the fixture from the
  // Shape2D — so the shape keeps its own copy of the material or a crouch would silently reset it.
  private _pushMaterial(): void {
    if (!this._h) return
    _creator2d.physicsSetFriction(this._h, this._friction)
    _creator2d.physicsSetBounce(this._h, this._bounce)
    this.node.get(Shape2D)?._setMaterial(this._friction, this._bounce)
  }

  /** Native physics body id. 0 until attached, or if the build has no physics support. Mirrors 3D `Physics.id`. */
  get id(): number { return this._h }

  // NOTE — read every option through its PUBLIC accessor here, never the `_backing` field. chisel
  // tree-shakes class members and keeps `_*` unconditionally, but it DROPS an accessor pair whose
  // name no bundled code reads as a PROPERTY: an object-literal key (`aspect(Physics2D, { gravityScale: 0 })`)
  // does NOT count. With the pair gone, Object.assign writes a plain own property, the backing field
  // keeps its default, and the option silently does nothing — measured: a `gravityScale: 0` body fell.
  // One `this.gravityScale` read in bundled SDK code keeps the whole pair alive.
  onAttach(): void {
    if (!_creator2d.physicsHasSupport || !_creator2d.physicsHasSupport()) return
    const shape = this.node.get(Shape2D)
    if (!shape) {
      throw new Error("Physics2D requires a Shape2D aspect — add it first: node.aspect(Shape2D, {…}).aspect(Physics2D, {…})")
    }
    this._h = _creator2d.physicsCreateBody(this.node.id, MOTION[this.motion])
    if (!this._h) return
    shape._addTo(this._h, {
      sensor: false, friction: this.friction, bounce: this.bounce,
      category: categoryOf(this.group), mask: solidMask(this.group),
      staticBody: this.motion === "static",
    })
    // Only a body that named a group cares when the topology changes.
    if (this._group) _watchGroupChanges(this._repush)
    // dynamic/kinematic bodies are moved by the native step → the node must re-sync its transform
    // cache from native on read. Static bodies never move, so they keep the free cache.
    if (this.motion !== "static") this.node._physicsDriven = true
    // Route node.position / .rotation writes to the body (this is what replaced physics.moveTo).
    this.node._xf = this._h
    this.node._xfKind = 2
    if (this._mass > 0) _creator2d.physicsSetMass(this._h, this.mass)
    if (this.fixedRotation) _creator2d.physicsSetFixedRotation(this._h, true)
    if (this.gravityScale !== 1) _creator2d.physicsSetGravityScale(this._h, this.gravityScale)
    if (this.linearDamping !== 0) _creator2d.physicsSetLinearDamping(this._h, this.linearDamping)
    if (this.angularDamping !== 0) _creator2d.physicsSetAngularDamping(this._h, this.angularDamping)
    if (this.bullet) _creator2d.physicsSetBullet(this._h, true)
    // The listener may have been added before the body existed; contact events are per-body opt-in.
    if (this.node._wantsContacts) _creator2d.physicsSetContactEvents(this._h, true)
  }

  onDetach(): void {
    _unwatchGroupChanges(this._repush)
    if (this._h) { _creator2d.physicsRemoveBody(this._h); this._h = 0 }
    this.node._physicsDriven = false
    this.node._xf = 0
    this.node._xfKind = 0
  }

  /** Re-applying options (`node.aspect(Physics2D, {…})`) pushes the live ones through. */
  onReconfigure(): void {
    if (!this._h) return
    this._pushMaterial()
    _creator2d.physicsSetFixedRotation(this._h, this.fixedRotation)
    _creator2d.physicsSetBullet(this._h, this.bullet)
    _creator2d.physicsSetGravityScale(this._h, this.gravityScale)
    _creator2d.physicsSetLinearDamping(this._h, this.linearDamping)
    _creator2d.physicsSetAngularDamping(this._h, this.angularDamping)
    if (this._mass > 0) _creator2d.physicsSetMass(this._h, this.mass)
  }

  /** Linear velocity in world units/second (fresh Vec2 on read). */
  get velocity(): Vec2 {
    const o = this._h ? _creator2d.physicsGetLinearVelocity(this._h) : null
    return o ? new Vec2(o[0], o[1]) : new Vec2(0, 0)
  }
  set velocity(v: Vec2Like) { if (this._h) _creator2d.physicsSetLinearVelocity(this._h, cx(v), cy(v)) }

  /** Angular velocity in degrees/second. */
  get angularVelocity(): number { return this._h ? _creator2d.physicsGetAngularVelocity(this._h) : 0 }
  set angularVelocity(degPerSec: number) { if (this._h) _creator2d.physicsSetAngularVelocity(this._h, degPerSec) }

  /** Per-body gravity multiplier: 1 = full world gravity, 0 = floats, <0 = repelled. */
  get gravityScale(): number { return this._gravityScale }
  set gravityScale(scale: number) { this._gravityScale = scale; if (this._h) _creator2d.physicsSetGravityScale(this._h, scale) }

  get linearDamping(): number { return this._linearDamping }
  set linearDamping(d: number) { this._linearDamping = d; if (this._h) _creator2d.physicsSetLinearDamping(this._h, d) }

  get angularDamping(): number { return this._angularDamping }
  set angularDamping(d: number) { this._angularDamping = d; if (this._h) _creator2d.physicsSetAngularDamping(this._h, d) }

  /** Pause this body — it stops simulating and stops colliding until re-enabled. */
  set enabled(v: boolean) { if (this._h) _creator2d.physicsSetEnabled(this._h, v) }

  /** Box2D puts resting bodies to sleep; wake one to make it react to a change you made by hand. */
  set awake(v: boolean) { if (this._h) _creator2d.physicsSetAwake(this._h, v) }

  /** Apply an instantaneous impulse at the centre of mass (never spins the body) and wake it. */
  applyImpulse(v: Vec2Like): this { if (this._h) _creator2d.physicsApplyLinearImpulse(this._h, cx(v), cy(v)); return this }
  /** Apply an impulse at a WORLD point — the lever arm becomes spin. Use this when the push has a real
   *  point of contact: a bullet's hit point, a blast pushing a prop at its base. */
  applyImpulseAt(v: Vec2Like, worldPoint: Vec2Like): this {
    if (this._h) _creator2d.physicsApplyImpulseAt(this._h, cx(v), cy(v), cx(worldPoint), cy(worldPoint))
    return this
  }
  /** Apply a continuous force (world units) and wake the body. */
  applyForce(v: Vec2Like): this { if (this._h) _creator2d.physicsApplyForce(this._h, cx(v), cy(v)); return this }

  // --- world (static) -------------------------------------------------------------------------
  /** Set the world's gravity / solver settings. OPTIONAL: the world is created on the first body with
   *  gravity [0, -980], 64 px/m and 4 sub-steps. Calling this later changes gravity LIVE and leaves
   *  bodies alone. */
  static configure(config: PhysicsConfig = {}): void {
    const g = config.gravity ?? [0, -980]
    _creator2d.physicsConfigure(cx(g), cy(g), config.pixelsPerMeter ?? 64, config.subSteps ?? 4)
  }

  /** World gravity, live. */
  static get gravity(): Vec2 { return new Vec2(Physics2D._gravity) }
  static set gravity(g: Vec2Like) {
    Physics2D._gravity = [ cx(g), cy(g) ]
    _creator2d.physicsConfigure(cx(g), cy(g), 0, 0)
  }
  private static _gravity: [number, number] = [ 0, -980 ]

  /** Whether this build has physics support (CREATOR_2D_PHYSICS). NOTE: this reports the BUILD, not
   *  the world — there is nothing else to guard, since the world creates itself. */
  static get supported(): boolean { return !!_creator2d.physicsHasSupport && _creator2d.physicsHasSupport() }

  /** 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 Physics2D._interpolation }
  static set interpolation(on: boolean) { Physics2D._interpolation = on; _creator2d.setInterpolation(on) }

  /**
   * Register a collision group. Groups are SUBTRACTIVE and SYMMETRIC (see groups2d.ts):
   *
   *     const player  = Physics2D.addGroup()
   *     const bullets = Physics2D.addGroup().ignoreSelf().ignore(player)
   */
  static addGroup(label?: string): PhysicsGroup2D { return _addGroup(label) }

  /** The implicit group of every body that never named one - name it to exclude "everything else". */
  static get defaultGroup(): PhysicsGroup2D { return defaultGroup }

  // --- queries (static) -----------------------------------------------------------------------
  /** Closest body hit by the segment, or null. `node` resolves the hit entity. */
  static raycast(from: Vec2Like, to: Vec2Like, q?: Query2D): RayHit | null {
    const o = _creator2d.physicsRaycastClosest(cx(from), cy(from), cx(to), cy(to), maskOf(q), ignoreOf(q), ignoreCount(q))
    if (o[0] < 0.5) return null
    return { node: node2dById(o[1]), point: new Vec2(o[2], o[3]), normal: new Vec2(o[4], o[5]), fraction: o[6] }
  }

  /** Every body along the segment, NEAREST FIRST - a piercing bullet, a laser, "how many walls are
   *  between us". Capped at 32 hits. */
  static raycastAll(from: Vec2Like, to: Vec2Like, q?: Query2D): RayHit[] {
    const o = _creator2d.physicsRaycastAll(cx(from), cy(from), cx(to), cy(to), maskOf(q), ignoreOf(q), ignoreCount(q), MAX_HITS)
    const hits: RayHit[] = []
    for (let i = 0; i + 5 < o.length; i += 6) {
      if (o[i] === 0) break
      hits.push({ node: node2dById(o[i]), point: new Vec2(o[i + 1], o[i + 2]), normal: new Vec2(o[i + 3], o[i + 4]), fraction: o[i + 5] })
    }
    return hits
  }

  /** Topmost body (solid or sensor) whose shape contains the world point, or null. "Topmost" means
   *  by DRAW order - layer, then z - so picking agrees with what the player sees. */
  static overlapPoint(p: Vec2Like, q?: Query2D): Node2D | null {
    return node2dById(_creator2d.physicsOverlapPoint(cx(p), cy(p), maskOf(q), ignoreOf(q), ignoreCount(q)))
  }

  /** Everything within `radius` of a point - an explosion, an aura, an aggro range. */
  static overlapCircle(centre: Vec2Like, radius: number, q?: Query2D): Node2D[] {
    return overlapNodes([ cx(centre), cy(centre) ], radius, q)
  }

  /** Everything inside an axis-aligned box (centre + HALF-extents) - a room, a camera bound. */
  static overlapBox(centre: Vec2Like, half: Vec2Like, q?: Query2D): Node2D[] {
    const x = cx(centre), y = cy(centre), hw = cx(half), hh = cy(half)
    return overlapNodes([ x - hw, y - hh, x + hw, y - hh, x + hw, y + hh, x - hw, y + hh ], 0, q)
  }

  /**
   * Everything touching a capsule from-to of the given radius. A capsule is the shape a DIRECTED
   * question has: a beam with width, a sword arc, "does my radius fit through this gap" - none of
   * which an axis-aligned box can express.
   *
   * It is also the SWEPT test: a circle of radius r moving from A to B during a frame sweeps exactly
   * this capsule, so a fast projectile checked this way cannot tunnel through a thin wall the way two
   * separate position checks do.
   */
  static overlapCapsule(from: Vec2Like, to: Vec2Like, radius: number, q?: Query2D): Node2D[] {
    return overlapNodes([ cx(from), cy(from), cx(to), cy(to) ], radius, q)
  }
}

/** Which groups a query may hit, and what to skip. */
export type Query2D = {
  /** Only hit bodies in these groups. Omitted = everything. */
  groups?: PhysicsGroup2D[]
  /** Skip these nodes - the "don't hit myself" case a ray fired from inside its own body needs. */
  ignore?: Node2D | Node2D[]
}

const MAX_HITS = 32
const emptyIgnore = new Float32Array(0)
let ignoreBuf = new Float32Array(8)

const maskOf = (q?: Query2D): number => {
  if (!q?.groups || q.groups.length === 0) return 0   // 0 = everything, native-side
  let m = 0
  for (const g of q.groups) m |= g.category
  return m >>> 0
}
const ignoreCount = (q?: Query2D): number =>
  !q?.ignore ? 0 : Array.isArray(q.ignore) ? q.ignore.length : 1
const ignoreOf = (q?: Query2D): Float32Array => {
  const n = ignoreCount(q)
  if (n === 0) return emptyIgnore
  if (ignoreBuf.length < n) ignoreBuf = new Float32Array(n)
  const ig = q!.ignore!
  if (Array.isArray(ig)) ig.forEach((node, i) => { ignoreBuf[i] = node.id })
  else ignoreBuf[0] = (ig as Node2D).id
  return ignoreBuf
}
const overlapNodes = (pts: number[], radius: number, q?: Query2D): Node2D[] => {
  const o = _creator2d.physicsOverlap(new Float32Array(pts), pts.length / 2, radius,
                                      maskOf(q), ignoreOf(q), ignoreCount(q), MAX_HITS)
  const out: Node2D[] = []
  for (let i = 0; i < o.length; i++) {
    if (o[i] === 0) break
    const n = node2dById(o[i])
    if (n) out.push(n)
  }
  return out
}
