/**
 * 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.defineLazyServiceGetter(
  lazy,
  "IDNService",
  "@mozilla.org/network/idn-service;1",
  "nsIIDNService"
);

XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "SELECT_FIRST_IN_UI_LISTS",
  "dom.security.credentialmanagement.identity.select_first_in_ui_lists",
  false
);

const BEST_HEADER_ICON_SIZE = 16;
const BEST_ICON_SIZE = 32;

// Used in plain mochitests to enable automation
function fulfilledPromiseFromFirstListElement(list) {
  if (list.length) {
    return Promise.resolve(0);
  }
  return Promise.reject();
}

// Converts a "blob" to a data URL
function blobToDataUrl(blob) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.addEventListener("loadend", function () {
      if (reader.error) {
        reject(reader.error);
      }
      resolve(reader.result);
    });
    reader.readAsDataURL(blob);
  });
}

// Converts a URL into a data:// url, suitable for inclusion in Chrome UI
async function fetchToDataUrl(url) {
  let result = await fetch(url);
  if (!result.ok) {
    throw result.status;
  }
  let blob = await result.blob();
  let data = blobToDataUrl(blob);
  return data;
}

/**
 * Class implementing the nsIIdentityCredentialPromptService
 * */
export class IdentityCredentialPromptService {
  classID = Components.ID("{936007db-a957-4f1d-a23d-f7d9403223e6}");
  QueryInterface = ChromeUtils.generateQI([
    "nsIIdentityCredentialPromptService",
  ]);

  async loadIconFromManifest(
    providerManifest,
    bestIconSize = BEST_ICON_SIZE,
    defaultIcon = null
  ) {
    if (providerManifest?.branding?.icons?.length) {
      // Prefer a vector icon, then an exactly sized icon,
      // the the largest icon available.
      let iconsArray = providerManifest.branding.icons;
      let vectorIcon = iconsArray.find(icon => !icon.size);
      if (vectorIcon) {
        return fetchToDataUrl(vectorIcon.url);
      }
      let exactIcon = iconsArray.find(icon => icon.size == bestIconSize);
      if (exactIcon) {
        return fetchToDataUrl(exactIcon.url);
      }
      let biggestIcon = iconsArray.sort(
        (iconA, iconB) => iconB.size - iconA.size
      )[0];
      if (biggestIcon) {
        return fetchToDataUrl(biggestIcon.url);
      }
    }

    return defaultIcon;
  }

  /**
   * Ask the user, using a PopupNotification, to select an Identity Provider from a provided list.
   * @param {BrowsingContext} browsingContext - The BrowsingContext of the document requesting an identity credential via navigator.credentials.get()
   * @param {IdentityProviderConfig[]} identityProviders - The list of identity providers the user selects from
   * @param {IdentityProviderAPIConfig[]} identityManifests - The manifests corresponding 1-to-1 with identityProviders
   * @returns {Promise<number>} The user-selected identity provider
   */
  async showProviderPrompt(
    browsingContext,
    identityProviders,
    identityManifests
  ) {
    // For testing only.
    if (lazy.SELECT_FIRST_IN_UI_LISTS) {
      return fulfilledPromiseFromFirstListElement(identityProviders);
    }
    let browser = browsingContext.top.embedderElement;
    if (!browser) {
      throw new Error("Null browser provided");
    }

    if (identityProviders.length != identityManifests.length) {
      throw new Error("Mismatch argument array length");
    }

    // Map each identity manifest to a promise that would resolve to its icon
    let promises = identityManifests.map(async providerManifest => {
      // we don't need to set default icon because default icon is already set on popup-notifications.inc
      const iconResult = await this.loadIconFromManifest(providerManifest);
      // If we didn't have a manifest with an icon, push a rejection.
      // This will be replaced with the default icon.
      return iconResult ? iconResult : Promise.reject();
    });

    // Sanity check that we made one promise per IDP.
    if (promises.length != identityManifests.length) {
      throw new Error("Mismatch promise array length");
    }

    let iconResults = await Promise.allSettled(promises);

    // Localize all strings to be used
    // Bug 1797154 - Convert localization calls to use the async formatValues.
    let localization = new Localization(
      ["preview/identityCredentialNotification.ftl"],
      true
    );
    let headerMessage = localization.formatValueSync(
      "identity-credential-header-providers"
    );
    let [accept, cancel] = localization.formatMessagesSync([
      { id: "identity-credential-accept-button" },
      { id: "identity-credential-cancel-button" },
    ]);

    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;

    // Build the choices into the panel
    let listBox = browser.ownerDocument.getElementById(
      "identity-credential-provider-selector-container"
    );
    while (listBox.firstChild) {
      listBox.removeChild(listBox.lastChild);
    }
    let itemTemplate = browser.ownerDocument.getElementById(
      "template-credential-provider-list-item"
    );
    for (const [providerIndex, provider] of identityProviders.entries()) {
      let providerURL = new URL(provider.configURL);
      let displayDomain = lazy.IDNService.convertToDisplayIDN(
        providerURL.host,
        {}
      );
      let newItem = itemTemplate.content.firstElementChild.cloneNode(true);

      // Create the radio button,
      // including the check callback and the initial state
      let newRadio = newItem.getElementsByClassName(
        "identity-credential-list-item-radio"
      )[0];
      newRadio.value = providerIndex;
      newRadio.addEventListener("change", function (event) {
        for (let item of listBox.children) {
          item.classList.remove("checked");
        }
        if (event.target.checked) {
          event.target.parentElement.classList.add("checked");
        }
      });
      if (providerIndex == 0) {
        newRadio.checked = true;
        newItem.classList.add("checked");
      }

      // Set the icon to the data url if we have one
      let iconResult = iconResults[providerIndex];
      if (iconResult.status == "fulfilled") {
        let newIcon = newItem.getElementsByClassName(
          "identity-credential-list-item-icon"
        )[0];
        newIcon.setAttribute("src", iconResult.value);
      }

      // Set the words that the user sees in the selection
      newItem.getElementsByClassName(
        "identity-credential-list-item-label"
      )[0].textContent = displayDomain;

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

    // Create a new promise to wrap the callbacks of the popup buttons
    return new Promise((resolve, reject) => {
      // Construct the necessary arguments for notification behavior
      let options = {
        hideClose: true,
        eventCallback: (topic, nextRemovalReason, isCancel) => {
          if (topic == "removed" && isCancel) {
            reject();
          }
        },
      };
      let mainAction = {
        label: acceptLabel,
        accessKey: acceptKey,
        callback(event) {
          let result = listBox.querySelector(
            ".identity-credential-list-item-radio:checked"
          ).value;
          resolve(parseInt(result));
        },
      };
      let secondaryActions = [
        {
          label: cancelLabel,
          accessKey: cancelKey,
          callback(event) {
            reject();
          },
        },
      ];

      // Show the popup
      browser.ownerDocument.getElementById(
        "identity-credential-provider"
      ).hidden = false;
      browser.ownerDocument.getElementById(
        "identity-credential-policy"
      ).hidden = true;
      browser.ownerDocument.getElementById(
        "identity-credential-account"
      ).hidden = true;
      browser.ownerDocument.getElementById(
        "identity-credential-header"
      ).hidden = true;
      browser.ownerGlobal.PopupNotifications.show(
        browser,
        "identity-credential",
        headerMessage,
        "identity-credential-notification-icon",
        mainAction,
        secondaryActions,
        options
      );
    });
  }

  /**
   * Ask the user, using a PopupNotification, to approve or disapprove of the policies of the Identity Provider.
   * @param {BrowsingContext} browsingContext - The BrowsingContext of the document requesting an identity credential via navigator.credentials.get()
   * @param {IdentityProviderConfig} identityProvider - The Identity Provider that the user has selected to use
   * @param {IdentityProviderAPIConfig} identityManifest - The Identity Provider that the user has selected to use's manifest
   * @param {IdentityCredentialMetadata} identityCredentialMetadata - The metadata displayed to the user
   * @returns {Promise<bool>} A boolean representing the user's acceptance of the metadata.
   */
  async showPolicyPrompt(
    browsingContext,
    identityProvider,
    identityManifest,
    identityCredentialMetadata
  ) {
    // For testing only.
    if (lazy.SELECT_FIRST_IN_UI_LISTS) {
      return Promise.resolve(true);
    }
    if (
      !identityCredentialMetadata ||
      !identityCredentialMetadata.privacy_policy_url ||
      !identityCredentialMetadata.terms_of_service_url
    ) {
      return Promise.resolve(true);
    }

    let iconResult = await this.loadIconFromManifest(
      identityManifest,
      BEST_HEADER_ICON_SIZE,
      "chrome://global/skin/icons/defaultFavicon.svg"
    );

    return new Promise(function (resolve, reject) {
      let browser = browsingContext.top.embedderElement;
      if (!browser) {
        reject();
        return;
      }

      let providerURL = new URL(identityProvider.configURL);
      let providerDisplayDomain = lazy.IDNService.convertToDisplayIDN(
        providerURL.host,
        {}
      );
      let currentBaseDomain =
        browsingContext.currentWindowContext.documentPrincipal.baseDomain;

      // Localize the description
      // Bug 1797154 - Convert localization calls to use the async formatValues.
      let localization = new Localization(
        ["preview/identityCredentialNotification.ftl"],
        true
      );
      let [accept, cancel] = localization.formatMessagesSync([
        { id: "identity-credential-accept-button" },
        { id: "identity-credential-cancel-button" },
      ]);

      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;

      let title = localization.formatValueSync(
        "identity-credential-policy-title",
        {
          provider: providerDisplayDomain,
        }
      );

      if (iconResult) {
        let headerIcon = browser.ownerDocument.getElementsByClassName(
          "identity-credential-header-icon"
        )[0];
        headerIcon.setAttribute("src", iconResult);
      }

      const headerText = browser.ownerDocument.getElementById(
        "identity-credential-header-text"
      );
      headerText.textContent = title;

      let privacyPolicyAnchor = browser.ownerDocument.getElementById(
        "identity-credential-privacy-policy"
      );
      privacyPolicyAnchor.href = identityCredentialMetadata.privacy_policy_url;
      let termsOfServiceAnchor = browser.ownerDocument.getElementById(
        "identity-credential-terms-of-service"
      );
      termsOfServiceAnchor.href =
        identityCredentialMetadata.terms_of_service_url;

      // Populate the content of the policy panel
      let description = browser.ownerDocument.getElementById(
        "identity-credential-policy-explanation"
      );
      browser.ownerDocument.l10n.setAttributes(
        description,
        "identity-credential-policy-description",
        {
          host: currentBaseDomain,
          provider: providerDisplayDomain,
        }
      );

      // Construct the necessary arguments for notification behavior
      let options = {
        hideClose: true,
        eventCallback: (topic, nextRemovalReason, isCancel) => {
          if (topic == "removed" && isCancel) {
            reject();
          }
        },
      };
      let mainAction = {
        label: acceptLabel,
        accessKey: acceptKey,
        callback(event) {
          resolve(true);
        },
      };
      let secondaryActions = [
        {
          label: cancelLabel,
          accessKey: cancelKey,
          callback(event) {
            resolve(false);
          },
        },
      ];

      // Show the popup
      let ownerDocument = browser.ownerDocument;
      ownerDocument.getElementById(
        "identity-credential-provider"
      ).hidden = true;
      ownerDocument.getElementById("identity-credential-policy").hidden = false;
      ownerDocument.getElementById("identity-credential-account").hidden = true;
      ownerDocument.getElementById("identity-credential-header").hidden = false;
      browser.ownerGlobal.PopupNotifications.show(
        browser,
        "identity-credential",
        "",
        "identity-credential-notification-icon",
        mainAction,
        secondaryActions,
        options
      );
    });
  }

  /**
   * Ask the user, using a PopupNotification, to select an account from a provided list.
   * @param {BrowsingContext} browsingContext - The BrowsingContext of the document requesting an identity credential via navigator.credentials.get()
   * @param {IdentityProviderAccountList} accountList - The list of accounts the user selects from
   * @param {IdentityProviderConfig} provider - The selected identity provider
   * @param {IdentityProviderAPIConfig} providerManifest - The manifest of the selected identity provider
   * @returns {Promise<IdentityProviderAccount>} The user-selected account
   */
  async showAccountListPrompt(
    browsingContext,
    accountList,
    provider,
    providerManifest
  ) {
    // For testing only.
    if (lazy.SELECT_FIRST_IN_UI_LISTS) {
      return fulfilledPromiseFromFirstListElement(accountList.accounts);
    }

    let browser = browsingContext.top.embedderElement;
    if (!browser) {
      throw new Error("Null browser provided");
    }

    // Map to an array of promises that resolve to a data URL,
    // encoding the corresponding account's picture
    let promises = accountList.accounts.map(async account => {
      if (!account?.picture) {
        throw new Error("Missing picture");
      }
      return fetchToDataUrl(account.picture);
    });

    // Sanity check that we made one promise per account.
    if (promises.length != accountList.accounts.length) {
      throw new Error("Incorrect number of promises obtained");
    }

    let pictureResults = await Promise.allSettled(promises);

    // Localize all strings to be used
    // Bug 1797154 - Convert localization calls to use the async formatValues.
    let localization = new Localization(
      ["preview/identityCredentialNotification.ftl"],
      true
    );
    let providerURL = new URL(provider.configURL);
    let displayDomain = lazy.IDNService.convertToDisplayIDN(
      providerURL.host,
      {}
    );
    let headerMessage = localization.formatValueSync(
      "identity-credential-header-accounts",
      {
        provider: displayDomain,
      }
    );
    let [accept, cancel] = localization.formatMessagesSync([
      { id: "identity-credential-sign-in-button" },
      { id: "identity-credential-cancel-button" },
    ]);

    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;

    // Build the choices into the panel
    let listBox = browser.ownerDocument.getElementById(
      "identity-credential-account-selector-container"
    );
    while (listBox.firstChild) {
      listBox.removeChild(listBox.lastChild);
    }
    let itemTemplate = browser.ownerDocument.getElementById(
      "template-credential-account-list-item"
    );
    for (const [accountIndex, account] of accountList.accounts.entries()) {
      let newItem = itemTemplate.content.firstElementChild.cloneNode(true);

      // Add the new radio button, including pre-selection and the callback
      let newRadio = newItem.getElementsByClassName(
        "identity-credential-list-item-radio"
      )[0];
      newRadio.value = accountIndex;
      newRadio.addEventListener("change", function (event) {
        for (let item of listBox.children) {
          item.classList.remove("checked");
        }
        if (event.target.checked) {
          event.target.parentElement.classList.add("checked");
        }
      });
      if (accountIndex == 0) {
        newRadio.checked = true;
        newItem.classList.add("checked");
      }

      // Change the default picture if one exists
      let pictureResult = pictureResults[accountIndex];
      if (pictureResult.status == "fulfilled") {
        let newPicture = newItem.getElementsByClassName(
          "identity-credential-list-item-icon"
        )[0];
        newPicture.setAttribute("src", pictureResult.value);
      }

      // Add information to the label
      newItem.getElementsByClassName(
        "identity-credential-list-item-label-name"
      )[0].textContent = account.name;
      newItem.getElementsByClassName(
        "identity-credential-list-item-label-email"
      )[0].textContent = account.email;

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

    let headerIconResult = await this.loadIconFromManifest(
      providerManifest,
      BEST_HEADER_ICON_SIZE,
      "chrome://global/skin/icons/defaultFavicon.svg"
    );

    // Create a new promise to wrap the callbacks of the popup buttons
    return new Promise(function (resolve, reject) {
      // Construct the necessary arguments for notification behavior
      let options = {
        hideClose: true,
        eventCallback: (topic, nextRemovalReason, isCancel) => {
          if (topic == "removed" && isCancel) {
            reject();
          }
        },
      };
      let mainAction = {
        label: acceptLabel,
        accessKey: acceptKey,
        callback(event) {
          let result = listBox.querySelector(
            ".identity-credential-list-item-radio:checked"
          ).value;
          resolve(parseInt(result));
        },
      };
      let secondaryActions = [
        {
          label: cancelLabel,
          accessKey: cancelKey,
          callback(event) {
            reject();
          },
        },
      ];

      if (headerIconResult) {
        let headerIcon = browser.ownerDocument.getElementsByClassName(
          "identity-credential-header-icon"
        )[0];
        headerIcon.setAttribute("src", headerIconResult);
      }

      const headerText = browser.ownerDocument.getElementById(
        "identity-credential-header-text"
      );
      headerText.textContent = headerMessage;

      // Show the popup
      browser.ownerDocument.getElementById(
        "identity-credential-provider"
      ).hidden = true;
      browser.ownerDocument.getElementById(
        "identity-credential-policy"
      ).hidden = true;
      browser.ownerDocument.getElementById(
        "identity-credential-account"
      ).hidden = false;
      browser.ownerDocument.getElementById(
        "identity-credential-header"
      ).hidden = false;
      browser.ownerGlobal.PopupNotifications.show(
        browser,
        "identity-credential",
        "",
        "identity-credential-notification-icon",
        mainAction,
        secondaryActions,
        options
      );
    });
  }

  /**
   * Close all UI from the other methods of this module for the provided window.
   * @param {BrowsingContext} browsingContext - The BrowsingContext of the document requesting an identity credential via navigator.credentials.get()
   * @returns
   */
  close(browsingContext) {
    let browser = browsingContext.top.embedderElement;
    if (!browser) {
      return;
    }
    let notification = browser.ownerGlobal.PopupNotifications.getNotification(
      "identity-credential",
      browser
    );
    if (notification) {
      browser.ownerGlobal.PopupNotifications.remove(notification, true);
    }
  }
}