// A collider shape, as an aspect on a Node2D. PURE GEOMETRY — attaching a Shape2D does not by itself
// make the node rigid or a sensor; pair it with a Physics2D (rigid body) or a Trigger2D (sensor), both
// of which require a Shape2D and turn its geometry into a Box2D fixture. The shape is also what pointer
// picking hits, so a node is clickable once it has a Shape2D + Physics2D/Trigger2D (a body to query).
//
//   wall.aspect(Shape2D, { box: [200, 16] }).aspect(Physics2D, { motion: 'static' })
//   goal.aspect(Shape2D, { circle: 20 }).aspect(Trigger2D)
//   hero.aspect(Shape2D, { capsule: { from: [0, 6], to: [0, 26], radius: 6 } }).aspect(Physics2D, { motion: 'dynamic' })
//
// With no geometry set, Shape2D derives a box from WHAT THE NODE DRAWS — the sprite's size scaled by
// the node, positioned by its anchor. So `aspect(Shape2D, {})` on a sprite anchored at its feet gives
// a collider that wraps the art instead of one centred on the pivot (half of it underground), which
// is why every character used to carry a hand-computed offset.
//
// RE-CONFIGURING REBUILDS IT IN PLACE: `node.aspect(Shape2D, { capsule: … })` on a live node swaps the
// fixture while the body keeps its id, its velocity and its place in the world. That is what makes a
// crouch (and a live editor edit) possible.

import { Aspect } from "../core/Aspect"
import { Vec2, cx, cy, type Vec2Like } from "../math/vec"
import { ALL_GROUPS } from "./groups2d"
import type { Node2D } from "./Node2D"

/** @internal How an owner (Physics2D / Trigger2D) wants the geometry realised. */
export type ShapeFixture2D = {
  sensor: boolean
  friction: number
  bounce: number
  category?: number
  mask?: number
  /** Chains carry no mass, so Box2D only accepts them on a static body. */
  staticBody?: boolean
}

export class Shape2D extends Aspect<"shape", Node2D> {
  static readonly aspect = "shape"

  /** Box half-extents [hw, hh] (world units). */
  box?: Vec2Like
  /** Circle radius. */
  circle?: number
  /** Capsule between two local points, with a radius. */
  capsule?: { from: Vec2Like, to: Vec2Like, radius: number }
  /** Thin segment (an edge) between two local points. */
  segment?: { from: Vec2Like, to: Vec2Like }
  /** Convex polygon, up to 8 local points (a convex hull is computed — a CONCAVE outline silently
   *  becomes its hull; use `chain` for those). */
  polygon?: Vec2Like[]
  /**
   * A polyline of connected segments — the one shape that expresses a long CONCAVE surface (rolling
   * terrain, a cave wall) in one piece, seam-free. Pass only the points that should be solid; the
   * tangent points Box2D needs at the ends are synthesised here.
   *
   * STATIC bodies only (a polyline has no area, so no mass), at least 2 points (4 with `loop`), and it
   * must not self-intersect. It is ONE-SIDED: solid on the right of the point order — walk the points
   * so that the solid material is on your right, or set `flip`.
   */
  chain?: Vec2Like[]
  /** `chain`: close the contour (an island / a room boundary). Needs at least 4 points. */
  loop?: boolean
  /** `chain`: put the solid side on the other side of the line. */
  flip?: boolean
  /** The collider's centre relative to the node, in world units. Applies to EVERY kind — it
   *  translates a capsule's / segment's / polygon's / chain's points too, unlike the old `offset`. */
  origin?: Vec2Like

  /** @internal Geometry as of the last build — an unchanged signature means nothing to rebuild. */
  private _sig = ""
  /** @internal The last resize the CHARACTER refused (backs `controller.resizing`). */
  _resizeRefused = false
  /** @internal How the owner asked for it, so a rebuild can replay the same fixture. */
  private _fx: ShapeFixture2D | null = null
  private _handle = 0

  /** @internal — the character claimed this shape; record the geometry so a later re-configure is
   *  compared against it. */
  _claimedByCharacter(): void { this._sig = this._signature(); this._resizeRefused = false }

  /** @internal — realise this geometry as a fixture on `handle` (a body from Physics2D/Trigger2D). */
  _addTo(handle: number, fx: ShapeFixture2D): void {
    this._handle = handle
    this._fx = fx
    this._sig = this._signature()
    this._create()
  }

  /** @internal — the owner's material changed live; keep it for the next rebuild. */
  _setMaterial(friction: number, bounce: number): void {
    if (this._fx) { this._fx.friction = friction; this._fx.bounce = bounce }
  }

  /** @internal — the owner's filter changed live. */
  _setFilter(category: number, mask: number): void {
    if (this._fx) { this._fx.category = category; this._fx.mask = mask }
  }

  onDetach(): void { this._handle = 0; this._fx = null; this._sig = ""; this._resizeRefused = false }

  /**
   * Re-configuring the aspect swaps the fixture in place — same body, same id, same velocity, same
   * position. Nothing happens when the geometry is unchanged, so re-applying the same options in a
   * loop is free.
   */
  onReconfigure(): void {
    const sig = this._signature()
    if (sig === this._sig) return

    // A CharacterController2D owns this shape instead of a body: resizing goes to the mover, which
    // VALIDATES it (you cannot stand up under a low pipe) and can refuse.
    if (this.node._xfKind === 1) {
      const cap = this._capsule()
      const ok = _creator2d.characterSetCapsule(this.node._xf, cap.x1, cap.y1, cap.x2, cap.y2, cap.radius)
      // On refusal NOTHING changed — so DON'T commit the signature. The next identical
      // `aspect(Shape2D, …)` then still sees a difference and retries; committing here would swallow
      // every retry for good, which is the whole trap in this design.
      this._resizeRefused = !ok
      if (ok) this._sig = sig
      return
    }

    if (!this._handle || !this._fx) return
    this._sig = sig
    _creator2d.physicsClearShapes(this._handle)
    this._create()
  }

  /**
   * @internal — this geometry as a CAPSULE, for CharacterController2D (the mover takes nothing else).
   * An explicit capsule is used as given; anything else is derived from the drawn rect, so
   * `aspect(Shape2D, {})` on a sprite gives a capsule that wraps the art.
   */
  _capsule(): { x1: number, y1: number, x2: number, y2: number, radius: number } {
    const ox = this.origin ? cx(this.origin) : 0
    const oy = this.origin ? cy(this.origin) : 0
    if (this.capsule) {
      const { from, to, radius } = this.capsule
      return { x1: cx(from) + ox, y1: cy(from) + oy, x2: cx(to) + ox, y2: cy(to) + oy, radius }
    }
    if (this.circle !== undefined) return { x1: ox, y1: oy, x2: ox, y2: oy, radius: this.circle }
    let hw = 8, hh = 8, bx = ox, by = oy
    if (this.box) { hw = cx(this.box); hh = cy(this.box) }
    else {
      const a = this._autoBox()
      hw = a.hw; hh = a.hh
      if (!this.origin) { bx = a.x; by = a.y }
    }
    const radius = Math.min(hw, hh)
    const half = Math.max(0, hh - radius)
    return { x1: bx, y1: by - half, x2: bx, y2: by + half, radius }
  }

  // ---- internals ---------------------------------------------------------------------------------

  private _create(): void {
    const fx = this._fx!
    const handle = this._handle
    const category = fx.category ?? ALL_GROUPS
    const mask = fx.mask ?? ALL_GROUPS
    const ox = this.origin ? cx(this.origin) : 0
    const oy = this.origin ? cy(this.origin) : 0

    if (this.chain) {
      this._createChain(handle, fx, category, mask, ox, oy)
    } else if (this.circle !== undefined) {
      _creator2d.physicsAddCircle(handle, this.circle, ox, oy, fx.friction, fx.bounce, fx.sensor, category, mask)
    } else if (this.capsule) {
      const { from, to, radius } = this.capsule
      _creator2d.physicsAddCapsule(handle, cx(from) + ox, cy(from) + oy, cx(to) + ox, cy(to) + oy, radius,
                                   fx.friction, fx.bounce, fx.sensor, category, mask)
    } else if (this.segment) {
      const { from, to } = this.segment
      _creator2d.physicsAddSegment(handle, cx(from) + ox, cy(from) + oy, cx(to) + ox, cy(to) + oy,
                                   fx.friction, fx.bounce, category, mask)
    } else if (this.polygon) {
      const pts = new Float32Array(this.polygon.length * 2)
      this.polygon.forEach((p, i) => { pts[i * 2] = cx(p) + ox; pts[i * 2 + 1] = cy(p) + oy })
      _creator2d.physicsAddPolygon(handle, pts, this.polygon.length, fx.friction, fx.bounce, fx.sensor, category, mask)
    } else {
      // box: explicit half-extents, or "auto" — the rect the node actually draws.
      let hw = 8, hh = 8, bx = ox, by = oy
      if (this.box) {
        hw = cx(this.box); hh = cy(this.box)
      } else {
        const auto = this._autoBox()
        hw = auto.hw; hh = auto.hh
        if (!this.origin) { bx = auto.x; by = auto.y }   // an explicit origin still wins
      }
      _creator2d.physicsAddBox(handle, hw, hh, bx, by, fx.friction, fx.bounce, fx.sensor, category, mask)
    }
  }

  /**
   * Box2D's chain wants the two TANGENT points as extra entries and does not collide on its first or
   * last edge — pass it your outline verbatim and you lose both end segments (four points give ONE).
   * So the caller passes only the solid points and the ends are extrapolated here: `p0 + (p0 - p1)`.
   * A loop needs none of that (every point is interior) but does need 4 of them.
   */
  private _createChain(handle: number, fx: ShapeFixture2D, category: number, mask: number, ox: number, oy: number): void {
    const src = this.chain!
    const loop = this.loop === true
    if (src.length < (loop ? 4 : 2)) {
      throw new Error(`Shape2D chain needs at least ${loop ? 4 : 2} points (got ${src.length})${loop ? " — a closed contour of 3 is a polygon" : ""}`)
    }
    if (fx.staticBody === false) {
      throw new Error("Shape2D { chain } works on a STATIC body only — a polyline has no area, so no mass. Use polygon/box for a dynamic body.")
    }
    const order = this.flip === true ? [ ...src ].reverse() : src
    const p: number[] = []
    if (!loop) {
      // leading ghost: mirror the first point across the second
      p.push(2 * cx(order[0]) - cx(order[1]) + ox, 2 * cy(order[0]) - cy(order[1]) + oy)
    }
    for (const v of order) p.push(cx(v) + ox, cy(v) + oy)
    if (!loop) {
      const n = order.length
      p.push(2 * cx(order[n - 1]) - cx(order[n - 2]) + ox, 2 * cy(order[n - 1]) - cy(order[n - 2]) + oy)
    }
    _creator2d.physicsAddChain(handle, new Float32Array(p), p.length / 2, loop, fx.friction, fx.bounce, category, mask)
  }

  // Everything that changes the GEOMETRY, so a re-configure that only touches the material is free.
  private _signature(): string {
    const o = this.origin ? `${cx(this.origin)},${cy(this.origin)}` : ""
    if (this.chain) return `chain:${this.chain.map(v => `${cx(v)},${cy(v)}`).join(";")}|${this.loop}|${this.flip}|${o}`
    if (this.circle !== undefined) return `circle:${this.circle}|${o}`
    if (this.capsule) {
      const { from, to, radius } = this.capsule
      return `cap:${cx(from)},${cy(from)},${cx(to)},${cy(to)},${radius}|${o}`
    }
    if (this.segment) {
      const { from, to } = this.segment
      return `seg:${cx(from)},${cy(from)},${cx(to)},${cy(to)}|${o}`
    }
    if (this.polygon) return `poly:${this.polygon.map(v => `${cx(v)},${cy(v)}`).join(";")}|${o}`
    if (this.box) return `box:${cx(this.box)},${cy(this.box)}|${o}`
    const a = this._autoBox()
    return `auto:${a.hw},${a.hh},${a.x},${a.y}|${o}`
  }

  /**
   * @internal — half-extents and centre of the rect this node DRAWS, in the node's own frame.
   *
   * Sprite anchor space is Y-DOWN while the world is Y-up (see docs/conventions.md), so `anchor: [.5, 1]`
   * ("feet") puts the art ABOVE the origin: its centre sits at +h/2. A node with no sprite falls back
   * to 8x8 at the origin. This is a SNAPSHOT taken when the fixture is built — growing the sprite later
   * does not resize the collider; re-configure the aspect to re-measure.
   */
  private _autoBox(): { hw: number, hh: number, x: number, y: number } {
    const n = this.node as { size?: Vec2, anchor?: Vec2, scale?: Vec2 }
    const size = n.size
    if (!size || !(size.x > 0) || !(size.y > 0)) return { hw: 8, hh: 8, x: 0, y: 0 }
    const sx = n.scale ? Math.abs(n.scale.x) : 1
    const sy = n.scale ? Math.abs(n.scale.y) : 1
    const w = size.x * sx, h = size.y * sy
    const ax = n.anchor ? n.anchor.x : 0.5
    const ay = n.anchor ? n.anchor.y : 0.5
    return { hw: w * 0.5, hh: h * 0.5, x: (0.5 - ax) * w, y: (ay - 0.5) * h }
  }
}
