export default () => {
  // Adds the apos.http client, which has the same API
  // as the server-side apos.http client, although it may
  // not have exactly the same features available.
  // This is a lean, IE11-friendly implementation.

  const busyActive = {};
  const apos = window.apos;
  apos.http = {};

  // Send a POST request. Note that POST body data should be in
  // `options.body`. See `apos.http.remote` for details.
  // You do NOT have to pass a callback unless you must support IE11
  // and do not want to include a promise polyfill in your build.

  apos.http.post = function(url, options, callback) {
    return apos.http.remote('POST', url, options, callback);
  };

  // Send a GET request. Note that query string data may be in
  // `options.qs`. See `apos.http.remote` for details.
  // You do NOT have to pass a callback unless you must support IE11
  // and do not want to include a promise polyfill in your build.
  apos.http.get = function(url, options, callback) {
    return apos.http.remote('GET', url, options, callback);
  };

  // Send a PATCH request. Note that PATCH body data should be in
  // `options.body`. See `apos.http.remote` for details.
  // You do NOT have to pass a callback unless you must support IE11
  // and do not want to include a promise polyfill in your build.
  apos.http.patch = function(url, options, callback) {
    return apos.http.remote('PATCH', url, options, callback);
  };

  // Send a PUT request. Note that PUT body data should be in
  // `options.body`. See `apos.http.remote` for details.
  // You do NOT have to pass a callback unless you must support IE11
  // and do not want to include a promise polyfill in your build.
  apos.http.put = function(url, options, callback) {
    return apos.http.remote('PUT', url, options, callback);
  };

  // Send a DELETE request. See `apos.http.remote` for details.
  // You do NOT have to pass a callback unless you must support IE11
  // and do not want to include a promise polyfill in your build.
  apos.http.delete = function(url, options, callback) {
    return apos.http.remote('DELETE', url, options, callback);
  };

  // Send an HTTP request with the given method to the given URL and return the
  // response body.
  //
  // The callback is optional as long as Promise support is present in the
  // browser, directly or as a polyfill. If a callback is used it will receive
  // `(err, result)` where `result` is the return value described below.
  //
  // Accepts the following options:
  //
  // `qs` (pass object; builds a query string, does not support recursion)
  // `send`: by default, `options.body` is sent as JSON if it is an object and
  // it is not a `FormData` object. If `send` is set to `json`, it is always
  // sent as JSON. `body` (request body, not for GET; if an object or array,
  // sent as JSON, otherwise sent as-is, unless the `send` option is set)
  // `parse` (can be 'json` to always parse the response body as JSON, otherwise
  // the response body is parsed as JSON only if the content-type is
  // application/json) `headers` (an object containing header names and values)
  // `draft` (if true, always add aposMode=draft to the query string, creating
  // one if needed) `fullResponse` (if true, return an object with `status`,
  // `headers` and `body` properties, rather than returning the body directly;
  // the individual `headers` are canonicalized to lowercase names. If there are
  // duplicate headers after canonicalization only the last value is returned.
  // If a header appears multiple times an array is returned for it)
  // `downloadProgress` (may be a function accepting `received` and `total`
  // parameters. May never be called. If called, `received` will be the bytes
  // sent so far, and `total` will be the total bytes to be received. If the
  // total is unknown, it will be `null`) `uploadProgress` (may be a function
  // accepting `sent` and `total` parameters. May never be called. If called,
  // `sent` will be the bytes sent so far, and `total` will be the total bytes
  // to be sent. If the total is unknown, it will be `null`) `prefix`: If
  // explicitly set to `false`, do not automatically prefix the URL, even if the
  // site has a site-wide prefix or locale prefix. It can become handy when the
  // given url is already prefixed, which is the case when using the document's
  // computed `_url` field for instance.
  //
  // If the status code is >= 400 an error is thrown. The error object will be
  // similar to a `fullResponse` object, with a `status` property.
  //
  // If the URL is site-relative (starts with /) it will be requested from
  // the apostrophe site itself.

  // Just before the XMLHTTPRequest is sent this method emits an
  // `apos-before-post` event on `document.body` (where `post` changes
  // to match the method, in lower case). The event object
  // has `uri`, `data` and `request` properties. `request` is the
  // XMLHTTPRequest object. You can use this to set custom headers
  // on all lean requests, etc.

  apos.http.remote = function(method, url, options, callback) {
    if (!callback) {
      if (!window.Promise) {
        throw new Error('If you wish to receive a promise from apos.http methods in older browsers you must have a Promise polyfill. If you do not want to provide one, pass a callback instead.');
      }
      return new window.Promise(function(resolve, reject) {
        if (!url) {
          return reject(new Error('url is not defined'));
        }
        return apos.http.remote(method, url, options, function(err, result) {
          if (err) {
            return reject(err);
          }
          return resolve(result);
        });
      });
    }

    if (!url) {
      return callback(new Error('url is not defined'));
    }

    if (apos.sitePrefix && options.prefix !== false) {
      // Prepend the prefix if the URL is absolute:
      if (url.substring(0, 1) === '/') {
        url = apos.sitePrefix + url;
      }
    }

    let query;
    let qat;

    // Intentional true / falsey check for determining
    // what set of docs the request is interested in
    if (options.draft != null) {
      if (options.qs) {
        // Already assumes no query parameters baked into URL, so OK to
        // just extend qs
        options.qs = options.draft
          ? apos.util.assign({ aposMode: 'draft' }, options.qs)
          : apos.util.assign({ aposMode: 'published' }, options.qs);
      } else {
        // Careful, there could be existing query parameters baked into url
        qat = url.indexOf('?');
        if (qat !== -1) {
          query = apos.http.parseQuery(url.substring(qat));
        } else {
          query = {};
        }
        query.aposMode = options.draft ? 'draft' : 'published';
        url = apos.http.addQueryToUrl(url, query);
      }
    }

    const busyName = options.busy === true ? 'busy' : options.busy;
    const xmlhttp = new XMLHttpRequest();
    let data = options.body;
    let keys;
    let i;

    qat = url.indexOf('?');
    const qs = {
      ...qat !== -1 && apos.http.parseQuery(url.substring(qat)),
      ...options.qs || {},
      ...options.qs?.aposLocale
        ? { aposLocale: options.qs.aposLocale }
        : { aposLocale: apos.getActiveLocale() }
    };
    url = apos.http.addQueryToUrl(url, qs);

    if (options.busy) {
      if (!busyActive[busyName]) {
        busyActive[busyName] = 0;
        if (apos.bus) {
          apos.bus.$emit('busy', {
            active: true,
            name: busyName
          });
        }
      }
      // keep track of nested calls
      busyActive[busyName]++;
    }
    xmlhttp.open(method, url);
    const formData = window.FormData && (data instanceof window.FormData);
    const sendJson = (options.send === 'json') || (options.body && ((typeof options.body) === 'object') && !formData);
    if (sendJson) {
      xmlhttp.setRequestHeader('Content-Type', 'application/json');
    }
    if (options.headers) {
      keys = Object.keys(options.headers);
      for (i = 0; (i < keys.length); i++) {
        xmlhttp.setRequestHeader(keys[i], options.headers[keys[i]]);
      }
    }
    apos.util.emit(document.body, 'apos-before-' + method.toLowerCase(), {
      uri: url,
      data: options.body,
      request: xmlhttp
    });
    if (sendJson) {
      data = JSON.stringify(options.body);
    } else {
      data = options.body;
    }
    xmlhttp.addEventListener('load', function() {
      let data = null;
      const responseHeader = this.getResponseHeader('Content-Type');
      if (responseHeader || (options.parse === 'json')) {
        if ((options.parse === 'json') || (responseHeader.match(/^application\/json/))) {
          try {
            data = JSON.parse(this.responseText);
          } catch (e) {
            return callback(e);
          }
        } else {
          data = this.responseText;
        }
      }

      if (xmlhttp.status < 400) {
        if (options.fullResponse) {
          return callback(null, {
            body: data,
            status: xmlhttp.status,
            headers: getHeaders()
          });
        } else {
          return callback(null, data);
        }
      } else {
        const error = new Error((data && data.message) || (data && data.name) || 'Error');
        error.status = xmlhttp.status;
        error.name = (data && data.name);
        error.body = data;
        error.headers = getHeaders();
        return callback(error);
      }
    });
    xmlhttp.addEventListener('abort', function(evt) {
      return callback(evt);
    });
    xmlhttp.addEventListener('error', function(evt) {
      return callback(evt);
    });
    if (options.downloadProgress) {
      xmlhttp.addEventListener('progress', function(evt) {
        options.downloadProgress(evt.loaded, evt.lengthComputable ? evt.total : null);
      });
    }
    if (xmlhttp.upload && options.uploadProgress) {
      xmlhttp.upload.addEventListener('progress', function(evt) {
        options.uploadProgress(evt.loaded, evt.lengthComputable ? evt.total : null);
      });
    }
    xmlhttp.addEventListener('loadend', function (evt) {
      if (options.busy) {
        busyActive[busyName]--;
        if (!busyActive[busyName]) {
          // if no nested calls, disable the "busy" state
          if (apos.bus) {
            apos.bus.$emit('busy', {
              active: false,
              name: busyName
            });
          }
        }
      }
    });
    xmlhttp.send(data);

    function getHeaders() {
      const headers = xmlhttp.getAllResponseHeaders();
      if (!headers) {
        return {};
      }
      // Per MDN
      const arr = headers.trim().split(/[\r\n]+/);
      // Create a map of header names to values
      const headerMap = {};
      arr.forEach(function (line) {
        const parts = line.split(': ');
        const header = parts.shift();
        if (!header) {
          return;
        }
        const value = parts.shift();
        // Optional support for fetching arrays of headers with the same name
        // could be added at a later time if anyone really cares. Usually
        // just a source of bugs
        headerMap[header.toLowerCase()] = value;
      });
      return headerMap;
    }
  };

  // Parse a query string. You can pass with or without the
  // leading ?. Don't pass the entire URL. Supports objects,
  // arrays and nesting with the classic PHP/Java bracket syntax.
  // If a key is set with no = it is considered null, per
  // the java convention. Good for use with window.location.search.

  apos.http.parseQuery = function(query) {
    query = query.replace(/^\?/, '');
    const data = {};
    const pairs = query.split('&');
    pairs.forEach(function(pair) {
      let parts;
      if (pair.indexOf('=') === -1) {
        patch(pair, null);
      } else {
        parts = pair.split('=');
        if (parts) {
          patch(parts[0], parts[1]);
        }
      }
    });
    return data.root || {};
    function patch(key, value) {
      let match;
      let parentKey = 'root';
      let context = data;
      key = decodeURIComponent(key);
      const path = key.split('[');
      path.forEach(function(subKey) {
        if (subKey === ']') {
          if (!Array.isArray(context[parentKey])) {
            context[parentKey] = [];
          }
          context = context[parentKey];
          parentKey = context.length;
        } else if (subKey.match(/^\d+]/)) {
          match = subKey.match(/^\d+/);
          if (!Array.isArray(context[parentKey])) {
            context[parentKey] = [];
          }
          context = context[parentKey];
          parentKey = parseInt(match);
        } else {
          match = subKey.replace(']', '');
          if (!context[parentKey]) {
            context[parentKey] = {};
          }
          context = context[parentKey];
          parentKey = match;
        }
      });
      value = (value === null) ? value : decodeURIComponent(value);
      if (Array.isArray(context[parentKey])) {
        context[parentKey].push(value);
      } else if (context[parentKey] !== undefined) {
        context[parentKey] = [ context[parentKey], value ];
      } else {
        context[parentKey] = value;
      }
    }
  };

  // Adds query string data to url. Supports nested structures with objects
  // and arrays, in a way compatible with qs and most other parsers including
  // those baked into PHP frameworks etc. If the URL already contains a query
  // it is discarded and replaced with the new one. All non-query parts of the
  // URL remain unchanged.

  apos.http.addQueryToUrl = function(url, data) {
    let hash = '';
    const hashAt = url.indexOf('#');
    if (hashAt !== -1) {
      hash = url.substring(hashAt);
      url = url.substring(0, hashAt);
    }
    url = url.replace(/\?.*$/, '');
    let i;
    let flat;
    if ((data != null) && ((typeof data) === 'object')) {
      flat = flatten('', data);
      for (i = 0; (i < flat.length); i++) {
        const key = flat[i][0];
        const val = flat[i][1];
        if (i > 0) {
          url += '&';
        } else {
          url += '?';
        }
        if (val == null) {
          // Java-style distinction between null and empty string
          url += encodeURIComponent(key);
        } else {
          url += encodeURIComponent(key) + '=' + encodeURIComponent(val);
        }
      }
    }
    return url + hash;
    function flatten(path, data) {
      let flat = [];
      let keys;
      let i;
      if (Array.isArray(data)) {
        for (i = 0; (i < data.length); i++) {
          insert(i, data[i]);
        }
      } else {
        keys = Object.keys(data);
        for (i = 0; (i < keys.length); i++) {
          insert(keys[i], data[keys[i]]);
        }
      }
      return flat;
      function insert(key, datum) {
        if ((datum != null) && ((typeof datum) === 'object')) {
          flat = flat.concat(flatten(path.length ? path + '[' + key + ']' : key, datum));
        } else {
          flat.push([ path.length ? path + '[' + key + ']' : key, datum ]);
        }
      }
    }
  };
};
