// Runtime for the generated React wrappers (react/index.tsx). Renders the custom
// element and bridges the React world to it: rich props are assigned as DOM
// *properties* (via a ref, so arrays/objects pass through unstringified), and
// `on<Event>` handlers are wired as `addEventListener` for the element's
// CustomEvents. Layout props (className/style/id) pass straight through.
import {
  createElement,
  forwardRef,
  useImperativeHandle,
  useLayoutEffect,
  useRef,
  type CSSProperties,
  type ForwardRefExoticComponent,
  type PropsWithoutRef,
  type ReactNode,
  type RefAttributes,
} from 'react';

export interface WebComponentProps {
  /** Color mode (`auto` follows prefers-color-scheme). */
  theme?: 'light' | 'dark' | 'auto';
  className?: string;
  style?: CSSProperties;
  id?: string;
  /** Light-DOM children passed through to the element (slots). */
  children?: ReactNode;
}

// Per-element registration fires on the CLIENT, once per tag. The element modules
// touch `window` at module-eval (Solid's runtime), so the thunk must never run on
// the server — it is only ever called from a client effect, browser-gated here too.
const registered = new Set<string>();
function ensureRegistered(tagName: string, register?: () => Promise<unknown>): void {
  if (!register || registered.has(tagName)) return;
  if (typeof window === 'undefined' || typeof customElements === 'undefined') return;
  registered.add(tagName);
  if (customElements.get(tagName)) return; // already defined (e.g. via registerAll)
  void register();
}

/** Eagerly register ALL kai-* elements (the register-all bundle). Opt-in escape
 *  hatch for consumers who prefer no first-mount upgrade delay. Browser-only;
 *  a no-op on the server. */
export function registerAll(): Promise<unknown> | undefined {
  if (typeof window === 'undefined' || typeof customElements === 'undefined') return undefined;
  return import('@kitn.ai/ui/elements');
}

export function createWebComponent<P extends WebComponentProps>(
  tagName: string,
  /** DOM-property names to assign from props (incl. `theme`). */
  propNames: readonly string[],
  /** Map of React handler prop → DOM event name. */
  eventMap: Record<string, string>,
  /** Client-only thunk that loads + registers this element (a literal dynamic
   *  import of its `@kitn.ai/ui/elements/<name>` chunk). */
  register?: () => Promise<unknown>,
): ForwardRefExoticComponent<PropsWithoutRef<P> & RefAttributes<HTMLElement>> {
  const eventEntries = Object.entries(eventMap);

  const Component = forwardRef<HTMLElement, P>((props, ref) => {
    const elRef = useRef<HTMLElement | null>(null);
    useImperativeHandle(ref, () => elRef.current as HTMLElement, []);
    const p = props as Record<string, unknown>;

    // Hold the latest handlers in a ref so the registered listeners always call
    // the current handler (no stale closures) without re-binding on every render.
    const handlersRef = useRef<Record<string, unknown>>({});
    for (const reactName of Object.keys(eventMap)) handlersRef.current[reactName] = p[reactName];

    // Assign rich props as DOM properties every render (idempotent). Arrays and
    // objects pass through unstringified; booleans become real boolean
    // properties so the element's `flag()` reads them. Updated props re-assign
    // because this effect runs after every render.
    //
    // Upgrade-race guard: if the element isn't upgraded yet (customElements.get
    // returns undefined), writes land on a plain HTMLElement and are lost when
    // Solid's solid-element upgrades the tag later. We call whenDefined() so
    // props set before upgrade are re-applied once the definition arrives.
    // With self-registration (elements/register imported at the top of
    // react/index.tsx) this is belt-and-braces — the element is already defined
    // before React renders — but keeps the runtime safe regardless of import order.
    useLayoutEffect(() => {
      const el = elRef.current;
      if (!el) return;
      const applyProps = () => {
        for (const name of propNames) {
          if (name in p && p[name] !== undefined) (el as unknown as Record<string, unknown>)[name] = p[name];
        }
      };
      applyProps();
      if (typeof customElements !== 'undefined' && !customElements.get(tagName)) {
        customElements.whenDefined(tagName).then(applyProps);
      }
    });

    // Client-only, deduped: load + register THIS element on first mount. The
    // prop-assign effect's whenDefined guard re-applies props once it upgrades.
    useLayoutEffect(() => {
      ensureRegistered(tagName, register);
    }, []);

    // Wire CustomEvent listeners ONCE per element. Each stable listener reads the
    // latest handler from handlersRef, so changing a handler's identity across
    // renders takes effect without add/remove churn, and listeners are removed on
    // unmount (no leaks).
    useLayoutEffect(() => {
      const el = elRef.current;
      if (!el) return;
      const added: Array<[string, EventListener]> = [];
      for (const [reactName, domName] of eventEntries) {
        const fn: EventListener = (e) => {
          const handler = handlersRef.current[reactName];
          if (typeof handler === 'function') (handler as (e: Event) => void)(e);
        };
        el.addEventListener(domName, fn);
        added.push([domName, fn]);
      }
      return () => added.forEach(([n, fn]) => el.removeEventListener(n, fn));
    }, []);

    return createElement(
      tagName,
      {
        ref: elRef,
        className: p.className as string | undefined,
        style: p.style as CSSProperties | undefined,
        id: p.id as string | undefined,
      },
      // Light-DOM children pass straight through to the element (slots).
      (p.children ?? null) as never,
    );
  });

  Component.displayName = tagName;
  return Component as ForwardRefExoticComponent<PropsWithoutRef<P> & RefAttributes<HTMLElement>>;
}
