// 2D scenes as data: the runtime behind `.scene2d.ts` files (docs/scene2d-editor-plan.md §4).
// The 2D sibling of scene/defineScene.ts — same literal grammar (one default-exported
// `defineScene2d({...})` call, nodes keyed by name, `use(...)` aspects, `make(...)` factories),
// with the 2D source vocabulary: `sprite` (a named entry of an imported `.sprite.ts` sheet),
// `tilemap` (texture + grid + a `cells('…')` payload), `camera`, `make`, or none = group.
//
//   // forest.scene2d.ts
//   import props from './props.sprite'
//   import hero from './hero.sprite'
//   export default defineScene2d({
//     env: { background: '#5a6b3b', pixelPerfect: true, gravity: [0, 0] },
//     layers: { 1: { ySort: true } },
//     nodes: {
//       ground: { tilemap: { texture: asset('./TileSet.png'), tile: 64, atlas: [64, 64],
//                            cells: cells('…') }, layer: 0 },
//       tree1:  { sprite: { sheet: props, name: 'tree_orange' }, position: [160, 288], layer: 1 },
//       player: { sprite: { sheet: hero, clip: 'idle' }, position: [800, 200], layer: 1,
//                 aspects: [use(CameraFollow, { smooth: 0.12 })] },
//     },
//   })
//
//   // main.ts
//   import forest from './forest.scene2d'
//   const { scene, nodes } = await forest.open()
//
// Colliders and bodies are SCENE semantics (sheets are art only): give a node `aspects:
// [use(Shape2D, {…}), use(Physics2D, {…})]`. Aspects attach normally in play mode; in edit mode
// (`globalThis.__lecodesSceneEdit`) they are held as data, so nothing falls or fires while
// editing — clip playback still runs (render-side).
// Prefabs (scene-in-scene) are a later phase — not in this grammar yet.

import type { ColorInput } from "../core/color"
import type { Vec2Like } from "../math/vec"
import {
  EDIT_FLAG, isEditMode, resolveRefs, type AspectEntry, type MakeEntry as SharedMakeEntry,
} from "../scene/grammar"
import { Scene2D } from "./Scene2D"
import { Node2D } from "./Node2D"
import { Sprite } from "./Sprite"
import { Tilemap } from "./Tilemap"
import { Texture2D } from "./Texture2D"
import { Physics2D } from "./Physics2D"
import { SpriteSheet } from "./SpriteSheet"
import { Tileset, Autotile2D } from "./Tileset"
import type { SpriteAnimation } from "./SpriteAnimation"
import type { CellsData } from "./cells"
import type { With } from "../core/Aspect"

// ---- the literal grammar (what the visual editor reads and writes) -----------

export type Scene2dEnv = {
  /** Background color — '#5a6b3b', 0x5a6b3b, or [r,g,b] in 0..1. */
  background?: ColorInput
  /** Texture sampling: 'nearest' (crisp pixel art, engine default) or 'linear'. */
  filter?: "nearest" | "linear"
  /** Pixel-art crispness: snap the camera to whole screen pixels (seeds CameraFollow's
   *  `pixelSnap`, and the initial camera position lands on whole world units). */
  pixelPerfect?: boolean
  /** Physics world gravity (Y-up: down is negative). Presence configures the world before any
   *  body is created — `[0, 0]` for top-down worlds. Omit to leave the world unconfigured. */
  gravity?: Vec2Like
}

/** A sprite node: one named entry of an imported `.sprite.ts` sheet. The sheet is ART only —
 *  colliders/bodies are scene semantics: put Shape2D / Physics2D / Trigger2D in `aspects`. */
export type SpriteSourceDef = {
  /** The imported sheet handle: `import props from './props.sprite'` … `sheet: props`. */
  sheet: SpriteSheet
  /** The entry name; omit when the sheet declares exactly one sprite. */
  name?: string
  /** Start this clip immediately (sliced entries with clips). */
  clip?: string
  /** Initial facing for `perDirection` clips. */
  direction?: string
}

export type TilemapSourceDef = {
  /** The tileset image — `asset('./TileSet.png')`. */
  texture: string
  /** On-screen cell size in world units — a number (square) or [w, h]. */
  tile: number | [number, number]
  /** Atlas grid: how the texture slices into tiles, [atlasCols, atlasRows]. */
  atlas: [number, number]
  /** The map: a `cells('…')` payload — or any `{ cols, rows, data }` of your own. */
  cells: CellsData | { cols: number, rows: number, data: number[] | Int32Array }
}

/** A camera marker node: seeds the scene camera's starting position (+ zoom). To MOVE the camera
 *  in play, attach `CameraFollow` to the node being followed — not aspects on this marker. */
export type Camera2dNodeDef = { zoom?: number }

export type Scene2dNodeDef = {
  // -- source (at most one; none = plain group node) --
  sprite?: SpriteSourceDef
  tilemap?: TilemapSourceDef
  camera?: Camera2dNodeDef
  /** A code-built subtree — `make(factoryFn, { ...literal args })`, factory returns a Node2D. */
  make?: SharedMakeEntry<any, Node2D>
  // -- transform / render --
  position?: Vec2Like
  /** Degrees, CCW. */
  rotation?: number
  scale?: Vec2Like | number
  /** Draw layer (int) — higher renders on top. */
  layer?: number
  /** Intra-layer depth for non-Y-sorted layers. */
  z?: number
  /** Sprite tint (sprite nodes only). */
  color?: ColorInput
  /** Sprite opacity 0..1 (sprite nodes only). */
  opacity?: number
  flipX?: boolean
  flipY?: boolean
  visible?: boolean
  /** Editor-only: the move gizmo won't target this node (fields still edit). No runtime effect. */
  locked?: boolean
  // -- capabilities / hierarchy --
  aspects?: readonly AspectEntry<any>[]
  children?: Record<string, Scene2dNodeDef>
}

export type Scene2dCameraDef = {
  position?: Vec2Like
  zoom?: number
}

export type Scene2dDef = {
  env?: Scene2dEnv
  /** Per-layer config, keyed by layer number: `layers: { 1: { ySort: true } }`. */
  layers?: Record<number | string, { ySort?: boolean }>
  /** Starting camera when there's no camera NODE (a camera node wins). */
  camera?: Scene2dCameraDef
  nodes?: Record<string, Scene2dNodeDef>
}

// ---- typed handle -------------------------------------------------------------

type SourceNodeOf<N extends Scene2dNodeDef> =
  N extends { sprite: { clip: string } } ? Sprite & { anim: SpriteAnimation }
  : N extends { sprite: SpriteSourceDef } ? Sprite
  : N extends { tilemap: TilemapSourceDef } ? Tilemap
  : Node2D

type AspectsOf<N extends Scene2dNodeDef> =
  N extends { aspects: readonly AspectEntry<infer A>[] } ? A : never

type NodeOf<N extends Scene2dNodeDef> =
  [AspectsOf<N>] extends [never] ? SourceNodeOf<N> : With<SourceNodeOf<N>, AspectsOf<N>>

type UnionToIntersection<U> =
  (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never

type ChildMapsOf<T extends Record<string, Scene2dNodeDef>> =
  { [K in keyof T]: T[K] extends { children: infer C extends Record<string, Scene2dNodeDef> } ? NodesOf<C> : never }[keyof T]

type NodesOf<T extends Record<string, Scene2dNodeDef>> =
  { [K in keyof T]: NodeOf<T[K]> } &
  ([ChildMapsOf<T>] extends [never] ? unknown : UnionToIntersection<ChildMapsOf<T>>)

export type Scene2dNodes<D extends Scene2dDef> =
  D["nodes"] extends Record<string, Scene2dNodeDef> ? NodesOf<D["nodes"]> : Record<string, Node2D>

export type LoadedScene2d<D extends Scene2dDef> = {
  scene: Scene2D
  nodes: Scene2dNodes<D>
}

// ---- runtime -------------------------------------------------------------------

const createSprite = (name: string, def: SpriteSourceDef): Sprite => {
  const sheet = def.sheet
  if (!(sheet instanceof SpriteSheet)) {
    throw new Error(`Scene2d node "${name}": sprite.sheet is not a sheet handle (import the .sprite file's default export)`)
  }
  const entryName = def.name ?? (sheet.spriteNames.length === 1 ? sheet.spriteNames[0]! : undefined)
  if (entryName === undefined) {
    throw new Error(`Scene2d node "${name}": sprite.name is required — the sheet has ${sheet.spriteNames.length} sprites (${sheet.spriteNames.join(", ")})`)
  }
  return sheet.make(entryName, {
    clip: def.clip,
    direction: def.direction,
  } as Parameters<SpriteSheet["make"]>[1])
}

const createTilemap = async (
  name: string, def: TilemapSourceDef, aspects?: readonly AspectEntry<any>[],
): Promise<Tilemap> => {
  const grid = def.cells
  if (!grid || typeof grid !== "object" || typeof (grid as CellsData).cols !== "number") {
    throw new Error(`Scene2d node "${name}": tilemap.cells must be a cells('…') payload or a { cols, rows, data } value`)
  }
  // Autotile2D on the node flips `cells` from raw atlas indices to material ids — derive the
  // display indices HERE, before the Tilemap exists (aspects are data on the def), so the native
  // layer keeps receiving a dumb index grid and derived data is never persisted.
  let data: number[] | Int32Array = grid.data
  for (const entry of aspects ?? []) {
    if ((entry.ctor as unknown) !== Autotile2D) continue
    const props = entry.props as Partial<Autotile2D> | undefined
    if (props?.tileset instanceof Tileset) {
      data = props.tileset.derive(grid.cols, grid.rows, data, props.seed ?? 0)
    } else {
      console.error(`[scene2d] node "${name}": Autotile2D has no tileset handle — cells render as raw indices`)
    }
  }
  const texture = await Texture2D.load(def.texture)
  return new Tilemap({
    texture, cols: grid.cols, rows: grid.rows,
    tile: def.tile, atlas: def.atlas, data,
  })
}

const createSource = (name: string, def: Scene2dNodeDef): Node2D | Promise<Node2D> => {
  const sources = [ def.sprite, def.tilemap, def.camera, def.make ].filter((s) => s !== undefined).length
  if (sources > 1) throw new Error(`Scene2d node "${name}" declares more than one source (sprite/tilemap/camera/make)`)
  if (def.sprite !== undefined) return createSprite(name, def.sprite)
  if (def.tilemap !== undefined) return createTilemap(name, def.tilemap, def.aspects)
  // make/camera/group nodes are plain wrappers — make subtrees mount under them in phase 2
  return new Node2D()
}

const applyNode = (node: Node2D, name: string, def: Scene2dNodeDef): void => {
  node.name = name
  if (def.position) node.position = def.position
  if (def.rotation !== undefined) node.rotation = def.rotation
  if (def.scale !== undefined) node.scale = def.scale
  if (def.layer !== undefined) node.layer = def.layer
  if (def.z !== undefined) node.z = def.z
  if (def.visible !== undefined) node.visible = def.visible
  if (def.locked !== undefined) (node as { _sceneLocked?: boolean })._sceneLocked = def.locked
  if (def.camera !== undefined) (node as { _sceneCamera?: boolean })._sceneCamera = true
  if (def.children) (node as { _sceneChildOrder?: string[] })._sceneChildOrder = Object.keys(def.children)
  if (node instanceof Sprite) {
    if (def.color !== undefined) node.color = def.color
    if (def.opacity !== undefined) node.opacity = def.opacity
    if (def.flipX !== undefined) node.flipX = def.flipX
    if (def.flipY !== undefined) node.flipY = def.flipY
  }
}

/** The first camera-source def in file order (depth-first), or null. */
const findCamera = (defs?: Record<string, Scene2dNodeDef>): { name: string, def: Camera2dNodeDef } | null => {
  for (const [ name, nd ] of Object.entries(defs ?? {})) {
    if (nd.camera !== undefined) return { name, def: nd.camera }
    const inner = findCamera(nd.children)
    if (inner) return inner
  }
  return null
}

const attachAspects = (
  node: Node2D, def: Scene2dNodeDef, nodes: Record<string, Node2D>, scene: Scene2D, env: Scene2dEnv,
): void => {
  if (isEditMode()) {
    // Data-only (the future harness reads [ctor, props] from here) — no onAttach side effects
    // (bodies, loops) while editing.
    ;(node as unknown as { _sceneAspects: readonly AspectEntry<any>[] })._sceneAspects = def.aspects ?? []
    return
  }
  for (const entry of def.aspects ?? []) {
    let props = resolveRefs(entry.props as Record<string, unknown> | undefined, nodes)
    // Aspects that declare `static wantsScene` (CameraFollow) get the scene wired in, and their
    // pixel snapping defaults to env.pixelPerfect — explicit props always win.
    if ((entry.ctor as { wantsScene?: boolean }).wantsScene) {
      props = {
        scene,
        ...(env.pixelPerfect !== undefined ? { pixelSnap: env.pixelPerfect } : {}),
        ...props,
      }
    }
    ;(node as Node2D & { aspect(c: unknown, p?: unknown): unknown }).aspect(entry.ctor, props)
  }
}

/** Run a def's make() factory (phase 2 — every node exists, so ref() args resolve). The subtree
 *  mounts under the wrapper node; the wrapper's transform stays editor-owned. */
const attachMake = (
  node: Node2D, name: string, def: Scene2dNodeDef, nodes: Record<string, Node2D>, scene: Scene2D,
): Promise<void> | undefined => {
  const entry = def.make
  if (!entry) return undefined
  const args = resolveRefs({ ...(entry.args ?? {}) } as Record<string, unknown>, nodes) ?? {}
  return Promise.resolve(entry.fn(args as never)).then((made) => {
    if (made instanceof Node2D) {
      node.add(made)
      // membership is per-entity in creator-2d — add the whole made subtree to the draw set
      const addAll = (n: Node2D): void => { scene.add(n); n.children.forEach(addAll) }
      addAll(made)
    }
  }).catch((e) => { console.error(`[scene2d] make() factory failed on "${name}":`, e) })
}

/** Build one defs record into `scene` under `parent`, two-phase (all nodes first, then
 *  aspects/make — so `ref()`s resolve regardless of declaration order). */
const buildNodes = async (
  defs: Record<string, Scene2dNodeDef>, parent: Node2D | null, scene: Scene2D,
  nodes: Record<string, Node2D>, env: Scene2dEnv,
): Promise<void> => {
  const pending: { name: string, node: Node2D, def: Scene2dNodeDef }[] = []

  const build = async (name: string, nd: Scene2dNodeDef, parentNode: Node2D | null): Promise<void> => {
    const node = await createSource(name, nd)
    if (parentNode) parentNode.add(node)
    scene.add(node)
    applyNode(node, name, nd)
    pending.push({ name, node, def: nd })
    nodes[name] = node
    await Promise.all(Object.entries(nd.children ?? {}).map(([ childName, child ]) => build(childName, child, node)))
  }

  await Promise.all(Object.entries(defs).map(([ name, nd ]) => build(name, nd, parent)))
  const waits: Promise<void>[] = []
  for (const p of pending) {
    attachAspects(p.node, p.def, nodes, scene, env)
    const wait = attachMake(p.node, p.name, p.def, nodes, scene)
    if (wait) waits.push(wait)
  }
  if (waits.length > 0) await Promise.all(waits)
}

const instantiate = async (def: Scene2dDef): Promise<{ scene: Scene2D, nodes: Record<string, Node2D> }> => {
  const env = def.env ?? {}
  // configure() resets the physics world, so it must run before any body is created (aspects
  // create bodies right after the node build). Edit mode holds physics as data — skip.
  if (env.gravity !== undefined && !isEditMode()) Physics2D.configure({ gravity: env.gravity })

  const scene = new Scene2D({ background: env.background, filter: env.filter })
  for (const [ key, layer ] of Object.entries(def.layers ?? {})) {
    if (layer?.ySort) scene.layer(Number(key)).ySort(true)
  }

  const nodes: Record<string, Node2D> = {}
  await buildNodes(def.nodes ?? {}, null, scene, nodes, env)

  // a camera NODE wins over the top-level `camera:` block; both only SEED the starting view —
  // attach CameraFollow to the tracked node to move the camera in play
  const cam = findCamera(def.nodes)
  const camNode = cam ? nodes[cam.name] : undefined
  if (cam && camNode) {
    const p = camNode.worldPosition
    scene.camera.position = env.pixelPerfect ? [ Math.round(p.x), Math.round(p.y) ] : p
    if (cam.def.zoom !== undefined) scene.camera.zoom = cam.def.zoom
  } else if (def.camera) {
    if (def.camera.position) scene.camera.position = def.camera.position
    if (def.camera.zoom !== undefined) scene.camera.zoom = def.camera.zoom
  }

  return { scene, nodes }
}

export class Scene2dHandle<D extends Scene2dDef = Scene2dDef> {
  readonly def: D
  private _loading?: Promise<LoadedScene2d<D>>

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

  /** Instantiate the scene (idempotent — subsequent calls return the same instance). Does not open. */
  load(): Promise<LoadedScene2d<D>> {
    this._loading ??= instantiate(this.def).then((live) => live as LoadedScene2d<D>)
    return this._loading
  }

  /** Load and make active (only the active 2D scene renders). */
  async open(): Promise<LoadedScene2d<D>> {
    const loaded = await this.load()
    loaded.scene.open()
    return loaded
  }
}

/**
 * Define a 2D scene as data — the default export of a `.scene2d.ts` file. Returns a typed handle:
 * `const { scene, nodes } = await handle.open()` gives `nodes.<name>` typed by its source block
 * (Sprite / Tilemap / Node2D) with its `use(...)`d aspects attached.
 */
export const defineScene2d = <const D extends Scene2dDef>(def: D): Scene2dHandle<D> => {
  const handle = new Scene2dHandle(def)
  const g = globalThis as unknown as { [EDIT_FLAG]?: boolean, __lecodesScenes2d?: Scene2dHandle[] }
  // Editor hook: expose defined handles to a host that runs the bundle in edit mode.
  if (g[EDIT_FLAG]) (g.__lecodesScenes2d ??= []).push(handle as Scene2dHandle)
  return handle
}
