/**
 * 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 = {};

ChromeUtils.defineESModuleGetters(lazy, {
  PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
});

ChromeUtils.defineLazyGetter(lazy, "localization", () => {
  return new Localization(["preview/credentialChooser.ftl"], true);
});

XPCOMUtils.defineLazyServiceGetter(
  lazy,
  "IDNService",
  "@mozilla.org/network/idn-service;1",
  "nsIIDNService"
);

XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "TESTING_MODE",
  "dom.security.credentialmanagement.chooser.testing.enabled",
  false
);

/**
 * Set an image element's src attribute to a data: url of the favicon for a
 * given origin, defaulting to the browser default favicon.
 *
 * @param {HTMLImageElement} icon The image Element that should have source be the icon result.
 * @param {string} origin The origin whose favicon should be used.
 */
async function setIconToFavicon(icon, origin) {
  try {
    let iconData = await lazy.PlacesUtils.promiseFaviconData(origin);
    icon.src = iconData.uri.spec;
  } catch {
    icon.src = "chrome://global/skin/icons/defaultFavicon.svg";
  }
}

/**
 * Class implementing the nsICredentialChooserService.
 *
 * This class shows UI to the user for the Credential Chooser for the
 * Credential Management API.
 *
 * @class CredentialChooserService
 */
export class CredentialChooserService {
  classID = Components.ID("{673ddc19-03e2-4b30-a868-06297e8fed89}");
  QueryInterface = ChromeUtils.generateQI(["nsICredentialChooserService"]);

  /**
   * @typedef {object} CredentialArgument
   * @property {string} id - The unique identifier for the credential.
   * @property {string} type - The type of the credential.
   * @property {string} [origin] - The origin associated to the credential.
   * @property {UIHints} [uiHints] - UI hints for the credential.
   */

  /**
   * @typedef {object} UIHints
   * @property {string} [name] - The display name for the credential.
   * @property {string} [iconURL] - The data URL of the icon for the credential.
   * @property {number} [expiresAfter] - The expiration time for the UI hint.
   */

  /**
   * This function displays the credential chooser UI, allowing the user to make an identity choice.
   * Once the user makes a choice from the credentials provided, or dismisses the prompt, we will
   * call the callback with that credential, or null in the case of a dismiss.
   *
   * We also support UI-less testing via choices provided by picking any credential with ID 'wpt-pick-me'
   * if the preference 'dom.security.credentialmanagement.chooser.testing.enabled' is true.
   *
   * @param {BrowsingContext} browsingContext The browsing context of the window calling the Credential Management API.
   * @param {Array<CredentialArgument>} credentials The credentials the user should choose from.
   * @param {nsICredentialChosenCallback} callback A callback to return the user's credential choice to.
   * @returns {nsresult}
   */
  async showCredentialChooser(browsingContext, credentials, callback) {
    if (!callback) {
      callback = { notify: () => {} };
    }
    if (!credentials.length) {
      return Cr.NS_ERROR_INVALID_ARG;
    }

    // If we are not an active BC, return no choice and bail out.
    if (!browsingContext?.currentWindowContext?.isActiveInTab) {
      callback.notify(null);
      return Cr.NS_OK;
    }

    if (lazy.TESTING_MODE) {
      let match = credentials.find(cred => cred.id == "wpt-pick-me");
      if (match) {
        if (browsingContext.currentWindowGlobal?.documentPrincipal) {
          Services.perms.addFromPrincipal(
            browsingContext.currentWindowGlobal.documentPrincipal,
            "credential-allow-silent-access^" + match.origin,
            Ci.nsIPermissionManager.ALLOW_ACTION,
            Ci.nsIPermissionManager.EXPIRE_SESSION
          );
          Services.perms.addFromPrincipal(
            browsingContext.currentWindowGlobal.documentPrincipal,
            "credential-allow-silent-access",
            Ci.nsIPermissionManager.ALLOW_ACTION,
            Ci.nsIPermissionManager.EXPIRE_SESSION
          );
        }
        callback.notify("wpt-pick-me");
      } else {
        callback.notify(null);
      }
      return Cr.NS_OK;
    }

    let browser = browsingContext.topFrameElement;
    if (browser?.tagName != "browser") {
      return Cr.NS_ERROR_INVALID_ARG;
    }

    let headerTextElement = browser.ownerDocument.getElementById(
      "credential-chooser-header-text"
    );
    let host = browser.ownerGlobal.gIdentityHandler.getHostForDisplay();
    browser.ownerDocument.l10n.setAttributes(
      headerTextElement,
      "credential-chooser-header",
      {
        host,
      }
    );

    let faviconPromises = [];

    let localizationPromise = lazy.localization.formatMessages([
      { id: "credential-chooser-sign-in-button" },
      { id: "credential-chooser-cancel-button" },
    ]);

    let listBox = browser.ownerDocument.getElementById(
      "credential-chooser-entry-selector-container"
    );
    while (listBox.firstChild) {
      listBox.removeChild(listBox.lastChild);
    }
    let itemTemplate = browser.ownerDocument.getElementById(
      "template-credential-entry-list-item"
    );
    for (let [index, credential] of credentials.entries()) {
      let newItem = itemTemplate.content.firstElementChild.cloneNode(true);
      // Add the new radio button, including pre-selection and the callback
      let [radio] = newItem.getElementsByClassName(
        "identity-credential-list-item-radio"
      );
      radio.value = index;
      if (index == 0) {
        radio.checked = true;
      }

      let providerURL = new URL(credential.origin);
      let displayDomain = lazy.IDNService.convertToDisplayIDN(
        providerURL.host,
        {}
      );

      let [primary] = newItem.getElementsByClassName(
        "identity-credential-list-item-label-primary"
      );
      let [secondary] = newItem.getElementsByClassName(
        "identity-credential-list-item-label-secondary"
      );
      let [icon] = newItem.getElementsByClassName(
        "identity-credential-list-item-icon"
      );

      if (
        credential.uiHints &&
        (credential.uiHints.expiresAfter == null ||
          credential.uiHints.expiresAfter > 0)
      ) {
        primary.textContent = credential.uiHints.name;
        browser.ownerDocument.l10n.setAttributes(
          secondary,
          "credential-chooser-host-descriptor",
          {
            provider: displayDomain,
          }
        );
        secondary.hidden = false;
        icon.src = credential.uiHints.iconURL;
      } else {
        let doneWithFavicon = setIconToFavicon(icon, credential.origin);
        faviconPromises.push(doneWithFavicon);
        browser.ownerDocument.l10n.setAttributes(
          primary,
          "credential-chooser-identity",
          {
            provider: displayDomain,
          }
        );
      }

      // Add the item to the DOM!
      listBox.append(newItem);
    }

    // wait for the labels to be localized before showing the panel
    let [accept, cancel] = await localizationPromise;
    let cancelLabel = cancel.attributes.find(x => x.name == "label").value;
    let cancelKey = cancel.attributes.find(x => x.name == "accesskey").value;
    let acceptLabel = accept.attributes.find(x => x.name == "label").value;
    let acceptKey = accept.attributes.find(x => x.name == "accesskey").value;

    // wait for icons to be set to prevent favicon jank
    await Promise.all(faviconPromises);

    // Construct the necessary arguments for notification behavior
    let options = {
      hideClose: true,
      removeOnDismissal: true,
      eventCallback: (topic, _nextRemovalReason, _isCancel) => {
        if (topic == "removed") {
          callback.notify(null);
        }
      },
    };
    let mainAction = {
      label: acceptLabel,
      accessKey: acceptKey,
      callback() {
        let result = listBox.querySelector(
          ".identity-credential-list-item-radio:checked"
        ).value;
        if (browsingContext.currentWindowGlobal?.documentPrincipal) {
          Services.perms.addFromPrincipal(
            browsingContext.currentWindowGlobal.documentPrincipal,
            "credential-allow-silent-access^" +
              credentials[parseInt(result)].origin,
            Ci.nsIPermissionManager.ALLOW_ACTION,
            Ci.nsIPermissionManager.EXPIRE_SESSION
          );
          Services.perms.addFromPrincipal(
            browsingContext.currentWindowGlobal.documentPrincipal,
            "credential-allow-silent-access",
            Ci.nsIPermissionManager.ALLOW_ACTION,
            Ci.nsIPermissionManager.EXPIRE_SESSION
          );
        }
        callback.notify(credentials[parseInt(result, 10)].id);
      },
    };
    let secondaryActions = [
      {
        label: cancelLabel,
        accessKey: cancelKey,
        callback() {
          callback.notify(null);
        },
      },
    ];
    browser.ownerGlobal.PopupNotifications.show(
      browser,
      "credential-chooser",
      "",
      "identity-credential-notification-icon",
      mainAction,
      secondaryActions,
      options
    );

    return Cr.NS_OK;
  }

  /**
   * Dismiss the credential chooser dialog for this browsing context's window.
   *
   * @param {BrowsingContext} browsingContext - The top browsing context of the window calling the Credential Management API
   */
  cancelCredentialChooser(browsingContext) {
    let browser = browsingContext.top.embedderElement;
    if (browser?.tagName != "browser") {
      return;
    }
    let notification = browser.ownerGlobal.PopupNotifications.getNotification(
      "credential-chooser",
      browser
    );
    if (notification) {
      browser.ownerGlobal.PopupNotifications.remove(notification, true);
    }
  }

  /**
   * A service function to help any UI. Fetches and serializes images to
   * data urls, which can be used in chrome UI.
   *
   * @param {Window} window - Window which should perform the fetch
   * @param {nsIURI} uri - Icon location to be fetched from
   * @returns {Promise<string, Error>} The data URI encoded as a string representing the icon that was loaded
   */
  async fetchImageToDataURI(window, uri) {
    if (uri.protocol === "data:") {
      return uri.href;
    }
    let request = new window.Request(uri.spec, { mode: "cors" });
    request.overrideContentPolicyType(Ci.nsIContentPolicy.TYPE_IMAGE);
    let blob;
    let response = await window.fetch(request);
    if (!response.ok) {
      return Promise.reject(new Error("HTTP failure on Fetch"));
    }
    blob = await response.blob();
    return new Promise((resolve, reject) => {
      let reader = new FileReader();
      reader.onloadend = () => resolve(reader.result);
      reader.onerror = reject;
      reader.readAsDataURL(blob);
    });
  }
}
