/* 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 provider that provides a heuristic result. The result
 * either vists a URL or does a search with the current engine. This result is
 * always the ultimate fallback for any query, so this provider is always active.
 */

import {
  UrlbarProvider,
  UrlbarUtils,
} from "resource:///modules/UrlbarUtils.sys.mjs";

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
  UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs",
  UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.sys.mjs",
  UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.sys.mjs",
});

/**
 * Class used to create the provider.
 */
class ProviderHeuristicFallback extends UrlbarProvider {
  constructor() {
    super();
  }

  /**
   * Returns the name of this provider.
   *
   * @returns {string} the name of this provider.
   */
  get name() {
    return "HeuristicFallback";
  }

  /**
   * Returns the type of this provider.
   *
   * @returns {integer} one of the types from UrlbarUtils.PROVIDER_TYPE.*
   */
  get type() {
    return UrlbarUtils.PROVIDER_TYPE.HEURISTIC;
  }

  /**
   * Whether this provider should be invoked for the given context.
   * If this method returns false, the providers manager won't start a query
   * with this provider, to save on resources.
   *
   * @returns {boolean} Whether this provider should be invoked for the search.
   */
  isActive() {
    return true;
  }

  /**
   * Gets the provider's priority.
   *
   * @returns {number} The provider's priority for the given query.
   */
  getPriority() {
    return 0;
  }

  /**
   * Starts querying.
   *
   * @param {object} queryContext The query context object
   * @param {Function} addCallback Callback invoked by the provider to add a new
   *        result.
   * @returns {Promise} resolved when the query stops.
   */
  async startQuery(queryContext, addCallback) {
    let instance = this.queryInstance;

    let result = this._matchUnknownUrl(queryContext);
    if (result) {
      addCallback(this, result);
      // Since we can't tell if this is a real URL and whether the user wants
      // to visit or search for it, we provide an alternative searchengine
      // match if the string looks like an alphanumeric origin or an e-mail.
      let str = queryContext.searchString;
      try {
        new URL(str);
      } catch (ex) {
        if (
          lazy.UrlbarPrefs.get("keyword.enabled") &&
          (lazy.UrlbarTokenizer.looksLikeOrigin(str, {
            noIp: true,
            noPort: true,
          }) ||
            lazy.UrlbarTokenizer.REGEXP_COMMON_EMAIL.test(str))
        ) {
          let searchResult = await this._engineSearchResult(queryContext);
          if (instance != this.queryInstance) {
            return;
          }
          addCallback(this, searchResult);
        }
      }
      return;
    }

    result = await this._searchModeKeywordResult(queryContext);
    if (instance != this.queryInstance) {
      return;
    }
    if (result) {
      addCallback(this, result);
      return;
    }

    if (
      lazy.UrlbarPrefs.get("keyword.enabled") ||
      queryContext.restrictSource == UrlbarUtils.RESULT_SOURCE.SEARCH ||
      queryContext.searchMode
    ) {
      result = await this._engineSearchResult(queryContext);
      if (instance != this.queryInstance) {
        return;
      }
      if (result) {
        result.heuristic = true;
        addCallback(this, result);
      }
    }
  }

  // TODO (bug 1054814): Use visited URLs to inform which scheme to use, if the
  // scheme isn't specificed.
  _matchUnknownUrl(queryContext) {
    // The user may have typed something like "word?" to run a search.  We
    // should not convert that to a URL.  We should also never convert actual
    // URLs into URL results when search mode is active or a search mode
    // restriction token was typed.
    if (
      queryContext.restrictSource == UrlbarUtils.RESULT_SOURCE.SEARCH ||
      lazy.UrlbarTokenizer.SEARCH_MODE_RESTRICT.has(
        queryContext.restrictToken?.value
      ) ||
      queryContext.searchMode
    ) {
      return null;
    }

    let unescapedSearchString = UrlbarUtils.unEscapeURIForUI(
      queryContext.searchString
    );
    let [prefix, suffix] = UrlbarUtils.stripURLPrefix(unescapedSearchString);
    if (!suffix && prefix) {
      // The user just typed a stripped protocol, don't build a non-sense url
      // like http://http/ for it.
      return null;
    }

    let searchUrl = queryContext.trimmedSearchString;

    if (queryContext.fixupError) {
      if (
        queryContext.fixupError == Cr.NS_ERROR_MALFORMED_URI &&
        !lazy.UrlbarPrefs.get("keyword.enabled")
      ) {
        let result = new lazy.UrlbarResult(
          UrlbarUtils.RESULT_TYPE.URL,
          UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL,
          ...lazy.UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, {
            fallbackTitle: [searchUrl, UrlbarUtils.HIGHLIGHT.NONE],
            url: [searchUrl, UrlbarUtils.HIGHLIGHT.NONE],
          })
        );
        result.heuristic = true;
        return result;
      }

      return null;
    }

    // If the URI cannot be fixed or the preferred URI would do a keyword search,
    // that basically means this isn't useful to us. Note that
    // fixupInfo.keywordAsSent will never be true if the keyword.enabled pref
    // is false or there are no engines, so in that case we will always return
    // a "visit".
    if (!queryContext.fixupInfo?.href || queryContext.fixupInfo?.isSearch) {
      return null;
    }

    let uri = new URL(queryContext.fixupInfo.href);
    // Check the host, as "http:///" is a valid nsIURI, but not useful to us.
    // But, some schemes are expected to have no host. So we check just against
    // schemes we know should have a host. This allows new schemes to be
    // implemented without us accidentally blocking access to them.
    let hostExpected = ["http:", "https:", "ftp:", "chrome:"].includes(
      uri.protocol
    );
    if (hostExpected && !uri.host) {
      return null;
    }

    // getFixupURIInfo() escaped the URI, so it may not be pretty.  Embed the
    // escaped URL in the result since that URL should be "canonical".  But
    // pass the pretty, unescaped URL as the result's title, since it is
    // displayed to the user.
    let escapedURL = uri.toString();
    let displayURL = UrlbarUtils.prepareUrlForDisplay(uri, {
      trimURL: false,
      // If the user didn't type a protocol, and we added one, don't show it,
      // as https-first may upgrade it, potentially breaking expectations.
      schemeless: !prefix,
    });

    // We don't know if this url is in Places or not, and checking that would
    // be expensive. Thus we also don't know if we may have an icon.
    // If we'd just try to fetch the icon for the typed string, we'd cause icon
    // flicker, since the url keeps changing while the user types.
    // By default we won't provide an icon, but for the subset of urls with a
    // host we'll check for a typed slash and set favicon for the host part.
    let iconUri;
    if (hostExpected && (searchUrl.endsWith("/") || uri.pathname.length > 1)) {
      // Look for an icon with the entire URL except for the pathname, including
      // scheme, usernames, passwords, hostname, and port.
      let pathIndex = uri.toString().lastIndexOf(uri.pathname);
      let prePath = uri.toString().slice(0, pathIndex);
      iconUri = `page-icon:${prePath}/`;
    }

    let result = new lazy.UrlbarResult(
      UrlbarUtils.RESULT_TYPE.URL,
      UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL,
      ...lazy.UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, {
        fallbackTitle: [displayURL, UrlbarUtils.HIGHLIGHT.NONE],
        url: [escapedURL, UrlbarUtils.HIGHLIGHT.NONE],
        icon: iconUri,
      })
    );
    result.heuristic = true;
    return result;
  }

  async _searchModeKeywordResult(queryContext) {
    if (!queryContext.tokens.length) {
      return null;
    }

    let firstToken = queryContext.tokens[0].value;
    if (!lazy.UrlbarTokenizer.SEARCH_MODE_RESTRICT.has(firstToken)) {
      return null;
    }

    // At this point, the search string starts with a token that can be
    // converted into search mode.
    // Now we need to determine what to do based on the remainder of the search
    // string.  If the remainder starts with a space, then we should enter
    // search mode, so we should continue below and create the result.
    // Otherwise, we should not enter search mode, and in that case, the search
    // string will look like one of the following:
    //
    // * The search string ends with the restriction token (e.g., the user
    //   has typed only the token by itself, with no trailing spaces).
    // * More tokens exist, but there's no space between the restriction
    //   token and the following token.  This is possible because the tokenizer
    //   does not require spaces between a restriction token and the remainder
    //   of the search string.  In this case, we should not enter search mode.
    //
    // If we return null here and thereby do not enter search mode, then we'll
    // continue on to _engineSearchResult, and the heuristic will be a
    // default engine search result.
    let query = UrlbarUtils.substringAfter(
      queryContext.searchString,
      firstToken
    );
    if (!lazy.UrlbarTokenizer.REGEXP_SPACES_START.test(query)) {
      return null;
    }

    let result;
    if (queryContext.restrictSource == UrlbarUtils.RESULT_SOURCE.SEARCH) {
      result = await this._engineSearchResult(queryContext, firstToken);
    } else {
      result = new lazy.UrlbarResult(
        UrlbarUtils.RESULT_TYPE.SEARCH,
        UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL,
        ...lazy.UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, {
          query: [query.trimStart(), UrlbarUtils.HIGHLIGHT.NONE],
          keyword: [firstToken, UrlbarUtils.HIGHLIGHT.NONE],
        })
      );
    }
    result.heuristic = true;
    return result;
  }

  async _engineSearchResult(queryContext, keyword = null) {
    let engine;
    if (queryContext.searchMode?.engineName) {
      engine = lazy.UrlbarSearchUtils.getEngineByName(
        queryContext.searchMode.engineName
      );
    } else {
      engine = lazy.UrlbarSearchUtils.getDefaultEngine(queryContext.isPrivate);
    }

    if (!engine) {
      return null;
    }

    // Strip a leading search restriction char, because we prepend it to text
    // when the search shortcut is used and it's not user typed. Don't strip
    // other restriction chars, so that it's possible to search for things
    // including one of those (e.g. "c#").
    let query = queryContext.searchString;
    if (
      queryContext.tokens[0] &&
      queryContext.tokens[0].value === lazy.UrlbarTokenizer.RESTRICT.SEARCH
    ) {
      query = UrlbarUtils.substringAfter(
        query,
        queryContext.tokens[0].value
      ).trim();
    }

    return new lazy.UrlbarResult(
      UrlbarUtils.RESULT_TYPE.SEARCH,
      UrlbarUtils.RESULT_SOURCE.SEARCH,
      ...lazy.UrlbarResult.payloadAndSimpleHighlights(queryContext.tokens, {
        engine: [engine.name, UrlbarUtils.HIGHLIGHT.TYPED],
        icon: await engine.getIconURL(),
        query: [query, UrlbarUtils.HIGHLIGHT.NONE],
        keyword: keyword ? [keyword, UrlbarUtils.HIGHLIGHT.NONE] : undefined,
      })
    );
  }
}

export var UrlbarProviderHeuristicFallback = new ProviderHeuristicFallback();
