/* import-globals-from head_appinfo.js */
/* import-globals-from ../../../common/tests/unit/head_helpers.js */
/* import-globals-from head_helpers.js */

var Cm = Components.manager;

// Shared logging for all HTTP server functions.
ChromeUtils.import("resource://gre/modules/Log.jsm");
ChromeUtils.import("resource://services-common/utils.js");
ChromeUtils.import("resource://testing-common/TestUtils.jsm");
ChromeUtils.import("resource://testing-common/services/sync/utils.js");

const SYNC_HTTP_LOGGER = "Sync.Test.Server";

// While the sync code itself uses 1.5, the tests hard-code 1.1,
// so we're sticking with 1.1 here.
const SYNC_API_VERSION = "1.1";

// Use the same method that record.js does, which mirrors the server.
// The server returns timestamps with 1/100 sec granularity. Note that this is
// subject to change: see Bug 650435.
function new_timestamp() {
  return Math.round(Date.now() / 10) / 100;
}

function return_timestamp(request, response, timestamp) {
  if (!timestamp) {
    timestamp = new_timestamp();
  }
  let body = "" + timestamp;
  response.setHeader("X-Weave-Timestamp", body);
  response.setStatusLine(request.httpVersion, 200, "OK");
  response.bodyOutputStream.write(body, body.length);
  return timestamp;
}

function has_hawk_header(req) {
  return req.hasHeader("Authorization") &&
         req.getHeader("Authorization").startsWith("Hawk");
}

function basic_auth_header(user, password) {
  return "Basic " + btoa(user + ":" + CommonUtils.encodeUTF8(password));
}

function basic_auth_matches(req, user, password) {
  if (!req.hasHeader("Authorization")) {
    return false;
  }

  let expected = basic_auth_header(user, CommonUtils.encodeUTF8(password));
  return req.getHeader("Authorization") == expected;
}

function httpd_basic_auth_handler(body, metadata, response) {
  if (basic_auth_matches(metadata, "guest", "guest")) {
    response.setStatusLine(metadata.httpVersion, 200, "OK, authorized");
    response.setHeader("WWW-Authenticate", 'Basic realm="secret"', false);
  } else {
    body = "This path exists and is protected - failed";
    response.setStatusLine(metadata.httpVersion, 401, "Unauthorized");
    response.setHeader("WWW-Authenticate", 'Basic realm="secret"', false);
  }
  response.bodyOutputStream.write(body, body.length);
}

/*
 * Represent a WBO on the server
 */
function ServerWBO(id, initialPayload, modified) {
  if (!id) {
    throw new Error("No ID for ServerWBO!");
  }
  this.id = id;
  if (!initialPayload) {
    return;
  }

  if (typeof initialPayload == "object") {
    initialPayload = JSON.stringify(initialPayload);
  }
  this.payload = initialPayload;
  this.modified = modified || new_timestamp();
  this.sortindex = 0;
}
ServerWBO.prototype = {

  get data() {
    return JSON.parse(this.payload);
  },

  get() {
    return JSON.stringify(this, ["id", "modified", "payload"]);
  },

  put(input) {
    input = JSON.parse(input);
    this.payload = input.payload;
    this.modified = new_timestamp();
    this.sortindex = input.sortindex || 0;
  },

  delete() {
    delete this.payload;
    delete this.modified;
    delete this.sortindex;
  },

  // This handler sets `newModified` on the response body if the collection
  // timestamp has changed. This allows wrapper handlers to extract information
  // that otherwise would exist only in the body stream.
  handler() {
    let self = this;

    return function(request, response) {
      var statusCode = 200;
      var status = "OK";
      var body;

      switch (request.method) {
        case "GET":
          if (self.payload) {
            body = self.get();
          } else {
            statusCode = 404;
            status = "Not Found";
            body = "Not Found";
          }
          break;

        case "PUT":
          self.put(readBytesFromInputStream(request.bodyInputStream));
          body = JSON.stringify(self.modified);
          response.setHeader("Content-Type", "application/json");
          response.newModified = self.modified;
          break;

        case "DELETE":
          self.delete();
          let ts = new_timestamp();
          body = JSON.stringify(ts);
          response.setHeader("Content-Type", "application/json");
          response.newModified = ts;
          break;
      }
      response.setHeader("X-Weave-Timestamp", "" + new_timestamp(), false);
      response.setStatusLine(request.httpVersion, statusCode, status);
      response.bodyOutputStream.write(body, body.length);
    };
  },

  /**
   * Get the cleartext data stored in the payload.
   *
   * This isn't `get cleartext`, because `x.cleartext.blah = 3;` wouldn't work,
   * which seems like a footgun.
   */
  getCleartext() {
    return JSON.parse(JSON.parse(this.payload).ciphertext);
  },

  /**
   * Setter for getCleartext(), but lets you adjust the modified timestamp too.
   * Returns this ServerWBO object.
   */
  setCleartext(cleartext, modifiedTimestamp = this.modified) {
    this.payload = JSON.stringify(encryptPayload(cleartext));
    this.modified = modifiedTimestamp;
    return this;
  },

};


/**
 * Represent a collection on the server. The '_wbos' attribute is a
 * mapping of id -> ServerWBO 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 wbos
 *        An object mapping WBO IDs to ServerWBOs.
 * @param acceptNew
 *        If true, POSTs to this collection URI will result in new WBOs 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().
 *
 * @return the new ServerCollection instance.
 *
 */
function ServerCollection(wbos, acceptNew, timestamp) {
  this._wbos = wbos || {};
  this.acceptNew = acceptNew || false;

  /*
   * Track modified timestamp.
   * We can't just use the timestamps of contained WBOs: an empty collection
   * has a modified time.
   */
  this.timestamp = timestamp || new_timestamp();
  this._log = Log.repository.getLogger(SYNC_HTTP_LOGGER);
}
ServerCollection.prototype = {

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

  /**
   * Convenience method to get an array of WBOs.
   * Optionally provide a filter function.
   *
   * @param filter
   *        A predicate function, applied to the WBO, which dictates whether to
   *        include the WBO in the output.
   *
   * @return an array of ServerWBOs.
   */
  wbos: function wbos(filter) {
    let os = [];
    for (let wbo of Object.values(this._wbos)) {
      if (wbo.payload) {
        os.push(wbo);
      }
    }

    if (filter) {
      return os.filter(filter);
    }
    return os;
  },

  /**
   * Convenience method to get an array of parsed ciphertexts.
   *
   * @return an array of the payloads of each stored WBO.
   */
  payloads() {
    return this.wbos().map(wbo => wbo.getCleartext());
  },

  // Just for syntactic elegance.
  wbo: function wbo(id) {
    return this._wbos[id];
  },

  payload: function payload(id) {
    return this.wbo(id).payload;
  },

  cleartext(id) {
    return this.wbo(id).getCleartext();
  },

  /**
   * Insert the provided WBO under its ID.
   *
   * @return the provided WBO.
   */
  insertWBO: function insertWBO(wbo) {
    this.timestamp = Math.max(this.timestamp, wbo.modified);
    return this._wbos[wbo.id] = wbo;
  },

  /**
   * Update an existing WBO's cleartext using a callback function that modifies
   * the record in place, or returns a new record.
   */
  updateRecord(id, updateCallback, optTimestamp) {
    let wbo = this.wbo(id);
    if (!wbo) {
      throw new Error("No record with provided ID");
    }
    let curCleartext = wbo.getCleartext();
    // Allow update callback to either return a new cleartext, or modify in place.
    let newCleartext = updateCallback(curCleartext) || curCleartext;
    wbo.setCleartext(newCleartext, optTimestamp);
    // It is already inserted, but we might need to update our timestamp based
    // on it's `modified` value, if `optTimestamp` was provided.
    return this.insertWBO(wbo);
  },

  /**
   * Insert a record, which may either an object with a cleartext property, or
   * the cleartext property itself.
   */
  insertRecord(record, timestamp = Date.now() / 1000) {
    if (typeof timestamp != "number") {
      throw new TypeError("insertRecord: Timestamp is not a number.");
    }
    if (!record.id) {
      throw new Error("Attempt to insert record with no id");
    }
    // Allow providing either the cleartext directly, or the CryptoWrapper-like.
    let cleartext = record.cleartext || record;
    return this.insert(record.id, encryptPayload(cleartext), timestamp);
  },

  /**
   * Insert the provided payload as part of a new ServerWBO with the provided
   * ID.
   *
   * @param id
   *        The GUID for the WBO.
   * @param payload
   *        The payload, as provided to the ServerWBO constructor.
   * @param modified
   *        An optional modified time for the ServerWBO.
   *
   * @return the inserted WBO.
   */
  insert: function insert(id, payload, modified) {
    return this.insertWBO(new ServerWBO(id, payload, modified));
  },

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

  _inResultSet(wbo, options) {
    return wbo.payload
           && (!options.ids || (options.ids.includes(wbo.id)))
           && (!options.newer || (wbo.modified > options.newer))
           && (!options.older || (wbo.modified < options.older));
  },

  count(options) {
    options = options || {};
    let c = 0;
    for (let wbo of Object.values(this._wbos)) {
      if (wbo.modified && this._inResultSet(wbo, options)) {
        c++;
      }
    }
    return c;
  },

  get(options, request) {
    let data = [];
    for (let wbo of Object.values(this._wbos)) {
      if (wbo.modified && this._inResultSet(wbo, options)) {
        data.push(wbo);
      }
    }
    switch (options.sort) {
      case "newest":
        data.sort((a, b) => b.modified - a.modified);
        break;

      case "oldest":
        data.sort((a, b) => a.modified - b.modified);
        break;

      case "index":
        data.sort((a, b) => b.sortindex - a.sortindex);
        break;

      default:
        if (options.sort) {
          this._log.error("Error: client requesting unknown sort order",
                          options.sort);
          throw new Error("Unknown sort order");
        }
        // If the client didn't request a sort order, shuffle the records
        // to ensure that we don't accidentally depend on the default order.
        TestUtils.shuffle(data);
    }
    if (options.full) {
      data = data.map(wbo => wbo.get());
      let start = options.offset || 0;
      if (options.limit) {
        let numItemsPastOffset = data.length - start;
        data = data.slice(start, start + options.limit);
        // use options as a backchannel to set x-weave-next-offset
        if (numItemsPastOffset > options.limit) {
          options.nextOffset = start + options.limit;
        }
      } else if (start) {
        data = data.slice(start);
      }

      if (request && request.getHeader("accept") == "application/newlines") {
        this._log.error("Error: client requesting application/newlines content");
        throw new Error("This server should not serve application/newlines content");
      }

      // Use options as a backchannel to report count.
      options.recordCount = data.length;
    } else {
      data = data.map(wbo => wbo.id);
      let start = options.offset || 0;
      if (options.limit) {
        data = data.slice(start, start + options.limit);
        options.nextOffset = start + options.limit;
      } else if (start) {
        data = data.slice(start);
      }
      options.recordCount = data.length;
    }
    return JSON.stringify(data);
  },

  post(input) {
    input = JSON.parse(input);
    let success = [];
    let failed = {};

    // This will count records where we have an existing ServerWBO
    // registered with us as successful and all other records as failed.
    for (let key in input) {
      let record = input[key];
      let wbo = this.wbo(record.id);
      if (!wbo && this.acceptNew) {
        this._log.debug("Creating WBO " + JSON.stringify(record.id) +
                        " on the fly.");
        wbo = new ServerWBO(record.id);
        this.insertWBO(wbo);
      }
      if (wbo) {
        wbo.payload = record.payload;
        wbo.modified = new_timestamp();
        wbo.sortindex = record.sortindex || 0;
        success.push(record.id);
      } else {
        failed[record.id] = "no wbo configured";
      }
    }
    return {modified: new_timestamp(),
            success,
            failed};
  },

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

  // This handler sets `newModified` on the response body if the collection
  // timestamp has changed.
  handler() {
    let self = this;

    return function(request, response) {
      var statusCode = 200;
      var status = "OK";
      var body;

      // Parse queryString
      let options = {};
      for (let chunk of request.queryString.split("&")) {
        if (!chunk) {
          continue;
        }
        chunk = chunk.split("=");
        if (chunk.length == 1) {
          options[chunk[0]] = "";
        } else {
          options[chunk[0]] = chunk[1];
        }
      }
      // The real servers return 400 if ids= is specified without a list of IDs.
      if (options.hasOwnProperty("ids")) {
        if (!options.ids) {
          response.setStatusLine(request.httpVersion, "400", "Bad Request");
          body = "Bad Request";
          response.bodyOutputStream.write(body, body.length);
          return;
        }
        options.ids = options.ids.split(",");
      }
      if (options.newer) {
        options.newer = parseFloat(options.newer);
      }
      if (options.older) {
        options.older = parseFloat(options.older);
      }
      if (options.limit) {
        options.limit = parseInt(options.limit, 10);
      }
      if (options.offset) {
        options.offset = parseInt(options.offset, 10);
      }

      switch (request.method) {
        case "GET":
          body = self.get(options, request);
          // see http://moz-services-docs.readthedocs.io/en/latest/storage/apis-1.5.html
          // for description of these headers.
          let { recordCount: records, nextOffset } = options;

          self._log.info("Records: " + records + ", nextOffset: " + nextOffset);
          if (records != null) {
            response.setHeader("X-Weave-Records", "" + records);
          }
          if (nextOffset) {
            response.setHeader("X-Weave-Next-Offset", "" + nextOffset);
          }
          response.setHeader("X-Last-Modified", "" + self.timestamp);
          break;

        case "POST":
          let res = self.post(readBytesFromInputStream(request.bodyInputStream), request);
          body = JSON.stringify(res);
          response.newModified = res.modified;
          break;

        case "DELETE":
          self._log.debug("Invoking ServerCollection.DELETE.");
          let deleted = self.delete(options, request);
          let ts = new_timestamp();
          body = JSON.stringify(ts);
          response.newModified = ts;
          response.deleted = deleted;
          break;
      }
      response.setHeader("X-Weave-Timestamp",
                         "" + new_timestamp(),
                         false);

      // Update the collection timestamp to the appropriate modified time.
      // This is either a value set by the handler, or the current time.
      if (request.method != "GET") {
        self.timestamp = (response.newModified >= 0) ?
                         response.newModified :
                         new_timestamp();
      }
      response.setHeader("X-Last-Modified", "" + self.timestamp, false);

      response.setStatusLine(request.httpVersion, statusCode, status);
      response.bodyOutputStream.write(body, body.length);
    };
  }

};

/*
 * Test setup helpers.
 */
function sync_httpd_setup(handlers) {
  handlers["/1.1/foo/storage/meta/global"]
      = (new ServerWBO("global", {})).handler();
  return httpd_setup(handlers);
}

/*
 * Track collection modified times. Return closures.
 *
 * XXX - DO NOT USE IN NEW TESTS
 *
 * This code has very limited and very hacky timestamp support - the test
 * server now has more complete and correct support - using this helper
 * may cause strangeness wrt timestamp headers and 412 responses.
 */
function track_collections_helper() {

  /*
   * Our tracking object.
   */
  let collections = {};

  /*
   * Update the timestamp of a collection.
   */
  function update_collection(coll, ts) {
    _("Updating collection " + coll + " to " + ts);
    let timestamp = ts || new_timestamp();
    collections[coll] = timestamp;
  }

  /*
   * Invoke a handler, updating the collection's modified timestamp unless
   * it's a GET request.
   */
  function with_updated_collection(coll, f) {
    return function(request, response) {
      f.call(this, request, response);

      // Update the collection timestamp to the appropriate modified time.
      // This is either a value set by the handler, or the current time.
      if (request.method != "GET") {
        update_collection(coll, response.newModified);
      }
    };
  }

  /*
   * Return the info/collections object.
   */
  function info_collections(request, response) {
    let body = "Error.";
    switch (request.method) {
      case "GET":
        body = JSON.stringify(collections);
        break;
      default:
        throw new Error("Non-GET on info_collections.");
    }

    response.setHeader("Content-Type", "application/json");
    response.setHeader("X-Weave-Timestamp",
                       "" + new_timestamp(),
                       false);
    response.setStatusLine(request.httpVersion, 200, "OK");
    response.bodyOutputStream.write(body, body.length);
  }

  return {"collections": collections,
          "handler": info_collections,
          "with_updated_collection": with_updated_collection,
          "update_collection": update_collection};
}

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

/**
 * In general, the preferred way of using SyncServer 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.
 */
var SyncServerCallback = {
  onCollectionDeleted: function onCollectionDeleted(user, collection) {},
  onItemDeleted: function onItemDeleted(user, collection, wboID) {},

  /**
   * 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.
   * The response is also passed so the callback can set headers etc - but care
   * must be taken to not screw with the response body or headers that may
   * conflict with normal operation of this server.
   */
  onRequest: function onRequest(request, response) {},
};

/**
 * Construct a new test Sync server. Takes a callback object (e.g.,
 * SyncServerCallback) as input.
 */
function SyncServer(callback) {
  this.callback = callback || {__proto__: SyncServerCallback};
  this.server   = new HttpServer();
  this.started  = false;
  this.users    = {};
  this._log     = Log.repository.getLogger(SYNC_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);
}
SyncServer.prototype = {
  server: null,    // HttpServer.
  users:  null,    // Map of username => {collections, password}.

  /**
   * Start the SyncServer's underlying HTTP server.
   *
   * @param port
   *        The numeric port on which to start. -1 implies the default, a
   *        randomly chosen port.
   * @param cb
   *        A callback function (of no arguments) which is invoked after
   *        startup.
   */
  start: function start(port = -1, cb) {
    if (this.started) {
      this._log.warn("Warning: server already started on " + this.port);
      return;
    }
    try {
      this.server.start(port);
      let i = this.server.identity;
      this.port = i.primaryPort;
      this.baseURI = i.primaryScheme + "://" + i.primaryHost + ":" +
                     i.primaryPort + "/";
      this.started = true;
      if (cb) {
        cb();
      }
    } catch (ex) {
      _("==========================================");
      _("Got exception starting Sync HTTP server.");
      _("Error: " + Log.exceptionStr(ex));
      _("Is there a process already listening on port " + port + "?");
      _("==========================================");
      do_throw(ex);
    }

  },

  /**
   * Stop the SyncServer'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("SyncServer: Warning: server not running. Can't stop me now!");
      return;
    }

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

  /**
   * Return a server timestamp for a record.
   * The server returns timestamps with 1/100 sec granularity. Note that this is
   * subject to change: see Bug 650435.
   */
  timestamp: function timestamp() {
    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.");
    }
    this.users[username] = {
      password,
      collections: {}
    };
    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, wbos) {
    let coll = new ServerCollection(wbos, true);
    coll.collectionHandler = coll.handler();
    collections[collection] = coll;
    return coll;
  },

  createCollection: function createCollection(username, collection, wbos) {
    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, wbos);
  },

  /**
   * Accept a map like the following:
   * {
   *   meta: {global: {version: 1, ...}},
   *   crypto: {"keys": {}, foo: {bar: 2}},
   *   bookmarks: {}
   * }
   * to cause collections and WBOs to be created.
   * If a collection already exists, no error is raised.
   * If a WBO 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 [wboID, payload] of Object.entries(contents)) {
        coll.insert(wboID, payload);
      }
    }
  },

  /**
   * Insert a WBO in an existing collection.
   */
  insertWBO: function insertWBO(username, collection, wbo) {
    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].insertWBO(wbo);
    return wbo;
  },

  /**
   * Delete all of the collections for the named user.
   *
   * @param username
   *        The name of the affected user.
   *
   * @return a timestamp.
   */
  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 = {};
    return this.timestamp();
  },

  /**
   * 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").wbo("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);
    return {
      collection,
      createCollection,
      createContents,
      deleteCollections,
      modified
    };
  },

  /*
   * Regular expressions for splitting up Sync request paths.
   * Sync URLs are of the form:
   *   /$apipath/$version/$user/$further
   * where $further is usually:
   *   storage/$collection/$wbo
   * 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, username, first, rest]
   * Storage: [all, collection?, id?]
   */
  pathRE: /^\/([0-9]+(?:\.[0-9]+)?)\/([-._a-zA-Z0-9]+)(?:\/([^\/]+)(?:\/(.+))?)?$/,
  storageRE: /^([-_a-zA-Z0-9]+)(?:\/([-_a-zA-Z0-9]+)\/?)?$/,

  defaultHeaders: {},

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

  /**
   * This is invoked by the HttpServer. `this` is bound to the SyncServer;
   * `handler` is the HttpServer's handler.
   *
   * TODO: need to use the correct Sync API response codes and errors here.
   * TODO: Basic Auth.
   * TODO: check username in path against username in BasicAuth.
   */
  handleDefault: function handleDefault(handler, req, resp) {
    try {
      this._handleDefault(handler, req, resp);
    } catch (e) {
      if (e instanceof HttpError) {
        this.respond(req, resp, e.code, e.description, "", {});
      } else {
        throw e;
      }
    }
  },

  _handleDefault: function _handleDefault(handler, req, resp) {
    this._log.debug("SyncServer: Handling request: " + req.method + " " + req.path);

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

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

    let [, version, username, first, rest] = parts;
    // Doing a float compare of the version allows for us to pretend there was
    // a node-reassignment - eg, we could re-assign from "1.1/user/" to
    // "1.10/user" - this server will then still accept requests with the new
    // URL while any code in sync itself which compares URLs will see a
    // different URL.
    if (parseFloat(version) != parseFloat(SYNC_API_VERSION)) {
      this._log.debug("SyncServer: Unknown version.");
      throw HTTP_404;
    }

    if (!this.userExists(username)) {
      this._log.debug("SyncServer: Unknown user.");
      throw HTTP_401;
    }

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

  /**
   * 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("SyncServer: info/collections returning " +
                    JSON.stringify(responseObject));
    return responseObject;
  },

  /**
   * 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("SyncServer: top-level storage " +
                        req.method + " request.");

        // TODO: verify if this is spec-compliant.
        if (req.method != "DELETE") {
          respond(405, "Method Not Allowed", "[]", {"Allow": "DELETE"});
          return undefined;
        }

        // Delete all collections and track the timestamp for the response.
        let timestamp = this.user(username).deleteCollections();

        // Return timestamp and OK for deletion.
        respond(200, "OK", JSON.stringify(timestamp));
        return undefined;
      }

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

      let checkXIUSFailure = () => {
        if (req.hasHeader("x-if-unmodified-since")) {
          let xius = parseFloat(req.getHeader("x-if-unmodified-since"));
          // Sadly the way our tests are setup, we often end up with xius of
          // zero (typically when syncing just one engine, so the date from
          // info/collections isn't used) - so we allow that to work.
          // Further, the Python server treats non-existing collections as
          // having a timestamp of 0.
          let collTimestamp = coll ? coll.timestamp : 0;
          if (xius && xius < collTimestamp) {
            this._log.info(`x-if-unmodified-since mismatch - request wants ${xius} but our collection has ${collTimestamp}`);
            respond(412, "precondition failed", "precondition failed");
            return true;
          }
        }
        return false;
      };

      switch (req.method) {
        case "GET": {
          if (!coll) {
            if (wboID) {
              respond(404, "Not found", "Not found");
              return undefined;
            }
            // *cries inside*: - apparently the real sync server returned 200
            // here for some time, then returned 404 for some time (bug 687299),
            // and now is back to 200 (bug 963332).
            respond(200, "OK", "[]");
            return undefined;
          }
          if (!wboID) {
            return coll.collectionHandler(req, resp);
          }
          let wbo = coll.wbo(wboID);
          if (!wbo) {
            respond(404, "Not found", "Not found");
            return undefined;
          }
          return wbo.handler()(req, resp);
        }
        case "DELETE": {
          if (!coll) {
            respond(200, "OK", "{}");
            return undefined;
          }
          if (checkXIUSFailure()) {
            return undefined;
          }
          if (wboID) {
            let wbo = coll.wbo(wboID);
            if (wbo) {
              wbo.delete();
              this.callback.onItemDeleted(username, collection, wboID);
            }
            respond(200, "OK", "{}");
            return undefined;
          }
          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 WBOs 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 undefined;
        }
        case "PUT":
          // PUT and POST have slightly different XIUS semantics - for PUT,
          // the check is against the item, whereas for POST it is against
          // the collection. So first, a special-case for PUT.
          if (req.hasHeader("x-if-unmodified-since")) {
            let xius = parseFloat(req.getHeader("x-if-unmodified-since"));
            // treat and xius of zero as if it wasn't specified - this happens
            // in some of our tests for a new collection.
            if (xius > 0) {
              let wbo = coll.wbo(wboID);
              if (xius < wbo.modified) {
                this._log.info(`x-if-unmodified-since mismatch - request wants ${xius} but wbo has ${wbo.modified}`);
                respond(412, "precondition failed", "precondition failed");
                return undefined;
              }
              wbo.handler()(req, resp);
              coll.timestamp = resp.newModified;
              return resp;
            }
          }
          // fall through to post.
        case "POST":
          if (checkXIUSFailure()) {
            return undefined;
          }
          if (!coll) {
            coll = this.createCollection(username, collection);
          }

          if (wboID) {
            let wbo = coll.wbo(wboID);
            if (!wbo) {
              this._log.trace("SyncServer: creating WBO " + collection + "/" + wboID);
              wbo = coll.insert(wboID);
            }
            // Rather than instantiate each WBO's handler function, do it once
            // per request. They get hit far less often than do collections.
            wbo.handler()(req, resp);
            coll.timestamp = resp.newModified;
            return resp;
          }
          return coll.collectionHandler(req, resp);
        default:
          throw new Error("Request method " + req.method + " not implemented.");
      }
    },

    "info": function handleInfo(handler, req, resp, version, username, rest) {
      switch (rest) {
        case "collections":
          let body = JSON.stringify(this.infoCollections(username));
          this.respond(req, resp, 200, "OK", body, {
            "Content-Type": "application/json"
          });
          return;
        case "collection_usage":
        case "collection_counts":
        case "quota":
          // TODO: implement additional info methods.
          this.respond(req, resp, 200, "OK", "TODO");
          return;
        default:
          // TODO
          this._log.warn("SyncServer: Unknown info operation " + rest);
          throw HTTP_404;
      }
    }
  }
};

/**
 * Test helper.
 */
function serverForUsers(users, contents, callback) {
  let server = new SyncServer(callback);
  for (let [user, pass] of Object.entries(users)) {
    server.registerUser(user, pass);
    server.createContents(user, contents);
  }
  server.start();
  return server;
}
