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

var kAllowedURLs = aValue => /^(https?|ftp|mailto|magnet):/.test(aValue);
var kAllowedMozClasses = aClassName =>
  aClassName == "moz-txt-underscore" ||
  aClassName == "moz-txt-tag" ||
  aClassName == "ib-person";
var kAllowedAnchorClasses = aClassName => aClassName == "ib-person";

/* Tags whose content should be fully removed, and reported in the Error Console. */
var kForbiddenTags = {
  script: true,
  style: true,
};

/**
 * In strict mode, remove all formatting. Keep only links and line breaks.
 *
 * @type {CleanRules}
 */
var kStrictMode = {
  attrs: {},

  tags: {
    a: {
      title: true,
      href: kAllowedURLs,
      class: kAllowedAnchorClasses,
    },
    br: true,
    p: true,
  },

  styles: {},
};

/**
 * Standard mode allows basic formattings (bold, italic, underlined).
 *
 * @type {CleanRules}
 */
var kStandardMode = {
  attrs: {
    style: true,
  },

  tags: {
    div: true,
    a: {
      title: true,
      href: kAllowedURLs,
      class: kAllowedAnchorClasses,
    },
    em: true,
    strong: true,
    b: true,
    i: true,
    u: true,
    s: true,
    span: {
      class: kAllowedMozClasses,
    },
    br: true,
    code: true,
    ul: true,
    li: true,
    ol: {
      start: true,
    },
    cite: true,
    blockquote: true,
    p: true,
    del: true,
    strike: true,
    ins: true,
    sub: true,
    sup: true,
    pre: true,
    table: true,
    thead: true,
    tbody: true,
    tr: true,
    th: true,
    td: true,
    caption: true,
    details: true,
    summary: true,
  },

  styles: {
    "font-style": true,
    "font-weight": true,
    "text-decoration-line": true,
  },
};

/**
 * Permissive mode allows just about anything that isn't going to mess up the chat window.
 * In comparison to normal mode this primarily means elements that can vary font sizes and
 * colors.
 *
 * @type {CleanRules}
 */
var kPermissiveMode = {
  attrs: {
    style: true,
  },

  tags: {
    div: true,
    a: {
      title: true,
      href: kAllowedURLs,
      class: kAllowedAnchorClasses,
    },
    font: {
      face: true,
      color: true,
      size: true,
    },
    em: true,
    strong: true,
    b: true,
    i: true,
    u: true,
    s: true,
    span: {
      class: kAllowedMozClasses,
    },
    br: true,
    hr: true,
    code: true,
    ul: true,
    li: true,
    ol: {
      start: true,
    },
    cite: true,
    blockquote: true,
    p: true,
    del: true,
    strike: true,
    ins: true,
    sub: true,
    sup: true,
    pre: true,
    table: true,
    thead: true,
    tbody: true,
    tr: true,
    th: true,
    td: true,
    caption: true,
    details: true,
    summary: true,
    h1: true,
    h2: true,
    h3: true,
    h4: true,
    h5: true,
    h6: true,
  },

  // FIXME: should be possible to use functions to filter values
  styles: {
    color: true,
    font: true,
    "font-family": true,
    "font-size": true,
    "font-style": true,
    "font-weight": true,
    "text-decoration-color": true,
    "text-decoration-style": true,
    "text-decoration-line": true,
  },
};

var kModePref = "messenger.options.filterMode";
var kModes = [kStrictMode, kStandardMode, kPermissiveMode];

var gGlobalRuleset = null;

function initGlobalRuleset() {
  gGlobalRuleset = newRuleset();

  Services.prefs.addObserver(kModePref, styleObserver);
}

var styleObserver = {
  observe(aObject, aTopic, aMsg) {
    if (aTopic != "nsPref:changed" || aMsg != kModePref) {
      throw new Error("bad notification");
    }

    if (!gGlobalRuleset) {
      throw new Error("gGlobalRuleset not initialized");
    }

    setBaseRuleset(getModePref(), gGlobalRuleset);
  },
};

function getModePref() {
  let baseNum = Services.prefs.getIntPref(kModePref);
  if (baseNum < 0 || baseNum > 2) {
    baseNum = 1;
  }

  return kModes[baseNum];
}

function setBaseRuleset(aBase, aResult) {
  for (let property in aBase) {
    aResult[property] = Object.create(aBase[property], aResult[property]);
  }
}

function newRuleset(aBase) {
  let result = {
    tags: {},
    attrs: {},
    styles: {},
  };
  setBaseRuleset(aBase || getModePref(), result);
  return result;
}

export function createDerivedRuleset() {
  if (!gGlobalRuleset) {
    initGlobalRuleset();
  }
  return newRuleset(gGlobalRuleset);
}

export function addGlobalAllowedTag(aTag, aAttrs = true) {
  gGlobalRuleset.tags[aTag] = aAttrs;
}

export function removeGlobalAllowedTag(aTag) {
  delete gGlobalRuleset.tags[aTag];
}

export function addGlobalAllowedAttribute(aAttr, aRule = true) {
  gGlobalRuleset.attrs[aAttr] = aRule;
}

export function removeGlobalAllowedAttribute(aAttr) {
  delete gGlobalRuleset.attrs[aAttr];
}

export function addGlobalAllowedStyleRule(aStyle, aRule = true) {
  gGlobalRuleset.styles[aStyle] = aRule;
}

export function removeGlobalAllowedStyleRule(aStyle) {
  delete gGlobalRuleset.styles[aStyle];
}

/**
 * A dynamic rule which decides if an attribute is allowed based on the
 * attribute's value.
 *
 * @callback  ValueRule
 * @param {string} value - The attribute value.
 * @returns {bool} - True if the attribute should be allowed.
 *
 * @example
 *
 *    aValue => aValue == 'about:blank'
 */

/**
 * An object whose properties are the allowed attributes.
 *
 * The value of the property should be true to unconditionally accept the
 * attribute, or a function which accepts the value of the attribute and
 * returns a boolean of whether the attribute should be accepted or not.
 *
 * @typedef Ruleset
 * @type {Object<string, (boolean | ValueRule)>}}
 */

/**
 * A set of rules for which tags, attributes, and styles should be allowed when
 * rendering HTML.
 *
 * See kStrictMode, kStandardMode, kPermissiveMode for examples of Rulesets.
 *
 * @typedef CleanRules
 * @type {object}
 * @property {Ruleset} attrs
 *    An object whose properties are the allowed attributes for any tag.
 * @property {Object<string, (boolean|Ruleset)>} tags
 *    An object whose properties are the allowed tags.
 *
 *    The value can point to a {@link Ruleset} for that tag which augments the
 *    ones provided by attrs. If either of the {@link Ruleset}s from attrs or
 *    tags allows an attribute, then it is accepted.
 * @property {Object<string, boolean>} styles
 *    An object whose properties are the allowed CSS style rules.
 *
 *    The value of each property is unused.
 *
 *    FIXME: make styles accept functions to filter the CSS values like Ruleset.
 *
 * @example
 *
 *    {
 *        attrs: { 'style': true },
 *        tags: {
 *            a: { 'href': true },
 *        },
 *        styles: {
 *            'font-size': true
 *        }
 *    }
 */

/**
 * A function to modify text nodes.
 *
 * @callback TextModifier
 * @param {Node} - The text node to modify.
 * @returns {int} - The number of nodes added.
 *
 *    -1 if the current textnode was deleted
 *    0 if the node count is unchanged
 *    positive value if nodes were added.
 *
 *    For instance, adding an <img> tag for a smiley adds 2 nodes:
 *    the img tag
 *    the new text node after the img tag.
 */

/**
 * Removes nodes, attributes and styles that are not allowed according to the
 * given rules.
 *
 * @param {Node} aNode
 *    A DOM node to inspect recursively against the rules.
 * @param {CleanRules} aRules
 *    The rules for what tags, attributes, and styles are allowed.
 * @param {TextModifier[]} aTextModifiers
 *    A list of functions to modify text content.
 */
function cleanupNode(aNode, aRules, aTextModifiers) {
  // Iterate each node and apply rules for what content is allowed. This has two
  // modes: one for element nodes and one for text nodes.
  for (let i = 0; i < aNode.childNodes.length; ++i) {
    let node = aNode.childNodes[i];
    if (
      node.nodeType == node.ELEMENT_NODE &&
      node.namespaceURI == "http://www.w3.org/1999/xhtml"
    ) {
      // If the node is an element, check if the node is an allowed tag.
      let nodeName = node.localName;
      if (!(nodeName in aRules.tags)) {
        // If the node is not allowed, either remove it completely (if
        // it is forbidden) or replace it with its children.
        if (nodeName in kForbiddenTags) {
          console.error(
            "removing a " + nodeName + " tag from a message before display"
          );
        } else {
          while (node.hasChildNodes()) {
            aNode.insertBefore(node.firstChild, node);
          }
        }
        aNode.removeChild(node);
        // We want to process again the node at the index i which is
        // now the first child of the node we removed
        --i;
        continue;
      }

      // This node is being kept, cleanup each child node.
      cleanupNode(node, aRules, aTextModifiers);

      // Cleanup the attributes of this node.
      let attrs = node.attributes;
      let acceptFunction = function (aAttrRules, aAttr) {
        // An attribute is always accepted if its rule is true, or conditionally
        // accepted if its rule is a function that evaluates to true.
        // If its rule does not exist, it is removed.
        let localName = aAttr.localName;
        let rule = localName in aAttrRules && aAttrRules[localName];
        return (
          rule === true || (typeof rule == "function" && rule(aAttr.value))
        );
      };
      for (let j = 0; j < attrs.length; ++j) {
        let attr = attrs[j];
        // If either the attribute is accepted for all tags or for this specific
        // tag then it is allowed.
        if (
          !(
            acceptFunction(aRules.attrs, attr) ||
            (typeof aRules.tags[nodeName] == "object" &&
              acceptFunction(aRules.tags[nodeName], attr))
          )
        ) {
          node.removeAttribute(attr.name);
          --j;
        }
      }

      // Cleanup the style attribute.
      let style = node.style;
      for (let j = 0; j < style.length; ++j) {
        if (!(style[j] in aRules.styles)) {
          style.removeProperty(style[j]);
          --j;
        }
      }

      // If the style attribute is now empty or if it contained unsupported or
      // unparsable CSS it should be dropped completely.
      if (!style.length) {
        node.removeAttribute("style");
      }

      // Sort the style attributes for easier checking/comparing later.
      if (node.hasAttribute("style")) {
        let trailingSemi = false;
        let attrs = node.getAttribute("style").trim();
        if (attrs.endsWith(";")) {
          attrs = attrs.slice(0, -1);
          trailingSemi = true;
        }
        attrs = attrs.split(";").map(a => a.trim());
        attrs.sort();
        node.setAttribute(
          "style",
          attrs.join("; ") + (trailingSemi ? ";" : "")
        );
      }
    } else {
      // We are on a text node, we need to apply the functions
      // provided in the aTextModifiers array.

      // Each of these function should return the number of nodes added:
      //  * -1 if the current textnode was deleted
      //  * 0 if the node count is unchanged
      //  * positive value if nodes were added.
      //     For instance, adding an <img> tag for a smiley adds 2 nodes:
      //      - the img tag
      //      - the new text node after the img tag.

      // This is the number of nodes we need to process. If new nodes
      // are created, the next text modifier functions have more nodes
      // to process.
      let textNodeCount = 1;
      for (let modifier of aTextModifiers) {
        for (let n = 0; n < textNodeCount; ++n) {
          let textNode = aNode.childNodes[i + n];

          // If we are processing nodes created by one of the previous
          // text modifier function, some of the nodes are likely not
          // text node, skip them.
          if (
            textNode.nodeType != textNode.TEXT_NODE &&
            textNode.nodeType != textNode.CDATA_SECTION_NODE
          ) {
            continue;
          }

          let result = modifier(textNode);
          textNodeCount += result;
          n += result;
        }
      }

      // newly created nodes should not be filtered, be sure we skip them!
      i += textNodeCount - 1;
    }
  }
}

export function cleanupImMarkup(aText, aRuleset, aTextModifiers = []) {
  if (!gGlobalRuleset) {
    initGlobalRuleset();
  }

  let parser = new DOMParser();
  // Wrap the text to be parsed in a <span> to avoid losing leading whitespace.
  let doc = parser.parseFromString(
    "<!DOCTYPE html><html><body><span>" + aText + "</span></body></html>",
    "text/html"
  );
  let span = doc.querySelector("span");
  cleanupNode(span, aRuleset || gGlobalRuleset, aTextModifiers);
  return span.innerHTML;
}
