// A collision shape, as an aspect on a 3D Node. PURE GEOMETRY: attaching a Shape does not by itself
// make the node rigid or a sensor — pair it with a Physics (rigid body) or a Trigger (sensor), both
// of which require a Shape. The Shape also makes the node pointer-pickable: on its own it creates a
// lightweight "pick-only" JoltPhysics body (collides with nothing, exists only for the raycast), so
// any node with a Shape can receive click/touchstart. Physics/Trigger REPLACE that body with the real
// one. Set `raycast = false` to opt a shape out of pointer picking.
//
//   wall.aspect(Shape, { box: [1, 2, 1] }).aspect(Physics, { motion: 'static' })
//   coin.aspect(Shape, { sphere: 0.5 }).aspect(Trigger)
//   crate.aspect(Shape, {}).aspect(Physics)                 // auto box from the mesh AABB × world scale
//   barrel.aspect(Shape, { capsule: {…}, origin: [0, 0.9, 0] })   // collider lifted off the pivot
//   tree.aspect(Shape, {}).shape.fit('capsule')             // dimensions measured from the model
//
// Explicit dims are world-space half-extents (not scaled by the node). Use {} ("auto") to track the
// mesh × world scale. `origin` moves the shape off the node's pivot (world units, in the node's
// rotated frame) — the usual case is a model whose art sits above its origin. Note: a Shape-only
// pick body is a STATIC snapshot at attach time — for a moving pickable object add a Physics body
// (which follows the sim), rather than relying on the pick body.

import { Aspect } from "../core/Aspect"
import { cx, cy, cz, type Vec3Like } from "../math/vec"
import type { Node } from "./Node"

/** Which geometry a Shape uses — the one-of the fields below encode (see `_build`'s order). */
export type ShapeKind = "auto" | "box" | "sphere" | "cylinder" | "capsule" | "mesh" | "convex" | "heightfield"

/** What a terrain node carries for `Shape { heightfield: true }` (gl/Terrain.ts `_data`). */
type TerrainData = { sizeX: number, sizeZ: number, cellSize: number, heights: Float32Array, holes: Uint8Array | null }
const terrainOf = (node: Node): TerrainData | null => (node as unknown as { _terrain?: { _data: TerrainData } })._terrain?._data ?? null

/** A node-local AABB, in the node's OWN frame (its own transform excluded), before world scale. */
export type ShapeBounds = { min: [number, number, number], max: [number, number, number] }

/** Dimensions measured from a node's rendered geometry — what `Shape.fit` writes, and the numbers
 *  the scene editor's "Fit to mesh" button puts in the file. Exactly one dimension key is set (none
 *  for a mesh/convex kind, whose geometry is the mesh itself); `origin` centres it on the art. */
export type ShapeFit = {
  box?: [number, number, number]
  sphere?: number
  cylinder?: { halfHeight: number, radius: number }
  capsule?: { halfHeight: number, radius: number }
  origin: [number, number, number]
}

/** Half-extents below this collapse a shape (Jolt refuses a box thinner than its convex radius,
 *  and a zero-radius sphere) — a fitted plane or a decal quad lands here. */
const MIN_HALF = 0.001
const r4 = (v: number): number => Math.round(v * 1e4) / 1e4
const dim = (v: number): number => Math.max(MIN_HALF, r4(v))

export class Shape extends Aspect<"shape", Node> {
  static readonly aspect = "shape"

  /** Box half-extents [hx, hy, hz] (world units). */
  box?: Vec3Like
  /** Sphere radius. */
  sphere?: number
  /** Cylinder aligned on Y: half-height + radius. */
  cylinder?: { halfHeight: number, radius: number }
  /** Capsule aligned on Y: half-height of the cylinder section + radius. */
  capsule?: { halfHeight: number, radius: number }
  /** The node's own triangles (a Mesh's geometry or a Model's GLB, world scale applied).
   *  `true` = exact triangle mesh — static/kinematic bodies, triggers, characters' ground and picking
   *  only (a dynamic body can't be a triangle mesh; Physics throws). `'convex'` = convex hull of the
   *  vertices, works for dynamic bodies too. A Model uses its bind pose (skinned parts skipped). */
  mesh?: boolean | "convex"
  /** The node's terrain (`Terrain.create(...).node`) as a Jolt height field — the exact drawn grid,
   *  holes included (docs/terrain-plan.md). Static / kinematic / picking / character ground only,
   *  like `mesh: true`. `terrain.commit()` pushes edits into the live shape. */
  heightfield?: boolean
  /**
   * The shape's centre, relative to the node's origin — world units in the node's ROTATED frame
   * (the node's scale is ignored, exactly like the explicit dimensions above). Default [0, 0, 0].
   *
   * A model's pivot is rarely its centre of volume (a character stands ON its origin, a wheel hangs
   * off its axle), so a collider centred on the pivot is half sunk into the floor. `origin` lifts it
   * without moving the node: `{ capsule: { halfHeight: 0.6, radius: 0.3 }, origin: [0, 0.9, 0] }`.
   * `fit()` and the scene editor's "Fit to mesh" fill it in from the rendered bounds.
   *
   * On a DYNAMIC body the offset also moves the centre of mass, so a lopsided collider tips the way
   * you would expect. With no `origin` an auto shape (`{}`) still centres itself on the node's own
   * mesh bounds — only explicit dimensions sit on the pivot by default.
   */
  origin?: Vec3Like
  /** Whether pointer rays can hit this shape (click / touchstart). Default true. */
  raycast = true

  private _shapeId = 0
  private _bodyId = 0
  /** Centre the "auto" box measured for itself — used when `origin` isn't set explicitly. */
  private _autoOrigin: [number, number, number] | null = null
  /** Geometry props as of the last build (see onReconfigure). */
  private _sig = ""

  /** @internal native shape id (0 if the build has no physics). */
  get shapeId(): number { return this._shapeId }
  /** @internal current native body id (a pick-only body until Physics/Trigger claim it). */
  get bodyId(): number { return this._bodyId }

  onAttach(): void {
    if (!_creator.physicsHasSupport || !_creator.physicsHasSupport()) return
    this._shapeId = this._build()
    if (this._shapeId) this._createPickBody()
  }

  onDetach(): void {
    if (this._bodyId) { _creator.physicsRemoveBody(this._bodyId); this._bodyId = 0 }
    if (this._shapeId) { _creator.physicsDestroyShape(this._shapeId); this._shapeId = 0 }
    this.node._xf = 0
    this.node._xfKind = 0
  }

  /** Re-configuring (`node.aspect(Shape, { origin })`) rebuilds the geometry when a dimension, the
   *  kind or the origin changed, and re-applies pickability. The body keeps its id and velocity. */
  onReconfigure(): void {
    if (this._signature() !== this._sig) this._rebuild()
    if (this._bodyId) _creator.physicsSetPickable(this._bodyId, this.raycast)
  }

  /**
   * Measure the node's rendered geometry and size the collider to it — the code half of the scene
   * editor's "Fit to mesh". Keeps the current kind unless one is passed; `auto` becomes an explicit
   * box, and a mesh/convex shape only has its `origin` cleared (its geometry IS the mesh).
   *
   *   crate.aspect(Shape, {}).shape.fit()             // box around what the model actually draws
   *   hero.aspect(Shape, {}).shape.fit('capsule')     // capsule of the same height + girth
   *
   * A model loads asynchronously, so fit AFTER its `load()` resolves — an empty subtree measures as
   * nothing and the call is a no-op (it warns). Rebuilds the live shape, keeping the body.
   */
  fit(kind?: ShapeKind): this {
    const f = Shape.fitTo(this.node, kind ?? this.kind)
    if (!f) {
      console.warn(`Shape.fit: "${this.node.name}" has no geometry to measure yet (a model still loading, or a node with no mesh)`)
      return this
    }
    this.box = f.box
    this.sphere = f.sphere
    this.cylinder = f.cylinder
    this.capsule = f.capsule
    if (kind === "mesh") this.mesh = true
    else if (kind === "convex") this.mesh = "convex"
    else if (kind !== undefined) this.mesh = undefined
    this.origin = f.origin
    this._rebuild()
    return this
  }

  /** Which geometry this shape currently uses (the `_build` dispatch, as a name). */
  get kind(): ShapeKind {
    if (this.heightfield) return "heightfield"
    if (this.mesh === "convex") return "convex"
    if (this.mesh) return "mesh"
    if (this.sphere !== undefined) return "sphere"
    if (this.cylinder) return "cylinder"
    if (this.capsule) return "capsule"
    if (this.box) return "box"
    return "auto"
  }

  // ---- fitting (static, so the scene-editor harness can measure without attaching) -------------

  /**
   * The node's subtree AABB in its OWN local frame (its own transform excluded), or null when there
   * is nothing to measure. Reads the engine's bounds — which cover a loaded GLB and every child —
   * and falls back to a Mesh node's own CPU geometry on hosts without them.
   */
  static boundsOf(node: Node): ShapeBounds | null {
    const box = _creator.computeBoundingBox?.(node.id)
    if (box && (box[3] - box[0] > 1e-6 || box[4] - box[1] > 1e-6 || box[5] - box[2] > 1e-6)) {
      return { min: [box[0], box[1], box[2]], max: [box[3], box[4], box[5]] }
    }
    const v = node.geometry?.vertices
    if (!v || v.length < 3) return null
    const min: [number, number, number] = [Infinity, Infinity, Infinity]
    const max: [number, number, number] = [-Infinity, -Infinity, -Infinity]
    for (let i = 0; i + 2 < v.length; i += 3) {
      for (let k = 0; k < 3; k++) {
        if (v[i + k] < min[k]) min[k] = v[i + k]
        if (v[i + k] > max[k]) max[k] = v[i + k]
      }
    }
    return isFinite(min[0]) ? { min, max } : null
  }

  /**
   * Dimensions + origin that make `kind` hug what `node` renders — world units, so the node's world
   * scale is baked in exactly as the Shape fields expect. Null when the node has no geometry (a
   * model that hasn't loaded, an empty).
   */
  static fitTo(node: Node, kind: ShapeKind = "box"): ShapeFit | null {
    const b = Shape.boundsOf(node)
    return b ? Shape.fitBounds(b, node.worldScale, kind) : null
  }

  /**
   * The measuring itself, over plain numbers: a node-local AABB + the node's world scale in, shape
   * dimensions out. Separate from `fitTo` so the scene-editor harness can measure a node from
   * ANOTHER bundle without touching accessors the bundler may have shaken out of it.
   *
   * A sphere takes the LARGEST half-extent and a cylinder/capsule the larger of X/Z, so the fit
   * reads as the object's silhouette rather than as an enclosing ball; adjust from there.
   */
  static fitBounds(bounds: ShapeBounds, scale: Vec3Like, kind: ShapeKind = "box"): ShapeFit {
    const sc = [Math.abs(cx(scale)), Math.abs(cy(scale)), Math.abs(cz(scale))]
    const half = [0, 1, 2].map((k) => (bounds.max[k] - bounds.min[k]) * 0.5 * sc[k])
    const origin = [0, 1, 2].map((k) => r4((bounds.max[k] + bounds.min[k]) * 0.5 * sc[k])) as [number, number, number]
    if (kind === "mesh" || kind === "convex") return { origin: [0, 0, 0] }   // the mesh IS the geometry
    if (kind === "sphere") return { sphere: dim(Math.max(half[0], half[1], half[2])), origin }
    if (kind === "cylinder" || kind === "capsule") {
      const radius = dim(Math.max(half[0], half[2]))
      const halfHeight = kind === "capsule" ? Math.max(0, r4(half[1] - radius)) : dim(half[1])
      return { [kind]: { halfHeight, radius }, origin }
    }
    return { box: [dim(half[0]), dim(half[1]), dim(half[2])], origin }
  }

  // ---- body/shape lifetime --------------------------------------------------------------------

  /** @internal Physics/Trigger/CharacterController/Vehicle take over: drop the pick body and hand back
   *  the shape id. The node's transform owner goes with the body (the new owner sets its own). */
  _claim(): number {
    if (this._bodyId) { _creator.physicsRemoveBody(this._bodyId); this._bodyId = 0 }
    this.node._xf = 0
    this.node._xfKind = 0
    return this._shapeId
  }
  /** @internal Physics/Trigger report the body they created, so Shape owns its lifetime + pickability.
   *  0 = they are detaching and no longer own it. */
  _ownBody(bodyId: number): void {
    this._bodyId = bodyId
    this.node._xf = bodyId          // route node.position writes to the body (see Node._setOwnedPosition)
    this.node._xfKind = bodyId ? 2 : 0
    if (!this.raycast && bodyId) _creator.physicsSetPickable(bodyId, false)
  }
  /** @internal restore a pick-only body when Physics/Trigger detach but the Shape remains. */
  _recreatePickBody(): void {
    if (!this._bodyId && this._shapeId) this._createPickBody()
  }

  private _createPickBody(): void {
    this._bodyId = _creator.physicsCreateBody(this.node.id, this._shapeId, 0, 0, false, /*pickOnly*/ true)
    // Route position writes here too: the pick body is a static snapshot, so without this a moved node
    // would keep taking its clicks at the old place.
    this.node._xf = this._bodyId
    this.node._xfKind = this._bodyId ? 2 : 0
    if (!this.raycast && this._bodyId) _creator.physicsSetPickable(this._bodyId, false)
  }

  /** @internal true for an exact triangle mesh or a height field — Physics refuses a dynamic body on it. */
  get _isTriangleMesh(): boolean { return this.mesh === true || this.heightfield === true }

  /** @internal the height range a heightfield shape can encode (`Terrain.commit` rebuilds past it). */
  _heightfieldRange: [number, number] | null = null

  /** @internal rebuild the geometry in place (a terrain edit that left the encodable range). */
  _rebuildShape(): void { this._rebuild() }

  // Rebuild the geometry in place: the body keeps its id, velocity and world transform (Physics
  // holds that id, so re-creating the body instead would strand it). A shape a CharacterController
  // or a Vehicle claimed is theirs — they were handed the old one and keep it.
  private _rebuild(): void {
    if (!this._shapeId) return               // never attached, or a build without physics
    const previous = this._shapeId
    const previousSig = this._sig
    const id = this._build()
    if (!id) return                          // build failed — keep what works

    // A CharacterController owns its shape and can REFUSE the swap: standing up under a pipe doesn't
    // fit. Nothing is half-applied on refusal, so the fix is to stay UNCOMMITTED — leave `_sig` on the
    // old geometry, and the next `node.aspect(Shape, …)` with the same dimensions sees a difference
    // and tries again. That retry is the headroom test, and it is exact (the real capsule, not a ray).
    const charId = this.node._xfKind === 1 ? this.node._xf : 0
    if (charId) {
      if (_creator.characterSetShape?.(charId, id)) {
        this._shapeId = id
        this._resizeRefused = false
        _creator.physicsDestroyShape(previous)
      } else {
        _creator.physicsDestroyShape(id)     // drop what we just built; the old collider stays live
        this._sig = previousSig
        this._resizeRefused = !!_creator.characterSetShape
        if (!_creator.characterSetShape) {
          console.warn(`Shape: this host can't resize a live CharacterController — "${this.node.name}" keeps its current collider`)
        }
      }
      return
    }

    this._shapeId = id
    if (this._bodyId) {
      if (_creator.physicsSetBodyShape) _creator.physicsSetBodyShape(this._bodyId, id, true)
      else console.warn("Shape: this host can't re-shape a live body — the new dimensions apply from the next attach")
    } else {
      console.warn(`Shape: "${this.node.name}"'s geometry is owned by a Vehicle — re-attach it to pick up the new dimensions`)
    }
    _creator.physicsDestroyShape(previous)
  }

  /** @internal True while a requested resize has been refused — the fields say one thing, the engine
   *  is still using another. Surfaced as `controller.resizing`. */
  _resizeRefused = false

  private _build(): number {
    this._autoOrigin = null
    this._sig = this._signature()
    let id = 0
    if (this.heightfield) id = this._buildHeightField()
    else if (this.mesh) id = this._buildMesh(this.mesh === "convex")
    else if (this.sphere !== undefined) id = _creator.physicsBuildSphere(this.sphere)
    else if (this.cylinder) id = _creator.physicsBuildCylinder(this.cylinder.halfHeight, this.cylinder.radius)
    else if (this.capsule) id = _creator.physicsBuildCapsule(this.capsule.halfHeight, this.capsule.radius)
    else if (this.box) id = _creator.physicsBuildBox(cx(this.box), cy(this.box), cz(this.box))
    else id = this._buildAutoBox()
    if (id) this._applyOrigin(id)
    return id
  }

  // Push `origin` (or, for an auto box, the mesh centre it measured) onto the built shape. A host
  // without the call keeps the old "centred on the node" behaviour; only an EXPLICIT origin warns,
  // since the auto centre is a silent improvement nobody asked for.
  private _applyOrigin(shapeId: number): void {
    const o = this.origin
    const x = o ? cx(o) : this._autoOrigin?.[0] ?? 0
    const y = o ? cy(o) : this._autoOrigin?.[1] ?? 0
    const z = o ? cz(o) : this._autoOrigin?.[2] ?? 0
    if (x === 0 && y === 0 && z === 0) return
    if (!_creator.physicsSetShapeOrigin) {
      if (o) console.warn("Shape { origin } needs a newer host — the collider stays centred on the node")
      return
    }
    _creator.physicsSetShapeOrigin(shapeId, x, y, z)
  }

  // What a rebuild has to notice: the kind, its dimensions and the origin (`raycast` is applied to
  // the body directly, and the node's own transform is the body's, not the shape's).
  private _signature(): string {
    const v = (a: Vec3Like | undefined) => (a ? `${cx(a)},${cy(a)},${cz(a)}` : "")
    const t = (o: { halfHeight: number, radius: number } | undefined) => (o ? `${o.halfHeight},${o.radius}` : "")
    return `${v(this.box)}|${this.sphere ?? ""}|${t(this.cylinder)}|${t(this.capsule)}|${this.mesh ?? ""}|${this.heightfield ? "hf" : ""}|${v(this.origin)}`
  }

  // heightfield: the terrain on this node hands its grid over; the engine keeps its own copy and
  // pads it to Jolt's block grid. No terrain / no host support = no collider (0), with a warning.
  private _buildHeightField(): number {
    const t = terrainOf(this.node)
    if (!t) { console.warn(`Shape { heightfield }: "${this.node.name}" carries no Terrain — no collider`); return 0 }
    if (!_creator.physicsBuildHeightField) { console.warn("Shape { heightfield } needs a newer host — the terrain has no collider"); return 0 }
    const s = this.node.worldScale
    let lo = Infinity, hi = -Infinity
    for (let i = 0; i < t.heights.length; i++) { const h = t.heights[i]; if (h < lo) lo = h; if (h > hi) hi = h }
    const range = Math.max(1, hi - lo), margin = Math.max(2, range * 0.25)
    this._heightfieldRange = [ lo - margin, hi + margin ]   // mirrors creator-gl physicsBuildHeightField's headroom
    const id = _creator.physicsBuildHeightField(t.heights, t.sizeX, t.sizeZ, t.cellSize, t.holes, s.x, s.y, s.z)
    if (!id) console.warn(`Shape { heightfield }: the engine refused "${this.node.name}"'s grid (all holes?)`)
    return id
  }

  // mesh: a Mesh node hands its CPU geometry over; anything else is tried as a GLB root (the host
  // reads the asset's triangles itself — 0 if the node is neither). Always × world scale: the
  // triangles are node-local, so the collider must follow the rendered size.
  private _buildMesh(convex: boolean): number {
    if (!_creator.physicsBuildMesh) {
      console.warn("Shape { mesh } needs a newer host — falling back to a box")
      return this._buildAutoBox()
    }
    const s = this.node.worldScale
    const geom = this.node.geometry
    let id = 0
    if (geom) {
      if (geom._meshKind !== 0) throw new Error("Shape { mesh } needs a triangle geometry (not edges/vertices)")
      id = _creator.physicsBuildMesh(geom.vertices, Uint32Array.from(geom.indices), convex, s.x, s.y, s.z)
    } else {
      id = _creator.physicsBuildMeshFromEntity(this.node.id, convex, s.x, s.y, s.z)
    }
    if (!id) {
      console.warn(`Shape { mesh } could not build a ${convex ? "convex hull" : "triangle mesh"} for "${this.node.name}" (no triangles / not a Mesh or Model) — falling back to a box`)
      return this._buildAutoBox()
    }
    return id
  }

  // auto: a box from the node's own mesh AABB × world scale (so the physical box matches the
  // rendered size), CENTRED on those bounds — geometry modelled off its pivot gets a box that still
  // wraps it. A node with no geometry of its own (a Model root, an empty) keeps the 0.5 default;
  // `fit()` measures the whole subtree instead.
  private _buildAutoBox(): number {
    const s = this.node.worldScale
    let hx = 0.5, hy = 0.5, hz = 0.5
    const geom = this.node.geometry
    if (geom) {
      const v = geom.vertices
      let minX = Infinity, minY = Infinity, minZ = Infinity, maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity
      for (let i = 0; i + 2 < v.length; i += 3) {
        if (v[i] < minX) minX = v[i]
        if (v[i] > maxX) maxX = v[i]
        if (v[i + 1] < minY) minY = v[i + 1]
        if (v[i + 1] > maxY) maxY = v[i + 1]
        if (v[i + 2] < minZ) minZ = v[i + 2]
        if (v[i + 2] > maxZ) maxZ = v[i + 2]
      }
      if (isFinite(minX)) {
        hx = (maxX - minX) * 0.5; hy = (maxY - minY) * 0.5; hz = (maxZ - minZ) * 0.5
        this._autoOrigin = [(maxX + minX) * 0.5 * s.x, (maxY + minY) * 0.5 * s.y, (maxZ + minZ) * 0.5 * s.z]
      }
    }
    return _creator.physicsBuildBox(hx * s.x, hy * s.y, hz * s.z)
  }
}
