/* 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/. */

"use strict";

module.metadata = {
  "stability": "stable"
};

const { ns } = require("./core/namespace");
const { emit } = require("./event/core");
const { merge } = require("./util/object");
const { stringify } = require("./querystring");
const { EventTarget } = require("./event/target");
const { Class } = require("./core/heritage");
const { XMLHttpRequest, forceAllowThirdPartyCookie } = require("./net/xhr");
const apiUtils = require("./deprecated/api-utils");
const { isValidURI } = require("./url.js");

const response = ns();
const request = ns();

// Instead of creating a new validator for each request, just make one and
// reuse it.
const { validateOptions, validateSingleOption } = new OptionsValidator({
  url: {
    // Also converts a URL instance to string, bug 857902
    map: url => url.toString(),
    ok: isValidURI
  },
  headers: {
    map: v => v || {},
    is:  ["object"],
  },
  content: {
    map: v => v || null,
    is:  ["string", "object", "null"],
  },
  contentType: {
    map: v => v || "application/x-www-form-urlencoded",
    is:  ["string"],
  },
  overrideMimeType: {
    map: v => v || null,
    is: ["string", "null"],
  },
  anonymous: {
    map: v => v || false,
    is: ["boolean", "null"],
  }
});

const REUSE_ERROR = "This request object has been used already. You must " +
                    "create a new one to make a new request."

// Utility function to prep the request since it's the same between
// request types
function runRequest(mode, target) {
  let source = request(target)
  let { xhr, url, content, contentType, headers, overrideMimeType, anonymous } = source;

  let isGetOrHead = (mode == "GET" || mode == "HEAD");

  // If this request has already been used, then we can't reuse it.
  // Throw an error.
  if (xhr)
    throw new Error(REUSE_ERROR);

  xhr = source.xhr = new XMLHttpRequest({
    mozAnon: anonymous
  });

  // Build the data to be set. For GET or HEAD requests, we want to append that
  // to the URL before opening the request.
  let data = stringify(content);
  // If the URL already has ? in it, then we want to just use &
  if (isGetOrHead && data)
    url = url + (/\?/.test(url) ? "&" : "?") + data;

  // open the request
  xhr.open(mode, url);


  forceAllowThirdPartyCookie(xhr);

  // request header must be set after open, but before send
  xhr.setRequestHeader("Content-Type", contentType);

  // set other headers
  Object.keys(headers).forEach(function(name) {
    xhr.setRequestHeader(name, headers[name]);
  });

  // set overrideMimeType
  if (overrideMimeType)
    xhr.overrideMimeType(overrideMimeType);

  // handle the readystate, create the response, and call the callback
  xhr.onreadystatechange = function onreadystatechange() {
    if (xhr.readyState === 4) {
      let response = Response(xhr);
      source.response = response;
      emit(target, 'complete', response);
    }
  };

  // actually send the request.
  // We don't want to send data on GET or HEAD requests.
  xhr.send(!isGetOrHead ? data : null);
}

const Request = Class({
  extends: EventTarget,
  initialize: function initialize(options) {
    // `EventTarget.initialize` will set event listeners that are named
    // like `onEvent` in this case `onComplete` listener will be set to
    // `complete` event.
    EventTarget.prototype.initialize.call(this, options);

    // Copy normalized options.
    merge(request(this), validateOptions(options));
  },
  get url() { return request(this).url; },
  set url(value) { request(this).url = validateSingleOption('url', value); },
  get headers() { return request(this).headers; },
  set headers(value) {
    return request(this).headers = validateSingleOption('headers', value);
  },
  get content() { return request(this).content; },
  set content(value) {
    request(this).content = validateSingleOption('content', value);
  },
  get contentType() { return request(this).contentType; },
  set contentType(value) {
    request(this).contentType = validateSingleOption('contentType', value);
  },
  get anonymous() { return request(this).anonymous; },
  get response() { return request(this).response; },
  delete: function() {
    runRequest('DELETE', this);
    return this;
  },
  get: function() {
    runRequest('GET', this);
    return this;
  },
  post: function() {
    runRequest('POST', this);
    return this;
  },
  put: function() {
    runRequest('PUT', this);
    return this;
  },
  head: function() {
    runRequest('HEAD', this);
    return this;
  }
});
exports.Request = Request;

const Response = Class({
  initialize: function initialize(request) {
    response(this).request = request;
  },
  // more about responseURL: https://bugzilla.mozilla.org/show_bug.cgi?id=998076
  get url() {
    return response(this).request.responseURL;
  },
  get text() {
    return response(this).request.responseText;
  },
  get xml() {
    throw new Error("Sorry, the 'xml' property is no longer available. " +
                    "see bug 611042 for more information.");
  },
  get status() {
    return response(this).request.status;
  },
  get statusText() {
    return response(this).request.statusText;
  },
  get json() {
    try {
      return JSON.parse(this.text);
    } catch(error) {
      return null;
    }
  },
  get headers() {
    let headers = {}, lastKey;
    // Since getAllResponseHeaders() will return null if there are no headers,
    // defend against it by defaulting to ""
    let rawHeaders = response(this).request.getAllResponseHeaders() || "";
    rawHeaders.split("\n").forEach(function (h) {
      // According to the HTTP spec, the header string is terminated by an empty
      // line, so we can just skip it.
      if (!h.length) {
        return;
      }

      let index = h.indexOf(":");
      // The spec allows for leading spaces, so instead of assuming a single
      // leading space, just trim the values.
      let key = h.substring(0, index).trim(),
          val = h.substring(index + 1).trim();

      // For empty keys, that means that the header value spanned multiple lines.
      // In that case we should append the value to the value of lastKey with a
      // new line. We'll assume lastKey will be set because there should never
      // be an empty key on the first pass.
      if (key) {
        headers[key] = val;
        lastKey = key;
      }
      else {
        headers[lastKey] += "\n" + val;
      }
    });
    return headers;
  },
  get anonymous() {
    return response(this).request.mozAnon;
  }
});

// apiUtils.validateOptions doesn't give the ability to easily validate single
// options, so this is a wrapper that provides that ability.
function OptionsValidator(rules) {
  return {
    validateOptions: function (options) {
      return apiUtils.validateOptions(options, rules);
    },
    validateSingleOption: function (field, value) {
      // We need to create a single rule object from our listed rules. To avoid
      // JavaScript String warnings, check for the field & default to an empty object.
      let singleRule = {};
      if (field in rules) {
        singleRule[field] = rules[field];
      }
      let singleOption = {};
      singleOption[field] = value;
      // This should throw if it's invalid, which will bubble up & out.
      return apiUtils.validateOptions(singleOption, singleRule)[field];
    }
  };
}