/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
 * You can obtain one at http://mozilla.org/MPL/2.0/. */

/**
 * This file contains an implementation of the Storage Server in JavaScript.
 *
 * The server should not be used for any production purposes.
 */

var {classes: Cc, interfaces: Ci, utils: Cu} = Components;

this.EXPORTED_SYMBOLS = [
  "ServerBSO",
  "StorageServerCallback",
  "StorageServerCollection",
  "StorageServer",
  "storageServerForUsers",
];

Cu.import("resource://testing-common/httpd.js");
Cu.import("resource://services-common/async.js");
Cu.import("resource://gre/modules/Log.jsm");
Cu.import("resource://services-common/utils.js");

const STORAGE_HTTP_LOGGER = "Services.Common.Test.Server";
const STORAGE_API_VERSION = "2.0";

// Use the same method that record.js does, which mirrors the server.
function new_timestamp() {
  return Math.round(Date.now());
}

function isInteger(s) {
  let re = /^[0-9]+$/;
  return re.test(s);
}

function writeHttpBody(response, body) {
  if (!body) {
    return;
  }

  response.bodyOutputStream.write(body, body.length);
}

function sendMozSvcError(request, response, code) {
  response.setStatusLine(request.httpVersion, 400, "Bad Request");
  response.setHeader("Content-Type", "text/plain", false);
  response.bodyOutputStream.write(code, code.length);
}

/**
 * Represent a BSO on the server.
 *
 * A BSO is constructed from an ID, content, and a modified time.
 *
 * @param id
 *        (string) ID of the BSO being created.
 * @param payload
 *        (strong|object) Payload for the BSO. Should ideally be a string. If
 *        an object is passed, it will be fed into JSON.stringify and that
 *        output will be set as the payload.
 * @param modified
 *        (number) Milliseconds since UNIX epoch that the BSO was last
 *        modified. If not defined or null, the current time will be used.
 */
this.ServerBSO = function ServerBSO(id, payload, modified) {
  if (!id) {
    throw new Error("No ID for ServerBSO!");
  }

  if (!id.match(/^[a-zA-Z0-9_-]{1,64}$/)) {
    throw new Error("BSO ID is invalid: " + id);
  }

  this._log = Log.repository.getLogger(STORAGE_HTTP_LOGGER);

  this.id = id;
  if (!payload) {
    return;
  }

  CommonUtils.ensureMillisecondsTimestamp(modified);

  if (typeof payload == "object") {
    payload = JSON.stringify(payload);
  }

  this.payload = payload;
  this.modified = modified || new_timestamp();
}
ServerBSO.prototype = {
  FIELDS: [
    "id",
    "modified",
    "payload",
    "ttl",
    "sortindex",
  ],

  toJSON: function toJSON() {
    let obj = {};

    for (let key of this.FIELDS) {
      if (this[key] !== undefined) {
        obj[key] = this[key];
      }
    }

    return obj;
  },

  delete: function delete_() {
    this.deleted = true;

    delete this.payload;
    delete this.modified;
  },

  /**
   * Handler for GET requests for this BSO.
   */
  getHandler: function getHandler(request, response) {
    let code = 200;
    let status = "OK";
    let body;

    function sendResponse() {
      response.setStatusLine(request.httpVersion, code, status);
      writeHttpBody(response, body);
    }

    if (request.hasHeader("x-if-modified-since")) {
      let headerModified = parseInt(request.getHeader("x-if-modified-since"),
                                    10);
      CommonUtils.ensureMillisecondsTimestamp(headerModified);

      if (headerModified >= this.modified) {
        code = 304;
        status = "Not Modified";

        sendResponse();
        return;
      }
    } else if (request.hasHeader("x-if-unmodified-since")) {
      let requestModified = parseInt(request.getHeader("x-if-unmodified-since"),
                                     10);
      let serverModified = this.modified;

      if (serverModified > requestModified) {
        code = 412;
        status = "Precondition Failed";
        sendResponse();
        return;
      }
    }

    if (!this.deleted) {
      body = JSON.stringify(this.toJSON());
      response.setHeader("Content-Type", "application/json", false);
      response.setHeader("X-Last-Modified", "" + this.modified, false);
    } else {
      code = 404;
      status = "Not Found";
    }

    sendResponse();
  },

  /**
   * Handler for PUT requests for this BSO.
   */
  putHandler: function putHandler(request, response) {
    if (request.hasHeader("Content-Type")) {
      let ct = request.getHeader("Content-Type");
      if (ct != "application/json") {
        throw HTTP_415;
      }
    }

    let input = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
    let parsed;
    try {
      parsed = JSON.parse(input);
    } catch (ex) {
      return sendMozSvcError(request, response, "8");
    }

    if (typeof(parsed) != "object") {
      return sendMozSvcError(request, response, "8");
    }

    // Don't update if a conditional request fails preconditions.
    if (request.hasHeader("x-if-unmodified-since")) {
      let reqModified = parseInt(request.getHeader("x-if-unmodified-since"));

      if (reqModified < this.modified) {
        response.setStatusLine(request.httpVersion, 412, "Precondition Failed");
        return;
      }
    }

    let code, status;
    if (this.payload) {
      code = 204;
      status = "No Content";
    } else {
      code = 201;
      status = "Created";
    }

    // Alert when we see unrecognized fields.
    for (let [key, value] of Object.entries(parsed)) {
      switch (key) {
        case "payload":
          if (typeof(value) != "string") {
            sendMozSvcError(request, response, "8");
            return true;
          }

          this.payload = value;
          break;

        case "ttl":
          if (!isInteger(value)) {
            sendMozSvcError(request, response, "8");
            return true;
          }
          this.ttl = parseInt(value, 10);
          break;

        case "sortindex":
          if (!isInteger(value) || value.length > 9) {
            sendMozSvcError(request, response, "8");
            return true;
          }
          this.sortindex = parseInt(value, 10);
          break;

        case "id":
          break;

        default:
          this._log.warn("Unexpected field in BSO record: " + key);
          sendMozSvcError(request, response, "8");
          return true;
      }
    }

    this.modified = request.timestamp;
    this.deleted = false;
    response.setHeader("X-Last-Modified", "" + this.modified, false);

    response.setStatusLine(request.httpVersion, code, status);
  },
};

/**
 * Represent a collection on the server.
 *
 * The '_bsos' attribute is a mapping of id -> ServerBSO objects.
 *
 * Note that if you want these records to be accessible individually,
 * you need to register their handlers with the server separately, or use a
 * containing HTTP server that will do so on your behalf.
 *
 * @param bsos
 *        An object mapping BSO IDs to ServerBSOs.
 * @param acceptNew
 *        If true, POSTs to this collection URI will result in new BSOs being
 *        created and wired in on the fly.
 * @param timestamp
 *        An optional timestamp value to initialize the modified time of the
 *        collection. This should be in the format returned by new_timestamp().
 */
this.StorageServerCollection =
 function StorageServerCollection(bsos, acceptNew, timestamp=new_timestamp()) {
  this._bsos = bsos || {};
  this.acceptNew = acceptNew || false;

  /*
   * Track modified timestamp.
   * We can't just use the timestamps of contained BSOs: an empty collection
   * has a modified time.
   */
  CommonUtils.ensureMillisecondsTimestamp(timestamp);
  this._timestamp = timestamp;

  this._log = Log.repository.getLogger(STORAGE_HTTP_LOGGER);
}
StorageServerCollection.prototype = {
  BATCH_MAX_COUNT: 100,         // # of records.
  BATCH_MAX_SIZE: 1024 * 1024,  // # bytes.

  _timestamp: null,

  get timestamp() {
    return this._timestamp;
  },

  set timestamp(timestamp) {
    CommonUtils.ensureMillisecondsTimestamp(timestamp);
    this._timestamp = timestamp;
  },

  get totalPayloadSize() {
    let size = 0;
    for (let bso of this.bsos()) {
      size += bso.payload.length;
    }

    return size;
  },

  /**
   * Convenience accessor for our BSO keys.
   * Excludes deleted items, of course.
   *
   * @param filter
   *        A predicate function (applied to the ID and BSO) which dictates
   *        whether to include the BSO's ID in the output.
   *
   * @return an array of IDs.
   */
  keys: function keys(filter) {
    let ids = [];
    for (let [id, bso] of Object.entries(this._bsos)) {
      if (!bso.deleted && (!filter || filter(id, bso))) {
        ids.push(id);
      }
    }
    return ids;
  },

  /**
   * Convenience method to get an array of BSOs.
   * Optionally provide a filter function.
   *
   * @param filter
   *        A predicate function, applied to the BSO, which dictates whether to
   *        include the BSO in the output.
   *
   * @return an array of ServerBSOs.
   */
  bsos: function bsos(filter) {
    let os = [];
    for (let [id, bso] of Object.entries(this._bsos)) {
      if (!bso.deleted) {
        os.push(bso);
      }
    }

    if (!filter) {
      return os;
    }

    return os.filter(filter);
  },

  /**
   * Obtain a BSO by ID.
   */
  bso: function bso(id) {
    return this._bsos[id];
  },

  /**
   * Obtain the payload of a specific BSO.
   *
   * Raises if the specified BSO does not exist.
   */
  payload: function payload(id) {
    return this.bso(id).payload;
  },

  /**
   * Insert the provided BSO under its ID.
   *
   * @return the provided BSO.
   */
  insertBSO: function insertBSO(bso) {
    return this._bsos[bso.id] = bso;
  },

  /**
   * Insert the provided payload as part of a new ServerBSO with the provided
   * ID.
   *
   * @param id
   *        The GUID for the BSO.
   * @param payload
   *        The payload, as provided to the ServerBSO constructor.
   * @param modified
   *        An optional modified time for the ServerBSO. If not specified, the
   *        current time will be used.
   *
   * @return the inserted BSO.
   */
  insert: function insert(id, payload, modified) {
    return this.insertBSO(new ServerBSO(id, payload, modified));
  },

  /**
   * Removes an object entirely from the collection.
   *
   * @param id
   *        (string) ID to remove.
   */
  remove: function remove(id) {
    delete this._bsos[id];
  },

  _inResultSet: function _inResultSet(bso, options) {
    if (!bso.payload) {
      return false;
    }

    if (options.ids) {
      if (options.ids.indexOf(bso.id) == -1) {
        return false;
      }
    }

    if (options.newer) {
      if (bso.modified <= options.newer) {
        return false;
      }
    }

    if (options.older) {
      if (bso.modified >= options.older) {
        return false;
      }
    }

    return true;
  },

  count: function count(options) {
    options = options || {};
    let c = 0;
    for (let [id, bso] of Object.entries(this._bsos)) {
      if (bso.modified && this._inResultSet(bso, options)) {
        c++;
      }
    }
    return c;
  },

  get: function get(options) {
    let data = [];
    for (let id in this._bsos) {
      let bso = this._bsos[id];
      if (!bso.modified) {
        continue;
      }

      if (!this._inResultSet(bso, options)) {
        continue;
      }

      data.push(bso);
    }

    if (options.sort) {
      if (options.sort == "oldest") {
        data.sort(function sortOldest(a, b) {
          if (a.modified == b.modified) {
            return 0;
          }

          return a.modified < b.modified ? -1 : 1;
        });
      } else if (options.sort == "newest") {
        data.sort(function sortNewest(a, b) {
          if (a.modified == b.modified) {
            return 0;
          }

          return a.modified > b.modified ? -1 : 1;
        });
      } else if (options.sort == "index") {
        data.sort(function sortIndex(a, b) {
          if (a.sortindex == b.sortindex) {
            return 0;
          }

          if (a.sortindex !== undefined && b.sortindex == undefined) {
            return 1;
          }

          if (a.sortindex === undefined && b.sortindex !== undefined) {
            return -1;
          }

          return a.sortindex > b.sortindex ? -1 : 1;
        });
      }
    }

    if (options.limit) {
      data = data.slice(0, options.limit);
    }

    return data;
  },

  post: function post(input, timestamp) {
    let success = [];
    let failed = {};
    let count = 0;
    let size = 0;

    // This will count records where we have an existing ServerBSO
    // registered with us as successful and all other records as failed.
    for (let record of input) {
      count += 1;
      if (count > this.BATCH_MAX_COUNT) {
        failed[record.id] = "Max record count exceeded.";
        continue;
      }

      if (typeof(record.payload) != "string") {
        failed[record.id] = "Payload is not a string!";
        continue;
      }

      size += record.payload.length;
      if (size > this.BATCH_MAX_SIZE) {
        failed[record.id] = "Payload max size exceeded!";
        continue;
      }

      if (record.sortindex) {
        if (!isInteger(record.sortindex)) {
          failed[record.id] = "sortindex is not an integer.";
          continue;
        }

        if (record.sortindex.length > 9) {
          failed[record.id] = "sortindex is too long.";
          continue;
        }
      }

      if ("ttl" in record) {
        if (!isInteger(record.ttl)) {
          failed[record.id] = "ttl is not an integer.";
          continue;
        }
      }

      try {
        let bso = this.bso(record.id);
        if (!bso && this.acceptNew) {
          this._log.debug("Creating BSO " + JSON.stringify(record.id) +
                          " on the fly.");
          bso = new ServerBSO(record.id);
          this.insertBSO(bso);
        }
        if (bso) {
          bso.payload = record.payload;
          bso.modified = timestamp;
          bso.deleted = false;
          success.push(record.id);

          if (record.sortindex) {
            bso.sortindex = parseInt(record.sortindex, 10);
          }

        } else {
          failed[record.id] = "no bso configured";
        }
      } catch (ex) {
        this._log.info("Exception when processing BSO", ex);
        failed[record.id] = "Exception when processing.";
      }
    }
    return {success: success, failed: failed};
  },

  delete: function delete_(options) {
    options = options || {};

    // Protocol 2.0 only allows the "ids" query string argument.
    let keys = Object.keys(options).filter(function(k) {
      return k != "ids";
    });
    if (keys.length) {
      this._log.warn("Invalid query string parameter to collection delete: " +
                     keys.join(", "));
      throw new Error("Malformed client request.");
    }

    if (options.ids && options.ids.length > this.BATCH_MAX_COUNT) {
      throw HTTP_400;
    }

    let deleted = [];
    for (let [id, bso] of Object.entries(this._bsos)) {
      if (this._inResultSet(bso, options)) {
        this._log.debug("Deleting " + JSON.stringify(bso));
        deleted.push(bso.id);
        bso.delete();
      }
    }
    return deleted;
  },

  parseOptions: function parseOptions(request) {
    let options = {};

    for (let chunk of request.queryString.split("&")) {
      if (!chunk) {
        continue;
      }
      chunk = chunk.split("=");
      let key = decodeURIComponent(chunk[0]);
      if (chunk.length == 1) {
        options[key] = "";
      } else {
        options[key] = decodeURIComponent(chunk[1]);
      }
    }

    if (options.ids) {
      options.ids = options.ids.split(",");
    }

    if (options.newer) {
      if (!isInteger(options.newer)) {
        throw HTTP_400;
      }

      CommonUtils.ensureMillisecondsTimestamp(options.newer);
      options.newer = parseInt(options.newer, 10);
    }

    if (options.older) {
      if (!isInteger(options.older)) {
        throw HTTP_400;
      }

      CommonUtils.ensureMillisecondsTimestamp(options.older);
      options.older = parseInt(options.older, 10);
    }

    if (options.limit) {
      if (!isInteger(options.limit)) {
        throw HTTP_400;
      }

      options.limit = parseInt(options.limit, 10);
    }

    return options;
  },

  getHandler: function getHandler(request, response) {
    let options = this.parseOptions(request);
    let data = this.get(options);

    if (request.hasHeader("x-if-modified-since")) {
      let requestModified = parseInt(request.getHeader("x-if-modified-since"),
                                     10);
      let newestBSO = 0;
      for (let bso of data) {
        if (bso.modified > newestBSO) {
          newestBSO = bso.modified;
        }
      }

      if (requestModified >= newestBSO) {
        response.setHeader("X-Last-Modified", "" + newestBSO);
        response.setStatusLine(request.httpVersion, 304, "Not Modified");
        return;
      }
    } else if (request.hasHeader("x-if-unmodified-since")) {
      let requestModified = parseInt(request.getHeader("x-if-unmodified-since"),
                                     10);
      let serverModified = this.timestamp;

      if (serverModified > requestModified) {
        response.setHeader("X-Last-Modified", "" + serverModified);
        response.setStatusLine(request.httpVersion, 412, "Precondition Failed");
        return;
      }
    }

    if (options.full) {
      data = data.map(function map(bso) {
        return bso.toJSON();
      });
    } else {
      data = data.map(function map(bso) {
        return bso.id;
      });
    }

    // application/json is default media type.
    let newlines = false;
    if (request.hasHeader("accept")) {
      let accept = request.getHeader("accept");
      if (accept == "application/newlines") {
        newlines = true;
      } else if (accept != "application/json") {
        throw HTTP_406;
      }
    }

    let body;
    if (newlines) {
      response.setHeader("Content-Type", "application/newlines", false);
      let normalized = data.map(function map(d) {
        return JSON.stringify(d);
      });

      body = normalized.join("\n") + "\n";
    } else {
      response.setHeader("Content-Type", "application/json", false);
      body = JSON.stringify({items: data});
    }

    this._log.info("Records: " + data.length);
    response.setHeader("X-Num-Records", "" + data.length, false);
    response.setHeader("X-Last-Modified", "" + this.timestamp, false);
    response.setStatusLine(request.httpVersion, 200, "OK");
    response.bodyOutputStream.write(body, body.length);
  },

  postHandler: function postHandler(request, response) {
    let options = this.parseOptions(request);

    if (!request.hasHeader("content-type")) {
      this._log.info("No Content-Type request header!");
      throw HTTP_400;
    }

    let inputStream = request.bodyInputStream;
    let inputBody = CommonUtils.readBytesFromInputStream(inputStream);
    let input = [];

    let inputMediaType = request.getHeader("content-type");
    if (inputMediaType == "application/json") {
      try {
        input = JSON.parse(inputBody);
      } catch (ex) {
        this._log.info("JSON parse error on input body!");
        throw HTTP_400;
      }

      if (!Array.isArray(input)) {
        this._log.info("Input JSON type not an array!");
        return sendMozSvcError(request, response, "8");
      }
    } else if (inputMediaType == "application/newlines") {
      for (let line of inputBody.split("\n")) {
        let record;
        try {
          record = JSON.parse(line);
        } catch (ex) {
          this._log.info("JSON parse error on line!");
          return sendMozSvcError(request, response, "8");
        }

        input.push(record);
      }
    } else {
      this._log.info("Unknown media type: " + inputMediaType);
      throw HTTP_415;
    }

    if (this._ensureUnmodifiedSince(request, response)) {
      return;
    }

    let res = this.post(input, request.timestamp);
    let body = JSON.stringify(res);
    response.setHeader("Content-Type", "application/json", false);
    this.timestamp = request.timestamp;
    response.setHeader("X-Last-Modified", "" + this.timestamp, false);

    response.setStatusLine(request.httpVersion, "200", "OK");
    response.bodyOutputStream.write(body, body.length);
  },

  deleteHandler: function deleteHandler(request, response) {
    this._log.debug("Invoking StorageServerCollection.DELETE.");

    let options = this.parseOptions(request);

    if (this._ensureUnmodifiedSince(request, response)) {
      return;
    }

    let deleted = this.delete(options);
    response.deleted = deleted;
    this.timestamp = request.timestamp;

    response.setStatusLine(request.httpVersion, 204, "No Content");
  },

  handler: function handler() {
    let self = this;

    return function(request, response) {
      switch(request.method) {
        case "GET":
          return self.getHandler(request, response);

        case "POST":
          return self.postHandler(request, response);

        case "DELETE":
          return self.deleteHandler(request, response);

      }

      request.setHeader("Allow", "GET,POST,DELETE");
      response.setStatusLine(request.httpVersion, 405, "Method Not Allowed");
    };
  },

  _ensureUnmodifiedSince: function _ensureUnmodifiedSince(request, response) {
    if (!request.hasHeader("x-if-unmodified-since")) {
      return false;
    }

    let requestModified = parseInt(request.getHeader("x-if-unmodified-since"),
                                   10);
    let serverModified = this.timestamp;

    this._log.debug("Request modified time: " + requestModified +
                    "; Server modified time: " + serverModified);
    if (serverModified <= requestModified) {
      return false;
    }

    this._log.info("Conditional request rejected because client time older " +
                   "than collection timestamp.");
    response.setStatusLine(request.httpVersion, 412, "Precondition Failed");
    return true;
  },
};


//===========================================================================//
// httpd.js-based Storage server.                                            //
//===========================================================================//

/**
 * In general, the preferred way of using StorageServer is to directly
 * introspect it. Callbacks are available for operations which are hard to
 * verify through introspection, such as deletions.
 *
 * One of the goals of this server is to provide enough hooks for test code to
 * find out what it needs without monkeypatching. Use this object as your
 * prototype, and override as appropriate.
 */
this.StorageServerCallback = {
  onCollectionDeleted: function onCollectionDeleted(user, collection) {},
  onItemDeleted: function onItemDeleted(user, collection, bsoID) {},

  /**
   * Called at the top of every request.
   *
   * Allows the test to inspect the request. Hooks should be careful not to
   * modify or change state of the request or they may impact future processing.
   */
  onRequest: function onRequest(request) {},
};

/**
 * Construct a new test Storage server. Takes a callback object (e.g.,
 * StorageServerCallback) as input.
 */
this.StorageServer = function StorageServer(callback) {
  this.callback     = callback || {__proto__: StorageServerCallback};
  this.server       = new HttpServer();
  this.started      = false;
  this.users        = {};
  this.requestCount = 0;
  this._log         = Log.repository.getLogger(STORAGE_HTTP_LOGGER);

  // Install our own default handler. This allows us to mess around with the
  // whole URL space.
  let handler = this.server._handler;
  handler._handleDefault = this.handleDefault.bind(this, handler);
}
StorageServer.prototype = {
  DEFAULT_QUOTA: 1024 * 1024, // # bytes.

  server: null,    // HttpServer.
  users:  null,    // Map of username => {collections, password}.

  /**
   * If true, the server will allow any arbitrary user to be used.
   *
   * No authentication will be performed. Whatever user is detected from the
   * URL or auth headers will be created (if needed) and used.
   */
  allowAllUsers: false,

  /**
   * Start the StorageServer's underlying HTTP server.
   *
   * @param port
   *        The numeric port on which to start. A falsy value implies to
   *        select any available port.
   * @param cb
   *        A callback function (of no arguments) which is invoked after
   *        startup.
   */
  start: function start(port, cb) {
    if (this.started) {
      this._log.warn("Warning: server already started on " + this.port);
      return;
    }
    if (!port) {
      port = -1;
    }
    this.port = port;

    try {
      this.server.start(this.port);
      this.port = this.server.identity.primaryPort;
      this.started = true;
      if (cb) {
        cb();
      }
    } catch (ex) {
      _("==========================================");
      _("Got exception starting Storage HTTP server on port " + this.port);
      _("Error: " + Log.exceptionStr(ex));
      _("Is there a process already listening on port " + this.port + "?");
      _("==========================================");
      do_throw(ex);
    }
  },

  /**
   * Start the server synchronously.
   *
   * @param port
   *        The numeric port on which to start. The default is to choose
   *        any available port.
   */
  startSynchronous: function startSynchronous(port=-1) {
    let cb = Async.makeSpinningCallback();
    this.start(port, cb);
    cb.wait();
  },

  /**
   * Stop the StorageServer's HTTP server.
   *
   * @param cb
   *        A callback function. Invoked after the server has been stopped.
   *
   */
  stop: function stop(cb) {
    if (!this.started) {
      this._log.warn("StorageServer: Warning: server not running. Can't stop " +
                     "me now!");
      return;
    }

    this.server.stop(cb);
    this.started = false;
  },

  serverTime: function serverTime() {
    return new_timestamp();
  },

  /**
   * Create a new user, complete with an empty set of collections.
   *
   * @param username
   *        The username to use. An Error will be thrown if a user by that name
   *        already exists.
   * @param password
   *        A password string.
   *
   * @return a user object, as would be returned by server.user(username).
   */
  registerUser: function registerUser(username, password) {
    if (username in this.users) {
      throw new Error("User already exists.");
    }

    if (!isFinite(parseInt(username))) {
      throw new Error("Usernames must be numeric: " + username);
    }

    this._log.info("Registering new user with server: " + username);
    this.users[username] = {
      password: password,
      collections: {},
      quota: this.DEFAULT_QUOTA,
    };
    return this.user(username);
  },

  userExists: function userExists(username) {
    return username in this.users;
  },

  getCollection: function getCollection(username, collection) {
    return this.users[username].collections[collection];
  },

  _insertCollection: function _insertCollection(collections, collection, bsos) {
    let coll = new StorageServerCollection(bsos, true);
    coll.collectionHandler = coll.handler();
    collections[collection] = coll;
    return coll;
  },

  createCollection: function createCollection(username, collection, bsos) {
    if (!(username in this.users)) {
      throw new Error("Unknown user.");
    }
    let collections = this.users[username].collections;
    if (collection in collections) {
      throw new Error("Collection already exists.");
    }
    return this._insertCollection(collections, collection, bsos);
  },

  deleteCollection: function deleteCollection(username, collection) {
    if (!(username in this.users)) {
      throw new Error("Unknown user.");
    }
    delete this.users[username].collections[collection];
  },

  /**
   * Accept a map like the following:
   * {
   *   meta: {global: {version: 1, ...}},
   *   crypto: {"keys": {}, foo: {bar: 2}},
   *   bookmarks: {}
   * }
   * to cause collections and BSOs to be created.
   * If a collection already exists, no error is raised.
   * If a BSO already exists, it will be updated to the new contents.
   */
  createContents: function createContents(username, collections) {
    if (!(username in this.users)) {
      throw new Error("Unknown user.");
    }
    let userCollections = this.users[username].collections;
    for (let [id, contents] of Object.entries(collections)) {
      let coll = userCollections[id] ||
                 this._insertCollection(userCollections, id);
      for (let [bsoID, payload] of Object.entries(contents)) {
        coll.insert(bsoID, payload);
      }
    }
  },

  /**
   * Insert a BSO in an existing collection.
   */
  insertBSO: function insertBSO(username, collection, bso) {
    if (!(username in this.users)) {
      throw new Error("Unknown user.");
    }
    let userCollections = this.users[username].collections;
    if (!(collection in userCollections)) {
      throw new Error("Unknown collection.");
    }
    userCollections[collection].insertBSO(bso);
    return bso;
  },

  /**
   * Delete all of the collections for the named user.
   *
   * @param username
   *        The name of the affected user.
   */
  deleteCollections: function deleteCollections(username) {
    if (!(username in this.users)) {
      throw new Error("Unknown user.");
    }
    let userCollections = this.users[username].collections;
    for (let name in userCollections) {
      let coll = userCollections[name];
      this._log.trace("Bulk deleting " + name + " for " + username + "...");
      coll.delete({});
    }
    this.users[username].collections = {};
  },

  getQuota: function getQuota(username) {
    if (!(username in this.users)) {
      throw new Error("Unknown user.");
    }

    return this.users[username].quota;
  },

  /**
   * Obtain the newest timestamp of all collections for a user.
   */
  newestCollectionTimestamp: function newestCollectionTimestamp(username) {
    let collections = this.users[username].collections;
    let newest = 0;
    for (let name in collections) {
      let collection = collections[name];
      if (collection.timestamp > newest) {
        newest = collection.timestamp;
      }
    }

    return newest;
  },

  /**
   * Compute the object that is returned for an info/collections request.
   */
  infoCollections: function infoCollections(username) {
    let responseObject = {};
    let colls = this.users[username].collections;
    for (let coll in colls) {
      responseObject[coll] = colls[coll].timestamp;
    }
    this._log.trace("StorageServer: info/collections returning " +
                    JSON.stringify(responseObject));
    return responseObject;
  },

  infoCounts: function infoCounts(username) {
    let data = {};
    let collections = this.users[username].collections;
    for (let [k, v] of Object.entries(collections)) {
      let count = v.count();
      if (!count) {
        continue;
      }

      data[k] = count;
    }

    return data;
  },

  infoUsage: function infoUsage(username) {
    let data = {};
    let collections = this.users[username].collections;
    for (let [k, v] of Object.entries(collections)) {
      data[k] = v.totalPayloadSize;
    }

    return data;
  },

  infoQuota: function infoQuota(username) {
    let total = 0;
    let usage = this.infoUsage(username);
    for (let key in usage) {
      let value = usage[key];
      total += value;
    }

    return {
      quota: this.getQuota(username),
      usage: total
    };
  },

  /**
   * Simple accessor to allow collective binding and abbreviation of a bunch of
   * methods. Yay!
   * Use like this:
   *
   *   let u = server.user("john");
   *   u.collection("bookmarks").bso("abcdefg").payload;  // Etc.
   *
   * @return a proxy for the user data stored in this server.
   */
  user: function user(username) {
    let collection       = this.getCollection.bind(this, username);
    let createCollection = this.createCollection.bind(this, username);
    let createContents   = this.createContents.bind(this, username);
    let modified         = function (collectionName) {
      return collection(collectionName).timestamp;
    }
    let deleteCollections = this.deleteCollections.bind(this, username);
    let quota             = this.getQuota.bind(this, username);
    return {
      collection:        collection,
      createCollection:  createCollection,
      createContents:    createContents,
      deleteCollections: deleteCollections,
      modified:          modified,
      quota:             quota,
    };
  },

  _pruneExpired: function _pruneExpired() {
    let now = Date.now();

    for (let username in this.users) {
      let user = this.users[username];
      for (let name in user.collections) {
        let collection = user.collections[name];
        for (let bso of collection.bsos()) {
          // ttl === 0 is a special case, so we can't simply !ttl.
          if (typeof(bso.ttl) != "number") {
            continue;
          }

          let ttlDate = bso.modified + (bso.ttl * 1000);
          if (ttlDate < now) {
            this._log.info("Deleting BSO because TTL expired: " + bso.id);
            bso.delete();
          }
        }
      }
    }
  },

  /*
   * Regular expressions for splitting up Storage request paths.
   * Storage URLs are of the form:
   *   /$apipath/$version/$userid/$further
   * where $further is usually:
   *   storage/$collection/$bso
   * or
   *   storage/$collection
   * or
   *   info/$op
   *
   * We assume for the sake of simplicity that $apipath is empty.
   *
   * N.B., we don't follow any kind of username spec here, because as far as I
   * can tell there isn't one. See Bug 689671. Instead we follow the Python
   * server code.
   *
   * Path: [all, version, first, rest]
   * Storage: [all, collection?, id?]
   */
  pathRE: /^\/([0-9]+(?:\.[0-9]+)?)(?:\/([0-9]+)\/([^\/]+)(?:\/(.+))?)?$/,
  storageRE: /^([-_a-zA-Z0-9]+)(?:\/([-_a-zA-Z0-9]+)\/?)?$/,

  defaultHeaders: {},

  /**
   * HTTP response utility.
   */
  respond: function respond(req, resp, code, status, body, headers, timestamp) {
    this._log.info("Response: " + code + " " + status);
    resp.setStatusLine(req.httpVersion, code, status);
    if (!headers) {
      headers = this.defaultHeaders;
    }
    for (let header in headers) {
      let value = headers[header];
      resp.setHeader(header, value, false);
    }

    if (timestamp) {
      resp.setHeader("X-Timestamp", "" + timestamp, false);
    }

    if (body) {
      resp.bodyOutputStream.write(body, body.length);
    }
  },

  /**
   * This is invoked by the HttpServer. `this` is bound to the StorageServer;
   * `handler` is the HttpServer's handler.
   *
   * TODO: need to use the correct Storage API response codes and errors here.
   */
  handleDefault: function handleDefault(handler, req, resp) {
    this.requestCount++;
    let timestamp = new_timestamp();
    try {
      this._handleDefault(handler, req, resp, timestamp);
    } catch (e) {
      if (e instanceof HttpError) {
        this.respond(req, resp, e.code, e.description, "", {}, timestamp);
      } else {
        this._log.warn("StorageServer: handleDefault caught an error", e);
        throw e;
      }
    }
  },

  _handleDefault: function _handleDefault(handler, req, resp, timestamp) {
    let path = req.path;
    if (req.queryString.length) {
      path += "?" + req.queryString;
    }

    this._log.debug("StorageServer: Handling request: " + req.method + " " +
                    path);

    if (this.callback.onRequest) {
      this.callback.onRequest(req);
    }

    // Prune expired records for all users at top of request. This is the
    // easiest way to process TTLs since all requests go through here.
    this._pruneExpired();

    req.timestamp = timestamp;
    resp.setHeader("X-Timestamp", "" + timestamp, false);

    let parts = this.pathRE.exec(req.path);
    if (!parts) {
      this._log.debug("StorageServer: Unexpected request: bad URL " + req.path);
      throw HTTP_404;
    }

    let [all, version, userPath, first, rest] = parts;
    if (version != STORAGE_API_VERSION) {
      this._log.debug("StorageServer: Unknown version.");
      throw HTTP_404;
    }

    let username;

    // By default, the server requires users to be authenticated. When a
    // request arrives, the user must have been previously configured and
    // the request must have authentication. In "allow all users" mode, we
    // take the username from the URL, create the user on the fly, and don't
    // perform any authentication.
    if (!this.allowAllUsers) {
      // Enforce authentication.
      if (!req.hasHeader("authorization")) {
        this.respond(req, resp, 401, "Authorization Required", "{}", {
          "WWW-Authenticate": 'Basic realm="secret"'
        });
        return;
      }

      let ensureUserExists = function ensureUserExists(username) {
        if (this.userExists(username)) {
          return;
        }

        this._log.info("StorageServer: Unknown user: " + username);
        throw HTTP_401;
      }.bind(this);

      let auth = req.getHeader("authorization");
      this._log.debug("Authorization: " + auth);

      if (auth.indexOf("Basic ") == 0) {
        let decoded = CommonUtils.safeAtoB(auth.substr(6));
        this._log.debug("Decoded Basic Auth: " + decoded);
        let [user, password] = decoded.split(":", 2);

        if (!password) {
          this._log.debug("Malformed HTTP Basic Authorization header: " + auth);
          throw HTTP_400;
        }

        this._log.debug("Got HTTP Basic auth for user: " + user);
        ensureUserExists(user);
        username = user;

        if (this.users[user].password != password) {
          this._log.debug("StorageServer: Provided password is not correct.");
          throw HTTP_401;
        }
      // TODO support token auth.
      } else {
        this._log.debug("Unsupported HTTP authorization type: " + auth);
        throw HTTP_500;
      }
    // All users mode.
    } else {
      // Auto create user with dummy password.
      if (!this.userExists(userPath)) {
        this.registerUser(userPath, "DUMMY-PASSWORD-*&%#");
      }

      username = userPath;
    }

    // Hand off to the appropriate handler for this path component.
    if (first in this.toplevelHandlers) {
      let handler = this.toplevelHandlers[first];
      try {
        return handler.call(this, handler, req, resp, version, username, rest);
      } catch (ex) {
        this._log.warn("Got exception during request", ex);
        throw ex;
      }
    }
    this._log.debug("StorageServer: Unknown top-level " + first);
    throw HTTP_404;
  },

  /**
   * Collection of the handler methods we use for top-level path components.
   */
  toplevelHandlers: {
    "storage": function handleStorage(handler, req, resp, version, username,
                                      rest) {
      let respond = this.respond.bind(this, req, resp);
      if (!rest || !rest.length) {
        this._log.debug("StorageServer: top-level storage " +
                        req.method + " request.");

        if (req.method != "DELETE") {
          respond(405, "Method Not Allowed", null, {"Allow": "DELETE"});
          return;
        }

        this.user(username).deleteCollections();

        respond(204, "No Content");
        return;
      }

      let match = this.storageRE.exec(rest);
      if (!match) {
        this._log.warn("StorageServer: Unknown storage operation " + rest);
        throw HTTP_404;
      }
      let [all, collection, bsoID] = match;
      let coll = this.getCollection(username, collection);
      let collectionExisted = !!coll;

      switch (req.method) {
        case "GET":
          // Tried to GET on a collection that doesn't exist.
          if (!coll) {
            respond(404, "Not Found");
            return;
          }

          // No BSO URL parameter goes to collection handler.
          if (!bsoID) {
            return coll.collectionHandler(req, resp);
          }

          // Handle non-existent BSO.
          let bso = coll.bso(bsoID);
          if (!bso) {
            respond(404, "Not Found");
            return;
          }

          // Proxy to BSO handler.
          return bso.getHandler(req, resp);

        case "DELETE":
          // Collection doesn't exist.
          if (!coll) {
            respond(404, "Not Found");
            return;
          }

          // Deleting a specific BSO.
          if (bsoID) {
            let bso = coll.bso(bsoID);

            // BSO does not exist on the server. Nothing to do.
            if (!bso) {
              respond(404, "Not Found");
              return;
            }

            if (req.hasHeader("x-if-unmodified-since")) {
              let modified = parseInt(req.getHeader("x-if-unmodified-since"));
              CommonUtils.ensureMillisecondsTimestamp(modified);

              if (bso.modified > modified) {
                respond(412, "Precondition Failed");
                return;
              }
            }

            bso.delete();
            coll.timestamp = req.timestamp;
            this.callback.onItemDeleted(username, collection, bsoID);
            respond(204, "No Content");
            return;
          }

          // Proxy to collection handler.
          coll.collectionHandler(req, resp);

          // Spot if this is a DELETE for some IDs, and don't blow away the
          // whole collection!
          //
          // We already handled deleting the BSOs by invoking the deleted
          // collection's handler. However, in the case of
          //
          //   DELETE storage/foobar
          //
          // we also need to remove foobar from the collections map. This
          // clause tries to differentiate the above request from
          //
          //  DELETE storage/foobar?ids=foo,baz
          //
          // and do the right thing.
          // TODO: less hacky method.
          if (-1 == req.queryString.indexOf("ids=")) {
            // When you delete the entire collection, we drop it.
            this._log.debug("Deleting entire collection.");
            delete this.users[username].collections[collection];
            this.callback.onCollectionDeleted(username, collection);
          }

          // Notify of item deletion.
          let deleted = resp.deleted || [];
          for (let i = 0; i < deleted.length; ++i) {
            this.callback.onItemDeleted(username, collection, deleted[i]);
          }
          return;

        case "POST":
        case "PUT":
          // Auto-create collection if it doesn't exist.
          if (!coll) {
            coll = this.createCollection(username, collection);
          }

          try {
            if (bsoID) {
              let bso = coll.bso(bsoID);
              if (!bso) {
                this._log.trace("StorageServer: creating BSO " + collection +
                                "/" + bsoID);
                try {
                  bso = coll.insert(bsoID);
                } catch (ex) {
                  return sendMozSvcError(req, resp, "8");
                }
              }

              bso.putHandler(req, resp);

              coll.timestamp = req.timestamp;
              return resp;
            }

            return coll.collectionHandler(req, resp);
          } catch (ex) {
            if (ex instanceof HttpError) {
              if (!collectionExisted) {
                this.deleteCollection(username, collection);
              }
            }

            throw ex;
          }

        default:
          throw new Error("Request method " + req.method + " not implemented.");
      }
    },

    "info": function handleInfo(handler, req, resp, version, username, rest) {
      switch (rest) {
        case "collections":
          return this.handleInfoCollections(req, resp, username);

        case "collection_counts":
          return this.handleInfoCounts(req, resp, username);

        case "collection_usage":
          return this.handleInfoUsage(req, resp, username);

        case "quota":
          return this.handleInfoQuota(req, resp, username);

        default:
          this._log.warn("StorageServer: Unknown info operation " + rest);
          throw HTTP_404;
      }
    }
  },

  handleInfoConditional: function handleInfoConditional(request, response,
                                                        user) {
    if (!request.hasHeader("x-if-modified-since")) {
      return false;
    }

    let requestModified = request.getHeader("x-if-modified-since");
    requestModified = parseInt(requestModified, 10);

    let serverModified = this.newestCollectionTimestamp(user);

    this._log.info("Server mtime: " + serverModified + "; Client modified: " +
                   requestModified);
    if (serverModified > requestModified) {
      return false;
    }

    this.respond(request, response, 304, "Not Modified", null, {
      "X-Last-Modified": "" + serverModified
    });

    return true;
  },

  handleInfoCollections: function handleInfoCollections(request, response,
                                                        user) {
    if (this.handleInfoConditional(request, response, user)) {
      return;
    }

    let info = this.infoCollections(user);
    let body = JSON.stringify(info);
    this.respond(request, response, 200, "OK", body, {
      "Content-Type":    "application/json",
      "X-Last-Modified": "" + this.newestCollectionTimestamp(user),
    });
  },

  handleInfoCounts: function handleInfoCounts(request, response, user) {
    if (this.handleInfoConditional(request, response, user)) {
      return;
    }

    let counts = this.infoCounts(user);
    let body = JSON.stringify(counts);

    this.respond(request, response, 200, "OK", body, {
      "Content-Type":    "application/json",
      "X-Last-Modified": "" + this.newestCollectionTimestamp(user),
    });
  },

  handleInfoUsage: function handleInfoUsage(request, response, user) {
    if (this.handleInfoConditional(request, response, user)) {
      return;
    }

    let body = JSON.stringify(this.infoUsage(user));
    this.respond(request, response, 200, "OK", body, {
      "Content-Type":    "application/json",
      "X-Last-Modified": "" + this.newestCollectionTimestamp(user),
    });
  },

  handleInfoQuota: function handleInfoQuota(request, response, user) {
    if (this.handleInfoConditional(request, response, user)) {
      return;
    }

    let body = JSON.stringify(this.infoQuota(user));
    this.respond(request, response, 200, "OK", body, {
      "Content-Type":    "application/json",
      "X-Last-Modified": "" + this.newestCollectionTimestamp(user),
    });
  },
};

/**
 * Helper to create a storage server for a set of users.
 *
 * Each user is specified by a map of username to password.
 */
this.storageServerForUsers =
 function storageServerForUsers(users, contents, callback) {
  let server = new StorageServer(callback);
  for (let [user, pass] of Object.entries(users)) {
    server.registerUser(user, pass);
    server.createContents(user, contents);
  }
  server.start();
  return server;
}
