// The Aspect system: attachable capabilities (animation, collider, physics, and user behaviors)
// that hang off a node WITHOUT bloating the node class. Composition with the terseness of methods.
//
//   const hero = new Sprite({ texture })
//     .aspect(SpriteAnimation, { fps: 8, clips: { walk: [1, 2, 3] } })   // attach + configure
//     .aspect(Shape2D, { box: [16, 8] })                                 // chains: returns the node
//   hero.anim.play('walk')          // access by name (the chain result is typed as having it)
//   if (hero.has(Physics)) hero.physics.velocity = [0, 0]                // has() is a type guard
//
// A user aspect declares its name + target node in the generic and reads `this.node`:
//   class Health extends Aspect<'health', Sprite> { hp = 100; hurt(n: number) { this.hp -= n } }
//   sprite.aspect(Health, { hp: 80 }).health.hurt(10)
//
// SYSTEMS are the same thing attached to a SCENE instead of a node — game logic that has no single
// node to live on (input mapping, FX pools, the HUD, the game mode): `scene.system(Fx)` → `scene.fx`.
// They share the lifecycle, the update phases, the ordering and the events of aspects (see System).
//
// The accessor name lives in the generic (Aspect<'name', Node>). The `chisel` bundler extracts it at
// compile time and injects the runtime `static aspect` on user aspects + the virtual type accessor;
// the SDK's own built-in aspects declare `static readonly aspect` directly (see SpriteAnimation etc.).

import type { Scene } from "../gl/Scene"
import { Emitter, type EventMap } from "./events"
import type { InspectorUI } from "./InspectorUI"
import type { Node } from "../gl/Node"
import { Time, _registerTimeScaleSink } from "./time"

// ---- type helpers -----------------------------------------------------------
/** The class object of an aspect — accepted even when its constructor is protected/abstract. */
export type AspectCtor<A extends Aspect<any, any, any>> = abstract new () => A
/** The accessor name carried in the aspect's generic. */
export type KeyOf<A> = A extends Aspect<infer K, any, any> ? K : never
/** The node kind an aspect targets (the scene kind for a System). */
export type TargetOf<A> = A extends Aspect<any, infer P, any> ? P : never
/** `{ name: Aspect }` — the field an aspect contributes to its node. */
export type FieldOf<A extends Aspect<any, any, any>> = { [P in KeyOf<A>]: A }

type UnionToIntersection<U> =
  (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never

/**
 * A variable typed as a node KNOWN to carry the given aspect(s). Union, not a tuple — reads as
 * English and mirrors the runtime guard `node.has(Health)`:
 *   let boss: With<Sprite, Health | Physics>
 */
export type With<N, A extends Aspect<any, any, any>> =
  N & UnionToIntersection<A extends any ? FieldOf<A> : never>

// ---- per-frame update dispatch (two phases) ---------------------------------
// Aspects tick every frame in one of two render-synced phases, chosen by WHICH METHOD they define:
//   • `update(dt)` — LATE: after the physics step + transform sync + animators, right before the frame
//     draws. Reads of node.worldPosition are the FINAL drawn position — cameras/followers land exactly,
//     what you write to a plain node is what this frame shows. The default home for game logic.
//   • `updateBefore(dt)` — EARLY: before the physics step, so writes of velocity / move commands /
//     kinematic transforms are consumed by the SAME frame's step (zero input latency).
// An aspect rarely needs both; when a behaviour does (feed the sim, then pose something after the
// animators), it is two aspects on the same node — not one class in two phases.
//
// The FRAME SOURCE is pluggable so core never names a specific engine bridge (a 3D-only bundle must not
// pull in the 2D one): each engine layer installs a render-synced source via _installAspectFrames (the
// native early/late hooks), so dispatch is in lockstep with the draw. With none installed (a runtime
// without the hooks) it falls back to a single host setLoop, early-then-late — no true post-physics slot.
//
// ORDER within a phase: declared constraints first (`static after = [Controls]` / `static before`),
// then the numeric `updateOrder` (ascending, default 0), then attach order. Constraints are edges between
// classes: an aspect ranks after everything it names in `after` and after everything that names it in
// `before`; a class with no constraints has rank 0. `updateOrder` only breaks ties inside a rank — so a
// declared dependency always wins over a number.
//
// `dt` is GAME time (core/time.ts): scaled by Time.scale, 0 while Time.paused — and a paused frame skips
// every updater except those with `updateWhilePaused = true`. Lazy: the first update-bearing aspect installs
// the dispatch; a project with none starts nothing. No visibility culling yet.
// The dispatcher's view of an instance: the lifecycle members are `protected` on Aspect (the engine calls
// them, game code never does), so core reaches them through this structural type.
interface Updatable {
  _phaseSeq: number
  _phases: number
  updateOrder: number
  updateWhilePaused: boolean
  update?(dt: number): void
  updateBefore?(dt: number): void
  updateFixed?(dt: number): void
  onAttach?(): void
  onDetach?(): void
  onReconfigure?(): void
}
const internals = (a: Aspect<any, any, any>): Updatable => a as unknown as Updatable
type PhaseFn = (dt: number) => void
type Ctor = Function & { after?: Function[], before?: Function[], aspect?: string }

// A render-synced frame source: given the two phase runners, wire them to the engine and return true;
// return false if unavailable (→ setLoop fallback). Installed by the engine layers (Scene / Scene2D).
type FrameInstaller = (early: PhaseFn, late: PhaseFn) => boolean
// The FIXED-phase source: wire `fixed` to the engine's per-substep hook and return true; false when the
// host has no hook (older builds, 2D-only engines) -> the dispatcher steps its own 1/60 accumulator.
type FixedInstaller = (fixed: PhaseFn) => boolean
let frameInstaller: FrameInstaller | undefined
let fixedInstaller: FixedInstaller | undefined
/** @internal Install a render-synced frame source for aspect update(dt) (+ the fixed-step source). Called from an engine layer. */
export const _installAspectFrames = (fn: FrameInstaller, fixed?: FixedInstaller): void => { frameInstaller = fn; fixedInstaller = fixed }

/** @internal Engine-level phase hooks (the Net system, sdk/src/net): FIRST in the early and fixed
 *  phases, LAST in the late one, ahead of / behind every game aspect regardless of ordering
 *  constraints. Single slot each; raw dt. */
export const _phaseHooks: { early: PhaseFn | null; fixed: PhaseFn | null; late: PhaseFn | null } = { early: null, fixed: null, late: null }

let attachSeq = 0
const warned = new Set<string>()
const warnOnce = (msg: string): void => { if (!warned.has(msg)) { warned.add(msg); console.warn(msg) } }

/** One phase's updater list. Iterated LIVE (no per-frame copy): removals during a pass null the slot
 *  and compact afterwards; additions land past the pass's end and tick from the next frame. Sorting
 *  is lazy — re-ranked before the next pass after any add. */
class PhaseList {
  readonly items: (Updatable | null)[] = []
  private iterating = false
  private dirty = false
  private holes = false

  add(a: Updatable, phase: "update" | "updateBefore" | "updateFixed"): void {
    a._phaseSeq = ++attachSeq
    this.items.push(a)
    this.dirty = true
    a._phases |= phase === "update" ? 2 : phase === "updateFixed" ? 4 : 1
  }
  remove(a: Updatable): boolean {
    const i = this.items.indexOf(a)
    if (i < 0) return false
    if (this.iterating) { this.items[i] = null; this.holes = true }
    else this.items.splice(i, 1)
    return true
  }
  run(method: "update" | "updateBefore" | "updateFixed", dt: number): void {
    if (this.dirty) this.sort()
    const items = this.items
    const n = items.length   // appended during the pass → next frame (attach-order snapshot semantics)
    const paused = Time.paused
    this.iterating = true
    for (let i = 0; i < n; i++) {
      const a = items[i]
      if (a === null || (paused && !a.updateWhilePaused)) continue
      a[method]!(dt)
    }
    this.iterating = false
    if (this.holes) {
      this.holes = false
      let w = 0
      for (let r = 0; r < items.length; r++) { const a = items[r]; if (a !== null) items[w++] = a }
      items.length = w
    }
  }
  /** Stable sort by (constraint rank, updateOrder, attach sequence). */
  private sort(): void {
    this.dirty = false
    const ranks = rankConstraints(this.items)
    this.items.sort((x, y) => {
      if (x === null || y === null) return x === null ? (y === null ? 0 : 1) : -1
      const rx = ranks.get(x.constructor) ?? 0, ry = ranks.get(y.constructor) ?? 0
      if (rx !== ry) return rx - ry
      if (x.updateOrder !== y.updateOrder) return x.updateOrder - y.updateOrder
      return x._phaseSeq - y._phaseSeq
    })
  }
}

// Constraint rank per class among the classes present in one phase list: rank = 0 with no incoming
// edge, else 1 + max(rank of predecessors). Edges: C after D (from C.after or D.before). A cycle is
// warned once and its closing edge ignored; a constraint naming a class not present in this phase is
// simply unconstrained here (early always precedes late anyway).
const rankConstraints = (items: (Updatable | null)[]): Map<Function, number> => {
  const present = new Set<Function>()
  for (const a of items) if (a) present.add(a.constructor)
  const preds = new Map<Function, Set<Function>>()
  const edge = (before: Function, after: Function): void => {
    if (!present.has(before) || !present.has(after) || before === after) return
    let s = preds.get(after)
    if (!s) preds.set(after, (s = new Set()))
    s.add(before)
  }
  for (const c of present) {
    for (const d of (c as Ctor).after ?? []) edge(d, c)
    for (const d of (c as Ctor).before ?? []) edge(c, d)
  }
  if (preds.size === 0) return new Map()
  const rank = new Map<Function, number>()
  const visiting = new Set<Function>()
  const rankOf = (c: Function): number => {
    const known = rank.get(c)
    if (known !== undefined) return known
    if (visiting.has(c)) {
      warnOnce(`[aspects] ordering cycle through ${c.name || "?"} — its constraint is ignored`)
      return 0
    }
    visiting.add(c)
    let r = 0
    for (const p of preds.get(c) ?? []) r = Math.max(r, rankOf(p) + 1)
    visiting.delete(c)
    rank.set(c, r)
    return r
  }
  for (const c of present) rankOf(c)
  return rank
}

const earlyUpdaters = new PhaseList()
const lateUpdaters = new PhaseList()
const fixedUpdaters = new PhaseList()
let dispatchInstalled = false
let fixedInstalled = false
let fixedFallbackOn = false
let fixedFallbackAcc = 0

// The FIXED phase: dt = Time.fixedDt (1/60) every call, once per physics substep, on every host. The
// engine drives it from inside its substep loop when it has the hook; otherwise (older wasm / Apple /
// Android builds, the setLoop path) the SDK steps its own accumulator right after the early phase -
// same step, same 4-substep cap, same drop-the-backlog rule as creator-gl's physics/world.cpp.
const fixedStep: PhaseFn = (dt) => { _phaseHooks.fixed?.(dt); fixedUpdaters.run("updateFixed", dt) }
const fixedFallback = (): void => {
  const step = Time.fixedDt
  fixedFallbackAcc += Time.dt   // scaled: paused -> no steps, slow-motion -> fewer steps, never a shorter dt
  let steps = 0
  while (fixedFallbackAcc >= step && steps < 4) { fixedStep(step); fixedFallbackAcc -= step; steps++ }
  if (steps === 4) fixedFallbackAcc = 0
}

const ensureDispatch = (): void => {
  if (dispatchInstalled) return
  dispatchInstalled = true
  // Both phases scale their OWN raw dt (the engine hands the same value to both; a host or test that
  // drives them apart still gets each phase's dt right). The frame counter / clocks advance once, early.
  const early: PhaseFn = (dt) => {
    Time._beginFrame(dt)
    _phaseHooks.early?.(dt)
    earlyUpdaters.run("updateBefore", Time.dt)
    if (fixedFallbackOn) fixedFallback()
  }
  const late: PhaseFn = (dt) => { lateUpdaters.run("update", Time._phaseDt(dt)); _phaseHooks.late?.(dt) }
  // render-synced source if an engine layer installed one; else one host loop (early then late).
  if (!(frameInstaller && frameInstaller(early, late))) {
    setLoop((dt) => { early(dt); late(dt) })
  }
}
// Registered lazily - an app with no `updateFixed` never makes the engine call into JS per substep.
const ensureFixedDispatch = (): void => {
  if (fixedInstalled) return
  fixedInstalled = true
  ensureDispatch()
  if (!(fixedInstaller && fixedInstaller(fixedStep))) fixedFallbackOn = true
}

/** @internal Force the frame dispatch on (the Net system pumps from the phase hooks even in an app
 *  with no aspects) — and the fixed phase with it, since Net.tick is a fixed-step count. */
export const _ensureNetDispatch = (): void => { ensureDispatch(); ensureFixedDispatch() }

const registerUpdater = (inst: Aspect<any, any, any>): void => {
  const a = internals(inst)
  const hasBefore = typeof a.updateBefore === "function"
  const hasUpdate = typeof a.update === "function"
  const hasFixed = typeof a.updateFixed === "function"
  if (!hasBefore && !hasUpdate && !hasFixed) return
  if (hasBefore) earlyUpdaters.add(a, "updateBefore")
  if (hasUpdate) lateUpdaters.add(a, "update")
  if (hasFixed) fixedUpdaters.add(a, "updateFixed")
  ensureDispatch()
  if (hasFixed) ensureFixedDispatch()
}
const unregisterUpdater = (inst: Aspect<any, any, any>): void => {
  const a = internals(inst)
  if (a._phases === 0) return
  earlyUpdaters.remove(a)
  lateUpdaters.remove(a)
  fixedUpdaters.remove(a)
  a._phases = 0
}

// ---- per-class instance registry (`Aspect.all(Ctor)`) -----------------------
const instances = new Map<Function, Aspect<any, any, any>[]>()
const trackInstance = (a: Aspect<any, any, any>): void => {
  let list = instances.get(a.constructor)
  if (!list) instances.set(a.constructor, (list = []))
  list.push(a)
}
const untrackInstance = (a: Aspect<any, any, any>): void => {
  const list = instances.get(a.constructor)
  if (!list) return
  const i = list.indexOf(a)
  if (i >= 0) list.splice(i, 1)
}

// ---- shared attach / detach (nodes AND scenes) ------------------------------
// `host` receives the named accessor and becomes `inst.node`; `store` is the class → instance map
// (a node's `_aspects`, a scene's `_systems`).
type Store = Map<Function, Aspect<any, any, any>>

const attachTo = <Self, A extends Aspect<any, any, any>>(
  host: Self, store: Store, ctor: AspectCtor<A>, opts: Partial<A> | undefined, scene: unknown,
): Self & FieldOf<A> => {
  let inst = store.get(ctor) as A | undefined
  const fresh = inst === undefined
  if (!inst) {
    inst = new (ctor as unknown as new () => A)()
    ;(inst as { node: unknown }).node = host
    if (scene !== undefined) (inst as { scene: unknown }).scene = scene
    store.set(ctor, inst)
    const name = (ctor as Ctor).aspect
    if (name) (host as unknown as Record<string, unknown>)[name] = inst
  }
  if (opts) Object.assign(inst, opts)
  if (fresh) {
    trackInstance(inst)
    internals(inst).onAttach?.()
    registerUpdater(inst)
  } else if (opts) internals(inst).onReconfigure?.()
  return host as Self & FieldOf<A>
}

const detachFrom = (host: unknown, store: Store, ctor: Function): void => {
  const inst = store.get(ctor)
  if (!inst) return
  internals(inst).onDetach?.()
  unregisterUpdater(inst)
  untrackInstance(inst)
  inst._clearEvents()
  store.delete(ctor)
  const name = (ctor as Ctor).aspect
  if (name) delete (host as Record<string, unknown>)[name]
}

/** Detach every aspect of a store, last-attached first (a Physics attached after its Shape releases
 *  its body before the Shape goes). */
const detachAllFrom = (host: unknown, store: Store): void => {
  const ctors = [...store.keys()]
  for (let i = ctors.length - 1; i >= 0; i--) detachFrom(host, store, ctors[i])
}

// ---- the host: mixed into every node kind (Node2D, Node) --------------------
/**
 * Base for anything that can carry aspects. Extends Emitter, so node kinds get events too. Provides
 * the four verbs; the named accessor (node.physics, node.health, …) is set as an own property at
 * attach time, keyed by the aspect class's runtime `aspect` name.
 */
export abstract class AspectHost<E extends EventMap = EventMap> extends Emitter<E> {
  /** @internal class → instance, the authoritative store (named accessors mirror this). */
  readonly _aspects = new Map<Function, Aspect<any, any, any>>()

  /** Attach (and configure) an aspect, or reconfigure it if already present. Returns the node typed
   *  as now-having that aspect (so no guard/`?.` is needed afterwards). Rejects a wrong target node. */
  aspect<Self extends TargetOf<A>, A extends Aspect<any, any, any>>(
    this: Self,
    ctor: AspectCtor<A>,
    opts?: Partial<A>,
  ): Self & FieldOf<A> {
    return attachTo(this, (this as unknown as AspectHost)._aspects, ctor, opts, undefined)
  }

  /** Safe access — undefined if the aspect isn't attached. */
  get<A extends Aspect<any, any, any>>(ctor: AspectCtor<A>): A | undefined {
    return this._aspects.get(ctor) as A | undefined
  }

  /** Existence check AND type guard: inside `if (node.has(Physics))`, `node.physics` is present. */
  has<A extends Aspect<any, any, any>>(ctor: AspectCtor<A>): this is this & FieldOf<A> {
    return this._aspects.has(ctor)
  }

  /** Detach an aspect (runs its onDetach). Named `removeAspect` so it never collides with the
   *  child-management `add`/`remove` semantics some node kinds expose. */
  removeAspect<A extends Aspect<any, any, any>>(ctor: AspectCtor<A>): this {
    detachFrom(this, this._aspects, ctor)
    return this
  }

  /** @internal Detach every aspect (node teardown): onDetach, updaters, events, registries. */
  _detachAll(): void {
    detachAllFrom(this, this._aspects)
    this.clearListeners()
  }
}

// ---- systems: aspects of a scene --------------------------------------------
/** @internal What a scene needs to carry systems. Scene / Scene2D implement it over these helpers. */
export type SystemHost = { readonly _systems: Map<Function, Aspect<any, any, any>> }

/** @internal `scene.system(Ctor, opts)`. */
export const _attachSystem = <Self extends SystemHost, A extends Aspect<any, any, any>>(
  scene: Self, ctor: AspectCtor<A>, opts?: Partial<A>,
): Self & FieldOf<A> => attachTo(scene, scene._systems, ctor, opts, scene)
/** @internal `scene.removeSystem(Ctor)`. */
export const _detachSystem = (scene: SystemHost, ctor: Function): void => detachFrom(scene, scene._systems, ctor)
/** @internal Scene teardown: every system, last-attached first. */
export const _detachAllSystems = (scene: SystemHost): void => detachAllFrom(scene, scene._systems)

/** @internal Engine layers call this once with their bridge's clock hook (feature-detected). */
export const _installTimeScale = (sink: (scale: number) => void): void => _registerTimeScaleSink(sink)

// ---- the aspect base --------------------------------------------------------
/**
 * Base for everything you attach. `K` = accessor name (extracted by chisel for the runtime). `P` =
 * the target node kind: `this.node` is typed to it, AND attaching to a wrong node is a compile error.
 * `E` = the events this aspect emits (`{ explode: (b: Barrel) => void }`): `this.emit('explode', …)`
 * inside, `node.barrel.on('explode', fn)` outside.
 * Initialize in `onAttach` (the node is set by then) — not a constructor; aspects are created by the
 * engine via `node.aspect()`, never `new`.
 */
export abstract class Aspect<K extends string, P = Node, E extends EventMap = {}> {
  /**
   * Opt this aspect class into EDITOR-RUN mode (generators): while a scene is edited, the scene
   * loader constructs the instance (refs resolved, `node`/`generated` set — never `onAttach`) and
   * calls `rebuild()` — again on inspector prop edits and when a `ref()`-referenced node moves.
   * `static editor = { rebuild: true }`.
   */
  static editor?: { rebuild?: boolean }

  /**
   * Custom inspector card for this aspect in the scene editor (immediate-mode — see
   * core/InspectorUI.ts): re-runs on every edit/event and emits a widget list. Without it, the
   * editor shows the inferred fields; `ui.auto()` emits those same fields, so a custom inspector
   * usually starts with `ui.auto()` and appends status lines / buttons / dynamic dropdowns:
   *
   *   static inspector(ui: InspectorUI, self: MyAspect) {
   *     ui.auto()
   *     if (ui.button('Randomize')) self.rebuild()
   *   }
   */
  static inspector?: (ui: InspectorUI, aspect: any) => void

  /** Tick AFTER these classes within the same phase (`static after = [Controls]`). A constraint
   *  always beats the numeric `updateOrder`. Classes absent from the phase are ignored. */
  static after?: Function[]
  /** Tick BEFORE these classes within the same phase (`static before = [Camera]`). */
  static before?: Function[]

  /** Every live instance of an aspect/system class, in attach order — the registry a game used to
   *  hand-roll as `static all[]`. Live, read-only: copy it (`[...Aspect.all(Barrel)]`) before a loop
   *  that detaches. Empty in the scene editor (aspects are inert data there). */
  static all<A extends Aspect<any, any, any>>(ctor: AspectCtor<A>): readonly A[] {
    return (instances.get(ctor) ?? []) as unknown as readonly A[]
  }

  // NOTE: P has no `extends AspectHost` constraint on purpose — Emitter<E>'s generic
  // addEventListener makes a specific-event node (AspectHost<NodeEvents>) not assignable to
  // AspectHost<any>, which would reject every real node. P only types `this.node`; target
  // enforcement happens in aspect() via `this: Self extends TargetOf<A>`. Defaults to Node
  // (3D is the default engine) — a 2D aspect declares its own P, e.g. Aspect<'x', Sprite>.
  /** The node this aspect is attached to (set at attach time). For a System: the scene. */
  readonly node!: P
  /**
   * GENERATOR aspects (`static editor = { rebuild: true }`, attached through a scene file): a
   * scene-added container child for the aspect's generated output — `rebuild()` clears and refills
   * it. Provided by the scene loader before `onAttach`/`rebuild` run; nodes `add()`ed to it join
   * the scene's draw set automatically (membership is separate from parenting). Undefined for
   * aspects attached by hand outside scene files. See docs/3d/scene-files.md.
   */
  readonly generated!: Node & { clear(): void, readonly version: number }
  /**
   * GENERATOR aspects attached through a scene file: the `Scene` the host node was built into —
   * what `handle.instantiate({ scene })` / `scene.add()` need inside `rebuild()`. Set by the scene
   * loader (edit AND play mode); undefined for aspects attached by hand. 3D only (2D aspects own
   * their `scene` field). For a System: always set — the scene it is attached to.
   */
  readonly scene!: P extends Node ? Scene : P extends SystemHost ? P : unknown
  // Phantom fields — they let the type system recover `K` (the accessor name) and `E` from an
  // instance type (`KeyOf<A>`), never read at runtime. Deliberately NOT marked as internal-only:
  // build-types emits with `stripInternal` (which matches that tag in ANY leading comment, `//`
  // included), and without these in the public .d.ts `infer K` collapses to `string`, which turned
  // every `node.<accessor>` / `scene.<accessor>` into an untyped index signature in projects.
  declare protected readonly __key__?: K
  declare protected readonly __events__?: E
  /** @internal attach sequence within the phase lists (tie-breaker). */
  _phaseSeq = 0
  /** @internal bit 1 = registered early, bit 2 = registered late. */
  _phases = 0
  private _listeners?: Map<string, Function[]>

  // ---- lifecycle: the engine calls these, game code never does (hence `protected`; a subclass may
  // still widen one to public when a method doubles as an explicit action). ----
  /** The node (or scene) is set — initialize here, not in a constructor. */
  protected onAttach?(): void
  /** Release what onAttach acquired. */
  protected onDetach?(): void
  /** Called after `node.aspect(Ctor, opts)` re-assigns options on an ALREADY attached aspect (a
   *  pre-attached one like `model.anim`): rebuild whatever was derived from the options at attach. */
  protected onReconfigure?(): void
  /**
   * GENERATOR aspects: (re)build the derived output under `this.generated` — must be idempotent
   * (clear, then create). Call it from `onAttach()` for play mode; a class opting in with
   * `static editor = { rebuild: true }` ALSO runs it while a scene is being edited: once at load,
   * and again whenever an inspector prop changes or a `ref()`-referenced node moves.
   */
  rebuild?(): void
  /**
   * LATE phase — every frame while attached, after the physics step + transform sync + animators,
   * right before the frame draws: reads of `node.worldPosition` are the final drawn position
   * (cameras/followers have no 1-frame lag) and what you write to a plain node is what this frame
   * shows. The default home for game logic. `dt` = GAME seconds since the last frame (`Time.scale`
   * applied; 0 while paused — see `updateWhilePaused`).
   */
  protected update?(dt: number): void
  /**
   * EARLY phase — every frame BEFORE the physics step, so what you feed the simulation (velocity,
   * `controller.move()`, forces, kinematic transforms) is consumed by this same frame's step: zero
   * input latency. Use it only when you FEED the simulation. Reads here see last frame's settled
   * state. A class rarely needs both phases — that is two aspects on one node.
   */
  protected updateBefore?(dt: number): void
  /**
   * FIXED phase - once per physics substep, BEFORE that substep's Jolt step, with `dt = Time.fixedDt`
   * (1/60) exactly, on every machine: under `Time.scale` the NUMBER of steps per frame changes, never
   * the dt (0-4 calls per frame). The home of simulation that must match another machine's - a
   * multiplayer player sim, dt-dependent motion that may not drift with the frame rate. `controller.move()`
   * / velocity writes here feed the same step; between steps the world is consistent, so `Physics.raycast`
   * is fine. Anything that looks at the screen (cameras, HUD, FX) stays in `updateBefore` / `update`.
   */
  protected updateFixed?(dt: number): void

  /** Tick order within a phase and rank — ascending; default 0, ties keep attach order. A declared
   *  `static after` / `static before` constraint always beats this number. Read once, at attach —
   *  set it as a class field. */
  protected updateOrder = 0
  /** Keep ticking while `Time.paused` (a HUD fade, the pause menu). `dt` is still 0 then — read
   *  `Time.unscaledDt` for wall-clock motion. Checked every frame; may be toggled at any time. */
  protected updateWhilePaused = false

  /** Opt-in: only run update(dt) while the node is on-screen. NOOP for now — visibility culling isn't
   *  wired yet, so every updater ticks regardless; declared so aspects can opt in ahead of it. */
  protected updateWhenVisible = false

  // ---- events: what this aspect tells the world (`E` in the generic) ----
  /** Listen to one of this aspect's events. Chainable. Cleared on detach. */
  on<C extends keyof E & string>(channel: C, callback: E[C]): this {
    const map = (this._listeners ??= new Map())
    const list = map.get(channel)
    if (list) list.push(callback)
    else map.set(channel, [callback])
    return this
  }
  /** Remove a listener added with `on` (same function reference). */
  off<C extends keyof E & string>(channel: C, callback: E[C]): this {
    const list = this._listeners?.get(channel)
    if (list) { const i = list.indexOf(callback); if (i >= 0) list.splice(i, 1) }
    return this
  }
  /** Fire one of this aspect's events. Protected: only the aspect itself emits. */
  protected emit<C extends keyof E & string>(channel: C, ...args: Parameters<E[C]>): void {
    const list = this._listeners?.get(channel)
    if (!list || list.length === 0) return
    for (const fn of list.slice()) fn(...args)
  }
  /** @internal */
  _clearEvents(): void { this._listeners = undefined }
}

/**
 * An aspect of a SCENE: game logic with no single node to live on — input mapping, the FX pools, the
 * HUD, the game mode. Same lifecycle (`onAttach`/`onDetach`), same phases (`updateFixed` / `updateBefore` /
 * `update`), same ordering (`updateOrder`, `static after`) and events as a node aspect; `this.scene` is the
 * scene it was attached to. `S` = the scene kind (`Scene` by default, `Scene2D` for 2D games).
 *
 *   class Hud extends System<'hud'> {
 *     static after = [Player]
 *     update(dt: number) { … }
 *   }
 *   scene.system(Hud)      // attach
 *   scene.hud              // the typed accessor
 */
export abstract class System<K extends string, S extends SystemHost = Scene, E extends EventMap = {}> extends Aspect<K, S, E> {
  // `this.scene` (and `this.node`) are typed S by the base class's conditional — nothing to add.
}
