/* 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";

loader.lazyRequireGetter(
  this,
  "getTabPrefs",
  "resource://devtools/shared/indentation.js",
  true
);

const {
  getSourceForDisplay,
} = require("resource://devtools/client/inspector/changes/utils/changes-utils.js");

/**
 * In the Redux state, changed CSS rules are grouped by source (stylesheet) and stored in
 * a single level array, regardless of nesting.
 * This method returns a nested tree structure of the changed CSS rules so the React
 * consumer components can traverse it easier when rendering the nested CSS rules view.
 * Keeping this interface updated allows the Redux state structure to change without
 * affecting the consumer components.
 *
 * @param {object} state
 *        Redux slice for tracked changes.
 * @param {object} filter
 *        Object with optional filters to use. Has the following properties:
 *        - sourceIds: {Array}
 *          Use only subtrees of sources matching source ids from this array.
 *        - ruleIds: {Array}
 *          Use only rules matching rule ids from this array. If the array includes ids
 *          of ancestor rules (@media, @supports), their nested rules will be included.
 * @return {object}
 */
function getChangesTree(state, filter = {}) {
  // Use or assign defaults of sourceId and ruleId arrays by which to filter the tree.
  const { sourceIds: sourceIdsFilter = [], ruleIds: rulesIdsFilter = [] } =
    filter;
  /**
   * Recursively replace a rule's array of child rule ids with the referenced child rules.
   * Mark visited rules so as not to handle them (and their children) again.
   *
   * Returns the rule object with expanded children or null if previously visited.
   *
   * @param  {string} ruleId
   * @param  {object} rule
   * @param  {Array} rules
   * @param  {Set} visitedRules
   * @return {object | null}
   */
  function expandRuleChildren(ruleId, rule, rules, visitedRules) {
    if (visitedRules.has(ruleId)) {
      return null;
    }

    visitedRules.add(ruleId);

    return {
      ...rule,
      children: rule.children.map(childRuleId =>
        expandRuleChildren(childRuleId, rules[childRuleId], rules, visitedRules)
      ),
    };
  }

  return Object.entries(state)
    .filter(([sourceId]) => {
      // Use only matching sources if an array to filter by was provided.
      if (sourceIdsFilter.length) {
        return sourceIdsFilter.includes(sourceId);
      }

      return true;
    })
    .reduce((sourcesObj, [sourceId, source]) => {
      const { rules } = source;
      // Log of visited rules in this source. Helps avoid duplication when traversing the
      // descendant rule tree. This Set is unique per source. It will be passed down to
      // be populated with ids of rules once visited. This ensures that only visited rules
      // unique to this source will be skipped and prevents skipping identical rules from
      // other sources (ex: rules with the same selector and the same index).
      const visitedRules = new Set();

      // Build a new collection of sources keyed by source id.
      sourcesObj[sourceId] = {
        ...source,
        // Build a new collection of rules keyed by rule id.
        rules: Object.entries(rules)
          .filter(([ruleId]) => {
            // Use only matching rules if an array to filter by was provided.
            if (rulesIdsFilter.length) {
              return rulesIdsFilter.includes(ruleId);
            }

            return true;
          })
          .reduce((rulesObj, [ruleId, rule]) => {
            // Expand the rule's array of child rule ids with the referenced child rules.
            // Skip exposing null values which mean the rule was previously visited
            // as part of an ancestor descendant tree.
            const expandedRule = expandRuleChildren(
              ruleId,
              rule,
              rules,
              visitedRules
            );
            if (expandedRule !== null) {
              rulesObj[ruleId] = expandedRule;
            }

            return rulesObj;
          }, {}),
      };

      return sourcesObj;
    }, {});
}

/**
 * Build the CSS text of a stylesheet with the changes aggregated in the Redux state.
 * If filters for rule id or source id are provided, restrict the changes to the matching
 * sources and rules.
 *
 * Code comments with the source origin are put above of the CSS rule (or group of
 * rules). Removed CSS declarations are written commented out. Added CSS declarations are
 * written as-is.
 *
 * @param  {object} state
 *         Redux slice for tracked changes.
 * @param  {object} filter
 *         Object with optional source and rule filters. See getChangesTree()
 * @return {string}
 *         CSS stylesheet text.
 */

// For stylesheet sources, the stylesheet filename and full path are used:
//
// /* styles.css | https://example.com/styles.css */
//
// .selector {
//  /* property: oldvalue; */
//  property: value;
// }

// For inline stylesheet sources, the stylesheet index and host document URL are used:
//
// /* Inline | https://example.com */
//
// .selector {
//  /* property: oldvalue; */
//  property: value;
// }

// For element style attribute sources, the unique selector generated for the element
// and the host document URL are used:
//
// /* Element (div) | https://example.com */
//
// div:nth-child(1) {
//  /* property: oldvalue; */
//  property: value;
// }
function getChangesStylesheet(state, filter) {
  const changeTree = getChangesTree(state, filter);
  // Get user prefs about indentation style.
  const { indentUnit, indentWithTabs } = getTabPrefs();
  const indentChar = indentWithTabs
    ? "\t".repeat(indentUnit)
    : " ".repeat(indentUnit);

  /**
   * If the rule has just one item in its array of selector versions, return it as-is.
   * If it has more than one, build a string using the first selector commented-out
   * and the last selector as-is. This indicates that a rule's selector has changed.
   *
   * @param  {Array} selectors
   *         History of selector versions if changed over time.
   *         Array with a single item (the original selector) if never changed.
   * @param  {number} level
   *         Level of nesting within a CSS rule tree.
   * @return {string}
   */
  function writeSelector(selectors = [], level) {
    const indent = indentChar.repeat(level);
    let selectorText;
    switch (selectors.length) {
      case 0:
        selectorText = "";
        break;
      case 1:
        selectorText = `${indent}${selectors[0]}`;
        break;
      default:
        selectorText =
          `${indent}/* ${selectors[0]} { */\n` +
          `${indent}${selectors[selectors.length - 1]}`;
    }

    return selectorText;
  }

  function writeRule(ruleId, rule, level) {
    // Write nested rules, if any.
    let ruleBody = rule.children.reduce((str, childRule) => {
      str += writeRule(childRule.ruleId, childRule, level + 1);
      return str;
    }, "");

    // Write changed CSS declarations.
    ruleBody += writeDeclarations(rule.remove, rule.add, level + 1);

    const indent = indentChar.repeat(level);
    const selectorText = writeSelector(rule.selectors, level);
    return `\n${selectorText} {${ruleBody}\n${indent}}`;
  }

  function writeDeclarations(remove = [], add = [], level) {
    const indent = indentChar.repeat(level);
    const removals = remove
      // Sort declarations in the order in which they exist in the original CSS rule.
      .sort((a, b) => a.index > b.index)
      .reduce((str, { property, value }) => {
        str += `\n${indent}/* ${property}: ${value}; */`;
        return str;
      }, "");

    const additions = add
      // Sort declarations in the order in which they exist in the original CSS rule.
      .sort((a, b) => a.index > b.index)
      .reduce((str, { property, value }) => {
        str += `\n${indent}${property}: ${value};`;
        return str;
      }, "");

    return removals + additions;
  }

  // Iterate through all sources in the change tree and build a CSS stylesheet string.
  return Object.values(changeTree).reduce((stylesheetText, source) => {
    const { href, rules } = source;
    // Write code comment with source origin
    stylesheetText += `\n/* ${getSourceForDisplay(source)} | ${href} */\n`;
    // Write CSS rules
    stylesheetText += Object.entries(rules).reduce((str, [ruleId, rule]) => {
      // Add a new like only after top-level rules (level == 0)
      str += writeRule(ruleId, rule, 0) + "\n";
      return str;
    }, "");

    return stylesheetText;
  }, "");
}

module.exports = {
  getChangesTree,
  getChangesStylesheet,
};
