common/SharedState.js

import {
  isPlainObject,
  isString,
  isFunction,
} from '@ircam/sc-utils';

import ParameterBag from './ParameterBag.js';
import PromiseStore from './PromiseStore.js';
import {
  DELETE_REQUEST,
  DELETE_RESPONSE,
  DELETE_ERROR,
  DELETE_NOTIFICATION,
  DETACH_REQUEST,
  DETACH_RESPONSE,
  DETACH_ERROR,
  UPDATE_REQUEST,
  UPDATE_RESPONSE,
  UPDATE_ABORT,
  UPDATE_NOTIFICATION,
  HAS_SIBLINGS_NOTIFICATION,
} from './constants.js';

import {
  kStateManagerClient,
  kStateManagerDeleteState,
  kPendingSharedStateConstructionData,
} from './BaseStateManager.js';

import {
  sanitizeOnUpdateParams,
} from './shared-state-utils.js';

import warnings from './logs/warnings.js';

// for testing purposes
export const kSharedStatePromiseStore = Symbol('soundworks:shared-state-promise-store');

/**
 * Callback executed when updates are applied on a {@link SharedState}.
 *
 * @callback sharedStateOnUpdateCallback
 * @param {Object|any} newValues - Key / value pairs of the updates that have been
 *  applied to the state, or single value if the callback has been registered against
 *  a parameter name filter.
 * @param {Object|any} oldValues - Key / value pairs of the updated params before
 *  the updates has been applied to the state, or single value if the callback has been
 *  registered against a parameter name filter.
 * @param {sharedStateDeleteOnUpdateCallback} unsubscribe - Reference to unsubscribe
 *  function returned by the `SharedState#onUpdate` method.
 */

/**
 * Delete the registered {@link sharedStateOnUpdateCallback}.
 *
 * @callback sharedStateDeleteOnUpdateCallback
 */

/**
 * The `SharedState` is one of the most important and versatile abstraction provided
 * by `soundworks`. It represents a set of parameters that are synchronized across
 * every nodes of the application (clients and server) that declared some interest
 * to the shared state.
 *
 * A `SharedState` instance is created according to a shared state class definition
 * which is composed of a {@link SharedStateClassName} and of a {@link SharedStateClassDescription}
 * registered in the {@link ServerStateManager}. Any number of `SharedState`s
 * can be created from a single class definition.
 *
 * A shared state can be created both by the clients or by the server, in which case
 * it is generally considered as a global state of the application. Similarly any
 * node of the application (clients or server) can declare interest and "attach" to
 * a state created by another node. All node attached to a state can modify its values
 * and/or react to the modifications applied by other nodes.
 *
 * Tutorial: {@link https://soundworks.dev/guide/state-manager.html}
 *
 * ```
 * // server-side
 * import { Server } from '@soundworks/server/index.js';
 *
 * const server = new Server(config);
 * // define a shared state class
 * server.stateManager.defineClass('some-global-state', {
 *   myRandom: {
 *     type: 'float',
 *     default: 0,
 *   }
 * });
 *
 * await server.start();
 *
 * // create a global state server-side
 * const globalState = await server.stateManager.create('some-global-state');
 * // listen and react to the changes made by the clients
 * globalState.onUpdate(updates => console.log(updates));
 * ```
 *
 * ```
 * // client-side
 * import { Client } from '@soundworks/client.index.js';
 *
 * const client = new Client(config);
 * await client.start();
 *
 * // attach to the global state created by the server
 * const globalState = await client.stateManager.attach('some-global-state');
 *
 * // update the value of a `myRandom` parameter every seconds
 * setInterval(() => {
 *   globalState.set({ myRandom: Math.random() });
 * }, 1000);
 * ```
 */
class SharedState {
  #id = null;
  #instanceId = null;
  #className = null;
  #isOwner = null;
  #client = null;
  #manager = null;
  #filter = null;
  #detached = false;
  #hasSiblings = false;
  #parameters = null;
  #onUpdateCallbacks = new Set();
  #onDetachCallbacks = new Set();
  #onDeleteCallbacks = new Set();
  #waitForConditions = new Set();

  constructor() {
    // cf. BaseStateManager#buildSharedState to get the whole pattern
    // rationale is to have a clean constructor API for future derived classes
    this.#manager = globalThis[kPendingSharedStateConstructionData].manager;
    this.#client = globalThis[kPendingSharedStateConstructionData].manager[kStateManagerClient];
    this.#className = globalThis[kPendingSharedStateConstructionData].className;
    this.#id = globalThis[kPendingSharedStateConstructionData].stateId;
    this.#instanceId =globalThis[kPendingSharedStateConstructionData]. instanceId;
    this.#isOwner = globalThis[kPendingSharedStateConstructionData].isOwner; // may be any node
    this.#filter = globalThis[kPendingSharedStateConstructionData].filter;

    try {
      this.#parameters = new ParameterBag(
        globalThis[kPendingSharedStateConstructionData].classDescription,
        globalThis[kPendingSharedStateConstructionData].initValues,
      );
    } catch (err) {
      throw new Error(`Cannot construct 'SharedState' (${this.#className}): ${err.message}`);
    }

    /** @private */
    this[kSharedStatePromiseStore] = new PromiseStore(this.constructor.name);

    this.#client.transport.addListener(`${UPDATE_RESPONSE}-${this.#id}-${this.#instanceId}`, this.#onUpdateResponse);
    this.#client.transport.addListener(`${UPDATE_ABORT}-${this.#id}-${this.#instanceId}`, this.#onUpdateAbort);
    this.#client.transport.addListener(`${UPDATE_NOTIFICATION}-${this.#id}-${this.#instanceId}`, this.#onUpdateNotification);
    this.#client.transport.addListener(`${DELETE_NOTIFICATION}-${this.#id}-${this.#instanceId}`, this.#onDeleteNotification);

    if (this.#isOwner) {
      this.#client.transport.addListener(`${DELETE_RESPONSE}-${this.#id}-${this.#instanceId}`, this.#onDeleteResponse);
      this.#client.transport.addListener(`${DELETE_ERROR}-${this.#id}`, this.#onDeleteError);
      this.#client.transport.addListener(`${HAS_SIBLINGS_NOTIFICATION}-${this.#id}-${this.#instanceId}`, this.#onHasSiblingsNotification);
    } else {
      this.#client.transport.addListener(`${DETACH_RESPONSE}-${this.#id}-${this.#instanceId}`, this.#onDetachResponse);
      this.#client.transport.addListener(`${DETACH_ERROR}-${this.#id}`, this.#onDetachError);
    }
  }

  #cleanup() {
    this.#detached = true;
    this.#manager[kStateManagerDeleteState](this.#id);

    this.#client.transport.removeAllListeners(`${UPDATE_RESPONSE}-${this.#id}-${this.#instanceId}`);
    this.#client.transport.removeAllListeners(`${UPDATE_NOTIFICATION}-${this.#id}-${this.#instanceId}`);
    this.#client.transport.removeAllListeners(`${UPDATE_ABORT}-${this.#id}-${this.#instanceId}`);
    this.#client.transport.removeAllListeners(`${DELETE_NOTIFICATION}-${this.#id}-${this.#instanceId}`);

    if (this.#isOwner) {
      this.#client.transport.removeAllListeners(`${DELETE_RESPONSE}-${this.#id}-${this.#instanceId}`);
      this.#client.transport.removeAllListeners(`${DELETE_ERROR}-${this.#id}-${this.#instanceId}`);
      this.#client.transport.removeAllListeners(`${HAS_SIBLINGS_NOTIFICATION}-${this.#id}-${this.#instanceId}`);
    } else {
      this.#client.transport.removeAllListeners(`${DETACH_RESPONSE}-${this.#id}-${this.#instanceId}`);
      this.#client.transport.removeAllListeners(`${DETACH_ERROR}-${this.#id}-${this.#instanceId}`);
    }
  }

  #onUpdateResponse = async (reqId, updates) => {
    const updated = await this.#commit(updates, true, true);
    this[kSharedStatePromiseStore].resolve(reqId, updated);
    this.#checkPendingWaitForConditions();
  };

  #onUpdateAbort = async (reqId, updates) => {
    const updated = await this.#commit(updates, false, true);
    this[kSharedStatePromiseStore].resolve(reqId, updated);
    this.#checkPendingWaitForConditions();
  };

  #onUpdateNotification = updates => {
    this.#commit(updates, true, false);
    this.#checkPendingWaitForConditions();
  };

  #onDetachResponse = async (reqId) => {
    this.#cleanup();

    for (let callback of this.#onDetachCallbacks) {
      try {
        await callback();
      } catch (err) {
        console.log(err.message);
      }
    }

    this.#onDetachCallbacks.clear();
    this.#onDeleteCallbacks.clear();
    this[kSharedStatePromiseStore].resolve(reqId, this);
    this[kSharedStatePromiseStore].flush();
  };

  #onDetachError = (reqId, msg) => {
    this.#onDetachCallbacks.clear();
    this.#onDeleteCallbacks.clear();
    this[kSharedStatePromiseStore].reject(reqId, msg);
    this[kSharedStatePromiseStore].flush();
  };

  #onDeleteResponse = async (reqId) => {
    this.#cleanup();

    for (let callback of this.#onDetachCallbacks) {
      try {
        await callback();
      } catch (err) {
        console.log(err.message);
      }
    }

    for (let callback of this.#onDeleteCallbacks) {
      try {
        await callback();
      } catch (err) {
        console.log(err.message);
      }
    }

    this.#onDetachCallbacks.clear();
    this.#onDeleteCallbacks.clear();
    this[kSharedStatePromiseStore].resolve(reqId, this);
    this[kSharedStatePromiseStore].flush();
  };

  #onDeleteError = (reqId, msg) => {
    this[kSharedStatePromiseStore].reject(reqId, msg);
  };

  #onDeleteNotification = async () => {
    this.#cleanup();

    for (let callback of this.#onDetachCallbacks) {
      try {
        await callback();
      } catch (err) {
        console.error(err.message);
      }
    }

    if (this.#isOwner) {
      for (let callback of this.#onDeleteCallbacks) {
        await callback();
      }
    }

    this.#onDetachCallbacks.clear();
    this.#onDeleteCallbacks.clear();
    this[kSharedStatePromiseStore].flush();
  };

  #onHasSiblingsNotification = hasSiblings => {
    this.#hasSiblings = hasSiblings;
  };

  #checkParamNameAgainstFilters(paramName) {
    if (this.#filter === null) {
      return;
    }

    const { whiteList, blackList } = this.#filter;

    if (whiteList && !whiteList.includes(paramName)) {
      throw new Error(`Parameter '${paramName}' is not in white list`);
    } else if (blackList && blackList.includes(paramName)) {
      throw new Error(`Parameter '${paramName}' is in black list`);
    }
  }

  #sanitizeValuesAgainstFilters(values) {
    if (this.#filter === null) {
      return values;
    }

    const { whiteList, blackList } = this.#filter;

    for (let name in values) {
      if (whiteList && !whiteList.includes(name)) {
        delete values[name];
      } else if (blackList && blackList.includes(name)) {
        delete values[name];
      }
    }

    return values;
  }

  async #commit(updates, propagate = true, initiator = false) {
    const newValues = {};
    const oldValues = {};

    for (let name in updates) {
      const { immediate, event } = this.#parameters.getDescription(name);
      // @note 20211209 - we had an issue here server-side, because if the value
      // is an object or an array, the reference is shared by everybody, therefore
      // `changed` is always false and the new value is never propagated...
      // FIXED - `state.get` now returns a deep copy when `type` is `any`
      const oldValue = this.#parameters.get(name);
      const [newValue, changed] = this.#parameters.set(name, updates[name]);

      // handle immediate stuff
      if (initiator && immediate) {
        // @note - we don't need to check filterChange here because the value
        // has been updated in parameters on the `set` side so can rely on `changed`
        // to avoid retrigger listeners.
        // If the value has been overridden by the server, `changed` will true
        // anyway so it should behave correctly.
        if (!changed || event) {
          continue;
        }
      }

      newValues[name] = newValue;
      oldValues[name] = oldValue;
    }

    // do not propagate if the `UPDATE_REQUEST` as been aborted by the server.
    if (propagate && Object.keys(newValues).length > 0) {
      const callbackPromises = this.#executeUpdateCallbacks(newValues, oldValues);
      // on a given client, `await state.set(update)` resolves after all
      // update callbacks have themselves resolved
      await Promise.all(callbackPromises);
    }

    // reset events to null after propagation of all listeners
    for (let name in newValues) {
      const { event } = this.#parameters.getDescription(name);

      if (event) {
        this.#parameters.set(name, null);
      }
    }

    return newValues;
  }

  #executeUpdateCallbacks(newValues, oldValues) {
    let promises = [];

    this.#onUpdateCallbacks.forEach(listenerPayload => {
      let somePromise = this.#executeUpdateCallback(listenerPayload, newValues, oldValues);
      promises.push(somePromise);
    });

    return promises;
  }

  #executeUpdateCallback(listenerPayload, newValues, oldValues) {
    const { paramName, listener, unsubscribe } = listenerPayload;

    if (paramName === null) {
      return listener(newValues, oldValues, unsubscribe);
    } else if (paramName in newValues) {
      return listener(newValues[paramName], oldValues[paramName], unsubscribe);
    }
  }

  #checkPendingWaitForConditions() {
    const values = this.getValues();

    this.#waitForConditions.forEach(payload => {
      const { resolve, condition, timeoutId } = payload;
      let match;

      if (isFunction(condition)) {
        match = condition(values);
      } else {
        match = true;

        for (let [key, value] of Object.entries(condition)) {
          if (values[key] !== value) {
            match = false;
          }
        }
      }

      if (match === true) {
        clearTimeout(timeoutId);
        this.#waitForConditions.delete(payload);
        resolve();
      }
    });
  }

  /**
   * Id of the state
   * @type {Number}
   */
  get id() {
    return this.#id;
  }

  /**
   * Name of the underlying {@link SharedState} class.
   * @type {String}
   */
  get className() {
    return this.#className;
  }

  /**
   * @deprecated Use {@link SharedState#className} instead.
   */
  get schemaName() {
    warnings.deprecated('SharedState#schemaName', 'SharedState#className', '4.0.0-alpha.29');
    return this.className;
  }

  /**
   * Indicates if the node is the owner of the state, i.e. if it created the state.
   * @type {Boolean}
   */
  get isOwner() {
    return this.#isOwner;
  }

  /**
   * Indicates if the node as siblings, i.e. if several versions of the same state
   * exists on the network
   * Always return true if the state instance is attached
   * @type {Boolean}
   */
  get hasSiblings() {
    if (this.#detached) {
      return false;
    }

    if (!this.isOwner) {
      return true;
    }

    return this.#hasSiblings;
  }

  /**
   * Return the underlying {@link SharedStateClassDescription} or the
   * {@link SharedStateParameterDescription} if `paramName` is given.
   *
   * @param {string} [paramName=null] - If defined, returns the parameter
   *  description of the given parameter name rather than the full class description.
   * @return {SharedStateClassDescription|SharedStateParameterDescription}
   * @throws Throws if `paramName` is not null and does not exists.
   * @example
   * const classDescription = state.getDescription();
   * const paramDescription = state.getDescription('my-param');
   */
  getDescription(paramName = null) {
    try {
      return this.#parameters.getDescription(paramName);
    } catch (err) {
      throw new ReferenceError(`Cannot execute 'getDescription' on SharedState: ${err.message}`);
    }
  }

  /**
   * Update the values of the state.
   *
   * The returned `Promise` resolves on an object that contains the applied updates,
   * and resolves after all the `onUpdate` callbacks have resolved themselves
   *
   * @overload
   * @param {object} updates - Key / value pairs of updates to apply to the state.
   * @returns {Promise<Object>} - Promise to the (coerced) updates.
   */
  /**
   * Update the values of the state.
   *
   * The returned `Promise` resolves on an object that contains the applied updates,
   * and resolves after all the `onUpdate` callbacks have resolved themselves
   *
   * @overload
   * @param {SharedStateParameterName} name - Name of the parameter.
   * @param {*} value - Value of the parameter.
   * @returns {Promise<Object>} - Promise to the (coerced) updates.
   */
  /**
   * Update the values of the state.
   *
   * The returned `Promise` resolves on an object that contains the applied updates,
   * and resolves after all the `onUpdate` callbacks have resolved themselves, i.e.:
   * ```js
   * server.stateManager.defineClass('test', {
   *   myBool: { type: 'boolean', default: false },
   * });
   * const a = await server.stateManager.create('a');
   *
   * let asyncCallbackCalled = false;
   *
   * a.onUpdate(updates => {
   *   return new Promise(resolve => {
   *     setTimeout(() => {
   *       asyncCallbackCalled = true;
   *       resolve();
   *     }, 100);
   *   });
   * });
   *
   * const updates = await a.set({ myBool: true });
   * assert.equal(asyncCallbackCalled, true);
   * assert.deepEqual(updates, { myBool: true });
   * ```
   *
   * Alternative signatures:
   * - `await state.set(updates)`
   * - `await state.set(name, value)`
   *
   * @param {object} updates - Key / value pairs of updates to apply to the state.
   * @returns {Promise<Object>} - Promise to the (coerced) updates.
   *
   * @example
   * const state = await client.stateManager.attach('globals');
   * const updates = await state.set({ myParam: Math.random() });
   */
  async set(updates) {
    if (this.#detached) {
      return;
    }

    if (isPlainObject(arguments[0]) && isPlainObject(arguments[1])) {
      warnings.removed('`context` argument in SharedState.set(updates, context)', 'a regular parameter configured with `event=true`', '4.0.0-alpha.29');
    }

    if (arguments.length === 2 && isString(updates)) {
      updates = { [updates]: arguments[1] };
    } else if (!isPlainObject(updates)) {
      throw new TypeError(`Cannot execute 'set' on SharedState (${this.#className}): 'updates' argument should be an object`);
    }

    const newValues = {};
    const oldValues = {};
    const locallyResolvedParam = {};
    const networkPropagatedParams = {};
    let hasLocalParam = false;
    let hasRegularSharedParam = false;
    let hasNonAcknowledgedParams = false;
    let syncedOnUpdate = false;

    for (let name in updates) {
      // Throw early if some param is blacklisted or not whitelisted
      try {
        this.#checkParamNameAgainstFilters(name);
      } catch (err) {
        throw new DOMException(`Cannot execute 'set' on SharedState (${this.#className}): ${err.message}`, 'NotSupportedError');
      }
      // Try to coerce value now, so that eventual errors are triggered early
      // on the node requesting the update and not only on the server side.
      try {
        // This also throws if name does not exists
        this.#parameters.coerceValue(name, updates[name]);
      } catch (err) {
        throw new TypeError(`Cannot execute 'set' on SharedState (${this.#className}): ${err.message}`);
      }

      // ### `immediate` modifier behavior
      //
      // If immediate=true
      //  - call listeners if value changed
      //  - go through normal server path
      //  - retrigger only if response from server is different from current value
      // If immediate=true && (filterChange=false || event=true)
      //  - call listeners with value regardless it changed
      //  - go through normal server path
      //  - if the node is initiator of the update (UPDATE_RESPONSE), (re-)check
      //    to prevent execute the listeners twice

      // ### `local` modifier behavior
      //
      // - If the `updates` object only contains local variables, we can call the
      // update listeners and return a fulfilled promise immediately
      // - If parameters that require network communication are present, we call the
      // update callback onces with the local payload, then we need to wait for the server
      // response, call update listeners with server response and resolve promise with
      // the full payload, i.e. reintegrating the local params in the resolve payload

      // ### `acknowledge` modifier behavior
      //
      // Same as immediate, but no feedback from server

      const {
        local,
        immediate,
        filterChange,
        event,
        acknowledge,
      } = this.#parameters.getDescription(name);

      if (immediate || local || acknowledge === false) {
        const oldValue = this.#parameters.get(name);
        const [newValue, changed] = this.#parameters.set(name, updates[name]);

        // prepare data for immediate propagation of listeners
        if (changed || filterChange === false || event) {
          oldValues[name] = oldValue;
          newValues[name] = newValue;
          syncedOnUpdate = true;
        }
      }

      if (local) {
        // these don't go to the network
        hasLocalParam = true;
        locallyResolvedParam[name] = this.#parameters.get(name);
      } else if (acknowledge === false) {
        // when acknowledge is false, e.g. streaming values, values are
        // propagated on the network only if hasSiblings is true
        hasNonAcknowledgedParams = true;
        locallyResolvedParam[name] = this.#parameters.get(name);
        // propagate only if there is an attached state somewhere
        if (this.hasSiblings) {
          networkPropagatedParams[name] = updates[name];
        }
      } else {
        // note that immediate params are shared and acknowledged
        hasRegularSharedParam = true;
        networkPropagatedParams[name] = updates[name];
      }
    }

    // params that trigger a synced onUpdate call are: immediate, local, non-acknowledged
    if (syncedOnUpdate) {
      this.#executeUpdateCallbacks(newValues, oldValues);
    }

    // if we only have local params, we can resolve immediately
    if (hasLocalParam && !hasNonAcknowledgedParams && !hasRegularSharedParam) {
      return Promise.resolve(locallyResolvedParam);
    }

    // go through server-side normal behavior
    const { id: reqId, promise } = this[kSharedStatePromiseStore].createPromise();
    // store local params that should be re-merged w/ regular ones
    if (Object.keys(locallyResolvedParam).length > 0) {
      this[kSharedStatePromiseStore].associateResolveData(reqId, locallyResolvedParam);
    }

    if (Object.keys(networkPropagatedParams).length > 0) {
      this.#client.transport.emit(`${UPDATE_REQUEST}-${this.#id}-${this.#instanceId}`, reqId, networkPropagatedParams);
    }

    // if we don't have regular shared params, we can resolve now
    if (!hasRegularSharedParam) {
      this[kSharedStatePromiseStore].resolve(reqId, {});
    }

    return promise;
  }

  /**
   * Get the value of a parameter of the state.
   *
   * Be aware that in case of 'any' type, the returned value is deeply copied.
   * While this prevents from pollution of the state by mutating the reference,
   * this can also lead to performance issues when the parameter contains large
   * data. In such cases you should use the {@link SharedState#getUnsafe} method
   * and make sure to treat the returned object as readonly.
   *
   * @param {SharedStateParameterName} name - Name of the param.
   * @return {any}
   * @throws Throws if `name` does not exists.
   * @example
   * const value = state.get('paramName');
   */
  get(name) {
    if (!this.#parameters.has(name)) {
      throw new ReferenceError(`Cannot execute 'get' on SharedState (${this.#className}): Parameter '${name}' is not defined`);
    }

    // Throw early if some param is blacklisted or not whitelisted
    try {
      this.#checkParamNameAgainstFilters(name);
    } catch (err) {
      throw new DOMException(`Cannot execute 'get' on SharedState (${this.#className}): ${err.message}`, 'NotSupportedError');
    }

    return this.#parameters.get(name);
  }

  /**
   * Get an unsafe reference to the value of a parameter of the state.
   *
   * Similar to `get` but returns a reference to the underlying value in case of
   * `any` type. Can be useful if the underlying value is large (e.g. sensors
   * recordings, etc.) and deep cloning expensive. Be aware that if changes are
   * made on the returned object, the state of your application will become
   * inconsistent.
   *
   * @param {SharedStateParameterName} name - Name of the param.
   * @return {any}
   * @throws Throws if `name` does not exists.
   * @example
   * const value = state.getUnsafe('paramName');
   */
  getUnsafe(name) {
    if (!this.#parameters.has(name)) {
      throw new ReferenceError(`Cannot execute 'getUnsafe' on SharedState (${this.#className}): Parameter '${name}' is not defined`);
    }

    // Throw early if some param is blacklisted or not whitelisted
    try {
      this.#checkParamNameAgainstFilters(name);
    } catch (err) {
      throw new DOMException(`Cannot execute 'getUnsafe' on SharedState (${this.#className}): ${err.message}`, 'NotSupportedError');
    }

    return this.#parameters.getUnsafe(name);
  }

  /**
   * Get all the key / value pairs of the state.
   *
   * If a parameter is of `any` type, a deep copy is made.
   *
   * @return {object}
   * @example
   * const values = state.getValues();
   */
  getValues() {
    const values = this.#parameters.getValues();
    return this.#sanitizeValuesAgainstFilters(values);
  }

  /**
   * Get all the key / value pairs of the state.
   *
   * Similar to `getValues` but returns a reference to the underlying value in
   * case of `any` type. Can be useful if the underlying value is big (e.g.
   * sensors recordings, etc.) and deep cloning expensive. Be aware that if
   * changes are made on the returned object, the state of your application will
   * become inconsistent.
   *
   * @return {object}
   * @example
   * const values = state.getValues();
   */
  getValuesUnsafe() {
    const values = this.#parameters.getValuesUnsafe();
    return this.#sanitizeValuesAgainstFilters(values);
  }

  /**
   * Get the values with which the state has been created. May defer from the
   * default values declared in the class description.
   *
   * @return {object}
   * @example
   * const initValues = state.getInitValues();
   */
  getInitValues() {
    return this.#parameters.getInitValues();
  }

  /**
   * Get the default values as declared in the class description.
   *
   * @return {object}
   * @example
   * const defaults = state.getDefaults();
   */
  getDefaults() {
    return this.#parameters.getDefaults();
  }

  /**
   * Detach from the state. If the client is the creator of the state, the state
   * is deleted and all attached nodes get notified.
   *
   * @example
   * const state = await client.state.attach('globals');
   * // later
   * await state.detach();
   */
  async detach() {
    if (this.#detached) {
      throw new DOMException(`Cannot execute 'detach' on SharedState (${this.#className}): SharedState (${this.#id}) already detached`, 'NotSupportedError');
    }

    this.#detached = true; // mark detached early
    this.#onUpdateCallbacks.clear();

    if (this.#isOwner) {
      const { id: reqId, promise } = this[kSharedStatePromiseStore].createPromise();
      this.#client.transport.emit(`${DELETE_REQUEST}-${this.#id}-${this.#instanceId}`, reqId);
      return promise;
    } else {
      const { id: reqId, promise } = this[kSharedStatePromiseStore].createPromise();
      this.#client.transport.emit(`${DETACH_REQUEST}-${this.#id}-${this.#instanceId}`, reqId);
      return promise;
    }
  }

  /**
   * Delete the state. Only the creator/owner of the state can use this method.
   *
   * All nodes attached to the state will be detached, triggering any registered
   * `onDetach` callbacks. The creator of the state will also have its own `onDelete`
   * callback triggered. The local `onDetach` and `onDelete` callbacks will be
   * executed *before* the returned Promise resolves
   *
   * @throws Throws if the method is called by a node which is not the owner of
   * the state.
   * @example
   * const state = await client.stateManager.create('my-class-name');
   * // later
   * await state.delete();
   */
  async delete() {
    if (this.#isOwner) {
      if (this.#detached) {
        throw new DOMException(`Cannot execute 'delete' on SharedState (${this.#className}): SharedState (${this.#id}) already deleted`, 'NotSupportedError');
      }

      return this.detach();
    } else {
      throw new DOMException(`Cannot execute 'delete' on SharedState (${this.#className}): This instance is attached and not owned. Use 'SharedState#detach' instead`, 'NotSupportedError');
    }
  }

  /**
   * Subscribe to any updates in the state.
   *
   * @overload
   * @param {sharedStateOnUpdateCallback} callback
   *  Callback to execute when an update is applied on the state.
   * @param {Boolean} [executeListener=false] - Execute the callback immediately
   *  with current state values. Note that `oldValues` will be set to `{}`.
   * @returns {sharedStateDeleteOnUpdateCallback}
   * @example
   * state.onUpdate(updates => {
   *   // ...
   * });
   */
  /**
   * Subscribe to updates in the state filtered by a given parameter name.
   *
   * @overload
   * @param {SharedStateParameterName} paramName
   * @param {sharedStateOnUpdateCallback} callback
   *  Callback to execute when an update is applied on the state.
   * @param {Boolean} [executeListener=false] - Execute the callback immediately
   *  with current state values. Note that `oldValues` will be set to `{}`.
   * @returns {sharedStateDeleteOnUpdateCallback}
   * @example
   * state.onUpdate('my-param', myValue => {
   *   // ...
   * });
   */
  /**
   * Subscribe to state updates.
   *
   * Alternative signatures:
   * - `state.onUpdate(callback, executeListener)`
   * - `state.onUpdate(paramName, callback, executeListener)`
   *
   * @param {SharedStateParameterName} paramName
   * @param {sharedStateOnUpdateCallback} callback
   *  Callback to execute when an update is applied on the state.
   * @param {Boolean} [executeListener=false] - Execute the callback immediately
   *  with current state values. Note that `oldValues` will be set to `{}`.
   * @returns {sharedStateDeleteOnUpdateCallback}
   * @example
   * const unsubscribe = state.onUpdate(async (newValues, oldValues) =>  {
   *   for (let [key, value] of Object.entries(newValues)) {
   *      switch (key) {
   *        // do something
   *      }
   *   }
   * });
   *
   * // later
   * unsubscribe();
   */
  onUpdate(...args) {
    const { paramName, listener, executeListener } = sanitizeOnUpdateParams(this, ...args);

    const listenerPayload = { paramName, listener };
    this.#onUpdateCallbacks.add(listenerPayload);
    // create unsubscribe and add to payload, to pass it back to the listeners as argument
    listenerPayload.unsubscribe = () => this.#onUpdateCallbacks.delete(listenerPayload);

    if (executeListener === true) {
      const currentValues = this.getValues();
      // filter `event: true` parameters from currentValues, having them here is
      // misleading as we are in the context of a callback, not from an active read
      const classDescription = this.getDescription();

      for (let name in classDescription) {
        if (classDescription[name].event === true) {
          delete currentValues[name];
        }
      }

      this.#executeUpdateCallback(listenerPayload, currentValues, {});
    }

    return listenerPayload.unsubscribe;
  }

  /**
   * Register a function to execute when detaching from the state. The function
   * will be executed before the `detach` promise resolves.
   *
   * @param {Function} callback - Callback to execute when detaching from the state.
   *   Whether the client as called `detach`, or the state has been deleted by its
   *   creator.
   */
  onDetach(callback) {
    this.#onDetachCallbacks.add(callback);
    return () => this.#onDetachCallbacks.delete(callback);
  }

  /**
   * Register a function to execute when the state is deleted. Only called if the
   * node was the creator of the state. Is called after `onDetach` and executed
   * before the `delete` Promise resolves.
   *
   * @param {Function} callback - Callback to execute when the state is deleted.
   */
  onDelete(callback) {
    if (!this.#isOwner) {
      throw new DOMException(`Cannot execute 'onDelete' on SharedState (${this.#className}): This instance is attached and not owned. Use 'SharedState#onDetach' instead`, 'NotSupportedError');
    }

    this.#onDeleteCallbacks.add(callback);
    return () => this.#onDeleteCallbacks.delete(callback);
  }

  /**
   * Wait for a given condition in the state of the SharedState instance.
   * If the state watches the condition when `waitFor` is called, the promise is
   * resolved immediately.
   *
   * @note - mark as private until tested in real-world
   *
   * @private
   * @param {object} condition - Condition to be meet in the state for the
   *  returned promise to resolve. The condition is either a function which returns
   *  `true` when the condition is met or an object that describe a desired state
   *  of a subset the `SharedState` values.
   * @param {object} timeout - Timeout (in milliseconds) that trigger the rejection
   *  of the returned promise.
   * @return {Promise}
   */
  waitFor(condition, timeout = null) {
    if (!isPlainObject(condition) && !isFunction(condition)) {
      throw new TypeError(`Cannot execute 'waitFor' on SharedState: argument 0 must be either a function or an object`);
    }

    if (timeout !== null && (!Number.isFinite(timeout) || timeout <= 0)) {
      throw new TypeError(`Cannot execute 'waitFor' on SharedState: optional argument 1 must be a finite strictly positive number`);
    }

    // @todo - check condition keys are not filtered, this could never resolve
    const descriptionKeys = Object.keys(this.getDescription());
    const conditionKeys = Object.keys(condition);
    const diff = conditionKeys.filter(key => !descriptionKeys.includes(key));

    if (diff.length > 0) {
      throw new ReferenceError(`Cannot execute 'waitFor' on SharedState: condition contains keys (${diff.join(', ')}) that are not declared in class description`);
    }

    const { promise, resolve, reject } = Promise.withResolvers();
    const payload = { resolve, condition };

    if (timeout !== null) {
      payload.timeoutId = setTimeout(() => {
        this.#waitForConditions.delete(payload);
        reject();
      }, timeout);
    }

    this.#waitForConditions.add(payload);
    this.#checkPendingWaitForConditions();

    return promise;
  }

  // ---------------------------------------------------------------------------
  // DEPRECATED
  // ---------------------------------------------------------------------------

  /**
   * @deprecated Use {@link SharedState#getDescription} instead.
   */
  getSchema(paramName = null) {
    warnings.deprecated('SharedState#getSchema', 'SharedState#getDescription', '4.0.0-alpha.29');
    return this.getDescription(paramName);
  }

}

export default SharedState;