/*!
 * algorithms/ecdh.js - Elliptic Curve Diffie-Hellman algorithms
 *
 * Copyright (c) 2015 Cisco Systems, Inc.  See LICENSE file.
 */
"use strict";

var merge = require("../util/merge"),
    util = require("../util"),
    ecUtil = require("./ec-util.js"),
    hkdf = require("./hkdf.js"),
    concat = require("./concat.js"),
    aesKw = require("./aes-kw.js"),
    helpers = require("./helpers.js"),
    CONSTANTS = require("./constants.js");

var clone = require("lodash/clone");
var omit = require("lodash/omit");
var pick = require("lodash/pick");

function idealHash(curve) {
  switch (curve) {
    case "P-256":
      return "SHA-256";
    case "P-384":
      return "SHA-384";
    case "P-521":
      return "SHA-512";
    default:
      throw new Error("unsupported curve: " + curve);
  }
}

// ### Exported
var ecdh = module.exports = {};

// ### Derivation algorithms
// ### "raw" ECDH
function ecdhDeriveFn() {
  var alg = {
    name: "ECDH"
  };

  var validatePublic = function(pk, form) {
    var pubKey = pk && ecUtil.convertToForge(pk, true);
    if (!pubKey || !pubKey.isValid()) {
      return Promise.reject(new Error("invalid EC public key"));
    }

    switch (form) {
      case "jwk":
        pubKey = ecUtil.convertToJWK(pk, true);
        break;
      case "buffer":
        pubKey = ecUtil.convertToBuffer(pk, true);
        break;
    }
    return Promise.resolve(pubKey);
  }

  // ### fallback implementation -- uses ecc + forge
  var fallback = function(key, props) {
    props = props || {};
    var keyLen = props.length || 0;
    // assume {key} is privateKey
    // assume {props.public} is publicKey
    var privKey = ecUtil.convertToForge(key, false);

    var p = validatePublic(props.public, "forge");
    p = p.then(function(pubKey) {
      // {pubKey} is "forge"

      var secret = privKey.computeSecret(pubKey);
      if (keyLen) {
        // truncate to requested key length
        if (secret.length < keyLen) {
          return Promise.reject(new Error("key length too large: " + keyLen));
        }
        secret = secret.slice(0, keyLen);
      }

      return secret;
    });
    return p;
  };

  // ### WebCryptoAPI implementation
  // TODO: cache CryptoKey sooner
  var webcrypto = function(key, props) {
    key = key || {};
    props = props || {};

    var keyLen = props.length || 0,
        algParams = merge(clone(alg), {
          namedCurve: key.crv
        });

    // assume {key} is privateKey
    if (!keyLen) {
      // calculate key length from private key size
      keyLen = key.d.length;
    }
    var privKey = ecUtil.convertToJWK(key, false);
    privKey = helpers.subtleCrypto.importKey("jwk",
                                             privKey,
                                             algParams,
                                             false,
                                             [ "deriveBits" ]);

    // assume {props.public} is publicKey
    var pubKey = validatePublic(props.public, "jwk");
    pubKey = pubKey.then(function(pubKey) {
      // {pubKey} is "jwk"
      return helpers.subtleCrypto.importKey("jwk",
                                            pubKey,
                                            algParams,
                                            false,
                                            []);
    });

    var p = Promise.all([privKey, pubKey]);
    p = p.then(function(keypair) {
      var privKey = keypair[0],
          pubKey = keypair[1];

      var algParams = merge(clone(alg), {
        public: pubKey
      });
      return helpers.subtleCrypto.deriveBits(algParams, privKey, keyLen * 8);
    });
    p = p.then(function(result) {
      result = Buffer.from(result);
      return result;
    });
    return p;
  };

  var nodejs = function(key, props) {
    if ("function" !== typeof helpers.nodeCrypto.createECDH) {
      throw new Error("unsupported algorithm: ECDH");
    }

    props = props || {};
    var keyLen = props.length || 0;
    var curve;
    switch (key.crv) {
      case "P-256":
        curve = "prime256v1";
        break;
      case "P-384":
        curve = "secp384r1";
        break;
      case "P-521":
        curve = "secp521r1";
        break;
      default:
        return Promise.reject(new Error("invalid curve: " + curve));
    }

    // assume {key} is privateKey
    // assume {props.public} is publicKey
    var privKey = ecUtil.convertToBuffer(key, false);

    var p = validatePublic(props.public, "buffer");
    p = p.then(function(pubKey) {
      // {pubKey} is "buffer"
      var ecdh = helpers.nodeCrypto.createECDH(curve);
      // dummy call so computeSecret doesn't fail
      // ecdh.generateKeys();
      ecdh.setPrivateKey(privKey);
      var secret = ecdh.computeSecret(pubKey);
      if (keyLen) {
        if (secret.length < keyLen) {
          return Promise.reject(new Error("key length too large: " + keyLen));
        }
        secret = secret.slice(0, keyLen);
      }
      return secret;
    });
    return p;
  };

  return helpers.setupFallback(nodejs, webcrypto, fallback);
}

function ecdhConcatDeriveFn() {
  // NOTE: no nodejs/webcrypto/fallback model, since this algorithm is
  //       implemented using other primitives

  var fn = function(key, props) {
    props = props || {};

    var hash;
    try {
      hash = props.hash || idealHash(key.crv);
      if (!hash) {
        throw new Error("invalid hash: " + hash);
      }
      hash.toUpperCase();
    } catch (ex) {
      return Promise.reject(ex);
    }

    var params = ["public"];
    // derive shared secret
    // NOTE: whitelist items from {props} for ECDH
    var promise = ecdh.ECDH.derive(key, pick(props, params));
    // expand
    promise = promise.then(function(shared) {
      // NOTE: blacklist items from {props} for ECDH
      return concat["CONCAT-" + hash].derive(shared, omit(props, params));
    });
    return promise;
  };

  return fn;
}

function ecdhHkdfDeriveFn() {
  // NOTE: no nodejs/webcrypto/fallback model, since this algorithm is
  //       implemented using other primitives

  var fn = function(key, props) {
    props = props || {};

    var hash;
    try {
      hash = props.hash || idealHash(key.crv);
      if (!hash) {
        throw new Error("invalid hash: " + hash);
      }
      hash.toUpperCase();
    } catch (ex) {
      return Promise.reject(ex);
    }

    var params = ["public"];
    // derive shared secret
    // NOTE: whitelist items from {props} for ECDH
    var promise = ecdh.ECDH.derive(key, pick(props, params));
    // extract-and-expand
    promise = promise.then(function(shared) {
      // NOTE: blacklist items from {props} for ECDH
      return hkdf["HKDF-" + hash].derive(shared, omit(props, params));
    });
    return promise;
  };

  return fn;
}

// ### Wrap/Unwrap algorithms
function doEcdhesCommonDerive(privKey, pubKey, props) {
  function prependLen(input) {
    return Buffer.concat([
      helpers.int32ToBuffer(input.length),
      input
    ]);
  }

  var algId = props.algorithm || "",
      keyLen = CONSTANTS.KEYLENGTH[algId],
      apu = util.asBuffer(props.apu || "", "base64url"),
      apv = util.asBuffer(props.apv || "", "base64url");
  var otherInfo = Buffer.concat([
    prependLen(Buffer.from(algId, "utf8")),
    prependLen(apu),
    prependLen(apv),
    helpers.int32ToBuffer(keyLen)
  ]);

  var params = {
    public: pubKey,
    length: keyLen / 8,
    hash: "SHA-256",
    otherInfo: otherInfo
  };
  return ecdh["ECDH-CONCAT"].derive(privKey, params);
}

function ecdhesDirEncryptFn() {
  // NOTE: no nodejs/webcrypto/fallback model, since this algorithm is
  //       implemented using other primitives
  var fn = function(key, pdata, props) {
    props = props || {};

    // {props.epk} is private
    if (!props.epk || !props.epk.d) {
      return Promise.reject(new Error("missing ephemeral private key"));
    }
    var epk = ecUtil.convertToObj(props.epk, false);

    // {key} is public
    if (!key || !key.x || !key.y) {
      return Promise.reject(new Error("missing static public key"));
    }
    var spk = ecUtil.convertToObj(key, true);

    // derive ECDH shared
    var promise = doEcdhesCommonDerive(epk, spk, {
      algorithm: props.enc,
      apu: props.apu,
      apv: props.apv
    });
    promise = promise.then(function(shared) {
      return {
        data: shared,
        once: true,
        direct: true
      };
    });
    return promise;
  };

  return fn;
}
function ecdhesDirDecryptFn() {
  // NOTE: no nodejs/webcrypto/fallback model, since this algorithm is
  //       implemented using other primitives
  var fn = function(key, cdata, props) {
    props = props || {};

    // {props.epk} is public
    if (!props.epk || !props.epk.x || !props.epk.y) {
      return Promise.reject(new Error("missing ephemeral public key"));
    }
    var epk = ecUtil.convertToObj(props.epk, true);

    // {key} is private
    if (!key || !key.d) {
      return Promise.reject(new Error("missing static private key"));
    }
    var spk = ecUtil.convertToObj(key, false);

    // derive ECDH shared
    var promise = doEcdhesCommonDerive(spk, epk, {
      algorithm: props.enc,
      apu: props.apu,
      apv: props.apv
    });
    promise = promise.then(function(shared) {
      return shared;
    });
    return promise;
  };

  return fn;
}

function ecdhesKwEncryptFn(wrap) {
  // NOTE: no nodejs/webcrypto/fallback model, since this algorithm is
  //       implemented using other primitives
  var fn = function(key, pdata, props) {
    props = props || {};

    // {props.epk} is private
    if (!props.epk || !props.epk.d) {
      return Promise.reject(new Error("missing ephemeral private key"));
    }
    var epk = ecUtil.convertToObj(props.epk, false);

    // {key} is public
    if (!key || !key.x || !key.y) {
      return Promise.reject(new Error("missing static public key"));
    }
    var spk = ecUtil.convertToObj(key, true);

    // derive ECDH shared
    var promise = doEcdhesCommonDerive(epk, spk, {
      algorithm: props.alg,
      apu: props.apu,
      apv: props.apv
    });
    promise = promise.then(function(shared) {
      // wrap provided key with ECDH shared
      return wrap(shared, pdata);
    });
    return promise;
  };

  return fn;
}

function ecdhesKwDecryptFn(unwrap) {
  // NOTE: no nodejs/webcrypto/fallback model, since this algorithm is
  //       implemented using other primitives
  var fn = function(key, cdata, props) {
    props = props || {};

    // {props.epk} is public
    if (!props.epk || !props.epk.x || !props.epk.y) {
      return Promise.reject(new Error("missing ephemeral public key"));
    }
    var epk = ecUtil.convertToObj(props.epk, true);

    // {key} is private
    if (!key || !key.d) {
      return Promise.reject(new Error("missing static private key"));
    }
    var spk = ecUtil.convertToObj(key, false);

    // derive ECDH shared
    var promise = doEcdhesCommonDerive(spk, epk, {
      algorithm: props.alg,
      apu: props.apu,
      apv: props.apv
    });
    promise = promise.then(function(shared) {
      // unwrap provided key with ECDH shared
      return unwrap(shared, cdata);
    });
    return promise;
  };

  return fn;
}

// ### Public API
// * [name].derive
[
  "ECDH",
  "ECDH-HKDF",
  "ECDH-CONCAT"
].forEach(function(name) {
  var kdf = /^ECDH(?:-(\w+))?$/g.exec(name || "")[1];
  var op = ecdh[name] = ecdh[name] || {};
  switch (kdf || "") {
    case "CONCAT":
      op.derive = ecdhConcatDeriveFn();
      break;
    case "HKDF":
      op.derive = ecdhHkdfDeriveFn();
      break;
    case "":
      op.derive = ecdhDeriveFn();
      break;
    default:
      op.derive = null;
  }
});

// * [name].encrypt
// * [name].decrypt
[
  "ECDH-ES",
  "ECDH-ES+A128KW",
  "ECDH-ES+A192KW",
  "ECDH-ES+A256KW"
].forEach(function(name) {
  var kw = /^ECDH-ES(?:\+(.+))?/g.exec(name || "")[1];
  var op = ecdh[name] = ecdh[name] || {};
  if (!kw) {
    op.encrypt = ecdhesDirEncryptFn();
    op.decrypt = ecdhesDirDecryptFn();
  } else {
    kw = aesKw[kw];
    if (kw) {
      op.encrypt = ecdhesKwEncryptFn(kw.encrypt);
      op.decrypt = ecdhesKwDecryptFn(kw.decrypt);
    } else {
      op.ecrypt = op.decrypt = null;
    }
  }
});
//*/
