/*!
 * jwe/decrypt.js - Decrypt from a JWE
 *
 * Copyright (c) 2015 Cisco Systems, Inc. See LICENSE file.
 */
"use strict";

var base64url = require("../util/base64url"),
    AlgConfig = require("../util/algconfig"),
    JWK = require("../jwk"),
    merge = require("../util/merge"),
    pako = require("pako");

var DEFAULT_OPTIONS = {
  algorithms: "*"
};

/**
 * @class JWE.Decrypter
 * @classdesc Processor of encrypted data.
 *
 * @description
 * **NOTE:** This class cannot be instantiated directly. Instead
 * call {@link JWE.createDecrypt}.
 */
function JWEDecrypter(ks, globalOpts) {
  var assumedKey,
    keystore;

  if (JWK.isKey(ks)) {
    assumedKey = ks;
    keystore = assumedKey.keystore;
  } else if (JWK.isKeyStore(ks)) {
    keystore = ks;
  } else {
    throw new TypeError("Keystore must be provided");
  }

  globalOpts = merge({}, DEFAULT_OPTIONS, globalOpts);

  /**
   * Decrypts the given input.
   *
   * {opts}, if provided, is used to customize this specific decrypt operation.
   * This argument has the same semantics as {JWE.createDecrypt}, and takes
   * precedence over those options.
   *
   * The returned PRomise, when fulfilled, returns an object with the
   * following members:
   *
   * - `header` - The JOSE Header, combined from the relevant "header" and
   *            "protected" fields from the original JWE object.
   * - `protected` - An array containing the names of the protected fields
   * - `key` - The used to decrypt the content
   * - `payload` - The decrypted content (as a Buffer)
   * - `plaintext` - An alias for `payload`
   *
   * @param {Object|String} input The encrypted content
   * @param {Object} [opts] The options for this decryption operation.
   * @returns {Promise} A promise for the decyprted plaintext
   */
  Object.defineProperty(this, "decrypt", {
    value: function(input, opts) {
      opts = merge({}, globalOpts, opts || {});
      var extraHandlers = opts.handlers || {};
      var handlerKeys = Object.keys(extraHandlers);
      var algSpec = new AlgConfig(opts.algorithms);

      /* eslint camelcase: [0] */
      if (typeof input === "string") {
        input = input.split(".");
        input = {
          protected: input[0],
          recipients: [
            {
              encrypted_key: input[1]
            }
          ],
          iv: input[2],
          ciphertext: input[3],
          tag: input[4]
        };
      } else if (!input || typeof input !== "object") {
        throw new Error("invalid input");
      }
      if ("encrypted_key" in input) {
        input.recipients = [
          {
            encrypted_key: input.encrypted_key
          }
        ];
      }

      var promise;

      // ensure recipients exists
      var rcptList = input.recipients || [{}];
      promise = Promise.resolve(rcptList);

      //combine fields
      var fields,
          protect;
      promise = promise.then(function(rcptList) {
        if (input.protected) {
          protect = base64url.decode(input.protected).toString("utf8");
          protect = JSON.parse(protect);

          // verify "crit" field first
          var crit = protect.crit;
          if (crit) {
            if (!Array.isArray(crit)) {
              return Promise.reject(new Error("Invalid 'crit' header"));
            }
            for (var idx = 0; crit.length > idx; idx++) {
              if (-1 === handlerKeys.indexOf(crit[idx])) {
                return Promise.reject(new Error(
                    "Critical extension is not supported: " + crit[idx]
                ));
              }
            }
          }

          fields = protect;
          protect = Object.keys(protect);
        } else {
          fields = {};
          protect = [];
        }
        fields = merge(input.unprotected || {}, fields);

        rcptList = rcptList.map(function(r) {
          var promise = Promise.resolve();
          var header = r.header || {};
          header = merge(header, fields);
          r.header = header;
          r.protected = protect;

          // check on allowed algorithms
          if (!algSpec.match(header.alg)) {
            promise = promise.then(function() {
              return Promise.reject(new Error("Algorithm not allowed: " + header.alg));
            });
          }
          if (!algSpec.match(header.enc)) {
            promise = promise.then(function () {
              return Promise.reject(new Error("Algorithm not allowed: " + header.enc));
            });
          }

          if (header.epk) {
            promise = promise.then(function() {
              return JWK.asKey(header.epk);
            });
            promise = promise.then(function(epk) {
              header.epk = epk.toObject(false);
            });
          }
          return promise.then(function() {
            return r;
          });
        });

        return Promise.all(rcptList);
      });

      // decrypt with first key found
      var algKey,
        encKey,
        kdata;
      promise = promise.then(function(rcptList) {
        var jwe = {};
        return new Promise(function(resolve, reject) {
          var processKey = function() {
            var rcpt = rcptList.shift();
            if (!rcpt) {
              reject(new Error("no key found"));
              return;
            }

            var algPromise = Promise.resolve(rcpt);
            algPromise = algPromise.then(function(rcpt) {
              // try to unwrap encrypted key
              var prekey = kdata = rcpt.encrypted_key || "";
              prekey = base64url.decode(prekey);
              algKey = assumedKey || keystore.get({
                use: "enc",
                alg: rcpt.header.alg,
                kid: rcpt.header.kid
              });
              if (algKey) {
                return algKey.unwrap(rcpt.header.alg, prekey, rcpt.header);
              } else {
                return Promise.reject();
              }
            });
            algPromise = algPromise.then(function(key) {
              encKey = {
                "kty": "oct",
                "k": base64url.encode(key)
              };
              encKey = JWK.asKey(encKey);
              jwe.key = algKey;
              jwe.header = rcpt.header;
              jwe.protected = rcpt.protected;
              resolve(jwe);
            });
            algPromise.catch(processKey);
          };
          processKey();
        });
      });

      // assign decipher inputs
      promise = promise.then(function(jwe) {
        jwe.iv = input.iv;
        jwe.tag = input.tag;
        jwe.ciphertext = input.ciphertext;

        return jwe;
      });

      // process any prepare-decrypt handlers
      promise = promise.then(function(jwe) {
        var processing = [];
        handlerKeys.forEach(function(h) {
          h = extraHandlers[h];
          var p;
          if ("function" === typeof h) {
            p = h(jwe);
          } else if ("object" === typeof h && "function" === typeof h.prepare) {
            p = h.prepare(jwe);
          }
          if (p) {
            processing.push(Promise.resolve(p));
          }
        });
        return Promise.all(processing).then(function() {
          // don't actually care about individual handler results
          // assume {jwe} is updated
          return jwe;
        });
      });

      // prepare decrypt inputs
      promise = promise.then(function(jwe) {
        if (!Buffer.isBuffer(jwe.ciphertext)) {
          jwe.ciphertext = base64url.decode(jwe.ciphertext);
        }

        return jwe;
      });

      // decrypt it!
      promise = promise.then(function(jwe) {
        var adata = input.protected;
        if ("aad" in input && null != input.aad) {
          adata += "." + input.aad;
        }

        var params = {
          iv: jwe.iv,
          adata: adata,
          tag: jwe.tag,
          kdata: kdata,
          epu: jwe.epu,
          epv: jwe.epv
        };
        var cdata = jwe.ciphertext;

        delete jwe.iv;
        delete jwe.tag;
        delete jwe.ciphertext;

        return encKey.
          then(function(enkKey) {
            return enkKey.decrypt(jwe.header.enc, cdata, params).
              then(function(pdata) {
                jwe.payload = jwe.plaintext = pdata;
                return jwe;
              });
          });
      });

      // (OPTIONAL) decompress plaintext
      promise = promise.then(function(jwe) {
        if ("DEF" === jwe.header.zip) {
          return new Promise(function(resolve, reject) {
            try {
              var data = pako.inflateRaw(Buffer.from(jwe.plaintext))

              jwe.payload = jwe.plaintext = Buffer.from(data);
              resolve(jwe);
            } catch (err) {
              reject(err);
            }
          });
        }

        return jwe;
      });

      // process any post-decrypt handlers
      promise = promise.then(function(jwe) {
        var processing = [];
        handlerKeys.forEach(function(h) {
          h = extraHandlers[h];
          var p;
          if ("object" === typeof h && "function" === typeof h.complete) {
            p = h.complete(jwe);
          }
          if (p) {
            processing.push(Promise.resolve(p));
          }
        });
        return Promise.all(processing).then(function() {
          // don't actually care about individual handler results
          // assume {jwe} is updated
          return jwe;
        });
      });

      return promise;
    }
  });
}

/**
 * @description
 * Creates a new Decrypter for the given Key or KeyStore.
 *
 * {opts}, when provided, is used to customize decryption processes. The
 * following options are currently supported:
 *
 * - `handlers` - An object where each name is a JOSE header member name and
 *   the value can be a boolean, function, or an object.
 *
 * Handlers are intended to support 'crit' extensions. When a boolean value,
 * the member is expected to be processed once decryption is fully complete.
 * When a function, it is called just before the ciphertext is decrypted
 * (processed as if it were a `prepare` handler, as decribed below). When an
 * object, it can contain any of the following members:
 *
 * - `recipient` - A function called after a valid key is determined; it takes
 *   an object describing the recipient, and returns a Promise that is
 *   fulfilled once the handler's processing is complete.
 * - `prepare` - A function called just prior to decrypting the ciphertext;
 *   it takes an object describing the decryption result (but containing
 *   `ciphertext` and `tag' instead of `payload` and `plaintext`), and
 *   returns a Promise that is fulfilled once the handler's processing is
 *   complete.
 * - `complete` - A function called once decryption is complete, just prior
 *   to fulfilling the Promise returned by `decrypt()`; it takes the object
 *   that will be returned by `decrypt()`'s fulfilled Promise, and returns
 *   a Promise that is fulfilled once the handler's processing is complete.
 *
 * Note that normal processing of `decrypt()` does not continue until all
 * relevant handlers have completed. Any changes handlers make to the
 * provided objects affects `decrypt()`'s processing.
 *
 * @param {JWK.Key|JWK.KeyStore} ks The Key or KeyStore to use for decryption.
 * @param {Object} [opts] The options for this Decrypter.
 * @returns {JWE.Decrypter} The new Decrypter.
 */
function createDecrypt(ks, opts) {
  var dec = new JWEDecrypter(ks, opts);
  return dec;
}

module.exports = {
  decrypter: JWEDecrypter,
  createDecrypt: createDecrypt
};
