/*!
 * jwk/basekey.js - JWK Key Base Class Implementation
 *
 * Copyright (c) 2015 Cisco Systems, Inc. See LICENSE file.
 */
"use strict";

var merge = require("../util/merge");
const { v4: uuidv4 } = require("uuid");

var assign = require("lodash/assign");
var clone = require("lodash/clone");
var flatten = require("lodash/flatten");
var intersection = require("lodash/intersection");
var omit = require("lodash/omit");
var pick = require("lodash/pick");
var uniq = require("lodash/uniq");

var ALGORITHMS = require("../algorithms"),
    CONSTANTS = require("./constants.js"),
    HELPERS = require("./helpers.js"),
    UTIL = require("../util");

/**
 * @class JWK.Key
 * @classdesc
 * Represents a JSON Web Key instance.
 *
 * @description
 * **NOTE:** This class cannot be instantiated directly. Instead call
 * {@link JWK.asKey}, {@link JWK.KeyStore#add}, or
 * {@link JWK.KeyStore#generate}.
 */
var JWKBaseKeyObject = function(kty, ks, props, cfg) {
  // ### validate/coerce arguments ###
  if (!kty) {
    throw new Error("kty cannot be null");
  }

  if (!ks) {
    throw new Error("keystore cannot be null");
  }

  if (!props) {
    throw new Error("props cannot be null");
  } else if ("string" === typeof props) {
    props = JSON.parse(props);
  }

  if (!cfg) {
    throw new Error("cfg cannot be null");
  }

  var excluded = [];
  var keys = {},
      json = {},
      prints,
      kid;

  props = clone(props);
  // strip thumbprints if present
  prints = assign({}, props[HELPERS.INTERNALS.THUMBPRINT_KEY] || {});
  delete props[HELPERS.INTERNALS.THUMBPRINT_KEY];
  Object.keys(prints).forEach(function(a) {
    var h = prints[a];
    if (!kid) {
      kid = h;
      if (Buffer.isBuffer(kid)) {
        kid = UTIL.base64url.encode(kid);
      }
    }
    if (!Buffer.isBuffer(h)) {
      h = UTIL.base64url.decode(h);
      prints[a] = h;
    }
  });

  // force certain values
  props.kty = kty;
  props.kid = props.kid || kid || uuidv4();

  // setup base info
  var included = Object.keys(HELPERS.COMMON_PROPS).map(function(p) {
    return HELPERS.COMMON_PROPS[p].name;
  });
  json.base = pick(props, included);
  excluded = excluded.concat(Object.keys(json.base));

  // setup public information
  json.public = clone(props);
  keys.public = cfg.publicKey(json.public);
  if (keys.public) {
    // exclude public values from extra
    excluded = excluded.concat(Object.keys(json.public));
  }

  // setup private information
  json.private = clone(props);
  keys.private = cfg.privateKey(json.private);
  if (keys.private) {
    // exclude private values from extra
    excluded = excluded.concat(Object.keys(json.private));
  }

  // setup extra information
  json.extra = omit(props, excluded);

  // TODO: validate 'alg' against supported algorithms

  // setup calculated values
  var keyLen;
  if (keys.public && ("length" in keys.public)) {
    keyLen = keys.public.length;
  } else if (keys.private && ("length" in keys.private)) {
    keyLen = keys.private.length;
  } else {
    keyLen = NaN;
  }

  // ### Public Properties ###
  /**
   * @member {JWK.KeyStore} JWK.Key#keystore
   * @description
   * The owning keystore.
   */
  Object.defineProperty(this, "keystore", {
    value: ks,
    enumerable: true
  });
  /**
   * @member {Number} JWK.Key#length
   * @description
   * The size of this Key, in bits.
   */
  Object.defineProperty(this, "length", {
    value: keyLen,
    enumerable: true
  });
  /**
   * @member {String} JWK.Key#kty
   * @description
   * The type of Key.
   */
  Object.defineProperty(this, "kty", {
    value: kty,
    enumerable: true
  });

  /**
   * @member {String} JWK.Key#kid
   * @description
   * The identifier for this Key.
   */
  Object.defineProperty(this, "kid", {
    value: json.base.kid,
    enumerable: true
  });
  /**
   * @member {String} JWK.Key#use
   * @description
   * The usage for this Key.
   */
  Object.defineProperty(this, "use", {
    value: json.base.use || "",
    enumerable: true
  });
  /**
   * @member {String} JWK.Key#alg
   * @description
   * The sole algorithm this key can be used for.
   */
  Object.defineProperty(this, "alg", {
    value: json.base.alg || "",
    enumerable: true
  });

  // ### Public Methods ###
  /**
   * Generates the thumbprint of this Key.
   *
   * @param {String} [] The hash algorithm to use
   * @returns {Promise} The promise for the thumbprint generation.
   */
  Object.defineProperty(this, "thumbprint", {
    value: function(hash) {
      hash = (hash || HELPERS.INTERNALS.THUMBPRINT_HASH).toUpperCase();
      if (prints[hash]) {
        // return cached value
        return Promise.resolve(prints[hash]);
      }
      var p = HELPERS.thumbprint(cfg, json, hash);
      p = p.then(function(result) {
        if (result) {
          prints[hash] = result;
        }
        return result;
      });
      return p;
    }
  });
  /**
   * @method JWK.Key#algorithms
   * @description
   * The possible algorithms this Key can be used for. The returned
   * list is not any particular order, but is filtered based on the
   * Key's intended usage.
   *
   * @param {String} mode The operation mode
   * @returns {String[]} The list of supported algorithms
   * @see JWK.Key#supports
   */
  Object.defineProperty(this, "algorithms", {
    value: function(mode) {
      var modes = [];
      if (!this.use || this.use === "sig") {
        if (!mode || CONSTANTS.MODE_SIGN === mode) {
          modes.push(CONSTANTS.MODE_SIGN);
        }
        if (!mode || CONSTANTS.MODE_VERIFY === mode) {
          modes.push(CONSTANTS.MODE_VERIFY);
        }
      }
      if (!this.use || this.use === "enc") {
        if (!mode || CONSTANTS.MODE_ENCRYPT === mode) {
          modes.push(CONSTANTS.MODE_ENCRYPT);
        }
        if (!mode || CONSTANTS.MODE_DECRYPT === mode) {
          modes.push(CONSTANTS.MODE_DECRYPT);
        }
        if (!mode || CONSTANTS.MODE_WRAP === mode) {
          modes.push(CONSTANTS.MODE_WRAP);
        }
        if (!mode || CONSTANTS.MODE_UNWRAP === mode) {
          modes.push(CONSTANTS.MODE_UNWRAP);
        }
      }

      var self = this;
      var algs = modes.map(function(m) {
        return cfg.algorithms.call(self, keys, m);
      });
      algs = flatten(algs);
      algs = uniq(algs);
      if (this.alg) {
        // TODO: fix this correctly
        var valid;
        if ("oct" === kty) {
          valid = [this.alg, "dir"];
        } else {
          valid = [this.alg];
        }
        algs = intersection(algs, valid);
      }

      return algs;
    }
  });
  /**
   * @method JWK.Key#supports
   * @description
   * Determines if the given algorithm is supported.
   *
   * @param {String} alg The algorithm in question
   * @param {String} [mode] The operation mode
   * @returns {Boolean} `true` if {alg} is supported, and `false` otherwise.
   * @see JWK.Key#algorithms
   */
  Object.defineProperty(this, "supports", {
    value: function(alg, mode) {
      return (this.algorithms(mode).indexOf(alg) !== -1);
    }
  });
  /**
   * @method JWK.Key#has
   * @description
   * Determines if this Key contains the given parameter.
   *
   * @param {String} name The name of the parameter
   * @param {Boolean} [isPrivate=false] `true` if private parameters should be
   *        checked.
   * @returns {Boolean} `true` if the given parameter is present; `false`
   *          otherwise.
   */
  Object.defineProperty(this, "has", {
    value: function(name, isPrivate) {
      var contains = false;
      contains = contains || !!(json.base &&
                                (name in json.base));
      contains = contains || !!(keys.public &&
                                (name in keys.public));
      contains = contains || !!(json.extra &&
                                (name in json.extra));
      contains = contains || !!(isPrivate &&
                                keys.private &&
                                (name in keys.private));
      // TODO: check for export restrictions

      return contains;
    }
  });
  /**
   * @method JWK.Key#get
   * @description
   * Retrieves the value of the given parameter. The value returned by this
   * method is in its natural format, which might not exactly match its
   * JSON encoding (e.g., a binary string rather than a base64url-encoded
   * string).
   *
   * **NOTE:** This method can return `false`. Call
   * {@link JWK.Key#has} to determine if the parameter is present.
   *
   * @param {String} name The name of the parameter
   * @param {Boolean} [isPrivate=false] `true` if private parameters should
   *        be checked.
   * @returns {any} The value of the named parameter, or undefined if
   *          it is not present.
   */
  Object.defineProperty(this, "get", {
    value: function(name, isPrivate) {
      var src;
      if (json.base && (name in json.base)) {
        src = json.base;
      } else if (keys.public && (name in keys.public)) {
        src = keys.public;
      } else if (json.extra && (name in json.extra)) {
        src = json.extra;
      } else if (isPrivate && keys.private && (name in keys.private)) {
        // TODO: check for export restrictions
        src = keys.private;
      }

      return src && src[name] || null;
    }
  });
  /**
   * @method JWK.Key#toJSON
   * @description
   * Returns the JSON representation of this Key.  All properties of the
   * returned JSON object are properly encoded (e.g., base64url encoding for
   * any binary strings).
   *
   * @param {Boolean} [isPrivate=false] `true` if private parameters should be
   *        included.
   * @param {String[]} [excluded] The list of parameters to exclude from
   *        the returned JSON.
   * @returns {Object} The plain JSON object
   */
  Object.defineProperty(this, "toJSON", {
    value: function(isPrivate, excluded) {
      // coerce arguments
      if (Array.isArray(isPrivate)) {
        excluded = isPrivate;
        isPrivate = false;
      }
      var result = {};

      // TODO: check for export restrictions
      result = merge(result,
                       json.base,
                       json.public,
                       ("boolean" === typeof isPrivate && isPrivate) ? json.private : {},
                       json.extra);
      result = omit(result, excluded || []);

      return result;
    }
  });

  /**
   * @method JWK.Key#toPEM
   * @description
   * Returns the PEM representation of this Key as a string.
   *
   * @param {Boolean} [isPrivate=false] `true` if private parameters should be
   *        included.
   * @returns {string} The PEM-encoded string
   */
  Object.defineProperty(this, "toPEM", {
    value: function(isPrivate) {
      if (isPrivate === null) {
        isPrivate = false;
      }

      if (!cfg.convertToPEM) {
        throw new Error("Unsupported key type for PEM encoding");
      }
      var k = (isPrivate) ? keys.private : keys.public;
      if (!k) {
        throw new Error("Invalid key");
      }
      return cfg.convertToPEM.call(this, k, isPrivate);
    }
  });

  /**
   * @method JWK.Key#toObject
   * @description
   * Returns the plain object representing this Key.  All properties of the
   * returned object are in their natural encoding (e.g., binary strings
   * instead of base64url encoded).
   *
   * @param {Boolean} [isPrivate=false] `true` if private parameters should be
   *        included.
   * @param {String[]} [excluded] The list of parameters to exclude from
   *        the returned object.
   * @returns {Object} The plain Object.
   */
  Object.defineProperty(this, "toObject", {
    value: function(isPrivate, excluded) {
      // coerce arguments
      if (Array.isArray(isPrivate)) {
        excluded = isPrivate;
        isPrivate = false;
      }
      var result = {};

      // TODO: check for export restrictions
      result = merge(result,
                       json.base,
                       keys.public,
                       ("boolean" === typeof isPrivate && isPrivate) ? keys.private : {},
                       json.extra);
      result = omit(result, (excluded || []).concat("length"));

      return result;
    }
  });

  /**
   * @method JWK.Key#sign
   * @description
   * Sign the given data using the specified algorithm.
   *
   * **NOTE:** This is the primitive signing operation; the output is
   * _**NOT**_ a JSON Web Signature (JWS) object.
   *
   * The Promise, when fulfilled, returns an Object with the following
   * properties:
   *
   * + **data**: The data that was signed (and should be equal to {data}).
   * + **mac**: The signature or message authentication code (MAC).
   *
   * @param {String} alg The signing algorithm
   * @param {String|Buffer} data The data to sign
   * @param {Object} [props] Additional properties for the signing
   *        algorithm.
   * @returns {Promise} The promise for the signing operation.
   * @throws {Error} If {alg} is not appropriate for this Key; or if
   *         this Key does not contain the appropriate parameters.
   */
  Object.defineProperty(this, "sign", {
    value: function(alg, data, props) {
      // validate appropriateness
      if (this.algorithms("sign").indexOf(alg) === -1) {
        return Promise.reject(new Error("unsupported algorithm"));
      }
      var k = cfg.signKey.call(this, alg, keys);
      if (!k) {
        return Promise.reject(new Error("improper key"));
      }

      // prepare properties (if any)
      props = (props) ?
              clone(props) :
              {};
      if (cfg.signProps) {
        props = merge(props, cfg.signProps.call(this, alg, props));
      }
      return ALGORITHMS.sign(alg, k, data, props);
    }
  });
  /**
   * @method JWK.Key#verify
   * @description
   * Verify the given data and signature using the specified algorithm.
   *
   * **NOTE:** This is the primitive verification operation; the input is
   * _**NOT**_ a JSON Web Signature.</p>
   *
   * The Promise, when fulfilled, returns an Object with the following
   * properties:
   *
   * + **data**: The data that was verified (and should be equal to
   *   {data}).
   * + **mac**: The signature or MAC that was verified (and should be equal
   *   to {mac}).
   * + **valid**: `true` if {mac} is valid for {data}.
   *
   * @param {String} alg The verification algorithm
   * @param {String|Buffer} data The data to verify
   * @param {String|Buffer} mac The signature or MAC to verify
   * @param {Object} [props] Additional properties for the verification
   *        algorithm.
   * @returns {Promise} The promise for the verification operation.
   * @throws {Error} If {alg} is not appropriate for this Key; or if
   *         the Key does not contain the appropriate properties.
   */
  Object.defineProperty(this, "verify", {
    value: function(alg, data, mac, props) {
      // validate appropriateness
      if (this.algorithms("verify").indexOf(alg) === -1) {
        return Promise.reject(new Error("unsupported algorithm"));
      }
      var k = cfg.verifyKey.call(this, alg, keys);
      if (!k) {
        return Promise.reject(new Error("improper key"));
      }

      // prepare properties (if any)
      props = (props) ?
              clone(props) :
              {};
      if (cfg.verifyProps) {
        props = merge(props, cfg.verifyProps.call(this, alg, props));
      }
      return ALGORITHMS.verify(alg, k, data, mac, props);
    }
  });

  /**
   * @method JWK.Key#encrypt
   * @description
   * Encrypts the given data using the specified algorithm.
   *
   * **NOTE:** This is the primitive encryption operation; the output is
   * _**NOT**_ a JSON Web Encryption (JWE) object.
   *
   * **NOTE:** This operation is treated as distinct from {@link
   * JWK.Key#wrap}, as different algorithms and properties are often
   * used for wrapping a key versues encrypting arbitrary data.
   *
   * The Promise, when fulfilled, returns an object with the following
   * properties:
   *
   * + **data**: The ciphertext data
   * + **mac**: The associated message authentication code (MAC).
   *
   * @param {String} alg The encryption algorithm
   * @param {Buffer|String} data The data to encrypt
   * @param {Object} [props] Additional properties for the encryption
   *        algorithm.
   * @returns {Promise} The promise for the encryption operation.
   * @throws {Error} If {alg} is not appropriate for this Key; or if
   *         this Key does not contain the appropriate parameters.
   */
  Object.defineProperty(this, "encrypt", {
    value: function(alg, data, props) {
      // validate appropriateness
      if (this.algorithms("encrypt").indexOf(alg) === -1) {
        return Promise.reject(new Error("unsupported algorithm"));
      }
      var k = cfg.encryptKey.call(this, alg, keys);
      if (!k) {
        return Promise.reject(new Error("improper key"));
      }

      // prepare properties (if any)
      props = (props) ?
              clone(props) :
              {};
      if (cfg.encryptProps) {
        props = merge(props, cfg.encryptProps.call(this, alg, props));
      }
      return ALGORITHMS.encrypt(alg, k, data, props);
    }
  });
  /**
   * @method JWK.Key#decrypt
   * @description
   * Decrypts the given data using the specified algorithm.
   *
   * **NOTE:** This is the primitive decryption operation; the input is
   * _**NOT**_ a JSON Web Encryption (JWE) object.
   *
   * **NOTE:** This operation is treated as distinct from {@link
   * JWK.Key#unwrap}, as different algorithms and properties are often used
   * for unwrapping a key versues decrypting arbitrary data.
   *
   * The Promise, when fulfilled, returns the plaintext data.
   *
   * @param {String} alg The decryption algorithm.
   * @param {Buffer|String} data The data to decypt.
   * @param {Object} [props] Additional data for the decryption operation.
   * @returns {Promise} The promise for the decryption operation.
   * @throws {Error} If {alg} is not appropriate for this Key; or if
   *         the Key does not contain the appropriate properties.
   */
  Object.defineProperty(this, "decrypt", {
    value: function(alg, data, props) {
      // validate appropriateness
      if (this.algorithms("decrypt").indexOf(alg) === -1) {
        return Promise.reject(new Error("unsupported algorithm"));
      }
      var k = cfg.decryptKey.call(this, alg, keys);
      if (!k) {
        return Promise.reject(new Error("improper key"));
      }

      // prepare properties (if any)
      props = (props) ?
              clone(props) :
              {};
      if (cfg.decryptProps) {
        props = merge(props, cfg.decryptProps.call(this, alg, props));
      }
      return ALGORITHMS.decrypt(alg, k, data, props);
    }
  });

  /**
   * @method JWK.Key#wrap
   * @description
   * Wraps the given key using the specified algorithm.
   *
   * **NOTE:** This is the primitive encryption operation; the output is
   * _**NOT**_ a JSON Web Encryption (JWE) object.
   *
   * **NOTE:** This operation is treated as distinct from {@link
   * JWK.Key#encrypt}, as different algorithms and properties are
   * often used for wrapping a key versues encrypting arbitrary data.
   *
   * The Promise, when fulfilled, returns an object with the following
   * properties:
   *
   * + **data**: The ciphertext data
   * + **headers**: The additional header parameters to apply to a JWE.
   *
   * @param {String} alg The encryption algorithm
   * @param {Buffer|String} data The data to encrypt
   * @param {Object} [props] Additional properties for the encryption
   *        algorithm.
   * @returns {Promise} The promise for the encryption operation.
   * @throws {Error} If {alg} is not appropriate for this Key; or if
   *         this Key does not contain the appropriate parameters.
   */
  Object.defineProperty(this, "wrap", {
    value: function(alg, data, props) {
      // validate appropriateness
      if (this.algorithms("wrap").indexOf(alg) === -1) {
        return Promise.reject(new Error("unsupported algorithm"));
      }
      var k = cfg.wrapKey.call(this, alg, keys);
      if (!k) {
        return Promise.reject(new Error("improper key"));
      }

      // prepare properties (if any)
      props = (props) ?
              clone(props) :
              {};
      if (cfg.wrapProps) {
        props = merge(props, cfg.wrapProps.call(this, alg, props));
      }
      return ALGORITHMS.encrypt(alg, k, data, props);
    }
  });
  /**
   * @method JWK.Key#unwrap
   * @description
   * Unwraps the given key using the specified algorithm.
   *
   * **NOTE:** This is the primitive unwrap operation; the input is
   * _**NOT**_ a JSON Web Encryption (JWE) object.
   *
   * **NOTE:** This operation is treated as distinct from {@link
   * JWK.Key#decrypt}, as different algorithms and properties are often used
   * for unwrapping a key versues decrypting arbitrary data.
   *
   * The Promise, when fulfilled, returns the unwrapped key.
   *
   * @param {String} alg The unwrap algorithm.
   * @param {Buffer|String} data The data to unwrap.
   * @param {Object} [props] Additional data for the unwrap operation.
   * @returns {Promise} The promise for the unwrap operation.
   * @throws {Error} If {alg} is not appropriate for this Key; or if
   *         the Key does not contain the appropriate properties.
   */
  Object.defineProperty(this, "unwrap", {
    value: function(alg, data, props) {
      // validate appropriateness
      if (this.algorithms("unwrap").indexOf(alg) === -1) {
        return Promise.reject(new Error("unsupported algorithm"));
      }
      var k = cfg.unwrapKey.call(this, alg, keys);
      if (!k) {
        return Promise.reject(new Error("improper key"));
      }

      // prepare properties (if any)
      props = (props) ?
              clone(props) :
              {};
      if (cfg.unwrapProps) {
        props = merge(props, cfg.unwrapProps.call(this, alg, props));
      }
      return ALGORITHMS.decrypt(alg, k, data, props);
    }
  });
};

module.exports = JWKBaseKeyObject;
