import type { FetchResponse } from "../runtime/fetch"
import { createBinding, disposeBinding } from "../core/signals"

export interface UINode {
  readonly type: string
  /** Author-given semantic name (a stable selector for tests + AI review feedback). */
  readonly name?: string
  style: Style<this, any>
  /** Style-class proxy — read/set/toggle/bind the `$`-classes declared in `.style()`. See {@link Classes}. */
  readonly class: Classes<this>
}

/** A child slot value: a node, or falsy (conditional rendering — falsy entries are skipped). */
export type UINodeChild = UINode | null | undefined | false

/** A factory child argument: a node (or falsy for conditionals), or an array of them — arrays are
 *  flattened one level at the argument position, so `UIColumn(header, items.map(row), footer)`
 *  needs no spread. */
export type UIChildArg = UINodeChild | UINodeChild[]

/** A color: CSS-style string (`"#1c1c1e"`, `"rgba(0,0,0,0.5)"`, `"var(--primaryColor)"`) or
 *  packed number; `null` clears. */
export type Color = number | string | null

/** Style props every element accepts. */
export type BaseStyle = {
  opacity?: number | `${number}`,
  transform?: string | null,
  backgroundColor?: Color | null,
  bgColor?: Color | null,
  overflow?: "visible" | "hidden",
  boxSizing?: "border-box" | "content-box",
  /**
   * Author-given semantic name — a stable selector for tests and for marking elements in the AI
   * code-review feedback loop. NOT a style: it's extracted at construction (never sent to the layout
   * engine) and surfaced on the node + in the renderer's serialized output. Available on every element.
   */
  name?: string,
}

/** Painted-box props: background, gradient, border, radius. */
export type DrawableStyle = {
  backgroundImage?: FetchResponse | File | string | null,
  bgGradient?: string,
  backgroundGradient?: string,
  bgImage?: FetchResponse  | File | string | null,
  backgroundSize?: "cover" | "contain" | "tile",
  bgSize?: "cover" | "contain" | "tile",
  border?: string | number | null,
  borderWidth?: number,
  borderColor?: Color,

  borderLeft?: string | number | null,
  borderLeftWidth?: number,
  borderLeftColor?: Color,
  borderTop?: string | number | null,
  borderTopWidth?: number,
  borderTopColor?: Color,
  borderRight?: string | number | null,
  borderRightWidth?: number,
  borderRightColor?: Color,
  borderBottom?: string | number | null,
  borderBottomWidth?: number,
  borderBottomColor?: Color,

  borderRadius?: UIValue | string,
  borderTopLeftRadius?: UIValue,
  borderTopRightRadius?: UIValue,
  borderBottomLeftRadius?: UIValue,
  borderBottomRightRadius?: UIValue,

  pointerEvents?: "all" | "none",
}

/** Flex-layout props of a container (how it lays out its children). */
export type ContainerStyle = {
  flexDirection?: "row" | "column",
  justifyContent?: "flex-start" | "center" | "flex-end" | "space-between" | "space-evenly",
  alignItems?: "flex-start" | "center" | "flex-end" | "stretch",
  gap?: UIValue,
  flexWrap?: "nowrap" | "wrap" | "wrap-reverse"
}

/** A dimension: a number (logical px) or a CSS-style unit / function string. */
type UIValue = number |
  `${number}px` |
  `${number}vw` |
  `${number}vh` |
  `${number}vmin` |
  `${number}vmax` |
  `${number}em` |
  `calc(${string})` |
  `min(${string})` |
  `max(${string})` |
  `${string}%` |
  `clamp(${string})` |
  `var(--${string})`

/** Padding, with `p`/`px`/`pl`… shorthands. `safe-*` insets by the device safe area,
 *  `comfort-*` by the theme's comfort knobs (max of both). */
export type PaddingStyle = {
  paddingLeft?: UIValue | "safe-left" | "comfort-left",
  paddingTop?: UIValue | "safe-top" | "comfort-top",
  paddingRight?: UIValue | "safe-right" | "comfort-right",
  paddingBottom?: UIValue | "safe-bottom" | "comfort-bottom",
  padding?: UIValue | "safe-all" | "comfort-all",
  paddingHorizontal?: UIValue | "comfort-x",
  paddingVertical?: UIValue | "comfort-y",

  pl?: UIValue | "safe-left" | "comfort-left",
  pt?: UIValue | "safe-top" | "comfort-top",
  pr?: UIValue | "safe-right" | "comfort-right",
  pb?: UIValue | "safe-bottom" | "comfort-bottom",
  p?: UIValue | "safe-all" | "comfort-all",
  px?: UIValue | "comfort-x",
  py?: UIValue | "comfort-y",
}

/** Margin, with `m`/`mx`/`ml`… shorthands; `"auto"` centers/pushes like CSS. */
export type MarginStyle = {
  marginLeft?: UIValue | "auto" | "safe-left" | "comfort-left",
  marginTop?: UIValue | "auto" | "safe-top" | "comfort-top",
  marginRight?: UIValue | "auto" | "safe-right" | "comfort-right",
  marginBottom?: UIValue | "auto" | "safe-bottom" | "comfort-bottom",
  margin?: UIValue | "auto" | "safe-all" | "comfort-all",
  marginHorizontal?: UIValue | "auto" | "comfort-x",
  marginVertical?: UIValue | "auto" | "comfort-y",

  ml?: UIValue | "auto" | "safe-left" | "comfort-left",
  mt?: UIValue | "auto" | "safe-top" | "comfort-top",
  mr?: UIValue | "auto" | "safe-right" | "comfort-right",
  mb?: UIValue | "auto" | "safe-bottom" | "comfort-bottom",
  m?: UIValue | "auto" | "safe-all" | "comfort-all",
  mx?: UIValue | "auto" | "comfort-x",
  my?: UIValue | "auto" | "comfort-y",
}

/** Box position (for `position: "absolute"`) and size constraints. */
export type PositionStyle = {
  top?: UIValue | `${number}%` | 'safe-top' | 'comfort-top',
  left?: UIValue | `${number}%` | 'safe-left' | 'comfort-left',
  bottom?: UIValue | `${number}%` | 'safe-bottom' | 'comfort-bottom',
  right?: UIValue | `${number}%` | 'safe-right' | 'comfort-right',
  /** Shorthand for all four edges at one value. A longhand (`top`, `left`, …) overrides it. */
  inset?: UIValue | `${number}%` | "auto",
  width?: UIValue | `${number}%` | "auto",
  height?: UIValue | `${number}%` | "auto",
  maxWidth?: UIValue | `${number}%` | "auto",
  maxHeight?: UIValue | `${number}%` | "auto",
  minWidth?: UIValue | `${number}%` | "auto",
  minHeight?: UIValue | `${number}%` | "auto",
}

/** The base style of every element: box + flex-child props. */
export type ElementStyle = PaddingStyle & MarginStyle & BaseStyle & PositionStyle & {
  position?: "absolute" | "static" | "relative",
  aspectRatio?: number,
  flex?: number | string,
  flexGrow?: number,
  flexShrink?: number,
  flexBase?: UIValue | `${number}%` | "auto",
  alignSelf?: "flex-start" | "center" | "flex-end" | "stretch",
  display?: "none" | "flex"
}

/** Typography props. */
export type TextStyle =  {
  textAlign?: "start" | "center" | "end" | "left" | "right"
  fontFamily?: string,
  fontSize?: UIValue,
  lineHeight?: UIValue | "normal",
  fontWeight?: number | `${number}` | "normal" | "bold",
  fontStyle?: "normal" | "italic",
  color?: Color,
  textDecoration?: "underline" | "line-through" | "none",
  letterSpacing?: UIValue,
  /** Cap the text at N lines; the box is measured to the clamped height. 0 / omitted = unbounded. */
  lineClamp?: number,
  /** How a clamped line ends. Only meaningful together with `lineClamp`. Default "ellipsis". */
  textOverflow?: "ellipsis" | "clip"
}

// Pulls function-valued props out of `style` (mutating it — they must never reach the host bridge)
// and returns them. A static value under the same key overrides (disposes) an existing binding.
function extractBindings(el: any, style: any): [string, () => any][] | null {
  let fns: [string, () => any][] | null = null
  for (const key of Object.keys(style)) {
    const value = style[key]
    if (typeof value === "function") {
      (fns ??= []).push([key, value])
      delete style[key]
    } else if (el._fx !== undefined) {
      disposeBinding(el, key)
    }
  }
  return fns
}

// One reactive binding per style key: re-runs when a signal the function read changes, writing
// through the style proxy (so a mounted node hits _creatorUI.updateStyle, an unmounted one just
// keeps _style current for the next mount).
function applyBindings(el: any, fns: [string, () => any][]) {
  for (const [ key, fn ] of fns) {
    createBinding(el, key, () => { el.style[key] = fn() })
  }
}

// NB: returns `this` — `.style()` must chain. Don't rewrite.
function styleFunction(this: any, newStyle: any) {
  // `name` is a JS-node selector (read by hosts off el.name), not a layout property — extract it
  // exactly like the constructor so `.style({ name })` is equivalent to the first-arg style. Left
  // in the style it would never become el.name (breaking name-based lookups) and would leak into
  // the layout engine.
  if (newStyle && typeof newStyle.name === "string" && newStyle.name.length > 0) {
    this.name = newStyle.name
    delete newStyle.name
  }
  if (newStyle) this._mapStyle?.(newStyle)
  const fns = extractBindings(this, newStyle)
  Object.assign(this._style, newStyle)
  if (this._id !== 0) {
    _creatorUI.mergeStyle(this._id, newStyle)
  }
  if (fns) applyBindings(this, fns)
  return this
}

const handler: ProxyHandler<any> = {
  get(target, key, receiver) {
    if (key === "_raw") {
      return target._el._style
    }
    return target._el._style[key]
  },
  set(target, key: string, value: string | number) {
    if (typeof value === "function") {
      // Reactive binding: `el.style.prop = () => sig.value`. Note a later plain write does NOT
      // dispose the binding (this is the hot path) — override statically via `.style({ prop })`.
      const el = target._el
      const fn = value as unknown as () => any
      createBinding(el, key, () => { el.style[key] = fn() })
      return true
    }
    const el = target._el
    if (el._mapStyle !== undefined) {
      const mapped = { [key]: value }
      el._mapStyle(mapped)
      value = mapped[key]
    }
    if (el._id !== 0) {
      _creatorUI.updateStyle(el._id, key, value)
    }
    el._style[key] = value
    return true
  },
  getOwnPropertyDescriptor(target, p) {
    return Object.getOwnPropertyDescriptor(target._el._style, p)
  },
  ownKeys(target) {
    return Object.keys(target._el._style)
  }
}

// ---- the el.class proxy --------------------------------------------------------------------------

// `$checked` and `checked` name the same class — `$` is only the declaration-site prefix in .style().
const classKey = (name: string) => name[0] === "$" ? name.slice(1) : name

// Single-class state write, shared by the set trap, the batch form and binding re-runs. No-op when
// the state already matches, so an unchanged binding re-run doesn't resend over the bridge.
function writeClass(el: any, name: string, enabled: boolean) {
  if ((el._classes?.has(name) ?? false) === enabled) return
  if (enabled) {
    (el._classes ??= new Set()).add(name)
  } else {
    el._classes.delete(name)
  }
  if (el._id !== 0) {
    _creatorUI.setClass(el._id, name, enabled)
  }
}

// One class write: a function value installs/replaces a reactive binding, a plain value replaces
// the binding (last write wins — unlike the style proxy's hot-path caveat, a stale class binding
// silently reverting a later plain write would be a footgun, and class flips are never per-frame).
function writeClassValue(el: any, name: string, value: ClassValue) {
  name = classKey(name)
  if (typeof value === "function") {
    createBinding(el, "#class:" + name, () => { writeClass(el, name, !!value()) })
  } else {
    if (el._fx !== undefined) disposeBinding(el, "#class:" + name)
    writeClass(el, name, !!value)
  }
}

// The callable target of the el.class proxy (batch form), bound to the element like styleFunction.
function classFunction(this: any, classes: Record<string, ClassValue>) {
  for (const key of Object.keys(classes)) {
    writeClassValue(this, key, classes[key])
  }
  return this
}

const classHandler: ProxyHandler<any> = {
  get(target, key) {
    if (typeof key !== "string") return undefined
    return target._el._classes?.has(classKey(key)) ?? false
  },
  set(target, key, value) {
    if (typeof key === "string") writeClassValue(target._el, key, value)
    return true
  },
  // Object.keys(el.class) / spread list the ACTIVE class names (inspection + tests).
  getOwnPropertyDescriptor(target, p) {
    if (typeof p !== "string" || !(target._el._classes?.has(classKey(p)) ?? false)) return undefined
    return { value: true, enumerable: true, configurable: true, writable: true }
  },
  ownKeys(target) {
    const classes = target._el._classes
    return classes !== undefined ? [ ...classes ] : []
  }
}

/** Reactive style input: every primitive-valued prop also accepts a `() => value` binding that
 *  re-applies when a signal it read changes. Nested state blocks (onPressed, onLandscape, …) stay
 *  static — bindings are extracted at the top level only. */
export type Reactive<T> = { [K in keyof T]: NonNullable<T[K]> extends object ? T[K] : T[K] | (() => T[K]) }

/** User-defined style classes: any `$`-prefixed key in `.style()` declares a state block toggled
 *  from code via the `el.class` proxy; `duration`/`delay` animate the swap. Class state INHERITS
 *  down the tree (CSS-`.dark`-on-body style): a class set on a node also activates same-name `$`
 *  blocks on all descendants within the same root (docs/style-class-cascade-plan.md).
 *  `$pressed`/`$focused` are reserved — hosts toggle them on press/focus (they beat other
 *  classes, lose to onPressed/onFocused), so children can react to an ancestor's press.
 *
 *  Instantiated with the node's FULL style `T` — a class block accepts everything the node's
 *  `.style()` does, like `onLandscape`. Narrowing it wrongly rejects valid props such as
 *  `$active: { color }`; guarded by tests/ui-types.test.ts. */
export type ClassStyles<T> = { [key: `$${string}`]: T & { duration?: number, delay?: number } }

/** Value accepted by a `el.class` write: a boolean sets the class, a `() => boolean` binds it —
 *  the class then tracks the signals the function reads (re-evaluated on change). Reads through
 *  the proxy always return a plain boolean, never the bound function. */
export type ClassValue = boolean | (() => boolean)

/** The `el.class` proxy — the runtime switch for `$`-class blocks declared in `.style()`
 *  (see {@link ClassStyles}). One surface, mirroring `el.style`:
 *
 *  ```ts
 *  el.class.checked                    // read: is it active on THIS element? → boolean
 *  el.class.checked = true             // activate (false deactivates)
 *  el.class.open = !el.class.open      // toggle
 *  el.class.done = () => todo.done.value          // reactive binding (signals)
 *  el.class({ checked: true, done: () => … })     // batch form — returns the element, chainable
 *  ```
 *
 *  Class names are accepted with or without the declaration-site `$` prefix. `R` is the concrete
 *  node type, so the batch form chains like `.style()`.
 *
 *  Typing note: property access is `any` because an index signature can't give reads (`boolean`)
 *  and writes ({@link ClassValue}) different types — the batch form is the fully typed path;
 *  single-key writes are runtime-coerced with `!!`. */
export type Classes<R> = ((classes: Record<string, ClassValue>) => R) & { [key: string]: any }

/** Members every UI element shares — element interfaces extend this so docs and types live in one
 *  place. `S` is the element's style object; `A` is the subset `animateTo` accepts (defaults to `S`). */
export interface UIElementBase<S extends object, A extends object = S> {
  // NB: no `name` member here. Declaring it would give every element a property in common with
  // the style types (BaseStyle.name), letting TS's weak-type check match a NODE against a legacy
  // style-first overload — `UIPager(UIText("x"))` must stay a type error (tests/ui-types.test.ts).
  /** Style: `.style({...})` merges (chainable); `el.style.key = v` writes one prop, a `() => v`
   *  value binds it to signals — see {@link Style}. */
  style: Style<this, S>
  /** Tween to the target style — meta keys `duration`/`delay`/`loop`/…, see {@link AnimateStyle}. */
  animateTo: AnimateStyle<this, A>
  /** Tween from the given style to the current one (entrance animations). */
  animateFrom: AnimateStyle<this, A>
  /** Fires after every layout pass with the parent-relative box. */
  onLayout(onLayout: OnLayoutCallback): this
  /** Absolute rect in device space, read live (includes scroll); `null` before mount. */
  getBoundingClientRect(): BoundingClientRect | null
  /** Style-class proxy — read `el.class.checked`, set `el.class.checked = true`, toggle with
   *  `!el.class.checked`, bind `el.class.done = () => sig.value`, batch/chain `el.class({ … })`.
   *  Classes cascade to descendants. See {@link Classes}. */
  readonly class: Classes<this>
}

/** {@link UIElementBase} plus the child-management surface every container shares. */
export interface UIContainerBase<S extends object, A extends object = S> extends UIElementBase<S, A> {
  /** Append children. */
  append(...nodes: UINodeChild[]): this
  /** Insert children at `index`. */
  insert(index: number, ...nodes: UINodeChild[]): this
  /** Remove (unmount) the given children. */
  remove(...nodes: UINodeChild[]): this
  /** Replace all children — an array, or a function for reactive children. */
  setContent(nodes: UINodeChild[] | ChildrenFn): this
  /** The children array — mutate via `append`/`insert`/`remove`/`setContent`. */
  readonly children: UINodeChild[]
}

/** Orientation state blocks — override props applied only in landscape / portrait (like a
 *  `$`-class, keyed by device orientation instead of `el.class`). Same full-`T` rule as class
 *  blocks. */
export type OrientationStyles<T> = {
  onLandscape?: T,
  onPortrait?: T,
  /** @deprecated Misspelling of `onPortrait` (no second "r"). Kept so existing projects and published
   *  bundles keep working — hosts still honour it — but new code should use `onPortrait`. */
  onPortait?: T,
}

// NB: do NOT annotate `this: R` on StyleFn. As a parameter it makes R contravariant, turning every
// node type INVARIANT (UIScreen<false> stopped being assignable to UIScreen<boolean>). Runtime
// `this` is already fixed by `styleFunction.bind(this)`; R stays covariant in the return, so
// `.style()` still chains. Guarded by tests/ui-types.test-d.ts.
export type StyleFn <R, T extends object> = ((style: Reactive<T> & OrientationStyles<T> & ClassStyles<T>) => R)

/** The `el.style` surface: callable — `.style({...})` merges and returns the element for
 *  chaining — and per-key readable/writable (`el.style.opacity = 0.5`; a `() => value` write
 *  installs a reactive binding). */
export type Style <R, T extends object> = StyleFn<R,T> & T & OrientationStyles<T> & ClassStyles<T>

/** The `animateTo`/`animateFrom` surface: target style props plus the flat meta keys below
 *  (stripped before the commit into the element's style). */
export type AnimateStyle<R, T extends object> = ((style: T & {
  /** Tween length in **milliseconds** (default 225). */
  duration?: number,
  /** Wait before starting, in **milliseconds**. A looping animation delays once, not per cycle. */
  delay?: number,
  /** Escape hatch: target an internal style layer (the onPressed/orientation mechanism). Not part of the supported surface. */
  layer?: number,
  /** `false` = animate without writing the targets into the element's style (e.g. a fade-out right before `.hide()`). */
  commit?: boolean,
  /** Repeat the animation: `true` = forever, a number = that many cycles. A looping animation is an
   *  effect, not a state change — it never commits, and the element ends back at its base style.
   *  The loop stops when a later `animateTo`/`animateFrom` runs on the element or it leaves the screen. */
  loop?: boolean | number,
  /** `"ping-pong"` (default) animates there and back each cycle — no jump. `"restart"` snaps back and
   *  replays forward — for full-turn spinners (`transform: "rotate(360deg)"`) and shimmers. Only meaningful with `loop`. */
  loopMode?: "restart" | "ping-pong",
}) => R)

/** Meta keys of the animateTo/animateFrom options bag — everything that is not a style target. */
const ANIMATE_META = new Set(["duration", "delay", "layer", "commit", "loop", "loopMode"])

export type AppearStyle <R, T> = (arg: { from: T, duration?: number, delay?: number }) => R
export type DisappearStyle <R, T> = (arg: { to: T, duration?: number, delay?: number }) => R

/** `onLayout` payload — the node's box in PARENT-relative coordinates. */
export type OnLayoutCallback = (layout: { left: number, top: number, width: number, height: number }) => void

/** Web-DOMRect shape on purpose (getBoundingClientRect priors must hold): all eight fields, with
 *  right/bottom/x/y derived SDK-side — hosts only report [left, top, width, height]. */
export type BoundingClientRect = {
  x: number, y: number,
  left: number, top: number, right: number, bottom: number,
  width: number, height: number,
}

export class Element<T extends string> {
  readonly type: T
  /** Author-given semantic name (LeCodes `name`) — see BaseStyle.name. Extracted from the options
   *  object so it never reaches the layout engine; the renderer reads it off the node. */
  readonly name?: string
  protected _style: any

  protected _id: number = 0

  protected _styleProxy: any
  protected _appear: any
  protected _disappear: any
  /** @internal Reactive bindings owned by this element (style key / "#text" → effect). The element
   *  is the effect's only strong owner, so discarding a subtree lets its bindings collect with it. */
  _fx?: Map<string, any>

  /** @internal Per-subclass style normalization (SDK-name → wire-name, e.g. UIInput's
   *  `type: "phone"` → `"tel"`), mutating the style in place before it is stored or sent. Runs on
   *  every entry point: the first-arg style, `.style()`, and single-key `el.style.k =` writes. */
  _mapStyle?(style: any): void

  // Per-type default styles are inlined at each component's construction site (spread UNDER the
  // user's style — e.g. UIRow's `flexDirection: "row"`), so every node reaches the host with
  // explicit values and hosts keep no per-type style opinions of their own.
  constructor(type: T, style: any) {
    this.type = type
    if (style && typeof style.name === "string" && style.name.length > 0) {
      this.name = style.name
      delete style.name
    }
    if (style) this._mapStyle?.(style)
    this._style = style
    if (style) {
      const fns = extractBindings(this, style)
      if (fns) applyBindings(this, fns)   // sync first run — _style holds concrete values pre-mount
    }
    // Creation-site capture for the editor preview's element inspector: the host sets the flag
    // before running the bundle, and the stack (mapped through the bundle's source map) points at
    // the user code that created this element. Off everywhere else — zero cost when unset.
    if ((globalThis as any).__lecodesCaptureSites) {
      (this as any)._site = new Error("lecodes-site")
    }
  }

  get style(): any {
    if (this._styleProxy) return this._styleProxy
    const obj = styleFunction.bind(this)
    // @ts-ignore
    obj._el = this

    const proxy = new Proxy(obj, handler)
    this._styleProxy = proxy
    return proxy
  }

  /** Tween to the target style and commit it (unless `commit: false` or looping — a loop is an
   *  effect, not a state change). Meta keys: `duration`/`delay`/`loop`/… — see {@link AnimateStyle}. */
  animateTo(style: any): this{
    _creatorUI.animateTo(this._id, style)
    if (style.commit !== false && !style.loop) {
      for (const key in style) {
        if (!ANIMATE_META.has(key)) this._style[key] = style[key]
      }
    }
    return this
  }
  /** Tween FROM the given style to the element's current one (entrance animations). */
  animateFrom(style: any): this{
    _creatorUI.animateFrom(this._id, style)
    return this
  }

  /** @internal Active style-class names (stored without the `$`). Kept on the element — not just in
   *  the layout engine — so hosts re-apply them when a detached node is re-mounted (screens
   *  re-assign _id on every open). */
  protected _classes?: Set<string>

  protected _classProxy?: any

  /** Style-class control — flips the `$`-class blocks declared in `.style()`. One proxy, same
   *  contract as `el.style`:
   *
   *  - `el.class.checked` — read: is the class active on THIS element? → `boolean`
   *  - `el.class.checked = true` / `= false` — activate / deactivate
   *  - `el.class.open = !el.class.open` — toggle
   *  - `el.class.done = () => todo.done.value` — reactive: the class tracks the signals the
   *    function reads; a later write to the same class (plain or function) replaces the binding
   *  - `el.class({ checked: true, done: () => … })` — batch form, returns the element (chainable)
   *
   *  Names are accepted with or without the leading `$`. A class set on an element CASCADES: it is
   *  also active on every descendant (their same-name `$` blocks light up), until the subtree's
   *  root — hosted screens/widgets don't inherit. Reads reflect only this element's own classes;
   *  there is no opt-out below an active ancestor. `Object.keys(el.class)` lists the active names. */
  get class(): any {
    if (this._classProxy) return this._classProxy
    const obj = classFunction.bind(this)
    // @ts-ignore
    obj._el = this

    const proxy = new Proxy(obj, classHandler)
    this._classProxy = proxy
    return proxy
  }

  protected ll?: OnLayoutCallback[]
  /** Observe layout: fires after every host layout pass with the parent-relative box. */
  onLayout(onLayout: OnLayoutCallback): this {
    if (this.ll) {
      this.ll.push(onLayout)
    } else {
      this.ll = [ onLayout ]
    }
    return this
  }

  /** Absolute rect in device space — the space `UIWidget` positions in and touch events report
   *  `clientX`/`clientY` in — including scroll offsets, read live at call time (unlike `onLayout`,
   *  whose coordinates are parent-relative and go stale when an ancestor scrolls). `null` before
   *  the element is mounted and on hosts without the bridge read. Anchor popovers position-once
   *  at open; don't poll per frame. */
  getBoundingClientRect(): BoundingClientRect | null {
    if (this._id === 0) return null
    const rect = _creatorUI.getBoundingClientRect?.(this._id)
    if (!rect) return null
    const [left, top, width, height] = rect
    return { x: left, y: top, left, top, right: left + width, bottom: top + height, width, height }
  }
}

// ---- reactive children --------------------------------------------------------------------------

/** Reactive children: re-runs when a signal it read changes; the result is reconciled against
 *  the mounted children (kept nodes stay mounted, state intact). */
export type ChildrenFn = () => UINodeChild[]

// The container whose children binding is currently executing. __uiMap uses it as the cache owner;
// nested bindings save/restore it, so each level caches on its own element.
let currentMapOwner: any = null

/**
 * Compiler-emitted memoized map for reactive children. The chisel `reactiveUi` pass rewrites
 * `list.map(fn)` inside a children binding to `__uiMap(list, fn, "<file>:<offset>")`, so unchanged
 * items keep their element identity across re-runs (the reconciler then leaves them mounted).
 * Cache: per (binding owner, call-site slot), keyed by item reference; duplicate items consume
 * cached nodes in order. Outside a children binding, for non-array inputs, or for non-element
 * results it behaves exactly like Array.prototype.map — a wrong rewrite can't change behavior.
 */
export function __uiMap(list: any, render: (item: any, index: number) => any, slot: string): any[] {
  if (currentMapOwner === null || !Array.isArray(list)) {
    return list?.map(render) ?? []
  }
  const slots: Map<string, Map<any, any[]>> = currentMapOwner._mapCache ??= new Map()
  const prev = slots.get(slot)
  const next = new Map<any, any[]>()
  const out: any[] = []
  for (let i = 0; i < list.length; i++) {
    const item = list[i]
    const stack = prev?.get(item)
    let node = stack !== undefined && stack.length > 0 ? stack.shift() : undefined
    if (node === undefined) {
      node = render(item, i)
      if (!(node instanceof Element)) {
        out.push(node)   // not a UI element — plain map semantics, never cached
        continue
      }
    }
    let arr = next.get(item)
    if (arr === undefined) next.set(item, arr = [])
    arr.push(node)
    out.push(node)
  }
  // entries left in `prev` (items gone from the list) are dropped here; their nodes and bindings
  // become unreachable and collect
  slots.set(slot, next)
  return out
}

// ---- factory argument dispatch ------------------------------------------------------------------

/** @internal Shared argument dispatch for the UI factories (`UIRow(...)`, `UIButton(...)`, …).
 *  Children are variadic; an array argument is flattened one level, so both the legacy
 *  `UIColumn([a, b])` and `UIColumn(header, items.map(row), footer)` work. A lone function
 *  argument is a reactive {@link ChildrenFn}. A plain non-node object in first position is the
 *  legacy style bag: it is spread over `defaults`, or — when `defaults` is null — stored as-is
 *  (the Element constructor mutates it in place, matching the old path). The rest-args array is
 *  taken over as the children array when nothing needs flattening — callers must hand it off. */
export function buildUI<R>(args: any[], defaults: any, make: (style: any, children: any[] | ChildrenFn) => R): R {
  const n = args.length
  const a0 = n > 0 ? args[0] : undefined
  let style: any
  let start = 0
  if (a0 !== null && typeof a0 === "object" && !Array.isArray(a0) && !(a0 instanceof Element)) {
    style = defaults ? { ...defaults, ...a0 } : a0
    start = 1
  } else {
    style = defaults ? { ...defaults } : {}
  }
  if (n === start + 1) {
    const a = args[start]
    // lone function → reactive children; lone array → used as the children array directly
    // (the legacy zero-copy path — nested arrays inside it are NOT flattened)
    if (typeof a === "function" || Array.isArray(a)) return make(style, a)
  }
  // Single pass: reuse the rest-args array as the children array until the first array argument
  // shows up, then copy the prefix once and flatten from there. Falsy entries stay — they are
  // compacted at mount like always.
  let flat: any[] | undefined
  for (let i = start; i < n; i++) {
    const a = args[i]
    if (flat === undefined) {
      if (Array.isArray(a)) {
        flat = args.slice(start, i)
        for (let j = 0; j < a.length; j++) flat.push(a[j])
      }
    } else if (Array.isArray(a)) {
      for (let j = 0; j < a.length; j++) flat.push(a[j])
    } else {
      flat.push(a)
    }
  }
  if (flat !== undefined) return make(style, flat)
  return make(style, start === 0 ? args : args.slice(1))
}

export class ContainerElement<T extends string> extends Element<T> {
  children: any[] = []

  constructor(type: T, style: any, children: any[] | ChildrenFn) {
    super(type, style)
    if (typeof children === "function") {
      this._bindChildren(children)
    } else {
      this.children = children
    }
  }

  /** @internal Reactive children: re-runs when a signal read inside `fn` changes and reconciles
   *  the result against the mounted children with minimal insert/remove ops. */
  _bindChildren(fn: ChildrenFn) {
    createBinding(this, "#children", () => {
      const prevOwner = currentMapOwner
      currentMapOwner = this
      let next: UINodeChild[]
      try {
        next = fn()
      } finally {
        currentMapOwner = prevOwner
      }
      this._reconcile(Array.isArray(next) ? next : [])
    })
  }

  /** @internal Identity diff of the current children against `next`: departed nodes are removed,
   *  new ones inserted at their position, kept nodes stay mounted (so their host state survives).
   *  A moved node is remove+insert — it remounts. Falsy entries are compacted away. */
  _reconcile(next: UINodeChild[]) {
    const target: any[] = []
    for (const c of next) if (c) target.push(c)

    if (this._id === 0) {
      this.children.length = 0
      this.children.push(...target)
      return
    }

    // compact holes left by a previous static children array (falsy entries were never mounted)
    for (let i = this.children.length - 1; i >= 0; i--) {
      if (!this.children[i]) this.children.splice(i, 1)
    }

    const targetSet = new Set(target)
    const departed = this.children.filter(c => !targetSet.has(c))
    if (departed.length > 0) this.remove(...departed)

    // this.children now mirrors the mounted order; walk the target and fix mismatches in place
    for (let i = 0; i < target.length; i++) {
      if (this.children[i] === target[i]) continue
      const node = target[i]
      if (this.children.includes(node, i + 1)) {
        this.remove(node)   // moved forward — remount at its new position
      }
      this.insert(i, node)
    }
  }

  /** Append children (falsy entries are kept in the array but never mounted). */
  append(...nodes: UINodeChild[]): this {
    if (this._id === 0) {
      this.children.push(...nodes)
      return this
    }
    let index = 0
    for (let child of this.children) {
      if (child) {
        index++
      }
    }
    for (let node of nodes) {
      if (!node) continue
      _creatorUI.insertNode(index, this._id, node) 
      index++
    }
    this.children.push(...nodes)
    return this
  }
  /** Insert children at `index` of the children array. */
  insert(index: number, ...nodes: UINodeChild[]): this {
    if (this._id === 0) {
      this.children.splice(index, 0, ...nodes)
      return this
    }

    let _index = 0
    for (let i = 0; i < index; i++) {
      if (this.children[i]) {
        _index++
      }
    }
    for (let node of nodes) {
      if (!node) continue
      _creatorUI.insertNode(_index, this._id, node) 
      _index++
    }
    this.children.splice(index, 0, ...nodes)
    return this
  }
  /** Remove (unmount) the given children. */
  remove(...nodesToDelete: UINode[]): this {
    const set = new Set(nodesToDelete)

    for (let i = 0; i < this.children.length; i++) {
      if (set.has(this.children[i])) {
        set.delete(this.children[i])
        this.children.splice(i, 1)
        i--

        if (set.size === 0) {
          break
        }
      }
    }

    if (this._id === 0) {
      return this
    }
    for (let node of nodesToDelete) {
      _creatorUI.removeNode(this._id, node)
    }
    return this
  }
  
  /** Replace all children — a plain array, or a function for reactive children. */
  setContent(children: UINodeChild[] | ChildrenFn): this {
    if (typeof children === "function") {
      this._bindChildren(children)   // createBinding replaces a previous "#children" binding
      return this
    }
    disposeBinding(this, "#children")  // static content overrides a reactive binding
    if (this._id !== 0) {
      _creatorUI.setContent(this._id, this.children, children)
    }
    this.children.length = 0
    this.children.push(...children)
    return this
  }
}