/* 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 {
  WorkersListener,
  // eslint-disable-next-line mozilla/reject-some-requires
} = require("resource://devtools/client/shared/workers-listener.js");

const LegacyWorkersWatcher = require("resource://devtools/shared/commands/target/legacy-target-watchers/legacy-workers-watcher.js");

class LegacyServiceWorkersWatcher extends LegacyWorkersWatcher {
  // Holds the current target URL object
  #currentTargetURL;

  constructor(targetCommand, onTargetAvailable, onTargetDestroyed, commands) {
    super(targetCommand, onTargetAvailable, onTargetDestroyed);
    this._registrations = [];
    this._processTargets = new Set();
    this.commands = commands;

    // We need to listen for registration changes at least in order to properly
    // filter service workers by domain when debugging a local tab.
    //
    // A WorkerTarget instance has a url property, but it points to the url of
    // the script, whereas the url property of the ServiceWorkerRegistration
    // points to the URL controlled by the service worker.
    //
    // Historically we have been matching the service worker registration URL
    // to match service workers for local tab tools (app panel & debugger).
    // Maybe here we could have some more info on the actual worker.
    this._workersListener = new WorkersListener(this.rootFront, {
      registrationsOnly: true,
    });

    // Note that this is called much more often than when a registration
    // is created or destroyed. WorkersListener notifies of anything that
    // potentially impacted workers.
    // I use it as a shortcut in this first patch. Listening to rootFront's
    // "serviceWorkerRegistrationListChanged" should be enough to be notified
    // about registrations. And if we need to also update the
    // "debuggerServiceWorkerStatus" from here, then we would have to
    // also listen to "registration-changed" one each registration.
    this._onRegistrationListChanged =
      this._onRegistrationListChanged.bind(this);
    this._onDocumentEvent = this._onDocumentEvent.bind(this);

    // Flag used from the parent class to listen to process targets.
    // Decision tree is complicated, keep all logic in the parent methods.
    this._isServiceWorkerWatcher = true;
  }

  /**
   * Override from LegacyWorkersWatcher.
   *
   * We record all valid service worker targets (ie workers that match a service
   * worker registration), but we will only notify about the ones which match
   * the current domain.
   */
  _recordWorkerTarget(workerTarget) {
    return !!this._getRegistrationForWorkerTarget(workerTarget);
  }

  // Override from LegacyWorkersWatcher.
  _supportWorkerTarget(workerTarget) {
    if (!workerTarget.isServiceWorker) {
      return false;
    }

    const registration = this._getRegistrationForWorkerTarget(workerTarget);
    return registration && this._isRegistrationValidForTarget(registration);
  }

  // Override from LegacyWorkersWatcher.
  async listen() {
    // Listen to the current target front.
    this.target = this.targetCommand.targetFront;

    if (this.targetCommand.descriptorFront.isTabDescriptor) {
      this.#currentTargetURL = new URL(this.targetCommand.targetFront.url);
    }

    this._workersListener.addListener(this._onRegistrationListChanged);

    // Fetch the registrations before calling listen, since service workers
    // might already be available and will need to be compared with the existing
    // registrations.
    await this._onRegistrationListChanged();

    if (this.targetCommand.descriptorFront.isTabDescriptor) {
      await this.commands.resourceCommand.watchResources(
        [this.commands.resourceCommand.TYPES.DOCUMENT_EVENT],
        {
          onAvailable: this._onDocumentEvent,
          ignoreExistingResources: true,
        }
      );
    }

    await super.listen();
  }

  // Override from LegacyWorkersWatcher.
  unlisten(...args) {
    this._workersListener.removeListener(this._onRegistrationListChanged);

    if (this.targetCommand.descriptorFront.isTabDescriptor) {
      this.commands.resourceCommand.unwatchResources(
        [this.commands.resourceCommand.TYPES.DOCUMENT_EVENT],
        {
          onAvailable: this._onDocumentEvent,
        }
      );
    }

    super.unlisten(...args);
  }

  // Override from LegacyWorkersWatcher.
  async _onProcessAvailable({ targetFront }) {
    if (this.targetCommand.descriptorFront.isTabDescriptor) {
      // XXX: This has been ported straight from the current debugger
      // implementation. Since pauseMatchingServiceWorkers expects an origin
      // to filter matching workers, it only makes sense when we are debugging
      // a tab. However in theory, parent process debugging could pause all
      // service workers without matching anything.
      try {
        // To support early breakpoint we need to setup the
        // `pauseMatchingServiceWorkers` mechanism in each process.
        await targetFront.pauseMatchingServiceWorkers({
          origin: this.#currentTargetURL.origin,
        });
      } catch (e) {
        if (targetFront.actorID) {
          throw e;
        } else {
          console.warn(
            "Process target destroyed while calling pauseMatchingServiceWorkers"
          );
        }
      }
    }

    this._processTargets.add(targetFront);
    return super._onProcessAvailable({ targetFront });
  }

  _onProcessDestroyed({ targetFront }) {
    this._processTargets.delete(targetFront);
    return super._onProcessDestroyed({ targetFront });
  }

  _onDocumentEvent(resources) {
    for (const resource of resources) {
      if (
        resource.resourceType !==
        this.commands.resourceCommand.TYPES.DOCUMENT_EVENT
      ) {
        continue;
      }

      if (resource.name === "will-navigate") {
        // We rely on will-navigate as the onTargetAvailable for the top-level frame can
        // happen after the onTargetAvailable for processes (handled in _onProcessAvailable),
        // where we need the origin we navigate to.
        this.#currentTargetURL = new URL(resource.newURI);
        continue;
      }

      // Note that we rely on "dom-loading" rather than "will-navigate" because the
      // destroyed/available callbacks should be triggered after the Debugger
      // has cleaned up its reducers, which happens on "will-navigate".
      // On the other end, "dom-complete", which is a better mapping of "navigate", is
      // happening too late (because of resources being throttled), and would cause failures
      // in test (like browser_target_command_service_workers_navigation.js), as the new worker
      // target would already be registered at this point, and seen as something that would
      // need to be destroyed.
      if (resource.name === "dom-loading") {
        const allServiceWorkerTargets = this._getAllServiceWorkerTargets();

        for (const target of allServiceWorkerTargets) {
          // Note: we call isTargetRegistered again because calls to
          // onTargetDestroyed might have modified the list of registered targets.
          const isRegisteredAfter =
            this.targetCommand.isTargetRegistered(target);
          const isValidTarget = this._supportWorkerTarget(target);
          if (isValidTarget && !isRegisteredAfter) {
            // If the target is still valid for the current top target, call
            // onTargetAvailable as well.
            this.onTargetAvailable(target);
          }
        }
      }
    }
  }

  async _onRegistrationListChanged() {
    if (this.targetCommand.isDestroyed()) {
      return;
    }

    await this._updateRegistrations();

    // Everything after this point is not strictly necessary for sw support
    // in the target list, but it makes the behavior closer to the previous
    // listAllWorkers/WorkersListener pair.
    const allServiceWorkerTargets = this._getAllServiceWorkerTargets();
    for (const target of allServiceWorkerTargets) {
      const hasRegistration = this._getRegistrationForWorkerTarget(target);
      if (!hasRegistration) {
        // XXX: At this point the worker target is not really destroyed, but
        // historically, listAllWorkers* APIs stopped returning worker targets
        // if worker registrations are no longer available.
        if (this.targetCommand.isTargetRegistered(target)) {
          // Only emit onTargetDestroyed if it wasn't already done by
          // onNavigate (ie the target is still tracked by TargetCommand)
          this.onTargetDestroyed(target);
        }
        // Here we only care about service workers which no longer match *any*
        // registration. The worker will be completely destroyed soon, remove
        // it from the legacy worker watcher internal targetsByProcess Maps.
        this._removeTargetReferences(target);
      }
    }
  }

  // Delete the provided worker target from the internal targetsByProcess Maps.
  _removeTargetReferences(target) {
    const allProcessTargets = this._getProcessTargets().filter(t =>
      this.targetsByProcess.get(t)
    );

    for (const processTarget of allProcessTargets) {
      this.targetsByProcess.get(processTarget).delete(target);
    }
  }

  async _updateRegistrations() {
    const { registrations } =
      await this.rootFront.listServiceWorkerRegistrations();

    this._registrations = registrations;
  }

  _getRegistrationForWorkerTarget(workerTarget) {
    return this._registrations.find(r => {
      return (
        r.evaluatingWorker?.id === workerTarget.id ||
        r.activeWorker?.id === workerTarget.id ||
        r.installingWorker?.id === workerTarget.id ||
        r.waitingWorker?.id === workerTarget.id
      );
    });
  }

  _getProcessTargets() {
    return [...this._processTargets];
  }

  // Flatten all service worker targets in all processes.
  _getAllServiceWorkerTargets() {
    const allProcessTargets = this._getProcessTargets().filter(target =>
      this.targetsByProcess.get(target)
    );

    const serviceWorkerTargets = [];
    for (const target of allProcessTargets) {
      serviceWorkerTargets.push(...this.targetsByProcess.get(target));
    }
    return serviceWorkerTargets;
  }

  // Check if the registration is relevant for the current target, ie
  // corresponds to the same domain.
  _isRegistrationValidForTarget(registration) {
    if (this.targetCommand.descriptorFront.isBrowserProcessDescriptor) {
      // All registrations are valid for main process debugging.
      return true;
    }

    if (!this.targetCommand.descriptorFront.isTabDescriptor) {
      // No support for service worker targets outside of main process &
      // tab debugging.
      return false;
    }

    // For local tabs, we match ServiceWorkerRegistrations and the target
    // if they share the same hostname for their "url" properties.
    const targetDomain = this.#currentTargetURL.hostname;
    try {
      const registrationDomain = new URL(registration.url).hostname;
      return registrationDomain === targetDomain;
    } catch (e) {
      // XXX: Some registrations have an empty URL.
      return false;
    }
  }
}

module.exports = LegacyServiceWorkersWatcher;
