/* 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 SOURCE_MAP_PREF = "devtools.source-map.client-service.enabled";

/**
 * A simple service to track source actors and keep a mapping between
 * original URLs and objects holding the source or style actor's ID
 * (which is used as a cookie by the devtools-source-map service) and
 * the source map URL.
 *
 * @param {object} commands
 *        The commands object with all interfaces defined from devtools/shared/commands/
 * @param {SourceMapLoader} sourceMapLoader
 *        The source-map-loader implemented in devtools/client/shared/source-map-loader/
 */
class SourceMapURLService {
  constructor(commands, sourceMapLoader) {
    this._commands = commands;
    this._sourceMapLoader = sourceMapLoader;

    this._prefValue = Services.prefs.getBoolPref(SOURCE_MAP_PREF);
    this._pendingIDSubscriptions = new Map();
    this._pendingURLSubscriptions = new Map();
    this._urlToIDMap = new Map();
    this._mapsById = new Map();
    this._sourcesLoading = null;
    this._onResourceAvailable = this._onResourceAvailable.bind(this);
    this._runningCallback = false;

    this._syncPrevValue = this._syncPrevValue.bind(this);
    this._clearAllState = this._clearAllState.bind(this);

    Services.prefs.addObserver(SOURCE_MAP_PREF, this._syncPrevValue);

    // If a tool has changed or introduced a source map
    // (e.g, by pretty-printing a source), tell the
    // source map URL service about the change, so that
    // subscribers to that service can be updated as
    // well.
    this._sourceMapLoader.on(
      "source-map-created",
      this.newSourceMapCreated.bind(this)
    );
  }

  destroy() {
    Services.prefs.removeObserver(SOURCE_MAP_PREF, this._syncPrevValue);

    this._clearAllState();

    const { resourceCommand } = this._commands;
    try {
      resourceCommand.unwatchResources(
        [
          resourceCommand.TYPES.STYLESHEET,
          resourceCommand.TYPES.SOURCE,
          resourceCommand.TYPES.DOCUMENT_EVENT,
        ],
        { onAvailable: this._onResourceAvailable }
      );
    } catch (e) {
      // If unwatchResources is called before finishing process of watchResources,
      // it throws an error during stopping listener.
    }

    this._sourcesLoading = null;
    this._pendingIDSubscriptions = null;
    this._pendingURLSubscriptions = null;
    this._urlToIDMap = null;
    this._mapsById = null;
  }

  /**
   * Subscribe to notifications about the original location of a given
   * generated location, as it may not be known at this time, may become
   * available at some unknown time in the future, or may change from one
   * location to another.
   *
   * @param {string} id The actor ID of the source.
   * @param {number} line The line number in the source.
   * @param {number} column The column number in the source.
   * @param {Function} callback A callback that may eventually be passed an
   *      an object with url/line/column properties specifying a location in
   *      the original file, or null if no particular original location could
   *      be found. The callback will run synchronously if the location is
   *      already know to the URL service.
   *
   * @return {Function} A function to call to remove this subscription. The
   *      "callback" argument is guaranteed to never run once unsubscribed.
   */
  subscribeByID(id, line, column, callback) {
    this._ensureAllSourcesPopulated();

    let pending = this._pendingIDSubscriptions.get(id);
    if (!pending) {
      pending = new Set();
      this._pendingIDSubscriptions.set(id, pending);
    }
    const entry = {
      line,
      column,
      callback,
      unsubscribed: false,
      owner: pending,
    };
    pending.add(entry);

    const map = this._mapsById.get(id);
    if (map) {
      this._flushPendingIDSubscriptionsToMapQueries(map);
    }

    return () => {
      entry.unsubscribed = true;
      entry.owner.delete(entry);
    };
  }

  /**
   * Subscribe to notifications about the original location of a given
   * generated location, as it may not be known at this time, may become
   * available at some unknown time in the future, or may change from one
   * location to another.
   *
   * @param {string} id The actor ID of the source.
   * @param {number} line The line number in the source.
   * @param {number} column The column number in the source.
   * @param {Function} callback A callback that may eventually be passed an
   *      an object with url/line/column properties specifying a location in
   *      the original file, or null if no particular original location could
   *      be found. The callback will run synchronously if the location is
   *      already know to the URL service.
   *
   * @return {Function} A function to call to remove this subscription. The
   *      "callback" argument is guaranteed to never run once unsubscribed.
   */
  subscribeByURL(url, line, column, callback) {
    this._ensureAllSourcesPopulated();

    let pending = this._pendingURLSubscriptions.get(url);
    if (!pending) {
      pending = new Set();
      this._pendingURLSubscriptions.set(url, pending);
    }
    const entry = {
      line,
      column,
      callback,
      unsubscribed: false,
      owner: pending,
    };
    pending.add(entry);

    const id = this._urlToIDMap.get(url);
    if (id) {
      this._convertPendingURLSubscriptionsToID(url, id);
      const map = this._mapsById.get(id);
      if (map) {
        this._flushPendingIDSubscriptionsToMapQueries(map);
      }
    }

    return () => {
      entry.unsubscribed = true;
      entry.owner.delete(entry);
    };
  }

  /**
   * Subscribe generically based on either an ID or a URL.
   *
   * In an ideal world we'd always know which of these to use, but there are
   * still cases where end up with a mixture of both, so this is provided as
   * a helper. If you can specifically use one of these, please do that
   * instead however.
   */
  subscribeByLocation({ id, url, line, column }, callback) {
    if (id) {
      return this.subscribeByID(id, line, column, callback);
    }

    return this.subscribeByURL(url, line, column, callback);
  }

  /**
   * Tell the URL service than some external entity has registered a sourcemap
   * in the worker for one of the source files.
   *
   * @param {Array<string>} ids The actor ids of the sources that had the map registered.
   */
  async newSourceMapCreated(ids) {
    await this._ensureAllSourcesPopulated();

    for (const id of ids) {
      const map = this._mapsById.get(id);
      if (!map) {
        // State could have been cleared.
        continue;
      }

      map.loaded = Promise.resolve();
      for (const query of map.queries.values()) {
        query.action = null;
        query.result = null;
        if (this._prefValue) {
          this._dispatchQuery(query);
        }
      }
    }
  }

  _syncPrevValue() {
    this._prefValue = Services.prefs.getBoolPref(SOURCE_MAP_PREF);

    for (const map of this._mapsById.values()) {
      for (const query of map.queries.values()) {
        this._ensureSubscribersSynchronized(query);
      }
    }
  }

  _clearAllState() {
    this._sourceMapLoader.clearSourceMaps();
    this._pendingIDSubscriptions.clear();
    this._pendingURLSubscriptions.clear();
    this._urlToIDMap.clear();
    this._mapsById.clear();
  }

  _onNewJavascript(source) {
    const { url, actor: id, sourceMapBaseURL, sourceMapURL } = source;

    this._onNewSource(id, url, sourceMapURL, sourceMapBaseURL);
  }

  _onNewStyleSheet(sheet) {
    const {
      href,
      nodeHref,
      sourceMapBaseURL,
      sourceMapURL,
      resourceId: id,
    } = sheet;
    const url = href || nodeHref;

    this._onNewSource(id, url, sourceMapURL, sourceMapBaseURL);
  }

  _onNewSource(id, url, sourceMapURL, sourceMapBaseURL) {
    this._urlToIDMap.set(url, id);
    this._convertPendingURLSubscriptionsToID(url, id);

    let map = this._mapsById.get(id);
    if (!map) {
      map = {
        id,
        url,
        sourceMapURL,
        sourceMapBaseURL,
        loaded: null,
        queries: new Map(),
      };
      this._mapsById.set(id, map);
    } else if (
      map.id !== id &&
      map.url !== url &&
      map.sourceMapURL !== sourceMapURL &&
      map.sourceMapBaseURL !== sourceMapBaseURL
    ) {
      console.warn(
        `Attempted to load populate sourcemap for source ${id} multiple times`
      );
    }

    this._flushPendingIDSubscriptionsToMapQueries(map);
  }

  _buildQuery(map, line, column) {
    const key = `${line}:${column}`;
    let query = map.queries.get(key);
    if (!query) {
      query = {
        map,
        line,
        column,
        subscribers: new Set(),
        action: null,
        result: null,
        mostRecentEmitted: null,
      };
      map.queries.set(key, query);
    }
    return query;
  }

  _dispatchQuery(query) {
    if (!this._prefValue) {
      throw new Error("This function should only be called if the pref is on.");
    }

    if (!query.action) {
      const { map } = query;

      // Call getOriginalURLs to make sure the source map has been
      // fetched.  We don't actually need the result of this though.
      if (!map.loaded) {
        map.loaded = this._sourceMapLoader.getOriginalURLs({
          id: map.id,
          url: map.url,
          sourceMapBaseURL: map.sourceMapBaseURL,
          sourceMapURL: map.sourceMapURL,
        });
      }

      const action = (async () => {
        let result = null;
        try {
          await map.loaded;
        } catch (e) {
          // SourceMapLoader.getOriginalURLs may throw, but it will handle
          // the exception and notify the user via a console message.
          // So ignore the exception here, which is meant to be used by the Debugger.
        }

        try {
          const position = await this._sourceMapLoader.getOriginalLocation({
            sourceId: map.id,
            line: query.line,
            column: query.column,
          });
          if (position && position.sourceId !== map.id) {
            result = {
              url: position.sourceUrl,
              line: position.line,
              column: position.column,
            };
          }
        } finally {
          // If this action was dispatched and then the file was pretty-printed
          // we want to ignore the result since the query has restarted.
          if (action === query.action) {
            // It is important that we consistently set the query result and
            // trigger the subscribers here in order to maintain the invariant
            // that if 'result' is truthy, then the subscribers will have run.
            const position = result;
            query.result = { position };
            this._ensureSubscribersSynchronized(query);
          }
        }
      })();
      query.action = action;
    }

    this._ensureSubscribersSynchronized(query);
  }

  _ensureSubscribersSynchronized(query) {
    // Synchronize the subscribers with the pref-disabled state if they need it.
    if (!this._prefValue) {
      if (query.mostRecentEmitted) {
        query.mostRecentEmitted = null;
        this._dispatchSubscribers(null, query.subscribers);
      }
      return;
    }

    // Synchronize the subscribers with the newest computed result if they
    // need it.
    const { result } = query;
    if (result && query.mostRecentEmitted !== result.position) {
      query.mostRecentEmitted = result.position;
      this._dispatchSubscribers(result.position, query.subscribers);
    }
  }

  _dispatchSubscribers(position, subscribers) {
    // We copy the subscribers before iterating because something could be
    // removed while we're calling the callbacks, which is also why we check
    // the 'unsubscribed' flag.
    for (const subscriber of Array.from(subscribers)) {
      if (subscriber.unsubscribed) {
        continue;
      }

      if (this._runningCallback) {
        console.error(
          "The source map url service does not support reentrant subscribers."
        );
        continue;
      }

      try {
        this._runningCallback = true;

        const { callback } = subscriber;
        callback(position ? { ...position } : null);
      } catch (err) {
        console.error("Error in source map url service subscriber", err);
      } finally {
        this._runningCallback = false;
      }
    }
  }

  _flushPendingIDSubscriptionsToMapQueries(map) {
    const subscriptions = this._pendingIDSubscriptions.get(map.id);
    if (!subscriptions || subscriptions.size === 0) {
      return;
    }
    this._pendingIDSubscriptions.delete(map.id);

    for (const entry of subscriptions) {
      const query = this._buildQuery(map, entry.line, entry.column);

      const { subscribers } = query;

      entry.owner = subscribers;
      subscribers.add(entry);

      if (query.mostRecentEmitted) {
        // Maintain the invariant that if a query has emitted a value, then
        // _all_ subscribers will have received that value.
        this._dispatchSubscribers(query.mostRecentEmitted, [entry]);
      }

      if (this._prefValue) {
        this._dispatchQuery(query);
      }
    }
  }

  _ensureAllSourcesPopulated() {
    if (!this._prefValue || this._commands.descriptorFront.isWorkerDescriptor) {
      return null;
    }

    if (!this._sourcesLoading) {
      const { resourceCommand } = this._commands;
      const { STYLESHEET, SOURCE, DOCUMENT_EVENT } = resourceCommand.TYPES;

      const onResources = resourceCommand.watchResources(
        [STYLESHEET, SOURCE, DOCUMENT_EVENT],
        {
          onAvailable: this._onResourceAvailable,
        }
      );
      this._sourcesLoading = onResources;
    }

    return this._sourcesLoading;
  }

  waitForSourcesLoading() {
    if (this._sourcesLoading) {
      return this._sourcesLoading;
    }
    return Promise.resolve();
  }

  _onResourceAvailable(resources) {
    const { resourceCommand } = this._commands;
    const { STYLESHEET, SOURCE, DOCUMENT_EVENT } = resourceCommand.TYPES;
    for (const resource of resources) {
      // Only consider top level document, and ignore remote iframes top document
      if (
        resource.resourceType == DOCUMENT_EVENT &&
        resource.name == "will-navigate" &&
        resource.targetFront.isTopLevel
      ) {
        this._clearAllState();
      } else if (resource.resourceType == STYLESHEET) {
        this._onNewStyleSheet(resource);
      } else if (resource.resourceType == SOURCE) {
        this._onNewJavascript(resource);
      }
    }
  }

  _convertPendingURLSubscriptionsToID(url, id) {
    const urlSubscriptions = this._pendingURLSubscriptions.get(url);
    if (!urlSubscriptions) {
      return;
    }
    this._pendingURLSubscriptions.delete(url);

    let pending = this._pendingIDSubscriptions.get(id);
    if (!pending) {
      pending = new Set();
      this._pendingIDSubscriptions.set(id, pending);
    }
    for (const entry of urlSubscriptions) {
      entry.owner = pending;
      pending.add(entry);
    }
  }
}

exports.SourceMapURLService = SourceMapURLService;
