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

/**
 * Represents a text node of a remote tree in a plain JavaScript format, with
 * the addition of a `version` property that is incremented whenever the
 * node is updated.
 */

/**
 * Represents a comment node of a remote tree in a plain JavaScript format, with
 * the addition of a `version` property that is incremented whenever the
 * node is updated.
 */

/**
 * Represents an element node of a remote tree in a plain JavaScript format, with
 * the addition of a `version` property that is incremented whenever the
 * node is updated.
 */

/**
 * Represents the root node of the remote tree in a plain JavaScript format, with
 * the addition of a `version` property that is incremented whenever the
 * root is updated.
 */

/**
 * Represents any node that can be stored in the host representation of the remote tree.
 */

/**
 * Any node in the remote tree that can have children nodes.
 */

/**
 * A `RemoteReceiver` stores remote elements into a basic JavaScript representation,
 * and allows subscribing to individual elements in the remote environment.
 * This can be useful for mapping remote elements to components in a JavaScript
 * framework; for example, the [`@remote-dom/react` library](https://github.com/Shopify/remote-dom/blob/main/packages/react#remoterenderer)
 * uses this receiver to map remote elements to React components.
 */
class RemoteReceiver {
  /**
   * Represents the root node of the remote tree. This node is always defined,
   * and you will likely be most interested in its `children` property, which
   * contains the top-level elements of the remote tree.
   */
  root = (() => ({
    id: ROOT_ID,
    type: NODE_TYPE_ROOT,
    children: [],
    version: 0,
    properties: {},
    attributes: {},
    eventListeners: {}
  }))();

  /**
   * 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([[ROOT_ID, this.root]]))();
  subscribers = (() => new Map())();
  parents = (() => new Map())();
  implementations = (() => new Map())();
  constructor({
    retain,
    release,
    methods
  } = {}) {
    const {
      attached,
      parents,
      subscribers
    } = this;
    this.connection = createRemoteConnection({
      call: (id, method, ...args) => {
        const implementation = this.implementations.get(id);
        const implementationMethod = implementation?.[method];
        if (typeof implementationMethod !== 'function') {
          throw new Error(`Node ${id} does not implement the ${method}() method`);
        }
        return implementationMethod(...args);
      },
      insertChild: (id, child, index) => {
        const parent = attached.get(id);
        const {
          children
        } = parent;
        const normalizedChild = attach(child, parent);
        if (index === children.length) {
          children.push(normalizedChild);
        } else {
          children.splice(index, 0, normalizedChild);
        }
        parent.version += 1;
        this.parents.set(child.id, parent.id);
        runSubscribers(parent);
      },
      removeChild: (id, index) => {
        const parent = attached.get(id);
        const {
          children
        } = parent;
        const [removed] = children.splice(index, 1);
        if (!removed) {
          return;
        }
        parent.version += 1;
        runSubscribers(parent);
        detach(removed);
      },
      updateProperty: (id, property, value, type = UPDATE_PROPERTY_TYPE_PROPERTY) => {
        const element = attached.get(id);
        retain?.(value);
        let updateObject;
        switch (type) {
          case UPDATE_PROPERTY_TYPE_PROPERTY:
            updateObject = element.properties;
            break;
          case UPDATE_PROPERTY_TYPE_ATTRIBUTE:
            updateObject = element.attributes;
            break;
          case UPDATE_PROPERTY_TYPE_EVENT_LISTENER:
            updateObject = element.eventListeners;
            break;
        }
        const oldValue = updateObject[property];
        updateObject[property] = value;
        element.version += 1;
        let parentForUpdate;

        // If the slot changes, inform parent nodes so they can
        // re-parent it appropriately.
        if (property === 'slot') {
          const parentId = this.parents.get(id);
          parentForUpdate = parentId == null ? parentId : attached.get(parentId);
          if (parentForUpdate) {
            parentForUpdate.version += 1;
          }
        }
        runSubscribers(element);
        if (parentForUpdate) runSubscribers(parentForUpdate);
        release?.(oldValue);
      },
      updateText: (id, newText) => {
        const text = attached.get(id);
        text.data = newText;
        text.version += 1;
        runSubscribers(text);
      }
    });
    if (methods) this.implement(this.root, methods);
    function runSubscribers(attached) {
      const subscribed = subscribers.get(attached.id);
      if (subscribed) {
        for (const subscriber of subscribed) {
          subscriber(attached);
        }
      }
    }
    function attach(child, parent) {
      let normalizedChild;
      switch (child.type) {
        case NODE_TYPE_TEXT:
        case NODE_TYPE_COMMENT:
          {
            const {
              id,
              type,
              data
            } = child;
            normalizedChild = {
              id,
              type,
              data,
              version: 0
            };
            break;
          }
        case NODE_TYPE_ELEMENT:
          {
            const {
              id,
              type,
              element,
              children,
              properties,
              attributes,
              eventListeners
            } = child;
            retain?.(properties);
            retain?.(eventListeners);
            const resolvedChildren = [];
            normalizedChild = {
              id,
              type,
              element,
              version: 0,
              children: resolvedChildren,
              properties: {
                ...properties
              },
              attributes: {
                ...attributes
              },
              eventListeners: {
                ...eventListeners
              }
            };
            for (const grandChild of children) {
              resolvedChildren.push(attach(grandChild, normalizedChild));
            }
            break;
          }
        default:
          {
            throw new Error(`Unknown node type: ${JSON.stringify(child)}`);
          }
      }
      attached.set(normalizedChild.id, normalizedChild);
      parents.set(normalizedChild.id, parent.id);
      return normalizedChild;
    }
    function detach(child) {
      attached.delete(child.id);
      parents.delete(child.id);
      if (release) {
        if ('properties' in child) release(child.properties);
        if ('eventListeners' in child) release(child.eventListeners);
      }
      if ('children' in child) {
        for (const grandChild of child.children) {
          detach(grandChild);
        }
      }
    }
  }

  /**
   * Fetches the latest state of a remote element that has been
   * received from the remote environment.
   *
   * @param node The remote node to fetch.
   * @returns The current state of the remote node, or `undefined` if the node is not connected to the remote tree.
   *
   * @example
   * import {RemoteReceiver} from '@remote-dom/core/receivers';
   *
   * const receiver = new RemoteReceiver();
   *
   * receiver.get(receiver.root) === receiver.root; // true
   */
  get({
    id
  }) {
    return this.attached.get(id);
  }

  /**
   * Lets you define how [remote methods](https://github.com/Shopify/remote-dom/blob/main/packages/core#remotemethods)
   * are implemented for a particular element in the tree.
   *
   * @param node The remote node to subscribe for changes.
   * @param implementation A record containing the methods to expose for the passed node.
   *
   * @example
   * // In the host environment:
   * import {RemoteReceiver} from '@remote-dom/core/receivers';
   *
   * const receiver = new RemoteReceiver();
   *
   * receiver.implement(receiver.root, {
   *   alert(message) {
   *     window.alert(message);
   *   },
   * });
   *
   * // In the remote environment:
   * import {RemoteRootElement} from '@remote-dom/core/elements';
   *
   * customElements.define('remote-root', RemoteRootElement);
   *
   * const root = document.createElement('remote-root');
   * root.connect(receiver.connection);
   *
   * root.callRemoteMethod('alert', 'Hello, world!');
   */
  implement({
    id
  }, implementation) {
    if (implementation == null) {
      this.implementations.delete(id);
    } else {
      this.implementations.set(id, implementation);
    }
  }

  /**
   * Allows you to subscribe to changes in a remote element. This includes
   * changes to the remote element’s properties and list of children, but
   * note that you will not receive updates for properties or children of
   * _nested_ elements.
   *
   * @param node The remote node to subscribe for changes.
   * @param subscriber A function that will be called with the updated node on each change.
   *
   * @example
   * import {RemoteReceiver} from '@remote-dom/core/receivers';
   *
   * const abort = new AbortController();
   * const receiver = new RemoteReceiver();
   *
   * // Subscribe to all changes in the top-level children, attached
   * // directly to the remote “root”.
   * receiver.subscribe(
   *   receiver.root,
   *   (root) => {
   *     console.log('Root changed!', root);
   *   },
   *   {signal: abort.signal},
   * );
   */
  subscribe({
    id
  }, subscriber, {
    signal
  } = {}) {
    let subscribersSet = this.subscribers.get(id);
    if (subscribersSet == null) {
      subscribersSet = new Set();
      this.subscribers.set(id, subscribersSet);
    }
    subscribersSet.add(subscriber);
    signal?.addEventListener('abort', () => {
      subscribersSet.delete(subscriber);
      if (subscribersSet.size === 0) {
        this.subscribers.delete(id);
      }
    });
  }
}

export { RemoteReceiver };
