// A material instance over creator-gl. Uniforms are set through a Proxy that lowers each JS value to
// the right native uniform call. The common materials are static factories (Material.lit/unlit/
// video/shadow); Material.load brings in a custom compiled shader. Ported from worker/src/Material.ts,
// with the Lit/Unlit/... subclasses collapsed into factories and color accepting any ColorInput.

import { Color, type ColorInput } from "../core/color"
import { fetch, type FetchResponse } from "../runtime/fetch"
import { Texture } from "./Texture"
// Type-only (erased at build) so a 3D material never pulls the 2D-canvas runtime; the guard is inline.
import type { Canvas } from "../canvas/Canvas"

/** Duck-type a Canvas without importing it (keeps 3D material free of 2D-canvas code). */
const isCanvasSource = (v: unknown): v is Canvas =>
  typeof v === "object" && v !== null && (v as { __canvas?: unknown }).__canvas === true

type UniformValue = string | Texture | number | boolean | number[] | Float32Array | null

const applyUniform = (id: number, key: string, value: UniformValue): void => {
  if (typeof value === "string") {
    if (value.length > 7) _creator.setUniformRgba(id, key, parseInt(value.slice(1), 16))
    else _creator.setUniformRgb(id, key, parseInt(value.slice(1), 16))
  } else if (typeof value === "number") {
    _creator.setUniformFloat(id, key, value)
  } else if (typeof value === "boolean") {
    _creator.setUniformBoolean(id, key, value)
  } else if (value instanceof Texture) {
    _creator.setUniformTexture(id, key, value._id, value.wrapS, value.wrapT)
  } else if (value instanceof Float32Array) {
    _creator.setUniformArray(id, key, value)
  } else if (Array.isArray(value)) {
    _creator.setUniformArray(id, key, Float32Array.of(...value))
  } else {
    _creator.setUniformNull(id, key)
  }
}

export type MaterialColorOptions = { color?: ColorInput, map?: Texture | Canvas | null }

/** `Material.particles` options — the default point-sprite material for particle systems. */
export type ParticlesMaterialOptions = {
  /** Sprite texture — a single image or a flipbook sheet of frames. Unset = soft round dot. */
  map?: Texture | null
  /** Flipbook grid: `N` = an N×N sheet, `[cols, rows]` = a non-square one. Frames run
   *  left-to-right, top-to-bottom; drive the index via `particles.frame`. Default 1. */
  sheet?: number | [number, number]
  /** Extra brightness multiplier for a glow look (0 = off). */
  emissive?: number
  /** (`render` quad / stretch) a second sheet on the same grid, ADDED as `emissiveMap × emissiveColor`
   *  — keyed by `map`'s alpha, not tinted by the particle colour. A fireball = a smoke `map` whose
   *  colour curve goes dark + a fire `emissiveMap` that keeps glowing while alpha lasts. */
  emissiveMap?: Texture | null
  /** Multiplier for `emissiveMap`; an `[r, g, b]` array may exceed 1 (HDR glow). Default 0 (off). */
  emissiveColor?: ColorInput
  /** (`render` quad / stretch) crossfade each flipbook frame into the next by the fractional frame
   *  index instead of snapping — smooth sheet playback at any frame rate. Default false. */
  frameBlend?: boolean
  /** Compositing: `"alpha"` (default — smoke, dust) or `"add"` (fire, sparks, magic). */
  blend?: "alpha" | "add"
  /** Soft radial falloff toward the sprite edge. Defaults to true without a map, false with one. */
  soft?: boolean
  /** Soft particles: fade each sprite out over this many metres as it cuts into the scene geometry
   *  behind it (Unity's "Soft Particles Factor"), so smoke and dust no longer slice through the
   *  ground and walls with a hard edge. 0 = off (default). Reads the scene's depth buffer, which the
   *  engine keeps bound while any soft system is on screen — the extra depth pass is only paid then.
   *  Filament hosts (desktop / web / Android / Apple); web-lite draws such sprites unfaded. */
  depthFade?: number
  /** How each particle is drawn: `'point'` (default — GPU point sprites, cheapest), `'quad'`
   *  (real camera-facing quads — no driver size cap, geometric rotation), or `'stretch'`
   *  (quads stretched along velocity — sparks, rain, speed streaks). `'ribbon'` is what `Trail`
   *  uses internally. */
  render?: "point" | "quad" | "stretch" | "ribbon"
  /** (`render: 'stretch'`) extra sprite length in world units per unit of speed:
   *  length = size + stretch·|velocity|. Default 0.05. */
  stretch?: number
}

/** @internal — the sheet option as whole [cols, rows], both ≥ 1. The particles shader divides
 *  by these with no guard of its own, so this clamp is the only thing between it and a NaN UV. */
export const _sheetColsRows = (sheet: number | [number, number] | undefined): [number, number] => {
  const [ c, r ] = typeof sheet === "number" ? [ sheet, sheet ] : sheet ?? [ 1, 1 ]
  return [ Math.max(1, Math.floor(c) || 1), Math.max(1, Math.floor(r) || 1) ]
}

/** `Material.lit` options — PBR scalars on top of the color/map pair. */
export type LitMaterialOptions = MaterialColorOptions & {
  /** Perceptual roughness, 0 (mirror) … 1 (matte). Unset = the shader's default. */
  roughness?: number
  /** Metallic factor, 0 (dielectric) … 1 (metal). Unset = the shader's default. */
  metallic?: number
}

export class Material {
  /** @internal native material-instance handle. */
  _id: number
  readonly shader: FetchResponse | "unknown"
  readonly uniforms: Record<string, UniformValue>

  private _colorKey: string | null = null
  private _useMapFlag = false

  constructor(shader: FetchResponse | number) {
    if (typeof shader === "number") {
      this.shader = "unknown"
      this._id = shader
    } else {
      this.shader = shader
      this._id = _creator.createMaterial((shader as any)._id)
    }
    const store: Record<string, UniformValue> = {}
    const id = () => this._id
    this.uniforms = new Proxy(store, {
      get: (t, k: string) => t[k],
      set: (t, k: string, v: UniformValue) => { t[k] = v; applyUniform(id(), k, v); return true },
    })
  }

  /** Set a uniform (chainable). */
  set(key: string, value: UniformValue): this {
    this.uniforms[key] = value
    return this
  }

  set color(c: ColorInput) {
    if (this._colorKey) this.uniforms[this._colorKey] = Color.toHexString(c)
  }

  // lit/unlit shade baseColor × baseColorMap (factor×map; engines default both to white), so
  // removing the map means resetting baseColorMap to the white 1×1. The useBaseColorMap writes
  // stay for older hosts whose shaders still branch on the flag — current engines ignore it.
  set map(value: Texture | Canvas | null) {
    if (value === null) {
      this.uniforms.baseColorMap = null
      if (this._useMapFlag) this.uniforms.useBaseColorMap = false
    } else {
      // A Canvas bakes to a (cached) 3D texture on assign — same as Sprite accepting a Canvas in 2D.
      this.uniforms.baseColorMap = isCanvasSource(value) ? Texture.fromCanvas(value) : value
      if (this._useMapFlag) this.uniforms.useBaseColorMap = true
    }
  }

  // --- factories ---

  /** PBR lit material. */
  static lit(options: LitMaterialOptions = {}): Material {
    // The fetchLocal literal must be inline so the compiler's preload header captures the shader name.
    const m = new Material({ _id: _creatorUtils.fetchLocal("lit.filamat") } as any)
    m._colorKey = "baseColor"
    if (options.color !== undefined) m.color = options.color
    if (options.map) m.map = options.map
    if (options.roughness !== undefined) m.uniforms.roughness = options.roughness
    if (options.metallic !== undefined) m.uniforms.metallic = options.metallic
    return m
  }

  /** Unlit (flat) material. */
  static unlit(options: MaterialColorOptions = {}): Material {
    const m = new Material({ _id: _creatorUtils.fetchLocal("unlit.filamat") } as any)
    m._colorKey = "baseColor"
    if (options.color !== undefined) m.color = options.color
    if (options.map) m.map = options.map
    return m
  }

  /** Default particle material — camera-facing point sprites with tint/opacity/rotation/flipbook
   *  driven by the particle curves. Uniforms all default to 0 on a fresh instance, so every look
   *  knob is primed here; JS writes after construction override them. */
  static particles(options: ParticlesMaterialOptions = {}): Material {
    const quad = options.render !== undefined && options.render !== "point"
    // Both fetchLocal literals inline so the compiler's preload header captures the shader names.
    const m = quad
      ? new Material({ _id: _creatorUtils.fetchLocal("particles-quad.filamat") } as any)
      : new Material({ _id: _creatorUtils.fetchLocal("particles.filamat") } as any)
    m._useMapFlag = false
    m.uniforms.sheetSize = _sheetColsRows(options.sheet)
    m.uniforms.emissive = options.emissive ?? 0
    m.uniforms.additive = options.blend === "add" ? 1 : 0
    m.uniforms.softness = (options.soft ?? !options.map) ? 1 : 0
    m.uniforms.depthFade = options.depthFade ?? 0
    if (quad) {
      m.uniforms.mode = options.render === "ribbon" ? 2 : options.render === "stretch" ? 1 : 0
      m.uniforms.stretch = options.stretch ?? (options.render === "stretch" ? 0.05 : 0)
      if (options.emissiveMap) m.uniforms.emissiveMap = options.emissiveMap
      if (options.emissiveColor !== undefined) m.uniforms.emissiveColor = Color.toRgb01(options.emissiveColor)
      if (options.frameBlend) m.uniforms.frameBlend = 1
    }
    if (options.map) m.map = options.map
    return m
  }

  /** Material that samples a VideoPlayer's texture. */
  static video(map?: Texture): Material {
    const m = new Material({ _id: _creatorUtils.fetchLocal("video.filamat") } as any)
    m._useMapFlag = false
    if (map) m.uniforms.baseColorMap = map
    return m
  }

  /** The lightmap material (docs/lightmap-plan.md): PBR base colour × a baked shadow/AO atlas on UV1.
   *  Models take it through `Model.load(…, { lightmap: true })`; `Lightmap.load` builds one per static
   *  Mesh. Parameters use gltfio's names (`baseColorFactor`, `baseColorMap`, `roughnessFactor`,
   *  `metallicFactor`) plus `lightmap`, `lightmapST`, `ambientScale`, `sunStrength` (the sun / IBL terms come from
   *  filament's per-frame uniforms inside the shader). */
  static lightmap(): Material {
    // Inline literal: the compiler's preload header captures the shader name from it.
    const m = new Material({ _id: _creatorUtils.fetchLocal("lightmap.filamat") } as any)
    m.set("baseColorFactor", "#ffffffff").set("roughnessFactor", 1).set("metallicFactor", 0)
    m.set("lightmapST", [ 1, 1, 0, 0 ]).set("ambientScale", 1).set("sunStrength", 1).set("hasNormalMap", 0)
    m.set("probeMin", [ 0, 0, 0, 0 ]).set("probeInvSize", [ 0, 0, 0, 0 ])
    return m
  }

  /** The terrain splat material (docs/terrain-plan.md §1.5): four albedo (+ normal-map) layers blended by
   *  a control map on the terrain's own grid, per-layer `tiling` (metres per repeat) / `roughness` /
   *  `normalScale` / `triplanar`, lightmap-aware (the same `lightmap` / `lightmapST` block as
   *  `Material.lightmap`). `Terrain` builds and owns one per terrain; the uniforms are primed here so an
   *  unset layer is white and an unbaked terrain is fully lit. */
  static terrain(): Material {
    // Inline literal: the compiler's preload header captures the shader name from it.
    const m = new Material({ _id: _creatorUtils.fetchLocal("terrain.filamat") } as any)
    m.set("tiling", [ 8, 8, 8, 8 ]).set("roughness", [ 1, 1, 1, 1 ]).set("normalScale", [ 0, 0, 0, 0 ]).set("triplanar", [ 0, 0, 0, 0 ])
    m.set("gridSize", [ 2, 2 ]).set("cellSize", 1).set("tint", "#ffffff")
    m.set("lightmapST", [ 1, 1, 0, 0 ]).set("ambientScale", 1).set("sunStrength", 1)
    return m
  }

  /** @internal the shared template instance `Model.load({ lightmap })` hands the engine (its MATERIAL is what
   *  the GLB provider instantiates; the template itself never draws). */
  static _lightmapTemplate(): Material { return (Material._lmTemplate ??= Material.lightmap()) }
  private static _lmTemplate: Material | undefined

  /** Shadow-catcher material (transparent except where shadows fall). */
  static shadow(color: ColorInput = "#000000aa"): Material {
    const m = new Material({ _id: _creatorUtils.fetchLocal("shadow.filamat") } as any)
    m._colorKey = "color"
    m.color = color
    return m
  }

  /** Load a custom compiled shader (.mat URL) as a material. */
  static load(url: string): Promise<Material> {
    // "id:<n>" — the artifact is a LOCAL file the host already resolved through fetchLocal (a
    // compile with no asset server: the desktop host reads it from next to the bundle). There is
    // nothing to fetch, so the material is ready synchronously; -1 means the host found no such
    // file, which would otherwise reach createMaterial as a bad handle.
    if (url.startsWith("id:")) {
      const id = Number(url.slice(3))
      if (!(id >= 0)) {
        return Promise.reject(new Error(`Shader not found next to the app (${url}). Compile it with \`lecodes shaders compile\`.`))
      }
      return Promise.resolve(new Material({ _id: id } as any))
    }
    return fetch(url, { useOnce: true }).then((resp) => {
      if (resp.status >= 400) return Promise.reject(new Error(`Failed to fetch shader from ${url}. HTTP ${resp.status}`))
      return new Material(resp)
    })
  }

  /** @internal */
  static idOf(material: Material): number { return material._id }
}
