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

import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";

import { Log } from "resource://gre/modules/Log.sys.mjs";

import { Observers } from "resource://services-common/observers.sys.mjs";
import { CommonUtils } from "resource://services-common/utils.sys.mjs";
import { Utils } from "resource://services-sync/util.sys.mjs";
import { setTimeout, clearTimeout } from "resource://gre/modules/Timer.sys.mjs";

/* global AbortController */

/*
 * Resource represents a remote network resource, identified by a URI.
 * Create an instance like so:
 *
 *   let resource = new Resource("http://foobar.com/path/to/resource");
 *
 * The 'resource' object has the following methods to issue HTTP requests
 * of the corresponding HTTP methods:
 *
 *   get(callback)
 *   put(data, callback)
 *   post(data, callback)
 *   delete(callback)
 */
export function Resource(uri) {
  this._log = Log.repository.getLogger(this._logName);
  this._log.manageLevelFromPref("services.sync.log.logger.network.resources");
  this.uri = uri;
  this._headers = {};
}

// (static) Caches the latest server timestamp (X-Weave-Timestamp header).
Resource.serverTime = null;

XPCOMUtils.defineLazyPreferenceGetter(
  Resource,
  "SEND_VERSION_INFO",
  "services.sync.sendVersionInfo",
  true
);
Resource.prototype = {
  _logName: "Sync.Resource",

  /**
   * Callback to be invoked at request time to add authentication details.
   * If the callback returns a promise, it will be awaited upon.
   *
   * By default, a global authenticator is provided. If this is set, it will
   * be used instead of the global one.
   */
  authenticator: null,

  // Wait 5 minutes before killing a request.
  ABORT_TIMEOUT: 300000,

  // Headers to be included when making a request for the resource.
  // Note: Header names should be all lower case, there's no explicit
  // check for duplicates due to case!
  get headers() {
    return this._headers;
  },
  set headers(_) {
    throw new Error("headers can't be mutated directly. Please use setHeader.");
  },
  setHeader(header, value) {
    this._headers[header.toLowerCase()] = value;
  },

  // URI representing this resource.
  get uri() {
    return this._uri;
  },
  set uri(value) {
    if (typeof value == "string") {
      this._uri = CommonUtils.makeURI(value);
    } else {
      this._uri = value;
    }
  },

  // Get the string representation of the URI.
  get spec() {
    if (this._uri) {
      return this._uri.spec;
    }
    return null;
  },

  /**
   * @param {string} method HTTP method
   * @returns {Headers}
   */
  async _buildHeaders(method) {
    const headers = new Headers(this._headers);

    if (Resource.SEND_VERSION_INFO) {
      headers.append("user-agent", Utils.userAgent);
    }

    if (this.authenticator) {
      const result = await this.authenticator(this, method);
      if (result && result.headers) {
        for (const [k, v] of Object.entries(result.headers)) {
          headers.append(k.toLowerCase(), v);
        }
      }
    } else {
      this._log.debug("No authenticator found.");
    }

    // PUT and POST are treated differently because they have payload data.
    if (("PUT" == method || "POST" == method) && !headers.has("content-type")) {
      headers.append("content-type", "text/plain");
    }

    if (this._log.level <= Log.Level.Trace) {
      for (const [k, v] of headers) {
        if (k == "authorization" || k == "x-client-state") {
          this._log.trace(`HTTP Header ${k}: ***** (suppressed)`);
        } else {
          this._log.trace(`HTTP Header ${k}: ${v}`);
        }
      }
    }

    if (!headers.has("accept")) {
      headers.append("accept", "application/json;q=0.9,*/*;q=0.2");
    }

    return headers;
  },

  /**
   * @param {string} method HTTP method
   * @param {string} data HTTP body
   * @param {object} signal AbortSignal instance
   * @returns {Request}
   */
  async _createRequest(method, data, signal) {
    const headers = await this._buildHeaders(method);
    const init = {
      cache: "no-store", // No cache.
      headers,
      method,
      signal,
      mozErrors: true, // Return nsresult error codes instead of a generic
      // NetworkError when fetch rejects.
    };

    if (data) {
      if (!(typeof data == "string" || data instanceof String)) {
        data = JSON.stringify(data);
      }
      this._log.debug(`${method} Length: ${data.length}`);
      this._log.trace(`${method} Body: ${data}`);
      init.body = data;
    }
    return new Request(this.uri.spec, init);
  },

  /**
   * @param {string} method HTTP method
   * @param {string} [data] HTTP body
   * @returns {Response}
   */
  async _doRequest(method, data = null) {
    const controller = new AbortController();
    const request = await this._createRequest(method, data, controller.signal);
    const responsePromise = fetch(request); // Rejects on network failure.
    let didTimeout = false;
    const timeoutId = setTimeout(() => {
      didTimeout = true;
      this._log.error(
        `Request timed out after ${this.ABORT_TIMEOUT}ms. Aborting.`
      );
      controller.abort();
    }, this.ABORT_TIMEOUT);
    let response;
    try {
      response = await responsePromise;
    } catch (e) {
      this._log.warn(`${method} request to ${this.uri.spec} failed`, e);
      if (!didTimeout) {
        throw e;
      }
      throw Components.Exception(
        "Request aborted (timeout)",
        Cr.NS_ERROR_NET_TIMEOUT
      );
    } finally {
      clearTimeout(timeoutId);
    }
    return this._processResponse(response, method);
  },

  async _processResponse(response, method) {
    const data = await response.text();
    this._logResponse(response, method, data);
    this._processResponseHeaders(response);

    const ret = {
      data,
      url: response.url,
      status: response.status,
      success: response.ok,
      headers: {},
    };
    for (const [k, v] of response.headers) {
      ret.headers[k] = v;
    }

    // Make a lazy getter to convert the json response into an object.
    // Note that this can cause a parse error to be thrown far away from the
    // actual fetch, so be warned!
    ChromeUtils.defineLazyGetter(ret, "obj", () => {
      try {
        return JSON.parse(ret.data);
      } catch (ex) {
        this._log.warn("Got exception parsing response body", ex);
        // Stringify to avoid possibly printing non-printable characters.
        this._log.debug(
          "Parse fail: Response body starts",
          (ret.data + "").slice(0, 100)
        );
        throw ex;
      }
    });

    return ret;
  },

  _logResponse(response, method, data) {
    const { status, ok: success, url } = response;

    // Log the status of the request.
    this._log.debug(
      `${method} ${success ? "success" : "fail"} ${status} ${url}`
    );

    // Additionally give the full response body when Trace logging.
    if (this._log.level <= Log.Level.Trace) {
      this._log.trace(`${method} body`, data);
    }

    if (!success) {
      this._log.warn(
        `${method} request to ${url} failed with status ${status}`
      );
    }
  },

  _processResponseHeaders({ headers, ok: success }) {
    if (headers.has("x-weave-timestamp")) {
      Resource.serverTime = parseFloat(headers.get("x-weave-timestamp"));
    }
    // This is a server-side safety valve to allow slowing down
    // clients without hurting performance.
    if (headers.has("x-weave-backoff")) {
      let backoff = headers.get("x-weave-backoff");
      this._log.debug(`Got X-Weave-Backoff: ${backoff}`);
      Observers.notify("weave:service:backoff:interval", parseInt(backoff, 10));
    }

    if (success && headers.has("x-weave-quota-remaining")) {
      Observers.notify(
        "weave:service:quota:remaining",
        parseInt(headers.get("x-weave-quota-remaining"), 10)
      );
    }
  },

  get() {
    return this._doRequest("GET");
  },

  put(data) {
    return this._doRequest("PUT", data);
  },

  post(data) {
    return this._doRequest("POST", data);
  },

  delete() {
    return this._doRequest("DELETE");
  },
};
