/* 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 module exports a urlbar result class, each representing a single result
 * found by a provider that can be passed from the model to the view through
 * the controller. It is mainly defined by a result type, and a payload,
 * containing the data. A few getters allow to retrieve information common to all
 * the result types.
 */

import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  JsonSchemaValidator:
    "resource://gre/modules/components-utils/JsonSchemaValidator.sys.mjs",
  UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
  UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs",
});

XPCOMUtils.defineLazyModuleGetters(lazy, {
  BrowserUIUtils: "resource:///modules/BrowserUIUtils.jsm",
});

/**
 * Class used to create a single result.
 */
export class UrlbarResult {
  /**
   * Creates a result.
   *
   * @param {integer} resultType one of UrlbarUtils.RESULT_TYPE.* values
   * @param {integer} resultSource one of UrlbarUtils.RESULT_SOURCE.* values
   * @param {object} payload data for this result. A payload should always
   *        contain a way to extract a final url to visit. The url getter
   *        should have a case for each of the types.
   * @param {object} [payloadHighlights] payload highlights, if any. Each
   *        property in the payload may have a corresponding property in this
   *        object. The value of each property should be an array of [index,
   *        length] tuples. Each tuple indicates a substring in the corresponding
   *        payload property.
   */
  constructor(resultType, resultSource, payload, payloadHighlights = {}) {
    // Type describes the payload and visualization that should be used for
    // this result.
    if (!Object.values(lazy.UrlbarUtils.RESULT_TYPE).includes(resultType)) {
      throw new Error("Invalid result type");
    }
    this.type = resultType;

    // Source describes which data has been used to derive this result. In case
    // multiple sources are involved, use the more privacy restricted.
    if (!Object.values(lazy.UrlbarUtils.RESULT_SOURCE).includes(resultSource)) {
      throw new Error("Invalid result source");
    }
    this.source = resultSource;

    // UrlbarView is responsible for updating this.
    this.rowIndex = -1;

    // May be used to indicate an heuristic result. Heuristic results can bypass
    // source filters in the ProvidersManager, that otherwise may skip them.
    this.heuristic = false;

    // Exposure specific properties. These allow us to track the exposure
    // of a result through the query process.
    // A non-zero value here indicates that this result's exposure should be
    // recorded in the exposure event.
    this.exposureResultType = "";

    // Determines if the exposure result should be hidden from the view.
    this.exposureResultHidden = false;

    // The payload contains result data. Some of the data is common across
    // multiple types, but most of it will vary.
    if (!payload || typeof payload != "object") {
      throw new Error("Invalid result payload");
    }
    this.payload = this.validatePayload(payload);

    if (!payloadHighlights || typeof payloadHighlights != "object") {
      throw new Error("Invalid result payload highlights");
    }
    this.payloadHighlights = payloadHighlights;

    // Make sure every property in the payload has an array of highlights.  If a
    // payload property does not have a highlights array, then give it one now.
    // That way the consumer doesn't need to check whether it exists.
    for (let name in payload) {
      if (!(name in this.payloadHighlights)) {
        this.payloadHighlights[name] = [];
      }
    }
  }

  /**
   * Returns a title that could be used as a label for this result.
   *
   * @returns {string} The label to show in a simplified title / url view.
   */
  get title() {
    return this._titleAndHighlights[0];
  }

  /**
   * Returns an array of highlights for the title.
   *
   * @returns {Array} The array of highlights.
   */
  get titleHighlights() {
    return this._titleAndHighlights[1];
  }

  /**
   * Returns an array [title, highlights].
   *
   * @returns {Array} The title and array of highlights.
   */
  get _titleAndHighlights() {
    switch (this.type) {
      case lazy.UrlbarUtils.RESULT_TYPE.KEYWORD:
      case lazy.UrlbarUtils.RESULT_TYPE.TAB_SWITCH:
      case lazy.UrlbarUtils.RESULT_TYPE.URL:
      case lazy.UrlbarUtils.RESULT_TYPE.OMNIBOX:
      case lazy.UrlbarUtils.RESULT_TYPE.REMOTE_TAB:
        if (this.payload.qsSuggestion) {
          return [
            // We will initially only be targeting en-US users with this experiment
            // but will need to change this to work properly with l10n.
            this.payload.qsSuggestion + " — " + this.payload.title,
            this.payloadHighlights.qsSuggestion,
          ];
        }

        if (this.payload.fallbackTitle) {
          return [
            this.payload.fallbackTitle,
            this.payloadHighlights.fallbackTitle,
          ];
        }

        if (this.payload.title) {
          return [this.payload.title, this.payloadHighlights.title];
        }

        return [this.payload.url ?? "", this.payloadHighlights.url ?? []];
      case lazy.UrlbarUtils.RESULT_TYPE.SEARCH:
        if (this.payload.providesSearchMode) {
          return ["", []];
        }
        if (this.payload.tail && this.payload.tailOffsetIndex >= 0) {
          return [this.payload.tail, this.payloadHighlights.tail];
        } else if (this.payload.suggestion) {
          return [this.payload.suggestion, this.payloadHighlights.suggestion];
        }
        return [this.payload.query, this.payloadHighlights.query];
      default:
        return ["", []];
    }
  }

  /**
   * Returns an icon url.
   *
   * @returns {string} url of the icon.
   */
  get icon() {
    return this.payload.icon;
  }

  /**
   * Returns whether the result's `suggestedIndex` property is defined.
   * `suggestedIndex` is an optional hint to the muxer that can be set to
   * suggest a specific position among the results.
   *
   * @returns {boolean} Whether `suggestedIndex` is defined.
   */
  get hasSuggestedIndex() {
    return typeof this.suggestedIndex == "number";
  }

  /**
   * Returns the given payload if it's valid or throws an error if it's not.
   * The schemas in UrlbarUtils.RESULT_PAYLOAD_SCHEMA are used for validation.
   *
   * @param {object} payload The payload object.
   * @returns {object} `payload` if it's valid.
   */
  validatePayload(payload) {
    let schema = lazy.UrlbarUtils.getPayloadSchema(this.type);
    if (!schema) {
      throw new Error(`Unrecognized result type: ${this.type}`);
    }
    let result = lazy.JsonSchemaValidator.validate(payload, schema, {
      allowExplicitUndefinedProperties: true,
      allowNullAsUndefinedProperties: true,
      allowExtraProperties: this.type == lazy.UrlbarUtils.RESULT_TYPE.DYNAMIC,
    });
    if (!result.valid) {
      throw result.error;
    }
    return payload;
  }

  /**
   * A convenience function that takes a payload annotated with
   * UrlbarUtils.HIGHLIGHT enums and returns the payload and the payload's
   * highlights. Use this function when the highlighting required by your
   * payload is based on simple substring matching, as done by
   * UrlbarUtils.getTokenMatches(). Pass the return values as the `payload` and
   * `payloadHighlights` params of the UrlbarResult constructor.
   * `payloadHighlights` is optional. If omitted, payload will not be
   * highlighted.
   *
   * If the payload doesn't have a title or has an empty title, and it also has
   * a URL, then this function also sets the title to the URL's domain.
   *
   * @param {Array} tokens The tokens that should be highlighted in each of the
   *        payload properties.
   * @param {object} payloadInfo An object that looks like this:
   *        { payloadPropertyName: payloadPropertyInfo }
   *
   *        Each payloadPropertyInfo may be either a string or an array.  If
   *        it's a string, then the property value will be that string, and no
   *        highlighting will be applied to it.  If it's an array, then it
   *        should look like this: [payloadPropertyValue, highlightType].
   *        payloadPropertyValue may be a string or an array of strings.  If
   *        it's a string, then the payloadHighlights in the return value will
   *        be an array of match highlights as described in
   *        UrlbarUtils.getTokenMatches().  If it's an array, then
   *        payloadHighlights will be an array of arrays of match highlights,
   *        one element per element in payloadPropertyValue.
   * @returns {Array} An array [payload, payloadHighlights].
   */
  static payloadAndSimpleHighlights(tokens, payloadInfo) {
    // Convert scalar values in payloadInfo to [value] arrays.
    for (let [name, info] of Object.entries(payloadInfo)) {
      if (!Array.isArray(info)) {
        payloadInfo[name] = [info];
      }
    }

    if (
      (!payloadInfo.title || !payloadInfo.title[0]) &&
      !payloadInfo.fallbackTitle &&
      payloadInfo.url &&
      typeof payloadInfo.url[0] == "string"
    ) {
      // If there's no title, show the domain as the title.  Not all valid URLs
      // have a domain.
      payloadInfo.title = payloadInfo.title || [
        "",
        lazy.UrlbarUtils.HIGHLIGHT.TYPED,
      ];
      try {
        payloadInfo.title[0] = new URL(payloadInfo.url[0]).host;
      } catch (e) {}
    }

    if (payloadInfo.url) {
      // For display purposes we need to unescape the url.
      payloadInfo.displayUrl = [...payloadInfo.url];
      let url = payloadInfo.displayUrl[0];
      if (url && lazy.UrlbarPrefs.get("trimURLs")) {
        url = lazy.BrowserUIUtils.removeSingleTrailingSlashFromURL(url);
        if (url.startsWith("https://")) {
          url = url.substring(8);
          if (url.startsWith("www.")) {
            url = url.substring(4);
          }
        }
      }
      payloadInfo.displayUrl[0] = lazy.UrlbarUtils.unEscapeURIForUI(url);
    }

    // For performance reasons limit excessive string lengths, to reduce the
    // amount of string matching we do here, and avoid wasting resources to
    // handle long textruns that the user would never see anyway.
    for (let prop of ["displayUrl", "title", "suggestion"]) {
      let val = payloadInfo[prop]?.[0];
      if (typeof val == "string") {
        payloadInfo[prop][0] = val.substring(
          0,
          lazy.UrlbarUtils.MAX_TEXT_LENGTH
        );
      }
    }

    let entries = Object.entries(payloadInfo);
    return [
      entries.reduce((payload, [name, [val, _]]) => {
        payload[name] = val;
        return payload;
      }, {}),
      entries.reduce((highlights, [name, [val, highlightType]]) => {
        if (highlightType) {
          highlights[name] = !Array.isArray(val)
            ? lazy.UrlbarUtils.getTokenMatches(tokens, val || "", highlightType)
            : val.map(subval =>
                lazy.UrlbarUtils.getTokenMatches(tokens, subval, highlightType)
              );
        }
        return highlights;
      }, {}),
    ];
  }

  static _dynamicResultTypesByName = new Map();

  /**
   * Registers a dynamic result type.  Dynamic result types are types that are
   * created at runtime, for example by an extension.  A particular type should
   * be added only once; if this method is called for a type more than once, the
   * `type` in the last call overrides those in previous calls.
   *
   * @param {string} name
   *   The name of the type.  This is used in CSS selectors, so it shouldn't
   *   contain any spaces or punctuation except for -, _, etc.
   * @param {object} type
   *   An object that describes the type.  Currently types do not have any
   *   associated metadata, so this object should be empty.
   */
  static addDynamicResultType(name, type = {}) {
    if (/[^a-z0-9_-]/i.test(name)) {
      this.logger.error(`Illegal dynamic type name: ${name}`);
      return;
    }
    this._dynamicResultTypesByName.set(name, type);
  }

  /**
   * Unregisters a dynamic result type.
   *
   * @param {string} name
   *   The name of the type.
   */
  static removeDynamicResultType(name) {
    let type = this._dynamicResultTypesByName.get(name);
    if (type) {
      this._dynamicResultTypesByName.delete(name);
    }
  }

  /**
   * Returns an object describing a registered dynamic result type.
   *
   * @param {string} name
   *   The name of the type.
   * @returns {object}
   *   Currently types do not have any associated metadata, so the return value
   *   is an empty object if the type exists.  If the type doesn't exist,
   *   undefined is returned.
   */
  static getDynamicResultType(name) {
    return this._dynamicResultTypesByName.get(name);
  }

  /**
   * This is useful for logging results. If you need the full payload, then it's
   * better to JSON.stringify the result object itself.
   *
   * @returns {string} string representation of the result.
   */
  toString() {
    if (this.payload.url) {
      return this.payload.title + " - " + this.payload.url.substr(0, 100);
    }
    if (this.payload.keyword) {
      return this.payload.keyword + " - " + this.payload.query;
    }
    if (this.payload.suggestion) {
      return this.payload.engine + " - " + this.payload.suggestion;
    }
    if (this.payload.engine) {
      return this.payload.engine + " - " + this.payload.query;
    }
    return JSON.stringify(this);
  }
}
