import { createRemoteConnection } from '../connection.mjs';
import { ROOT_ID, UPDATE_PROPERTY_TYPE_PROPERTY, NODE_TYPE_COMMENT, NODE_TYPE_TEXT, NODE_TYPE_ELEMENT, UPDATE_PROPERTY_TYPE_EVENT_LISTENER, UPDATE_PROPERTY_TYPE_ATTRIBUTE } from '../constants.mjs';
import { setRemoteId } from '../elements/internals.mjs';

const REMOTE_IDS = new WeakMap();
const REMOTE_PROPERTIES = new WeakMap();
const REMOTE_EVENT_LISTENERS = new WeakMap();

/**
 * Takes care of mapping remote elements to matching HTML elements
 * on the host page. If you implement your UI with [custom elements](https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements),
 * `DOMRemoteReceiver` is a simple option that avoids much of the
 * manual work required when using the basic `RemoteReceiver`.
 */
class DOMRemoteReceiver {
  /**
   * The root element that will contain the host implementations of
   * all nodes attached to the remote tree. To connect the receiver to
   * a new element, call the `connect()` method.
   */

  /**
   * An object that can synchronize a tree of elements between two JavaScript
   * environments. This object acts as a “thin waist”, allowing for efficient
   * communication of changes between a “remote” environment (usually, a JavaScript
   * sandbox, such as an `iframe` or Web Worker) and a “host” environment
   * (usually, a top-level browser page).
   */

  attached = (() => new Map())();
  constructor({
    root,
    retain,
    release,
    call,
    cache
  } = {}) {
    this.root = root ?? document.createDocumentFragment();
    const {
      attached
    } = this;
    const destroyTimeouts = new Map();
    this.connection = createRemoteConnection({
      call: (id, method, ...args) => {
        const element = id === ROOT_ID && this.root.nodeType !== 11 ? this.root : attached.get(id);
        return call ? call(element, method, ...args) : element[method](...args);
      },
      insertChild: (id, child, index) => {
        const parent = id === ROOT_ID ? this.root : attached.get(id);
        const existingTimeout = destroyTimeouts.get(id);
        if (existingTimeout) clearTimeout(existingTimeout);
        parent.insertBefore(attach(child), parent.childNodes[index] || null);
      },
      removeChild: (id, index) => {
        const parent = id === ROOT_ID ? this.root : attached.get(id);
        const child = parent.childNodes[index];
        child.remove();
        if (cache?.maxAge) {
          const existingTimeout = destroyTimeouts.get(id);
          if (existingTimeout) clearTimeout(existingTimeout);
          const timeout = setTimeout(() => {
            detach(child);
          }, cache.maxAge);
          destroyTimeouts.set(id, timeout);
        } else {
          detach(child);
        }
      },
      updateProperty: (id, property, value, type = UPDATE_PROPERTY_TYPE_PROPERTY) => {
        const element = attached.get(id);
        retain?.(value);
        const remoteProperties = REMOTE_PROPERTIES.get(element);
        const oldValue = remoteProperties[property];
        remoteProperties[property] = value;
        updateRemoteProperty(element, property, value, type);
        release?.(oldValue);
      },
      updateText: (id, newText) => {
        const text = attached.get(id);
        text.data = newText;
      }
    });
    function attach(node) {
      const existingNode = attached.get(node.id);
      if (existingNode) return existingNode;
      let normalizedChild;
      switch (node.type) {
        case NODE_TYPE_ELEMENT:
          {
            normalizedChild = document.createElement(node.element);
            if (node.properties) {
              REMOTE_PROPERTIES.set(normalizedChild, node.properties);
              for (const property of Object.keys(node.properties)) {
                const value = node.properties[property];
                retain?.(value);
                updateRemoteProperty(normalizedChild, property, value, UPDATE_PROPERTY_TYPE_PROPERTY);
              }
            } else {
              REMOTE_PROPERTIES.set(normalizedChild, {});
            }
            if (node.attributes) {
              for (const attribute of Object.keys(node.attributes)) {
                const value = node.attributes[attribute];
                retain?.(value);
                updateRemoteProperty(normalizedChild, attribute, value, UPDATE_PROPERTY_TYPE_ATTRIBUTE);
              }
            }
            REMOTE_EVENT_LISTENERS.set(normalizedChild, {});
            if (node.eventListeners) {
              for (const event of Object.keys(node.eventListeners)) {
                const listener = node.eventListeners[event];
                retain?.(listener);
                updateRemoteProperty(normalizedChild, event, listener, UPDATE_PROPERTY_TYPE_EVENT_LISTENER);
              }
            }
            for (const child of node.children) {
              normalizedChild.appendChild(attach(child));
            }
            break;
          }
        case NODE_TYPE_TEXT:
          {
            normalizedChild = document.createTextNode(node.data);
            break;
          }
        case NODE_TYPE_COMMENT:
          {
            normalizedChild = document.createComment(node.data);
            break;
          }
        default:
          {
            throw new Error(`Unknown node type: ${JSON.stringify(node)}`);
          }
      }
      setRemoteId(normalizedChild, node.id);
      attached.set(node.id, normalizedChild);
      return normalizedChild;
    }
    function detach(child) {
      const id = REMOTE_IDS.get(child);
      if (id) attached.delete(id);
      const properties = REMOTE_PROPERTIES.get(child);
      if (properties && release) release(properties);
      if (child instanceof Element) {
        for (const grandChild of child.childNodes) {
          detach(grandChild);
        }
      }
    }
  }

  /**
   * Connects the receiver to a new root element. The representation of
   * any child elements of the remote root will be appended to this node
   * as children, and the `root` property will be updated to point to the
   * new element.
   */
  connect(element) {
    const oldRoot = this.root;
    this.root = element;
    oldRoot.childNodes.forEach(node => {
      element.appendChild(node);
    });
  }

  /**
   * Disconnects the receiver from its current root element. Any current
   * children of the root element will be moved to a `DocumentFragment`
   * instead, so they can be re-attached to a new element later.
   */
  disconnect() {
    // DocumentFragment
    if (this.root.nodeType === 11) return this.root;
    const oldRoot = this.root;
    const fragment = new DocumentFragment();
    this.root = fragment;
    oldRoot.childNodes.forEach(node => {
      fragment.appendChild(node);
    });
    return fragment;
  }
}
function updateRemoteProperty(element, property, value, type) {
  switch (type) {
    case UPDATE_PROPERTY_TYPE_PROPERTY:
      {
        element[property] = value;
        break;
      }
    case UPDATE_PROPERTY_TYPE_ATTRIBUTE:
      {
        if (value == null) {
          element.removeAttribute(property);
        } else {
          element.setAttribute(property, value);
        }
        break;
      }
    case UPDATE_PROPERTY_TYPE_EVENT_LISTENER:
      {
        const remoteListeners = REMOTE_EVENT_LISTENERS.get(element);
        const existing = remoteListeners?.[property];
        if (existing) element.removeEventListener(property, existing);
        if (value != null) {
          // Support a `RemoteEvent`-shaped event object, where the `detail` argument
          // is passed to the remote environment, and the resulting promise call is passed
          // to `event.resolve()`. A host implementation can use this conventional event shape
          // to use the internal function representation of the event listener.
          const handler = event => {
            // If the event is bubbling/ capturing, we don’t trigger the listener here,
            // we let the event be dispatched to the remote environment only from the actual
            // target element. In the remote environment, the event will go through a separate
            // capture/ bubbling phase, where it will invoke the remote event listener
            // that corresponds to this `value` function.
            if (event.target !== element) return;
            const result = value(event.detail);
            event.resolve?.(result);
          };
          if (remoteListeners) {
            remoteListeners[property] = handler;
          }
          element.addEventListener(property, handler);
        }
        break;
      }
  }
}

export { DOMRemoteReceiver };
