Source: lib/core/events/bindEvents.js

import { findScope } from '../renderer/bindingScope.js';
/**
 * Checks if a given DOM element belongs to the component defined by root.
 * Deals with nested component boundaries and transcluded slots.
 * @param {Element} element
 * @param {Element} root
 * @returns {boolean}
 */
function belongsToComponent(element, root) {
  if (element === root) {
    return !(root.hasAttribute && (root.hasAttribute('data-avenx-comp') || root.hasAttribute('data-avenx-comp-dynamic')));
  }
  let current = element;
  let isTranscluded = false;
  while (current && current !== root) {
    if (current.nodeType === 1) {
      if (current.nodeName === 'SLOT' && current.hasAttribute && current.hasAttribute('data-avenx-transcluded')) {
        isTranscluded = true;
      } else if (
        current.hasAttribute &&
        (current.hasAttribute('data-avenx-comp') || current.hasAttribute('data-avenx-comp-dynamic'))
      ) {
        if (isTranscluded) {
          isTranscluded = false;
        } else if (current !== element) {
          return false;
        }
      }
    }
    current = current.parentNode;
  }
  return !isTranscluded;
}

/**
 * Responsible for binding event listeners to DOM elements based on attributes.
 * Uses event delegation on the root element.
 */
export class EventBinder {
  /**
   * Stores bound events and handlers.
   * @type {WeakMap<Element, Map<string, {handler: Function, options: object}>>}
   * @private
   */
  #boundEvents = new WeakMap();

  /**
   * Builds addEventListener options from OR'd passive/capture flags.
   * @param {object} flags
   * @returns {object}
   * @private
   */
  #toListenerOptions(flags) {
    const options = {};
    if (flags && flags.passive) options.passive = true;
    if (flags && flags.capture) options.capture = true;
    return options;
  }

  /**
   * ORs `.passive` / `.capture` modifiers into an options flags object.
   * @param {{passive: boolean, capture: boolean}} flags
   * @param {string[]} modifiers
   * @private
   */
  #orListenerFlags(flags, modifiers) {
    if (modifiers.includes('passive')) flags.passive = true;
    if (modifiers.includes('capture')) flags.capture = true;
  }

  /**
   * Stores elements and executed modifier keys for once handlers.
   * @type {WeakMap<Element, Set<string>>}
   * @private
   */
  #onceExecuted = new WeakMap();

  /**
   * Reads all event handlers (including delegation metadata) from an element.
   * Supports both data-ax-event and traditional @event attributes.
   * @param {Element} el
   * @returns {Array<{fullName: string, value: string, attrName: string}>}
   * @private
   */
  #getEventHandlers(el) {
    const handlers = [];
    if (!el) return handlers;

    if (el.hasAttribute && el.hasAttribute('data-ax-event')) {
      const attrVal = el.getAttribute('data-ax-event');
      try {
        const parsed = JSON.parse(attrVal);
        for (const [key, val] of Object.entries(parsed)) {
          handlers.push({
            fullName: key,
            value: val,
            attrName: `data-ax-event:${key}`
          });
        }
      } catch {
        // Ignored
      }
    }

    if (el.attributes) {
      Array.from(el.attributes).forEach((attr) => {
        if (attr.name.startsWith('@')) {
          handlers.push({
            fullName: attr.name.substring(1),
            value: attr.value,
            attrName: attr.name
          });
        }
      });
    }

    return handlers;
  }

  /**
   * Binds event listeners to all elements under the root that have attributes starting with '@'.
   * Uses event delegation on Element roots, falls back to direct binding on DocumentFragments.
   * @param {Element|DocumentFragment} root - The root element to bind events on.
   * @param {object} dispatcher - The object responsible for executing the event handler.
   * @param {function(string, Event): void} dispatcher.execute - Method to execute the event.
   */
  bind(root, dispatcher) {
    if (!root) return;
    if (root.nodeType === 11) {
      this.#bindDirect(root, dispatcher);
    } else {
      this.#bindDelegated(root, dispatcher);
    }
  }

  /**
   * Removes all event listeners for the given root.
   * @param {Element|DocumentFragment} root
   */
  unbind(root) {
    if (!root) return;
    if (root.nodeType === 11) {
      this.#unbindDirect(root);
    } else {
      this.#unbindDelegated(root);
      this.#unbindDirect(root);
    }
  }

  /**
   * @param {Element} root
   * @param {object} dispatcher
   */
  #bindDelegated(root, dispatcher) {
    const COMMON_EVENTS = ['click', 'input', 'change', 'keydown'];
    const eventNames = new Set(COMMON_EVENTS);
    /** @type {Map<string, {passive: boolean, capture: boolean}>} */
    const eventFlags = new Map();

    const ensureFlags = (eventName) => {
      if (!eventFlags.has(eventName)) {
        eventFlags.set(eventName, { passive: false, capture: false });
      }
      return eventFlags.get(eventName);
    };

    COMMON_EVENTS.forEach((name) => ensureFlags(name));

    const traverse = (node) => {
      if (node.nodeType !== 1) return;

      const handlers = this.#getEventHandlers(node);
      handlers.forEach((h) => {
        const parts = h.fullName.split('.');
        const baseEventName = parts[0];
        eventNames.add(baseEventName);
        this.#orListenerFlags(ensureFlags(baseEventName), parts.slice(1));
      });

      if (node.nodeName === 'SLOT' && node.hasAttribute && node.hasAttribute('data-avenx-transcluded')) {
        return;
      }
      if (
        node !== root &&
        node.hasAttribute &&
        (node.hasAttribute('data-avenx-comp') || node.hasAttribute('data-avenx-comp-dynamic'))
      ) {
        return;
      }

      const children = node.childNodes || node.children;
      if (children) {
        for (let i = 0; i < children.length; i++) {
          traverse(children[i]);
        }
      }
    };
    traverse(root);

    if (!root.__avenxDelegatedListeners) {
      root.__avenxDelegatedListeners = new Map();
    }
    const delegatedMap = root.__avenxDelegatedListeners;
    const existing = this.#boundEvents.get(root) || new Map();

    eventNames.forEach((eventName) => {
      if (delegatedMap.has(eventName)) {
        const oldEntry = delegatedMap.get(eventName);
        const oldHandler = typeof oldEntry === 'function' ? oldEntry : oldEntry.handler;
        const oldOptions = typeof oldEntry === 'function' ? undefined : oldEntry.options;
        if (typeof root.removeEventListener === 'function') {
          root.removeEventListener(eventName, oldHandler, oldOptions);
        }
        delegatedMap.delete(eventName);
        existing.delete(eventName);
      }

      const handler = (event) => {
        let current = (event && event.target) || root;
        while (current) {
          if (belongsToComponent(current, root)) {
            if (current.hasAttribute && current.hasAttribute('data-ax-validate')) {
              if (dispatcher && typeof dispatcher.validate === 'function') {
                dispatcher.validate(current);
              }
            }
            const handlers = this.#getEventHandlers(current);
            handlers.forEach((h) => {
              const parts = h.fullName.split('.');
              const baseEventName = parts[0];
              if (baseEventName === eventName) {
                const modifiers = parts.slice(1);
                if (h.value) {
                  this.#executeWithModifiers(dispatcher, h.value, event, modifiers, current, h.attrName);
                }
              }
            });
          }
          if (current === root) {
            break;
          }
          current = current.parentNode;
          if (event.cancelBubble) {
            break;
          }
        }
      };

      const options = this.#toListenerOptions(eventFlags.get(eventName));
      if (typeof root.addEventListener === 'function') {
        root.addEventListener(eventName, handler, options);
      }
      const entry = { handler, options };
      delegatedMap.set(eventName, entry);
      existing.set(eventName, entry);
      this.#boundEvents.set(root, existing);
    });
  }

  /**
   * @param {Element} root
   */
  #unbindDelegated(root) {
    if (root.__avenxDelegatedListeners) {
      root.__avenxDelegatedListeners.forEach((entry, eventName) => {
        const handler = typeof entry === 'function' ? entry : entry.handler;
        const options = typeof entry === 'function' ? undefined : entry.options;
        if (typeof root.removeEventListener === 'function') {
          root.removeEventListener(eventName, handler, options);
        }
      });
      delete root.__avenxDelegatedListeners;
    }
    const existing = this.#boundEvents.get(root);
    if (existing) {
      existing.forEach((entry, eventName) => {
        const handler = typeof entry === 'function' ? entry : entry.handler;
        const options = typeof entry === 'function' ? undefined : entry.options;
        if (typeof root.removeEventListener === 'function') {
          root.removeEventListener(eventName, handler, options);
        }
      });
      this.#boundEvents.delete(root);
    }
  }

  /**
   * @param {Element|DocumentFragment} root
   * @param {object} dispatcher
   */
  #bindDirect(root, dispatcher) {
    const elements = [];
    const traverse = (node) => {
      if (node.nodeType !== 1 && node.nodeType !== 11) return;
      if (node.nodeType === 1) {
        elements.push(node);
      }
      if (node.nodeName === 'SLOT' && node.hasAttribute && node.hasAttribute('data-avenx-transcluded')) {
        return;
      }
      const children = node.childNodes || node.children;
      if (children) {
        for (let i = 0; i < children.length; i++) {
          traverse(children[i]);
        }
      }
    };
    traverse(root);

    elements.forEach((el) => {
      if (!el.__avenxDirectListeners) {
        el.__avenxDirectListeners = new Map();
      }
      const directMap = el.__avenxDirectListeners;
      const existing = this.#boundEvents.get(el) || new Map();
      const handlers = this.#getEventHandlers(el);

      // Remove any existing direct listeners on el before binding new ones
      directMap.forEach((oldEntry, baseEventName) => {
        const oldHandler = typeof oldEntry === 'function' ? oldEntry : oldEntry.handler;
        const oldOptions = typeof oldEntry === 'function' ? undefined : oldEntry.options;
        if (typeof el.removeEventListener === 'function') {
          el.removeEventListener(baseEventName, oldHandler, oldOptions);
        }
      });
      directMap.clear();
      existing.clear();

      /** @type {Map<string, {passive: boolean, capture: boolean}>} */
      const eventFlags = new Map();
      handlers.forEach((h) => {
        const parts = h.fullName.split('.');
        const baseEventName = parts[0];
        if (!eventFlags.has(baseEventName)) {
          eventFlags.set(baseEventName, { passive: false, capture: false });
        }
        this.#orListenerFlags(eventFlags.get(baseEventName), parts.slice(1));
      });

      handlers.forEach((h) => {
        const parts = h.fullName.split('.');
        const baseEventName = parts[0];

        if (!directMap.has(baseEventName)) {
          const handler = (event) => {
            const currentHandlers = this.#getEventHandlers(el);
            currentHandlers.forEach((ch) => {
              const cp = ch.fullName.split('.');
              const cbEventName = cp[0];
              if (cbEventName === baseEventName) {
                const mods = cp.slice(1);
                if (ch.value) {
                  this.#executeWithModifiers(dispatcher, ch.value, event, mods, el, ch.attrName);
                }
              }
            });
          };
          const options = this.#toListenerOptions(eventFlags.get(baseEventName));
          if (typeof el.addEventListener === 'function') {
            el.addEventListener(baseEventName, handler, options);
          }
          const entry = { handler, options };
          directMap.set(baseEventName, entry);
          existing.set(baseEventName, entry);
          this.#boundEvents.set(el, existing);
        }
      });
    });
  }

  /**
   * Resolves the scope an element's bindings belong to.
   *
   * A scoped slot was the first thing to need this; a `<@for>` row needs
   * exactly the same answer, so the walk lives in one module now and both stamp
   * through it.
   * @param {Element} el - The element a binding is attached to.
   * @returns {object|null} The nearest enclosing derived scope, or null.
   * @private
   */
  #getSlotScope(el) {
    return findScope(el);
  }

  /**
   * Executes the handler expression if modifiers permit, and applies modifier effects.
   * @param {object} dispatcher
   * @param {string} handlerExpression
   * @param {Event} event
   * @param {string[]} modifiers
   * @param {Element} el
   * @param {string} attrName
   * @private
   */
  #executeWithModifiers(dispatcher, handlerExpression, event, modifiers, el, attrName) {
    // 0. Check self modifier
    if (modifiers.includes('self')) {
      if (event && event.target && event.target !== el) {
        return;
      }
    }

    // 1. Check once modifier
    if (modifiers.includes('once')) {
      let executedSet = this.#onceExecuted.get(el);
      if (!executedSet) {
        executedSet = new Set();
        this.#onceExecuted.set(el, executedSet);
      }
      if (executedSet.has(attrName)) {
        return;
      }
      executedSet.add(attrName);
    }

    // 2. Check system key modifiers
    const SYSTEM_MODIFIER_MAP = {
      ctrl: 'ctrlKey',
      alt: 'altKey',
      shift: 'shiftKey',
      meta: 'metaKey',
      cmd: 'metaKey',
    };

    const systemModifiers = modifiers.filter(m => Object.prototype.hasOwnProperty.call(SYSTEM_MODIFIER_MAP, m));
    if (systemModifiers.some(mod => !event || event[SYSTEM_MODIFIER_MAP[mod]] !== true)) {
      return;
    }

    // 3. Check key modifiers for keyboard events
    const KEY_MODIFIER_MAP = {
      enter: 'Enter',
      esc: 'Escape',
      escape: 'Escape',
      space: ' ',
      tab: 'Tab',
      delete: 'Delete',
    };

    const keyModifiers = modifiers.filter(m => Object.prototype.hasOwnProperty.call(KEY_MODIFIER_MAP, m));
    if (keyModifiers.length > 0) {
      if (!event || typeof event.key !== 'string') {
        return;
      }
      const matches = keyModifiers.some(mod => event.key === KEY_MODIFIER_MAP[mod]);
      if (!matches) {
        return;
      }
    }

    // 4. Apply prevent & stop modifiers
    // Skip preventDefault when .passive is present — browsers ignore it on passive listeners
    if (
      modifiers.includes('prevent') &&
      !modifiers.includes('passive') &&
      event &&
      typeof event.preventDefault === 'function'
    ) {
      event.preventDefault();
    }
    if (modifiers.includes('stop') && event && typeof event.stopPropagation === 'function') {
      event.stopPropagation();
    }

    // 4. Execute handler
    const slotScope = this.#getSlotScope(el);
    dispatcher.execute(handlerExpression, event, slotScope);
  }

  /**
   * @param {Element|DocumentFragment} root
   */
  #unbindDirect(root) {
    const elements = [];
    const traverse = (node) => {
      if (node.nodeType !== 1 && node.nodeType !== 11) return;
      if (node.nodeType === 1) {
        elements.push(node);
      }
      if (node.nodeName === 'SLOT' && node.hasAttribute && node.hasAttribute('data-avenx-transcluded')) {
        return;
      }
      const children = node.childNodes || node.children;
      if (children) {
        for (let i = 0; i < children.length; i++) {
          traverse(children[i]);
        }
      }
    };
    traverse(root);

    elements.forEach((el) => {
      if (el.__avenxDirectListeners) {
        el.__avenxDirectListeners.forEach((entry, eventName) => {
          const handler = typeof entry === 'function' ? entry : entry.handler;
          const options = typeof entry === 'function' ? undefined : entry.options;
          if (typeof el.removeEventListener === 'function') {
            el.removeEventListener(eventName, handler, options);
          }
        });
        delete el.__avenxDirectListeners;
      }

      const existing = this.#boundEvents.get(el);
      if (existing) {
        existing.forEach((entry, eventName) => {
          const handler = typeof entry === 'function' ? entry : entry.handler;
          const options = typeof entry === 'function' ? undefined : entry.options;
          if (typeof el.removeEventListener === 'function') {
            el.removeEventListener(eventName, handler, options);
          }
        });
        this.#boundEvents.delete(el);
      }
    });
  }

  /**
   * Resets internal WeakMaps to release any remaining element-handler references.
   */
  teardown() {
    this.#boundEvents = new WeakMap();
    this.#onceExecuted = new WeakMap();
  }
}