/* 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/. */

// This file holds various CSS parsing and rewriting utilities.
// Some entry points of note are:
// parseDeclarations - parse a CSS rule into declarations
// RuleRewriter - rewrite CSS rule text
// parsePseudoClassesAndAttributes - parse selector and extract
//     pseudo-classes
// parseSingleValue - parse a single CSS property value

"use strict";

const {
  InspectorCSSParserWrapper,
} = require("resource://devtools/shared/css/lexer.js");
const {
  COMMENT_PARSING_HEURISTIC_BYPASS_CHAR,
  escapeCSSComment,
  parseNamedDeclarations,
  unescapeCSSComment,
} = require("resource://devtools/shared/css/parsing-utils.js");

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

// Used to test whether a newline appears anywhere in some text.
const NEWLINE_RX = /[\r\n]/;
// Used to test whether a bit of text starts an empty comment, either
// an "ordinary" /* ... */ comment, or a "heuristic bypass" comment
// like /*! ... */.
const EMPTY_COMMENT_START_RX = /^\/\*!?[ \r\n\t\f]*$/;
// Used to test whether a bit of text ends an empty comment.
const EMPTY_COMMENT_END_RX = /^[ \r\n\t\f]*\*\//;
// Used to test whether a string starts with a blank line.
const BLANK_LINE_RX = /^[ \t]*(?:\r\n|\n|\r|\f|$)/;

/**
 * Return an object that can be used to rewrite declarations in some
 * source text.  The source text and parsing are handled in the same
 * way as @see parseNamedDeclarations, with |parseComments| being true.
 * Rewriting is done by calling one of the modification functions like
 * setPropertyEnabled.  The returned object has the same interface
 * as @see RuleModificationList.
 *
 * An example showing how to disable the 3rd property in a rule:
 *
 *    let rewriter = new RuleRewriter(win, isCssPropertyKnown, ruleActor,
 *                                    ruleActor.authoredText);
 *    rewriter.setPropertyEnabled(3, "color", false);
 *    rewriter.apply().then(() => { ... the change is made ... });
 *
 * The exported rewriting methods are |renameProperty|, |setPropertyEnabled|,
 * |createProperty|, |setProperty|, and |removeProperty|.  The |apply|
 * method can be used to send the edited text to the StyleRuleActor;
 * |getDefaultIndentation| is useful for the methods requiring a
 * default indentation value; and |getResult| is useful for testing.
 *
 * Additionally, editing will set the |changedDeclarations| property
 * on this object.  This property has the same form as the |changed|
 * property of the object returned by |getResult|.
 */
class RuleRewriter {
  /**
   * @class
   * @param {Window} win
   * @param {Function} isCssPropertyKnown
   *        A function to check if the CSS property is known. This is either an
   *        internal server function or from the CssPropertiesFront.
   *        that are supported by the server. Note that if Bug 1222047
   *        is completed then isCssPropertyKnown will not need to be passed in.
   *        The CssProperty front will be able to obtained directly from the
   *        RuleRewriter.
   * @param {StyleRuleFront} rule The style rule to use.  Note that this
   *        is only needed by the |apply| and |getDefaultIndentation| methods;
   *        and in particular for testing it can be |null|.
   * @param {string} inputString The CSS source text to parse and modify.
   */
  constructor(win, isCssPropertyKnown, rule, inputString) {
    this.win = win;
    this.rule = rule;
    this.isCssPropertyKnown = isCssPropertyKnown;
    // The RuleRewriter sends CSS rules as text to the server, but with this modifications
    // array, it also sends the list of changes so the server doesn't have to re-parse the
    // rule if it needs to track what changed.
    this.modifications = [];

    // Keep track of which any declarations we had to rewrite while
    // performing the requested action.
    this.changedDeclarations = {};

    // If not null, a promise that must be wait upon before |apply| can
    // do its work.
    this.editPromise = null;

    // If the |defaultIndentation| property is set, then it is used;
    // otherwise the RuleRewriter will try to compute the default
    // indentation based on the style sheet's text.  This override
    // facility is for testing.
    this.defaultIndentation = null;

    this.startInitialization(inputString);
  }

  /**
   * An internal function to initialize the rewriter with a given
   * input string.
   *
   * @param {string} inputString the input to use
   */
  startInitialization(inputString) {
    this.inputString = inputString;
    // Whether there are any newlines in the input text.
    this.hasNewLine = /[\r\n]/.test(this.inputString);
    // The declarations.
    this.declarations = parseNamedDeclarations(
      this.isCssPropertyKnown,
      this.inputString,
      true
    );
    this.decl = null;
    this.result = null;
  }

  /**
   * An internal function to complete initialization and set some
   * properties for further processing.
   *
   * @param {number} index The index of the property to modify
   */
  completeInitialization(index) {
    if (index < 0) {
      throw new Error("Invalid index " + index + ". Expected positive integer");
    }
    // |decl| is the declaration to be rewritten, or null if there is no
    // declaration corresponding to |index|.
    // |result| is used to accumulate the result text.
    if (index < this.declarations.length) {
      this.decl = this.declarations[index];
      this.result = this.inputString.substring(0, this.decl.offsets[0]);
    } else {
      this.decl = null;
      this.result = this.inputString;
    }
  }

  /**
   * A helper function to compute the indentation of some text.  This
   * examines the rule's existing text to guess the indentation to use;
   * unlike |getDefaultIndentation|, which examines the entire style
   * sheet.
   *
   * @param {string} string the input text
   * @param {number} offset the offset at which to compute the indentation
   * @return {string} the indentation at the indicated position
   */
  getIndentation(string, offset) {
    let originalOffset = offset;
    for (--offset; offset >= 0; --offset) {
      const c = string[offset];
      if (c === "\r" || c === "\n" || c === "\f") {
        return string.substring(offset + 1, originalOffset);
      }
      if (c !== " " && c !== "\t") {
        // Found some non-whitespace character before we found a newline
        // -- let's reset the starting point and keep going, as we saw
        // something on the line before the declaration.
        originalOffset = offset;
      }
    }
    // Ran off the end.
    return "";
  }

  /**
   * Modify a property value to ensure it is "lexically safe" for
   * insertion into a style sheet.  This function doesn't attempt to
   * ensure that the resulting text is a valid value for the given
   * property; but rather just that inserting the text into the style
   * sheet will not cause unwanted changes to other rules or
   * declarations.
   *
   * @param {string} text The input text.  This should include the trailing ";".
   * @return {Array} An array of the form [anySanitized, text], where
   *                 |anySanitized| is a boolean that indicates
   *                  whether anything substantive has changed; and
   *                  where |text| is the text that has been rewritten
   *                  to be "lexically safe".
   */
  sanitizePropertyValue(text) {
    // Start by stripping any trailing ";".  This is done here to
    // avoid the case where the user types "url(" (which is turned
    // into "url(;" by the rule view before coming here), being turned
    // into "url(;)" by this code -- due to the way "url(...)" is
    // parsed as a single token.
    text = text.replace(/;$/, "");
    const lexer = new InspectorCSSParserWrapper(text, { trackEOFChars: true });

    let result = "";
    let previousOffset = 0;
    const parenStack = [];
    let anySanitized = false;

    // Push a closing paren on the stack.
    const pushParen = (token, closer) => {
      result =
        result +
        text.substring(previousOffset, token.startOffset) +
        text.substring(token.startOffset, token.endOffset);
      // We set the location of the paren in a funny way, to handle
      // the case where we've seen a function token, where the paren
      // appears at the end.
      parenStack.push({ closer, offset: result.length - 1, token });
      previousOffset = token.endOffset;
    };

    // Pop a closing paren from the stack.
    const popSomeParens = closer => {
      while (parenStack.length) {
        const paren = parenStack.pop();

        if (paren.closer === closer) {
          return true;
        }

        // We need to handle non-closed url function differently, as performEOFFixup will
        // only automatically close missing parenthesis `url`.
        // In such case, don't do anything here.
        if (
          paren.closer === ")" &&
          closer == null &&
          paren.token.tokenType === "Function" &&
          paren.token.value === "url"
        ) {
          return true;
        }

        // Found a non-matching closing paren, so quote it.  Note that
        // these are processed in reverse order.
        result =
          result.substring(0, paren.offset) +
          "\\" +
          result.substring(paren.offset);
        anySanitized = true;
      }
      return false;
    };

    let token;
    while ((token = lexer.nextToken())) {
      switch (token.tokenType) {
        case "Semicolon":
          // We simply drop the ";" here.  This lets us cope with
          // declarations that don't have a ";" and also other
          // termination.  The caller handles adding the ";" again.
          result += text.substring(previousOffset, token.startOffset);
          previousOffset = token.endOffset;
          break;

        case "CurlyBracketBlock":
          pushParen(token, "}");
          break;

        case "ParenthesisBlock":
        case "Function":
          pushParen(token, ")");
          break;

        case "SquareBracketBlock":
          pushParen(token, "]");
          break;

        case "CloseCurlyBracket":
        case "CloseParenthesis":
        case "CloseSquareBracket":
          // Did we find an unmatched close bracket?
          if (!popSomeParens(token.text)) {
            // Copy out text from |previousOffset|.
            result += text.substring(previousOffset, token.startOffset);
            // Quote the offending symbol.
            result += "\\" + token.text;
            previousOffset = token.endOffset;
            anySanitized = true;
          }
          break;
      }
    }

    // Fix up any unmatched parens.
    popSomeParens(null);

    // Copy out any remaining text, then any needed terminators.
    result += text.substring(previousOffset, text.length);

    const eofFixup = lexer.performEOFFixup("");
    if (eofFixup) {
      anySanitized = true;
      result += eofFixup;
    }
    return [anySanitized, result];
  }

  /**
   * Start at |index| and skip whitespace
   * backward in |string|.  Return the index of the first
   * non-whitespace character, or -1 if the entire string was
   * whitespace.
   *
   * @param {string} string the input string
   * @param {number} index the index at which to start
   * @return {number} index of the first non-whitespace character, or -1
   */
  skipWhitespaceBackward(string, index) {
    for (
      --index;
      index >= 0 && (string[index] === " " || string[index] === "\t");
      --index
    ) {
      // Nothing.
    }
    return index;
  }

  /**
   * Terminate a given declaration, if needed.
   *
   * @param {number} index The index of the rule to possibly
   *                       terminate.  It might be invalid, so this
   *                       function must check for that.
   */
  maybeTerminateDecl(index) {
    if (
      index < 0 ||
      index >= this.declarations.length ||
      // No need to rewrite declarations in comments.
      "commentOffsets" in this.declarations[index]
    ) {
      return;
    }

    const termDecl = this.declarations[index];
    let endIndex = termDecl.offsets[1];
    // Due to an oddity of the lexer, we might have gotten a bit of
    // extra whitespace in a trailing bad_url token -- so be sure to
    // skip that as well.
    endIndex = this.skipWhitespaceBackward(this.result, endIndex) + 1;

    const trailingText = this.result.substring(endIndex);
    if (termDecl.terminator) {
      // Insert the terminator just at the end of the declaration,
      // before any trailing whitespace.
      this.result =
        this.result.substring(0, endIndex) + termDecl.terminator + trailingText;
      // In a couple of cases, we may have had to add something to
      // terminate the declaration, but the termination did not
      // actually affect the property's value -- and at this spot, we
      // only care about reporting value changes.  In particular, we
      // might have added a plain ";", or we might have terminated a
      // comment with "*/;".  Neither of these affect the value.
      if (termDecl.terminator !== ";" && termDecl.terminator !== "*/;") {
        this.changedDeclarations[index] =
          termDecl.value + termDecl.terminator.slice(0, -1);
      }
    }
    // If the rule generally has newlines, but this particular
    // declaration doesn't have a trailing newline, insert one now.
    // Maybe this style is too weird to bother with.
    if (this.hasNewLine && !NEWLINE_RX.test(trailingText)) {
      this.result += "\n";
    }
  }

  /**
   * Sanitize the given property value and return the sanitized form.
   * If the property is rewritten during sanitization, make a note in
   * |changedDeclarations|.
   *
   * @param {string} text The property text.
   * @param {number} index The index of the property.
   * @return {string} The sanitized text.
   */
  sanitizeText(text, index) {
    const [anySanitized, sanitizedText] = this.sanitizePropertyValue(text);
    if (anySanitized) {
      this.changedDeclarations[index] = sanitizedText;
    }
    return sanitizedText;
  }

  /**
   * Rename a declaration.
   *
   * @param {number} index index of the property in the rule.
   * @param {string} name current name of the property
   * @param {string} newName new name of the property
   */
  renameProperty(index, name, newName) {
    this.completeInitialization(index);
    this.result += CSS.escape(newName);
    // We could conceivably compute the name offsets instead so we
    // could preserve white space and comments on the LHS of the ":".
    this.completeCopying(this.decl.colonOffsets[0]);
    this.modifications.push({ type: "set", index, name, newName });
  }

  /**
   * Enable or disable a declaration
   *
   * @param {number} index index of the property in the rule.
   * @param {string} name current name of the property
   * @param {boolean} isEnabled true if the property should be enabled;
   *                        false if it should be disabled
   */
  setPropertyEnabled(index, name, isEnabled) {
    this.completeInitialization(index);
    const decl = this.decl;
    const priority = decl.priority;
    let copyOffset = decl.offsets[1];
    if (isEnabled) {
      // Enable it.  First see if the comment start can be deleted.
      const commentStart = decl.commentOffsets[0];
      if (EMPTY_COMMENT_START_RX.test(this.result.substring(commentStart))) {
        this.result = this.result.substring(0, commentStart);
      } else {
        this.result += "*/ ";
      }

      // Insert the name and value separately, so we can report
      // sanitization changes properly.
      const commentNamePart = this.inputString.substring(
        decl.offsets[0],
        decl.colonOffsets[1]
      );
      this.result += unescapeCSSComment(commentNamePart);

      // When uncommenting, we must be sure to sanitize the text, to
      // avoid things like /* decl: }; */, which will be accepted as
      // a property but which would break the entire style sheet.
      let newText = this.inputString.substring(
        decl.colonOffsets[1],
        decl.offsets[1]
      );
      newText = cssTrimRight(unescapeCSSComment(newText));
      this.result += this.sanitizeText(newText, index) + ";";

      // See if the comment end can be deleted.
      const trailingText = this.inputString.substring(decl.offsets[1]);
      if (EMPTY_COMMENT_END_RX.test(trailingText)) {
        copyOffset = decl.commentOffsets[1];
      } else {
        this.result += " /*";
      }
    } else {
      // Disable it.  Note that we use our special comment syntax
      // here.
      const declText = this.inputString.substring(
        decl.offsets[0],
        decl.offsets[1]
      );
      this.result +=
        "/*" +
        COMMENT_PARSING_HEURISTIC_BYPASS_CHAR +
        " " +
        escapeCSSComment(declText) +
        " */";
    }
    this.completeCopying(copyOffset);

    if (isEnabled) {
      this.modifications.push({
        type: "set",
        index,
        name,
        value: decl.value,
        priority,
      });
    } else {
      this.modifications.push({ type: "disable", index, name });
    }
  }

  /**
   * Return a promise that will be resolved to the default indentation
   * of the rule.  This is a helper for internalCreateProperty.
   *
   * @return {Promise} a promise that will be resolved to a string
   *         that holds the default indentation that should be used
   *         for edits to the rule.
   */
  async getDefaultIndentation() {
    const prefIndent = getIndentationFromPrefs();
    if (prefIndent) {
      const { indentUnit, indentWithTabs } = prefIndent;
      return indentWithTabs ? "\t" : " ".repeat(indentUnit);
    }

    const styleSheetsFront =
      await this.rule.targetFront.getFront("stylesheets");

    if (!this.rule.parentStyleSheet) {
      // See Bug 1899341, due to resource throttling, the parentStyleSheet for
      // the rule might not be received by the client yet. Fallback to a usable
      // default value.
      console.error(
        "Cannot retrieve default indentation for rule if parentStyleSheet is not attached yet, falling back to 2 spaces"
      );
      return "  ";
    }

    const styleSheetResourceId = this.rule.parentStyleSheet.resourceId;
    return styleSheetsFront.getStyleSheetIndentation(styleSheetResourceId);
  }

  /**
   * An internal function to create a new declaration.  This does all
   * the work of |createProperty|.
   *
   * @param {number} index index of the property in the rule.
   * @param {string} name name of the new property
   * @param {string} value value of the new property
   * @param {string} priority priority of the new property; either
   *                          the empty string or "important"
   * @param {boolean} enabled True if the new property should be
   *                          enabled, false if disabled
   * @return {Promise} a promise that is resolved when the edit has
   *                   completed
   */
  async internalCreateProperty(index, name, value, priority, enabled) {
    this.completeInitialization(index);
    let newIndentation = "";
    if (this.hasNewLine) {
      if (this.declarations.length) {
        newIndentation = this.getIndentation(
          this.inputString,
          this.declarations[0].offsets[0]
        );
      } else if (this.defaultIndentation) {
        newIndentation = this.defaultIndentation;
      } else {
        newIndentation = await this.getDefaultIndentation();
      }
    }

    this.maybeTerminateDecl(index - 1);

    // If we generally have newlines, and if skipping whitespace
    // backward stops at a newline, then insert our text before that
    // whitespace.  This ensures the indentation we computed is what
    // is actually used.
    let savedWhitespace = "";
    if (this.hasNewLine) {
      const wsOffset = this.skipWhitespaceBackward(
        this.result,
        this.result.length
      );
      if (this.result[wsOffset] === "\r" || this.result[wsOffset] === "\n") {
        savedWhitespace = this.result.substring(wsOffset + 1);
        this.result = this.result.substring(0, wsOffset + 1);
      }
    }

    let newText = CSS.escape(name) + ": " + this.sanitizeText(value, index);
    if (priority === "important") {
      newText += " !important";
    }
    newText += ";";

    if (!enabled) {
      newText =
        "/*" +
        COMMENT_PARSING_HEURISTIC_BYPASS_CHAR +
        " " +
        escapeCSSComment(newText) +
        " */";
    }

    newText = `${newIndentation}${newText}${this.hasNewLine ? "\n" : ""}${savedWhitespace}`;

    // If the rule has some nested declarations, we need to find the proper index where
    // to put the new declaration at.
    // e.g. if we have `body { color: red; &>span {}; }`, we want to put the new property
    // after `color: red` but before `&>span`.
    let insertIndex = -1;
    // Don't try to find the index if we can already see there's no nested rules
    if (this.result.includes("{")) {
      // Create a rule with the initial rule text so we can check for children rules
      const dummySheet = new this.win.CSSStyleSheet();
      dummySheet.replaceSync(":root {\n" + this.result + "}");
      const dummyRule = dummySheet.cssRules[0];
      if (dummyRule.cssRules.length) {
        const nestedRule = dummyRule.cssRules[0];
        const nestedRuleLine = InspectorUtils.getRelativeRuleLine(nestedRule);
        const nestedRuleColumn = InspectorUtils.getRuleColumn(nestedRule);
        // We need to account for the new line we added for the parent rule,
        // and then remove 1 again since the InspectorUtils method returns 1-based values
        let actualLine = nestedRuleLine - 2;
        const actualColumn = nestedRuleColumn - 1;

        // First, we compute the index in the original rule text corresponding to the
        // nested rule line number.
        insertIndex = 0;
        for (
          ;
          insertIndex < this.result.length && actualLine > 0;
          insertIndex++
        ) {
          if (this.result[insertIndex] === "\n") {
            actualLine--;
          }
        }

        // If the property doesn't add a new line, we need to insert the declaration
        // before the nested declaration. When the property does add a new line,
        // insertIndex already has the correct position.
        if (!this.hasNewLine) {
          insertIndex += actualColumn;
        }
      }
    }

    if (insertIndex == -1) {
      this.result += newText;
    } else {
      this.result =
        this.result.substring(0, insertIndex) +
        newText +
        this.result.substring(insertIndex);
    }

    if (this.decl) {
      // Still want to copy in the declaration previously at this index.
      this.completeCopying(this.decl.offsets[0]);
    }
  }

  /**
   * Create a new declaration.
   *
   * @param {number} index index of the property in the rule.
   * @param {string} name name of the new property
   * @param {string} value value of the new property
   * @param {string} priority priority of the new property; either
   *                          the empty string or "important"
   * @param {boolean} enabled True if the new property should be
   *                          enabled, false if disabled
   */
  createProperty(index, name, value, priority, enabled) {
    this.editPromise = this.internalCreateProperty(
      index,
      name,
      value,
      priority,
      enabled
    );
    // Log the modification only if the created property is enabled.
    if (enabled) {
      this.modifications.push({ type: "set", index, name, value, priority });
    }
  }

  /**
   * Set a declaration's value.
   *
   * @param {number} index index of the property in the rule.
   *                       This can be -1 in the case where
   *                       the rule does not support setRuleText;
   *                       generally for setting properties
   *                       on an element's style.
   * @param {string} name the property's name
   * @param {string} value the property's value
   * @param {string} priority the property's priority, either the empty
   *                          string or "important"
   */
  setProperty(index, name, value, priority) {
    this.completeInitialization(index);
    // We might see a "set" on a previously non-existent property; in
    // that case, act like "create".
    if (!this.decl) {
      this.createProperty(index, name, value, priority, true);
      return;
    }

    // Note that this assumes that "set" never operates on disabled
    // properties.
    this.result +=
      this.inputString.substring(
        this.decl.offsets[0],
        this.decl.colonOffsets[1]
      ) + this.sanitizeText(value, index);

    if (priority === "important") {
      this.result += " !important";
    }
    this.result += ";";
    this.completeCopying(this.decl.offsets[1]);
    this.modifications.push({ type: "set", index, name, value, priority });
  }

  /**
   * Remove a declaration.
   *
   * @param {number} index index of the property in the rule.
   * @param {string} name the name of the property to remove
   */
  removeProperty(index, name) {
    this.completeInitialization(index);

    // If asked to remove a property that does not exist, bail out.
    if (!this.decl) {
      return;
    }

    // If the property is disabled, then first enable it, and then
    // delete it.  We take this approach because we want to remove the
    // entire comment if possible; but the logic for dealing with
    // comments is hairy and already implemented in
    // setPropertyEnabled.
    if (this.decl.commentOffsets) {
      this.setPropertyEnabled(index, name, true);
      this.startInitialization(this.result);
      this.completeInitialization(index);
    }

    let copyOffset = this.decl.offsets[1];
    // Maybe removing this rule left us with a completely blank
    // line.  In this case, we'll delete the whole thing.  We only
    // bother with this if we're looking at sources that already
    // have a newline somewhere.
    if (this.hasNewLine) {
      const nlOffset = this.skipWhitespaceBackward(
        this.result,
        this.decl.offsets[0]
      );
      if (
        nlOffset < 0 ||
        this.result[nlOffset] === "\r" ||
        this.result[nlOffset] === "\n"
      ) {
        const trailingText = this.inputString.substring(copyOffset);
        const match = BLANK_LINE_RX.exec(trailingText);
        if (match) {
          this.result = this.result.substring(0, nlOffset + 1);
          copyOffset += match[0].length;
        }
      }
    }
    this.completeCopying(copyOffset);
    this.modifications.push({ type: "remove", index, name });
  }

  /**
   * An internal function to copy any trailing text to the output
   * string.
   *
   * @param {number} copyOffset Offset into |inputString| of the
   *        final text to copy to the output string.
   */
  completeCopying(copyOffset) {
    // Add the trailing text.
    this.result += this.inputString.substring(copyOffset);
  }

  /**
   * Apply the modifications in this object to the associated rule.
   *
   * @return {Promise} A promise which will be resolved when the modifications
   *         are complete.
   */
  apply() {
    return Promise.resolve(this.editPromise).then(() => {
      return this.rule.setRuleText(this.result, this.modifications);
    });
  }

  /**
   * Get the result of the rewriting.  This is used for testing.
   *
   * @return {object} an object of the form {changed: object, text: string}
   *                  |changed| is an object where each key is
   *                  the index of a property whose value had to be
   *                  rewritten during the sanitization process, and
   *                  whose value is the new text of the property.
   *                  |text| is the rewritten text of the rule.
   */
  getResult() {
    return { changed: this.changedDeclarations, text: this.result };
  }
}

/**
 * Like trimRight, but only trims CSS-allowed whitespace.
 */
function cssTrimRight(str) {
  const match = /^(.*?)[ \t\r\n\f]*$/.exec(str);
  if (match) {
    return match[1];
  }
  return str;
}

module.exports = RuleRewriter;
