// GPU particle system, as a Node. Draws with the default point-sprite material (Material.particles)
// unless a custom one is passed; properties drive the native emitter.
//
// All emitter/curve config crosses the bridge as ONE Float32Array of [tag, payloadLen, ...payload]
// records (_creator.setParticleSystemConfig) — the constructor batches every option into a single
// call, live setters send a one-record buffer. The tag values mirror PsTag in
// creator-gl/src/particles.h (guarded by tests/particles-tags.test.ts).
//
// Curves over a particle's lifetime are built with curve()/colorCurve() chains — see the builders
// below. A builder's whole state is the flat `_data` array (the exact record payload), so the
// chisel compiler can constant-fold literal chains into the buffer at compile time.

import { Color, type ColorInput } from "../core/color"
import { Vec3, cx, cy, cz, type Vec3Like } from "../math/vec"
import { Material, _sheetColsRows, type ParticlesMaterialOptions } from "./Material"
import { Node } from "./Node"

type Range<T> = T | { min: T, max: T }

// --- config record tags (keep in sync with PsTag in creator-gl/src/particles.h) -----------------

const TAG_SHAPE = 1
const TAG_LIFETIME = 2
const TAG_RATE = 3
const TAG_VELOCITY = 4
const TAG_VELOCITY_RND = 5
const TAG_ACCELERATION = 6
const TAG_DRAG = 7
const TAG_NOISE = 8
const TAG_SEED = 9
const TAG_PARAM_CURVE = 10
const TAG_COLOR_CURVE = 11
const TAG_SPACE = 12
const TAG_INHERIT_VELOCITY = 13
const TAG_RATE_DISTANCE = 14
const TAG_RENDER_MODE = 15

/** @internal — exported for the tag-sync test only. */
export const _particleTags = {
  SHAPE: TAG_SHAPE, LIFETIME: TAG_LIFETIME, RATE: TAG_RATE, VELOCITY: TAG_VELOCITY,
  VELOCITY_RND: TAG_VELOCITY_RND, ACCELERATION: TAG_ACCELERATION, DRAG: TAG_DRAG,
  NOISE: TAG_NOISE, SEED: TAG_SEED, PARAM_CURVE: TAG_PARAM_CURVE, COLOR_CURVE: TAG_COLOR_CURVE,
  SPACE: TAG_SPACE, INHERIT_VELOCITY: TAG_INHERIT_VELOCITY, RATE_DISTANCE: TAG_RATE_DISTANCE,
  RENDER_MODE: TAG_RENDER_MODE,
}

// --- curve builders -----------------------------------------------------------------------------

// A value curve reads as a journey over the particle's life:
//   curve(0.3).from(0).via(0.15, 1).via(0.6, 1).to(0)   // fade in, hold, fade out — or:
//   curve(0.3).fade(0.15, 0.4)
// The curve multiplies the base value (or adds to it with mode 'add'); the base and any stop can be
// a { min, max } range, randomized per particle. With no `.from` the curve starts at the identity
// (1 for multiply, 0 for add); with no `.to` it holds its last value to end of life.
//
// chisel constant-folds a literal chain into its `_data` buffer at compile time and validates it
// (bad stop order, >8 stops, unparsable color → a build diagnostic): chisel-core/src/curve.rs mirrors
// these method bodies op-for-op, against a verbatim copy of them in chisel's fixtures/curve-sdk/. Keep
// the two in step — a change here means a change there (chisel's scripts/curve-exact.mjs compares the
// fused payload against the real builder, buffer for buffer).

const lohi = (v: Range<number>): [number, number] =>
  typeof v === "number" ? [ v, v ] : [ v.min, v.max ]

export class CurveBuilder {
  private _kind: 0 | 1
  private _lo: number
  private _hi: number
  private _stops: number[] = []   // flat (t, lo, hi)

  constructor(base: Range<number>, mode: "multiply" | "add") {
    this._kind = mode === "add" ? 1 : 0
    ;[ this._lo, this._hi ] = lohi(base)
  }

  /** Curve value at birth (t = 0). Must come before any other stop. */
  from(v: Range<number>): this {
    if (this._stops.length > 0) throw new Error("curve: .from() must come first")
    return this.via(0, v)
  }

  /** Curve value at `t` (0..1 of the particle's life). Stops must be added in ascending t order. */
  via(t: number, v: Range<number>): this {
    const [ lo, hi ] = lohi(v)
    this._stops.push(t, lo, hi)
    return this
  }

  /** Curve value at death (t = 1). */
  to(v: Range<number>): this {
    return this.via(1, v)
  }

  /** The classic opacity envelope: rise over the first `fadeIn`, fall over the last `fadeOut`. */
  fade(fadeIn = 0.15, fadeOut = fadeIn): this {
    const up = Math.min(Math.max(fadeIn, 0), 1)
    const down = Math.min(Math.max(fadeOut, 0), 1 - up)
    if (up > 0) this.via(0, 0).via(up, 1)
    else this.via(0, 1)
    if (down > 0) {
      if (1 - down > up) this.via(1 - down, 1)
      this.via(1, 0)
    }
    return this
  }

  /** The PARAM_CURVE record payload (sans slot): [kind, baseLo, baseHi, n, (t, lo, hi)×n]. */
  get _data(): Float32Array {
    const stops = this._stops
    const prepend = stops.length > 0 && stops[0] > 0
    const n = stops.length / 3 + (prepend ? 1 : 0)
    const out = new Float32Array(4 + n * 3)
    out[0] = this._kind
    out[1] = this._lo
    out[2] = this._hi
    out[3] = n
    let o = 4
    if (prepend) {
      const identity = this._kind === 1 ? 0 : 1
      out[o + 1] = identity
      out[o + 2] = identity
      o += 3
    }
    out.set(stops, o)
    return out
  }
}

export class ColorCurveBuilder {
  private _lo: [number, number, number, number]
  private _hi: [number, number, number, number]
  private _stops: number[] = []   // flat (t, lo rgba, hi rgba)

  constructor(base: Range<ColorInput>) {
    if (isColorRange(base)) {
      this._lo = Color.toRgba01(base.min)
      this._hi = Color.toRgba01(base.max)
    } else {
      this._lo = Color.toRgba01(base)
      this._hi = this._lo
    }
  }

  /** Curve color at birth (t = 0). Must come first. The curve multiplies the base color. */
  from(c: Range<ColorInput>): this {
    if (this._stops.length > 0) throw new Error("colorCurve: .from() must come first")
    return this.via(0, c)
  }

  /** Curve color at `t` (0..1 of the particle's life), in ascending t order. */
  via(t: number, c: Range<ColorInput>): this {
    let lo: number[], hi: number[]
    if (isColorRange(c)) {
      lo = Color.toRgba01(c.min)
      hi = Color.toRgba01(c.max)
    } else {
      lo = hi = Color.toRgba01(c)
    }
    this._stops.push(t, ...lo, ...hi)
    return this
  }

  /** Curve color at death (t = 1). */
  to(c: Range<ColorInput>): this {
    return this.via(1, c)
  }

  /** The COLOR_CURVE record payload: [baseLo rgba, baseHi rgba, n, (t, lo rgba, hi rgba)×n]. */
  get _data(): Float32Array {
    const stops = this._stops
    const prepend = stops.length > 0 && stops[0] > 0
    const n = stops.length / 9 + (prepend ? 1 : 0)
    const out = new Float32Array(9 + n * 9)
    out.set(this._lo, 0)
    out.set(this._hi, 4)
    out[8] = n
    let o = 9
    if (prepend) {
      for (let i = 1; i < 9; i++) out[o + i] = 1   // identity multiplier: white, t = 0
      o += 9
    }
    out.set(stops, o)
    return out
  }
}

/** A value curve over each particle's lifetime. Default mode `multiply` (the curve scales the
 *  base); `add` offsets it instead. */
export const curve = (base: Range<number> = 1, mode: "multiply" | "add" = "multiply"): CurveBuilder =>
  new CurveBuilder(base, mode)

/** A color curve over each particle's lifetime — always multiplies the base color. */
export const colorCurve = (base: Range<ColorInput> = "#ffffff"): ColorCurveBuilder =>
  new ColorCurveBuilder(base)

// --- option types -------------------------------------------------------------------------------

export type ParticleValue = Range<number> | CurveBuilder | { _data: Float32Array }
export type ParticleColor = ColorInput | { min: ColorInput, max: ColorInput } | ColorCurveBuilder | { _data: Float32Array }

type VelocityValue = {
  speed?: Range<number>
  /** Cone half-angle in radians — jitter the launch direction uniformly within it. */
  spread?: number
  /** Asymmetric jitter: two angles (u, v — the x/y components) in radians. */
  randomizeAngle?: { min: Vec3Like, max: Vec3Like }
} & ({ from: Vec3Like } | { to: Vec3Like } | { dir: Vec3Like } | {})

type Shape =
  | { type: "point", v: Vec3Like }
  | { type: "box", min: Vec3Like, max: Vec3Like }
  | { type: "circle", center: Vec3Like, radius: number }
type Noise = { strength?: number, frequency?: number, speed?: number }

export type ParticlesOptions = ParticlesMaterialOptions & {
  /** Custom draw material. Unset = the default point-sprite material, configured by the
   *  `ParticlesMaterialOptions` sugar (map/sheet/emissive/blend/soft) — which is ignored
   *  when an explicit material is passed. */
  material?: Material
  /** Particle pool capacity (default 1000). */
  maxParticles?: number
  /** Particles emitted per second. */
  rate?: number
  /** Simulation space. `'local'` (default) — particles ride the node's transform; `'world'` —
   *  each particle stays where it was born as the emitter moves on (trails: drift smoke, mud,
   *  wake). Switching at runtime resets live particles. */
  space?: "local" | "world"
  /** Fraction (usually 0..1) of the emitter's own velocity added to each particle at spawn —
   *  smoke "thrown" off a moving car. Takes effect with `space: 'world'`. */
  inheritVelocity?: number
  /** Particles emitted per world unit the emitter MOVES, on top of `rate`, with spawn points
   *  spread evenly along the path — trail density independent of speed, no per-frame clumps.
   *  Meant for `space: 'world'`. */
  rateOverDistance?: number
  shape?: Shape
  startVelocity?: Vec3Like | VelocityValue
  /** Downward acceleration, world units/s² — positive pulls particles DOWN (sugar for
   *  `acceleration: [0, -g, 0]`). */
  gravity?: number
  /** Constant acceleration vector (gravity, wind, …). Wins over `gravity` when both are set. */
  acceleration?: Vec3Like
  drag?: number
  lifetime?: Range<number>
  color?: ParticleColor
  /** World-unit diameter (alias of custom[0]). Default 1. */
  size?: ParticleValue
  /** Radians (alias of custom[1]). */
  rotation?: ParticleValue
  /** Per-particle opacity 0..1 (alias of custom[2]). Default 1. */
  opacity?: ParticleValue
  /** Flipbook frame index (alias of custom[3]). With `sheet` set and no explicit frame, the
   *  default is one full sheet cycle over each particle's lifetime, starting at `startFrame`. */
  frame?: ParticleValue
  /** Where the default flipbook cycle begins: `'random'` (default — desynchronized loops like
   *  fire/smoke) or a frame index (`0` plays the sheet in order — explosions, one-shot bursts).
   *  Only shapes the default; an explicit `frame` wins. */
  startFrame?: number | "random"
  noise?: Noise | null
  /** Fixed random seed for deterministic tests. */
  seed?: number
}

// --- record assembly ----------------------------------------------------------------------------

const isColorRange = (v: unknown): v is { min: ColorInput, max: ColorInput } =>
  typeof v === "object" && v !== null && !Array.isArray(v) && "min" in v

const hasData = (v: unknown): v is { _data: Float32Array } =>
  typeof v === "object" && v !== null && "_data" in v

const pushParam = (out: number[], slot: number, value: ParticleValue): void => {
  if (typeof value === "number") {
    out.push(TAG_PARAM_CURVE, 5, slot, 0, value, value, 0)
  } else if (hasData(value)) {
    const d = value._data
    out.push(TAG_PARAM_CURVE, d.length + 1, slot)
    for (let i = 0; i < d.length; i++) out.push(d[i])
  } else {
    out.push(TAG_PARAM_CURVE, 5, slot, 0, value.min, value.max, 0)
  }
}

const pushColor = (out: number[], value: ParticleColor): void => {
  let d: Float32Array
  if (hasData(value)) {
    d = value._data
  } else if (isColorRange(value)) {
    d = new ColorCurveBuilder(value)._data
  } else {
    d = new ColorCurveBuilder(value as ColorInput)._data
  }
  out.push(TAG_COLOR_CURVE, d.length)
  for (let i = 0; i < d.length; i++) out.push(d[i])
}

const pushShape = (out: number[], shape: Shape): void => {
  if (shape.type === "point") out.push(TAG_SHAPE, 4, 0, cx(shape.v), cy(shape.v), cz(shape.v))
  else if (shape.type === "box") out.push(TAG_SHAPE, 7, 1, cx(shape.min), cy(shape.min), cz(shape.min), cx(shape.max), cy(shape.max), cz(shape.max))
  else out.push(TAG_SHAPE, 5, 2, cx(shape.center), cy(shape.center), cz(shape.center), shape.radius)
}

const pushVelocity = (out: number[], val: Vec3Like | VelocityValue): void => {
  if (Array.isArray(val) || val instanceof Float32Array || val instanceof Vec3) {
    // a plain vector: launch along it, its length is the speed (native derives it)
    out.push(TAG_VELOCITY, 6, 1, cx(val), cy(val), cz(val), 0, 0)
    out.push(TAG_VELOCITY_RND, 5, 0, 0, 0, 0, 0)
    return
  }
  const v = val as VelocityValue
  let mode = 0
  let p: Vec3Like = [ 0, 0, 0 ]
  if ("dir" in v) { mode = 1; p = v.dir }
  else if ("from" in v) { mode = 2; p = v.from }
  else if ("to" in v) { mode = 3; p = v.to }
  let lo = 0, hi = 0
  if (v.speed !== undefined) {
    if (typeof v.speed === "number") lo = hi = v.speed
    else { lo = v.speed.min; hi = v.speed.max }
  }
  out.push(TAG_VELOCITY, 6, mode, cx(p), cy(p), cz(p), lo, hi)
  if (v.spread !== undefined) {
    out.push(TAG_VELOCITY_RND, 5, 1, -v.spread, -v.spread, v.spread, v.spread)
  } else if (v.randomizeAngle) {
    const a = v.randomizeAngle
    out.push(TAG_VELOCITY_RND, 5, 1, cx(a.min), cy(a.min), cx(a.max), cy(a.max))
  } else {
    out.push(TAG_VELOCITY_RND, 5, 0, 0, 0, 0, 0)
  }
}

// --- the node -----------------------------------------------------------------------------------

export class Particles extends Node {
  private _material: Material

  /** Custom curve-param slots (0..3); size/rotation/opacity/frame alias slots 0-3. */
  readonly custom: ParticleValue[]

  constructor(options: ParticlesOptions = {}) {
    super()
    this._material = options.material ?? Material.particles(options)
    _creator.createParticleSystem(this.id, Material.idOf(this._material), options.maxParticles ?? 0)

    const id = this.id
    this.custom = new Proxy([] as ParticleValue[], {
      set(target, prop: string, value: ParticleValue) {
        const i = parseInt(prop)
        if (Number.isNaN(i)) { (target as any)[prop] = value; return true }
        target[i] = value
        const out: number[] = []
        pushParam(out, i, value)
        _creator.setParticleSystemConfig(id, Float32Array.from(out))
        return true
      },
    })

    const out: number[] = []
    if (options.render !== undefined && options.render !== "point") {
      out.push(TAG_RENDER_MODE, 1, options.render === "ribbon" ? 2 : 1)
    }
    if (options.space !== undefined) out.push(TAG_SPACE, 1, options.space === "world" ? 1 : 0)
    if (options.inheritVelocity !== undefined) out.push(TAG_INHERIT_VELOCITY, 1, options.inheritVelocity)
    if (options.rateOverDistance !== undefined) out.push(TAG_RATE_DISTANCE, 1, options.rateOverDistance)
    if (options.rate !== undefined) out.push(TAG_RATE, 1, options.rate)
    if (options.shape !== undefined) pushShape(out, options.shape)
    if (options.startVelocity !== undefined) pushVelocity(out, options.startVelocity)
    if (options.acceleration !== undefined) {
      const a = options.acceleration
      out.push(TAG_ACCELERATION, 3, cx(a), cy(a), cz(a))
    } else if (options.gravity !== undefined) {
      out.push(TAG_ACCELERATION, 3, 0, -options.gravity, 0)
    }
    if (options.drag !== undefined) out.push(TAG_DRAG, 1, options.drag)
    if (options.lifetime !== undefined) {
      const [ lo, hi ] = lohi(options.lifetime)
      out.push(TAG_LIFETIME, 2, lo, hi)
    }
    if (options.color !== undefined) pushColor(out, options.color)
    if (options.size !== undefined) pushParam(out, 0, options.size)
    if (options.rotation !== undefined) pushParam(out, 1, options.rotation)
    if (options.opacity !== undefined) pushParam(out, 2, options.opacity)
    if (options.frame !== undefined) {
      pushParam(out, 3, options.frame)
    } else if (options.material === undefined) {
      const [ cols, rows ] = _sheetColsRows(options.sheet)
      const frames = cols * rows
      if (frames > 1) {
        const start = options.startFrame ?? "random"
        const base = start === "random" ? { min: 0, max: frames - 1 } : start
        pushParam(out, 3, curve(base, "add").from(0).to(frames))
      }
    }
    if (options.noise !== undefined) {
      const n = options.noise
      out.push(TAG_NOISE, 3, n ? n.strength ?? 1 : 0, n ? n.frequency ?? 1 : 0, n ? n.speed ?? 1 : 0)
    }
    if (options.seed !== undefined) out.push(TAG_SEED, 1, options.seed)

    if (out.length > 0) _creator.setParticleSystemConfig(this.id, Float32Array.from(out))
  }

  /** Emit `count` particles immediately (a burst). */
  spawn(count: number): this { _creator.spawnParticles(this.id, count); return this }

  private _send(out: number[]): void {
    _creator.setParticleSystemConfig(this.id, Float32Array.from(out))
  }

  set rate(val: number) { this._send([ TAG_RATE, 1, val ]) }

  /** `'world'` leaves particles behind where they were born (trails). Switching resets live ones. */
  set space(val: "local" | "world") { this._send([ TAG_SPACE, 1, val === "world" ? 1 : 0 ]) }
  set inheritVelocity(val: number) { this._send([ TAG_INHERIT_VELOCITY, 1, val ]) }
  set rateOverDistance(val: number) { this._send([ TAG_RATE_DISTANCE, 1, val ]) }

  set shape(shape: Shape) {
    const out: number[] = []
    pushShape(out, shape)
    this._send(out)
  }

  set startVelocity(val: Vec3Like | VelocityValue) {
    const out: number[] = []
    pushVelocity(out, val)
    this._send(out)
  }

  /** Positive pulls particles down — sugar for `acceleration = [0, -g, 0]`. */
  set gravity(val: number) { this._send([ TAG_ACCELERATION, 3, 0, -val, 0 ]) }
  set acceleration(a: Vec3Like) { this._send([ TAG_ACCELERATION, 3, cx(a), cy(a), cz(a) ]) }
  set drag(val: number) { this._send([ TAG_DRAG, 1, val ]) }
  set seed(val: number) { this._send([ TAG_SEED, 1, val ]) }

  set lifetime(val: Range<number>) {
    const [ lo, hi ] = lohi(val)
    this._send([ TAG_LIFETIME, 2, lo, hi ])
  }

  set size(val: ParticleValue) { this.custom[0] = val }
  set rotation(val: ParticleValue) { this.custom[1] = val }
  set opacity(val: ParticleValue) { this.custom[2] = val }
  set frame(val: ParticleValue) { this.custom[3] = val }

  set color(value: ParticleColor) {
    const out: number[] = []
    pushColor(out, value)
    this._send(out)
  }

  get material(): Material { return this._material }
  set material(material: Material) {
    this._material = material
    _creator.setMaterial(this.id, Material.idOf(material), 0)
  }

  set noise(noise: Noise | null) {
    this._send(noise
      ? [ TAG_NOISE, 3, noise.strength ?? 1, noise.frequency ?? 1, noise.speed ?? 1 ]
      : [ TAG_NOISE, 3, 0, 0, 0 ])
  }
}

// --- ribbon trail -------------------------------------------------------------------------------

/** `Trail` options. The look options (map/sheet/emissive/blend/soft) come from
 *  `ParticlesMaterialOptions`, same as `Particles`. */
export type TrailOptions = Omit<ParticlesMaterialOptions, "render" | "stretch"> & {
  /** Seconds each trail point lives — the trail's length in time. Default 0.5. */
  time?: number
  /** Strip width in world units; a curve tapers it over each point's life (head → tail).
   *  Default 0.1. */
  width?: ParticleValue
  /** Minimum emitter movement (world units) between recorded points. Default 0.05. */
  minDistance?: number
  /** Point capacity (default 128). */
  maxPoints?: number
  color?: ParticleColor
  /** Opacity along the trail. Defaults to fading the tail out (`curve().to(0)`). */
  opacity?: ParticleValue
}

/** A ribbon strip that follows this node through the world — sword swings, skid marks, missile
 *  trails. Attach it to (or under) the moving node and just move; points are laid down per
 *  `minDistance` of movement, live for `time` seconds, and the strip stays glued to the node at
 *  its head. Width/opacity/color accept the same curves as `Particles`, evaluated over each
 *  point's life — i.e. along the trail from head (fresh) to tail (dying). */
export class Trail extends Node {
  private _material: Material
  private _rateDistance: number

  constructor(options: TrailOptions = {}) {
    super()
    this._material = Material.particles({ ...options, render: "ribbon" })
    _creator.createParticleSystem(this.id, Material.idOf(this._material), options.maxPoints ?? 128)

    const time = options.time ?? 0.5
    this._rateDistance = 1 / Math.max(options.minDistance ?? 0.05, 1e-3)
    const out: number[] = [
      TAG_RENDER_MODE, 1, 2,
      TAG_SPACE, 1, 1,          // a trail is only meaningful in world space
      TAG_RATE, 1, 0,           // points come from movement, never from time
      TAG_RATE_DISTANCE, 1, this._rateDistance,
      TAG_LIFETIME, 2, time, time,
    ]
    pushParam(out, 0, options.width ?? 0.1)
    pushParam(out, 2, options.opacity ?? curve().to(0))
    if (options.color !== undefined) pushColor(out, options.color)
    _creator.setParticleSystemConfig(this.id, Float32Array.from(out))
  }

  private _send(out: number[]): void {
    _creator.setParticleSystemConfig(this.id, Float32Array.from(out))
  }

  /** Trail length in seconds — how long each laid-down point lives. */
  set time(val: number) { this._send([ TAG_LIFETIME, 2, val, val ]) }

  set width(val: ParticleValue) {
    const out: number[] = []
    pushParam(out, 0, val)
    this._send(out)
  }

  set opacity(val: ParticleValue) {
    const out: number[] = []
    pushParam(out, 2, val)
    this._send(out)
  }

  set color(value: ParticleColor) {
    const out: number[] = []
    pushColor(out, value)
    this._send(out)
  }

  set minDistance(val: number) {
    this._rateDistance = 1 / Math.max(val, 1e-3)
    this._send([ TAG_RATE_DISTANCE, 1, this._rateDistance ])
  }

  /** Pause/resume laying down points — existing ones still age out, so the trail fades naturally
   *  after e.g. a sword swing ends. */
  set emitting(val: boolean) { this._send([ TAG_RATE_DISTANCE, 1, val ? this._rateDistance : 0 ]) }

  get material(): Material { return this._material }
}
