// Copyright 2011 Mark Cavage, Inc.  All rights reserved.


var assert = require('assert-plus');


///--- Helpers

function invalidDN(name) {
  var e = new Error();
  e.name = 'InvalidDistinguishedNameError';
  e.message = name;
  return e;
}

function isAlphaNumeric(c) {
  var re = /[A-Za-z0-9]/;
  return re.test(c);
}

function isWhitespace(c) {
  var re = /\s/;
  return re.test(c);
}

function repeatChar(c, n) {
  var out = '';
  var max = n ? n : 0;
  for (var i = 0; i < max; i++)
    out += c;
  return out;
}

///--- API

function RDN(obj) {
  var self = this;
  this.attrs = {};

  if (obj) {
    Object.keys(obj).forEach(function (k) {
      self.set(k, obj[k]);
    });
  }
}

RDN.prototype.set = function rdnSet(name, value, opts) {
  assert.string(name, 'name (string) required');
  assert.string(value, 'value (string) required');

  var self = this;
  var lname = name.toLowerCase();
  this.attrs[lname] = {
    value: value,
    name: name
  };
  if (opts && typeof (opts) === 'object') {
    Object.keys(opts).forEach(function (k) {
      if (k !== 'value')
        self.attrs[lname][k] = opts[k];
    });
  }
};

RDN.prototype.equals = function rdnEquals(rdn) {
  if (typeof (rdn) !== 'object')
    return false;

  var ourKeys = Object.keys(this.attrs);
  var theirKeys = Object.keys(rdn.attrs);
  if (ourKeys.length !== theirKeys.length)
    return false;

  ourKeys.sort();
  theirKeys.sort();

  for (var i = 0; i < ourKeys.length; i++) {
    if (ourKeys[i] !== theirKeys[i])
      return false;
    if (this.attrs[ourKeys[i]].value !== rdn.attrs[ourKeys[i]].value)
      return false;
  }
  return true;
};


/**
 * Convert RDN to string according to specified formatting options.
 * (see: DN.format for option details)
 */
RDN.prototype.format = function rdnFormat(options) {
  assert.optionalObject(options, 'options must be an object');
  options = options || {};

  var self = this;
  var str = '';

  function escapeValue(val, forceQuote) {
    var out = '';
    var cur = 0;
    var len = val.length;
    var quoted = false;
    /* BEGIN JSSTYLED */
    var escaped = /[\\\"]/;
    var special = /[,=+<>#;]/;
    /* END JSSTYLED */

    if (len > 0) {
      // Wrap strings with trailing or leading spaces in quotes
      quoted = forceQuote || (val[0] == ' ' || val[len-1] == ' ');
    }

    while (cur < len) {
      if (escaped.test(val[cur]) || (!quoted && special.test(val[cur]))) {
        out += '\\';
      }
      out += val[cur++];
    }
    if (quoted)
      out = '"' + out + '"';
    return out;
  }
  function sortParsed(a, b) {
    return self.attrs[a].order - self.attrs[b].order;
  }
  function sortStandard(a, b) {
    var nameCompare = a.localeCompare(b);
    if (nameCompare === 0) {
      // TODO: Handle binary values
      return self.attrs[a].value.localeCompare(self.attrs[b].value);
    } else {
      return nameCompare;
    }
  }

  var keys = Object.keys(this.attrs);
  if (options.keepOrder) {
    keys.sort(sortParsed);
  } else {
    keys.sort(sortStandard);
  }

  keys.forEach(function (key) {
    var attr = self.attrs[key];
    if (str.length)
      str += '+';

    if (options.keepCase) {
      str += attr.name;
    } else {
      if (options.upperName)
        str += key.toUpperCase();
      else
        str += key;
    }

    str += '=' + escapeValue(attr.value, (options.keepQuote && attr.quoted));
  });

  return str;
};

RDN.prototype.toString = function rdnToString() {
  return this.format();
};


// Thank you OpenJDK!
function parse(name) {
  if (typeof (name) !== 'string')
    throw new TypeError('name (string) required');

  var cur = 0;
  var len = name.length;

  function parseRdn() {
    var rdn = new RDN();
    var order = 0;
    rdn.spLead = trim();
    while (cur < len) {
      var opts = {
        order: order
      };
      var attr = parseAttrType();
      trim();
      if (cur >= len || name[cur++] !== '=')
        throw invalidDN(name);

      trim();
      // Parameters about RDN value are set in 'opts' by parseAttrValue
      var value = parseAttrValue(opts);
      rdn.set(attr, value, opts);
      rdn.spTrail = trim();
      if (cur >= len || name[cur] !== '+')
        break;
      ++cur;
      ++order;
    }
    return rdn;
  }


  function trim() {
    var count = 0;
    while ((cur < len) && isWhitespace(name[cur])) {
      ++cur;
      count++;
    }
    return count;
  }

  function parseAttrType() {
    var beg = cur;
    while (cur < len) {
      var c = name[cur];
      if (isAlphaNumeric(c) ||
          c == '.' ||
          c == '-' ||
          c == ' ') {
        ++cur;
      } else {
        break;
      }
    }
    // Back out any trailing spaces.
    while ((cur > beg) && (name[cur - 1] == ' '))
      --cur;

    if (beg == cur)
      throw invalidDN(name);

    return name.slice(beg, cur);
  }

  function parseAttrValue(opts) {
    if (cur < len && name[cur] == '#') {
      opts.binary = true;
      return parseBinaryAttrValue();
    } else if (cur < len && name[cur] == '"') {
      opts.quoted = true;
      return parseQuotedAttrValue();
    } else {
      return parseStringAttrValue();
    }
  }

  function parseBinaryAttrValue() {
    var beg = cur++;
    while (cur < len && isAlphaNumeric(name[cur]))
      ++cur;

    return name.slice(beg, cur);
  }

  function parseQuotedAttrValue() {
    var str = '';
    ++cur; // Consume the first quote

    while ((cur < len) && name[cur] != '"') {
      if (name[cur] === '\\')
        cur++;
      str += name[cur++];
    }
    if (cur++ >= len) // no closing quote
      throw invalidDN(name);

    return str;
  }

  function parseStringAttrValue() {
    var beg = cur;
    var str = '';
    var esc = -1;

    while ((cur < len) && !atTerminator()) {
      if (name[cur] === '\\') {
        // Consume the backslash and mark its place just in case it's escaping
        // whitespace which needs to be preserved.
        esc = cur++;
      }
      if (cur === len) // backslash followed by nothing
        throw invalidDN(name);
      str += name[cur++];
    }

    // Trim off (unescaped) trailing whitespace and rewind cursor to the end of
    // the AttrValue to record whitespace length.
    for (; cur > beg; cur--) {
      if (!isWhitespace(name[cur - 1]) || (esc === (cur - 1)))
        break;
    }
    return str.slice(0, cur - beg);
  }

  function atTerminator() {
    return (cur < len &&
            (name[cur] === ',' ||
             name[cur] === ';' ||
             name[cur] === '+'));
  }

  var rdns = [];

  // Short-circuit for empty DNs
  if (len === 0)
    return new DN(rdns);

  rdns.push(parseRdn());
  while (cur < len) {
    if (name[cur] === ',' || name[cur] === ';') {
      ++cur;
      rdns.push(parseRdn());
    } else {
      throw invalidDN(name);
    }
  }

  return new DN(rdns);
}


function DN(rdns) {
  assert.optionalArrayOfObject(rdns, '[object] required');

  this.rdns = rdns ? rdns.slice() : [];
  this._format = {};
}
Object.defineProperties(DN.prototype, {
  length: {
    get: function getLength() { return this.rdns.length; },
    configurable: false
  }
});

/**
 * Convert DN to string according to specified formatting options.
 *
 * Parameters:
 * - options: formatting parameters (optional, details below)
 *
 * Options are divided into two types:
 * - Preservation options: Using data recorded during parsing, details of the
 *   original DN are preserved when converting back into a string.
 * - Modification options: Alter string formatting defaults.
 *
 * Preservation options _always_ take precedence over modification options.
 *
 * Preservation Options:
 * - keepOrder: Order of multi-value RDNs.
 * - keepQuote: RDN values which were quoted will remain so.
 * - keepSpace: Leading/trailing spaces will be output.
 * - keepCase: Parsed attr name will be output instead of lowercased version.
 *
 * Modification Options:
 * - upperName: RDN names will be uppercased instead of lowercased.
 * - skipSpace: Disable trailing space after RDN separators
 */
DN.prototype.format = function dnFormat(options) {
  assert.optionalObject(options, 'options must be an object');
  options = options || this._format;

  var str = '';
  this.rdns.forEach(function (rdn) {
    var rdnString = rdn.format(options);
    if (str.length !== 0) {
      str += ',';
    }
    if (options.keepSpace) {
      str += (repeatChar(' ', rdn.spLead) +
        rdnString + repeatChar(' ', rdn.spTrail));
    } else if (options.skipSpace === true || str.length === 0) {
      str += rdnString;
    } else {
      str += ' ' + rdnString;
    }
  });
  return str;
};

/**
 * Set default string formatting options.
 */
DN.prototype.setFormat = function setFormat(options) {
  assert.object(options, 'options must be an object');

  this._format = options;
};

DN.prototype.toString = function dnToString() {
  return this.format();
};

DN.prototype.parentOf = function parentOf(dn) {
  if (typeof (dn) !== 'object')
    dn = parse(dn);

  if (this.rdns.length >= dn.rdns.length)
    return false;

  var diff = dn.rdns.length - this.rdns.length;
  for (var i = this.rdns.length - 1; i >= 0; i--) {
    var myRDN = this.rdns[i];
    var theirRDN = dn.rdns[i + diff];

    if (!myRDN.equals(theirRDN))
      return false;
  }

  return true;
};

DN.prototype.childOf = function childOf(dn) {
  if (typeof (dn) !== 'object')
    dn = parse(dn);
  return dn.parentOf(this);
};

DN.prototype.isEmpty = function isEmpty() {
  return (this.rdns.length === 0);
};

DN.prototype.equals = function dnEquals(dn) {
  if (typeof (dn) !== 'object')
    dn = parse(dn);

  if (this.rdns.length !== dn.rdns.length)
    return false;

  for (var i = 0; i < this.rdns.length; i++) {
    if (!this.rdns[i].equals(dn.rdns[i]))
      return false;
  }

  return true;
};

DN.prototype.parent = function dnParent() {
  if (this.rdns.length !== 0) {
    var save = this.rdns.shift();
    var dn = new DN(this.rdns);
    this.rdns.unshift(save);
    return dn;
  }

  return null;
};

DN.prototype.clone = function dnClone() {
  var dn = new DN(this.rdns);
  dn._format = this._format;
  return dn;
};

DN.prototype.reverse = function dnReverse() {
  this.rdns.reverse();
  return this;
};

DN.prototype.pop = function dnPop() {
  return this.rdns.pop();
};

DN.prototype.push = function dnPush(rdn) {
  assert.object(rdn, 'rdn (RDN) required');

  return this.rdns.push(rdn);
};

DN.prototype.shift = function dnShift() {
  return this.rdns.shift();
};

DN.prototype.unshift = function dnUnshift(rdn) {
  assert.object(rdn, 'rdn (RDN) required');

  return this.rdns.unshift(rdn);
};

DN.isDN = function isDN(dn) {
  if (!dn || typeof (dn) !== 'object') {
    return false;
  }
  if (dn instanceof DN) {
    return true;
  }
  if (Array.isArray(dn.rdns)) {
    // Really simple duck-typing for now
    return true;
  }
  return false;
};


///--- Exports

module.exports = {
  parse: parse,
  DN: DN,
  RDN: RDN
};
