/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

"use strict";

const { throttle } = require("resource://devtools/shared/throttle.js");

class RootResourceCommand {
  /**
   * This class helps retrieving existing and listening to "root" resources.
   *
   * This is a fork of ResourceCommand, but specific to context-less
   * resources which can be listened to right away when connecting to the RDP server.
   *
   * The main difference in term of implementation is that:
   * - we receive a root front as constructor argument (instead of `commands` object)
   * - we only listen for RDP events on the Root actor (instead of watcher and target actors)
   * - there is no legacy listener support
   * - there is no resource transformers
   * - there is a lot of logic around targets that is removed here.
   *
   * See ResourceCommand for comments and jsdoc.
   *
   * TODO Bug 1758530 - Investigate sharing code with ResourceCommand instead of forking.
   *
   * @param object commands
   *        The commands object with all interfaces defined from devtools/shared/commands/
   * @param object rootFront
   *        Front for the Root actor.
   */
  constructor({ commands, rootFront }) {
    this.rootFront = rootFront ? rootFront : commands.client.mainRoot;

    this._onResourceAvailable = this._onResourceAvailable.bind(this);
    this._onResourceDestroyed = this._onResourceDestroyed.bind(this);
    this._onResourceAvailableArray = this._onResourceAvailableArray.bind(this);
    this._onResourceDestroyedArray = this._onResourceDestroyedArray.bind(this);

    this._watchers = [];

    this._pendingWatchers = new Set();

    this._cache = [];
    this._listenedResources = new Set();

    this._processingExistingResources = new Set();

    this._notifyWatchers = this._notifyWatchers.bind(this);
    this._throttledNotifyWatchers = throttle(this._notifyWatchers, 100);
  }

  getAllResources(resourceType) {
    return this._cache.filter(r => r.resourceType === resourceType);
  }

  getResourceById(resourceType, resourceId) {
    return this._cache.find(
      r => r.resourceType === resourceType && r.resourceId === resourceId
    );
  }

  async watchResources(resources, options) {
    const {
      onAvailable,
      onUpdated,
      onDestroyed,
      ignoreExistingResources = false,
    } = options;

    if (typeof onAvailable !== "function") {
      throw new Error(
        "RootResourceCommand.watchResources expects an onAvailable function as argument"
      );
    }

    for (const type of resources) {
      if (!this._isValidResourceType(type)) {
        throw new Error(
          `RootResourceCommand.watchResources invoked with an unknown type: "${type}"`
        );
      }
    }

    const pendingWatcher = {
      resources,
      onAvailable,
    };
    this._pendingWatchers.add(pendingWatcher);

    if (!this._listenerRegistered) {
      this._listenerRegistered = true;
      this.rootFront.on(
        "resources-available-array",
        this._onResourceAvailableArray
      );
      this.rootFront.on(
        "resources-destroyed-array",
        this._onResourceDestroyedArray
      );
    }

    const promises = [];
    for (const resource of resources) {
      promises.push(this._startListening(resource));
    }
    await Promise.all(promises);

    this._notifyWatchers();

    this._pendingWatchers.delete(pendingWatcher);

    const watchedResources = pendingWatcher.resources;

    if (!watchedResources.length) {
      return;
    }

    this._watchers.push({
      resources: watchedResources,
      onAvailable,
      onUpdated,
      onDestroyed,
      pendingEvents: [],
    });

    if (!ignoreExistingResources) {
      await this._forwardExistingResources(watchedResources, onAvailable);
    }
  }

  unwatchResources(resources, options) {
    const { onAvailable } = options;

    if (typeof onAvailable !== "function") {
      throw new Error(
        "RootResourceCommand.unwatchResources expects an onAvailable function as argument"
      );
    }

    for (const type of resources) {
      if (!this._isValidResourceType(type)) {
        throw new Error(
          `RootResourceCommand.unwatchResources invoked with an unknown type: "${type}"`
        );
      }
    }

    const allWatchers = [...this._watchers, ...this._pendingWatchers];
    for (const watcherEntry of allWatchers) {
      if (watcherEntry.onAvailable == onAvailable) {
        watcherEntry.resources = watcherEntry.resources.filter(resourceType => {
          return !resources.includes(resourceType);
        });
      }
    }
    this._watchers = this._watchers.filter(entry => {
      return !!entry.resources.length;
    });

    for (const resource of resources) {
      const isResourceWatched = allWatchers.some(watcherEntry =>
        watcherEntry.resources.includes(resource)
      );

      if (!isResourceWatched && this._listenedResources.has(resource)) {
        this._stopListening(resource);
      }
    }
  }

  clearResources(resourceTypes) {
    if (!Array.isArray(resourceTypes)) {
      throw new Error("clearResources expects an array of resource types");
    }
    // Clear the cached resources of the type.
    this._cache = this._cache.filter(
      cachedResource => !resourceTypes.includes(cachedResource.resourceType)
    );

    if (resourceTypes.length) {
      this.rootFront.clearResources(resourceTypes);
    }
  }

  async waitForNextResource(
    resourceType,
    { ignoreExistingResources = false, predicate } = {}
  ) {
    predicate = predicate || (resource => !!resource);

    let resolve;
    const promise = new Promise(r => (resolve = r));
    const onAvailable = async resources => {
      const matchingResource = resources.find(resource => predicate(resource));
      if (matchingResource) {
        this.unwatchResources([resourceType], { onAvailable });
        resolve(matchingResource);
      }
    };

    await this.watchResources([resourceType], {
      ignoreExistingResources,
      onAvailable,
    });
    return { onResource: promise };
  }

  async _onResourceAvailableArray(array) {
    for (const [resourceType, resources] of array) {
      for (const resource of resources) {
        if (!("resourceType" in resource)) {
          resource.resourceType = resourceType;
        }
      }
      this._onResourceAvailable(resources);
    }
  }

  async _onResourceDestroyedArray(context, array) {
    for (const [resourceType, resources] of array) {
      for (const resource of resources) {
        if (!("resourceType" in resource)) {
          resource.resourceType = resourceType;
        }
      }
      this._onResourceDestroyed(resources);
    }
  }

  _onResourceAvailable(resources) {
    for (const resource of resources) {
      const { resourceType } = resource;

      resource.isAlreadyExistingResource =
        this._processingExistingResources.has(resourceType);

      this._queueResourceEvent("available", resourceType, resource);

      this._cache.push(resource);
    }

    this._throttledNotifyWatchers();
  }

  _onResourceDestroyed(resources) {
    for (const resource of resources) {
      const { resourceType, resourceId } = resource;

      let index = -1;
      if (resourceId) {
        index = this._cache.findIndex(
          cachedResource =>
            cachedResource.resourceType == resourceType &&
            cachedResource.resourceId == resourceId
        );
      } else {
        index = this._cache.indexOf(resource);
      }
      if (index >= 0) {
        this._cache.splice(index, 1);
      } else {
        console.warn(
          `Resource ${resourceId || ""} of ${resourceType} was not found.`
        );
      }

      this._queueResourceEvent("destroyed", resourceType, resource);
    }
    this._throttledNotifyWatchers();
  }

  _queueResourceEvent(callbackType, resourceType, update) {
    for (const { resources, pendingEvents } of this._watchers) {
      if (!resources.includes(resourceType)) {
        continue;
      }
      if (pendingEvents.length) {
        const lastEvent = pendingEvents[pendingEvents.length - 1];
        if (lastEvent.callbackType == callbackType) {
          lastEvent.updates.push(update);
          continue;
        }
      }
      pendingEvents.push({
        callbackType,
        updates: [update],
      });
    }
  }

  _notifyWatchers() {
    for (const watcherEntry of this._watchers) {
      const { onAvailable, onDestroyed, pendingEvents } = watcherEntry;
      watcherEntry.pendingEvents = [];

      for (const { callbackType, updates } of pendingEvents) {
        try {
          if (callbackType == "available") {
            onAvailable(updates, { areExistingResources: false });
          } else if (callbackType == "destroyed" && onDestroyed) {
            onDestroyed(updates);
          }
        } catch (e) {
          console.error(
            "Exception while calling a RootResourceCommand",
            callbackType,
            "callback",
            ":",
            e
          );
        }
      }
    }
  }

  _isValidResourceType(type) {
    return this.ALL_TYPES.includes(type);
  }

  async _startListening(resourceType) {
    if (this._listenedResources.has(resourceType)) {
      return;
    }
    this._listenedResources.add(resourceType);

    this._processingExistingResources.add(resourceType);

    // For now, if the server doesn't support the resource type
    // act as if we were listening, but do nothing.
    // Calling watchResources/unwatchResources will work fine,
    // but no resource will be notified.
    if (this.rootFront.traits.resources?.[resourceType]) {
      await this.rootFront.watchResources([resourceType]);
    } else {
      console.warn(
        `Ignored watchRequest, resourceType "${resourceType}" not found in rootFront.traits.resources`
      );
    }
    this._processingExistingResources.delete(resourceType);
  }

  async _forwardExistingResources(resourceTypes, onAvailable) {
    const existingResources = this._cache.filter(resource =>
      resourceTypes.includes(resource.resourceType)
    );
    if (existingResources.length) {
      await onAvailable(existingResources, { areExistingResources: true });
    }
  }

  _stopListening(resourceType) {
    if (!this._listenedResources.has(resourceType)) {
      throw new Error(
        `Stopped listening for resource '${resourceType}' that isn't being listened to`
      );
    }
    this._listenedResources.delete(resourceType);

    this._cache = this._cache.filter(
      cachedResource => cachedResource.resourceType !== resourceType
    );

    if (
      !this.rootFront.isDestroyed() &&
      this.rootFront.traits.resources?.[resourceType]
    ) {
      this.rootFront.unwatchResources([resourceType]);
    }
  }
}

RootResourceCommand.TYPES = RootResourceCommand.prototype.TYPES = {
  EXTENSIONS_BGSCRIPT_STATUS: "extensions-backgroundscript-status",
};
RootResourceCommand.ALL_TYPES = RootResourceCommand.prototype.ALL_TYPES =
  Object.values(RootResourceCommand.TYPES);
module.exports = RootResourceCommand;
