// The scene-file literal grammar markers, shared by `.scene.ts` (defineScene, 3D) and
// `.scene2d.ts` (defineScene2d, 2D): `use(Ctor, props)` aspect entries, `ref('name')` node
// references, `make(fn, args)` code-built sources — plus the ref-resolution helpers both loaders
// run. One implementation so the two formats can't drift; the engine-specific node/def types stay
// in their own modules (defineScene.ts / defineScene2d.ts), which re-export these for user code.

import type { Aspect, AspectCtor } from "../core/Aspect"
import type { Node } from "../gl/Node"

/** One aspect to attach, created by `use(Ctor, props)`. */
export type AspectEntry<A extends Aspect<any, any> = Aspect<any, any>> = {
  /** @internal grammar marker. */
  readonly __use: true
  ctor: AspectCtor<A>
  props?: Partial<A>
}

/** Reference an aspect in a scene file: `aspects: [use(Physics, { motion: 'static' })]`. Props are
 *  typechecked against the aspect's fields, exactly like `node.aspect(Ctor, props)`. */
export const use = <A extends Aspect<any, any>>(ctor: AspectCtor<A>, props?: Partial<A>): AspectEntry<A> =>
  ({ __use: true, ctor, props })

/** A `make(fn, args)` source entry — see {@link make}. `N` is the node family the factory returns
 *  (3D `Node` by default; a `.scene2d.ts` file's factories return `Node2D`). */
export type MakeEntry<A extends Record<string, unknown> = Record<string, unknown>, N = Node> = {
  /** @internal grammar marker. */
  readonly __make: true
  fn: (args: A) => N | Promise<N>
  args?: A
}

/**
 * A code-created source in a scene file: `tower: { make: make(buildTower, { floors: 5 }), … }`.
 * The factory runs after every scene node exists (so `ref()` args resolve, forward references
 * included) and its returned subtree mounts under the def node — the def's transform stays
 * editor-owned, so moving the node never re-calls the factory. Args must be literal data (same
 * grammar as aspect props); in the editor they edit as fields, and an arg change re-CALLS the
 * factory live — the code is already in the bundle, so no compile happens. Keep factories pure
 * builders: same args → same subtree, no side effects outside the returned nodes.
 */
export const make = <A extends Record<string, unknown>, N>(
  fn: (args: A) => N | Promise<N>, args?: A,
): MakeEntry<A, N> => ({ __make: true, fn, args })

/**
 * Reference another scene node by PATH in an aspect's props:
 * `use(Road, { from: ref('pointA'), to: ref('lane/pointB') })`. Resolves to the live node when
 * aspects attach — after EVERY node of the scene exists, so declaration order doesn't matter.
 * Resolution is scoped upward from the host node (like variable scoping): the host's own children
 * first, then its siblings, then each ancestor's scope up to the scene root. The FIRST segment
 * binds the scope; the remaining segments descend from there. Refs address def nodes only (no
 * `::`/`name[i]` asset-internal segments); an unknown path resolves to `null` (type your aspect
 * field `Node | null`). Scene files only — hand-written code passes nodes directly:
 * `node.aspect(Road, { from: nodes.pointA })`.
 */
export const ref = <T = Node>(path: string): T => ({ $ref: path } as unknown as T)

export const isNodeRef = (v: unknown): v is { $ref: string } =>
  typeof v === "object" && v !== null && typeof (v as { $ref?: unknown }).$ref === "string"

/** Resolve one ref string against a path-keyed nodes record, scoped upward from `hostPath` (own
 *  children → parent's scope (siblings + the host itself) → each ancestor scope → root, `""`).
 *  The ref's FIRST segment binds the scope — a match there is final even when the rest of the
 *  path doesn't exist (lexical shadowing). Returns the target's ABSOLUTE path, or null.
 *  `Object.hasOwn`, not indexing: a node named `constructor` must not resolve via the prototype. */
export const resolveRefPath = (
  nodes: Record<string, unknown>, hostPath: string, ref: string,
): string | null => {
  const i = ref.indexOf("/")
  const first = i < 0 ? ref : ref.slice(0, i)
  for (let scope = hostPath; ; ) {
    const base = scope === "" ? "" : `${scope}/`
    if (Object.hasOwn(nodes, base + first)) return Object.hasOwn(nodes, base + ref) ? base + ref : null
    if (scope === "") return null
    const cut = scope.lastIndexOf("/")
    scope = cut < 0 ? "" : scope.slice(0, cut)
  }
}

/** Swap `ref()` markers in aspect props for the live nodes (top level + one array level deep),
 *  resolved scoped-upward from `hostPath` ("" = root scope only — the 2D flat-map case). */
export const resolveRefs = <N>(
  props: Record<string, unknown> | undefined, nodes: Record<string, N>, hostPath = "",
): Record<string, unknown> | undefined => {
  if (!props) return props
  const lookup = (r: string): N | null => {
    const p = resolveRefPath(nodes, hostPath, r)
    return p === null ? null : nodes[p]
  }
  let out: Record<string, unknown> | undefined
  for (const [ k, v ] of Object.entries(props)) {
    if (isNodeRef(v)) {
      ;(out ??= { ...props })[k] = lookup(v.$ref)
    } else if (Array.isArray(v) && v.some(isNodeRef)) {
      ;(out ??= { ...props })[k] = v.map((el) => (isNodeRef(el) ? lookup(el.$ref) : el))
    }
  }
  return out ?? props
}

/** ABSOLUTE paths of the nodes a props record's ref() values resolve to (editor dep tracking).
 *  An unresolved ref contributes no dep — the handle re-derives deps after structural changes. */
export const collectRefDeps = (
  props: Record<string, unknown>, nodes: Record<string, unknown>, hostPath: string,
): Set<string> => {
  const deps = new Set<string>()
  const add = (r: string): void => {
    const p = resolveRefPath(nodes, hostPath, r)
    if (p !== null) deps.add(p)
  }
  for (const v of Object.values(props)) {
    if (isNodeRef(v)) add(v.$ref)
    else if (Array.isArray(v)) for (const el of v) if (isNodeRef(el)) add(el.$ref)
  }
  return deps
}

/** The edit-mode flag an editor host sets before running the bundle (shared by both formats). */
export const EDIT_FLAG = "__lecodesSceneEdit"
export const isEditMode = (): boolean => (globalThis as Record<string, unknown>)[EDIT_FLAG] === true
