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

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

const lazy = XPCOMUtils.declareLazy({
  BrowserUIUtils: "resource:///modules/BrowserUIUtils.sys.mjs",
  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
  UrlbarPrefs: "moz-src:///browser/components/urlbar/UrlbarPrefs.sys.mjs",
  UrlbarUtils: "moz-src:///browser/components/urlbar/UrlbarUtils.sys.mjs",
});

/**
 * Applies URL highlighting and other styling to the text in the urlbar input,
 * depending on the text.
 */
export class UrlbarValueFormatter {
  /**
   * @param {UrlbarInput} urlbarInput
   *   The parent instance of UrlbarInput
   */
  constructor(urlbarInput) {
    this.#urlbarInput = urlbarInput;

    this.#window.addEventListener("resize", this);
  }

  async update() {
    let instance = (this.#updateInstance = {});

    // #getUrlMetaData does URI fixup, which depends on the search service, so
    // make sure it's initialized, or URIFixup may force synchronous
    // initialization. It can be uninitialized here on session restore. Skip
    // this if the service is already initialized in order to avoid the async
    // call in the common case. However, we can't access Service.search before
    // first paint (delayed startup) because there's a performance test that
    // prohibits it, so first await delayed startup.
    if (!this.#window.gBrowserInit.delayedStartupFinished) {
      await this.#window.delayedStartupPromise;
      if (this.#updateInstance != instance) {
        return;
      }
    }
    if (!Services.search.isInitialized) {
      try {
        await Services.search.init();
      } catch {}

      if (this.#updateInstance != instance) {
        return;
      }
    }

    // If this window is being torn down, stop here
    if (!this.#window.docShell) {
      return;
    }

    // Cleanup that must be done in any case, even if there's no value.
    this.#urlbarInput.removeAttribute("domaindir");
    this.#scheme.value = "";

    if (!this.#urlbarInput.value) {
      return;
    }

    // Remove the current formatting.
    this.#removeURLFormat();
    this.#removeSearchAliasFormat();

    // Apply new formatting.  Formatter methods should return true if they
    // successfully formatted the value and false if not.  We apply only
    // one formatter at a time, so we stop at the first successful one.
    this.#window.requestAnimationFrame(() => {
      if (this.#updateInstance != instance) {
        return;
      }
      this.#formattingApplied = this.#formatURL() || this.#formatSearchAlias();
    });
  }

  /**
   * The parent instance of UrlbarInput
   */
  #urlbarInput;

  get #document() {
    return this.#urlbarInput.document;
  }

  get #inputField() {
    return this.#urlbarInput.inputField;
  }

  get #window() {
    return this.#urlbarInput.window;
  }

  get #scheme() {
    return /** @type {HTMLInputElement} */ (
      this.#urlbarInput.querySelector("#urlbar-scheme")
    );
  }

  #ensureFormattedHostVisible(urlMetaData) {
    // Make sure the host is always visible. Since it is aligned on
    // the first strong directional character, we set scrollLeft
    // appropriately to ensure the domain stays visible in case of an
    // overflow.
    // In the future, for example in bug 525831, we may add a forceRTL
    // char just after the domain, and in such a case we should not
    // scroll to the left.
    urlMetaData = urlMetaData || this.#getUrlMetaData();
    if (!urlMetaData) {
      this.#urlbarInput.removeAttribute("domaindir");
      return;
    }
    let { url, preDomain, domain } = urlMetaData;
    let directionality = this.#window.windowUtils.getDirectionFromText(domain);
    if (
      directionality == this.#window.windowUtils.DIRECTION_RTL &&
      url[preDomain.length + domain.length] != "\u200E"
    ) {
      this.#urlbarInput.setAttribute("domaindir", "rtl");
      this.#inputField.scrollLeft = this.#inputField.scrollLeftMax;
    } else {
      this.#urlbarInput.setAttribute("domaindir", "ltr");
      this.#inputField.scrollLeft = 0;
    }
    this.#urlbarInput.updateTextOverflow();
  }

  #getUrlMetaData() {
    if (this.#urlbarInput.focused) {
      return null;
    }

    let inputValue = this.#urlbarInput.value;
    // getFixupURIInfo logs an error if the URL is empty. Avoid that by
    // returning early.
    if (!inputValue) {
      return null;
    }
    let browser = this.#window.gBrowser.selectedBrowser;
    let browserState = this.#urlbarInput.getBrowserState(browser);

    // Since doing a full URIFixup and offset calculations is expensive, we
    // keep the metadata cached in the browser itself, so when switching tabs
    // we can skip most of this.
    if (
      browserState.urlMetaData &&
      browserState.urlMetaData.inputValue == inputValue &&
      browserState.urlMetaData.untrimmedValue ==
        this.#urlbarInput.untrimmedValue
    ) {
      return browserState.urlMetaData.data;
    }
    browserState.urlMetaData = {
      inputValue,
      untrimmedValue: this.#urlbarInput.untrimmedValue,
      data: null,
    };

    // Get the URL from the fixup service:
    let flags =
      Services.uriFixup.FIXUP_FLAG_FIX_SCHEME_TYPOS |
      Services.uriFixup.FIXUP_FLAG_ALLOW_KEYWORD_LOOKUP;
    if (lazy.PrivateBrowsingUtils.isWindowPrivate(this.#window)) {
      flags |= Services.uriFixup.FIXUP_FLAG_PRIVATE_CONTEXT;
    }

    let uriInfo;
    try {
      uriInfo = Services.uriFixup.getFixupURIInfo(
        this.#urlbarInput.untrimmedValue,
        flags
      );
    } catch (ex) {}
    // Ignore if we couldn't make a URI out of this, the URI resulted in a search,
    // or the URI has a non-http(s) protocol.
    if (
      !uriInfo ||
      !uriInfo.fixedURI ||
      uriInfo.keywordProviderName ||
      !["http", "https"].includes(uriInfo.fixedURI.scheme)
    ) {
      return null;
    }

    // We must ensure the protocol is present in the parsed string, so we don't
    // get confused by user:pass@host. It may not have been present originally,
    // or it may have been trimmed. We later use trimmedLength to ensure we
    // don't count the length of a trimmed protocol when determining which parts
    // of the input value to de-emphasize as `preDomain`.
    let url = inputValue;
    let trimmedLength = 0;
    let trimmedProtocol = lazy.BrowserUIUtils.trimURLProtocol;
    if (
      this.#urlbarInput.untrimmedValue.startsWith(trimmedProtocol) &&
      !inputValue.startsWith(trimmedProtocol)
    ) {
      // The protocol has been trimmed, so we add it back.
      url = trimmedProtocol + inputValue;
      trimmedLength = trimmedProtocol.length;
    } else if (
      uriInfo.schemelessInput == Ci.nsILoadInfo.SchemelessInputTypeSchemeless
    ) {
      // The original string didn't have a protocol, but it was identified as
      // a URL. It's not important which scheme we use for parsing, so we'll
      // just copy URIFixup.
      let scheme = uriInfo.fixedURI.scheme + "://";
      url = scheme + url;
      trimmedLength = scheme.length;
    }

    // This RegExp is not a perfect match, and for specially crafted URLs it may
    // get the host wrong; for safety reasons we will later compare the found
    // host with the one that will actually be loaded.
    let matchedURL = url.match(
      /^(([a-z]+:\/\/)(?:[^\/#?]+@)?)(\S+?)(?::\d+)?\s*(?:[\/#?]|$)/
    );
    if (!matchedURL) {
      return null;
    }
    let [, preDomain, schemeWSlashes, domain] = matchedURL;

    // If the found host differs from the fixed URI one, we can't properly
    // highlight it. To stay on the safe side, we clobber user's input with
    // the fixed URI and apply highlight to that one instead.
    let replaceUrl = false;
    try {
      replaceUrl =
        Services.io.newURI("http://" + domain).displayHost !=
        uriInfo.fixedURI.displayHost;
    } catch (ex) {
      return null;
    }
    if (replaceUrl) {
      if (this.#inGetUrlMetaData) {
        // Protect from infinite recursion.
        return null;
      }
      try {
        this.#inGetUrlMetaData = true;
        this.#window.gBrowser.userTypedValue = null;
        this.#urlbarInput.setURI({ uri: uriInfo.fixedURI });
        return this.#getUrlMetaData();
      } finally {
        this.#inGetUrlMetaData = false;
      }
    }

    return (browserState.urlMetaData.data = {
      domain,
      origin: uriInfo.fixedURI.host,
      preDomain,
      schemeWSlashes,
      trimmedLength,
      url,
    });
  }

  #removeURLFormat() {
    if (!this.#formattingApplied) {
      return;
    }
    let controller = this.#urlbarInput.editor.selectionController;
    let strikeOut = controller.getSelection(controller.SELECTION_URLSTRIKEOUT);
    strikeOut.removeAllRanges();
    let selection = controller.getSelection(controller.SELECTION_URLSECONDARY);
    selection.removeAllRanges();
    this.#formatScheme(controller.SELECTION_URLSTRIKEOUT, true);
    this.#formatScheme(controller.SELECTION_URLSECONDARY, true);
    this.#inputField.style.setProperty("--urlbar-scheme-size", "0px");
  }

  /**
   * Whether formatting is enabled.
   *
   * @returns {boolean}
   */
  get formattingEnabled() {
    return lazy.UrlbarPrefs.get("formatting.enabled");
  }

  /**
   * Whether a striked out active mixed content protocol will show for the
   * currently loaded input field value.
   *
   * @param {string} val The value to evaluate. If it's not the currently
   *   loaded page, this will return false, as we cannot know if a page has
   *   active mixed content until it's loaded.
   * @returns {boolean}
   */
  willShowFormattedMixedContentProtocol(val) {
    return (
      this.formattingEnabled &&
      !lazy.UrlbarPrefs.get("security.insecure_connection_text.enabled") &&
      val.startsWith("https://") &&
      val == this.#urlbarInput.value &&
      this.#showingMixedContentLoadedPageUrl
    );
  }

  /**
   * This is used only as an optimization to avoid removing formatting in
   * the _remove* format methods when no formatting is actually applied.
   *
   * @type {boolean}
   */
  #formattingApplied = false;

  /**
   * An empty object, which is used as a lock to avoid updating old instances.
   *
   * @type {?object}
   */
  #updateInstance;

  /**
   * The previously selected result.
   *
   * @type {?UrlbarResult}
   */
  #selectedResult;

  /**
   * The timer handling the resize throttling.
   *
   * @type {?number}
   */
  #resizeThrottleTimeout;

  /**
   * An empty object, which is used to avoid updating old instances.
   *
   * @type {?object}
   */
  #resizeInstance;

  /**
   * Used to protect against re-entry in getUrlMetaData.
   *
   * @type {boolean}
   */
  #inGetUrlMetaData = false;

  /**
   * Whether the currently loaded page is in mixed content mode.
   *
   * @returns {boolean} whether the loaded page has active mixed content.
   */
  get #showingMixedContentLoadedPageUrl() {
    return (
      this.#urlbarInput.getAttribute("pageproxystate") == "valid" &&
      !!(
        this.#window.gBrowser.securityUI.state &
        Ci.nsIWebProgressListener.STATE_LOADED_MIXED_ACTIVE_CONTENT
      )
    );
  }

  /**
   * If the input value is a URL and the input is not focused, this
   * formatter method highlights the domain, and if mixed content is present,
   * it crosses out the https scheme.  It also ensures that the host is
   * visible (not scrolled out of sight).
   *
   * @returns {boolean}
   *   True if formatting was applied and false if not.
   */
  #formatURL() {
    let urlMetaData = this.#getUrlMetaData();
    if (!urlMetaData) {
      return false;
    }
    let state = this.#urlbarInput.getBrowserState(
      this.#window.gBrowser.selectedBrowser
    );
    if (state.searchTerms) {
      return false;
    }

    let { domain, origin, preDomain, schemeWSlashes, trimmedLength, url } =
      urlMetaData;

    // When RTL domains cause the address bar to overflow to the left, the
    // protocol may get hidden, if it was not trimmed. We then set the
    // `--urlbar-scheme-size` property to show the protocol in a floating box.
    // We don't show the floating protocol box if:
    //  - The insecure label is enabled, as it is a sufficient indicator.
    //  - The current page is mixed content but formatting is disabled, as it
    //    may be confusing for the user to see a non striked out protocol.
    //  - The protocol was trimmed.
    let isUnformattedMixedContent =
      this.#showingMixedContentLoadedPageUrl && !this.formattingEnabled;
    if (
      !lazy.UrlbarPrefs.get("security.insecure_connection_text.enabled") &&
      !isUnformattedMixedContent &&
      this.#urlbarInput.value.startsWith(schemeWSlashes)
    ) {
      this.#scheme.value = schemeWSlashes;
      this.#inputField.style.setProperty(
        "--urlbar-scheme-size",
        schemeWSlashes.length + "ch"
      );
    }

    this.#ensureFormattedHostVisible(urlMetaData);

    if (!this.formattingEnabled) {
      return false;
    }

    let editor = this.#urlbarInput.editor;
    let controller = editor.selectionController;

    this.#formatScheme(controller.SELECTION_URLSECONDARY);

    let textNode = editor.rootElement.firstChild;

    // Strike out the "https" part if mixed active content status should be
    // shown.
    if (this.willShowFormattedMixedContentProtocol(this.#urlbarInput.value)) {
      let range = this.#document.createRange();
      range.setStart(textNode, 0);
      range.setEnd(textNode, 5);
      let strikeOut = controller.getSelection(
        controller.SELECTION_URLSTRIKEOUT
      );
      strikeOut.addRange(range);
      this.#formatScheme(controller.SELECTION_URLSTRIKEOUT);
    }

    let baseDomain = domain;
    let subDomain = "";
    try {
      baseDomain = Services.eTLD.getBaseDomainFromHost(origin);
      if (!domain.endsWith(baseDomain)) {
        // getBaseDomainFromHost converts its resultant to ACE.
        let IDNService = Cc["@mozilla.org/network/idn-service;1"].getService(
          Ci.nsIIDNService
        );
        // XXX This should probably convert to display IDN instead.
        // https://bugzilla.mozilla.org/show_bug.cgi?id=1906048
        baseDomain = IDNService.convertACEtoUTF8(baseDomain);
      }
    } catch (e) {}
    if (baseDomain != domain) {
      subDomain = domain.slice(0, -baseDomain.length);
    }

    let selection = controller.getSelection(controller.SELECTION_URLSECONDARY);

    let rangeLength = preDomain.length + subDomain.length - trimmedLength;
    if (rangeLength) {
      let range = this.#document.createRange();
      range.setStart(textNode, 0);
      range.setEnd(textNode, rangeLength);
      selection.addRange(range);
    }

    let startRest = preDomain.length + domain.length - trimmedLength;
    if (startRest < url.length - trimmedLength) {
      let range = this.#document.createRange();
      range.setStart(textNode, startRest);
      range.setEnd(textNode, url.length - trimmedLength);
      selection.addRange(range);
    }

    return true;
  }

  #formatScheme(selectionType, clear) {
    let editor = this.#scheme.editor;
    let controller = editor.selectionController;
    let textNode = editor.rootElement.firstChild;
    let selection = controller.getSelection(selectionType);
    if (clear) {
      selection.removeAllRanges();
    } else {
      let r = this.#document.createRange();
      r.setStart(textNode, 0);
      r.setEnd(textNode, textNode.textContent.length);
      selection.addRange(r);
    }
  }

  #removeSearchAliasFormat() {
    if (!this.#formattingApplied) {
      return;
    }
    let selection = this.#urlbarInput.editor.selectionController.getSelection(
      Ci.nsISelectionController.SELECTION_FIND
    );
    selection.removeAllRanges();
  }

  /**
   * If the input value starts with an @engine search alias, this highlights it.
   *
   * @returns {boolean}
   *   True if formatting was applied and false if not.
   */
  #formatSearchAlias() {
    if (!this.formattingEnabled) {
      return false;
    }

    let editor = this.#urlbarInput.editor;
    let textNode = editor.rootElement.firstChild;
    let value = textNode.textContent;
    let trimmedValue = value.trim();

    if (
      !trimmedValue.startsWith("@") ||
      this.#urlbarInput.view.oneOffSearchButtons.selectedButton
    ) {
      return false;
    }

    let alias = this.#findEngineAliasOrRestrictKeyword();
    if (!alias) {
      return false;
    }

    // Make sure the current input starts with the alias because it can change
    // without the popup results changing.  Most notably that happens when the
    // user performs a search using an alias: The popup closes (preserving its
    // results), the search results page loads, and the input value is set to
    // the URL of the page.
    if (trimmedValue != alias && !trimmedValue.startsWith(alias + " ")) {
      return false;
    }

    let index = value.indexOf(alias);
    if (index < 0) {
      return false;
    }

    // We abuse the SELECTION_FIND selection type to do our highlighting.
    // It's the only type that works with Selection.setColors().
    let selection = editor.selectionController.getSelection(
      Ci.nsISelectionController.SELECTION_FIND
    );

    let range = this.#document.createRange();
    range.setStart(textNode, index);
    range.setEnd(textNode, index + alias.length);
    selection.addRange(range);

    let fg = "#2362d7";
    let bg = "#d2e6fd";

    // Selection.setColors() will swap the given foreground and background
    // colors if it detects that the contrast between the background
    // color and the frame color is too low.  Normally we don't want that
    // to happen; we want it to use our colors as given (even if setColors
    // thinks the contrast is too low).  But it's a nice feature for non-
    // default themes, where the contrast between our background color and
    // the input's frame color might actually be too low.  We can
    // (hackily) force setColors to use our colors as given by passing
    // them as the alternate colors.  Otherwise, allow setColors to swap
    // them, which we can do by passing "currentColor".  See
    // nsTextPaintStyle::GetHighlightColors for details.
    if (
      this.#document.documentElement.hasAttribute("lwtheme") ||
      this.#window.matchMedia("(prefers-contrast)").matches
    ) {
      // non-default theme(s)
      selection.setColors(fg, bg, "currentColor", "currentColor");
    } else {
      // default themes
      selection.setColors(fg, bg, fg, bg);
    }

    return true;
  }

  #findEngineAliasOrRestrictKeyword() {
    // To determine whether the input contains a valid alias, check if the
    // selected result is a search result with an alias. If there is no selected
    // result, we check the first result in the view, for cases when we do not
    // highlight token alias results. The selected result is null when the popup
    // is closed, but we want to continue highlighting the alias when the popup
    // is closed, and that's why we keep around the previously selected result
    // in #selectedResult.
    this.#selectedResult =
      this.#urlbarInput.view.selectedResult ||
      this.#urlbarInput.view.getResultAtIndex(0) ||
      this.#selectedResult;

    if (!this.#selectedResult) {
      return null;
    }

    let { type, payload } = this.#selectedResult;

    if (type === lazy.UrlbarUtils.RESULT_TYPE.SEARCH) {
      return payload.keyword || null;
    }

    if (type === lazy.UrlbarUtils.RESULT_TYPE.RESTRICT) {
      return payload.autofillKeyword || null;
    }

    return null;
  }

  /**
   * Passes DOM events to the _on_<event type> methods.
   *
   * @param {Event} event
   *   DOM event.
   */
  handleEvent(event) {
    let methodName = "_on_" + event.type;
    if (methodName in this) {
      this[methodName](event);
    } else {
      throw new Error("Unrecognized UrlbarValueFormatter event: " + event.type);
    }
  }

  _on_resize(event) {
    if (event.target != this.#window) {
      return;
    }
    // Make sure the host remains visible in the input field when the window is
    // resized.  We don't want to hurt resize performance though, so do this
    // only after resize events have stopped and a small timeout has elapsed.
    if (this.#resizeThrottleTimeout) {
      this.#window.clearTimeout(this.#resizeThrottleTimeout);
    }
    this.#resizeThrottleTimeout = this.#window.setTimeout(() => {
      this.#resizeThrottleTimeout = null;
      let instance = (this.#resizeInstance = {});
      this.#window.requestAnimationFrame(() => {
        if (instance == this.#resizeInstance) {
          this.#ensureFormattedHostVisible();
        }
      });
    }, 100);
  }
}
