// A character controller, as an aspect on a Node2D, backed by Box2D's kinematic MOVER — collide and
// slide, not a rigid body. Requires a capsule Shape2D (one is derived from the sprite if absent) and
// does NOT use Physics2D.
//
//   const hero = new Sprite({ texture, anchor: [0.5, 1] })
//     .aspect(Shape2D, { capsule: { from: [0, 8], to: [0, 26], radius: 8 } })
//     .aspect(CharacterController2D)
//
//   setLoop(() => hero.controller.move(((Input.key('KeyD') ? 1 : 0) - (Input.key('KeyA') ? 1 : 0)) * SPEED))
//   Input.on('keydown', e => { if (e.code === 'Space' && hero.controller.grounded) hero.controller.velocityY = 700 })
//
// The two halves of the velocity have different NATURES, so they have different channels:
//
//   • HORIZONTAL — `move(x)`, 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 / bounce pad / explosion. There is deliberately no ground check: coyote
//     time, double jumps and wall jumps all need the caller's own condition.
//
// FREE MODE — `gravityScale = 0` turns the character into a swimmer / flyer / ladder climber / the
// hero of a TOP-DOWN game: no gravity, no ground, no stick-to-floor. There the vertical is
// muscle-driven too, so `move()` takes BOTH components and the whole velocity becomes a per-frame
// command. Top-down vs platformer is this one number, not two controllers.
//
// `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 300. Everything runs on the engine's fixed clock,
// so the aspect has no per-frame update: a character costs zero JS work per frame.
//
// ROTATION AND SCALE stay the node's: the engine writes only the position (a capsule is symmetric
// about its axis, so its angle is physically meaningless).
//
// The character IS visible to raycasts, overlaps and triggers — it carries a hidden kinematic body for
// exactly that, so pickups and hazard zones need no special code. Solid contacts do not arrive as
// 'enter' events (the mover is not a body); read `collisions` for those.

import { Aspect } from "../core/Aspect"
import type { FieldMeta } from "../core/fields"
import { Vec2, cx, cy, type Vec2Like } from "../math/vec"
import type { CompAxis, CompWriter } from "../core/compWrite"
import { PhysicsGroup2D, _watchGroupChanges, _unwatchGroupChanges, categoryOf, solidMask } from "./groups2d"
import { Shape2D } from "./Shape2D"
import { node2dRegistry, type Node2D } from "./Node2D"

/** Where the character's feet are, as reported by the solver after the last step. */
export type GroundState2D = "ground" | "slope" | "air"
const GROUND_STATES: readonly GroundState2D[] = [ "ground", "slope", "air" ]

/** One surface the mover pushed out of this step. `normal` points back at the character. */
export type Collision2D = { node: Node2D | null, normal: Vec2 }

const MAX_COLLISIONS = 8
const state = new Float32Array(8)
let warnedFreeMove = false

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

  // Accessor-backed tunables are invisible to describeFields (it enumerates own enumerable fields),
  // so the inspector is told about them explicitly. Defaults still come from the getters.
  static fields: FieldMeta<CharacterController2D> = {
    gravityScale: { editor: "number", min: 0, step: 0.1, label: "Gravity scale" },
    maxSlope: { editor: "number", min: 0, max: 89, step: 1, label: "Max slope°" },
  }

  private _id = 0
  private _gravityScale = 1
  private _maxSlope = 45
  private _group: PhysicsGroup2D | undefined
  private _dropThrough: Node2D | null = null
  private _repush = (): void => {
    if (this._id) _creator2d.characterSetFilter(this._id, categoryOf(this._group), solidMask(this._group))
  }

  onAttach(): void {
    if (!_creator2d.physicsHasSupport || !_creator2d.physicsHasSupport()) return
    if (!this.node.has(Shape2D)) this.node.aspect(Shape2D, {})
    const shape = this.node.get(Shape2D)!
    const cap = shape._capsule()
    // Read the options through their PUBLIC accessors — chisel drops an accessor pair whose name is
    // only ever an object-literal key, and the option would silently do nothing (see Physics2D).
    this._id = _creator2d.characterCreate(this.node.id, cap.x1, cap.y1, cap.x2, cap.y2, cap.radius,
                                          this.maxSlope, categoryOf(this.group), solidMask(this.group))
    if (!this._id) return
    shape._claimedByCharacter()
    _creator2d.characterSetGravityScale(this._id, this.gravityScale)
    this.node._xf = this._id      // routes node.position writes to the character
    this.node._xfKind = 1
    // The engine moves this node every step, so its JS transform cache must re-sync on READ — without
    // this the character simulates correctly and node.position never changes.
    this.node._physicsDriven = true
    if (this._group) _watchGroupChanges(this._repush)
  }

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

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

  /**
   * 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).
   *
   * ONE component = horizontal, the everyday platformer call. TWO = the whole velocity, for free mode
   * (`gravityScale = 0`); with gravity on, a two-component call fights the ballistic vertical and the
   * character hangs in the air, so that combination warns once.
   *
   * 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): void
  move(x: number, y: number): void
  move(v: Vec2Like): void
  move(x: number | Vec2Like, y?: number): void {
    if (!this._id) return
    if (typeof x === "number") {
      if (y === undefined) { _creator2d.characterMove(this._id, x); return }
      this._warnFree()
      _creator2d.characterMoveFree(this._id, x, y)
      return
    }
    this._warnFree()
    _creator2d.characterMoveFree(this._id, cx(x), cy(x))
  }

  private _warnFree(): void {
    if (this._gravityScale === 0 || warnedFreeMove) return
    warnedFreeMove = true
    console.warn("CharacterController2D.move(): the two-component form is the FREE-mode command (top-down / 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) and velocityY for a platformer.")
  }

  /** Vertical velocity (world units/s) — LATCHED: gravity works on it, you seed it. `= 700` to jump,
   *  `+= 300` 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). Does nothing in free mode. `velocity.y = 700` is
   *  the same channel — pick whichever reads better. */
  get velocityY(): number { return this._state()[1] }
  set velocityY(v: number) { if (this._id) _creator2d.characterSetVerticalVelocity(this._id, v) }

  /**
   * READ — the velocity the solver ended up with after the most recent step (world units/s, fresh
   * Vec2): what HAPPENED, not what you asked for. Walking into a wall reads ~0, sliding along one
   * reads the tangent.
   *
   * 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 * 600, 400]          // hit by the blast
   *     if (thrown) { if (hero.controller.grounded) thrown = false }   // landing ends it
   *     else hero.controller.move(ix * SPEED)                          // …and this reclaims the axis
   *
   * Nothing clears the latch by itself, landing included — a kinematic controller has no friction.
   * Reading is not the inverse of writing: against a wall the read is ~0 and would cancel the throw.
   *
   * `c.velocity.y = 700` (the direct spelling) is a jump — the compiler routes it to the exact
   * `velocityY` channel via `_writeComp` below — and `c.velocity.x = 300` latches the whole vector
   * with the measured vertical filling in. A STORED copy is still a copy (`const v = c.velocity`).
   */
  get velocity(): Vec2 { const s = this._state(); return new Vec2(s[0], s[1]) }
  set velocity(v: Vec2Like) { if (this._id) _creator2d.characterSetVelocity(this._id, cx(v), cy(v)) }

  /** Compile-time list (chisel reads it, then strips it) — see CharacterController / core/compWrite.ts. */
  static _comps = [ "velocity" ]

  /** @internal `c.velocity.<axis> = v` compiles to this; see CharacterController. */
  _writeComp(_prop: string, axis: CompAxis, v: number): void {
    if (!this._id) return
    if (axis === "y") { _creator2d.characterSetVerticalVelocity(this._id, v); return }
    const s = this._state()
    _creator2d.characterSetVelocity(this._id, axis === "x" ? v : s[0], s[1])
  }

  /** True while standing on walkable ground. */
  get grounded(): boolean { return this._state()[2] === 0 }

  /** Where the feet are after the last step: walkable ground, too-steep ground, or the air. */
  get groundState(): GroundState2D { return GROUND_STATES[this._state()[2]] ?? "air" }

  /** The surface normal under the feet — for orienting a sprite to a slope, or deciding a slide. */
  get groundNormal(): Vec2 { const s = this._state(); return new Vec2(s[3], s[4]) }

  /** What the character is standing on: a moving platform, a hazard, an ice patch whose `friction`
   *  the game can read. The engine already carries the character along a kinematic platform. */
  get groundNode(): Node2D | null {
    const id = this._state()[5]
    return id ? node2dRegistry.get(id) ?? null : null
  }

  /**
   * Every surface the mover pushed out of during the last step, with the normal pointing back at the
   * character. This is what wall jumps and box pushing are written against — a kinematic mover is not
   * a body, so those contacts do not arrive as 'enter' events.
   *
   *     const wall = c.collisions.find(h => Math.abs(h.normal.x) > 0.7)
   *     if (wall && jumpPressed) c.velocity = [-wall.normal.x * KICK, JUMP]
   *
   *     for (const h of c.collisions) h.node?.physics?.applyImpulse([-h.normal.x * PUSH, 0])
   */
  get collisions(): Collision2D[] {
    if (!this._id) return []
    const o = _creator2d.characterGetCollisions(this._id, MAX_COLLISIONS)
    const out: Collision2D[] = []
    for (let i = 0; i + 2 < o.length; i += 3) {
      out.push({ node: o[i] ? node2dRegistry.get(o[i]) ?? null : null, normal: new Vec2(o[i + 1], o[i + 2]) })
    }
    return out
  }

  /** Multiplier over the world gravity; default 1. **0 = free mode**: no gravity, no ground, no
   *  stick-to-floor — a swimmer, a drone, a ladder climber, or a top-down hero, driven by the
   *  two-component `move()`. */
  get gravityScale(): number { return this._gravityScale }
  set gravityScale(v: number) {
    this._gravityScale = v
    if (this._id) _creator2d.characterSetGravityScale(this._id, 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._id) _creator2d.characterSetMaxSlope(this._id, v)
  }

  /** Which collision group the character belongs to. Live. */
  get group(): PhysicsGroup2D | undefined { return this._group }
  set group(g: PhysicsGroup2D | undefined) { this._group = g; this._repush() }

  /**
   * While this names a node, that node's `OneWay2D` surfaces are not solid for this character — how
   * you drop off a semisolid platform. A plain latch: whoever sets it clears it.
   *
   *     if (downPressed && jumpPressed && c.grounded) {
   *       const platform = c.groundNode
   *       c.dropThrough = platform
   *       setTimeout(() => { if (c.dropThrough === platform) c.dropThrough = null }, 200)
   *     }
   *
   * Because it names ONE node, the timeout is not delicate: too long merely means you could have
   * re-landed on that platform for a moment, and the platform below stays solid either way.
   */
  get dropThrough(): Node2D | null { return this._dropThrough }
  set dropThrough(n: Node2D | null) {
    this._dropThrough = n
    if (this._id) _creator2d.characterSetDropThrough(this._id, n ? n.id : 0)
  }

  /**
   * True while a requested collider resize hasn't taken — you asked to stand up and there is something
   * overhead. Resizing goes through the `Shape2D` aspect itself:
   *
   *     hero.aspect(Shape2D, { capsule: CROUCHED })   // always fits — you are shrinking
   *     hero.aspect(Shape2D, { 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(Shape2D)?._resizeRefused ?? false }

  private _state(): Float32Array {
    if (!this._id) { state.fill(0); state[2] = 2; state[4] = 1; return state }
    _creator2d.characterGetState(this._id, state)
    return state
  }
}
