// No-code scenario aspects: ready-made behaviors a scene builder attaches in the editor without
// writing any code — move to a target, travel a waypoint path, spin, face something, start a GLB
// animation. They are ordinary aspects (work from hand-written code too); in the scene editor the
// movement ones double as `static editor` generators that draw their paths as editor-only lines
// (edges meshes under `this.generated` — play mode never calls rebuild(), so the lines cost
// nothing at runtime).
//
// Conventions shared by the family:
//   • targets/waypoints are scene nodes — place an Empty, then pick it in the inspector (`ref()`);
//   • times are seconds, angles degrees; all motion runs in `update(dt)` (play mode only);
//   • orientation is quaternion-composed — never euler-incremented (see math/quat.ts).

import { Aspect } from "../core/Aspect"
import type { FieldMeta } from "../core/fields"
import type { InspectorUI } from "../core/InspectorUI"
import { Vec3 } from "../math/vec"
import { Quat } from "../math/quat"
import { Node } from "./Node"
import { Mesh } from "./Mesh"
import { Model } from "./Model"
import { Geometry } from "./Geometry"
import { Material } from "./Material"

const MOVE_LINE = "#5b8ef0"
const LOOK_LINE = "#d9a13f"
const DEG2RAD = Math.PI / 180

type LoopMode = "once" | "loop" | "pingpong"

/** Normalized phase for elapsed time `t` over one trip of `duration`: once clamps at 1, loop
 *  wraps, pingpong triangles 0→1→0. */
const phase = (t: number, duration: number, mode: LoopMode): number => {
  if (duration <= 0) return 1
  const u = t / duration
  if (mode === "once") return Math.min(u, 1)
  if (mode === "loop") return u - Math.floor(u)
  const v = u % 2
  return v <= 1 ? v : 2 - v
}

const ease = (k: number, easing: "smooth" | "linear"): number =>
  easing === "smooth" ? k * k * (3 - 2 * k) : k

/** A world point in `node`'s PARENT space — what `node.position` is expressed in. */
const toParentLocal = (node: Node, world: Vec3): Vec3 => {
  const parent = node.parent
  return parent ? parent.worldMatrix.invert().transformPoint(world) : world
}

// ---- editor line drawing (edit mode only — see rebuild() on the aspects below) -------------------

/** @internal An "edges" line mesh from flat segment endpoints ([x,y,z, x,y,z] per segment) —
 *  editor-only visuals (path lines here, node markers in defineScene). */
export const edgesMesh = (segments: number[], color: string): Mesh => {
  const vertices = new Float32Array(segments)
  const count = vertices.length / 3
  const normals = new Float32Array(count * 3)
  for (let i = 0; i < count; i++) normals[i * 3 + 1] = 1
  const indices = new Uint16Array(count)
  for (let i = 0; i < count; i++) indices[i] = i
  const geo = new Geometry(vertices, normals, indices, new Float32Array(count * 2))
  geo.kind = "edges"
  const mesh = Mesh.from(geo, { material: Material.unlit({ color }), name: "__editor_line" })
  mesh.castShadows = false
  mesh.receiveShadows = false
  return mesh
}

const pushCross = (segments: number[], p: Vec3, s: number): void => {
  segments.push(p.x - s, p.y, p.z, p.x + s, p.y, p.z)
  segments.push(p.x, p.y - s, p.z, p.x, p.y + s, p.z)
  segments.push(p.x, p.y, p.z - s, p.x, p.y, p.z + s)
}

/** `this.generated` hangs UNDER the host node, so line points (world space) must be expressed in
 *  the host's local frame; the host is an editor-run dep of its own aspects, so dragging it
 *  re-runs rebuild() and the conversion stays fresh. */
const worldSegmentsToLocal = (node: Node, segments: number[]): number[] => {
  const inv = node.worldMatrix.invert()
  const out: number[] = []
  for (let i = 0; i < segments.length; i += 3) {
    const p = inv.transformPoint([ segments[i]!, segments[i + 1]!, segments[i + 2]! ])
    out.push(p.x, p.y, p.z)
  }
  return out
}

/** A path node's waypoints: its children in scene-FILE order (`_sceneChildOrder`, stamped by
 *  defineScene — the engine's live child order is insertion-based and may differ), editor helper
 *  nodes (`__`-prefixed) excluded. Runtime-added children append in engine order. */
const pathWaypoints = (path: Node): Node[] => {
  const children = path.children.filter((c) => !(c.name ?? "").startsWith("__"))
  const order = (path as { _sceneChildOrder?: string[] })._sceneChildOrder
  if (!order) return children
  const byName = new Map(children.map((c) => [ c.name, c ]))
  const sorted: Node[] = []
  for (const n of order) { const c = byName.get(n); if (c) sorted.push(c) }
  const seen = new Set(sorted)
  for (const c of children) if (!seen.has(c)) sorted.push(c)
  return sorted
}

// ---- the aspects ---------------------------------------------------------------------------------

/** Travel from the node's starting position to a target node in `duration` seconds. */
export class MoveTo extends Aspect<"moveTo"> {
  /** Where to travel — place an Empty node and pick it. */
  target: Node | null = null

  /** Seconds for the full trip. */
  duration = 2

  /** Seconds to wait before starting. */
  delay = 0

  /** once = stop at the target · loop = restart from the start point · pingpong = back and forth. */
  mode: LoopMode = "once"

  /** Motion curve. */
  easing: "smooth" | "linear" = "smooth"

  static readonly aspect = "moveTo"
  static editor = { rebuild: true }
  static fields: FieldMeta<MoveTo> = {
    target: { editor: "node" },
    duration: { min: 0.05, max: 120, step: 0.05 },
    delay: { min: 0, max: 120, step: 0.05 },
    mode: { options: [ "once", "loop", "pingpong" ] },
    easing: { options: [ "smooth", "linear" ] },
  }

  private _t = 0
  private _start: Vec3 | null = null

  update(dt: number): void {
    if (!this.target) return
    this._start ??= this.node.position
    this._t += dt
    const k = ease(phase(Math.max(0, this._t - this.delay), this.duration, this.mode), this.easing)
    const end = toParentLocal(this.node, this.target.worldPosition)
    this.node.position = new Vec3(this._start).add(end.sub(this._start).scale(k))
  }

  /** Editor-only path line (edit mode; play mode never calls this). */
  rebuild(): void {
    this.generated.clear()
    if (!this.target) return
    const segments: number[] = []
    const a = this.node.worldPosition
    const b = this.target.worldPosition
    segments.push(a.x, a.y, a.z, b.x, b.y, b.z)
    pushCross(segments, b, 0.09)
    this.generated.add(edgesMesh(worldSegmentsToLocal(this.node, segments), MOVE_LINE))
  }
}

/** Travel through the children of a path node (add an Empty per waypoint) in `duration` seconds.
 *  `loop` runs the circuit closed (last → first); `pingpong` goes back and forth along the open
 *  path; `once` stops at the last waypoint. */
export class FollowPath extends Aspect<"followPath"> {
  /** A node whose CHILDREN are the waypoints, in file order. */
  path: Node | null = null

  /** Seconds for one full pass of the path. */
  duration = 6

  /** Seconds to wait before starting. */
  delay = 0

  mode: LoopMode = "loop"

  /** Turn to face the direction of travel. */
  orient = true

  /** Which local axis leads when orienting. */
  forward: "-z" | "z" | "x" | "-x" = "-z"

  static readonly aspect = "followPath"
  static editor = { rebuild: true }
  static fields: FieldMeta<FollowPath> = {
    path: { editor: "node" },
    duration: { min: 0.05, max: 300, step: 0.05 },
    delay: { min: 0, max: 120, step: 0.05 },
    mode: { options: [ "loop", "once", "pingpong" ] },
    forward: { options: [ "-z", "z", "x", "-x" ] },
  }

  private _t = 0

  private _points(): Vec3[] {
    if (!this.path) return []
    const points = pathWaypoints(this.path).map((c) => c.worldPosition)
    if (this.mode === "loop" && points.length > 1) points.push(points[0]!.clone())
    return points
  }

  update(dt: number): void {
    const points = this._points()
    if (points.length < 2) return
    this._t += dt
    const k = phase(Math.max(0, this._t - this.delay), this.duration, this.mode === "loop" ? "loop" : this.mode)

    const lengths: number[] = []
    let total = 0
    for (let i = 0; i < points.length - 1; i++) {
      const l = points[i + 1]!.sub(points[i]!).length()
      lengths.push(l)
      total += l
    }
    if (total <= 0) return

    let dist = k * total
    let i = 0
    while (i < lengths.length - 1 && dist > lengths[i]!) { dist -= lengths[i]!; i++ }
    const a = points[i]!
    const b = points[i + 1]!
    const seg = b.sub(a)
    const u = lengths[i]! > 0 ? Math.min(dist / lengths[i]!, 1) : 0
    const world = a.add(seg.scale(u))
    this.node.position = toParentLocal(this.node, world)
    if (this.orient && seg.lengthSq() > 1e-12) this.node.lookAt(world.add(seg), this.forward)
  }

  /** Editor-only waypoint polyline (edit mode; play mode never calls this). */
  rebuild(): void {
    this.generated.clear()
    const points = this._points()
    if (points.length < 2) return
    const segments: number[] = []
    for (let i = 0; i < points.length - 1; i++) {
      const a = points[i]!, b = points[i + 1]!
      segments.push(a.x, a.y, a.z, b.x, b.y, b.z)
    }
    for (const p of points) pushCross(segments, p, 0.07)
    this.generated.add(edgesMesh(worldSegmentsToLocal(this.node, segments), MOVE_LINE))
  }
}

/** Continuous rotation about one of the node's local axes, degrees per second. */
export class Spin extends Aspect<"spin"> {
  /** Degrees per second. */
  speed = 45

  /** Local axis to spin around. */
  axis: "x" | "y" | "z" = "y"

  static readonly aspect = "spin"
  static fields: FieldMeta<Spin> = {
    speed: { min: -720, max: 720, step: 5 },
    axis: { options: [ "x", "y", "z" ] },
  }

  update(dt: number): void {
    const axis: [number, number, number] = this.axis === "x" ? [ 1, 0, 0 ] : this.axis === "y" ? [ 0, 1, 0 ] : [ 0, 0, 1 ]
    // compose in quaternion space about the LOCAL axis (q ⊗ dq) — never euler-increment
    this.node.quaternion = this.node.quaternion.mul(Quat.fromAxisAngle(axis, this.speed * dt * DEG2RAD))
  }
}

/** Keep the node facing a target node (a turret tracking, a signpost, a camera aimed at a hero).
 *  Accessor `node.facing` — NOT `lookAt`, which would shadow the `Node.lookAt()` method. */
export class LookAt extends Aspect<"facing"> {
  /** The node to face. */
  target: Node | null = null

  /** Which local axis points at the target. */
  forward: "-z" | "z" | "x" | "-x" = "-z"

  /** 0 = snap instantly; otherwise seconds of turn lag (bigger = slower, smoother). */
  smoothing = 0

  static readonly aspect = "facing"
  static editor = { rebuild: true }
  static fields: FieldMeta<LookAt> = {
    target: { editor: "node" },
    forward: { options: [ "-z", "z", "x", "-x" ] },
    smoothing: { min: 0, max: 5, step: 0.05 },
  }

  update(dt: number): void {
    if (!this.target) return
    const point = this.target.worldPosition
    if (this.smoothing <= 0) { this.node.lookAt(point, this.forward); return }
    const q0 = this.node.quaternion
    this.node.lookAt(point, this.forward)
    const q1 = this.node.quaternion
    this.node.quaternion = q0.slerp(q1, 1 - Math.exp(-dt / this.smoothing))
  }

  /** Editor-only sight line (edit mode; play mode never calls this). */
  rebuild(): void {
    this.generated.clear()
    if (!this.target) return
    const segments: number[] = []
    const a = this.node.worldPosition
    const b = this.target.worldPosition
    segments.push(a.x, a.y, a.z, b.x, b.y, b.z)
    pushCross(segments, b, 0.07)
    this.generated.add(edgesMesh(worldSegmentsToLocal(this.node, segments), LOOK_LINE))
  }
}

/** Start a GLB animation clip when the scene runs. Attach to a `model:` node; the inspector card
 *  lists the model's clips live and can preview them while editing. */
export class PlayAnimation extends Aspect<"playAnimation", Model> {
  /** Clip name; empty = the model's first clip. */
  clip = ""

  loop = true

  /** Playback rate (1 = authored speed). */
  speed = 1

  static readonly aspect = "playAnimation"
  static fields: FieldMeta<PlayAnimation> = {
    speed: { min: 0.05, max: 5, step: 0.05 },
  }

  onAttach(): void {
    const anim = (this.node as Model | undefined)?.anim
    if (!anim) return
    anim.speed = this.speed
    anim.play(this.clip === "" ? 0 : this.clip, { loop: this.loop })
  }

  static inspector(ui: InspectorUI, a: PlayAnimation): void {
    const anim = (a.node as Model | undefined)?.anim
    const clips = anim?.clips ?? []
    if (clips.length === 0) {
      ui.warn("No animation clips — attach this to a GLB model node")
      return
    }
    ui.select("clip", clips.map((c) => c.name))
    ui.auto("loop", "speed")
    if (anim) {
      if (ui.button(anim.playing ? "Restart preview" : "Preview")) {
        anim.speed = a.speed
        anim.play(a.clip === "" ? 0 : a.clip, { loop: a.loop })
      }
      if (anim.playing && ui.button("Stop")) {
        anim.stop()
        anim.time = 0
      }
      const clip = a.clip === "" ? clips[0]?.name : a.clip
      const duration = clips.find((c) => c.name === clip)?.duration
      ui.info(anim.playing ? "previewing…" : duration !== undefined ? `${duration.toFixed(2)}s` : "")
    }
  }
}
