function accessAssignmentModule(utils) {
  // Adds a prefix to the specified item if the prefix is defined
  function prefixItem(item, prefix) {
    return prefix ? prefix + item : item.toString();
  }

  // Transforms the given item or items into a new list of items with the specified prefix (if any) appended to each element
  function resolveCollectionItems(originalItems, itemPrefix) {
    if (utils.isValueNullOrUndefined(originalItems)) {
      return [ ];
    } else if (Array.isArray(originalItems)) {
      var resultItems = [ ];
      for (var i = 0; i < originalItems.length; i++) {
        var item = originalItems[i];

        if (utils.isValueNullOrUndefined(item)) {
          continue;
        }

        resultItems.push(prefixItem(item, itemPrefix));
      }

      return resultItems;
    } else {
      // Represents a single item
      return [ prefixItem(originalItems, itemPrefix) ];
    }
  }

  // Transforms the given collection definition, which may have been defined as a single item, a list of items or a function that returns a
  // list of items into a simple list, where each item has the specified prefix, if any
  function resolveCollectionDefinition(doc, oldDoc, collectionDefinition, itemPrefix) {
    if (utils.isValueNullOrUndefined(collectionDefinition)) {
      return [ ];
    } else {
      if (typeof collectionDefinition === 'function') {
        var fnResults = collectionDefinition(doc, oldDoc);

        return resolveCollectionItems(fnResults, itemPrefix);
      } else {
        return resolveCollectionItems(collectionDefinition, itemPrefix);
      }
    }
  }

  // Transforms a role collection definition into a simple list and prefixes each element with "role:"
  function resolveRoleCollectionDefinition(doc, oldDoc, rolesDefinition) {
    return resolveCollectionDefinition(doc, oldDoc, rolesDefinition, 'role:');
  }

  // Assigns channel access to users/roles
  function assignChannelsToUsersAndRoles(doc, oldDoc, accessAssignmentDefinition) {
    var usersAndRoles = [ ];

    var users = resolveCollectionDefinition(doc, oldDoc, accessAssignmentDefinition.users);
    for (var userIndex = 0; userIndex < users.length; userIndex++) {
      usersAndRoles.push(users[userIndex]);
    }

    var roles = resolveRoleCollectionDefinition(doc, oldDoc, accessAssignmentDefinition.roles);
    for (var roleIndex = 0; roleIndex < roles.length; roleIndex++) {
      usersAndRoles.push(roles[roleIndex]);
    }

    var channels = resolveCollectionDefinition(doc, oldDoc, accessAssignmentDefinition.channels);

    access(usersAndRoles, channels);

    return {
      type: 'channel',
      usersAndRoles: usersAndRoles,
      channels: channels
    };
  }

  // Assigns role access to users
  function assignRolesToUsers(doc, oldDoc, accessAssignmentDefinition) {
    var users = resolveCollectionDefinition(doc, oldDoc, accessAssignmentDefinition.users);
    var roles = resolveRoleCollectionDefinition(doc, oldDoc, accessAssignmentDefinition.roles);

    role(users, roles);

    return {
      type: 'role',
      users: users,
      roles: roles
    };
  }

  // Transforms the given access assignments definition into an array of access assignment entries (e.g. if it was defined as a function)
  function resolveAccessAssignmentsDefinition(doc, oldDoc, accessAssignmentsDefinition) {
    if (typeof accessAssignmentsDefinition === 'function') {
      return accessAssignmentsDefinition(doc, oldDoc);
    } else {
      return accessAssignmentsDefinition || [ ];
    }
  }

  // Assigns role access to users and/or channel access to users/roles according to the given access assignment definitions
  function assignUserAccess(doc, oldDoc, documentDefinition) {
    var effectiveOldDoc = utils.resolveOldDoc();
    var accessAssignmentDefinitions = resolveAccessAssignmentsDefinition(doc, effectiveOldDoc, documentDefinition.accessAssignments);

    var effectiveAssignments = [ ];
    for (var assignmentIndex = 0; assignmentIndex < accessAssignmentDefinitions.length; assignmentIndex++) {
      var definition = accessAssignmentDefinitions[assignmentIndex];

      if (definition.type === 'role') {
        effectiveAssignments.push(assignRolesToUsers(doc, effectiveOldDoc, definition));
      } else if (definition.type === 'channel' || utils.isValueNullOrUndefined(definition.type)) {
        effectiveAssignments.push(assignChannelsToUsersAndRoles(doc, effectiveOldDoc, definition));
      }
    }

    return effectiveAssignments;
  }

  return {
    assignUserAccess: assignUserAccess
  };
}


function authorizationModule(utils) {
  // A document definition may define its authorizations (channels, roles or users) for each operation type (view, add, replace, delete or
  // write) as either a string or an array of strings. In either case, add them to the list if they are not already present.
  function appendToAuthorizationList(allAuthorizations, authorizationsToAdd) {
    if (!utils.isValueNullOrUndefined(authorizationsToAdd)) {
      if (Array.isArray(authorizationsToAdd)) {
        for (var i = 0; i < authorizationsToAdd.length; i++) {
          var authorization = authorizationsToAdd[i];
          if (allAuthorizations.indexOf(authorization) < 0) {
            allAuthorizations.push(authorization);
          }
        }
      } else if (allAuthorizations.indexOf(authorizationsToAdd) < 0) {
        allAuthorizations.push(authorizationsToAdd);
      }
    }
  }

  // Retrieves a list of channels the document belongs to based on its specified type
  function getAllDocChannels(docDefinition) {
    var docChannelMap = utils.resolveDocumentConstraint(docDefinition.channels);

    var allChannels = [ ];
    if (docChannelMap) {
      appendToAuthorizationList(allChannels, docChannelMap.view);
      appendToAuthorizationList(allChannels, docChannelMap.write);
      appendToAuthorizationList(allChannels, docChannelMap.add);
      appendToAuthorizationList(allChannels, docChannelMap.replace);
      appendToAuthorizationList(allChannels, docChannelMap.remove);
    }

    return allChannels;
  }

  // Retrieves a list of authorizations (e.g. channels, roles, users) for the current document write operation type (add, replace or remove)
  function getRequiredAuthorizations(doc, oldDoc, authorizationDefinition) {
    var authorizationMap = utils.resolveDocumentConstraint(authorizationDefinition);

    if (utils.isValueNullOrUndefined(authorizationMap)) {
      // This document type does not define any authorizations (channels, roles, users) at all
      return null;
    }

    var requiredAuthorizations = [ ];
    var writeAuthorizationFound = false;
    if (authorizationMap.write) {
      writeAuthorizationFound = true;
      appendToAuthorizationList(requiredAuthorizations, authorizationMap.write);
    }

    if (doc._deleted) {
      if (authorizationMap.remove) {
        writeAuthorizationFound = true;
        appendToAuthorizationList(requiredAuthorizations, authorizationMap.remove);
      }
    } else if (!utils.isDocumentMissingOrDeleted(oldDoc) && authorizationMap.replace) {
      writeAuthorizationFound = true;
      appendToAuthorizationList(requiredAuthorizations, authorizationMap.replace);
    } else if (utils.isDocumentMissingOrDeleted(oldDoc) && authorizationMap.add) {
      writeAuthorizationFound = true;
      appendToAuthorizationList(requiredAuthorizations, authorizationMap.add);
    }

    if (writeAuthorizationFound) {
      return requiredAuthorizations;
    } else {
      // This document type does not define any authorizations (channels, roles, users) that apply to this particular write operation type
      return null;
    }
  }

  // Ensures the user is authorized to create/replace/delete this document
  function authorize(doc, oldDoc, docDefinition) {
    var authorizedChannels = getRequiredAuthorizations(doc, oldDoc, docDefinition.channels);
    var authorizedRoles = getRequiredAuthorizations(doc, oldDoc, docDefinition.authorizedRoles);
    var authorizedUsers = getRequiredAuthorizations(doc, oldDoc, docDefinition.authorizedUsers);

    var channelMatch = false;
    if (authorizedChannels) {
      try {
        requireAccess(authorizedChannels);
        channelMatch = true;
      } catch (ex) {
        // The user has none of the authorized channels
        if (!authorizedRoles && !authorizedUsers) {
          // ... and the document definition does not specify any authorized roles or users
          throw ex;
        }
      }
    }

    var roleMatch = false;
    if (authorizedRoles) {
      try {
        requireRole(authorizedRoles);
        roleMatch = true;
      } catch (ex) {
        // The user belongs to none of the authorized roles
        if (!authorizedChannels && !authorizedUsers) {
          // ... and the document definition does not specify any authorized channels or users
          throw ex;
        }
      }
    }

    var userMatch = false;
    if (authorizedUsers) {
      try {
        requireUser(authorizedUsers);
        userMatch = true;
      } catch (ex) {
        // The user does not match any of the authorized usernames
        if (!authorizedChannels && !authorizedRoles) {
          // ... and the document definition does not specify any authorized channels or roles
          throw ex;
        }
      }
    }

    if (!authorizedChannels && !authorizedRoles && !authorizedUsers) {
      // The document type does not define any channels, roles or users that apply to this particular write operation type, so fall back to
      // Sync Gateway's default behaviour for an empty channel list: 403 Forbidden for requests via the public API and either 200 OK or 201
      // Created for requests via the admin API. That way, the admin API will always be able to create, replace or remove documents,
      // regardless of their authorized channels, roles or users, as intended.
      requireAccess([ ]);
    } else if (!channelMatch && !roleMatch && !userMatch) {
      // None of the authorization methods (e.g. channels, roles, users) succeeded
      var errorMessage = 'missing channel access';
      var error = new Error(errorMessage);
      error.forbidden = errorMessage;

      throw error;
    }

    return {
      channels: authorizedChannels,
      roles: authorizedRoles,
      users: authorizedUsers
    };
  }

  return {
    authorize: authorize,
    getAllDocChannels: getAllDocChannels
  };
}


/**
 * Module dependencies.
 */

var EventEmitter = require('events').EventEmitter;
var spawn = require('child_process').spawn;
var path = require('path');
var dirname = path.dirname;
var basename = path.basename;
var fs = require('fs');

/**
 * Inherit `Command` from `EventEmitter.prototype`.
 */

require('util').inherits(Command, EventEmitter);

/**
 * Expose the root command.
 */

exports = module.exports = new Command();

/**
 * Expose `Command`.
 */

exports.Command = Command;

/**
 * Expose `Option`.
 */

exports.Option = Option;

/**
 * Initialize a new `Option` with the given `flags` and `description`.
 *
 * @param {String} flags
 * @param {String} description
 * @api public
 */

function Option(flags, description) {
  this.flags = flags;
  this.required = flags.indexOf('<') >= 0;
  this.optional = flags.indexOf('[') >= 0;
  this.bool = flags.indexOf('-no-') === -1;
  flags = flags.split(/[ ,|]+/);
  if (flags.length > 1 && !/^[[<]/.test(flags[1])) this.short = flags.shift();
  this.long = flags.shift();
  this.description = description || '';
}

/**
 * Return option name.
 *
 * @return {String}
 * @api private
 */

Option.prototype.name = function() {
  return this.long
    .replace('--', '')
    .replace('no-', '');
};

/**
 * Return option name, in a camelcase format that can be used
 * as a object attribute key.
 *
 * @return {String}
 * @api private
 */

Option.prototype.attributeName = function() {
  return camelcase(this.name());
};

/**
 * Check if `arg` matches the short or long flag.
 *
 * @param {String} arg
 * @return {Boolean}
 * @api private
 */

Option.prototype.is = function(arg) {
  return this.short === arg || this.long === arg;
};

/**
 * Initialize a new `Command`.
 *
 * @param {String} name
 * @api public
 */

function Command(name) {
  this.commands = [];
  this.options = [];
  this._execs = {};
  this._allowUnknownOption = false;
  this._args = [];
  this._name = name || '';
}

/**
 * Add command `name`.
 *
 * The `.action()` callback is invoked when the
 * command `name` is specified via __ARGV__,
 * and the remaining arguments are applied to the
 * function for access.
 *
 * When the `name` is "*" an un-matched command
 * will be passed as the first arg, followed by
 * the rest of __ARGV__ remaining.
 *
 * Examples:
 *
 *      program
 *        .version('0.0.1')
 *        .option('-C, --chdir <path>', 'change the working directory')
 *        .option('-c, --config <path>', 'set config path. defaults to ./deploy.conf')
 *        .option('-T, --no-tests', 'ignore test hook')
 *
 *      program
 *        .command('setup')
 *        .description('run remote setup commands')
 *        .action(function() {
 *          console.log('setup');
 *        });
 *
 *      program
 *        .command('exec <cmd>')
 *        .description('run the given remote command')
 *        .action(function(cmd) {
 *          console.log('exec "%s"', cmd);
 *        });
 *
 *      program
 *        .command('teardown <dir> [otherDirs...]')
 *        .description('run teardown commands')
 *        .action(function(dir, otherDirs) {
 *          console.log('dir "%s"', dir);
 *          if (otherDirs) {
 *            otherDirs.forEach(function (oDir) {
 *              console.log('dir "%s"', oDir);
 *            });
 *          }
 *        });
 *
 *      program
 *        .command('*')
 *        .description('deploy the given env')
 *        .action(function(env) {
 *          console.log('deploying "%s"', env);
 *        });
 *
 *      program.parse(process.argv);
  *
 * @param {String} name
 * @param {String} [desc] for git-style sub-commands
 * @return {Command} the new command
 * @api public
 */

Command.prototype.command = function(name, desc, opts) {
  if (typeof desc === 'object' && desc !== null) {
    opts = desc;
    desc = null;
  }
  opts = opts || {};
  var args = name.split(/ +/);
  var cmd = new Command(args.shift());

  if (desc) {
    cmd.description(desc);
    this.executables = true;
    this._execs[cmd._name] = true;
    if (opts.isDefault) this.defaultExecutable = cmd._name;
  }
  cmd._noHelp = !!opts.noHelp;
  this.commands.push(cmd);
  cmd.parseExpectedArgs(args);
  cmd.parent = this;

  if (desc) return this;
  return cmd;
};

/**
 * Define argument syntax for the top-level command.
 *
 * @api public
 */

Command.prototype.arguments = function(desc) {
  return this.parseExpectedArgs(desc.split(/ +/));
};

/**
 * Add an implicit `help [cmd]` subcommand
 * which invokes `--help` for the given command.
 *
 * @api private
 */

Command.prototype.addImplicitHelpCommand = function() {
  this.command('help [cmd]', 'display help for [cmd]');
};

/**
 * Parse expected `args`.
 *
 * For example `["[type]"]` becomes `[{ required: false, name: 'type' }]`.
 *
 * @param {Array} args
 * @return {Command} for chaining
 * @api public
 */

Command.prototype.parseExpectedArgs = function(args) {
  if (!args.length) return;
  var self = this;
  args.forEach(function(arg) {
    var argDetails = {
      required: false,
      name: '',
      variadic: false
    };

    switch (arg[0]) {
      case '<':
        argDetails.required = true;
        argDetails.name = arg.slice(1, -1);
        break;
      case '[':
        argDetails.name = arg.slice(1, -1);
        break;
    }

    if (argDetails.name.length > 3 && argDetails.name.slice(-3) === '...') {
      argDetails.variadic = true;
      argDetails.name = argDetails.name.slice(0, -3);
    }
    if (argDetails.name) {
      self._args.push(argDetails);
    }
  });
  return this;
};

/**
 * Register callback `fn` for the command.
 *
 * Examples:
 *
 *      program
 *        .command('help')
 *        .description('display verbose help')
 *        .action(function() {
 *           // output help here
 *        });
 *
 * @param {Function} fn
 * @return {Command} for chaining
 * @api public
 */

Command.prototype.action = function(fn) {
  var self = this;
  var listener = function(args, unknown) {
    // Parse any so-far unknown options
    args = args || [];
    unknown = unknown || [];

    var parsed = self.parseOptions(unknown);

    // Output help if necessary
    outputHelpIfNecessary(self, parsed.unknown);

    // If there are still any unknown options, then we simply
    // die, unless someone asked for help, in which case we give it
    // to them, and then we die.
    if (parsed.unknown.length > 0) {
      self.unknownOption(parsed.unknown[0]);
    }

    // Leftover arguments need to be pushed back. Fixes issue #56
    if (parsed.args.length) args = parsed.args.concat(args);

    self._args.forEach(function(arg, i) {
      if (arg.required && args[i] == null) {
        self.missingArgument(arg.name);
      } else if (arg.variadic) {
        if (i !== self._args.length - 1) {
          self.variadicArgNotLast(arg.name);
        }

        args[i] = args.splice(i);
      }
    });

    // Always append ourselves to the end of the arguments,
    // to make sure we match the number of arguments the user
    // expects
    if (self._args.length) {
      args[self._args.length] = self;
    } else {
      args.push(self);
    }

    fn.apply(self, args);
  };
  var parent = this.parent || this;
  var name = parent === this ? '*' : this._name;
  parent.on('command:' + name, listener);
  if (this._alias) parent.on('command:' + this._alias, listener);
  return this;
};

/**
 * Define option with `flags`, `description` and optional
 * coercion `fn`.
 *
 * The `flags` string should contain both the short and long flags,
 * separated by comma, a pipe or space. The following are all valid
 * all will output this way when `--help` is used.
 *
 *    "-p, --pepper"
 *    "-p|--pepper"
 *    "-p --pepper"
 *
 * Examples:
 *
 *     // simple boolean defaulting to false
 *     program.option('-p, --pepper', 'add pepper');
 *
 *     --pepper
 *     program.pepper
 *     // => Boolean
 *
 *     // simple boolean defaulting to true
 *     program.option('-C, --no-cheese', 'remove cheese');
 *
 *     program.cheese
 *     // => true
 *
 *     --no-cheese
 *     program.cheese
 *     // => false
 *
 *     // required argument
 *     program.option('-C, --chdir <path>', 'change the working directory');
 *
 *     --chdir /tmp
 *     program.chdir
 *     // => "/tmp"
 *
 *     // optional argument
 *     program.option('-c, --cheese [type]', 'add cheese [marble]');
 *
 * @param {String} flags
 * @param {String} description
 * @param {Function|*} [fn] or default
 * @param {*} [defaultValue]
 * @return {Command} for chaining
 * @api public
 */

Command.prototype.option = function(flags, description, fn, defaultValue) {
  var self = this,
    option = new Option(flags, description),
    oname = option.name(),
    name = option.attributeName();

  // default as 3rd arg
  if (typeof fn !== 'function') {
    if (fn instanceof RegExp) {
      var regex = fn;
      fn = function(val, def) {
        var m = regex.exec(val);
        return m ? m[0] : def;
      };
    } else {
      defaultValue = fn;
      fn = null;
    }
  }

  // preassign default value only for --no-*, [optional], or <required>
  if (!option.bool || option.optional || option.required) {
    // when --no-* we make sure default is true
    if (!option.bool) defaultValue = true;
    // preassign only if we have a default
    if (defaultValue !== undefined) {
      self[name] = defaultValue;
      option.defaultValue = defaultValue;
    }
  }

  // register the option
  this.options.push(option);

  // when it's passed assign the value
  // and conditionally invoke the callback
  this.on('option:' + oname, function(val) {
    // coercion
    if (val !== null && fn) {
      val = fn(val, self[name] === undefined ? defaultValue : self[name]);
    }

    // unassigned or bool
    if (typeof self[name] === 'boolean' || typeof self[name] === 'undefined') {
      // if no value, bool true, and we have a default, then use it!
      if (val == null) {
        self[name] = option.bool
          ? defaultValue || true
          : false;
      } else {
        self[name] = val;
      }
    } else if (val !== null) {
      // reassign
      self[name] = val;
    }
  });

  return this;
};

/**
 * Allow unknown options on the command line.
 *
 * @param {Boolean} arg if `true` or omitted, no error will be thrown
 * for unknown options.
 * @api public
 */
Command.prototype.allowUnknownOption = function(arg) {
  this._allowUnknownOption = arguments.length === 0 || arg;
  return this;
};

/**
 * Parse `argv`, settings options and invoking commands when defined.
 *
 * @param {Array} argv
 * @return {Command} for chaining
 * @api public
 */

Command.prototype.parse = function(argv) {
  // implicit help
  if (this.executables) this.addImplicitHelpCommand();

  // store raw args
  this.rawArgs = argv;

  // guess name
  this._name = this._name || basename(argv[1], '.js');

  // github-style sub-commands with no sub-command
  if (this.executables && argv.length < 3 && !this.defaultExecutable) {
    // this user needs help
    argv.push('--help');
  }

  // process argv
  var parsed = this.parseOptions(this.normalize(argv.slice(2)));
  var args = this.args = parsed.args;

  var result = this.parseArgs(this.args, parsed.unknown);

  // executable sub-commands
  var name = result.args[0];

  var aliasCommand = null;
  // check alias of sub commands
  if (name) {
    aliasCommand = this.commands.filter(function(command) {
      return command.alias() === name;
    })[0];
  }

  if (this._execs[name] && typeof this._execs[name] !== 'function') {
    return this.executeSubCommand(argv, args, parsed.unknown);
  } else if (aliasCommand) {
    // is alias of a subCommand
    args[0] = aliasCommand._name;
    return this.executeSubCommand(argv, args, parsed.unknown);
  } else if (this.defaultExecutable) {
    // use the default subcommand
    args.unshift(this.defaultExecutable);
    return this.executeSubCommand(argv, args, parsed.unknown);
  }

  return result;
};

/**
 * Execute a sub-command executable.
 *
 * @param {Array} argv
 * @param {Array} args
 * @param {Array} unknown
 * @api private
 */

Command.prototype.executeSubCommand = function(argv, args, unknown) {
  args = args.concat(unknown);

  if (!args.length) this.help();
  if (args[0] === 'help' && args.length === 1) this.help();

  // <cmd> --help
  if (args[0] === 'help') {
    args[0] = args[1];
    args[1] = '--help';
  }

  // executable
  var f = argv[1];
  // name of the subcommand, link `pm-install`
  var bin = basename(f, path.extname(f)) + '-' + args[0];

  // In case of globally installed, get the base dir where executable
  //  subcommand file should be located at
  var baseDir,
    link = fs.lstatSync(f).isSymbolicLink() ? fs.readlinkSync(f) : f;

  // when symbolink is relative path
  if (link !== f && link.charAt(0) !== '/') {
    link = path.join(dirname(f), link);
  }
  baseDir = dirname(link);

  // prefer local `./<bin>` to bin in the $PATH
  var localBin = path.join(baseDir, bin);

  // whether bin file is a js script with explicit `.js` or `.ts` extension
  var isExplicitJS = false;
  if (exists(localBin + '.js')) {
    bin = localBin + '.js';
    isExplicitJS = true;
  } else if (exists(localBin + '.ts')) {
    bin = localBin + '.ts';
    isExplicitJS = true;
  } else if (exists(localBin)) {
    bin = localBin;
  }

  args = args.slice(1);

  var proc;
  if (process.platform !== 'win32') {
    if (isExplicitJS) {
      args.unshift(bin);
      // add executable arguments to spawn
      args = (process.execArgv || []).concat(args);

      proc = spawn(process.argv[0], args, { stdio: 'inherit', customFds: [0, 1, 2] });
    } else {
      proc = spawn(bin, args, { stdio: 'inherit', customFds: [0, 1, 2] });
    }
  } else {
    args.unshift(bin);
    proc = spawn(process.execPath, args, { stdio: 'inherit' });
  }

  var signals = ['SIGUSR1', 'SIGUSR2', 'SIGTERM', 'SIGINT', 'SIGHUP'];
  signals.forEach(function(signal) {
    process.on(signal, function() {
      if (proc.killed === false && proc.exitCode === null) {
        proc.kill(signal);
      }
    });
  });
  proc.on('close', process.exit.bind(process));
  proc.on('error', function(err) {
    if (err.code === 'ENOENT') {
      console.error('error: %s(1) does not exist, try --help', bin);
    } else if (err.code === 'EACCES') {
      console.error('error: %s(1) not executable. try chmod or run with root', bin);
    }
    process.exit(1);
  });

  // Store the reference to the child process
  this.runningCommand = proc;
};

/**
 * Normalize `args`, splitting joined short flags. For example
 * the arg "-abc" is equivalent to "-a -b -c".
 * This also normalizes equal sign and splits "--abc=def" into "--abc def".
 *
 * @param {Array} args
 * @return {Array}
 * @api private
 */

Command.prototype.normalize = function(args) {
  var ret = [],
    arg,
    lastOpt,
    index;

  for (var i = 0, len = args.length; i < len; ++i) {
    arg = args[i];
    if (i > 0) {
      lastOpt = this.optionFor(args[i - 1]);
    }

    if (arg === '--') {
      // Honor option terminator
      ret = ret.concat(args.slice(i));
      break;
    } else if (lastOpt && lastOpt.required) {
      ret.push(arg);
    } else if (arg.length > 1 && arg[0] === '-' && arg[1] !== '-') {
      arg.slice(1).split('').forEach(function(c) {
        ret.push('-' + c);
      });
    } else if (/^--/.test(arg) && ~(index = arg.indexOf('='))) {
      ret.push(arg.slice(0, index), arg.slice(index + 1));
    } else {
      ret.push(arg);
    }
  }

  return ret;
};

/**
 * Parse command `args`.
 *
 * When listener(s) are available those
 * callbacks are invoked, otherwise the "*"
 * event is emitted and those actions are invoked.
 *
 * @param {Array} args
 * @return {Command} for chaining
 * @api private
 */

Command.prototype.parseArgs = function(args, unknown) {
  var name;

  if (args.length) {
    name = args[0];
    if (this.listeners('command:' + name).length) {
      this.emit('command:' + args.shift(), args, unknown);
    } else {
      this.emit('command:*', args);
    }
  } else {
    outputHelpIfNecessary(this, unknown);

    // If there were no args and we have unknown options,
    // then they are extraneous and we need to error.
    if (unknown.length > 0) {
      this.unknownOption(unknown[0]);
    }
    if (this.commands.length === 0 &&
        this._args.filter(function(a) { return a.required }).length === 0) {
      this.emit('command:*');
    }
  }

  return this;
};

/**
 * Return an option matching `arg` if any.
 *
 * @param {String} arg
 * @return {Option}
 * @api private
 */

Command.prototype.optionFor = function(arg) {
  for (var i = 0, len = this.options.length; i < len; ++i) {
    if (this.options[i].is(arg)) {
      return this.options[i];
    }
  }
};

/**
 * Parse options from `argv` returning `argv`
 * void of these options.
 *
 * @param {Array} argv
 * @return {Array}
 * @api public
 */

Command.prototype.parseOptions = function(argv) {
  var args = [],
    len = argv.length,
    literal,
    option,
    arg;

  var unknownOptions = [];

  // parse options
  for (var i = 0; i < len; ++i) {
    arg = argv[i];

    // literal args after --
    if (literal) {
      args.push(arg);
      continue;
    }

    if (arg === '--') {
      literal = true;
      continue;
    }

    // find matching Option
    option = this.optionFor(arg);

    // option is defined
    if (option) {
      // requires arg
      if (option.required) {
        arg = argv[++i];
        if (arg == null) return this.optionMissingArgument(option);
        this.emit('option:' + option.name(), arg);
      // optional arg
      } else if (option.optional) {
        arg = argv[i + 1];
        if (arg == null || (arg[0] === '-' && arg !== '-')) {
          arg = null;
        } else {
          ++i;
        }
        this.emit('option:' + option.name(), arg);
      // bool
      } else {
        this.emit('option:' + option.name());
      }
      continue;
    }

    // looks like an option
    if (arg.length > 1 && arg[0] === '-') {
      unknownOptions.push(arg);

      // If the next argument looks like it might be
      // an argument for this option, we pass it on.
      // If it isn't, then it'll simply be ignored
      if ((i + 1) < argv.length && argv[i + 1][0] !== '-') {
        unknownOptions.push(argv[++i]);
      }
      continue;
    }

    // arg
    args.push(arg);
  }

  return { args: args, unknown: unknownOptions };
};

/**
 * Return an object containing options as key-value pairs
 *
 * @return {Object}
 * @api public
 */
Command.prototype.opts = function() {
  var result = {},
    len = this.options.length;

  for (var i = 0; i < len; i++) {
    var key = this.options[i].attributeName();
    result[key] = key === this._versionOptionName ? this._version : this[key];
  }
  return result;
};

/**
 * Argument `name` is missing.
 *
 * @param {String} name
 * @api private
 */

Command.prototype.missingArgument = function(name) {
  console.error("error: missing required argument `%s'", name);
  process.exit(1);
};

/**
 * `Option` is missing an argument, but received `flag` or nothing.
 *
 * @param {String} option
 * @param {String} flag
 * @api private
 */

Command.prototype.optionMissingArgument = function(option, flag) {
  if (flag) {
    console.error("error: option `%s' argument missing, got `%s'", option.flags, flag);
  } else {
    console.error("error: option `%s' argument missing", option.flags);
  }
  process.exit(1);
};

/**
 * Unknown option `flag`.
 *
 * @param {String} flag
 * @api private
 */

Command.prototype.unknownOption = function(flag) {
  if (this._allowUnknownOption) return;
  console.error("error: unknown option `%s'", flag);
  process.exit(1);
};

/**
 * Variadic argument with `name` is not the last argument as required.
 *
 * @param {String} name
 * @api private
 */

Command.prototype.variadicArgNotLast = function(name) {
  console.error("error: variadic arguments must be last `%s'", name);
  process.exit(1);
};

/**
 * Set the program version to `str`.
 *
 * This method auto-registers the "-V, --version" flag
 * which will print the version number when passed.
 *
 * @param {String} str
 * @param {String} [flags]
 * @return {Command} for chaining
 * @api public
 */

Command.prototype.version = function(str, flags) {
  if (arguments.length === 0) return this._version;
  this._version = str;
  flags = flags || '-V, --version';
  var versionOption = new Option(flags, 'output the version number');
  this._versionOptionName = versionOption.long.substr(2) || 'version';
  this.options.push(versionOption);
  this.on('option:' + this._versionOptionName, function() {
    process.stdout.write(str + '\n');
    process.exit(0);
  });
  return this;
};

/**
 * Set the description to `str`.
 *
 * @param {String} str
 * @param {Object} argsDescription
 * @return {String|Command}
 * @api public
 */

Command.prototype.description = function(str, argsDescription) {
  if (arguments.length === 0) return this._description;
  this._description = str;
  this._argsDescription = argsDescription;
  return this;
};

/**
 * Set an alias for the command
 *
 * @param {String} alias
 * @return {String|Command}
 * @api public
 */

Command.prototype.alias = function(alias) {
  var command = this;
  if (this.commands.length !== 0) {
    command = this.commands[this.commands.length - 1];
  }

  if (arguments.length === 0) return command._alias;

  if (alias === command._name) throw new Error('Command alias can\'t be the same as its name');

  command._alias = alias;
  return this;
};

/**
 * Set / get the command usage `str`.
 *
 * @param {String} str
 * @return {String|Command}
 * @api public
 */

Command.prototype.usage = function(str) {
  var args = this._args.map(function(arg) {
    return humanReadableArgName(arg);
  });

  var usage = '[options]' +
    (this.commands.length ? ' [command]' : '') +
    (this._args.length ? ' ' + args.join(' ') : '');

  if (arguments.length === 0) return this._usage || usage;
  this._usage = str;

  return this;
};

/**
 * Get or set the name of the command
 *
 * @param {String} str
 * @return {String|Command}
 * @api public
 */

Command.prototype.name = function(str) {
  if (arguments.length === 0) return this._name;
  this._name = str;
  return this;
};

/**
 * Return prepared commands.
 *
 * @return {Array}
 * @api private
 */

Command.prototype.prepareCommands = function() {
  return this.commands.filter(function(cmd) {
    return !cmd._noHelp;
  }).map(function(cmd) {
    var args = cmd._args.map(function(arg) {
      return humanReadableArgName(arg);
    }).join(' ');

    return [
      cmd._name +
        (cmd._alias ? '|' + cmd._alias : '') +
        (cmd.options.length ? ' [options]' : '') +
        (args ? ' ' + args : ''),
      cmd._description
    ];
  });
};

/**
 * Return the largest command length.
 *
 * @return {Number}
 * @api private
 */

Command.prototype.largestCommandLength = function() {
  var commands = this.prepareCommands();
  return commands.reduce(function(max, command) {
    return Math.max(max, command[0].length);
  }, 0);
};

/**
 * Return the largest option length.
 *
 * @return {Number}
 * @api private
 */

Command.prototype.largestOptionLength = function() {
  var options = [].slice.call(this.options);
  options.push({
    flags: '-h, --help'
  });
  return options.reduce(function(max, option) {
    return Math.max(max, option.flags.length);
  }, 0);
};

/**
 * Return the largest arg length.
 *
 * @return {Number}
 * @api private
 */

Command.prototype.largestArgLength = function() {
  return this._args.reduce(function(max, arg) {
    return Math.max(max, arg.name.length);
  }, 0);
};

/**
 * Return the pad width.
 *
 * @return {Number}
 * @api private
 */

Command.prototype.padWidth = function() {
  var width = this.largestOptionLength();
  if (this._argsDescription && this._args.length) {
    if (this.largestArgLength() > width) {
      width = this.largestArgLength();
    }
  }

  if (this.commands && this.commands.length) {
    if (this.largestCommandLength() > width) {
      width = this.largestCommandLength();
    }
  }

  return width;
};

/**
 * Return help for options.
 *
 * @return {String}
 * @api private
 */

Command.prototype.optionHelp = function() {
  var width = this.padWidth();

  // Append the help information
  return this.options.map(function(option) {
    return pad(option.flags, width) + '  ' + option.description +
      ((option.bool && option.defaultValue !== undefined) ? ' (default: ' + JSON.stringify(option.defaultValue) + ')' : '');
  }).concat([pad('-h, --help', width) + '  ' + 'output usage information'])
    .join('\n');
};

/**
 * Return command help documentation.
 *
 * @return {String}
 * @api private
 */

Command.prototype.commandHelp = function() {
  if (!this.commands.length) return '';

  var commands = this.prepareCommands();
  var width = this.padWidth();

  return [
    'Commands:',
    commands.map(function(cmd) {
      var desc = cmd[1] ? '  ' + cmd[1] : '';
      return (desc ? pad(cmd[0], width) : cmd[0]) + desc;
    }).join('\n').replace(/^/gm, '  '),
    ''
  ].join('\n');
};

/**
 * Return program help documentation.
 *
 * @return {String}
 * @api private
 */

Command.prototype.helpInformation = function() {
  var desc = [];
  if (this._description) {
    desc = [
      this._description,
      ''
    ];

    var argsDescription = this._argsDescription;
    if (argsDescription && this._args.length) {
      var width = this.padWidth();
      desc.push('Arguments:');
      desc.push('');
      this._args.forEach(function(arg) {
        desc.push('  ' + pad(arg.name, width) + '  ' + argsDescription[arg.name]);
      });
      desc.push('');
    }
  }

  var cmdName = this._name;
  if (this._alias) {
    cmdName = cmdName + '|' + this._alias;
  }
  var usage = [
    'Usage: ' + cmdName + ' ' + this.usage(),
    ''
  ];

  var cmds = [];
  var commandHelp = this.commandHelp();
  if (commandHelp) cmds = [commandHelp];

  var options = [
    'Options:',
    '' + this.optionHelp().replace(/^/gm, '  '),
    ''
  ];

  return usage
    .concat(desc)
    .concat(options)
    .concat(cmds)
    .join('\n');
};

/**
 * Output help information for this command
 *
 * @api public
 */

Command.prototype.outputHelp = function(cb) {
  if (!cb) {
    cb = function(passthru) {
      return passthru;
    };
  }
  process.stdout.write(cb(this.helpInformation()));
  this.emit('--help');
};

/**
 * Output help information and exit.
 *
 * @api public
 */

Command.prototype.help = function(cb) {
  this.outputHelp(cb);
  process.exit();
};

/**
 * Camel-case the given `flag`
 *
 * @param {String} flag
 * @return {String}
 * @api private
 */

function camelcase(flag) {
  return flag.split('-').reduce(function(str, word) {
    return str + word[0].toUpperCase() + word.slice(1);
  });
}

/**
 * Pad `str` to `width`.
 *
 * @param {String} str
 * @param {Number} width
 * @return {String}
 * @api private
 */

function pad(str, width) {
  var len = Math.max(0, width - str.length);
  return str + Array(len + 1).join(' ');
}

/**
 * Output help information if necessary
 *
 * @param {Command} command to output help for
 * @param {Array} array of options to search for -h or --help
 * @api private
 */

function outputHelpIfNecessary(cmd, options) {
  options = options || [];
  for (var i = 0; i < options.length; i++) {
    if (options[i] === '--help' || options[i] === '-h') {
      cmd.outputHelp();
      process.exit(0);
    }
  }
}

/**
 * Takes an argument an returns its human readable equivalent for help usage.
 *
 * @param {Object} arg
 * @return {String}
 * @api private
 */

function humanReadableArgName(arg) {
  var nameOutput = arg.name + (arg.variadic === true ? '...' : '');

  return arg.required
    ? '<' + nameOutput + '>'
    : '[' + nameOutput + ']';
}

// for versions before node v0.8 when there weren't `fs.existsSync`
function exists(file) {
  try {
    if (fs.statSync(file).isFile()) {
      return true;
    }
  } catch (e) {
    return false;
  }
}


function documentPropertiesValidationModule(utils, simpleTypeFilter, typeIdValidator) {
  var timeModule = importSyncFunctionFragment('time-module.js')(utils);
  var comparisonModule = importSyncFunctionFragment('comparison-module.js')(utils, buildItemPath, timeModule);

  return {
    validateProperties: function(doc, oldDoc, docDefinition) {
      var attachmentsValidationModule =
        importSyncFunctionFragment('attachments-validation-module.js')(utils, buildItemPath, resolveItemConstraint);

      var validationErrors = [ ];
      var itemStack = [
        {
          itemValue: doc,
          oldItemValue: oldDoc,
          itemName: null
        }
      ];

      var resolvedPropertyValidators = utils.resolveDocumentConstraint(docDefinition.propertyValidators);

      // Ensure that, if the document type uses the simple type filter, it supports the "type" property
      if (docDefinition.typeFilter === simpleTypeFilter && utils.isValueNullOrUndefined(resolvedPropertyValidators.type)) {
        resolvedPropertyValidators.type = typeIdValidator;
      }

      // Execute each of the document's property validators while ignoring internal document properties at the root level
      validateObjectProperties(
        resolvedPropertyValidators,
        utils.resolveDocumentConstraint(docDefinition.allowUnknownProperties),
        true);

      if (doc._attachments) {
        storeOptionalValidationErrors(attachmentsValidationModule.validateAttachments(doc, docDefinition));
      }

      return validationErrors;

      // The following functions are nested within this function so they can share access to the doc, oldDoc and
      // validationErrors params and the attachmentReferenceValidators and itemStack variables
      function validateObjectProperties(propertyValidators, allowUnknownProperties, ignoreInternalProperties) {
        var currentItemEntry = itemStack[itemStack.length - 1];
        var objectValue = currentItemEntry.itemValue;
        var oldObjectValue = currentItemEntry.oldItemValue;

        var supportedProperties = [ ];
        for (var propertyValidatorName in propertyValidators) {
          var validator = propertyValidators[propertyValidatorName];
          if (utils.isValueNullOrUndefined(validator) || utils.isValueNullOrUndefined(resolveItemConstraint(validator.type))) {
            // Skip over non-validator fields/properties
            continue;
          }

          var propertyValue = objectValue[propertyValidatorName];

          var oldPropertyValue;
          if (!utils.isValueNullOrUndefined(oldObjectValue)) {
            oldPropertyValue = oldObjectValue[propertyValidatorName];
          }

          supportedProperties.push(propertyValidatorName);

          itemStack.push({
            itemValue: propertyValue,
            oldItemValue: oldPropertyValue,
            itemName: propertyValidatorName
          });

          validateItemValue(validator);

          itemStack.pop();
        }

        // Verify there are no unsupported properties in the object
        if (!allowUnknownProperties) {
          for (var propertyName in objectValue) {
            if (ignoreInternalProperties && propertyName.indexOf('_') === 0) {
              // These properties are special cases that should always be allowed - generally only applied at the root
              // level of the document
              continue;
            }

            if (supportedProperties.indexOf(propertyName) < 0) {
              var objectPath = buildItemPath(itemStack);
              var fullPropertyPath = objectPath ? objectPath + '.' + propertyName : propertyName;
              validationErrors.push('property "' + fullPropertyPath + '" is not supported');
            }
          }
        }
      }

      function validateItemValue(validator) {
        var currentItemEntry = itemStack[itemStack.length - 1];
        var itemValue = currentItemEntry.itemValue;
        var validatorType = resolveItemConstraint(validator.type);

        if (validatorType === 'conditional') {
          return performConditionalValidation(validator);
        } else if (shouldSkipItemValidation(validator, validatorType)) {
          return;
        }

        if (validator.customValidation) {
          performCustomValidation(validator);
        }

        if (!utils.isDocumentMissingOrDeleted(oldDoc)) {
          if (resolveItemConstraint(validator.immutable)) {
            storeOptionalValidationErrors(comparisonModule.validateImmutable(itemStack, false, validatorType));
          }

          if (resolveItemConstraint(validator.immutableStrict)) {
            // Omitting validator type forces it to perform strict equality comparisons for specialized string types
            // (e.g. "date", "datetime", "time", "timezone", "uuid")
            storeOptionalValidationErrors(comparisonModule.validateImmutable(itemStack, false));
          }

          if (resolveItemConstraint(validator.immutableWhenSet)) {
            storeOptionalValidationErrors(comparisonModule.validateImmutable(itemStack, true, validatorType));
          }

          if (resolveItemConstraint(validator.immutableWhenSetStrict)) {
            // Omitting validator type forces it to perform strict equality comparisons for specialized string types
            // (e.g. "date", "datetime", "time", "timezone", "uuid")
            storeOptionalValidationErrors(comparisonModule.validateImmutable(itemStack, true));
          }
        }

        var expectedEqualValue = resolveItemConstraint(validator.mustEqual);
        if (expectedEqualValue !== void 0) {
          storeOptionalValidationErrors(comparisonModule.validateEquality(itemStack, expectedEqualValue, validatorType));
        }

        var expectedStrictEqualValue = resolveItemConstraint(validator.mustEqualStrict);
        if (expectedStrictEqualValue !== void 0) {
          // Omitting validator type forces it to perform strict equality comparisons for specialized string types
          // (e.g. "date", "datetime", "time", "timezone", "uuid")
          storeOptionalValidationErrors(comparisonModule.validateEquality(itemStack, expectedStrictEqualValue));
        }

        if (!utils.isValueNullOrUndefined(itemValue)) {
          if (resolveItemConstraint(validator.mustNotBeEmpty) && itemValue.length < 1) {
            validationErrors.push('item "' + buildItemPath(itemStack) + '" must not be empty');
          }

          var minimumValue = resolveItemConstraint(validator.minimumValue);
          if (!utils.isValueNullOrUndefined(minimumValue)) {
            storeOptionalValidationErrors(
              comparisonModule.validateMinValueInclusiveConstraint(itemStack, minimumValue, validatorType));
          }

          var minimumValueExclusive = resolveItemConstraint(validator.minimumValueExclusive);
          if (!utils.isValueNullOrUndefined(minimumValueExclusive)) {
            storeOptionalValidationErrors(comparisonModule.validateMinValueExclusiveConstraint(
              itemStack,
              minimumValueExclusive,
              validatorType));
          }

          var maximumValue = resolveItemConstraint(validator.maximumValue);
          if (!utils.isValueNullOrUndefined(maximumValue)) {
            storeOptionalValidationErrors(
              comparisonModule.validateMaxValueInclusiveConstraint(itemStack, maximumValue, validatorType));
          }

          var maximumValueExclusive = resolveItemConstraint(validator.maximumValueExclusive);
          if (!utils.isValueNullOrUndefined(maximumValueExclusive)) {
            storeOptionalValidationErrors(comparisonModule.validateMaxValueExclusiveConstraint(
              itemStack,
              maximumValueExclusive,
              validatorType));
          }

          var minimumLength = resolveItemConstraint(validator.minimumLength);
          if (!utils.isValueNullOrUndefined(minimumLength) && itemValue.length < minimumLength) {
            validationErrors.push('length of item "' + buildItemPath(itemStack) + '" must not be less than ' + minimumLength);
          }

          var maximumLength = resolveItemConstraint(validator.maximumLength);
          if (!utils.isValueNullOrUndefined(maximumLength) && itemValue.length > maximumLength) {
            validationErrors.push('length of item "' + buildItemPath(itemStack) + '" must not be greater than ' + maximumLength);
          }

          switch (validatorType) {
            case 'any':
              // Any type of value is allowed - no further validation required
              break;
            case 'string':
              if (typeof itemValue !== 'string') {
                validationErrors.push('item "' + buildItemPath(itemStack) + '" must be a string');
              } else {
                validateString(validator);
              }
              break;
            case 'integer':
              if (!utils.isValueAnInteger(itemValue)) {
                validationErrors.push('item "' + buildItemPath(itemStack) + '" must be an integer');
              }
              break;
            case 'float':
              if (typeof itemValue !== 'number') {
                validationErrors.push('item "' + buildItemPath(itemStack) + '" must be a floating point or integer number');
              }
              break;
            case 'boolean':
              if (typeof itemValue !== 'boolean') {
                validationErrors.push('item "' + buildItemPath(itemStack) + '" must be a boolean');
              }
              break;
            case 'datetime':
              if (typeof itemValue !== 'string' || !timeModule.isIso8601DateTimeString(itemValue)) {
                validationErrors.push('item "' + buildItemPath(itemStack) + '" must be an ECMAScript simplified ISO 8601 date string with optional time and time zone components');
              }
              break;
            case 'date':
              if (typeof itemValue !== 'string' || !timeModule.isIso8601DateString(itemValue)) {
                validationErrors.push('item "' + buildItemPath(itemStack) + '" must be an ECMAScript simplified ISO 8601 date string with no time or time zone components');
              }
              break;
            case 'time':
              if (typeof itemValue !== 'string' || !timeModule.isIso8601TimeString(itemValue)) {
                validationErrors.push('item "' + buildItemPath(itemStack) + '" must be an ECMAScript simplified ISO 8601 time string with no date or time zone components');
              }
              break;
            case 'timezone':
              if (typeof itemValue !== 'string' || !timeModule.isIso8601TimeZoneString(itemValue)) {
                validationErrors.push('item "' + buildItemPath(itemStack) + '" must be an ECMAScript simplified ISO 8601 time zone string');
              }
              break;
            case 'enum':
              var enumPredefinedValues = resolveItemConstraint(validator.predefinedValues);
              if (!Array.isArray(enumPredefinedValues)) {
                validationErrors.push('item "' + buildItemPath(itemStack) + '" belongs to an enum that has no predefined values');
              } else if (enumPredefinedValues.indexOf(itemValue) < 0) {
                validationErrors.push('item "' + buildItemPath(itemStack) + '" must be one of the predefined values: ' + enumPredefinedValues.join(','));
              }
              break;
            case 'uuid':
              if (!isValueAUuid(itemValue)) {
                validationErrors.push('item "' + buildItemPath(itemStack) + '" must be a UUID string');
              }
              break;
            case 'object':
              var childPropertyValidators = resolveItemConstraint(validator.propertyValidators);
              if (typeof itemValue !== 'object' || Array.isArray(itemValue)) {
                validationErrors.push('item "' + buildItemPath(itemStack) + '" must be an object');
              } else if (childPropertyValidators) {
                validateObjectProperties(childPropertyValidators, resolveItemConstraint(validator.allowUnknownProperties));
              }
              break;
            case 'array':
              validateArray(resolveItemConstraint(validator.arrayElementsValidator));
              break;
            case 'hashtable':
              validateHashtable(validator);
              break;
            case 'attachmentReference':
              storeOptionalValidationErrors(attachmentsValidationModule.validateAttachmentReference(doc, validator, itemStack));
              break;
            default:
              // This is not a document validation error; the item validator is configured incorrectly and must be fixed
              throw new Error('No data type defined for validator of item "' + buildItemPath(itemStack) + '"');
          }
        } else if (resolveItemConstraint(validator.required)) {
          // The item has no value (either it's null or undefined), but the validator indicates it is required
          validationErrors.push('item "' + buildItemPath(itemStack) + '" must not be null or missing');
        } else if (resolveItemConstraint(validator.mustNotBeMissing)) {
          // The item is missing (i.e. it's undefined), but the validator indicates it must not be
          validationErrors.push('item "' + buildItemPath(itemStack) + '" must not be missing');
        } else if (resolveItemConstraint(validator.mustNotBeNull)) {
          // The item is null, but the validator indicates it must not be
          validationErrors.push('item "' + buildItemPath(itemStack) + '" must not be null');
        }
      }

      function storeOptionalValidationErrors(errorMessages) {
        if (typeof errorMessages === 'string') {
          validationErrors.push(errorMessages);
        } else if (Array.isArray(errorMessages)) {
          for (var errorMessageIndex = 0; errorMessageIndex < errorMessages.length; errorMessageIndex++) {
            validationErrors.push(errorMessages[errorMessageIndex]);
          }
        }
      }

      function validateString(validator) {
        var currentItemEntry = itemStack[itemStack.length - 1];
        var itemValue = currentItemEntry.itemValue;

        var regexPattern = resolveItemConstraint(validator.regexPattern);
        if (regexPattern && !regexPattern.test(itemValue)) {
          validationErrors.push('item "' + buildItemPath(itemStack) + '" must conform to expected format ' + regexPattern);
        }

        var mustBeTrimmed = resolveItemConstraint(validator.mustBeTrimmed);
        if (mustBeTrimmed && isStringUntrimmed(itemValue)) {
          validationErrors.push('item "' + buildItemPath(itemStack) + '" must not have any leading or trailing whitespace');
        }

        var mustEqualIgnoreCase = resolveItemConstraint(validator.mustEqualIgnoreCase);
        if (!utils.isValueNullOrUndefined(mustEqualIgnoreCase) &&
            (itemValue.toLowerCase() !== mustEqualIgnoreCase.toLowerCase())) {
          validationErrors.push('value of item "' + buildItemPath(itemStack) + '" must equal (case insensitive) "' + mustEqualIgnoreCase + '"');
        }
      }

      function validateArray(elementValidator) {
        var currentItemEntry = itemStack[itemStack.length - 1];
        var itemValue = currentItemEntry.itemValue;
        var oldItemValue = currentItemEntry.oldItemValue;

        if (!Array.isArray(itemValue)) {
          validationErrors.push('item "' + buildItemPath(itemStack) + '" must be an array');
        } else if (elementValidator) {
          // Validate each element in the array
          for (var elementIndex = 0; elementIndex < itemValue.length; elementIndex++) {
            var elementName = '[' + elementIndex + ']';
            var elementValue = itemValue[elementIndex];
            var oldElementValue =
              (!utils.isValueNullOrUndefined(oldItemValue) && elementIndex < oldItemValue.length) ? oldItemValue[elementIndex] : null;

            itemStack.push({
              itemName: elementName,
              itemValue: elementValue,
              oldItemValue: oldElementValue
            });

            validateItemValue(elementValidator);

            itemStack.pop();
          }
        }
      }

      function validateHashtable(validator) {
        var keyValidator = resolveItemConstraint(validator.hashtableKeysValidator);
        var valueValidator = resolveItemConstraint(validator.hashtableValuesValidator);
        var currentItemEntry = itemStack[itemStack.length - 1];
        var itemValue = currentItemEntry.itemValue;
        var oldItemValue = currentItemEntry.oldItemValue;
        var hashtablePath = buildItemPath(itemStack);

        if (typeof itemValue !== 'object' || Array.isArray(itemValue)) {
          validationErrors.push('item "' + buildItemPath(itemStack) + '" must be an object/hashtable');
        } else {
          var size = 0;
          for (var elementKey in itemValue) {
            size++;
            var elementValue = itemValue[elementKey];

            var elementName = '[' + elementKey + ']';
            if (keyValidator) {
              var fullKeyPath = hashtablePath ? hashtablePath + elementName : elementName;
              if (typeof elementKey !== 'string') {
                validationErrors.push('hashtable key "' + fullKeyPath + '" is not a string');
              } else {
                if (resolveItemConstraint(keyValidator.mustNotBeEmpty) && elementKey.length < 1) {
                  validationErrors.push('hashtable "' + buildItemPath(itemStack) + '" must not have an empty key');
                }
                var regexPattern = resolveItemConstraint(keyValidator.regexPattern);
                if (regexPattern && !regexPattern.test(elementKey)) {
                  validationErrors.push('hashtable key "' + fullKeyPath + '" must conform to expected format ' + regexPattern);
                }
              }
            }

            if (valueValidator) {
              var oldElementValue;
              if (!utils.isValueNullOrUndefined(oldItemValue)) {
                oldElementValue = oldItemValue[elementKey];
              }

              itemStack.push({
                itemName: elementName,
                itemValue: elementValue,
                oldItemValue: oldElementValue
              });

              validateItemValue(valueValidator);

              itemStack.pop();
            }
          }

          var maximumSize = resolveItemConstraint(validator.maximumSize);
          if (!utils.isValueNullOrUndefined(maximumSize) && size > maximumSize) {
            validationErrors.push('hashtable "' + hashtablePath + '" must not be larger than ' + maximumSize + ' elements');
          }

          var minimumSize = resolveItemConstraint(validator.minimumSize);
          if (!utils.isValueNullOrUndefined(minimumSize) && size < minimumSize) {
            validationErrors.push('hashtable "' + hashtablePath + '" must not be smaller than ' + minimumSize + ' elements');
          }
        }
      }

      function performConditionalValidation(validator) {
        var currentItemEntry = itemStack[itemStack.length - 1];

        // Copy all but the last element so that the item's parent is at the top of the stack for condition functions
        var conditionalValidationItemStack = itemStack.slice(0, -1);

        var resolvedOldDoc = utils.resolveOldDoc(oldDoc);
        var validationCandidates = resolveItemConstraint(validator.validationCandidates) || [ ];
        for (var candidateIndex = 0; candidateIndex < validationCandidates.length; candidateIndex++) {
          var candidate = validationCandidates[candidateIndex];
          if (typeof candidate.condition === 'function' &&
              candidate.condition(doc, resolvedOldDoc, currentItemEntry, conditionalValidationItemStack)) {

            // Create a new validator that merges the universal constraints from the conditional validator and the more
            // specific constraints from the candidate validator
            var combinedValidator =
              assignProperties({ }, [ validator, candidate.validator ], [ 'validationCandidates' ]);

            return validateItemValue(combinedValidator);
          }
        }

        // If we got here, then none of the candidate validator conditions were satisfied
        if (shouldSkipItemValidation(validator, resolveItemConstraint('conditional'))) {
          return;
        } else if (utils.isValueNullOrUndefined(currentItemEntry.itemValue)) {
          // Ensure that a null/missing value does not violate any of the universal constraints specified by the
          // conditional validator (e.g. required, immutable)
          var nullValidator = assignProperties({ }, [ validator, { type: 'any' } ], [ 'validationCandidates' ]);

          validateItemValue(nullValidator);
        } else {
          validationErrors.push('item "' + buildItemPath(itemStack) + '" does not satisfy any candidate validation conditions');
        }
      }

      function performCustomValidation(validator) {
        var currentItemEntry = itemStack[itemStack.length - 1];

        // Copy all but the last/top element so that the item's parent is at the top of the stack for the custom validation function
        var customValidationItemStack = itemStack.slice(0, -1);

        var customValidationErrors =
          validator.customValidation(doc, oldDoc, currentItemEntry, customValidationItemStack);

        if (Array.isArray(customValidationErrors)) {
          for (var errorIndex = 0; errorIndex < customValidationErrors.length; errorIndex++) {
            validationErrors.push(customValidationErrors[errorIndex]);
          }
        }
      }

      function resolveItemConstraint(constraintDefinition) {
        if (typeof constraintDefinition === 'function') {
          var currentItemEntry = itemStack[itemStack.length - 1];

          return constraintDefinition(
            doc,
            utils.resolveOldDoc(oldDoc),
            currentItemEntry.itemValue,
            currentItemEntry.oldItemValue);
        } else {
          return constraintDefinition;
        }
      }

      function shouldSkipItemValidation(validator, validatorType) {
        var currentItemEntry = itemStack[itemStack.length - 1];
        var itemValue = currentItemEntry.itemValue;
        var oldItemValue = currentItemEntry.oldItemValue;

        if (utils.isDocumentMissingOrDeleted(oldDoc)) {
          // Can't skip validation when creating a new document
          return false;
        } else if (utils.isDocumentMissingOrDeleted(doc)) {
          // No need to validate when deleting an existing document
          return true;
        } else if (resolveItemConstraint(validator.skipValidationWhenValueUnchanged) &&
            comparisonModule.checkItemEquality(itemValue, oldItemValue, validatorType)) {
          // Old and new values are semantically equal and the item is allowed to skip validation
          return true;
        } else if (resolveItemConstraint(validator.skipValidationWhenValueUnchangedStrict) &&
            comparisonModule.checkItemEquality(itemValue, oldItemValue)) {
          // Old and new values are strictly equal and the item is allowed skip validation
          return true;
        } else {
          return false;
        }
      }
    }
  };

  function isValueAUuid(value) {
    var regex = /^[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12}$/;

    return regex.test(value);
  }

  function isStringUntrimmed(value) {
    if (utils.isValueNullOrUndefined(value)) {
      return false;
    } else {
      return value !== value.trim();
    }
  }

  // Constructs the fully qualified path of the item at the top of the given stack
  function buildItemPath(itemStack) {
    var nameComponents = [ ];
    for (var i = 0; i < itemStack.length; i++) {
      var itemName = itemStack[i].itemName;

      if (!itemName) {
        // Skip null or empty names (e.g. the first element is typically the root of the document, which has no name)
        continue;
      } else if (nameComponents.length < 1 || itemName.indexOf('[') === 0) {
        nameComponents.push(itemName);
      } else {
        nameComponents.push('.' + itemName);
      }
    }

    return nameComponents.join('');
  }

  function assignProperties(target, sources, skipPropertyNames) {
    var actualSkipPropertyNames = skipPropertyNames || [ ];
    for (var sourceIndex = 0; sourceIndex < sources.length; sourceIndex++) {
      var source = sources[sourceIndex];
      for (var propertyName in source) {
        if (source.hasOwnProperty(propertyName) && actualSkipPropertyNames.indexOf(propertyName) < 0) {
          target[propertyName] = source[propertyName];
        }
      }
    }

    return target;
  }
}


const joi = require('../../lib/joi/joi.bundle');
const makeConstraintSchemaDynamic = require('./dynamic-constraint-schema-maker');

const integerSchema = joi.number().integer();
const uuidSchema = joi.string().uuid();
const regexSchema = joi.object().type(RegExp);

// NOTE: When Joi runs, it does so in a Node.js process, which may support a different subset of the ISO 8601 date-time
// string format than Sync Gateway's JavaScript interpreter does. To prevent the validator from allowing date or
// date-time strings that cannot be parsed when a generated sync function is used in Sync Gateway, strings with explicit
// regular expression definitions are used for the "date" and "datetime" types rather than `joi.date().iso()`.
const datetimeStringSchema = joi.string()
  .regex(/^([+-]\d{6}|\d{4})(-(0[1-9]|1[0-2])(-(0[1-9]|[12]\d|3[01]))?)?(T((([01]\d|2[0-3])(:[0-5]\d)(:[0-5]\d(\.\d{1,3})?)?)|(24:00(:00(\.0{1,3})?)?))(Z|([+-])([01]\d|2[0-3]):([0-5]\d))?)?$/);
const datetimeSchema = joi.any().when(
  joi.string(),
  {
    then: datetimeStringSchema,
    otherwise: joi.date().options({ convert: false })
  });

const dateOnlyStringSchema = joi.string().regex(/^([+-]\d{6}|\d{4})(-(0[1-9]|1[0-2])(-(0[1-9]|[12]\d|3[01]))?)?$/);
const dateOnlySchema = joi.any().when(
  joi.string(),
  {
    then: dateOnlyStringSchema,
    otherwise: joi.date().options({ convert: false })
  });

const timeOnlySchema = joi.string().regex(/^((([01]\d|2[0-3])(:[0-5]\d)(:[0-5]\d(\.\d{1,3})?)?)|(24:00(:00(\.0{1,3})?)?))$/);
const timezoneSchema = joi.string().regex(/^(Z|([+-])([01]\d|2[0-3]):([0-5]\d))$/);

const typeEqualitySchemas = {
  any: joi.any(),
  string: joi.string(),
  integer: integerSchema,
  float: joi.number(),
  boolean: joi.boolean(),
  datetime: datetimeStringSchema,
  date: dateOnlyStringSchema,
  time: timeOnlySchema,
  timezone: timezoneSchema,
  enum: joi.alternatives().try([ joi.string(), integerSchema ]),
  uuid: uuidSchema,
  attachmentReference: joi.string(),
  array: joi.array(),
  object: joi.object().unknown(),
  hashtable: joi.object().unknown(),
  conditional: joi.any()
};

const validPropertyTypes = Object.keys(typeEqualitySchemas).sort();

const schema = joi.object().keys({
  type: dynamicConstraintSchema(joi.string().only(validPropertyTypes)).required()
})
  .when(
    joi.object().unknown().keys({ type: 'any' }),
    { then: makeTypeConstraintsSchema('any') })
  .when(
    joi.object().unknown().keys({ type: 'string' }),
    { then: makeTypeConstraintsSchema('string') })
  .when(
    joi.object().unknown().keys({ type: 'integer' }),
    { then: makeTypeConstraintsSchema('integer') })
  .when(
    joi.object().unknown().keys({ type: 'float' }),
    { then: makeTypeConstraintsSchema('float') })
  .when(
    joi.object().unknown().keys({ type: 'boolean' }),
    { then: makeTypeConstraintsSchema('boolean') })
  .when(
    joi.object().unknown().keys({ type: 'datetime' }),
    { then: makeTypeConstraintsSchema('datetime') })
  .when(
    joi.object().unknown().keys({ type: 'date' }),
    { then: makeTypeConstraintsSchema('date') })
  .when(
    joi.object().unknown().keys({ type: 'time' }),
    { then: makeTypeConstraintsSchema('time') })
  .when(
    joi.object().unknown().keys({ type: 'timezone' }),
    { then: makeTypeConstraintsSchema('timezone') })
  .when(
    joi.object().unknown().keys({ type: 'enum' }),
    { then: makeTypeConstraintsSchema('enum') })
  .when(
    joi.object().unknown().keys({ type: 'uuid' }),
    { then: makeTypeConstraintsSchema('uuid') })
  .when(
    joi.object().unknown().keys({ type: 'attachmentReference' }),
    { then: makeTypeConstraintsSchema('attachmentReference') })
  .when(
    joi.object().unknown().keys({ type: 'array' }),
    { then: makeTypeConstraintsSchema('array') })
  .when(
    joi.object().unknown().keys({ type: 'object' }),
    { then: makeTypeConstraintsSchema('object') })
  .when(
    joi.object().unknown().keys({ type: 'hashtable' }),
    { then: makeTypeConstraintsSchema('hashtable') })
  .when(
    joi.object().unknown().keys({ type: 'conditional' }),
    { then: makeTypeConstraintsSchema('conditional') })
  .when(
    joi.object().unknown().keys({ type: joi.func() }),
    { then: joi.object().unknown() });

/**
 * A partial schema for a single entry in a "propertyValidators" object at either the top level of a document definition
 * or nested within an "object" validator.
 */
module.exports = exports = schema;

// Defined as a function rather than a plain object because it contains lazy references that result in recursive
// references between the complex types (e.g. "array", "object", "hashtable") and the main "propertyValidators" schema
function typeSpecificConstraintSchemas() {
  return {
    any: { },
    string: {
      mustNotBeEmpty: dynamicConstraintSchema(joi.boolean()),
      mustBeTrimmed: dynamicConstraintSchema(joi.boolean()),
      regexPattern: dynamicConstraintSchema(regexSchema),
      minimumLength: dynamicConstraintSchema(integerSchema.min(0)),
      maximumLength: maximumSizeConstraintSchema('minimumLength'),
      minimumValue: dynamicConstraintSchema(joi.string()),
      minimumValueExclusive: dynamicConstraintSchema(joi.string()),
      maximumValue: dynamicConstraintSchema(joi.string()),
      maximumValueExclusive: dynamicConstraintSchema(joi.string()),
      mustEqualIgnoreCase: dynamicConstraintSchema(joi.string())
    },
    integer: {
      minimumValue: dynamicConstraintSchema(integerSchema),
      minimumValueExclusive: dynamicConstraintSchema(integerSchema),
      maximumValue: maximumValueInclusiveNumberConstraintSchema(integerSchema),
      maximumValueExclusive: maximumValueExclusiveNumberConstraintSchema(integerSchema)
    },
    float: {
      minimumValue: dynamicConstraintSchema(joi.number()),
      minimumValueExclusive: dynamicConstraintSchema(joi.number()),
      maximumValue: maximumValueInclusiveNumberConstraintSchema(joi.number()),
      maximumValueExclusive: maximumValueExclusiveNumberConstraintSchema(joi.number())
    },
    boolean: { },
    datetime: {
      minimumValue: dynamicConstraintSchema(datetimeSchema),
      minimumValueExclusive: dynamicConstraintSchema(datetimeSchema),
      maximumValue: dynamicConstraintSchema(datetimeSchema),
      maximumValueExclusive: dynamicConstraintSchema(datetimeSchema)
    },
    date: {
      minimumValue: dynamicConstraintSchema(dateOnlySchema),
      minimumValueExclusive: dynamicConstraintSchema(dateOnlySchema),
      maximumValue: dynamicConstraintSchema(dateOnlySchema),
      maximumValueExclusive: dynamicConstraintSchema(dateOnlySchema)
    },
    time: {
      minimumValue: dynamicConstraintSchema(timeOnlySchema),
      minimumValueExclusive: dynamicConstraintSchema(timeOnlySchema),
      maximumValue: dynamicConstraintSchema(timeOnlySchema),
      maximumValueExclusive: dynamicConstraintSchema(timeOnlySchema)
    },
    timezone: {
      minimumValue: dynamicConstraintSchema(timezoneSchema),
      minimumValueExclusive: dynamicConstraintSchema(timezoneSchema),
      maximumValue: dynamicConstraintSchema(timezoneSchema),
      maximumValueExclusive: dynamicConstraintSchema(timezoneSchema)
    },
    enum: {
      predefinedValues: dynamicConstraintSchema(joi.array().required().min(1).items([ integerSchema, joi.string() ]))
    },
    uuid: {
      minimumValue: dynamicConstraintSchema(uuidSchema),
      minimumValueExclusive: dynamicConstraintSchema(uuidSchema),
      maximumValue: dynamicConstraintSchema(uuidSchema),
      maximumValueExclusive: dynamicConstraintSchema(uuidSchema)
    },
    attachmentReference: {
      maximumSize: dynamicConstraintSchema(integerSchema.min(1).max(20971520)),
      supportedExtensions: dynamicConstraintSchema(joi.array().min(1).items(joi.string())),
      supportedContentTypes: dynamicConstraintSchema(joi.array().min(1).items(joi.string().min(1))),
      regexPattern: dynamicConstraintSchema(regexSchema)
    },
    array: {
      mustNotBeEmpty: dynamicConstraintSchema(joi.boolean()),
      minimumLength: dynamicConstraintSchema(integerSchema.min(0)),
      maximumLength: maximumSizeConstraintSchema('minimumLength'),
      arrayElementsValidator: dynamicConstraintSchema(joi.lazy(() => schema))
    },
    object: {
      allowUnknownProperties: dynamicConstraintSchema(joi.boolean()),
      propertyValidators: dynamicConstraintSchema(joi.object().min(1).pattern(/^.+$/, joi.lazy(() => schema)))
    },
    hashtable: {
      minimumSize: dynamicConstraintSchema(integerSchema.min(0)),
      maximumSize: maximumSizeConstraintSchema('minimumSize'),
      hashtableKeysValidator: dynamicConstraintSchema(joi.object().keys({
        mustNotBeEmpty: dynamicConstraintSchema(joi.boolean()),
        regexPattern: dynamicConstraintSchema(regexSchema)
      })),
      hashtableValuesValidator: dynamicConstraintSchema(joi.lazy(() => schema))
    },
    conditional: {
      validationCandidates: dynamicConstraintSchema(conditionalValidationCandidatesSchema()).required()
    }
  };
}

// Creates a validation schema for the constraints of the specified type
function makeTypeConstraintsSchema(typeName) {
  const allTypeConstraints = typeSpecificConstraintSchemas();
  const constraints = Object.assign({ }, universalConstraintSchemas(typeEqualitySchemas[typeName]), allTypeConstraints[typeName]);

  return joi.object().keys(constraints)
    // Prevent the use of more than one constraint from the "equality" category
    .without('mustEqual', [ 'mustEqualStrict', 'mustEqualIgnoreCase' ])
    .without('mustEqualStrict', [ 'mustEqual', 'mustEqualIgnoreCase' ])
    .without('mustEqualIgnoreCase', [ 'mustEqual', 'mustEqualStrict' ])

    // Prevent the use of more than one constraint from the "minimum value" category
    .without('minimumValue', [ 'minimumValueExclusive', 'mustEqual', 'mustEqualStrict', 'mustEqualIgnoreCase' ])
    .without('minimumValueExclusive', [ 'minimumValue', 'mustEqual', 'mustEqualStrict', 'mustEqualIgnoreCase' ])

    // Prevent the use of more than one constraint from the "maximum value" category
    .without('maximumValue', [ 'maximumValueExclusive', 'mustEqualStrict', 'mustEqual', 'mustEqualIgnoreCase' ])
    .without('maximumValueExclusive', [ 'maximumValue', 'mustEqualStrict', 'mustEqual', 'mustEqualIgnoreCase' ])

    // Prevent the use of more than one constraint from the "immutability" category
    .without('immutable', [ 'immutableStrict', 'immutableWhenSet', 'immutableWhenSetStrict' ])
    .without('immutableStrict', [ 'immutable', 'immutableWhenSet', 'immutableWhenSetStrict' ])
    .without('immutableWhenSet', [ 'immutable', 'immutableStrict', 'immutableWhenSetStrict' ])
    .without('immutableWhenSetStrict', [ 'immutable', 'immutableStrict', 'immutableWhenSet' ])

    // Prevent the use of more than one constraint from the "skip validation" category
    .without('skipValidationWhenValueUnchanged', [ 'skipValidationWhenValueUnchangedStrict' ]);
}

function mustEqualConstraintSchema(comparisonSchema) {
  return joi.any().when(joi.any().only(null), { otherwise: comparisonSchema });
}

function universalConstraintSchemas(typeEqualitySchema) {
  return {
    type: dynamicConstraintSchema(joi.string()).required(),
    required: dynamicConstraintSchema(joi.boolean()),
    mustNotBeMissing: joi.any().forbidden().error(() =>
      ({ message: '"mustNotBeMissing" is deprecated; use "required" instead' })),
    mustNotBeNull: joi.any().forbidden().error(() =>
      ({ message: '"mustNotBeNull" is deprecated; use "required" instead' })),
    immutable: dynamicConstraintSchema(joi.boolean()),
    immutableStrict: dynamicConstraintSchema(joi.boolean()),
    immutableWhenSet: dynamicConstraintSchema(joi.boolean()),
    immutableWhenSetStrict: dynamicConstraintSchema(joi.boolean()),
    mustEqual: dynamicConstraintSchema(mustEqualConstraintSchema(typeEqualitySchema)),
    mustEqualStrict: dynamicConstraintSchema(mustEqualConstraintSchema(typeEqualitySchema)),
    skipValidationWhenValueUnchanged: dynamicConstraintSchema(joi.boolean()),
    skipValidationWhenValueUnchangedStrict: dynamicConstraintSchema(joi.boolean()),
    customValidation: joi.func().maxArity(4) // Function parameters: doc, oldDoc, currentItemEntry, validationItemStack
  };
}

function maximumSizeConstraintSchema(minimumSizePropertyName) {
  return joi.any().when(
    minimumSizePropertyName,
    {
      is: joi.number().exist(),
      then: dynamicConstraintSchema(integerSchema.min(joi.ref(minimumSizePropertyName))),
      otherwise: dynamicConstraintSchema(integerSchema.min(0))
    });
}

function maximumValueInclusiveNumberConstraintSchema(numberType) {
  return joi.any().when(
    'minimumValue',
    {
      is: joi.number().exist(),
      then: dynamicConstraintSchema(numberType.min(joi.ref('minimumValue'))),
      otherwise: joi.any().when(
        'minimumValueExclusive',
        {
          is: joi.number().exist(),
          then: dynamicConstraintSchema(numberType.greater(joi.ref('minimumValueExclusive'))),
          otherwise: dynamicConstraintSchema(numberType)
        })
    });
}

function maximumValueExclusiveNumberConstraintSchema(numberType) {
  return joi.any().when(
    'minimumValue',
    {
      is: joi.number().exist(),
      then: dynamicConstraintSchema(numberType.greater(joi.ref('minimumValue'))),
      otherwise: joi.any().when(
        'minimumValueExclusive',
        {
          is: joi.number().exist(),
          then: dynamicConstraintSchema(numberType.greater(joi.ref('minimumValueExclusive'))),
          otherwise: dynamicConstraintSchema(numberType)
        })
    });
}

function conditionalValidationCandidatesSchema() {
  return joi.array().min(1).items([
    joi.object().keys({
      condition: joi.func().maxArity(4).required(),
      validator: joi.lazy(() => schema).required()
    })
  ]);
}

// Generates a schema that can be used for property validator constraints
function dynamicConstraintSchema(wrappedSchema) {
  // The function schema this creates will support no more than four parameters (doc, oldDoc, value, oldValue)
  return makeConstraintSchemaDynamic(wrappedSchema, 4);
}


function() {
  // The role that confers universal privileges and is only applicable to Kashoo services
  var serviceRole = 'SERVICE';

  // A special user that has universal privileges
  var adminUser = 'ADMIN';

  // Matches values that look like three-letter ISO 4217 currency codes. It is not comprehensive.
  var iso4217CurrencyCodeRegex = /^[A-Z]{3}$/;

  // Creates a RegExp to match the ID of an entity that belongs to a business
  function createBusinessEntityRegex(suffixPattern) {
    // Note that this regex uses double quotes rather than single quotes as a workaround to https://github.com/Kashoo/synctos/issues/116
    return new RegExp("^biz\\.\\d+\\." + suffixPattern + "$");
  }

  // Checks that a business ID is valid (an integer greater than 0) and is not changed from the old version of the document
  function validateBusinessIdProperty(doc, oldDoc, currentItemEntry, validationItemStack) {
    var parentObjectElement = validationItemStack[validationItemStack.length - 1];

    var businessId = currentItemEntry.itemValue;
    var oldBusinessId = currentItemEntry.oldItemValue;

    var validationErrors = [ ];

    if (parentObjectElement.oldItemValue && oldBusinessId !== businessId) {
      validationErrors.push('cannot change "businessId" property');
    }

    return validationErrors;
  }

  // Retrieves the ID of the business to which the document belongs
  function getBusinessId(doc, oldDoc) {
    var regex = /^biz\.([A-Za-z0-9_-]+)(?:\..+)?$/;
    var matchGroups = regex.exec(doc._id);
    if (matchGroups) {
      return matchGroups[1];
    } else if (oldDoc && oldDoc.businessId) {
      // The document ID doesn't contain a business ID, so use the property from the old document
      return oldDoc.businessId || null;
    } else {
      // Neither the document ID nor the old document's contents contain a business ID, so use the property from the new document
      return doc.businessId || null;
    }
  }

  // Converts a Books business privilege to a Couchbase Sync Gateway document channel name
  function toSyncChannel(businessId, privilege) {
    return businessId + '-' + privilege;
  }

  // Builds a function that returns the view, add, replace, remove channels extrapolated from the specified base privilege, name which is
  // formatted according to the de facto Books convention of "VIEW_FOOBAR", "ADD_FOOBAR", "CHANGE_FOOBAR" and "REMOVE_FOOBAR" assuming the
  // base privilege name is "FOOBAR"
  function toDefaultSyncChannels(doc, oldDoc, basePrivilegeName) {
    var businessId = getBusinessId(doc, oldDoc);

    return function(doc, oldDoc) {
      return {
        view: [ toSyncChannel(businessId, 'VIEW_' + basePrivilegeName) ],
        add: [ toSyncChannel(businessId, 'ADD_' + basePrivilegeName) ],
        replace: [ toSyncChannel(businessId, 'CHANGE_' + basePrivilegeName) ],
        remove: [ toSyncChannel(businessId, 'REMOVE_' + basePrivilegeName) ]
      };
    };
  }

  var defaultAuthorizedRoles = { write: serviceRole };

  var defaultAuthorizedUsers = { write: adminUser };

  // The document type definitions. For everyone's sanity, please keep the document types in case-insensitive alphabetical order
  return {
    // The general business configuration
    business: importDocumentDefinitionFragment('fragment-business.js'),

    // A notification to be delivered to the registered notification transports for the corresponding notification type
    notification: importDocumentDefinitionFragment('fragment-notification.js'),

    // Configuration of notification transports for the business
    notificationsConfig: importDocumentDefinitionFragment('fragment-notifications-config.js'),

    // Keeps track of all notifications that have been generated for a business
    notificationsReference: importDocumentDefinitionFragment('fragment-notifications-reference.js'),

    // Configuration for a notification transport
    notificationTransport: importDocumentDefinitionFragment('fragment-notification-transport.js'),

    // A summary of the progress of processing and sending a notification via a specific notification transport method
    notificationTransportProcessingSummary: importDocumentDefinitionFragment('fragment-notification-transport-processing-summary.js'),

    // Describes an attempt to pay an invoice payment requisition, whether successful or not. May not be replaced or deleted once created.
    paymentAttempt: importDocumentDefinitionFragment('fragment-payment-attempt.js'),

    // Configuration for a payment processor
    paymentProcessorDefinition: importDocumentDefinitionFragment('fragment-payment-processor-definition.js'),

    // Describes a processed settlement from a payment processor
    paymentProcessorSettlement: importDocumentDefinitionFragment('fragment-payment-processor-settlement.js')(),

    // A request/requisition for payment of an invoice
    paymentRequisition: importDocumentDefinitionFragment('fragment-payment-requisition.js'),

    // References the payment requisitions and payment attempts that were created for an invoice
    paymentRequisitionsReference: importDocumentDefinitionFragment('fragment-payment-requisitions-reference.js')
  };
}


function timeModule(utils) {
  var utcTimeZone = {
    multiplicationFactor: 1,
    hour: 0,
    minute: 0
  };

  // NOTE: Many of the following functions make use of custom logic, rather than relying on the built-in Date object, to
  // work around a number of deficiencies in the Date implementation from Sync Gateway's JavaScript engine (otto)
  return {
    isIso8601DateTimeString: isIso8601DateTimeString,
    isIso8601DateString: isIso8601DateString,
    isIso8601TimeString: isIso8601TimeString,
    isIso8601TimeZoneString: isIso8601TimeZoneString,
    compareTimes: compareTimes,
    compareDates: compareDates,
    compareTimeZones: compareTimeZones
  };

  // Check that a given value is a valid ISO 8601 format date string with optional time and time zone components
  function isIso8601DateTimeString(value) {
    var dateAndTimePieces = splitDateAndTime(value);
    var date = extractDateStructureFromDateAndTime(dateAndTimePieces);
    if (date) {
      var timeAndTimezone = extractTimeStructuresFromDateAndTime(dateAndTimePieces);
      var time = timeAndTimezone.time;
      var timezone = timeAndTimezone.timezone;

      return isValidDateStructure(date) &&
        isValidTimeStructure(time) &&
        (timezone === null || isValidTimeZoneStructure(timezone));
    } else {
      return false;
    }
  }

  // Check that a given value is a valid ISO 8601 date string without time and time zone components
  function isIso8601DateString(value) {
    return isValidDateStructure(parseIso8601Date(value));
  }

  // Check that a given value is a valid ISO 8601 time string without date and time zone components
  function isIso8601TimeString(value) {
    return isValidTimeStructure(parseIso8601Time(value));
  }

  // Check that a given value is a valid ISO 8601 time zone
  function isIso8601TimeZoneString(value) {
    return isValidTimeZoneStructure(parseIso8601TimeZone(value));
  }

  function isValidDateStructure(date) {
    return isSupportedYear(date.year) &&
      date.month >= 1 && date.month <= 12 &&
      isValidDayOfMonth(date.year, date.month, date.day);
  }

  function isValidTimeStructure(time) {
    if (time.hour === 24) {
      return time.minute === 0 && time.second === 0 && time.millisecond === 0;
    } else {
      return time.hour <= 23 && time.minute <= 59 && time.second <= 59 && time.millisecond <= 999;
    }
  }

  function isValidTimeZoneStructure(timezone) {
    return (timezone.multiplicationFactor === 1 || timezone.multiplicationFactor === -1) &&
      timezone.hour <= 23 &&
      timezone.minute <= 59;
  }

  function numDaysInMonth(year, month) {
    switch (month) {
      case 1: // Jan
      case 3: // Mar
      case 5: // May
      case 7: // Jul
      case 8: // Aug
      case 10: // Oct
      case 12: // Dec
        return 31;
      case 4: // Apr
      case 6: // Jun
      case 9: // Sep
      case 11: // Nov
        return 30;
      case 2: // Feb
        return isLeapYear(year) ? 29 : 28;
      default:
        return NaN;
    }
  }

  function isValidDayOfMonth(year, month, day) {
    if (day < 1) {
      return false;
    } else if (month === 2) {
      // This is a small optimization; checking whether February has a leap day is a moderately expensive operation, so
      // only perform that check if the day of the month is 29.
      return (day === 29) ? isLeapYear(year) : day <= 28;
    } else {
      return day <= numDaysInMonth(year, month);
    }
  }

  function isLeapYear(year) {
    if (year % 4 !== 0) {
      // The year is not a multiple of 4, so it cannot be a leap year
      return false;
    } else if (year % 100 !== 0) {
      // The year is a multiple of 4 but not a multiple of 100, so it must be a leap year
      return true;
    } else if (year % 400 !== 0) {
      // The year is a multiple of 4 and 100, but it is not a multiple of 400, so it cannot be a leap year
      return false;
    } else {
      // The year is a multiple of 4 and 400, so it must be a leap year
      return true;
    }
  }

  function isSupportedYear(year) {
    // The year must fall within the range specified by the ECMAScript extended year format:
    // https://www.ecma-international.org/ecma-262/5.1/#sec-15.9.1.15.1
    return year >= -283457 && year <= 287396;
  }

  function splitDateAndTime(value) {
    return value.split('T', 2);
  }

  function parseIso8601Time(value) {
    var timePieces = /^(\d{2}):(\d{2})(?:\:(\d{2})(?:\.(\d{1,3}))?)?$/.exec(value);
    if (timePieces !== null) {
      // The millisecond component has a variable length; normalize the length by padding it with zeros
      var millisecond = timePieces[4] ? parseInt(utils.padRight(timePieces[4], 3, '0'), 10) : 0;

      return {
        hour: parseInt(timePieces[1], 10),
        minute: parseInt(timePieces[2], 10),
        second: timePieces[3] ? parseInt(timePieces[3], 10) : 0,
        millisecond: millisecond
      };
    } else {
      return {
        hour: NaN,
        minute: NaN,
        second: NaN,
        millisecond: NaN
      };
    }
  }

  // Converts the given time to the number of milliseconds since hour 0
  function normalizeIso8601Time(time, timezoneOffsetMinutes) {
    var msPerSecond = 1000;
    var msPerMinute = 60000;
    var msPerHour = 3600000;
    var effectiveTimezoneOffset = timezoneOffsetMinutes || 0;

    var rawTimeMs =
      (time.hour * msPerHour) + (time.minute * msPerMinute) + (time.second * msPerSecond) + time.millisecond;

    return rawTimeMs - (effectiveTimezoneOffset * msPerMinute);
  }

  // Compares the given time strings. Returns a negative number if a is less than b, a positive number if a is greater
  // than b, or zero if a and b are equal.
  function compareTimes(a, b) {
    if (typeof a !== 'string' || typeof b !== 'string') {
      return NaN;
    }

    return normalizeIso8601Time(parseIso8601Time(a)) - normalizeIso8601Time(parseIso8601Time(b));
  }

  function parseIso8601Date(value) {
    var datePieces = /^((?:[+-]\d{6})|(?:\d{4}))(?:-(\d{2}))?(?:-(\d{2}))?$/.exec(value);
    if (datePieces !== null) {
      return {
        year: parseInt(datePieces[1], 10),
        month: datePieces[2] ? parseInt(datePieces[2], 10) : 1,
        day: datePieces[3] ? parseInt(datePieces[3], 10) : 1
      };
    } else {
      return {
        year: NaN,
        month: NaN,
        day: NaN
      };
    }
  }

  function extractDateStructureFromDateAndTime(dateAndTimePieces) {
    return (dateAndTimePieces.length > 0) ? parseIso8601Date(dateAndTimePieces[0]) : null;
  }

  function extractTimeStructuresFromDateAndTime(dateAndTimePieces) {
    if (dateAndTimePieces.length <= 1) {
      // Default to midnight UTC since the candidate value represents a date only
      return {
        time: {
          hour: 0,
          minute: 0,
          second: 0,
          millisecond: 0
        },
        timezone: utcTimeZone
      };
    } else {
      var timeAndTimezoneString = dateAndTimePieces[1];
      var timezoneSeparatorIndex =
        Math.max(timeAndTimezoneString.indexOf('-'), timeAndTimezoneString.indexOf('+'), timeAndTimezoneString.indexOf('Z'));

      var timeString =
        (timezoneSeparatorIndex >= 0) ? timeAndTimezoneString.substr(0, timezoneSeparatorIndex) : timeAndTimezoneString;
      var time = parseIso8601Time(timeString);

      var timezoneString = (timezoneSeparatorIndex >= 0) ? timeAndTimezoneString.substr(timezoneSeparatorIndex) : null;
      var timezone = (timezoneString !== null) ? parseIso8601TimeZone(timezoneString) : null;

      return {
        time: time,
        timezone: timezone
      };
    }
  }

  function extractDatePiecesFromDateObject(value) {
    var timeStructure = {
      hour: value.getUTCHours(),
      minute: value.getUTCMinutes(),
      second: value.getUTCSeconds(),
      millisecond: value.getUTCMilliseconds()
    };
    var timeOfDayMs = normalizeIso8601Time(timeStructure);

    return [ value.getUTCFullYear(), value.getUTCMonth() + 1, value.getUTCDate(), timeOfDayMs ];
  }

  function extractDatePiecesFromIso8601String(value) {
    var dateAndTimePieces = splitDateAndTime(value);

    var date = extractDateStructureFromDateAndTime(dateAndTimePieces);
    if (!isValidDateStructure(date)) {
      return null;
    }

    var timeAndTimezone = extractTimeStructuresFromDateAndTime(dateAndTimePieces);
    var time = timeAndTimezone.time;
    var timezone = timeAndTimezone.timezone;
    if (!isValidTimeStructure(time)) {
      return null;
    } else if (timezone !== null && !isValidTimeZoneStructure(timezone)) {
      return null;
    }

    var calculatedYear = date.year;
    var calculatedMonth = date.month;
    var calculatedDay = date.day;
    var calculatedTimeOfDayMs = normalizeIso8601Time(time, normalizeIso8601TimeZone(timezone));

    // Carry the overflow/underflow of the time of day to the day of the month as necessary
    var msPerDay = 86400000;
    if (calculatedTimeOfDayMs < 0) {
      calculatedDay--;
      calculatedTimeOfDayMs += msPerDay;
    } else if (calculatedTimeOfDayMs >= msPerDay) {
      calculatedDay++;
      calculatedTimeOfDayMs -= msPerDay;
    }

    // Carry the overflow/underflow of the day of the month to the month as necessary
    if (calculatedDay < 1) {
      calculatedMonth--;

      // There was an underflow, so roll back to the last day of the previous month
      if (calculatedMonth > 12) {
        calculatedDay = numDaysInMonth(calculatedYear + 1, 1);
      } else if (calculatedMonth < 1) {
        calculatedDay = numDaysInMonth(calculatedYear - 1, 12);
      } else {
        calculatedDay = numDaysInMonth(calculatedYear, calculatedMonth);
      }
    } else if (!isValidDayOfMonth(calculatedYear, calculatedMonth, calculatedDay)) {
      // There was an overflow, so roll over to the first day of the next month
      calculatedMonth++;
      calculatedDay = 1;
    }

    // Carry the overflow/underflow of the month to the year as necessary
    if (calculatedMonth < 1) {
      calculatedYear--;
      calculatedMonth = 12;
    } else if (calculatedMonth > 12) {
      calculatedYear++;
      calculatedMonth = 1;
    }

    return [ calculatedYear, calculatedMonth, calculatedDay, calculatedTimeOfDayMs ];
  }

  function extractDatePieces(value) {
    if (value instanceof Date) {
      return extractDatePiecesFromDateObject(value);
    } else if (typeof value === 'string') {
      return extractDatePiecesFromIso8601String(value);
    } else {
      return null;
    }
  }

  // Compares the given date representations. Returns a negative number if a is less than b, a positive number if a is
  // greater than b, or zero if a and b are equal.
  function compareDates(a, b) {
    var aPieces = extractDatePieces(a);
    var bPieces = extractDatePieces(b);

    if (aPieces === null || bPieces === null) {
      return NaN;
    }

    for (var pieceIndex = 0; pieceIndex < aPieces.length; pieceIndex++) {
      if (aPieces[pieceIndex] < bPieces[pieceIndex]) {
        return -1;
      } else if (aPieces[pieceIndex] > bPieces[pieceIndex]) {
        return 1;
      }
    }

    // If we got here, the two parameters represent the same date/point in time
    return 0;
  }

  function parseIso8601TimeZone(value) {
    if (value === 'Z') {
      return utcTimeZone;
    } else {
      var matches = /^([+-])(\d\d):(\d\d)$/.exec(value);
      if (matches !== null) {
        return {
          multiplicationFactor: (matches[1] === '+') ? 1 : -1,
          hour: parseInt(matches[2], 10),
          minute: parseInt(matches[3], 10)
        };
      } else {
        return {
          multiplicationFactor: NaN,
          hour: NaN,
          minute: NaN
        };
      }
    }
  }

  // Converts an ISO 8601 time zone into the number of minutes offset from UTC
  function normalizeIso8601TimeZone(value) {
    return value ? value.multiplicationFactor * ((value.hour * 60) + value.minute) : -(new Date().getTimezoneOffset());
  }

  // Compares the given time zone representations. Returns a negative number if a is less than b, a positive number if
  // a is greater than b, or zero if a and b are equal.
  function compareTimeZones(a, b) {
    if (typeof a !== 'string' || typeof b !== 'string') {
      return NaN;
    }

    return normalizeIso8601TimeZone(parseIso8601TimeZone(a)) - normalizeIso8601TimeZone(parseIso8601TimeZone(b));
  }
}