/**
 * 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";
import { AppConstants } from "resource://gre/modules/AppConstants.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
);

ChromeUtils.defineESModuleGetters(lazy, {
  GeckoViewIdentityCredential:
    "resource://gre/modules/GeckoViewIdentityCredential.sys.mjs",
});
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.xhtml
      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();
    });

    const providerNames = identityManifests.map(
      providerManifest => providerManifest?.branding?.name
    );

    // 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);
    if (AppConstants.platform === "android") {
      const providers = [];
      for (const [providerIndex, provider] of identityProviders.entries()) {
        let providerURL = new URL(provider.configURL);
        let displayDomain = lazy.IDNService.convertToDisplayIDN(
          providerURL.host
        );

        let iconResult = iconResults[providerIndex];
        const data = {
          id: providerIndex,
          icon: iconResult.value,
          name: providerNames[providerIndex],
          domain: displayDomain,
        };
        providers.push(data);
      }

      return new Promise((resolve, reject) => {
        lazy.GeckoViewIdentityCredential.onShowProviderPrompt(
          browsingContext,
          providers,
          resolve,
          reject
        );
      });
    }

    // Localize all strings to be used
    // Bug 1797154 - Convert localization calls to use the async formatValues.
    let localization = new Localization(
      ["browser/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-primary"
      )[0].textContent = providerNames[providerIndex] || displayDomain;
      newItem.getElementsByClassName(
        "identity-credential-list-item-label-secondary"
      )[0].hidden = true;

      if (providerNames[providerIndex] && displayDomain) {
        newItem.getElementsByClassName(
          "identity-credential-list-item-label-secondary"
        )[0].hidden = false;
        newItem.getElementsByClassName(
          "identity-credential-list-item-label-secondary"
        )[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"
    );

    const providerName = identityManifest?.branding?.name;

    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;

      if (AppConstants.platform === "android") {
        lazy.GeckoViewIdentityCredential.onShowPolicyPrompt(
          browsingContext,
          identityCredentialMetadata.privacy_policy_url,
          identityCredentialMetadata.terms_of_service_url,
          providerDisplayDomain,
          currentBaseDomain,
          iconResult,
          resolve,
          reject
        );
      } else {
        // Localize the description
        // Bug 1797154 - Convert localization calls to use the async formatValues.
        let localization = new Localization(
          ["browser/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: providerName || 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(
      ["browser/identityCredentialNotification.ftl"],
      true
    );
    const providerName = providerManifest?.branding?.name;
    let providerURL = new URL(provider.configURL);
    let displayDomain = lazy.IDNService.convertToDisplayIDN(providerURL.host);

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

    if (AppConstants.platform === "android") {
      const accounts = [];

      for (const [accountIndex, account] of accountList.accounts.entries()) {
        var picture = "";
        let pictureResult = pictureResults[accountIndex];
        if (pictureResult.status == "fulfilled") {
          picture = pictureResult.value;
        }
        account.name;
        account.email;
        const data = {
          id: accountIndex,
          icon: picture,
          name: account.name,
          email: account.email,
        };
        accounts.push(data);
        console.log(data);
      }

      const provider = {
        name: providerName || displayDomain,
        domain: displayDomain,
        icon: headerIconResult,
      };

      const result = {
        provider,
        accounts,
      };

      return new Promise((resolve, reject) => {
        lazy.GeckoViewIdentityCredential.onShowAccountsPrompt(
          browsingContext,
          result,
          resolve,
          reject
        );
      });
    }

    let headerMessage = localization.formatValueSync(
      "identity-credential-header-accounts",
      {
        provider: providerName || 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-primary"
      )[0].textContent = account.name;
      newItem.getElementsByClassName(
        "identity-credential-list-item-label-secondary"
      )[0].textContent = account.email;

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

    // 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 || AppConstants.platform === "android") {
      return;
    }
    let notification = browser.ownerGlobal.PopupNotifications.getNotification(
      "identity-credential",
      browser
    );
    if (notification) {
      browser.ownerGlobal.PopupNotifications.remove(notification, true);
    }
  }
}
