/* 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 offers a search engine when the user is
 * typing a search engine domain.
 */

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

const lazy = {};

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

const DYNAMIC_RESULT_TYPE = "onboardTabToSearch";
const VIEW_TEMPLATE = {
  attributes: {
    selectable: true,
  },
  children: [
    {
      name: "no-wrap",
      tag: "span",
      classList: ["urlbarView-no-wrap"],
      children: [
        {
          name: "icon",
          tag: "img",
          classList: ["urlbarView-favicon"],
        },
        {
          name: "text-container",
          tag: "span",
          children: [
            {
              name: "first-row-container",
              tag: "span",
              children: [
                {
                  name: "title",
                  tag: "span",
                  classList: ["urlbarView-title"],
                  children: [
                    {
                      name: "titleStrong",
                      tag: "strong",
                    },
                  ],
                },
                {
                  name: "title-separator",
                  tag: "span",
                  classList: ["urlbarView-title-separator"],
                },
                {
                  name: "action",
                  tag: "span",
                  classList: ["urlbarView-action"],
                  attributes: {
                    "slide-in": true,
                  },
                },
              ],
            },
            {
              name: "description",
              tag: "span",
            },
          ],
        },
      ],
    },
  ],
};

/**
 * Initializes this provider's dynamic result. To be called after the creation
 *  of the provider singleton.
 */
function initializeDynamicResult() {
  lazy.UrlbarResult.addDynamicResultType(DYNAMIC_RESULT_TYPE);
  lazy.UrlbarView.addDynamicViewTemplate(DYNAMIC_RESULT_TYPE, VIEW_TEMPLATE);
}

/**
 * Class used to create the provider.
 */
class ProviderTabToSearch extends UrlbarProvider {
  constructor() {
    super();
    this.enginesShown = {
      onboarding: new Set(),
      regular: new Set(),
    };
  }

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

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

  /**
   * 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.
   *
   * @param {UrlbarQueryContext} queryContext The query context object
   * @returns {boolean} Whether this provider should be invoked for the search.
   */
  async isActive(queryContext) {
    return (
      queryContext.searchString &&
      queryContext.tokens.length == 1 &&
      !queryContext.searchMode &&
      lazy.UrlbarPrefs.get("suggest.engines")
    );
  }

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

  /**
   * This is called only for dynamic result types, when the urlbar view updates
   * the view of one of the results of the provider.  It should return an object
   * describing the view update.
   *
   * @param {UrlbarResult} result The result whose view will be updated.
   * @param {Map} idsByName
   *   A Map from an element's name, as defined by the provider; to its ID in
   *   the DOM, as defined by the browser.
   * @returns {object} An object describing the view update.
   */
  getViewUpdate(result, idsByName) {
    return {
      icon: {
        attributes: {
          src: result.payload.icon,
        },
      },
      titleStrong: {
        l10n: {
          id: "urlbar-result-action-search-w-engine",
          args: {
            engine: result.payload.engine,
          },
        },
      },
      action: {
        l10n: {
          id: result.payload.isGeneralPurposeEngine
            ? "urlbar-result-action-tabtosearch-web"
            : "urlbar-result-action-tabtosearch-other-engine",
          args: {
            engine: result.payload.engine,
          },
        },
      },
      description: {
        l10n: {
          id: "urlbar-tabtosearch-onboard",
        },
      },
    };
  }

  /**
   * Called when a result from the provider is selected. "Selected" refers to
   * the user highlighing the result with the arrow keys/Tab, before it is
   * picked. onSelection is also called when a user clicks a result. In the
   * event of a click, onSelection is called just before onEngagement.
   *
   * @param {UrlbarResult} result
   *   The result that was selected.
   * @param {Element} element
   *   The element in the result's view that was selected.
   */
  onSelection(result, element) {
    // We keep track of the number of times the user interacts with
    // tab-to-search onboarding results so we stop showing them after
    // `tabToSearch.onboard.interactionsLeft` interactions.
    // Also do not increment the counter if the result was interacted with less
    // than 5 minutes ago. This is a guard against the user running up the
    // counter by interacting with the same result repeatedly.
    if (
      result.payload.dynamicType &&
      (!this.onboardingInteractionAtTime ||
        this.onboardingInteractionAtTime < Date.now() - 1000 * 60 * 5)
    ) {
      let interactionsLeft = lazy.UrlbarPrefs.get(
        "tabToSearch.onboard.interactionsLeft"
      );

      if (interactionsLeft > 0) {
        lazy.UrlbarPrefs.set(
          "tabToSearch.onboard.interactionsLeft",
          --interactionsLeft
        );
      }

      this.onboardingInteractionAtTime = Date.now();
    }
  }

  /**
   * Called when the user starts and ends an engagement with the urlbar.  For
   * details on parameters, see UrlbarProvider.onEngagement().
   *
   * @param {boolean} isPrivate
   *   True if the engagement is in a private context.
   * @param {string} state
   *   The state of the engagement, one of: start, engagement, abandonment,
   *   discard
   * @param {UrlbarQueryContext} queryContext
   *   The engagement's query context.  This is *not* guaranteed to be defined
   *   when `state` is "start".  It will always be defined for "engagement" and
   *   "abandonment".
   * @param {object} details
   *   This is defined only when `state` is "engagement" or "abandonment", and
   *   it describes the search string and picked result.
   */
  onEngagement(isPrivate, state, queryContext, details) {
    let { result, element } = details;
    if (
      result?.providerName == this.name &&
      result.type == UrlbarUtils.RESULT_TYPE.DYNAMIC
    ) {
      // Confirm search mode, but only for the onboarding (dynamic) result. The
      // input will handle confirming search mode for the non-onboarding
      // `RESULT_TYPE.SEARCH` result since it sets `providesSearchMode`.
      element.ownerGlobal.gURLBar.maybeConfirmSearchModeFromResult({
        result,
        checkValue: false,
      });
    }

    if (!this.enginesShown.regular.size && !this.enginesShown.onboarding.size) {
      return;
    }

    try {
      // urlbar.tabtosearch.* is prerelease-only/opt-in for now. See bug 1686330.
      for (let engine of this.enginesShown.regular) {
        let scalarKey = lazy.UrlbarSearchUtils.getSearchModeScalarKey({
          engineName: engine,
        });
        Services.telemetry.keyedScalarAdd(
          "urlbar.tabtosearch.impressions",
          scalarKey,
          1
        );
      }
      for (let engine of this.enginesShown.onboarding) {
        let scalarKey = lazy.UrlbarSearchUtils.getSearchModeScalarKey({
          engineName: engine,
        });
        Services.telemetry.keyedScalarAdd(
          "urlbar.tabtosearch.impressions_onboarding",
          scalarKey,
          1
        );
      }

      // We also record in urlbar.tips because only it has been approved for use
      // in release channels.
      Services.telemetry.keyedScalarAdd(
        "urlbar.tips",
        "tabtosearch-shown",
        this.enginesShown.regular.size
      );
      Services.telemetry.keyedScalarAdd(
        "urlbar.tips",
        "tabtosearch_onboard-shown",
        this.enginesShown.onboarding.size
      );
    } catch (ex) {
      // If your test throws this error or causes another test to throw it, it
      // is likely because your test showed a tab-to-search result but did not
      // start and end the engagement in which it was shown. Be sure to fire an
      // input event to start an engagement and blur the Urlbar to end it.
      this.logger.error(
        `Exception while recording TabToSearch telemetry: ${ex})`
      );
    } finally {
      // Even if there's an exception, we want to clear these Sets. Otherwise,
      // we might get into a state where we repeatedly run the same engines
      // through the code above and never record telemetry, because there's an
      // error every time.
      this.enginesShown.regular.clear();
      this.enginesShown.onboarding.clear();
    }
  }

  /**
   * Defines whether the view should defer user selection events while waiting
   * for the first result from this provider.
   *
   * @returns {boolean} Whether the provider wants to defer user selection
   *          events.
   */
  get deferUserSelection() {
    return true;
  }

  /**
   * 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) {
    // enginesForDomainPrefix only matches against engine domains.
    // Remove trailing slashes and www. from the search string and check if the
    // resulting string is worth matching.
    let [searchStr] = UrlbarUtils.stripPrefixAndTrim(
      queryContext.searchString,
      {
        stripWww: true,
        trimSlash: true,
      }
    );
    // Skip any string that cannot be an origin.
    if (
      !lazy.UrlbarTokenizer.looksLikeOrigin(searchStr, {
        ignoreKnownDomains: true,
        noIp: true,
      })
    ) {
      return;
    }

    // Also remove the public suffix, if present, to allow for partial matches.
    if (searchStr.includes(".")) {
      searchStr = UrlbarUtils.stripPublicSuffixFromHost(searchStr);
    }

    // Add all matching engines.
    let engines = await lazy.UrlbarSearchUtils.enginesForDomainPrefix(
      searchStr,
      {
        matchAllDomainLevels: true,
        onlyEnabled: true,
      }
    );
    if (!engines.length) {
      return;
    }

    const onboardingInteractionsLeft = lazy.UrlbarPrefs.get(
      "tabToSearch.onboard.interactionsLeft"
    );

    // If the engine host begins with the search string, autofill may happen
    // for it, and the Muxer will retain the result only if there's a matching
    // autofill heuristic result.
    // Otherwise, we may have a partial match, where the search string is at
    // the boundary of a host part, for example "wiki" in "en.wikipedia.org".
    // We put those engines apart, and later we check if their host satisfies
    // the autofill threshold. If they do, we mark them with the
    // "satisfiesAutofillThreshold" payload property, so the muxer can avoid
    // filtering them out.
    let partialMatchEnginesByHost = new Map();

    for (let engine of engines) {
      // Trim the engine host. This will also be set as the result url, so the
      // Muxer can use it to filter.
      let [host] = UrlbarUtils.stripPrefixAndTrim(engine.searchUrlDomain, {
        stripWww: true,
      });
      // Check if the host may be autofilled.
      if (host.startsWith(searchStr.toLocaleLowerCase())) {
        if (onboardingInteractionsLeft > 0) {
          addCallback(this, makeOnboardingResult(engine));
        } else {
          addCallback(this, makeResult(queryContext, engine));
        }
        continue;
      }

      // Otherwise it may be a partial match that would not be autofilled.
      if (host.includes("." + searchStr.toLocaleLowerCase())) {
        partialMatchEnginesByHost.set(engine.searchUrlDomain, engine);
        // Don't continue here, we are looking for more partial matches.
      }
      // We also try to match the searchForm domain, because otherwise for an
      // engine like ebay, we'd check rover.ebay.com, when the user is likely
      // to visit ebay.LANG. The searchForm URL often points to the main host.
      let searchFormHost;
      try {
        searchFormHost = new URL(engine.searchForm).host;
      } catch (ex) {
        // Invalid url or no searchForm.
      }
      if (searchFormHost?.includes("." + searchStr)) {
        partialMatchEnginesByHost.set(searchFormHost, engine);
      }
    }
    if (partialMatchEnginesByHost.size) {
      let host = await lazy.UrlbarProviderAutofill.getTopHostOverThreshold(
        queryContext,
        Array.from(partialMatchEnginesByHost.keys())
      );
      if (host) {
        let engine = partialMatchEnginesByHost.get(host);
        if (onboardingInteractionsLeft > 0) {
          addCallback(this, makeOnboardingResult(engine, true));
        } else {
          addCallback(this, makeResult(queryContext, engine, true));
        }
      }
    }
  }
}

function makeOnboardingResult(engine, satisfiesAutofillThreshold = false) {
  let [url] = UrlbarUtils.stripPrefixAndTrim(engine.searchUrlDomain, {
    stripWww: true,
  });
  url = url.substr(0, url.length - engine.searchUrlPublicSuffix.length);
  let result = new lazy.UrlbarResult(
    UrlbarUtils.RESULT_TYPE.DYNAMIC,
    UrlbarUtils.RESULT_SOURCE.SEARCH,
    {
      engine: engine.name,
      url,
      providesSearchMode: true,
      icon: UrlbarUtils.ICON.SEARCH_GLASS,
      dynamicType: DYNAMIC_RESULT_TYPE,
      satisfiesAutofillThreshold,
    }
  );
  result.resultSpan = 2;
  result.suggestedIndex = 1;
  return result;
}

function makeResult(context, engine, satisfiesAutofillThreshold = false) {
  let [url] = UrlbarUtils.stripPrefixAndTrim(engine.searchUrlDomain, {
    stripWww: true,
  });
  url = url.substr(0, url.length - engine.searchUrlPublicSuffix.length);
  let result = new lazy.UrlbarResult(
    UrlbarUtils.RESULT_TYPE.SEARCH,
    UrlbarUtils.RESULT_SOURCE.SEARCH,
    ...lazy.UrlbarResult.payloadAndSimpleHighlights(context.tokens, {
      engine: engine.name,
      isGeneralPurposeEngine: engine.isGeneralPurposeEngine,
      url,
      providesSearchMode: true,
      icon: UrlbarUtils.ICON.SEARCH_GLASS,
      query: "",
      satisfiesAutofillThreshold,
    })
  );
  result.suggestedIndex = 1;
  return result;
}

export var UrlbarProviderTabToSearch = new ProviderTabToSearch();
initializeDynamicResult();
