///|
/// The JavaScript error passed to a React root error callback.
#external
pub type ReactError

///|
/// Metadata passed to a React root error callback.
#external
pub type ReactErrorInfo

///|
/// Returns the JavaScript Error message reported by React.
pub extern "js" fn ReactError::message(self : ReactError) -> String =
  #| (error) => error?.message ?? String(error)

///|
/// Returns the React component stack associated with a root error.
pub extern "js" fn ReactErrorInfo::component_stack(
  self : ReactErrorInfo,
) -> String =
  #| (info) => info?.componentStack ?? ""

///|
/// Configuration shared by `createRoot` and `hydrateRoot`.
struct RootOptions {
  js_value : @dom.JsObjectObscure
}

///|
/// Creates root options matching React 19's root error and identifier options.
/// `identifier_prefix` must match the server renderer during hydration.
pub fn RootOptions::new(
  identifier_prefix? : String,
  on_caught_error? : (ReactError, ReactErrorInfo) -> Unit,
  on_uncaught_error? : (ReactError, ReactErrorInfo) -> Unit,
  on_recoverable_error? : (ReactError, ReactErrorInfo) -> Unit,
) -> RootOptions {
  let options = @dom.JsObjectObscure::new()
  match identifier_prefix {
    Some(prefix) =>
      options.set("identifierPrefix", @dom.JsObscure::from_string(prefix))
    None => ()
  }
  match on_caught_error {
    Some(callback) =>
      options.set("onCaughtError", @dom.v_to_js_obscure(callback))
    None => ()
  }
  match on_uncaught_error {
    Some(callback) =>
      options.set("onUncaughtError", @dom.v_to_js_obscure(callback))
    None => ()
  }
  match on_recoverable_error {
    Some(callback) =>
      options.set("onRecoverableError", @dom.v_to_js_obscure(callback))
    None => ()
  }
  RootOptions::{ js_value: options }
}

///|
fn RootOptions::to_js_obscure(self : RootOptions) -> @dom.JsObscure {
  self.js_value.to_js_obscure()
}

///|
extern "js" fn react_render(
  vdom : @dom.JsObscure,
  parent : @dom.Node,
  options : @dom.JsObscure,
) -> Unit =
  #| (vdom, parent, options) => {
  #|   const roots = globalThis.__moonbitReactRoots ??= new WeakMap();
  #|   let root = roots.get(parent);
  #|   if (!root) {
  #|     root = globalThis.ReactDOMClient.createRoot(parent, options ?? undefined);
  #|     roots.set(parent, root);
  #|   }
  #|   root.render(vdom);
  #| }

///|
extern "js" fn react_hydrate_root(
  vdom : @dom.JsObscure,
  parent : @dom.Node,
  options : @dom.JsObscure,
) -> Unit =
  #| (vdom, parent, options) => {
  #|   const roots = globalThis.__moonbitReactRoots ??= new WeakMap();
  #|   if (roots.has(parent)) {
  #|     throw new Error("hydrate_root requires a parent without an existing React root");
  #|   }
  #|   const root = globalThis.ReactDOMClient.hydrateRoot(
  #|     parent,
  #|     vdom,
  #|     options ?? undefined,
  #|   );
  #|   roots.set(parent, root);
  #| }

///|
extern "js" fn react_unmount(parent : @dom.Node) -> Unit =
  #| (parent) => {
  #|   const roots = globalThis.__moonbitReactRoots;
  #|   const root = roots?.get(parent);
  #|   if (root) {
  #|     root.unmount();
  #|     roots.delete(parent);
  #|   }
  #| }

///|
extern "js" fn react_create_portal(
  child : @dom.JsObscure,
  parent : @dom.Node,
  key : @dom.JsObscure,
) -> @dom.JsObscure =
  #| (child, parent, key) => key == null
  #|   ? globalThis.ReactDOM.createPortal(child, parent)
  #|   : globalThis.ReactDOM.createPortal(child, parent, key)

///|
extern "js" fn react_render_to_string(
  vdom : @dom.JsObscure,
  identifier_prefix : @dom.JsObscure,
) -> String =
  #| (vdom, identifierPrefix) => globalThis.ReactDOMServer.renderToString(
  #|   vdom,
  #|   identifierPrefix == null ? undefined : { identifierPrefix },
  #| )

///|
/// Renders a virtual DOM node to the specified parent element. Repeated calls
/// for the same parent reuse its React root.
pub fn render(vdom : VirtualNode, parent : @dom.Element) -> Unit {
  react_render(
    vdom.to_js_obscure(),
    parent.reinterpret_as_node(),
    @dom.JsObscure::null(),
  )
}

///|
/// Renders through a new root configured with React 19 root options. Options
/// are consumed only when this call creates the root; later renders reuse it.
pub fn render_with_options(
  vdom : VirtualNode,
  parent : @dom.Element,
  options : RootOptions,
) -> Unit {
  react_render(
    vdom.to_js_obscure(),
    parent.reinterpret_as_node(),
    options.to_js_obscure(),
  )
}

///|
/// Hydrates React-generated HTML already present in `parent`. The initial VDOM
/// must produce identical markup. Later `render` calls reuse the hydrated root.
pub fn hydrate_root(
  vdom : VirtualNode,
  parent : @dom.Element,
  options? : RootOptions,
) -> Unit {
  let js_options = match options {
    Some(value) => value.to_js_obscure()
    None => @dom.JsObscure::null()
  }
  react_hydrate_root(
    vdom.to_js_obscure(),
    parent.reinterpret_as_node(),
    js_options,
  )
}

///|
/// Creates a React portal whose DOM is placed in `parent` while context and
/// event propagation continue to follow the owning React tree.
pub fn create_portal(
  child : VirtualNode,
  parent : @dom.Element,
  key? : String,
) -> VirtualNode {
  let js_key = match key {
    Some(value) => @dom.JsObscure::from_string(value)
    None => @dom.JsObscure::null()
  }
  JsNode(
    react_create_portal(
      child.to_js_obscure(),
      parent.reinterpret_as_node(),
      js_key,
    ),
  )
}

///|
/// Renders a React tree to an HTML string for basic synchronous SSR or SSG.
/// This intentionally does not provide streaming or wait for suspended data.
pub fn render_to_string(
  vdom : VirtualNode,
  identifier_prefix? : String,
) -> String {
  let js_prefix = match identifier_prefix {
    Some(value) => @dom.JsObscure::from_string(value)
    None => @dom.JsObscure::null()
  }
  react_render_to_string(vdom.to_js_obscure(), js_prefix)
}

///|
/// Unmounts the React root associated with a parent element, if one exists.
/// A later call to `render` or `hydrate_root` creates a fresh root.
pub fn unmount(parent : @dom.Element) -> Unit {
  react_unmount(parent.reinterpret_as_node())
}