// Navigation meshes (docs/navmesh-plan.md). A level bakes ONE navmesh at dev time from its static
// collision geometry (`lecodes navmesh bake` — the desktop host dumps every static Jolt body's
// triangles, Recast builds the mesh in the CLI) and loads it at runtime; agents (`NavAgent`) walk
// it through DetourCrowd, and the game asks it for paths, nearest points, walkability rays and
// random spots. A scene FILE does the loading by itself:
//
//   env: { navmesh: { data: asset('./assets/nav/yard.navmesh'), agent: { radius: 0.4, height: 1.8 } } }
//
// Hand-built scenes call it once the level is complete:
//
//   const nav = await NavMesh.load(scene, { data: asset('./assets/nav/yard.navmesh') })
//   nav?.findPath(a, b)                       // Vec3[] corners (+ .partial)
//   enemy.aspect(NavAgent, { speed: 3 }).agent.goTo(player.position)
//
// Under `lecodes navmesh bake` the same call is where the geometry dump fires ("the scene is
// complete"); on a host without navigation, or with a placeholder file, `load` resolves null and
// agents stay inert — the project runs everywhere. All logic lives here in JS; the native side
// (creator-nav) is the algorithms and the per-step hot path: ONE crowd update + ONE flat read per
// fixed step for every agent in the scene (`NavCrowd`, a system the mesh attaches to its scene).

import { fetch } from "../runtime/fetch"
import { System } from "../core/Aspect"
import { Vec3, cx, cy, cz, type Vec3Like } from "../math/vec"
import { Geometry } from "./Geometry"
import { Material } from "./Material"
import { Mesh } from "./Mesh"
import type { Node } from "./Node"
import type { Scene } from "./Scene"
import type { NavAgent } from "./NavAgent"

// Fixed-order layouts — keep in step with creator-nav/creator-nav.h (CNAV_AGENT_* / CNAV_READ_*).
export const NAV_AGENT = {
  RADIUS: 0, HEIGHT: 1, MAX_SPEED: 2, MAX_ACCELERATION: 3, COLLISION_QUERY_RANGE: 4, PATH_OPTIMIZATION_RANGE: 5,
  SEPARATION_WEIGHT: 6, AVOIDANCE: 7, INCLUDE_FLAGS: 8, EXCLUDE_FLAGS: 9, ANTICIPATE_TURNS: 10,
  OPTIMIZE_VIS: 11, OPTIMIZE_TOPO: 12, COUNT: 13,
} as const
export const NAV_READ = {
  POS_X: 0, POS_Y: 1, POS_Z: 2, VEL_X: 3, VEL_Y: 4, VEL_Z: 5, DVEL_X: 6, DVEL_Y: 7, DVEL_Z: 8,
  STATE: 9, TARGET_STATE: 10, REMAINING: 11, PARTIAL: 12, STRIDE: 13,
} as const
const FLAGS_ALL = 0xffff
const AREA_UNWALKABLE = 255
const AREA_SKIP = 254

/** The agent size a navmesh is built for (and the crowd's defaults). */
export type NavAgentSize = {
  /** metres — polygons are eroded by it. Default 0.4 */
  radius?: number
  /** metres. Default 1.8 */
  height?: number
  /** the highest step walked over, metres. Default 0.4 */
  climb?: number
  /** the steepest walkable slope, degrees. Default 50 */
  slope?: number
}

export type NavMeshLoadOptions = {
  /** `asset('./assets/nav/<scene>.navmesh')` — what `lecodes navmesh bake` writes. */
  data: string
  /** The agent size the bake uses (the file records it; the crowd reads it back). */
  agent?: NavAgentSize
  /** Named areas → traversal cost (1 = plain ground). Ids are assigned in this order (1..15); the
   *  bake stores the names, queries and agents refer to them (`include` / `exclude`, `cost`). */
  areas?: Record<string, number>
}

/** The file header `lecodes navmesh bake` wrote. */
export type NavMeshInfo = {
  version: number
  agent: { radius: number, height: number, climb: number, slope: number }
  cell: number
  cellHeight: number
  tileSize: number
  bounds: [[number, number, number], [number, number, number]]
  stats: { tiles: number, polys: number, verts: number, triangles: number, volumes: number, links: number }
  meta: { scene?: string, areas?: Record<string, number>, costs?: Record<string, number>, [k: string]: unknown } | null
}

/** A straight path: the corners to walk, plus whether it stops short of the target. */
export type NavPath = Vec3[] & { partial: boolean }

export type NavQueryOptions = {
  /** Only these areas (names). Default: every area. */
  include?: string[]
  /** Never these areas (names). */
  exclude?: string[]
  /** Search half-extents around each endpoint (how far off the mesh a point may be). Default [2, 4, 2]. */
  extents?: Vec3Like
}

export type NavRaycastHit = { hit: boolean, point: Vec3, normal: Vec3 }

type BakeConfig = { outDir: string, name?: string }

const bakeConfig = (): BakeConfig | null => {
  const g = (globalThis as unknown as { __lecodesNavmesh?: BakeConfig }).__lecodesNavmesh
  return g && typeof g.outDir === "string" ? g : null
}

const hasNav = (): boolean => typeof _creatorNav !== "undefined" && _creatorNav !== null

const scratch = new Float32Array(6)
const pathScratch = new Float32Array(256 * 3)

/** The crowd behind a scene's agents — a system `NavMesh.load` attaches to the scene so the whole
 *  crowd advances once per fixed step, BEFORE every `NavAgent` applies its slot (`static after`). */
export class NavCrowd extends System<"navCrowd"> {
  static readonly aspect = "navCrowd"

  /** @internal */ _mesh: NavMesh | null = null
  /** @internal */ _id = 0
  /** @internal */ _max = 0
  /** @internal */ _buf = new Float32Array(0)
  /** @internal */ _agents = new Set<NavAgent>()
  /** @internal */ _step = 0

  /** @internal Make room for one more agent (the crowd is re-created with double the slots; every
   *  agent re-joins — slots change, targets are re-requested). */
  _ensureCapacity(): boolean {
    if (!this._mesh || !hasNav()) return false
    if (this._id && this._agents.size < this._max) return true
    const max = Math.max(16, this._max * 2)
    const id = _creatorNav.crowdCreate(this._mesh.id, max, Math.max(0.6, this._mesh._maxRadius))
    if (!id) { console.warn(`NavCrowd: ${_creatorNav.lastError()}`); return false }
    const old = this._id
    this._id = id
    this._max = max
    this._buf = new Float32Array(max * NAV_READ.STRIDE)
    if (old) {
      for (const a of this._agents) a._rejoin()
      _creatorNav.crowdDestroy(old)
    }
    return true
  }

  updateFixed(dt: number): void {
    if (!this._id) return
    _creatorNav.crowdUpdate(this._id, dt)
    _creatorNav.crowdRead(this._id, this._buf)
    this._step++
  }

  onDetach(): void {
    if (this._id && hasNav()) _creatorNav.crowdDestroy(this._id)
    this._id = 0
    this._max = 0
    this._agents.clear()
  }
}

export class NavMesh {
  /** True while `lecodes navmesh bake` runs the app — skip menus and build the level straight away. */
  static get baking(): boolean { return bakeConfig() !== null }

  /** The navmesh agents bind to (the last one loaded; one per world at a time). */
  static active: NavMesh | null = null

  private static byScene = new WeakMap<Scene, NavMesh>()
  private static overrides = new Map<number, number | string>()   // entity → area id / name / skip
  private static warned = false
  private static baked = false

  /** Leave a static body out of the bake (`nav: false` in a scene file). */
  static exclude(node: Node): void { NavMesh.overrides.set(node.id, AREA_SKIP) }
  /** A static body nobody may walk on (`nav: 'unwalkable'`): its footprint is cut out. */
  static unwalkable(node: Node): void { NavMesh.overrides.set(node.id, AREA_UNWALKABLE) }
  /** A static body's surface belongs to a named area (`navArea: 'road'`; names from `load`'s `areas`). */
  static area(node: Node, name: string): void { NavMesh.overrides.set(node.id, name) }
  /** Forget every override (a scene rebuild). */
  static clear(): void { NavMesh.overrides.clear() }

  static of(scene: Scene): NavMesh | null { return NavMesh.byScene.get(scene) ?? null }

  /** Load the level's navmesh — or, under `lecodes navmesh bake`, dump the collision geometry for
   *  it. Resolves null when nothing was loaded (no bake yet, a host without navigation, bake mode). */
  static async load(scene: Scene, opts: NavMeshLoadOptions): Promise<NavMesh | null> {
    const bake = bakeConfig()
    if (bake) { NavMesh.bake(bake, opts); return null }
    if (!hasNav()) {
      if (!NavMesh.warned) { NavMesh.warned = true; console.warn("NavMesh: this host has no navigation support — agents stay still") }
      return null
    }
    if (_creatorNav.ready) await _creatorNav.ready()
    let id = 0
    try {
      const resp = await fetch(opts.data, { useOnce: true })
      if (resp.status >= 400) { console.warn(`NavMesh: no navmesh at ${opts.data} (HTTP ${resp.status}) — run \`lecodes navmesh bake\``); return null }
      id = _creatorNav.load((resp as unknown as { _id: number })._id)
      resp.dispose()
    } catch (e) {
      console.warn(`NavMesh: could not read ${opts.data}: ${String(e)}`)
      return null
    }
    if (!id) { console.warn(`NavMesh: ${opts.data}: ${_creatorNav.lastError()}`); return null }
    let info: NavMeshInfo
    try { info = JSON.parse(_creatorNav.header(id)) as NavMeshInfo } catch { info = { version: 0 } as NavMeshInfo }
    if (!info.stats || info.stats.polys === 0) {
      console.warn(`NavMesh: ${opts.data} is a placeholder — run \`lecodes navmesh bake\``)
      _creatorNav.unload(id)
      return null
    }
    const previous = NavMesh.byScene.get(scene)
    if (previous) previous.dispose()
    const nav = new NavMesh(scene, id, info, opts)
    NavMesh.byScene.set(scene, nav)
    NavMesh.active = nav
    return nav
  }

  readonly scene: Scene
  /** @internal the host handle */
  readonly id: number
  readonly info: NavMeshInfo
  /** The agent size the mesh was built for. */
  readonly agent: { radius: number, height: number, climb: number, slope: number }
  /** Named areas → ids (from the bake; `load`'s `areas` when the file has none). */
  readonly areas: Record<string, number>
  /** @internal the largest agent radius seen (sizes the crowd's grid) */
  _maxRadius = 0
  private _debug: Mesh[] = []
  private _seed = 0x9e3779b9
  private _disposed = false

  private constructor(scene: Scene, id: number, info: NavMeshInfo, opts: NavMeshLoadOptions) {
    this.scene = scene
    this.id = id
    this.info = info
    this.agent = info.agent ?? { radius: opts.agent?.radius ?? 0.4, height: opts.agent?.height ?? 1.8, climb: opts.agent?.climb ?? 0.4, slope: opts.agent?.slope ?? 50 }
    this._maxRadius = this.agent.radius
    this.areas = info.meta?.areas ?? NavMesh.areaIds(opts.areas)
    const costs = { ...(info.meta?.costs ?? {}), ...(opts.areas ?? {}) }
    for (const [ name, cost ] of Object.entries(costs)) this.cost(name, cost)
  }

  private static areaIds(areas: Record<string, number> | undefined): Record<string, number> {
    const out: Record<string, number> = {}
    let next = 1
    for (const name of Object.keys(areas ?? {})) {
      if (next > 15) { console.warn(`NavMesh: more than 15 areas — "${name}" ignored`); break }
      out[name] = next++
    }
    return out
  }

  /** The include/exclude flag mask for a list of area names (undefined = every area). */
  flags(names?: string[]): number {
    if (!names) return FLAGS_ALL
    let mask = 0
    for (const n of names) {
      const id = this.areas[n]
      if (id === undefined) { console.warn(`NavMesh: unknown area "${n}"`); continue }
      mask |= 1 << id
    }
    return mask
  }

  private masks(opts?: NavQueryOptions): [number, number] {
    return [ opts?.include ? this.flags(opts.include) : FLAGS_ALL, opts?.exclude ? this.flags(opts.exclude) : 0 ]
  }

  /** Traversal cost of a named area (1 = plain ground; > 1 = avoided when a cheaper way exists). */
  cost(area: string, cost: number): void {
    const id = this.areas[area]
    if (id === undefined) { console.warn(`NavMesh: unknown area "${area}"`); return }
    if (!this._disposed) _creatorNav.setAreaCost(this.id, id, cost)
  }

  /** The straight (string-pulled) path from → to. Empty when an endpoint is off the mesh;
   *  `.partial` when the target is unreachable (the path ends at the closest polygon). */
  findPath(from: Vec3Like, to: Vec3Like, opts?: NavQueryOptions & { maxCorners?: number }): NavPath {
    const path = [] as unknown as NavPath
    path.partial = false
    if (this._disposed) return path
    const [ inc, exc ] = this.masks(opts)
    const e = opts?.extents
    const max = Math.min(opts?.maxCorners ?? 256, 256)
    const n = _creatorNav.findPath(this.id, cx(from), cy(from), cz(from), cx(to), cy(to), cz(to),
      e ? cx(e) : 0, e ? cy(e) : 0, e ? cz(e) : 0, inc, exc, max, pathScratch)
    const count = Math.abs(n)
    for (let i = 0; i < count; i++) path.push(new Vec3(pathScratch[i * 3]!, pathScratch[i * 3 + 1]!, pathScratch[i * 3 + 2]!))
    path.partial = n < 0
    return path
  }

  /** The closest point on the mesh (within `extents` of `p`), or null. */
  nearest(p: Vec3Like, opts?: NavQueryOptions): Vec3 | null {
    if (this._disposed) return null
    const [ inc, exc ] = this.masks(opts)
    const e = opts?.extents
    const ok = _creatorNav.nearest(this.id, cx(p), cy(p), cz(p), e ? cx(e) : 0, e ? cy(e) : 0, e ? cz(e) : 0, inc, exc, scratch)
    return ok ? new Vec3(scratch[0]!, scratch[1]!, scratch[2]!) : null
  }

  /** Can an agent walk straight from → to? `hit` = a wall in between (at `point`, facing `normal`). */
  raycast(from: Vec3Like, to: Vec3Like, opts?: NavQueryOptions): NavRaycastHit {
    if (this._disposed) return { hit: true, point: new Vec3(cx(from), cy(from), cz(from)), normal: new Vec3(0, 0, 0) }
    const [ inc, exc ] = this.masks(opts)
    const hit = _creatorNav.raycast(this.id, cx(from), cy(from), cz(from), cx(to), cy(to), cz(to), inc, exc, scratch)
    return { hit, point: new Vec3(scratch[0]!, scratch[1]!, scratch[2]!), normal: new Vec3(scratch[3]!, scratch[4]!, scratch[5]!) }
  }

  /** A random reachable point — within walking distance `radius` of `center`, or anywhere on the
   *  mesh. Deterministic: the sequence depends only on the calls made (a seeded generator). */
  randomPoint(center?: Vec3Like, radius = 0, opts?: NavQueryOptions): Vec3 | null {
    if (this._disposed) return null
    const [ inc, exc ] = this.masks(opts)
    this._seed = (Math.imul(this._seed, 1664525) + 1013904223) >>> 0
    const c = center && radius > 0
    const ok = _creatorNav.randomPoint(this.id, c ? cx(center) : 0, c ? cy(center) : 0, c ? cz(center) : 0, c ? radius : 0, this._seed || 1, inc, exc, scratch)
    return ok ? new Vec3(scratch[0]!, scratch[1]!, scratch[2]!) : null
  }

  /** Draw the polygon mesh (a flat unlit surface 3 cm above the walkable area). */
  get debug(): boolean { return this._debug.length > 0 }
  set debug(on: boolean) {
    if (on === this.debug || this._disposed) return
    if (!on) { for (const m of this._debug) m.destroy(); this._debug = []; return }
    const tris = _creatorNav.debugTriangles(this.id)
    const material = Material.unlit({ color: "#3b82f6" })
    // Geometry indexes with 16-bit indices: chunk the soup
    const perChunk = 65535 - (65535 % 3)
    for (let start = 0; start < tris.length / 3; start += perChunk) {
      const count = Math.min(perChunk, tris.length / 3 - start)
      const vertices = new Float32Array(count * 3)
      const normals = new Float32Array(count * 3)
      const uv = new Float32Array(count * 2)
      const indices = new Uint16Array(count)
      for (let i = 0; i < count; i++) {
        vertices[i * 3] = tris[(start + i) * 3]!
        vertices[i * 3 + 1] = tris[(start + i) * 3 + 1]! + 0.03
        vertices[i * 3 + 2] = tris[(start + i) * 3 + 2]!
        normals[i * 3 + 1] = 1
        indices[i] = i
      }
      const mesh = new Mesh(new Geometry(vertices, normals, indices, uv), material)
      mesh.name = "navmesh-debug"
      mesh.castShadows = false
      this.scene.add(mesh)
      this._debug.push(mesh)
    }
  }

  /** @internal The scene's crowd system (created on the first agent). */
  _crowd(): NavCrowd {
    const existing = this.scene.get(NavCrowd)
    if (existing && existing._mesh === this) return existing
    const crowd = this.scene.system(NavCrowd).navCrowd
    crowd._mesh = this
    return crowd
  }

  /** Release the host handles (a scene teardown). Agents fall back to inert. */
  dispose(): void {
    if (this._disposed) return
    this._disposed = true
    this.debug = false
    const crowd = this.scene.get(NavCrowd)
    if (crowd && crowd._mesh === this) {
      for (const a of crowd._agents) a._leave()
      this.scene.removeSystem(NavCrowd)
    }
    if (hasNav()) _creatorNav.unload(this.id)
    if (NavMesh.byScene.get(this.scene) === this) NavMesh.byScene.delete(this.scene)
    if (NavMesh.active === this) NavMesh.active = null
  }

  // ---- bake mode ----------------------------------------------------------------------------------

  private static bake(cfg: BakeConfig, opts: NavMeshLoadOptions): void {
    if (NavMesh.baked) { console.log("[navmesh] a second NavMesh.load — skipped (one bake per run; use --entry per scene)"); return }
    NavMesh.baked = true
    if (!_creator.physicsStaticGeometry) {
      console.error("[navmesh] error: this host cannot dump collision geometry (build the desktop host from packages/desktop, or update: lecodes desktop update)")
      return
    }
    const areaIds = NavMesh.areaIds(opts.areas)
    const ids: number[] = []
    const areas: number[] = []
    for (const [ entity, area ] of NavMesh.overrides) {
      let id: number
      if (typeof area === "string") {
        id = areaIds[area] ?? -1
        if (id < 0) { console.warn(`[navmesh] unknown area "${area}" on entity ${entity} — declare it in the navmesh 'areas'`); continue }
      } else id = area
      ids.push(entity)
      areas.push(id)
    }
    const path = `${cfg.outDir}/${cfg.name ?? "scene"}.navgeo`
    const n = _creator.physicsStaticGeometry(path, Uint32Array.from(ids), Uint8Array.from(areas))
    if (n < 0) { console.error(`[navmesh] error: the geometry dump failed (${path})`); return }
    if (n === 0) { console.error("[navmesh] error: no static collision geometry — agents walk on STATIC Physics bodies (Shape + Physics { motion: 'static' })"); return }
    const agent = { radius: opts.agent?.radius ?? 0.4, height: opts.agent?.height ?? 1.8, climb: opts.agent?.climb ?? 0.4, slope: opts.agent?.slope ?? 50 }
    console.log(`[navmesh] geometry ${n} triangles → ${path}`)
    console.log(`[navmesh] config ${JSON.stringify({ agent, areas: areaIds, costs: opts.areas ?? {}, data: opts.data })}`)
    console.log("[navmesh] done")
  }
}
