// 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
  /** 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
  /** 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 = true

  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
    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.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
  }

  /** 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> {
    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 }
}
