/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim: set ts=2 et sw=2 tw=80: */
/* 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 promise = require("promise");
const {Rule} = require("devtools/client/inspector/rules/models/rule");
const {promiseWarn} = require("devtools/client/inspector/shared/utils");
const {ELEMENT_STYLE} = require("devtools/shared/specs/styles");
const {getCssProperties} = require("devtools/shared/fronts/css-properties");

/**
 * ElementStyle is responsible for the following:
 *   Keeps track of which properties are overridden.
 *   Maintains a list of Rule objects for a given element.
 *
 * @param {Element} element
 *        The element whose style we are viewing.
 * @param {CssRuleView} ruleView
 *        The instance of the rule-view panel.
 * @param {Object} store
 *        The ElementStyle can use this object to store metadata
 *        that might outlast the rule view, particularly the current
 *        set of disabled properties.
 * @param {PageStyleFront} pageStyle
 *        Front for the page style actor that will be providing
 *        the style information.
 * @param {Boolean} showUserAgentStyles
 *        Should user agent styles be inspected?
 */
function ElementStyle(element, ruleView, store, pageStyle,
    showUserAgentStyles) {
  this.element = element;
  this.ruleView = ruleView;
  this.store = store || {};
  this.pageStyle = pageStyle;
  this.showUserAgentStyles = showUserAgentStyles;
  this.rules = [];
  this.cssProperties = getCssProperties(this.ruleView.inspector.toolbox);

  // We don't want to overwrite this.store.userProperties so we only create it
  // if it doesn't already exist.
  if (!("userProperties" in this.store)) {
    this.store.userProperties = new UserProperties();
  }

  if (!("disabled" in this.store)) {
    this.store.disabled = new WeakMap();
  }
}

ElementStyle.prototype = {
  // The element we're looking at.
  element: null,

  destroy: function () {
    if (this.destroyed) {
      return;
    }
    this.destroyed = true;

    for (let rule of this.rules) {
      if (rule.editor) {
        rule.editor.destroy();
      }
    }
  },

  /**
   * Called by the Rule object when it has been changed through the
   * setProperty* methods.
   */
  _changed: function () {
    if (this.onChanged) {
      this.onChanged();
    }
  },

  /**
   * Refresh the list of rules to be displayed for the active element.
   * Upon completion, this.rules[] will hold a list of Rule objects.
   *
   * Returns a promise that will be resolved when the elementStyle is
   * ready.
   */
  populate: function () {
    let populated = this.pageStyle.getApplied(this.element, {
      inherited: true,
      matchedSelectors: true,
      filter: this.showUserAgentStyles ? "ua" : undefined,
    }).then(entries => {
      if (this.destroyed) {
        return promise.resolve(undefined);
      }

      if (this.populated !== populated) {
        // Don't care anymore.
        return promise.resolve(undefined);
      }

      // Store the current list of rules (if any) during the population
      // process.  They will be reused if possible.
      let existingRules = this.rules;

      this.rules = [];

      for (let entry of entries) {
        this._maybeAddRule(entry, existingRules);
      }

      // Mark overridden computed styles.
      this.markOverriddenAll();

      this._sortRulesForPseudoElement();

      // We're done with the previous list of rules.
      for (let r of existingRules) {
        if (r && r.editor) {
          r.editor.destroy();
        }
      }

      return undefined;
    }).then(null, e => {
      // populate is often called after a setTimeout,
      // the connection may already be closed.
      if (this.destroyed) {
        return promise.resolve(undefined);
      }
      return promiseWarn(e);
    });
    this.populated = populated;
    return this.populated;
  },

  /**
   * Put pseudo elements in front of others.
   */
  _sortRulesForPseudoElement: function () {
    this.rules = this.rules.sort((a, b) => {
      return (a.pseudoElement || "z") > (b.pseudoElement || "z");
    });
  },

  /**
   * Add a rule if it's one we care about.  Filters out duplicates and
   * inherited styles with no inherited properties.
   *
   * @param {Object} options
   *        Options for creating the Rule, see the Rule constructor.
   * @param {Array} existingRules
   *        Rules to reuse if possible.  If a rule is reused, then it
   *        it will be deleted from this array.
   * @return {Boolean} true if we added the rule.
   */
  _maybeAddRule: function (options, existingRules) {
    // If we've already included this domRule (for example, when a
    // common selector is inherited), ignore it.
    if (options.rule &&
        this.rules.some(rule => rule.domRule === options.rule)) {
      return false;
    }

    if (options.system) {
      return false;
    }

    let rule = null;

    // If we're refreshing and the rule previously existed, reuse the
    // Rule object.
    if (existingRules) {
      let ruleIndex = existingRules.findIndex((r) => r.matches(options));
      if (ruleIndex >= 0) {
        rule = existingRules[ruleIndex];
        rule.refresh(options);
        existingRules.splice(ruleIndex, 1);
      }
    }

    // If this is a new rule, create its Rule object.
    if (!rule) {
      rule = new Rule(this, options);
    }

    // Ignore inherited rules with no visible properties.
    if (options.inherited && !rule.hasAnyVisibleProperties()) {
      return false;
    }

    this.rules.push(rule);
    return true;
  },

  /**
   * Calls markOverridden with all supported pseudo elements
   */
  markOverriddenAll: function () {
    this.markOverridden();
    for (let pseudo of this.cssProperties.pseudoElements) {
      this.markOverridden(pseudo);
    }
  },

  /**
   * Mark the properties listed in this.rules for a given pseudo element
   * with an overridden flag if an earlier property overrides it.
   *
   * @param {String} pseudo
   *        Which pseudo element to flag as overridden.
   *        Empty string or undefined will default to no pseudo element.
   */
  markOverridden: function (pseudo = "") {
    // Gather all the text properties applied by these rules, ordered
    // from more- to less-specific. Text properties from keyframes rule are
    // excluded from being marked as overridden since a number of criteria such
    // as time, and animation overlay are required to be check in order to
    // determine if the property is overridden.
    let textProps = [];
    for (let rule of this.rules) {
      if ((rule.matchedSelectors.length > 0 ||
           rule.domRule.type === ELEMENT_STYLE) &&
          rule.pseudoElement === pseudo && !rule.keyframes) {
        for (let textProp of rule.textProps.slice(0).reverse()) {
          if (textProp.enabled) {
            textProps.push(textProp);
          }
        }
      }
    }

    // Gather all the computed properties applied by those text
    // properties.
    let computedProps = [];
    for (let textProp of textProps) {
      computedProps = computedProps.concat(textProp.computed);
    }

    // Walk over the computed properties.  As we see a property name
    // for the first time, mark that property's name as taken by this
    // property.
    //
    // If we come across a property whose name is already taken, check
    // its priority against the property that was found first:
    //
    //   If the new property is a higher priority, mark the old
    //   property overridden and mark the property name as taken by
    //   the new property.
    //
    //   If the new property is a lower or equal priority, mark it as
    //   overridden.
    //
    // _overriddenDirty will be set on each prop, indicating whether its
    // dirty status changed during this pass.
    let taken = {};
    for (let computedProp of computedProps) {
      let earlier = taken[computedProp.name];

      // Prevent -webkit-gradient from being selected after unchecking
      // linear-gradient in this case:
      //  -moz-linear-gradient: ...;
      //  -webkit-linear-gradient: ...;
      //  linear-gradient: ...;
      if (!computedProp.textProp.isValid()) {
        computedProp.overridden = true;
        continue;
      }
      let overridden;
      if (earlier &&
          computedProp.priority === "important" &&
          earlier.priority !== "important" &&
          (earlier.textProp.rule.inherited ||
           !computedProp.textProp.rule.inherited)) {
        // New property is higher priority.  Mark the earlier property
        // overridden (which will reverse its dirty state).
        earlier._overriddenDirty = !earlier._overriddenDirty;
        earlier.overridden = true;
        overridden = false;
      } else {
        overridden = !!earlier;
      }

      computedProp._overriddenDirty =
        (!!computedProp.overridden !== overridden);
      computedProp.overridden = overridden;
      if (!computedProp.overridden && computedProp.textProp.enabled) {
        taken[computedProp.name] = computedProp;
      }
    }

    // For each TextProperty, mark it overridden if all of its
    // computed properties are marked overridden.  Update the text
    // property's associated editor, if any.  This will clear the
    // _overriddenDirty state on all computed properties.
    for (let textProp of textProps) {
      // _updatePropertyOverridden will return true if the
      // overridden state has changed for the text property.
      if (this._updatePropertyOverridden(textProp)) {
        textProp.updateEditor();
      }
    }
  },

  /**
   * Mark a given TextProperty as overridden or not depending on the
   * state of its computed properties.  Clears the _overriddenDirty state
   * on all computed properties.
   *
   * @param {TextProperty} prop
   *        The text property to update.
   * @return {Boolean} true if the TextProperty's overridden state (or any of
   *         its computed properties overridden state) changed.
   */
  _updatePropertyOverridden: function (prop) {
    let overridden = true;
    let dirty = false;
    for (let computedProp of prop.computed) {
      if (!computedProp.overridden) {
        overridden = false;
      }
      dirty = computedProp._overriddenDirty || dirty;
      delete computedProp._overriddenDirty;
    }

    dirty = (!!prop.overridden !== overridden) || dirty;
    prop.overridden = overridden;
    return dirty;
  }
};

/**
 * Store of CSSStyleDeclarations mapped to properties that have been changed by
 * the user.
 */
function UserProperties() {
  this.map = new Map();
}

UserProperties.prototype = {
  /**
   * Get a named property for a given CSSStyleDeclaration.
   *
   * @param {CSSStyleDeclaration} style
   *        The CSSStyleDeclaration against which the property is mapped.
   * @param {String} name
   *        The name of the property to get.
   * @param {String} value
   *        Default value.
   * @return {String}
   *        The property value if it has previously been set by the user, null
   *        otherwise.
   */
  getProperty: function (style, name, value) {
    let key = this.getKey(style);
    let entry = this.map.get(key, null);

    if (entry && name in entry) {
      return entry[name];
    }
    return value;
  },

  /**
   * Set a named property for a given CSSStyleDeclaration.
   *
   * @param {CSSStyleDeclaration} style
   *        The CSSStyleDeclaration against which the property is to be mapped.
   * @param {String} bame
   *        The name of the property to set.
   * @param {String} userValue
   *        The value of the property to set.
   */
  setProperty: function (style, bame, userValue) {
    let key = this.getKey(style, bame);
    let entry = this.map.get(key, null);

    if (entry) {
      entry[bame] = userValue;
    } else {
      let props = {};
      props[bame] = userValue;
      this.map.set(key, props);
    }
  },

  /**
   * Check whether a named property for a given CSSStyleDeclaration is stored.
   *
   * @param {CSSStyleDeclaration} style
   *        The CSSStyleDeclaration against which the property would be mapped.
   * @param {String} name
   *        The name of the property to check.
   */
  contains: function (style, name) {
    let key = this.getKey(style, name);
    let entry = this.map.get(key, null);
    return !!entry && name in entry;
  },

  getKey: function (style, name) {
    return style.actorID + ":" + name;
  },

  clear: function () {
    this.map.clear();
  }
};

exports.ElementStyle = ElementStyle;
