/* 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 _SUPPORT_STATE_BROWSER_NOT_FOUND = "BROWSER_NOT_FOUND";
const _SUPPORT_STATE_SUPPORTED = "SUPPORTED";
const _SUPPORT_STATE_UNSUPPORTED = "UNSUPPORTED";
const _SUPPORT_STATE_UNSUPPORTED_PREFIX_NEEDED = "UNSUPPORTED_PREFIX_NEEDED";

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

loader.lazyRequireGetter(
  this,
  ["getCompatNode", "getCompatTable"],
  "resource://devtools/shared/compatibility/helpers.js",
  true
);

const PREFIX_REGEX = /^-\w+-/;

/**
 * A class with methods used to query the MDN compatibility data for CSS properties and
 * HTML nodes and attributes for specific browsers and versions.
 */
class MDNCompatibility {
  /**
   * Constructor.
   *
   * @param {JSON} cssPropertiesCompatData
   *        JSON of the compat data for CSS properties.
   *        https://github.com/mdn/browser-compat-data/tree/master/css/properties
   */
  constructor(cssPropertiesCompatData) {
    this._cssPropertiesCompatData = cssPropertiesCompatData;
  }

  /**
   * Return the CSS related compatibility issues from given CSS declaration blocks.
   *
   * @param {Array} declarations
   *                CSS declarations to check.
   *                e.g. [{ name: "background-color", value: "lime" }, ...]
   * @param {Array} browsers
   *                Restrict compatibility checks to these browsers and versions.
   *                e.g. [{ id: "firefox", name: "Firefox", version: "68" }, ...]
   * @return {Array} issues
   */
  getCSSDeclarationBlockIssues(declarations, browsers) {
    const summaries = [];
    for (const { name: property } of declarations) {
      // Ignore CSS custom properties as any name is valid.
      if (property.startsWith("--")) {
        continue;
      }

      summaries.push(this._getCSSPropertyCompatSummary(browsers, property));
    }

    // Classify to aliases summaries and normal summaries.
    const { aliasSummaries, normalSummaries } =
      this._classifyCSSCompatSummaries(summaries, browsers);

    // Finally, convert to CSS issues.
    return this._toCSSIssues(normalSummaries.concat(aliasSummaries));
  }

  /**
   * Classify the compatibility summaries that are able to get from
   * `getCSSPropertyCompatSummary`.
   * There are CSS properties that can specify the style with plural aliases such as
   * `user-select`, aggregates those as the aliases summaries.
   *
   * @param {Array} summaries
   *                Assume the result of _getCSSPropertyCompatSummary().
   * @param {Array} browsers
   *                All browsers that to check
   *                e.g. [{ id: "firefox", name: "Firefox", version: "68" }, ...]
   * @return Object
   *                {
   *                  aliasSummaries: Array of alias summary,
   *                  normalSummaries: Array of normal summary
   *                }
   */
  _classifyCSSCompatSummaries(summaries, browsers) {
    const aliasSummariesMap = new Map();
    const normalSummaries = summaries.filter(s => {
      const {
        database,
        invalid,
        terms,
        unsupportedBrowsers,
        prefixNeededBrowsers,
      } = s;

      if (invalid) {
        return true;
      }

      const alias = this._getAlias(database, terms);
      if (!alias) {
        return true;
      }

      if (!aliasSummariesMap.has(alias)) {
        aliasSummariesMap.set(
          alias,
          Object.assign(s, {
            property: alias,
            aliases: [],
            unsupportedBrowsers: browsers,
            prefixNeededBrowsers: browsers,
          })
        );
      }

      // Update alias summary.
      const terminal = terms.pop();
      const aliasSummary = aliasSummariesMap.get(alias);
      if (!aliasSummary.aliases.includes(terminal)) {
        aliasSummary.aliases.push(terminal);
      }
      aliasSummary.unsupportedBrowsers =
        aliasSummary.unsupportedBrowsers.filter(b =>
          unsupportedBrowsers.includes(b)
        );
      aliasSummary.prefixNeededBrowsers =
        aliasSummary.prefixNeededBrowsers.filter(b =>
          prefixNeededBrowsers.includes(b)
        );
      return false;
    });

    const aliasSummaries = [...aliasSummariesMap.values()].map(s => {
      s.prefixNeeded = s.prefixNeededBrowsers.length !== 0;
      return s;
    });

    return { aliasSummaries, normalSummaries };
  }

  _getAlias(compatNode, terms) {
    const targetNode = getCompatNode(compatNode, terms);
    return targetNode ? targetNode._aliasOf : null;
  }

  /**
   * Return the compatibility summary of the terms.
   *
   * @param {Array} browsers
   *                All browsers that to check
   *                e.g. [{ id: "firefox", name: "Firefox", version: "68" }, ...]
   * @param {Array} database
   *                MDN compatibility dataset where finds from
   * @param {Array} terms
   *                The terms which is checked the compatibility summary from the
   *                database. The paremeters are passed as `rest parameters`.
   *                e.g. _getCompatSummary(browsers, database, "user-select", ...)
   * @return {object}
   *                {
   *                  database: The passed database as a parameter,
   *                  terms: The passed terms as a parameter,
   *                  url: The link which indicates the spec in MDN,
   *                  deprecated: true if the spec of terms is deprecated,
   *                  experimental: true if the spec of terms is experimental,
   *                  unsupportedBrowsers: Array of unsupported browsers,
   *                }
   */
  _getCompatSummary(browsers, database, terms) {
    const compatTable = getCompatTable(database, terms);

    if (!compatTable) {
      return { invalid: true, unsupportedBrowsers: [] };
    }

    const unsupportedBrowsers = [];
    const prefixNeededBrowsers = [];

    for (const browser of browsers) {
      const state = this._getSupportState(
        compatTable,
        browser,
        database,
        terms
      );

      switch (state) {
        case _SUPPORT_STATE_UNSUPPORTED_PREFIX_NEEDED: {
          prefixNeededBrowsers.push(browser);
          unsupportedBrowsers.push(browser);
          break;
        }
        case _SUPPORT_STATE_UNSUPPORTED: {
          unsupportedBrowsers.push(browser);
          break;
        }
      }
    }

    const { deprecated, experimental } = compatTable.status || {};

    return {
      database,
      terms,
      url: compatTable.mdn_url,
      specUrl: compatTable.spec_url,
      deprecated,
      experimental,
      unsupportedBrowsers,
      prefixNeededBrowsers,
    };
  }

  /**
   * Return the compatibility summary of the CSS property.
   * This function just adds `property` filed to the result of `_getCompatSummary`.
   *
   * @param {Array} browsers
   *                All browsers that to check
   *                e.g. [{ id: "firefox", name: "Firefox", version: "68" }, ...]
   * @return {object} compatibility summary
   */
  _getCSSPropertyCompatSummary(browsers, property) {
    const summary = this._getCompatSummary(
      browsers,
      this._cssPropertiesCompatData,
      [property]
    );
    return Object.assign(summary, { property });
  }

  _getSupportState(compatTable, browser, compatNode, terms) {
    const supportList = compatTable.support[browser.id];
    if (!supportList) {
      return _SUPPORT_STATE_BROWSER_NOT_FOUND;
    }

    const version = parseFloat(browser.version);
    const terminal = terms.at(-1);
    const prefix = terminal.match(PREFIX_REGEX)?.[0];

    let prefixNeeded = false;
    for (const support of supportList) {
      const { alternative_name: alternativeName, added, removed } = support;

      if (
        // added id true when feature is supported, but we don't know the version
        (added === true ||
          // `null` and `undefined` is when we don't know if it's supported.
          // Since we don't want to have false negative, we consider it as supported
          added === null ||
          added === undefined ||
          // It was added on a previous version number
          added <= version) &&
        // `added` is false when the property isn't supported
        added !== false &&
        // `removed` is false when the feature wasn't removevd
        (removed === false ||
          // `null` and `undefined` is when we don't know if it was removed.
          // Since we don't want to have false negative, we consider it as supported
          removed === null ||
          removed === undefined ||
          // It was removed, but on a later version, so it's still supported
          version <= removed)
      ) {
        if (alternativeName) {
          if (alternativeName === terminal) {
            return _SUPPORT_STATE_SUPPORTED;
          }
        } else if (
          support.prefix === prefix ||
          // There are compat data that are defined with prefix like "-moz-binding".
          // In this case, we don't have to check the prefix.
          (prefix && !this._getAlias(compatNode, terms))
        ) {
          return _SUPPORT_STATE_SUPPORTED;
        }

        prefixNeeded = true;
      }
    }

    return prefixNeeded
      ? _SUPPORT_STATE_UNSUPPORTED_PREFIX_NEEDED
      : _SUPPORT_STATE_UNSUPPORTED;
  }

  _hasIssue({ unsupportedBrowsers, deprecated, experimental, invalid }) {
    // Don't apply as issue the invalid term which was not in the database.
    return (
      !invalid && (unsupportedBrowsers.length || deprecated || experimental)
    );
  }

  _toIssue(summary, type) {
    const issue = Object.assign({}, summary, { type });
    delete issue.database;
    delete issue.terms;
    delete issue.prefixNeededBrowsers;
    return issue;
  }

  _toCSSIssues(summaries) {
    const issues = [];

    for (const summary of summaries) {
      if (!this._hasIssue(summary)) {
        continue;
      }

      const type = summary.aliases
        ? COMPATIBILITY_ISSUE_TYPE.CSS_PROPERTY_ALIASES
        : COMPATIBILITY_ISSUE_TYPE.CSS_PROPERTY;
      issues.push(this._toIssue(summary, type));
    }

    return issues;
  }
}

module.exports = MDNCompatibility;
