'use strict';

Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });

const RemoteEvent = require('./RemoteEvent.cjs');
const internals = require('./internals.cjs');

/**
 * A class that represents a remote custom element, which can have properties,
 * attributes, event listeners, methods, and slots that are synchronized with
 * a host environment.
 */

/**
 * Returns the properties type from a remote element constructor.
 */

/**
 * Returns the methods type from a remote element constructor.
 */

/**
 * Returns the slots type from a remote element constructor.
 */

/**
 * Returns the event listeners type from a remote element constructor.
 */

/**
 * Options that can be passed when creating a new remote element class with
 * `createRemoteElement()`.
 */

const EMPTY_DEFINITION = Object.freeze({});
function createRemoteElement({
  slots,
  properties,
  attributes,
  events,
  methods
} = {}) {
  const RemoteElementConstructor = class extends RemoteElement {
    static remoteSlots = (() => slots)();
    static remoteProperties = (() => properties)();
    static remoteAttributes = (() => attributes)();
    static remoteEvents = (() => events)();
    static remoteMethods = (() => methods)();
  };
  return RemoteElementConstructor;
}

// Heavily inspired by https://github.com/lit/lit/blob/343187b1acbbdb02ce8d01fa0a0d326870419763/packages/reactive-element/src/reactive-element.ts

/**
 * A base class for creating “remote” HTML elements, which have properties, attributes,
 * event listeners, slots, and methods that can be synchronized between a host and
 * remote environment. When subclassing `RemoteElement`, you can define how different fields
 * in the class will be synchronized by defining the `remoteProperties`, `remoteAttributes`,
 * `remoteEvents`, and/or `remoteMethods` static properties.
 *
 * @example
 * ```ts
 * class CustomButton extends RemoteElement {
 *   static remoteAttributes = ['disabled', 'primary'];
 *   static remoteEvents = ['click'];
 *
 *   focus() {
 *     console.log('Calling focus in the remote environment...');
 *     return this.callRemoteMethod('focus');
 *   }
 * }
 * ```
 */
class RemoteElement extends HTMLElement {
  static slottable = true;
  static get observedAttributes() {
    return this.finalize().__observedAttributes;
  }

  /**
   * The resolved property definitions for this remote element.
   */
  static get remotePropertyDefinitions() {
    return this.finalize().__remotePropertyDefinitions;
  }

  /**
   * The resolved attribute definitions for this remote element.
   */
  static get remoteAttributeDefinitions() {
    return this.finalize().__remoteAttributeDefinitions;
  }

  /**
   * The resolved event listener definitions for this remote element.
   */
  static get remoteEventDefinitions() {
    return this.finalize().__remoteEventDefinitions;
  }

  /**
   * The resolved slot definitions for this remote element.
   */
  static get remoteSlotDefinitions() {
    return this.finalize().__remoteSlotDefinitions;
  }
  static __finalized = true;
  static __observedAttributes = [];
  static __attributeToPropertyMap = (() => new Map())();
  static __eventToPropertyMap = (() => new Map())();
  static __remotePropertyDefinitions = (() => new Map())();
  static __remoteAttributeDefinitions = (() => new Map())();
  static __remoteEventDefinitions = (() => new Map())();
  static __remoteSlotDefinitions = (() => new Map())();

  /**
   * Creates a new definition for a property that will be synchronized between
   * this remote element and its host representation.
   */
  static createProperty(name, definition) {
    saveRemoteProperty(name, definition, this.observedAttributes, this.remotePropertyDefinitions, this.__attributeToPropertyMap, this.__eventToPropertyMap);
  }

  /**
   * Consumes all the static members defined on the class and converts them
   * into the internal representation used to handle properties, attributes,
   * and event listeners.
   */
  static finalize() {
    // eslint-disable-next-line no-prototype-builtins
    if (this.hasOwnProperty('__finalized')) {
      return this;
    }
    this.__finalized = true;
    const {
      slottable,
      remoteSlots,
      remoteProperties,
      remoteAttributes,
      remoteEvents,
      remoteMethods
    } = this;

    // finalize any superclasses
    const SuperConstructor = Object.getPrototypeOf(this);
    const observedAttributes = new Set();
    if (slottable) observedAttributes.add('slot');
    const attributeToPropertyMap = new Map();
    const eventToPropertyMap = new Map();
    const remoteSlotDefinitions = new Map();
    const remotePropertyDefinitions = new Map();
    const remoteAttributeDefinitions = new Map();
    const remoteEventDefinitions = new Map();
    if (typeof SuperConstructor.finalize === 'function') {
      SuperConstructor.finalize();
      SuperConstructor.observedAttributes.forEach(attribute => {
        observedAttributes.add(attribute);
      });
      SuperConstructor.remotePropertyDefinitions.forEach((definition, property) => {
        remotePropertyDefinitions.set(property, definition);
      });
      SuperConstructor.remoteAttributeDefinitions.forEach((definition, event) => {
        remoteAttributeDefinitions.set(event, definition);
      });
      SuperConstructor.remoteEventDefinitions.forEach((definition, event) => {
        remoteEventDefinitions.set(event, definition);
      });
      SuperConstructor.remoteSlotDefinitions.forEach((definition, slot) => {
        remoteSlotDefinitions.set(slot, definition);
      });
    }
    if (remoteSlots != null) {
      const slotNames = Array.isArray(remoteSlots) ? remoteSlots : Object.keys(remoteSlots);
      slotNames.forEach(slotName => {
        remoteSlotDefinitions.set(slotName, EMPTY_DEFINITION);
      });
    }
    if (remoteProperties != null) {
      if (Array.isArray(remoteProperties)) {
        remoteProperties.forEach(propertyName => {
          saveRemoteProperty(propertyName, undefined, observedAttributes, remotePropertyDefinitions, attributeToPropertyMap, eventToPropertyMap);
        });
      } else {
        Object.keys(remoteProperties).forEach(propertyName => {
          saveRemoteProperty(propertyName, remoteProperties[propertyName], observedAttributes, remotePropertyDefinitions, attributeToPropertyMap, eventToPropertyMap);
        });
      }
    }
    if (remoteAttributes != null) {
      remoteAttributes.forEach(attribute => {
        remoteAttributeDefinitions.set(attribute, EMPTY_DEFINITION);
        observedAttributes.add(attribute);
      });
    }
    if (remoteEvents != null) {
      if (Array.isArray(remoteEvents)) {
        remoteEvents.forEach(event => {
          remoteEventDefinitions.set(event, EMPTY_DEFINITION);
        });
      } else {
        Object.keys(remoteEvents).forEach(event => {
          remoteEventDefinitions.set(event, remoteEvents[event]);
        });
      }
    }
    if (remoteMethods != null) {
      if (Array.isArray(remoteMethods)) {
        for (const method of remoteMethods) {
          // @ts-expect-error We are dynamically defining methods, which TypeScript can’t
          // really keep track of.
          this.prototype[method] = function (...args) {
            return this.callRemoteMethod(method, ...args);
          };
        }
      } else {
        Object.assign(this, remoteMethods);
      }
    }
    Object.defineProperties(this, {
      __observedAttributes: {
        value: [...observedAttributes],
        enumerable: false
      },
      __remoteSlotDefinitions: {
        value: remoteSlotDefinitions,
        enumerable: false
      },
      __remotePropertyDefinitions: {
        value: remotePropertyDefinitions,
        enumerable: false
      },
      __remoteAttributeDefinitions: {
        value: remoteAttributeDefinitions,
        enumerable: false
      },
      __remoteEventDefinitions: {
        value: remoteEventDefinitions,
        enumerable: false
      },
      __attributeToPropertyMap: {
        value: attributeToPropertyMap,
        enumerable: false
      },
      __eventToPropertyMap: {
        value: eventToPropertyMap,
        enumerable: false
      }
    });
    return this;
  }

  // Just need to use these types so TS doesn’t lose track of them.
  /** @internal */

  /** @internal */

  /** @internal */

  /** @internal */

  constructor() {
    super();
    this.constructor.finalize();
    const propertyDescriptors = {};
    const initialPropertiesToSet = {};
    const prototype = Object.getPrototypeOf(this);
    const ThisClass = this.constructor;
    for (const [property, description] of ThisClass.remotePropertyDefinitions.entries()) {
      const aliasedName = description.name;

      // Don’t override actual accessors. This is handled by the
      // `remoteProperty()` decorator applied to the accessor.
      // eslint-disable-next-line no-prototype-builtins
      if (prototype.hasOwnProperty(property)) {
        continue;
      }
      if (property === aliasedName) {
        initialPropertiesToSet[property] = description.default;
      }
      const propertyDescriptor = {
        configurable: true,
        enumerable: property === aliasedName,
        get: () => {
          return internals.remoteProperties(this)?.[aliasedName];
        },
        set: value => {
          internals.updateRemoteElementProperty(this, aliasedName, value);
        }
      };
      propertyDescriptors[property] = propertyDescriptor;
    }
    for (const [event, definition] of ThisClass.remoteEventDefinitions.entries()) {
      const propertyFromDefinition = definition.property ?? true;
      if (!propertyFromDefinition) continue;
      const property = propertyFromDefinition === true ? `on${event}` : propertyFromDefinition;
      propertyDescriptors[property] = {
        configurable: true,
        enumerable: true,
        get: () => {
          return getRemoteEvents(this).properties.get(property) ?? null;
        },
        set: value => {
          const remoteEvents = getRemoteEvents(this);
          const currentListener = remoteEvents.properties.get(property);
          if (typeof value === 'function') {
            // Wrapping this in a custom function so you can’t call `removeEventListener`
            // on it.
            function handler(...args) {
              return value.call(this, ...args);
            }
            remoteEvents.properties.set(property, handler);
            this.addEventListener(event, handler);
          } else {
            remoteEvents.properties.delete(property);
          }
          if (currentListener) {
            this.removeEventListener(event, currentListener);
          }
        }
      };
    }
    Object.defineProperties(this, propertyDescriptors);
    Object.assign(this, initialPropertiesToSet);
  }
  attributeChangedCallback(attribute, _oldValue, newValue) {
    if (attribute === 'slot' && this.constructor.slottable) {
      internals.updateRemoteElementAttribute(this, attribute, newValue ? String(newValue) : undefined);
      return;
    }
    const {
      remotePropertyDefinitions,
      remoteAttributeDefinitions,
      __attributeToPropertyMap: attributeToPropertyMap
    } = this.constructor;
    if (remoteAttributeDefinitions.has(attribute)) {
      internals.updateRemoteElementAttribute(this, attribute, newValue);
      return;
    }
    const property = attributeToPropertyMap.get(attribute);
    const propertyDefinition = property == null ? property : remotePropertyDefinitions.get(property);
    if (propertyDefinition == null) return;
    this[property] = convertAttributeValueToProperty(newValue, propertyDefinition.type);
  }
  connectedCallback() {
    // Ensure a connection is made with the host environment, so that
    // the event will be emitted even if no listener is directly attached
    // to this element.
    for (const [event, descriptor] of this.constructor.remoteEventDefinitions.entries()) {
      if (descriptor.bubbles) {
        this.addEventListener(event, noopBubblesEventListener);
      }
    }
  }
  disconnectedCallback() {
    for (const [event, descriptor] of this.constructor.remoteEventDefinitions.entries()) {
      if (descriptor.bubbles) {
        this.removeEventListener(event, noopBubblesEventListener);
      }
    }
  }
  addEventListener(type, listener, options) {
    const {
      remoteEventDefinitions,
      __eventToPropertyMap: eventToPropertyMap
    } = this.constructor;
    const listenerDefinition = remoteEventDefinitions.get(type);
    const property = eventToPropertyMap.get(type);
    if (listenerDefinition == null && property == null) {
      return super.addEventListener(type, listener, options);
    }
    const remoteEvents = getRemoteEvents(this);
    const remoteEvent = getRemoteEventRecord.call(this, type, {
      property,
      definition: listenerDefinition
    });
    const normalizedListener = typeof options === 'object' && options?.once ? (...args) => {
      const result = typeof listener === 'object' ? listener.handleEvent(...args) : listener.call(this, ...args);
      removeRemoteListener.call(this, type, listener, listenerRecord);
      return result;
    } : listener;
    const listenerRecord = [normalizedListener, remoteEvent];
    remoteEvent.listeners.add(listener);
    remoteEvents.listeners.set(listener, listenerRecord);
    super.addEventListener(type, normalizedListener, options);
    if (typeof options === 'object' && options.signal) {
      options.signal.addEventListener('abort', () => {
        removeRemoteListener.call(this, type, listener, listenerRecord);
      }, {
        once: true
      });
    }
    if (listenerDefinition) {
      internals.updateRemoteElementEventListener(this, type, remoteEvent.dispatch);
    } else {
      internals.updateRemoteElementProperty(this, property, remoteEvent.dispatch);
    }
  }
  removeEventListener(type, listener, options) {
    const listenerRecord = REMOTE_EVENTS.get(this)?.listeners.get(listener);
    const normalizedListener = listenerRecord ? listenerRecord[0] : listener;
    super.removeEventListener(type, normalizedListener, options);
    if (listenerRecord == null) return;
    removeRemoteListener.call(this, type, listener, listenerRecord);
  }

  /**
   * Updates a single remote property on an element node. If the element is
   * connected to a remote root, this function will also make a `mutate()` call
   * to communicate the change to the host.
   */
  updateRemoteProperty(name, value) {
    internals.updateRemoteElementProperty(this, name, value);
  }

  /**
   * Updates a single remote attribute on an element node. If the element is
   * connected to a remote root, this function will also make a `mutate()` call
   * to communicate the change to the host.
   */
  updateRemoteAttribute(name, value) {
    internals.updateRemoteElementAttribute(this, name, value);
  }

  /**
   * Performs a method through `RemoteConnection.call()`, using the remote ID and
   * connection for the provided node.
   */
  callRemoteMethod(method, ...args) {
    return internals.callRemoteElementMethod(this, method, ...args);
  }
}

// Utilities

const REMOTE_EVENTS = new WeakMap();
function getRemoteEvents(element) {
  let events = REMOTE_EVENTS.get(element);
  if (events) return events;
  events = {
    events: new Map(),
    properties: new Map(),
    listeners: new WeakMap()
  };
  REMOTE_EVENTS.set(element, events);
  return events;
}
function getRemoteEventRecord(type, {
  property,
  definition
}) {
  const remoteEvents = getRemoteEvents(this);
  let remoteEvent = remoteEvents.events.get(type);
  if (remoteEvent == null) {
    remoteEvent = {
      name: type,
      property,
      definition,
      listeners: new Set(),
      dispatch: (...args) => {
        const event = definition?.dispatchEvent?.apply(this, args) ?? new RemoteEvent.RemoteEvent(type, {
          detail: args[0],
          bubbles: definition?.bubbles
        });
        this.dispatchEvent(event);
        return event.response;
      }
    };
    remoteEvents.events.set(type, remoteEvent);
  }
  return remoteEvent;
}
function removeRemoteListener(type, listener, listenerRecord) {
  const remoteEvents = getRemoteEvents(this);
  const remoteEvent = listenerRecord[1];
  remoteEvent.listeners.delete(listener);
  remoteEvents.listeners.delete(listener);
  if (remoteEvent.listeners.size > 0) return;
  remoteEvents.events.delete(type);
  if (remoteEvent.property) {
    if (internals.remoteProperties(this)?.[remoteEvent.property] === remoteEvent.dispatch) {
      internals.updateRemoteElementProperty(this, remoteEvent.property, undefined);
    }
  } else {
    if (internals.remoteEventListeners(this)?.[type] === remoteEvent.dispatch) {
      internals.updateRemoteElementEventListener(this, type, undefined);
    }
  }
}
function saveRemoteProperty(name, description, observedAttributes, remotePropertyDefinitions, attributeToPropertyMap, eventToPropertyMap) {
  if (remotePropertyDefinitions.has(name)) {
    return remotePropertyDefinitions.get(name);
  }
  const looksLikeEventCallback = name[0] === 'o' && name[1] === 'n';
  const resolvedDescription = description ?? {};
  let {
    alias
  } = resolvedDescription;
  const {
    type = looksLikeEventCallback ? Function : String,
    attribute = type !== Function,
    event = looksLikeEventCallback,
    default: defaultValue = type === Boolean ? false : undefined
  } = resolvedDescription;
  if (alias == null) {
    // Svelte lowercases property names before assigning them to elements,
    // this ensures that those properties are forwarded to their canonical
    // names.
    const lowercaseProperty = name.toLowerCase();
    if (lowercaseProperty !== name) {
      alias = [lowercaseProperty];
    }

    // Preact (and others) automatically treat properties that start with
    // `on` as being event listeners, and uses an actual event listener for
    // them. This alias gives wrapping components an alternative property
    // to write to that won't be treated as an event listener.
    if (looksLikeEventCallback) {
      alias ??= [];
      alias.unshift(`_${name}`);
    }
  }
  let attributeName;
  if (attribute === true) {
    attributeName = camelToKebabCase(name);
  } else if (typeof attribute === 'string') {
    attributeName = attribute;
  }
  if (attributeName) {
    if (Array.isArray(observedAttributes)) {
      observedAttributes.push(attributeName);
    } else {
      observedAttributes.add(attributeName);
    }
    attributeToPropertyMap.set(attributeName, name);
  }
  let eventName;
  if (event === true) {
    eventName = camelToKebabCase(looksLikeEventCallback ? name.slice(2) : name);
  } else if (typeof event === 'string') {
    eventName = event;
  }
  if (eventName) {
    eventToPropertyMap.set(eventName, name);
  }
  const definition = {
    name,
    type,
    alias,
    event: eventName,
    attribute: attributeName,
    default: defaultValue
  };
  remotePropertyDefinitions.set(name, definition);
  if (alias) {
    for (const propertyAlias of alias) {
      remotePropertyDefinitions.set(propertyAlias, definition);
    }
  }
  return definition;
}
function convertAttributeValueToProperty(value, type) {
  if (value == null) return undefined;
  switch (type) {
    case Boolean:
      return value != null && value !== 'false';
    case Object:
    case Array:
      try {
        return JSON.parse(value);
      } catch {
        return undefined;
      }
    case String:
      return String(value);
    case Number:
      return Number.parseFloat(value);
    case Function:
      return undefined;
    default:
      {
        return type.parse?.(value);
      }
  }
}
function camelToKebabCase(str) {
  return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
}
function noopBubblesEventListener() {}

exports.RemoteElement = RemoteElement;
exports.createRemoteElement = createRemoteElement;
