// Materials as data — the `material:` grammar of scene files and the `defineMaterial` document
// behind `*.material.ts` assets (docs/scene-materials-plan.md).
//
//   // materials/holo-glass.material.ts
//   export default defineMaterial({
//     shader: asset('../assets/shaders/lens.mat'),
//     params: { tint: '#1d2b26', opacity: 0.22, mask: asset('../assets/weapons/reticle.png') },
//   })
//
//   // range.scene.ts
//   import holoGlass from './materials/holo-glass.material'
//   lens: { mesh: { kind: 'plane' }, material: holoGlass },
//
// One grammar serves the inline form (`material: { lit: {…} }` / `{ shader, params }` on a node),
// the asset form (the handle) and GLB-part overrides. A handle is ONE shared GPU material: every
// node using the asset renders the same instance, and editing the asset changes all of them
// (Unity's rule — two looks are two assets).
//
// Parameter values need no shader metadata at runtime: a string is a colour when it starts with
// `#`, otherwise it is a texture URL (`asset('./x.png')` compiles to one) — the same lowering
// `Material.set` already applies to colours.

import type { ColorInput } from "../core/color"
import { Material, type LitMaterialOptions, type MaterialColorOptions } from "../gl/Material"
import { Texture } from "../gl/Texture"
import type { Node } from "../gl/Node"
import { EDIT_FLAG } from "./grammar"

/** A shader parameter value as scene files write it: number, boolean, `#colour`, texture URL
 *  (`asset('./x.png')`), or a numeric vector. */
export type MaterialParamValue = number | boolean | string | number[]

type WithMapUrl<T> = Omit<T, "map"> & {
  /** Base-colour texture — a `Texture`, a `Canvas`, or `asset('./x.png')` (a URL string). */
  map?: LitMaterialOptions["map"] | string
}

export type LitMaterialDef = WithMapUrl<LitMaterialOptions>
export type UnlitMaterialDef = WithMapUrl<MaterialColorOptions>

/** A custom Filament shader (`asset('./x.mat')` — compiled by the platform / `lecodes shaders`)
 *  plus its parameter values. */
export type ShaderMaterialDef = {
  shader: string
  params?: Record<string, MaterialParamValue>
}

export type MaterialDef =
  | { lit: LitMaterialDef }
  | { unlit: UnlitMaterialDef }
  | { shadow: ColorInput }
  | ShaderMaterialDef
  /** A material asset (`import m from './x.material'`) — one shared instance. */
  | MaterialHandle
  /** A code-level Material instance — valid at runtime; the editor shows it read-only. */
  | Material

/** The data forms of MaterialDef (everything but a live instance / handle). */
export type MaterialData = Exclude<MaterialDef, MaterialHandle | Material>

const isTextureUrl = (v: unknown): v is string => typeof v === "string" && !v.startsWith("#")

/** Push one parameter into a live material. Textures load asynchronously — the returned promise
 *  settles once the value is applied (immediately for scalars). */
export const applyMaterialParam = (material: Material, name: string, value: MaterialParamValue | null): Promise<void> => {
  if (isTextureUrl(value)) {
    return Texture.load(value).then((tex) => { material.set(name, tex) }, (e) => {
      console.warn(`[material] "${name}": ${e instanceof Error ? e.message : String(e)}`)
    })
  }
  material.set(name, value as never)
  return Promise.resolve()
}

/** Build a live Material from a data def. Resolves once every texture it names has loaded. */
export const createMaterialFromData = async (def: MaterialData): Promise<Material> => {
  if ("shader" in def) {
    const m = await Material.load(def.shader)
    await Promise.all(Object.entries(def.params ?? {}).map(([ k, v ]) => applyMaterialParam(m, k, v)))
    return m
  }
  if ("shadow" in def) return Material.shadow(def.shadow)
  const lit = "lit" in def
  const opts = lit ? def.lit : def.unlit
  const { map, ...rest } = opts
  const m = lit ? Material.lit(rest as LitMaterialOptions) : Material.unlit(rest as MaterialColorOptions)
  if (isTextureUrl(map)) m.map = await Texture.load(map)
  else if (map !== undefined) m.map = map as LitMaterialOptions["map"] as Texture | null
  return m
}

/**
 * A material asset: the default export of a `*.material.ts` file. `load()` yields ONE shared
 * `Material` (cached); scene files hand the handle to `material:` and the loader resolves it.
 * The editor edits the asset live through `setParam` / `reset` — every node the handle was
 * applied to follows (`_users`).
 */
export class MaterialHandle {
  def: MaterialData
  private _instance: Material | null = null
  private _loading: Promise<Material> | null = null
  /** Every (node, slot) this handle was applied to through a scene — the editor re-assigns them
   *  when the asset's shader changes. */
  readonly _users = new Set<{ node: Node, slot: number }>()

  constructor(def: MaterialData) {
    this.def = def
  }

  /** The shared material instance (built on first use). */
  load(): Promise<Material> {
    if (this._instance) return Promise.resolve(this._instance)
    this._loading ??= createMaterialFromData(this.def).then((m) => { this._instance = m; return m })
    return this._loading
  }

  /** The live instance, if built yet. */
  get material(): Material | null { return this._instance }

  /** Editor: assign this material to `node`'s `slot`, remembering the user. */
  async _applyTo(node: Node, slot: number): Promise<Material> {
    const m = await this.load()
    node.setMaterial(m, slot)
    this._users.add({ node, slot })
    return m
  }

  /** Editor: one parameter value, applied live to the shared instance (a texture loads in). */
  setParam(name: string, value: MaterialParamValue | null): void {
    const def = this.def
    if ("shader" in def) {
      def.params ??= {}
      if (value === null) delete def.params[name]
      else def.params[name] = value
    } else if ("lit" in def || "unlit" in def) {
      const opts = ("lit" in def ? def.lit : def.unlit) as Record<string, unknown>
      if (value === null) delete opts[name]
      else opts[name] = value
    }
    const m = this._instance
    if (!m) return
    if (!("shader" in def) && name === "color") { m.color = value as string; return }
    if (!("shader" in def) && name === "map") {
      if (isTextureUrl(value)) void Texture.load(value).then((t) => { m.map = t })
      else m.map = null
      return
    }
    void applyMaterialParam(m, name, value)
  }

  /** Editor: replace the whole definition (a shader/kind switch) — rebuilds the instance and
   *  re-assigns every user. */
  async reset(def: MaterialData): Promise<Material> {
    this.def = def
    this._instance = null
    this._loading = null
    const m = await this.load()
    for (const u of this._users) u.node.setMaterial(m, u.slot)
    return m
  }
}

/** Resolve any `material:` value to a live Material (a handle's shared instance, a data def's
 *  fresh one, a code instance as is). */
export const resolveMaterialDef = (def: MaterialDef): Promise<Material> => {
  if (def instanceof Material) return Promise.resolve(def)
  if (def instanceof MaterialHandle) return def.load()
  return createMaterialFromData(def)
}

/** Resolve and assign a `material:` value to one slot of a node (tracking handle users). */
export const assignMaterialDef = async (node: Node, slot: number, def: MaterialDef): Promise<Material> => {
  if (def instanceof MaterialHandle) return def._applyTo(node, slot)
  const m = await resolveMaterialDef(def)
  node.setMaterial(m, slot)
  return m
}

/**
 * Define a material as data — the default export of a `*.material.ts` file. Import it into scene
 * files (`material: handle`) or code (`const m = await handle.load()`).
 */
export const defineMaterial = (def: MaterialData): MaterialHandle => {
  const handle = new MaterialHandle(def)
  const g = globalThis as unknown as { [EDIT_FLAG]?: boolean, __lecodesMaterialHandles?: MaterialHandle[] }
  if (g[EDIT_FLAG]) (g.__lecodesMaterialHandles ??= []).push(handle)
  return handle
}
