import {base64UrlEncodeFromBytes} from '#core/types/string/base64';
import {stringToBytes, utf8Encode} from '#core/types/string/bytes';

import {Services} from '#service';

import {dev, devAssert, user} from '#utils/log';

import {getService, registerServiceBuilder} from '../service-helpers';

/** @const {string} */
const TAG = 'Crypto';

/**
 * @typedef {function((string|Uint8Array))}
 */
let CryptoPolyfillDef;

export class Crypto {
  /**
   * Creates an instance of Crypto.
   * @param {!Window} win
   */
  constructor(win) {
    /** @private {!Window} */
    this.win_ = win;

    let subtle = null;
    let isLegacyWebkit = false;
    if (win.crypto) {
      if (win.crypto.subtle) {
        subtle = win.crypto.subtle;
      } else if (win.crypto.webkitSubtle) {
        subtle = win.crypto.webkitSubtle;
        isLegacyWebkit = true;
      }
    }

    /** @const {{name: string}} */
    this.pkcsAlgo = {
      name: 'RSASSA-PKCS1-v1_5',
      hash: {name: 'SHA-256'},
    };

    /** @const {?webCrypto.SubtleCrypto} */
    this.subtle = subtle;

    /** @private @const {boolean} */
    this.isLegacyWebkit_ = isLegacyWebkit;

    /** @private {?Promise<!CryptoPolyfillDef>} */
    this.polyfillPromise_ = null;
  }

  /**
   * Returns the SHA-384 hash of the input string in a number array.
   * Input string cannot contain chars out of range [0,255].
   * @param {string|!Uint8Array} input
   * @return {!Promise<!Uint8Array>}
   * @throws {!Error} when input string contains chars out of range [0,255]
   */
  sha384(input) {
    if (typeof input === 'string') {
      input = stringToBytes(input);
    }

    if (!this.subtle || this.polyfillPromise_) {
      // means native Crypto API is not available or failed before.
      return (this.polyfillPromise_ || this.loadPolyfill_()).then(
        (polyfillSha384) => polyfillSha384(input)
      );
    }

    try {
      return (
        this.subtle
          .digest({name: 'SHA-384'}, input)
          /** @param {?} buffer */
          .then(
            (buffer) => new Uint8Array(buffer),
            (e) => {
              // Chrome doesn't allow the usage of Crypto API under
              // non-secure origin: https://www.chromium.org/Home/chromium-security/prefer-secure-origins-for-powerful-new-features
              if (e.message && e.message.indexOf('secure origin') < 0) {
                // Log unexpected fallback.
                user().error(
                  TAG,
                  'SubtleCrypto failed, fallback to closure lib.',
                  e
                );
              }
              return this.loadPolyfill_().then(() => this.sha384(input));
            }
          )
      );
    } catch (e) {
      dev().error(TAG, 'SubtleCrypto failed, fallback to closure lib.', e);
      return this.loadPolyfill_().then(() => this.sha384(input));
    }
  }

  /**
   * Returns the SHA-384 hash of the input string in the format of web safe
   * base64 (using -_. instead of +/=).
   * Input string cannot contain chars out of range [0,255].
   * @param {string|!Uint8Array} input
   * @return {!Promise<string>}
   * @throws {!Error} when input string contains chars out of range [0,255]
   */
  sha384Base64(input) {
    return this.sha384(input).then((buffer) =>
      base64UrlEncodeFromBytes(buffer)
    );
  }

  /**
   * Returns a uniform hash of the input string as a float number in the range
   * of [0, 1).
   * Input string cannot contain chars out of range [0,255].
   * @param {string|!Uint8Array} input
   * @return {!Promise<number>}
   */
  uniform(input) {
    return this.sha384(input).then((buffer) => {
      // Consider the Uint8 array as a base256 fraction number,
      // then convert it to the decimal form.
      let result = 0;
      for (let i = 2; i >= 0; i--) {
        // 3 base256 digits give enough precision
        result = (result + buffer[i]) / 256;
      }
      return result;
    });
  }

  /**
   * Loads Crypto polyfill library.
   * @return {!Promise<!CryptoPolyfillDef>}
   * @private
   */
  loadPolyfill_() {
    if (this.polyfillPromise_) {
      return this.polyfillPromise_;
    }
    return (this.polyfillPromise_ = Services.extensionsFor(this.win_)
      .preloadExtension('amp-crypto-polyfill')
      .then(() => getService(this.win_, 'crypto-polyfill')));
  }

  /**
   * Checks whether Web Cryptography is available, which is required for PKCS 1
   * operations. SHA-384 operations do not need this because there's a polyfill.
   * This could be false if the browser does not support Web Cryptography, or if
   * the current browsing context is not secure (e.g., it's on an insecure HTTP
   * page, or an HTTPS iframe embedded in an insecure HTTP page).
   *
   * @return {boolean} whether Web Cryptography is available
   */
  isPkcsAvailable() {
    return Boolean(this.subtle) && this.win_['isSecureContext'] !== false;
  }

  /**
   * Converts an RSA JSON Web Key object to a browser-native cryptographic key.
   * As a precondition, `isPkcsAvailable()` must be `true`.
   *
   * @param {!Object} jwk a deserialized RSA JSON Web Key, as specified in
   *     Section 6.3 of RFC 7518
   * @return {!Promise<!webCrypto.CryptoKey>}
   * @throws {TypeError} if `jwk` is not an RSA JSON Web Key
   */
  importPkcsKey(jwk) {
    devAssert(this.isPkcsAvailable());
    // Safari 10 and earlier want this as an ArrayBufferView.
    const keyData = this.isLegacyWebkit_
      ? utf8Encode(JSON.stringify(/** @type {!JsonObject} */ (jwk)))
      : /** @type {!webCrypto.JsonWebKey} */ (jwk);
    return /** @type {!Promise<!webCrypto.CryptoKey>} */ (
      this.subtle.importKey('jwk', keyData, this.pkcsAlgo, true, ['verify'])
    );
  }

  /**
   * Verifies an RSASSA-PKCS1-v1_5 signature with a SHA-256 hash. As a
   * precondition, `isPkcsAvailable()` must be `true`.
   *
   * @param {!webCrypto.CryptoKey} key an RSA public key
   * @param {!Uint8Array} signature an RSASSA-PKCS1-v1_5 signature
   * @param {!BufferSource} data the data that was signed
   * @return {!Promise<boolean>} whether the signature is correct for the given
   *     data and public key
   */
  verifyPkcs(key, signature, data) {
    devAssert(this.isPkcsAvailable());
    return /** @type {!Promise<boolean>} */ (
      this.subtle.verify(this.pkcsAlgo, key, signature, data)
    );
  }
}

/**
 * @param {!Window} win
 * @return {*} TODO(#23582): Specify return type
 */
export function installCryptoService(win) {
  return registerServiceBuilder(win, 'crypto', Crypto);
}
