/* 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 { Task } = require("devtools/shared/task");
const EventEmitter = require("devtools/shared/event-emitter");
const { LocationStore, serialize, deserialize } = require("./location-store");

/**
 * A manager class that wraps a TabTarget and listens to source changes
 * from source maps and resolves non-source mapped locations to the source mapped
 * versions and back and forth, and creating smart elements with a location that
 * auto-update when the source changes (from pretty printing, source maps loading, etc)
 *
 * @param {TabTarget} target
 */

function SourceMapService(target) {
  this._target = target;
  this._locationStore = new LocationStore();
  this._isNotSourceMapped = new Map();

  EventEmitter.decorate(this);

  this._onSourceUpdated = this._onSourceUpdated.bind(this);
  this._resolveLocation = this._resolveLocation.bind(this);
  this._resolveAndUpdate = this._resolveAndUpdate.bind(this);
  this.subscribe = this.subscribe.bind(this);
  this.unsubscribe = this.unsubscribe.bind(this);
  this.reset = this.reset.bind(this);
  this.destroy = this.destroy.bind(this);

  target.on("source-updated", this._onSourceUpdated);
  target.on("navigate", this.reset);
  target.on("will-navigate", this.reset);
}

/**
 * Clears the store containing the cached promised locations
 */
SourceMapService.prototype.reset = function () {
  // Guard to prevent clearing the store when it is not initialized yet.
  if (!this._locationStore) {
    return;
  }
  this._locationStore.clear();
  this._isNotSourceMapped.clear();
};

SourceMapService.prototype.destroy = function () {
  this.reset();
  this._target.off("source-updated", this._onSourceUpdated);
  this._target.off("navigate", this.reset);
  this._target.off("will-navigate", this.reset);
  this._target.off("close", this.destroy);
  this._target = this._locationStore = this._isNotSourceMapped = null;
};

/**
 * Sets up listener for the callback to update the FrameView
 * and tries to resolve location, if it is source-mappable
 * @param location
 * @param callback
 */
SourceMapService.prototype.subscribe = function (location, callback) {
  // A valid candidate location for source-mapping should have a url and line.
  // Abort if there's no `url`, which means it's unsourcemappable anyway,
  // like an eval script.
  // From previous attempts to source-map locations, we also determine if a location
  // is not source-mapped.
  if (!location.url || !location.line || this._isNotSourceMapped.get(location.url)) {
    return;
  }
  this.on(serialize(location), callback);
  this._locationStore.set(location);
  this._resolveAndUpdate(location);
};

/**
 * Removes the listener for the location and clears cached locations
 * @param location
 * @param callback
 */
SourceMapService.prototype.unsubscribe = function (location, callback) {
  this.off(serialize(location), callback);
  // Check to see if the store exists before attempting to clear a location
  // Sometimes un-subscribe happens during the destruction cascades and this
  // condition is to protect against that. Could be looked into in the future.
  if (!this._locationStore) {
    return;
  }
  this._locationStore.clearByURL(location.url);
};

/**
 * Tries to resolve the location and if successful,
 * emits the resolved location
 * @param location
 * @private
 */
SourceMapService.prototype._resolveAndUpdate = function (location) {
  this._resolveLocation(location).then(resolvedLocation => {
    // We try to source map the first console log to initiate the source-updated
    // event from target. The isSameLocation check is to make sure we don't update
    // the frame, if the location is not source-mapped.
    if (resolvedLocation && !isSameLocation(location, resolvedLocation)) {
      this.emit(serialize(location), location, resolvedLocation);
    }
  });
};

/**
 * Checks if there is existing promise to resolve location, if so returns cached promise
 * if not, tries to resolve location and returns a promised location
 * @param location
 * @return Promise<Object>
 * @private
 */
SourceMapService.prototype._resolveLocation = Task.async(function* (location) {
  let resolvedLocation;
  const cachedLocation = this._locationStore.get(location);
  if (cachedLocation) {
    resolvedLocation = cachedLocation;
  } else {
    const promisedLocation = resolveLocation(this._target, location);
    if (promisedLocation) {
      this._locationStore.set(location, promisedLocation);
      resolvedLocation = promisedLocation;
    }
  }
  return resolvedLocation;
});

/**
 * Checks if the `source-updated` event is fired from the target.
 * Checks to see if location store has the source url in its cache,
 * if so, tries to update each stale location in the store.
 * Determines if the source should be source-mapped or not.
 * @param _
 * @param sourceEvent
 * @private
 */
SourceMapService.prototype._onSourceUpdated = function (_, sourceEvent) {
  let { type, source } = sourceEvent;

  // If we get a new source, and it's not a source map, abort;
  // we can have no actionable updates as this is just a new normal source.
  // Check Source Actor for sourceMapURL property (after Firefox 48)
  // If not present, utilize isSourceMapped and isPrettyPrinted properties
  // to estimate if a source is not source-mapped.
  const isNotSourceMapped = !(source.sourceMapURL ||
    source.isSourceMapped || source.isPrettyPrinted);
  if (type === "newSource" && isNotSourceMapped) {
    this._isNotSourceMapped.set(source.url, true);
    return;
  }
  let sourceUrl = null;
  if (source.generatedUrl && source.isSourceMapped) {
    sourceUrl = source.generatedUrl;
  } else if (source.url && source.isPrettyPrinted) {
    sourceUrl = source.url;
  }
  const locationsToResolve = this._locationStore.getByURL(sourceUrl);
  if (locationsToResolve.length) {
    this._locationStore.clearByURL(sourceUrl);
    for (let location of locationsToResolve) {
      this._resolveAndUpdate(deserialize(location));
    }
  }
};

exports.SourceMapService = SourceMapService;

/**
 * Take a TabTarget and a location, containing a `url`, `line`, and `column`, resolve
 * the location to the latest location (so a source mapped location, or if pretty print
 * status has been updated)
 *
 * @param {TabTarget} target
 * @param {Object} location
 * @return {Promise<Object>}
 */
function resolveLocation(target, location) {
  return Task.spawn(function* () {
    let newLocation = yield target.resolveLocation({
      url: location.url,
      line: location.line,
      column: location.column || Infinity
    });
    // Source or mapping not found, so don't do anything
    if (newLocation.error) {
      return null;
    }
    return newLocation;
  });
}

/**
 * Returns true if the original location and resolved location are the same
 * @param location
 * @param resolvedLocation
 * @returns {boolean}
 */
function isSameLocation(location, resolvedLocation) {
  return location.url === resolvedLocation.url &&
    location.line === resolvedLocation.line &&
    location.column === resolvedLocation.column;
}