// Copyright (c) 2026, The Robot Web Tools Contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

'use strict';

const { TypeValidationError, OperationError } = require('./errors');
const { normalizeNodeName } = require('./utils');
const validator = require('./validator');
const debug = require('debug')('rclnodejs:parameter_event_handler');

const PARAMETER_EVENT_MSG_TYPE = 'rcl_interfaces/msg/ParameterEvent';
const PARAMETER_EVENT_TOPIC = '/parameter_events';

/**
 * @class ParameterCallbackHandle
 * Opaque handle returned when adding a parameter callback.
 * Used to remove the callback later.
 */
class ParameterCallbackHandle {
  /**
   * @param {string} parameterName - The parameter name
   * @param {string} nodeName - The fully qualified node name
   * @param {Function} callback - The callback function
   * @hideconstructor
   */
  constructor(parameterName, nodeName, callback) {
    this.parameterName = parameterName;
    this.nodeName = nodeName;
    this.callback = callback;
  }
}

/**
 * @class ParameterEventCallbackHandle
 * Opaque handle returned when adding a parameter event callback.
 * Used to remove the callback later.
 */
class ParameterEventCallbackHandle {
  /**
   * @param {Function} callback - The callback function
   * @hideconstructor
   */
  constructor(callback) {
    this.callback = callback;
  }
}

/**
 * @class ParameterEventHandler
 *
 * Monitors and responds to parameter changes on any node in the ROS 2 graph
 * by subscribing to the `/parameter_events` topic.
 *
 * Unlike {@link ParameterWatcher}, which is tied to a single remote node and
 * requires waiting for that node's parameter services, ParameterEventHandler
 * responds to parameter events from any node without needing service availability.
 *
 * Two types of callbacks are supported:
 * - **Parameter callbacks**: fired when a specific parameter on a specific node
 *   is added or changed (new_parameters + changed_parameters).
 *   Note: deleted parameters are not dispatched to parameter callbacks;
 *   use event callbacks to observe deletions.
 * - **Event callbacks**: fired for every ParameterEvent message received,
 *   including deletions.
 *
 * @example
 * const handler = node.createParameterEventHandler();
 *
 * // Watch a specific parameter on a specific node
 * const handle = handler.addParameterCallback(
 *   'my_param',
 *   '/my_node',
 *   (parameter) => {
 *     console.log(`Parameter changed: ${parameter.name} = ${parameter.value}`);
 *   }
 * );
 *
 * // Watch all parameter events
 * const eventHandle = handler.addParameterEventCallback((event) => {
 *   console.log(`Event from node: ${event.node}`);
 * });
 *
 * // Remove callbacks when done
 * handler.removeParameterCallback(handle);
 * handler.removeParameterEventCallback(eventHandle);
 *
 * // Destroy when no longer needed
 * handler.destroy();
 */
class ParameterEventHandler {
  #node;
  #subscription;
  #parameterCallbacks; // Map<string, ParameterCallbackHandle[]> keyed by "paramName\0nodeName"
  #eventCallbacks; // ParameterEventCallbackHandle[]
  #destroyed;

  /**
   * Create a ParameterEventHandler.
   *
   * @param {object} node - The rclnodejs Node used to create the subscription
   * @param {object} [options] - Options
   * @param {object} [options.qos] - QoS profile for the parameter_events subscription
   */
  constructor(node, options = {}) {
    if (!node || typeof node.createSubscription !== 'function') {
      throw new TypeValidationError('node', node, 'Node instance', {
        entityType: 'parameter event handler',
      });
    }

    if (
      options !== undefined &&
      options !== null &&
      typeof options !== 'object'
    ) {
      throw new TypeValidationError('options', options, 'object or undefined', {
        entityType: 'parameter event handler',
      });
    }

    const opts = options || {};

    this.#node = node;
    this.#parameterCallbacks = new Map();
    this.#eventCallbacks = [];
    this.#destroyed = false;

    const subscriptionOptions = opts.qos ? { qos: opts.qos } : undefined;

    this.#subscription = node.createSubscription(
      PARAMETER_EVENT_MSG_TYPE,
      PARAMETER_EVENT_TOPIC,
      subscriptionOptions,
      (event) => this.#handleEvent(event)
    );

    debug('Created ParameterEventHandler on node=%s', node.name());
  }

  /**
   * Add a callback for a specific parameter on a specific node.
   *
   * The callback is invoked whenever the named parameter is added or changed
   * on the specified node. The callback receives the parameter message object
   * (rcl_interfaces/msg/Parameter) with `name` and `value` fields.
   *
   * @param {string} parameterName - Name of the parameter to monitor
   * @param {string} nodeName - Fully qualified name of the node (e.g., '/my_node')
   * @param {Function} callback - Called with (parameter) when the parameter changes
   * @returns {ParameterCallbackHandle} Handle for removing this callback later
   * @throws {Error} If the handler has been destroyed
   * @throws {TypeError} If arguments are invalid
   */
  addParameterCallback(parameterName, nodeName, callback) {
    this.#checkNotDestroyed();

    if (typeof parameterName !== 'string' || parameterName.trim() === '') {
      throw new TypeValidationError(
        'parameterName',
        parameterName,
        'non-empty string',
        { entityType: 'parameter event handler' }
      );
    }

    if (typeof nodeName !== 'string' || nodeName.trim() === '') {
      throw new TypeValidationError('nodeName', nodeName, 'non-empty string', {
        entityType: 'parameter event handler',
      });
    }

    if (typeof callback !== 'function') {
      throw new TypeValidationError('callback', callback, 'function', {
        entityType: 'parameter event handler',
      });
    }

    const resolvedNodeName = normalizeNodeName(nodeName);
    const resolvedParamName = parameterName.trim();
    const handle = new ParameterCallbackHandle(
      resolvedParamName,
      resolvedNodeName,
      callback
    );
    const key = this.#makeKey(resolvedParamName, resolvedNodeName);

    if (!this.#parameterCallbacks.has(key)) {
      this.#parameterCallbacks.set(key, []);
    }

    // Insert at front (FILO order, matching rclpy behavior)
    this.#parameterCallbacks.get(key).unshift(handle);

    debug(
      'Added parameter callback: param=%s node=%s',
      resolvedParamName,
      resolvedNodeName
    );

    return handle;
  }

  /**
   * Configure which node parameter events will be received.
   *
   * If nodeNames is omitted or empty, the current node filter is cleared.
   * When a filter is active, parameter and event callbacks only receive
   * events from the specified nodes.
   *
   * @param {string[]} [nodeNames] - Node names to filter parameter events from.
   *   Relative names are resolved against the handler node namespace.
   * @returns {boolean} True if the filter is active or was successfully cleared.
   */
  configureNodesFilter(nodeNames) {
    this.#checkNotDestroyed();

    if (nodeNames === undefined || nodeNames === null) {
      this.#subscription.clearContentFilter();
      return !this.#subscription.hasContentFilter();
    }

    if (!Array.isArray(nodeNames)) {
      throw new TypeValidationError('nodeNames', nodeNames, 'string[]', {
        entityType: 'parameter event handler',
      });
    }

    if (nodeNames.length === 0) {
      this.#subscription.clearContentFilter();
      return !this.#subscription.hasContentFilter();
    }

    const resolvedNodeNames = nodeNames.map((nodeName, index) => {
      if (typeof nodeName !== 'string' || nodeName.trim() === '') {
        throw new TypeValidationError(
          `nodeNames[${index}]`,
          nodeName,
          'non-empty string',
          {
            entityType: 'parameter event handler',
          }
        );
      }

      const resolvedNodeName = this.#resolvePath(nodeName.trim());
      this.#validateFullyQualifiedNodePath(resolvedNodeName);
      return resolvedNodeName;
    });

    const contentFilter = {
      expression: resolvedNodeNames
        .map((_, index) => `node = %${index}`)
        .join(' OR '),
      parameters: resolvedNodeNames.map((nodeName) => `'${nodeName}'`),
    };

    this.#subscription.setContentFilter(contentFilter);
    return this.#subscription.hasContentFilter();
  }

  /**
   * Remove a previously added parameter callback.
   *
   * @param {ParameterCallbackHandle} handle - The handle returned by addParameterCallback
   * @throws {Error} If the handle is not found or handler is destroyed
   */
  removeParameterCallback(handle) {
    this.#checkNotDestroyed();

    if (!(handle instanceof ParameterCallbackHandle)) {
      throw new TypeValidationError(
        'handle',
        handle,
        'ParameterCallbackHandle',
        { entityType: 'parameter event handler' }
      );
    }

    const key = this.#makeKey(handle.parameterName, handle.nodeName);
    const callbacks = this.#parameterCallbacks.get(key);

    if (!callbacks) {
      throw new OperationError(
        `No callbacks registered for parameter '${handle.parameterName}' on node '${handle.nodeName}'`,
        { entityType: 'parameter event handler' }
      );
    }

    const index = callbacks.indexOf(handle);
    if (index === -1) {
      throw new OperationError("Callback doesn't exist", {
        entityType: 'parameter event handler',
      });
    }

    callbacks.splice(index, 1);

    if (callbacks.length === 0) {
      this.#parameterCallbacks.delete(key);
    }

    debug(
      'Removed parameter callback: param=%s node=%s',
      handle.parameterName,
      handle.nodeName
    );
  }

  /**
   * Add a callback that is invoked for every parameter event.
   *
   * The callback receives the full ParameterEvent message
   * (rcl_interfaces/msg/ParameterEvent) with `node`, `new_parameters`,
   * `changed_parameters`, and `deleted_parameters` fields.
   *
   * @param {Function} callback - Called with (event) for every ParameterEvent
   * @returns {ParameterEventCallbackHandle} Handle for removing this callback later
   * @throws {Error} If the handler has been destroyed
   * @throws {TypeError} If callback is not a function
   */
  addParameterEventCallback(callback) {
    this.#checkNotDestroyed();

    if (typeof callback !== 'function') {
      throw new TypeValidationError('callback', callback, 'function', {
        entityType: 'parameter event handler',
      });
    }

    const handle = new ParameterEventCallbackHandle(callback);

    // Insert at front (FILO order)
    this.#eventCallbacks.unshift(handle);

    debug('Added parameter event callback');

    return handle;
  }

  /**
   * Remove a previously added parameter event callback.
   *
   * @param {ParameterEventCallbackHandle} handle - The handle returned by addParameterEventCallback
   * @throws {Error} If the handle is not found or handler is destroyed
   */
  removeParameterEventCallback(handle) {
    this.#checkNotDestroyed();

    if (!(handle instanceof ParameterEventCallbackHandle)) {
      throw new TypeValidationError(
        'handle',
        handle,
        'ParameterEventCallbackHandle',
        { entityType: 'parameter event handler' }
      );
    }

    const index = this.#eventCallbacks.indexOf(handle);
    if (index === -1) {
      throw new OperationError("Callback doesn't exist", {
        entityType: 'parameter event handler',
      });
    }

    this.#eventCallbacks.splice(index, 1);

    debug('Removed parameter event callback');
  }

  /**
   * Check if the handler has been destroyed.
   *
   * @returns {boolean} True if destroyed
   */
  isDestroyed() {
    return this.#destroyed;
  }

  /**
   * Destroy the handler and clean up resources.
   * Removes the subscription and clears all callbacks.
   */
  destroy() {
    if (this.#destroyed) {
      return;
    }

    debug('Destroying ParameterEventHandler');

    if (this.#subscription) {
      try {
        this.#node.destroySubscription(this.#subscription);
      } catch (error) {
        debug('Error destroying subscription: %s', error.message);
      }
      this.#subscription = null;
    }

    this.#parameterCallbacks.clear();
    this.#eventCallbacks.length = 0;
    this.#destroyed = true;
  }

  /**
   * Get a specific parameter from a ParameterEvent message.
   *
   * @param {object} event - A ParameterEvent message
   * @param {string} parameterName - The parameter name to look for
   * @param {string} nodeName - The node name to match
   * @returns {object|null} The matching parameter message, or null
   * @static
   */
  static getParameterFromEvent(event, parameterName, nodeName) {
    const resolvedNodeName = normalizeNodeName(nodeName);
    const resolvedParamName = (parameterName || '').trim();

    if (normalizeNodeName(event.node) !== resolvedNodeName) {
      return null;
    }

    const allParams = [
      ...(event.new_parameters || []),
      ...(event.changed_parameters || []),
    ];

    for (const param of allParams) {
      if (param.name === resolvedParamName) {
        return param;
      }
    }

    return null;
  }

  /**
   * Get all parameters from a ParameterEvent message (new + changed).
   *
   * @param {object} event - A ParameterEvent message
   * @returns {object[]} Array of parameter messages
   * @static
   */
  static getParametersFromEvent(event) {
    return [
      ...(event.new_parameters || []),
      ...(event.changed_parameters || []),
    ];
  }

  /**
   * Handle incoming parameter event.
   * @private
   */
  #handleEvent(event) {
    const eventNodeName = normalizeNodeName(event.node);

    // Dispatch parameter-specific callbacks by iterating event params
    // and doing direct Map lookups (O(event_params) instead of O(registered_callbacks))
    const allParams = [
      ...(event.new_parameters || []),
      ...(event.changed_parameters || []),
    ];

    for (const parameter of allParams) {
      const key = this.#makeKey(parameter.name, eventNodeName);
      const callbacks = this.#parameterCallbacks.get(key);

      if (callbacks) {
        for (const handle of callbacks.slice()) {
          try {
            handle.callback(parameter);
          } catch (err) {
            debug(
              'Error in parameter callback for %s on %s: %s',
              parameter.name,
              eventNodeName,
              err.message
            );
          }
        }
      }
    }

    // Dispatch event-level callbacks
    for (const handle of this.#eventCallbacks.slice()) {
      try {
        handle.callback(event);
      } catch (err) {
        debug('Error in parameter event callback: %s', err.message);
      }
    }
  }

  /**
   * Create a map key from parameter name and node name.
   * @private
   */
  #makeKey(paramName, nodeName) {
    return `${paramName}\0${nodeName}`;
  }

  /**
   * Resolve a node path to the fully qualified name used in ParameterEvent.node.
   * @private
   */
  #resolvePath(nodePath) {
    // Absolute node paths are already rooted. Relative names are resolved
    // against the handler node namespace before building the content filter.
    const unresolvedPath = nodePath.startsWith('/')
      ? nodePath
      : `${this.#node.namespace().replace(/\/+$/, '')}/${nodePath}`;

    // Collapse repeated separators for inputs like '/ns//node/' or 'nested//node'.
    const resolvedPath = unresolvedPath.replace(/\/+/g, '/');

    // Preserve the root namespace as '/' and strip trailing slashes everywhere
    // else so the filter matches the canonical ParameterEvent.node format.
    if (resolvedPath === '/') {
      return resolvedPath;
    }

    return resolvedPath.replace(/\/+$/, '');
  }

  /**
   * Validate a fully qualified node path before using it in a content filter.
   * @private
   */
  #validateFullyQualifiedNodePath(nodePath) {
    const normalizedPath =
      nodePath.length > 1 ? nodePath.replace(/\/+$/, '') : nodePath;
    const separatorIndex = normalizedPath.lastIndexOf('/');
    const nodeNamespace =
      separatorIndex === 0 ? '/' : normalizedPath.slice(0, separatorIndex);
    const nodeName = normalizedPath.slice(separatorIndex + 1);

    validator.validateNamespace(nodeNamespace);
    validator.validateNodeName(nodeName);
  }

  /**
   * Check if the handler has been destroyed and throw if so.
   * @private
   */
  #checkNotDestroyed() {
    if (this.#destroyed) {
      throw new OperationError('ParameterEventHandler has been destroyed', {
        entityType: 'parameter event handler',
      });
    }
  }
}

module.exports = ParameterEventHandler;
module.exports.ParameterCallbackHandle = ParameterCallbackHandle;
module.exports.ParameterEventCallbackHandle = ParameterEventCallbackHandle;