// Copyright 2012 Joyent, Inc.  All rights reserved.

var assert = require('assert-plus');
var util = require('util');
var utils = require('./utils');



///--- Globals

var HASH_ALGOS = utils.HASH_ALGOS;
var PK_ALGOS = utils.PK_ALGOS;
var HttpSignatureError = utils.HttpSignatureError;
var InvalidAlgorithmError = utils.InvalidAlgorithmError;
var validateAlgorithm = utils.validateAlgorithm;

var State = {
  New: 0,
  Params: 1
};

var ParamsState = {
  Name: 0,
  Quote: 1,
  Value: 2,
  Comma: 3,
  Number: 4
};

///--- Specific Errors


function ExpiredRequestError(message) {
  HttpSignatureError.call(this, message, ExpiredRequestError);
}
util.inherits(ExpiredRequestError, HttpSignatureError);


function InvalidHeaderError(message) {
  HttpSignatureError.call(this, message, InvalidHeaderError);
}
util.inherits(InvalidHeaderError, HttpSignatureError);


function InvalidParamsError(message) {
  HttpSignatureError.call(this, message, InvalidParamsError);
}
util.inherits(InvalidParamsError, HttpSignatureError);


function MissingHeaderError(message) {
  HttpSignatureError.call(this, message, MissingHeaderError);
}
util.inherits(MissingHeaderError, HttpSignatureError);

function StrictParsingError(message) {
  HttpSignatureError.call(this, message, StrictParsingError);
}
util.inherits(StrictParsingError, HttpSignatureError);

///--- Exported API

module.exports = {

  /**
   * Parses the 'Authorization' header out of an http.ServerRequest object.
   *
   * Note that this API will fully validate the Authorization header, and throw
   * on any error.  It will not however check the signature, or the keyId format
   * as those are specific to your environment.  You can use the options object
   * to pass in extra constraints.
   *
   * As a response object you can expect this:
   *
   *     {
   *       "scheme": "Signature",
   *       "params": {
   *         "keyId": "foo",
   *         "algorithm": "rsa-sha256",
   *         "headers": [
   *           "date" or "x-date",
   *           "digest"
   *         ],
   *         "signature": "base64"
   *       },
   *       "signingString": "ready to be passed to crypto.verify()"
   *     }
   *
   * @param {Object} request an http.ServerRequest.
   * @param {Object} options an optional options object with:
   *                   - clockSkew: allowed clock skew in seconds (default 300).
   *                   - headers: required header names (def: date or x-date)
   *                   - algorithms: algorithms to support (default: all).
   *                   - strict: should enforce latest spec parsing
   *                             (default: false).
   * @return {Object} parsed out object (see above).
   * @throws {TypeError} on invalid input.
   * @throws {InvalidHeaderError} on an invalid Authorization header error.
   * @throws {InvalidParamsError} if the params in the scheme are invalid.
   * @throws {MissingHeaderError} if the params indicate a header not present,
   *                              either in the request headers from the params,
   *                              or not in the params from a required header
   *                              in options.
   * @throws {StrictParsingError} if old attributes are used in strict parsing
   *                              mode.
   * @throws {ExpiredRequestError} if the value of date or x-date exceeds skew.
   */
  parseRequest: function parseRequest(request, options) {
    assert.object(request, 'request');
    assert.object(request.headers, 'request.headers');
    if (options === undefined) {
      options = {};
    }
    assert.object(options, 'options');
    assert.optionalFinite(options.clockSkew, 'options.clockSkew');

    var headers = [request.headers['x-date'] ? 'x-date' : 'date'];
    if (options.headers !== undefined) {
      assert.arrayOfString(headers, 'options.headers');
      headers = options.headers;
    }

    var authzHeaderName = options.authorizationHeaderName;
    var authz = request.headers[authzHeaderName] ||
      request.headers[utils.HEADER.AUTH] || request.headers[utils.HEADER.SIG];

    if (!authz) {
      var errHeader = authzHeaderName ? authzHeaderName :
        utils.HEADER.AUTH + ' or ' + utils.HEADER.SIG;

      throw new MissingHeaderError('no ' + errHeader + ' header ' +
        'present in the request');
    }

    options.clockSkew = options.clockSkew || 300;


    var i = 0;
    var state = authz === request.headers[utils.HEADER.SIG] ?
      State.Params : State.New;
    var substate = ParamsState.Name;
    var tmpName = '';
    var tmpValue = '';

    var parsed = {
      scheme: authz === request.headers[utils.HEADER.SIG] ? 'Signature' : '',
      params: {},
      signingString: ''
    };

    for (i = 0; i < authz.length; i++) {
      var c = authz.charAt(i);

      switch (Number(state)) {

        case State.New:
          if (c !== ' ') parsed.scheme += c;
          else state = State.Params;
          break;

        case State.Params:
          switch (Number(substate)) {

            case ParamsState.Name:
              var code = c.charCodeAt(0);
              // restricted name of A-Z / a-z
              if ((code >= 0x41 && code <= 0x5a) || // A-Z
                (code >= 0x61 && code <= 0x7a)) { // a-z
                tmpName += c;
              } else if (c === '=') {
                if (tmpName.length === 0)
                  throw new InvalidHeaderError('bad param format');
                substate = ParamsState.Quote;
              } else {
                throw new InvalidHeaderError('bad param format');
              }
              break;

            case ParamsState.Quote:
              if (c === '"') {
                tmpValue = '';
                substate = ParamsState.Value;
              } else {
                //number
                substate = ParamsState.Number;
                code = c.charCodeAt(0);
                if (code < 0x30 || code > 0x39) { //character not in 0-9
                  throw new InvalidHeaderError('bad param format');
                }
                tmpValue = c;
              }
              break;

            case ParamsState.Value:
              if (c === '"') {
                parsed.params[tmpName] = tmpValue;
                substate = ParamsState.Comma;
              } else {
                tmpValue += c;
              }
              break;

            case ParamsState.Number:
              if (c === ',') {
                parsed.params[tmpName] = parseInt(tmpValue, 10);
                tmpName = '';
                substate = ParamsState.Name;
              } else {
                code = c.charCodeAt(0);
                if (code < 0x30 || code > 0x39) { //character not in 0-9
                  throw new InvalidHeaderError('bad param format');
                }
                tmpValue += c;
              }
              break;


            case ParamsState.Comma:
              if (c === ',') {
                tmpName = '';
                substate = ParamsState.Name;
              } else {
                throw new InvalidHeaderError('bad param format');
              }
              break;

            default:
              throw new Error('Invalid substate');
          }
          break;

        default:
          throw new Error('Invalid substate');
      }

    }

    if (!parsed.params.headers || parsed.params.headers === '') {
      if (request.headers['x-date']) {
        parsed.params.headers = ['x-date'];
      } else {
        parsed.params.headers = ['date'];
      }
    } else {
      parsed.params.headers = parsed.params.headers.split(' ');
    }

    // Minimally validate the parsed object
    if (!parsed.scheme || parsed.scheme !== 'Signature')
      throw new InvalidHeaderError('scheme was not "Signature"');

    if (!parsed.params.keyId)
      throw new InvalidHeaderError('keyId was not specified');

    if (!parsed.params.algorithm)
      throw new InvalidHeaderError('algorithm was not specified');

    if (!parsed.params.signature)
      throw new InvalidHeaderError('signature was not specified');

    // Check the algorithm against the official list
    try {
      validateAlgorithm(parsed.params.algorithm);
    } catch (e) {
      if (e instanceof InvalidAlgorithmError)
        throw (new InvalidParamsError(parsed.params.algorithm + ' is not ' +
          'supported'));
      else
        throw (e);
    }

    // Build the signingString
    for (i = 0; i < parsed.params.headers.length; i++) {
      var h = parsed.params.headers[i].toLowerCase();
      parsed.params.headers[i] = h;

      if (h === 'request-line') {
        if (!options.strict) {
          /*
           * We allow headers from the older spec drafts if strict parsing isn't
           * specified in options.
           */
          parsed.signingString +=
            request.method + ' ' + request.url + ' HTTP/' + request.httpVersion;
        } else {
          /* Strict parsing doesn't allow older draft headers. */
          throw (new StrictParsingError('request-line is not a valid header ' +
            'with strict parsing enabled.'));
        }
      } else if (h === '(request-target)') {
        parsed.signingString +=
          '(request-target): ' + request.method.toLowerCase() + ' ' +
          request.url;
      } else if (h === '(keyid)') {
        parsed.signingString += '(keyid): ' + parsed.params.keyId;
      } else if (h === '(algorithm)') {
        parsed.signingString += '(algorithm): ' + parsed.params.algorithm;
      } else if (h === '(opaque)') {
        var opaque = parsed.params.opaque;
        if (opaque === undefined) {
          throw new MissingHeaderError('opaque param was not in the ' +
            authzHeaderName + ' header');
        }
        parsed.signingString += '(opaque): ' + opaque;
      } else if (h === '(created)') {
        parsed.signingString += '(created): ' + parsed.params.created;
    } else if (h === '(expires)') {
        parsed.signingString += '(expires): ' + parsed.params.expires;
      } else {
        var value = request.headers[h];
        if (value === undefined)
          throw new MissingHeaderError(h + ' was not in the request');
        parsed.signingString += h + ': ' + value;
      }

      if ((i + 1) < parsed.params.headers.length)
        parsed.signingString += '\n';
    }

    // Check against the constraints
    var date;
    var skew;
    if (request.headers.date || request.headers['x-date']) {
      if (request.headers['x-date']) {
        date = new Date(request.headers['x-date']);
      } else {
        date = new Date(request.headers.date);
      }
      var now = new Date();
      skew = Math.abs(now.getTime() - date.getTime());

      if (skew > options.clockSkew * 1000) {
        throw new ExpiredRequestError('clock skew of ' +
          (skew / 1000) +
          's was greater than ' +
          options.clockSkew + 's');
      }
    }

    if (parsed.params.created) {
      skew = parsed.params.created  - Math.floor(Date.now() / 1000);
      if (skew > options.clockSkew) {
        throw new ExpiredRequestError('Created lies in the future (with ' +
          'skew ' + skew + 's greater than allowed ' + options.clockSkew +
          's');
      }
    }

    if (parsed.params.expires) {
      var expiredSince = Math.floor(Date.now() / 1000) - parsed.params.expires;
      if (expiredSince > options.clockSkew) {
        throw new ExpiredRequestError('Request expired with skew ' +
          expiredSince + 's greater than allowed ' + options.clockSkew + 's');
      }
    }

    headers.forEach(function (hdr) {
      // Remember that we already checked any headers in the params
      // were in the request, so if this passes we're good.
      if (parsed.params.headers.indexOf(hdr.toLowerCase()) < 0)
        throw new MissingHeaderError(hdr + ' was not a signed header');
    });

    parsed.params.algorithm = parsed.params.algorithm.toLowerCase();
    if (options.algorithms) {
      if (options.algorithms.indexOf(parsed.params.algorithm) === -1)
        throw new InvalidParamsError(parsed.params.algorithm +
          ' is not a supported algorithm');
    }

    parsed.algorithm = parsed.params.algorithm.toUpperCase();
    parsed.keyId = parsed.params.keyId;
    parsed.opaque = parsed.params.opaque;
    return parsed;
  }

};
