/*!
 * jws/sign.js - Sign to JWS
 *
 * Copyright (c) 2015 Cisco Systems, Inc. See LICENSE file.
 */
"use strict";

var merge = require("../util/merge"),
    util = require("../util"),
    JWK = require("../jwk"),
    slice = require("./helpers").slice;

var clone = require("lodash/clone");
var uniq = require("lodash/uniq");

var DEFAULTS = require("./defaults");

/**
 * @class JWS.Signer
 * @classdesc Generator of signed content.
 *
 * @description
 * **NOTE:** this class cannot be instantiated directly. Instead call {@link
 * JWS.createSign}.
 */
var JWSSigner = function(cfg, signatories) {
  var finalized = false,
      format = cfg.format || "general",
      content = Buffer.alloc(0);

  /**
  * @member {Boolean} JWS.Signer#compact
  * @description
  * Indicates whether the outuput of this signature generator is using
  * the Compact serialization (`true`) or the JSON serialization
  * (`false`).
  */
  Object.defineProperty(this, "compact", {
    get: function() {
      return "compact" === format;
    },
    enumerable: true
  });
  Object.defineProperty(this, "format", {
    get: function() {
      return format;
    },
    enumerable: true
  });

  /**
  * @method JWS.Signer#update
  * @description
  * Updates the signing content for this signature content. The content
  * is appended to the end of any other content already applied.
  *
  * If {data} is a Buffer, {encoding} is ignored. Otherwise, {data} is
  * converted to a Buffer internally to {encoding}.
  *
  * @param {Buffer|String} data The data to sign.
  * @param {String} [encoding="binary"] The encoding of {data}.
  * @returns {JWS.Signer} This signature generator.
  * @throws {Error} If a signature has already been generated.
  */
  Object.defineProperty(this, "update", {
    value: function(data, encoding) {
      if (finalized) {
        throw new Error("already final");
      }
      if (data != null) {
        data = util.asBuffer(data, encoding);
        if (content.length) {
          content = Buffer.concat([content, data],
                      content.length + data.length);
        } else {
          content = data;
        }
      }

      return this;
    }
  });
  /**
  * @method JWS.Signer#final
  * @description
  * Finishes the signature operation.
  *
  * The returned Promise, when fulfilled, is the JSON Web Signature (JWS)
  * object, either in the Compact (if {@link JWS.Signer#format} is
  * `"compact"`), the flattened JSON (if {@link JWS.Signer#format} is
  * "flattened"), or the general JSON serialization.
  *
  * @param {Buffer|String} [data] The final content to apply.
  * @param {String} [encoding="binary"] The encoding of the final content
  *        (if any).
  * @returns {Promise} The promise for the signatures
  * @throws {Error} If a signature has already been generated.
  */
  Object.defineProperty(this, "final", {
    value: function(data, encoding) {
      if (finalized) {
        return Promise.reject(new Error("already final"));
      }

      // last-minute data
      this.update(data, encoding);

      // mark as done...ish
      finalized = true;
      var promise;

      // map signatory promises to just signatories
      promise = Promise.all(signatories);
      promise = promise.then(function(sigs) {
        // prepare content
        content = util.base64url.encode(content);

        sigs = sigs.map(function(s) {
          // prepare protected
          var protect = {},
              lenProtect = 0,
              unprotect = clone(s.header),
              lenUnprotect = Object.keys(unprotect).length;
          s.protected.forEach(function(h) {
            if (!(h in unprotect)) {
              return;
            }
            protect[h] = unprotect[h];
            lenProtect++;
            delete unprotect[h];
            lenUnprotect--;
          });
          if (lenProtect > 0) {
            protect = JSON.stringify(protect);
            protect = util.base64url.encode(protect);
          } else {
            protect = "";
          }

          // signit!
          var data = Buffer.from(protect + "." + content, "ascii");
          s = s.key.sign(s.header.alg, data, s.header);
          s = s.then(function(result) {
            var sig = {};
            if (0 < lenProtect) {
              sig.protected = protect;
            }
            if (0 < lenUnprotect) {
              sig.header = unprotect;
            }
            sig.signature = util.base64url.encode(result.mac);
            return sig;
          });
          return s;
        });
        sigs = [Promise.resolve(content)].concat(sigs);
        return Promise.all(sigs);
      });
      promise = promise.then(function(results) {
        var content = results[0];
        return {
          payload: content,
          signatures: results.slice(1)
        };
      });
      switch (format) {
        case "compact":
          promise = promise.then(function(jws) {
            var compact = [
              jws.signatures[0].protected,
              jws.payload,
              jws.signatures[0].signature
            ];
            compact = compact.join(".");
            return compact;
          });
          break;
        case "flattened":
          promise = promise.then(function(jws) {
            var flattened = {};
            flattened.payload = jws.payload;

            var sig = jws.signatures[0];
            if (sig.protected) {
              flattened.protected = sig.protected;
            }
            if (sig.header) {
              flattened.header = sig.header;
            }
            flattened.signature = sig.signature;

            return flattened;
          });
          break;
      }

      return promise;
    }
  });
};


/**
 * @description
 * Creates a new JWS.Signer with the given options and signatories.
 *
 * @param {Object} [opts] The signing options
 * @param {Boolean} [opts.compact] Use compact serialization?
 * @param {String} [opts.format] The serialization format to use ("compact",
 *                 "flattened", "general")
 * @param {Object} [opts.fields] Additional header fields
 * @param {JWK.Key[]|Object[]} [signs] Signatories, either as an array of
 *        JWK.Key instances; or an array of objects, each with the following
 *        properties
 * @param {JWK.Key} signs.key Key used to sign content
 * @param {Object} [signs.header] Per-signatory header fields
 * @param {String} [signs.reference] Reference field to identify the key
 * @param {String[]|String} [signs.protect] List of fields to integrity
 *        protect ("*" to protect all fields)
 * @returns {JWS.Signer} The signature generator.
 * @throws {Error} If Compact serialization is requested but there are
 *         multiple signatories
 */
function createSign(opts, signs) {
  // fixup signatories
  var options = opts,
      signStart = 1,
      signList = signs;

  if (arguments.length === 0) {
    throw new Error("at least one signatory must be provided");
  }
  if (arguments.length === 1) {
    signList = opts;
    signStart = 0;
    options = {};
  } else if (JWK.isKey(opts) ||
            (opts && "kty" in opts) ||
            (opts && "key" in opts &&
            (JWK.isKey(opts.key) || "kty" in opts.key))) {
    signList = opts;
    signStart = 0;
    options = {};
  } else {
    options = clone(opts);
  }
  if (!Array.isArray(signList)) {
    signList = slice(arguments, signStart);
  }

  // fixup options
  options = merge(clone(DEFAULTS), options);

  // setup header fields
  var allFields = options.fields || {};
  // setup serialization format
  var format = options.format;
  if (!format) {
    format = options.compact ? "compact" : "general";
  }
  if (("compact" === format || "flattened" === format) && 1 < signList.length) {
    throw new Error("too many signatories for compact or flattened JSON serialization");
  }

  // note protected fields (globally)
  // protected fields are per signature
  var protectAll = ("*" === options.protect);
  if (options.compact) {
    protectAll = true;
  }

  signList = signList.map(function(s, idx) {
    var p;

    // resolve a key
    if (s && "kty" in s) {
      p = JWK.asKey(s);
      p = p.then(function(k) {
        return {
          key: k
        };
      });
    } else if (s) {
      p = JWK.asKey(s.key);
      p = p.then(function(k) {
        return {
          header: s.header,
          reference: s.reference,
          protect: s.protect,
          key: k
        };
      });
    } else {
      p = Promise.reject(new Error("missing key for signatory " + idx));
    }

    // resolve the complete signatory
    p = p.then(function(signatory) {
      var key = signatory.key;

      // make sure there is a header
      var header = signatory.header || {};
      header = merge(merge({}, allFields), header);
      signatory.header = header;

      // ensure an algorithm
      if (!header.alg) {
        header.alg = key.algorithms(JWK.MODE_SIGN)[0] || "";
      }

      // determine the key reference
      var ref = signatory.reference;
      delete signatory.reference;
      if (undefined === ref) {
        // header already contains the key reference
        ref = ["kid", "jku", "x5c", "x5t", "x5u"].some(function(k) {
          return (k in header);
        });
        ref = !ref ? "kid" : null;
      } else if ("boolean" === typeof ref) {
        // explicit (positive | negative) request for key reference
        ref = ref ? "kid" : null;
      }
      var jwk;
      if (ref) {
        jwk = key.toJSON();
        if ("jwk" === ref) {
          if ("oct" === key.kty) {
            return Promise.reject(new Error("cannot embed key"));
          }
          header.jwk = jwk;
        } else if (ref in jwk) {
          header[ref] = jwk[ref];
        }
      }

      // determine protected fields
      var protect = signatory.protect;
      if (protectAll || "*" === protect) {
        protect = Object.keys(header);
      } else if ("string" === protect) {
        protect = [protect];
      } else if (Array.isArray(protect)) {
        protect = protect.concat();
      } else if (!protect) {
        protect = [];
      } else {
        return Promise.reject(new Error("protect must be a list of fields"));
      }
      protect = uniq(protect);
      signatory.protected = protect;

      // freeze signatory
      signatory = Object.freeze(signatory);
      return signatory;
    });

    return p;
  });

  var cfg = {
    format: format
  };
  return new JWSSigner(cfg,
                       signList);
}

module.exports = {
  signer: JWSSigner,
  createSign: createSign
};
