# Source: src/main/js/common/MoneroConnectionManager.js

    const GenUtils = require("./GenUtils");
    const MoneroError = require("./MoneroError");
    const MoneroRpcConnection = require("./MoneroRpcConnection");
    const TaskLooper = require("./TaskLooper");
    const ThreadPool = require("./ThreadPool");

    /**
     * <p>Manages a collection of prioritized connections to daemon or wallet RPC endpoints.</p>
     *
     * <p>Example usage:</p>
     *
     * <code>
     * // imports<br>
     * const monerojs = require("monero-javascript");<br>
     * const MoneroRpcConnection = monerojs.MoneroRpcConnection;<br>
     * const MoneroConnectionManager = monerojs.MoneroConnectionManager;<br>
     * const MoneroConnectionManagerListener = monerojs.MoneroConnectionManagerListener;<br><br>
     *
     * // create connection manager<br>
     * let connectionManager = new MoneroConnectionManager();<br><br>
     *
     * // add managed connections with priorities<br>
     * connectionManager.addConnection(new MoneroRpcConnection("http://localhost:38081").setPriority(1)); // use localhost as first priority<br>
     * connectionManager.addConnection(new MoneroRpcConnection("http://example.com")); // default priority is prioritized last<br><br>
     *
     * // set current connection<br>
     * connectionManager.setConnection(new MoneroRpcConnection("http://foo.bar", "admin", "password")); // connection is added if new<br><br>
     *
     * // check connection status<br>
     * await connectionManager.checkConnection();<br>
     * console.log("Connection manager is connected: " + connectionManager.isConnected());<br>
     * console.log("Connection is online: " + connectionManager.getConnection().isOnline());<br>
     * console.log("Connection is authenticated: " + connectionManager.getConnection().isAuthenticated());<br><br>
     *
     * // receive notifications of any changes to current connection<br>
     * connectionManager.addListener(new class extends MoneroConnectionManagerListener {<br>
     * &nbsp;&nbsp; onConnectionChanged(connection) {<br>
     * &nbsp;&nbsp;&nbsp;&nbsp; console.log("Connection changed to: " + connection);<br>
     * &nbsp;&nbsp; }<br>
     * });<br><br>
     *
     * // check connection status every 10 seconds<br>
     * await connectionManager.startCheckingConnection(10000);<br><br>
     *
     * // automatically switch to best available connection if disconnected<br>
     * connectionManager.setAutoSwitch(true);<br><br>
     *
     * // get best available connection in order of priority then response time<br>
     * let bestConnection = await connectionManager.getBestAvailableConnection();<br><br>
     *
     * // check status of all connections<br>
     * await connectionManager.checkConnections();<br><br>
     *
     * // get connections in order of current connection, online status from last check, priority, and name<br>
     * let connections = connectionManager.getConnections();<br><br>
     *
     * // clear connection manager<br>
     * connectionManager.clear();
     * <code>
     */
    class MoneroConnectionManager {

      /**
       * Construct a connection manager.
       *
       * @param {boolean} proxyToWorker - configure all connections to proxy to worker (default true)
       */
      constructor(proxyToWorker) {
        this._proxyToWorker = proxyToWorker !== false;
        this._timeoutInMs = MoneroConnectionManager.DEFAULT_TIMEOUT;
        this._connections = [];
        this._listeners = [];
      }

      /**
       * Add a listener to receive notifications when the connection changes.
       *
       * @param {MoneroConnectionManagerListener} listener - the listener to add
       * @return {MoneroConnectionManager} this connection manager for chaining
       */
      addListener(listener) {
        this._listeners.push(listener);
        return this;
      }

      /**
       * Remove a listener.
       *
       * @param {MoneroConnectionManagerListener} listener - the listener to remove
       * @return {MoneroConnectionManager} this connection manager for chaining
       */
      removeListener(listener) {
        if (!GenUtils.remove(this._listeners, listener)) throw new MoneroError("Monero connection manager does not contain listener to remove");
        return this;
      }

      /**
       * Remove all listeners.
       *
       * @return {MoneroConnectionManager} this connection manager for chaining
       */
      removeListeners() {
        this._listeners.splice(0, this._listeners.length);
        return this;
      }

      /**
       * Add a connection. The connection may have an elevated priority for this manager to use.
       *
       * @param {MoneroRpcConnection} connection - the connection to add
       * @return {Promise<MoneroConnectionManager>} this connection manager for chaining
       */
      async addConnection(connection) {
        for (let aConnection of this._connections) {
          if (aConnection.getUri() === connection.getUri()) throw new MoneroError("Connection URI already exists");
        }
        if (this._proxyToWorker !== undefined) connection.setProxyToWorker(this._proxyToWorker);
        this._connections.push(connection);
        return this;
      }

      /**
       * Remove a connection.
       *
       * @param {string} uri - of the the connection to remove
       * @return {Promise<MoneroConnectionManager>} this connection manager for chaining
       */
      async removeConnection(uri) {
        let connection = this.getConnectionByUri(uri);
        if (!connection) throw new MoneroError("No connection exists with URI: " + uri);
        GenUtils.remove(this._connections, connection);
        if (connection === this._currentConnection) {
          this._currentConnection = undefined;
          this._onConnectionChanged(this._currentConnection);
        }
        return this;
      }

      /**
       * Indicates if the connection manager is connected to a node.
       *
       * @return {boolean|undefined} true if the current connection is set, online, and not unauthenticated, undefined if unknown, false otherwise
       */
      isConnected() {
        if (!this._currentConnection) return false;
        return this._currentConnection.isConnected();
      }

      /**
       * Get the current connection.
       *
       * @return {MoneroRpcConnection} the current connection or undefined if no connection set
       */
      getConnection() {
        return this._currentConnection;
      }

      /**
       * Get a connection by URI.
       *
       * @param {string} uri is the URI of the connection to get
       * @return {MoneroRpcConnection} the connection with the URI or undefined if no connection with the URI exists
       */
      getConnectionByUri(uri) {
        for (let connection of this._connections) if (connection.getUri() === uri) return connection;
        return undefined;
      }

      /**
       * Get all connections in order of current connection (if applicable), online status, priority, and name.
       *
       * @return {MoneroRpcConnection[]} the list of sorted connections
       */
      getConnections() {
        let sortedConnections = GenUtils.copyArray(this._connections);
        sortedConnections.sort(this._compareConnections.bind(this));
        return sortedConnections;
      }

      /**
       * Get the best available connection in order of priority then response time.
       *
       * @param {MoneroRpcConnection[]} excludedConnections - connections to be excluded from consideration (optional)
       * @return {Promise<MoneroRpcConnection>} the best available connection in order of priority then response time, undefined if no connections available
       */
      async getBestAvailableConnection(excludedConnections) {

        // try connections within each ascending priority
        for (let prioritizedConnections of this._getConnectionsInAscendingPriority()) {
          try {

            // create promises to check connections
            let that = this;
            let checkPromises = [];
            for (let connection of prioritizedConnections) {
              if (excludedConnections && GenUtils.arrayContains(excludedConnections, connection)) continue;
              checkPromises.push(new Promise(async function(resolve, reject) {
                await connection.checkConnection(that._timeoutInMs);
                if (connection.isConnected()) resolve(connection);
                reject();
              }));
            }

            // use first available connection
            let firstAvailable = await Promise.any(checkPromises);
            if (firstAvailable) return firstAvailable;
          } catch (err) {
            if (!(err instanceof AggregateError)) throw new MoneroError(err);
          }
        }
        return undefined;
      }

      /**
       * Set the current connection.
       * Provide a URI to select an existing connection without updating its credentials.
       * Provide a MoneroRpcConnection to add new connection or replace existing connection with the same URI.
       * Notify if current connection changes.
       * Does not check the connection.
       *
       * @param {string|MoneroRpcConnection} uriOrConnection - is the uri of the connection or the connection to make current (default undefined for no current connection)
       * @return {MoneroConnectionManager} this connection manager for chaining
       */
      setConnection(uriOrConnection) {

        // handle uri
        if (uriOrConnection && typeof uriOrConnection === "string") {
          let connection = this.getConnectionByUri(uriOrConnection);
          return this.setConnection(connection === undefined ? new MoneroRpcConnection(uriOrConnection) : connection);
        }

        // handle connection
        let connection = uriOrConnection;
        if (this._currentConnection === connection) return this;

        // check if setting undefined connection
        if (!connection) {
          this._currentConnection = undefined;
          this._onConnectionChanged(undefined);
          return this;
        }

        // validate connection
        if (!(connection instanceof MoneroRpcConnection)) throw new MoneroError("Must provide string or MoneroRpcConnection to set connection");
        if (!connection.getUri()) throw new MoneroError("Connection is missing URI");

        // add or replace connection
        let prevConnection = this.getConnectionByUri(connection.getUri());
        if (prevConnection) GenUtils.remove(this._connections, prevConnection);
        this.addConnection(connection);
        this._currentConnection = connection;
        this._onConnectionChanged(this._currentConnection);

        return this;
      }

      /**
       * Check the current connection. If disconnected and auto switch enabled, switches to best available connection.
       *
       * @return {Promise<MoneroConnectionManager>} this connection manager for chaining
       */
      async checkConnection() {
        let connectionChanged = false;
        let connection = this.getConnection();
        if (connection && await connection.checkConnection(this._timeoutInMs)) connectionChanged = true;
        if (this._autoSwitch && !this.isConnected()) {
          let bestConnection = await this.getBestAvailableConnection([connection]);
          if (bestConnection) {
            this.setConnection(bestConnection);
            return this;
          }
        }
        if (connectionChanged) await this._onConnectionChanged(connection);
        return this;
      }

      /**
       * Check all managed connections.
       *
       * @return {Promise<MoneroConnectionManager>} this connection manager for chaining
       */
      async checkConnections() {

        // check all connections
        await Promise.all(this.checkConnectionPromises());

        // auto switch to best connection
        if (this._autoSwitch && !this.isConnected()) {
          for (let prioritizedConnections of this._getConnectionsInAscendingPriority()) {
            let bestConnection;
            for (let prioritizedConnection of prioritizedConnections) {
              if (prioritizedConnection.isConnected() && (!bestConnection || prioritizedConnection.getResponseTime() < bestConnection.getResponseTime())) {
                bestConnection = prioritizedConnection;
              }
            }
            if (bestConnection) {
              this.setConnection(bestConnection);
              break;
            }
          }
        }
        return this;
      }

      /**
       * Check all managed connections, returning a promise for each connection check.
       * Does not auto switch if disconnected.
       *
       * @return {Promise[]} a promise for each connection in the order of getConnections().
       */
      checkConnectionPromises() {
        let that = this;
        let checkPromises = [];
        let pool = new ThreadPool(this._connections.length);
        for (let connection of this.getConnections()) {
          checkPromises.push(pool.submit(async function() {
            try {
              if (await connection.checkConnection(that._timeoutInMs) && connection === this._currentConnection) await that._onConnectionChanged(connection);
            } catch (err) {
              // ignore error
            }
          }));
        }
        Promise.all(checkPromises);
        return checkPromises;
      }

      /**
       * Check the connection and start checking the connection periodically.
       *
       * @param {number} periodMs is the time between checks in milliseconds (default 10000 or 10 seconds)
       * @return {Promise<MoneroConnectionManager>} this connection manager for chaining (after first checking the connection)
       */
      async startCheckingConnection(periodMs) {
        await this.checkConnection();
        if (!periodMs) periodMs = MoneroConnectionManager.DEFAULT_CHECK_CONNECTION_PERIOD;
        if (this._checkLooper) return this;
        let that = this;
        let firstCheck = true;
        this._checkLooper = new TaskLooper(async function() {
          if (firstCheck) {
            firstCheck = false; // skip first check
            return;
          }
          try { await that.checkConnection(); }
          catch (err) { console.error("Error checking connection: " + err); }
        });
        this._checkLooper.start(periodMs);
        return this;
      }

      /**
       * Stop checking the connection status periodically.
       *
       * @return {MoneroConnectionManager} this connection manager for chaining
       */
      stopCheckingConnection() {
        if (this._checkLooper) this._checkLooper.stop();
        delete this._checkLooper;
        return this;
      }

      /**
       * Automatically switch to best available connection if current connection is disconnected after being checked.
       *
       * @param {boolean} autoSwitch specifies if the connection should switch on disconnect
       * @return {MoneroConnectionManager} this connection manager for chaining
       */
      setAutoSwitch(autoSwitch) {
        this._autoSwitch = autoSwitch;
        return this;
      }

      /**
       * Get if auto switch is enabled or disabled.
       *
       * @return {boolean} true if auto switch enabled, false otherwise
       */
      getAutoSwitch() {
        return this._autoSwitch;
      }

      /**
       * Set the maximum request time before its connection is considered offline.
       *
       * @param {int} timeoutInMs - the timeout before the connection is considered offline
       * @return {MoneroConnectionManager} this connection manager for chaining
       */
      setTimeout(timeoutInMs) {
        this._timeoutInMs = timeoutInMs;
        return this;
      }

      /**
       * Get the request timeout.
       *
       * @return {int} the request timeout before a connection is considered offline
       */
      getTimeout() {
        return this._timeoutInMs;
      }

      /**
       * Collect connectable peers of the managed connections.
       *
       * @return {MoneroRpcConnection[]} connectable peers
       */
      async getPeerConnections() {
        throw new MoneroError("Not implemented");
      }

      /**
       * Disconnect from the current connection.
       *
       * @return {MoneroConnectionManager} this connection manager for chaining
       */
      disconnect() {
        this.setConnection(undefined);
        return this;
      }

      /**
       * Remove all connections.
       *
       * @return {MoneroConnectonManager} this connection manager for chaining
       */
      clear() {
        this._connections.splice(0, this._connections.length);
        if (this._currentConnection) {
          this._currentConnection = undefined;
          this._onConnectionChanged(undefined);
        }
        return this;
      }

      /**
       * Reset to default state.
       *
       * @return {MoneroConnectonManager} this connection manager for chaining
       */
      reset() {
        this.removeListeners();
        this.stopCheckingConnection();
        this.clear();
        this._timeoutMs = MoneroConnectionManager.DEFAULT_TIMEOUT;
        this._autoSwitch = false;
        return this;
      }

      /**
       * Get all listeners.
       *
       * @return {MoneroConnectionManagerListener[]} all listeners
       */
      getListeners() {
        return this._listeners
      }

      // ------------------------------ PRIVATE HELPERS ---------------------------

      async _onConnectionChanged(connection) {
        let promises = [];
        for (let listener of this._listeners) promises.push(listener.onConnectionChanged(connection));
        return Promise.all(promises);
      }

      _getConnectionsInAscendingPriority() {
        let connectionPriorities = new Map();
        for (let connection of this._connections) {
          if (!connectionPriorities.has(connection.getPriority())) connectionPriorities.set(connection.getPriority(), []);
          connectionPriorities.get(connection.getPriority()).push(connection);
        }
        let ascendingPriorities = new Map([...connectionPriorities].sort((a, b) => parseInt(a[0]) - parseInt(b[0]))); // create map in ascending order
        let ascendingPrioritiesList = [];
        for (let priorityConnections of ascendingPriorities.values()) ascendingPrioritiesList.push(priorityConnections);
        if (connectionPriorities.has(0)) ascendingPrioritiesList.push(ascendingPrioritiesList.splice(0, 1)[0]); // move priority 0 to end
        return ascendingPrioritiesList;
      }

      _compareConnections(c1, c2) {

          // current connection is first
          if (c1 === this._currentConnection) return -1;
          if (c2 === this._currentConnection) return 1;

          // order by availability then priority then by name
          if (c1.isOnline() === c2.isOnline()) {
            if (c1.getPriority() === c2.getPriority()) return c1.getUri().localeCompare(c2.getUri());
            else return c1.getPriority() == 0 ? 1 : c2.getPriority() == 0 ? -1 : c1.getPriority() - c2.getPriority();
          } else {
            if (c1.isOnline()) return -1;
            else if (c2.isOnline()) return 1;
            else if (c1.isOnline() === undefined) return -1;
            else return 1; // c1 is offline
          }
      }
    }

    MoneroConnectionManager.DEFAULT_TIMEOUT = 5000;
    MoneroConnectionManager.DEFAULT_CHECK_CONNECTION_PERIOD = 15000;

    module.exports = MoneroConnectionManager;