// Baked shadows for static 3D geometry (docs/lightmap-plan.md). A scene FILE does all of this by
// itself: `env: { lightmap: { data, texture } }` marks the level as baked, every model/mesh node
// that no Physics aspect moves is a static (keys = node paths, `lightmap: true|false` overrides),
// and the runtime applies the bake when the level finishes loading (scene/defineScene.ts).
// Hand-built scenes register what never moves with `Lightmap.add`, bake once with
// `lecodes lightmap bake` (the desktop host traces the live scene and writes lightmap.ktx2 +
// lightmap.bake), and `Lightmap.load` applies the atlas at runtime:
//
//   const crate = await Model.load(asset('./assets/props/crate.glb'), { lightmap: true })
//   Lightmap.add(holder)                    // a Model, a Mesh, or a node holding them
//   Lightmap.add(ground)                    // Mesh.plane works too
//   ...
//   await Lightmap.load(scene, { data: asset('./assets/lightmap/lightmap.bake'),
//                                texture: asset('./assets/lightmap/lightmap.ktx2') })
//
// Both files are asset() handles because the compiler stages exactly the files it sees (the data
// file is JSON with a .bake extension — a .json would be scanned as a code module). `load` is a
// no-op when the bake is missing or the host predates the feature, so the project stays runnable —
// and in BAKE mode (`lecodes lightmap bake`) it is where the bake fires: the same call marks "the
// scene is complete". Atlas channels: R = sun visibility, G = ambient occlusion; the shader keeps
// the real-time sun for dynamic casters and multiplies the baked mask in with an ambient floor.
//
// DYNAMIC objects (docs/lightmap-plan.md §9): the bake also writes lightmap.volume — the same two
// channels on a 3D grid over the level. Pass it as `volume` and every model loaded with
// `{ lightmap: 'dynamic' }` (the default while a scene file with `env.lightmap.volume` runs) plus
// every dynamic Mesh a scene file registers samples it per pixel: a crate rolled under the roof
// loses the sun like the floor does, a character near a wall picks up its ambient occlusion.

import { fetch } from "../runtime/fetch"
import { Texture } from "./Texture"
import { Material } from "./Material"
import { Mesh } from "./Mesh"
import { Model } from "./Model"
import { Node } from "./Node"
import type { Scene } from "./Scene"

export type LightmapLoadOptions = {
  /** 1 = baked sun shadows at full strength, 0 = ambient occlusion only. Default 1. */
  sunStrength?: number
  /** Multiplier on the ambient (IBL) share in the shadow math — how bright a fully shadowed texel
   *  stays. 1 reproduces filament's own real-time shadow darkness (the shader reads the scene's sun
   *  and IBL from filament's per-frame uniforms); raise it for lighter shadows, lower for deeper. Default 1. */
  ambientScale?: number
}

export type LightmapInfo = {
  size: number
  texel: number
  /** The light volume, when one was loaded: grid dims + cell size in metres. */
  volume?: { dims: number[], cell: number }
  /** keys applied / keys in the file / registered statics without a rect */
  applied: number
  total: number
  missing: string[]
}

import type { Terrain } from "./Terrain"

type Entry = { key: string, model?: Model, mesh?: Mesh, terrain?: Terrain }

const terrainOf = (n: Node): Terrain | null => (n as unknown as { _terrain?: Terrain })._terrain ?? null

type BakeConfig = {
  outDir: string, size?: number, texel?: number, sunRays?: number, aoRays?: number,
  aoDistance?: number, bias?: number, sunAngle?: number, volumeCell?: number,
}

type VolumeHeader = { texture: number, dims: number[], min: number[], cell: number }

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

/** A hex colour as the 9-char form the float4 uniform path expects ("#rrggbbaa"). */
const hex8 = (c: unknown): string => {
  if (typeof c !== "string") return "#ffffffff"
  if (c.length === 7) return c + "ff"
  if (c.length === 4) return "#" + c[1] + c[1] + c[2] + c[2] + c[3] + c[3] + "ff"
  return c
}

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

  private static entries: Entry[] = []
  private static dynamics: Mesh[] = []
  private static ordinals = new Map<string, number>()
  private static warned = false


  /** Register static geometry — a Model, a Mesh, or any node whose subtree holds them. Statics are
   *  both receivers and occluders in the bake. `key` names the entry in lightmap.bake (default:
   *  the node's name + a running number, `container#3`); pass one when names are not stable. */
  static add(node: Node, key?: string): Node {
    const base = key ?? `${node.name || "node"}#${Lightmap.next(node.name || "node")}`
    const found: Entry[] = []
    const walk = (n: Node): void => {
      const t = terrainOf(n)
      if (t) { found.push({ key: base, terrain: t }); return }   // one receiver, its chunks are internal
      if (n instanceof Model) { found.push({ key: base, model: n }); return }
      if (n instanceof Mesh) { found.push({ key: base, mesh: n }); return }
      for (const c of n.children) walk(c)
    }
    walk(node)
    if (found.length === 0) { console.warn(`Lightmap.add: ${base} holds no Model or Mesh`); return node }
    if (found.length > 1) found.forEach((e, i) => { e.key = `${base}/${i}` })
    Lightmap.entries.push(...found)
    return node
  }

  /** @internal Scene-file runtime: one model/mesh node under its path key (no subtree walk). */
  static _register(node: Model | Mesh, key: string): void {
    Lightmap.entries.push(node instanceof Model ? { key, model: node } : { key, mesh: node })
  }

  /** @internal Scene-file runtime: a terrain node (its own material carries the atlas, no swap). */
  static _registerTerrain(terrain: Terrain, key: string): void {
    Lightmap.entries.push({ key, terrain })
  }

  /** @internal Scene-file runtime: a dynamic Mesh — swapped to the lightmap material on the volume
   *  when `load` runs (dynamic Models need nothing here: the engine binds the volume itself). */
  static _registerDynamic(mesh: Mesh): void {
    Lightmap.dynamics.push(mesh)
  }

  private static next(name: string): number {
    const n = Lightmap.ordinals.get(name) ?? 0
    Lightmap.ordinals.set(name, n + 1)
    return n
  }

  /** Forget every registration (a scene rebuild). */
  static clear(): void {
    Lightmap.entries = []
    Lightmap.dynamics = []
    Lightmap.ordinals.clear()
    Model._lightmapDefault = false
    _creator.lightmapVolumeSet?.(0xFFFFFFFF, 0, 0, 0, 0, 0, 0, 0, 1, 1)
  }

  /** Apply a bake — or, under `lecodes lightmap bake`, run it. Resolves to null when nothing was
   *  applied (no bake yet, a host without the feature, bake mode). */
  static async load(scene: Scene, files: { data: string, texture: string, volume?: string }, options: LightmapLoadOptions = {}): Promise<LightmapInfo | null> {
    const bake = bakeConfig()
    if (bake) { Lightmap.bake(bake); return null }
    if (!_creator.lightmapApply) {
      if (!Lightmap.warned) { Lightmap.warned = true; console.warn("Lightmap: this host has no lightmap support — rendering without the bake") }
      return null
    }
    let data: { size: number, texel: number, sun?: number[] | null, instances: { key: string, receiver: boolean, st?: number[] }[] }
    try {
      const resp = await fetch(files.data, { useOnce: true })
      if (resp.status >= 400) { console.warn(`Lightmap: no bake at ${files.data} (HTTP ${resp.status}) — run \`lecodes lightmap bake\``); return null }
      data = resp.json()
      resp.dispose()
    } catch (e) {
      console.warn(`Lightmap: could not read ${files.data}: ${String(e)}`)
      return null
    }
    const texture = await Texture.load(files.texture)
    const ambientScale = options.ambientScale ?? 1
    const strength = options.sunStrength ?? 1
    // the light volume first: it claims every lightmap-material instance, the atlas rects below win back the statics
    const volume = files.volume ? await Lightmap.loadVolume(files.volume, strength, ambientScale) : null
    const rects = new Map<string, number[]>()
    for (const inst of data.instances) if (inst.receiver && inst.st) rects.set(inst.key, inst.st)
    let applied = 0
    const missing: string[] = []
    for (const e of Lightmap.entries) {
      const st = rects.get(e.key)
      if (!st) { missing.push(e.key); continue }
      if (e.model) {
        const n = _creator.lightmapApply(e.model.id, texture._id, st[0], st[1], st[2], st[3], ambientScale, strength)
        if (n === 0) { console.warn(`Lightmap: ${e.key} was not loaded with { lightmap: true } — skipped`); continue }
        e.model.castShadows = false
      } else if (e.mesh) {
        Lightmap.swapMeshMaterial(e.mesh, texture, st, ambientScale, strength)
        e.mesh.castShadows = false
      } else if (e.terrain) {
        // terrain.mat carries lightmap.mat's atlas block: bind the rect on the terrain's own material
        e.terrain.material.set("lightmap", texture).set("lightmapST", [ st[0], st[1], st[2], st[3] ])
          .set("ambientScale", ambientScale).set("sunStrength", strength)
        e.terrain.castShadows = false
      }
      applied++
    }
    if (missing.length) console.warn(`Lightmap: ${missing.length} static(s) have no rect in the bake (${missing.slice(0, 5).join(", ")}${missing.length > 5 ? "…" : ""}) — rebake`)
    const info: LightmapInfo = { size: data.size, texel: data.texel, applied, total: rects.size, missing }
    if (volume) info.volume = { dims: volume.dims, cell: volume.cell }
    return info
  }

  /** lightmap.volume → the engine's 3D texture → THE volume for every dynamic lightmap-material instance,
   *  plus the registered dynamic Meshes. Null when the file is a placeholder or the host lacks the feature. */
  private static async loadVolume(url: string, strength: number, ambientScale: number): Promise<VolumeHeader | null> {
    if (!_creator.lightmapVolumeLoad || !_creator.lightmapVolumeSet) return null
    let header: VolumeHeader
    try {
      const resp = await fetch(url, { useOnce: true })
      if (resp.status >= 400) { console.warn(`Lightmap: no volume at ${url} (HTTP ${resp.status}) — run \`lecodes lightmap bake\``); return null }
      const json = _creator.lightmapVolumeLoad((resp as unknown as { _id: number })._id)
      resp.dispose()
      if (!json) { console.warn(`Lightmap: ${url} is not a light volume — rebake`); return null }
      header = JSON.parse(json) as VolumeHeader
    } catch (e) {
      console.warn(`Lightmap: could not read ${url}: ${String(e)}`)
      return null
    }
    const [ nx, ny, nz ] = header.dims
    if (nx * ny * nz <= 1) return null   // the CLI's placeholder: nothing baked yet
    const size = [ nx * header.cell, ny * header.cell, nz * header.cell ]
    const texture = new Texture(nx, ny, header.texture)
    _creator.lightmapVolumeSet(header.texture, header.min[0], header.min[1], header.min[2], size[0], size[1], size[2], header.cell, strength, ambientScale)
    for (const mesh of Lightmap.dynamics) {
      const m = Lightmap.meshMaterial(mesh, ambientScale, strength)
      m.set("probeVolume", texture)
      m.set("probeMin", [ header.min[0], header.min[1], header.min[2], header.cell * 0.5 ])
      m.set("probeInvSize", [ 1 / size[0], 1 / size[1], 1 / size[2], 1 ])
      mesh.setMaterial(m)
    }
    return header
  }

  /** A Mesh keeps its look (colour / map / roughness / metallic) but moves to the lightmap material. */
  private static meshMaterial(mesh: Mesh, ambientScale: number, strength: number): Material {
    const src = mesh.material
    const u = src?.uniforms ?? {}
    const m = Material.lightmap()
    m.set("baseColorFactor", hex8(u.baseColor))
    if (u.baseColorMap instanceof Texture) m.set("baseColorMap", u.baseColorMap)
    if (typeof u.roughness === "number") m.set("roughnessFactor", u.roughness)
    if (typeof u.metallic === "number") m.set("metallicFactor", u.metallic)
    m.set("ambientScale", ambientScale)
    m.set("sunStrength", strength)
    return m
  }

  private static swapMeshMaterial(mesh: Mesh, texture: Texture, st: number[], ambientScale: number, strength: number): void {
    const m = Lightmap.meshMaterial(mesh, ambientScale, strength)
    m.set("lightmap", texture)
    m.set("lightmapST", [ st[0], st[1], st[2], st[3] ])
    mesh.setMaterial(m)
  }

  private static bake(cfg: BakeConfig): void {
    if (!_creator.lightmapBake) { console.error("[lightmap] error: this host has no bake module (build the desktop host with CREATOR_GL_LIGHTMAP, or update lecodes desktop)"); return }
    const ids = Uint32Array.from(Lightmap.entries.map((e) => (e.model ?? e.mesh ?? e.terrain!.node).id))
    const keys = Lightmap.entries.map((e) => e.key).join("\n")
    if (ids.length === 0) { console.error("[lightmap] error: nothing registered — call Lightmap.add on the static props before Lightmap.load"); return }
    _creator.lightmapBake(cfg.outDir, ids, keys, cfg.size ?? 4096, cfg.texel ?? 0.02, cfg.sunRays ?? 32, cfg.aoRays ?? 64,
      cfg.aoDistance ?? 2, cfg.bias ?? 0.02, cfg.sunAngle ?? 0.5, cfg.volumeCell ?? 0.5)
  }
}
