/* 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";

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  LocationHelper: "resource://gre/modules/LocationHelper.sys.mjs",
  clearTimeout: "resource://gre/modules/Timer.sys.mjs",
  setTimeout: "resource://gre/modules/Timer.sys.mjs",
});

// GeolocationPositionError has no interface object, so we can't use that here.
const POSITION_UNAVAILABLE = 2;

XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "gNetworkGeolocationLogLevel",
  "geo.provider.network.loglevel",
  "Off"
);

ChromeUtils.defineLazyGetter(lazy, "log", () => {
  let consoleOptions = {
    maxLogLevelPref: lazy.gNetworkGeolocationLogLevel,
    prefix: "NetworkGeolocationProvider",
  };
  return console.createInstance(consoleOptions);
});

function CachedRequest(loc, wifiList) {
  this.location = loc;

  let wifis = new Set();
  if (wifiList) {
    for (let i = 0; i < wifiList.length; i++) {
      wifis.add(wifiList[i].macAddress);
    }
  }

  this.hasWifis = () => wifis.size > 0;

  // if 50% of the SSIDS match
  this.isWifiApproxEqual = function (wifiList) {
    if (!this.hasWifis()) {
      return false;
    }

    // if either list is a 50% subset of the other, they are equal
    let common = 0;
    for (let i = 0; i < wifiList.length; i++) {
      if (wifis.has(wifiList[i].macAddress)) {
        common++;
      }
    }
    let kPercentMatch = 0.5;
    return common >= Math.max(wifis.size, wifiList.length) * kPercentMatch;
  };

  this.isGeoip = function () {
    return !this.hasWifis();
  };
}

/** @type {CachedRequest?} */
var gCachedRequest = null;
var gDebugCacheReasoning = ""; // for logging the caching logic

// This function serves two purposes:
// 1) do we have a cached request
// 2) is the cached request better than what newWifiList will obtain
// If the cached request exists, and we know it to have greater accuracy
// by the nature of its origin (wifi/geoip), use its cached location.
//
// If there is more source info than the cached request had, return false
// In other cases, MLS is known to produce better/worse accuracy based on the
// inputs, so base the decision on that.
function isCachedRequestMoreAccurateThanServerRequest(newWifiList) {
  gDebugCacheReasoning = "";
  let isNetworkRequestCacheEnabled = Services.prefs.getBoolPref(
    "geo.provider.network.debug.requestCache.enabled",
    true
  );
  // Mochitest needs this pref to simulate request failure
  if (!isNetworkRequestCacheEnabled) {
    gCachedRequest = null;
  }

  if (!gCachedRequest || !isNetworkRequestCacheEnabled) {
    gDebugCacheReasoning = "No cached data";
    return false;
  }

  if (!newWifiList) {
    gDebugCacheReasoning = "New req. is GeoIP.";
    return true;
  }

  let hasEqualWifis = false;
  if (newWifiList) {
    hasEqualWifis = gCachedRequest.isWifiApproxEqual(newWifiList);
  }

  gDebugCacheReasoning = `EqualWifis: ${hasEqualWifis}`;

  if (gCachedRequest.hasWifis() && hasEqualWifis) {
    gDebugCacheReasoning += ", Wifi only.";
    return true;
  }

  return false;
}

function NetworkGeoCoordsObject(lat, lon, acc) {
  this.latitude = lat;
  this.longitude = lon;
  this.accuracy = acc;

  // Neither GLS nor MLS return the following properties, so set them to NaN
  // here. nsGeoPositionCoords will convert NaNs to null for optional properties
  // of the JavaScript Coordinates object.
  this.altitude = NaN;
  this.altitudeAccuracy = NaN;
  this.heading = NaN;
  this.speed = NaN;
}

NetworkGeoCoordsObject.prototype = {
  QueryInterface: ChromeUtils.generateQI(["nsIDOMGeoPositionCoords"]),
};

function NetworkGeoPositionObject(lat, lng, acc) {
  this.coords = new NetworkGeoCoordsObject(lat, lng, acc);
  this.address = null;
  this.timestamp = Date.now();
}

NetworkGeoPositionObject.prototype = {
  QueryInterface: ChromeUtils.generateQI(["nsIDOMGeoPosition"]),
};

export function NetworkGeolocationProvider() {
  /*
    The _wifiMonitorTimeout controls how long we wait on receiving an update
    from the Wifi subsystem.  If this timer fires, we believe the Wifi scan has
    had a problem and we no longer can use Wifi to position the user this time
    around (we will continue to be hopeful that Wifi will recover).
  */
  XPCOMUtils.defineLazyPreferenceGetter(
    this,
    "_wifiMonitorTimeout",
    "geo.provider.network.timeToWaitBeforeSending",
    5000
  );

  XPCOMUtils.defineLazyPreferenceGetter(
    this,
    "_wifiScanningEnabled",
    "geo.provider.network.scan",
    true
  );

  this.wifiService = null;
  this.timer = null;
  this.started = false;
}

NetworkGeolocationProvider.prototype = {
  classID: Components.ID("{77DA64D3-7458-4920-9491-86CC9914F904}"),
  name: "NetworkGeolocationProvider",
  QueryInterface: ChromeUtils.generateQI([
    "nsIGeolocationProvider",
    "nsIWifiListener",
    "nsITimerCallback",
    "nsIObserver",
    "nsINamed",
  ]),
  listener: null,

  get isWifiScanningEnabled() {
    return Cc["@mozilla.org/wifi/monitor;1"] && this._wifiScanningEnabled;
  },

  resetTimer() {
    if (this.timer) {
      this.timer.cancel();
      this.timer = null;
    }
    // Wifi thread triggers NetworkGeolocationProvider to proceed. With no wifi,
    // do manual timeout.
    this.timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
    this.timer.initWithCallback(
      this,
      this._wifiMonitorTimeout,
      this.timer.TYPE_REPEATING_SLACK
    );
  },

  startup() {
    lazy.log.debug("startup called.");
    if (this.started) {
      return;
    }

    this.started = true;

    if (this.isWifiScanningEnabled) {
      if (this.wifiService) {
        this.wifiService.stopWatching(this);
      }
      this.wifiService = Cc["@mozilla.org/wifi/monitor;1"].getService(
        Ci.nsIWifiMonitor
      );
      this.wifiService.startWatching(this, false);
    }

    this.resetTimer();
  },

  watch(c) {
    lazy.log.debug("watch called");
    this.listener = c;
    this.notify();
    this.resetTimer();
  },

  shutdown() {
    lazy.log.debug("shutdown called");
    if (!this.started) {
      return;
    }

    // Without clearing this, we could end up using the cache almost indefinitely
    // TODO: add logic for cache lifespan, for now just be safe and clear it
    gCachedRequest = null;

    if (this.timer) {
      this.timer.cancel();
      this.timer = null;
    }

    if (this.wifiService) {
      this.wifiService.stopWatching(this);
      this.wifiService = null;
    }

    this.listener = null;
    this.started = false;
  },

  setHighAccuracy(enable) {
    // Mochitest wants to check this value
    if (Services.prefs.getBoolPref("geo.provider.testing", false)) {
      Services.obs.notifyObservers(
        null,
        "testing-geolocation-high-accuracy",
        enable
      );
    }
  },

  onChange(accessPoints) {
    // we got some wifi data, rearm the timer.
    this.resetTimer();

    let wifiData = null;
    if (accessPoints) {
      wifiData = lazy.LocationHelper.formatWifiAccessPoints(accessPoints);
    }
    this.sendLocationRequest(wifiData);
  },

  onError(code) {
    lazy.log.debug("wifi error: " + code);
    this.sendLocationRequest(null);
  },

  onStatus(err, statusMessage) {
    if (!this.listener) {
      return;
    }
    lazy.log.debug("onStatus called." + statusMessage);

    if (statusMessage && this.listener.notifyStatus) {
      this.listener.notifyStatus(statusMessage);
    }

    if (err && this.listener.notifyError) {
      this.listener.notifyError(POSITION_UNAVAILABLE, statusMessage);
    }
  },

  notify() {
    this.onStatus(false, "wifi-timeout");
    this.sendLocationRequest(null);
  },

  /**
   * After wifi data has been gathered, this method is invoked to perform the
   * request to network geolocation provider.
   * The result of each request is sent to all registered listener (@see watch)
   * by invoking its respective `update`, `notifyError` or `notifyStatus`
   * callbacks.
   * `update` is called upon a successful request with its response data; this will be a `NetworkGeoPositionObject` instance.
   * `notifyError` is called whenever the request gets an error from the local
   * network subsystem, the server or simply times out.
   * `notifyStatus` is called for each status change of the request that may be
   * of interest to the consumer of this class. Currently the following status
   * changes are reported: 'xhr-start', 'xhr-timeout', 'xhr-error' and
   * 'xhr-empty'.
   *
   * @param  {Array} wifiData Optional set of publicly available wifi networks
   *                          in the following structure:
   *                          <code>
   *                          [
   *                            { macAddress: <mac1>, signalStrength: <signal1> },
   *                            { macAddress: <mac2>, signalStrength: <signal2> }
   *                          ]
   *                          </code>
   */
  async sendLocationRequest(wifiData) {
    let data = { wifiAccessPoints: undefined };
    // Zero or one entry means lookup is only by our own IP address.
    if (wifiData && wifiData.length >= 2) {
      data.wifiAccessPoints = wifiData;
    }

    let useCached = isCachedRequestMoreAccurateThanServerRequest(
      data.wifiAccessPoints
    );

    lazy.log.debug(
      "Use request cache:" + useCached + " reason:" + gDebugCacheReasoning
    );

    if (useCached) {
      Glean.geolocation.geolocationCacheHit.NetworkGeolocationProvider.add();

      gCachedRequest.location.timestamp = Date.now();
      if (this.listener) {
        this.listener.update(gCachedRequest.location);
      }
      return;
    }

    // From here on, do a network geolocation request //
    let url = Services.urlFormatter.formatURLPref("geo.provider.network.url");
    let logStr = data.wifiAccessPoints ? " with wifi APs" : "";
    lazy.log.info(
      `Sending IP-address-based geolocation request${logStr} to network service: ${url}`
    );

    let result;
    try {
      result = await this.fetchLocation(url, wifiData);
      lazy.log.info(
        `geo provider reported: ${result.location.lng}:${result.location.lat}`
      );
      let newLocation = new NetworkGeoPositionObject(
        result.location.lat,
        result.location.lng,
        result.accuracy
      );

      if (this.listener) {
        this.listener.update(newLocation);
      }

      gCachedRequest = new CachedRequest(newLocation, data.wifiAccessPoints);
    } catch (err) {
      lazy.log.error("Location request hit error: " + err.name);
      console.error(err);
      if (err.name == "AbortError") {
        this.onStatus(true, "xhr-timeout");
      } else {
        this.onStatus(true, "xhr-error");
      }
    }
  },

  async fetchLocation(url, wifiData) {
    this.onStatus(false, "xhr-start");

    let fetchController = new AbortController();
    let fetchOpts = {
      method: "POST",
      headers: { "Content-Type": "application/json; charset=UTF-8" },
      credentials: "omit",
      signal: fetchController.signal,
    };

    if (wifiData) {
      fetchOpts.body = JSON.stringify({ wifiAccessPoints: wifiData });
    }

    let timeoutId = lazy.setTimeout(
      () => fetchController.abort(),
      Services.prefs.getIntPref("geo.provider.network.timeout", 60000)
    );

    let isWifi = wifiData && wifiData.length >= 2;
    let label = isWifi ? "network_wifi_and_ip" : "network_ip";
    Glean.geolocation.geolocationService[label].add();

    let response;
    try {
      response = await fetch(url, fetchOpts);
    } catch (err) {
      Glean.geolocation.networkFailures[label].add();
      throw err;
    } finally {
      lazy.clearTimeout(timeoutId);
    }

    if (!response.ok) {
      Glean.geolocation.networkFailures[label].add();
      throw new Error(
        `The geolocation provider returned a non-ok status ${response.status}`,
        { cause: await response.text() }
      );
    }

    let result = response.json();
    return result;
  },
};
