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

package org.mozilla.gecko;

import android.annotation.SuppressLint;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.net.ConnectivityManager;
import android.net.DhcpInfo;
import android.net.Proxy;
import android.net.ProxyInfo;
import android.net.wifi.WifiManager;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.mozilla.gecko.annotation.WrapForJNI;
import org.mozilla.gecko.util.NetworkUtils;
import org.mozilla.gecko.util.NetworkUtils.ConnectionSubType;
import org.mozilla.gecko.util.NetworkUtils.ConnectionType;
import org.mozilla.gecko.util.NetworkUtils.NetworkStatus;

/**
 * Provides connection type, subtype and general network status (up/down).
 *
 * <p>According to spec of Network Information API version 3, connection types include: bluetooth,
 * cellular, ethernet, none, wifi and other. The objective of providing such general connection is
 * due to some security concerns. In short, we don't want to expose exact network type, especially
 * the cellular network type.
 *
 * <p>Specific mobile subtypes are mapped to general 2G, 3G and 4G buckets.
 *
 * <p>Logic is implemented as a state machine, so see the transition matrix to figure out what
 * happens when. This class depends on access to the context, so only use after GeckoAppShell has
 * been initialized.
 */
public class GeckoNetworkManager extends BroadcastReceiver {
  private static final String LOGTAG = "GeckoNetworkManager";

  // If network configuration and/or status changed, we send details of what changed.
  // If we received a "check out new network state!" intent from the OS but nothing in it looks
  // different, we ignore it. See Bug 1330836 for some relevant details.
  private static final String LINK_DATA_CHANGED = "changed";

  private static GeckoNetworkManager instance;

  // We hackishly (yet harmlessly, in this case) keep a Context reference passed in via the start
  // method.
  // See context handling notes in handleManagerEvent, and Bug 1277333.
  private Context mContext;

  public static void destroy() {
    if (instance != null) {
      instance.onDestroy();
      instance = null;
    }
  }

  public enum ManagerState {
    OffNoListeners,
    OffWithListeners,
    OnNoListeners,
    OnWithListeners
  }

  public enum ManagerEvent {
    start,
    stop,
    enableNotifications,
    disableNotifications,
    receivedUpdate
  }

  private ManagerState mCurrentState = ManagerState.OffNoListeners;
  private ConnectionType mCurrentConnectionType = ConnectionType.NONE;
  private ConnectionType mPreviousConnectionType = ConnectionType.NONE;
  private ConnectionSubType mCurrentConnectionSubtype = ConnectionSubType.UNKNOWN;
  private ConnectionSubType mPreviousConnectionSubtype = ConnectionSubType.UNKNOWN;
  private NetworkStatus mCurrentNetworkStatus = NetworkStatus.UNKNOWN;
  private NetworkStatus mPreviousNetworkStatus = NetworkStatus.UNKNOWN;

  private GeckoNetworkManager() {}

  private void onDestroy() {
    handleManagerEvent(ManagerEvent.stop);
  }

  public static GeckoNetworkManager getInstance() {
    if (instance == null) {
      instance = new GeckoNetworkManager();
    }

    return instance;
  }

  public double[] getCurrentInformation() {
    final Context applicationContext = GeckoAppShell.getApplicationContext();
    final ConnectionType connectionType = mCurrentConnectionType;
    return new double[] {
      connectionType.value,
      connectionType == ConnectionType.WIFI ? 1.0 : 0.0,
      connectionType == ConnectionType.WIFI ? wifiDhcpGatewayAddress(applicationContext) : 0.0
    };
  }

  @Override
  public void onReceive(final Context aContext, final Intent aIntent) {
    if (aIntent.getAction().equals(Proxy.PROXY_CHANGE_ACTION)) {
      getProxyInfoAndReport(aContext);
      return;
    }

    handleManagerEvent(ManagerEvent.receivedUpdate);
  }

  private void getProxyInfoAndReport(final Context aContext) {
    if (mCurrentState != ManagerState.OnNoListeners
        && mCurrentState != ManagerState.OnWithListeners) {
      return;
    }

    final ConnectivityManager connectivityManager =
        (ConnectivityManager) aContext.getSystemService(Context.CONNECTIVITY_SERVICE);
    if (connectivityManager == null) {
      return;
    }

    final ProxyInfo proxyInfo = connectivityManager.getDefaultProxy();
    if (proxyInfo == null) {
      if (GeckoThread.isRunning()) {
        onProxyChanged("", 0, "", new String[0]);
      } else {
        GeckoThread.queueNativeCall(
            GeckoNetworkManager.class,
            "onProxyChanged",
            String.class,
            "",
            0,
            String.class,
            "",
            String[].class,
            new String[0]);
      }
      return;
    }

    final String host = proxyInfo.getHost();
    final int port = proxyInfo.getPort();
    final String[] exclusionList = proxyInfo.getExclusionList();
    final String pacFileUrl =
        proxyInfo.getPacFileUrl() != null ? proxyInfo.getPacFileUrl().toString() : "";

    if (GeckoThread.isRunning()) {
      onProxyChanged(host, port, pacFileUrl, exclusionList);
    } else {
      GeckoThread.queueNativeCall(
          GeckoNetworkManager.class,
          "onProxyChanged",
          String.class,
          host,
          port,
          String.class,
          pacFileUrl,
          String[].class,
          exclusionList);
    }
  }

  public void start(final Context context) {
    mContext = context;
    handleManagerEvent(ManagerEvent.start);
    getProxyInfoAndReport(context);
  }

  public void stop() {
    handleManagerEvent(ManagerEvent.stop);
  }

  public void enableNotifications() {
    handleManagerEvent(ManagerEvent.enableNotifications);
  }

  public void disableNotifications() {
    handleManagerEvent(ManagerEvent.disableNotifications);
  }

  /**
   * For a given event, figure out the next state, run any transition by-product actions, and switch
   * current state to the next state. If event is invalid for the current state, this is a no-op.
   *
   * @param event Incoming event
   * @return Boolean indicating if transition was performed.
   */
  private synchronized boolean handleManagerEvent(final ManagerEvent event) {
    final ManagerState nextState = getNextState(mCurrentState, event);

    Log.d(LOGTAG, "Incoming event " + event + " for state " + mCurrentState + " -> " + nextState);
    if (nextState == null) {
      Log.w(LOGTAG, "Invalid event " + event + " for state " + mCurrentState);
      return false;
    }

    // We're being deliberately careful about handling context here; it's possible that in some
    // rare cases and possibly related to timing of when this is called (seems to be early in the
    // startup phase),
    // GeckoAppShell.getApplicationContext() will be null, and .start() wasn't called yet,
    // so we don't have a local Context reference either. If both of these are true, we have to drop
    // the event.
    // NB: this is hacky (and these checks attempt to isolate the hackiness), and root cause
    // seems to be how this class fits into the larger ecosystem and general flow of events.
    // See Bug 1277333.
    final Context contextForAction;
    if (mContext != null) {
      contextForAction = mContext;
    } else {
      contextForAction = GeckoAppShell.getApplicationContext();
    }

    if (contextForAction == null) {
      Log.w(
          LOGTAG,
          "Context is not available while processing event "
              + event
              + " for state "
              + mCurrentState);
      return false;
    }

    performActionsForStateEvent(contextForAction, mCurrentState, event);
    mCurrentState = nextState;

    return true;
  }

  /**
   * Defines a transition matrix for our state machine. For a given state/event pair, returns
   * nextState.
   *
   * @param currentState Current state against which we have an incoming event
   * @param event Incoming event for which we'd like to figure out the next state
   * @return State into which we should transition as result of given event
   */
  @Nullable
  public static ManagerState getNextState(
      final @NonNull ManagerState currentState, final @NonNull ManagerEvent event) {
    switch (currentState) {
      case OffNoListeners:
        switch (event) {
          case start:
            return ManagerState.OnNoListeners;
          case enableNotifications:
            return ManagerState.OffWithListeners;
          default:
            return null;
        }
      case OnNoListeners:
        switch (event) {
          case stop:
            return ManagerState.OffNoListeners;
          case enableNotifications:
            return ManagerState.OnWithListeners;
          case receivedUpdate:
            return ManagerState.OnNoListeners;
          default:
            return null;
        }
      case OnWithListeners:
        switch (event) {
          case stop:
            return ManagerState.OffWithListeners;
          case disableNotifications:
            return ManagerState.OnNoListeners;
          case receivedUpdate:
            return ManagerState.OnWithListeners;
          default:
            return null;
        }
      case OffWithListeners:
        switch (event) {
          case start:
            return ManagerState.OnWithListeners;
          case disableNotifications:
            return ManagerState.OffNoListeners;
          default:
            return null;
        }
      default:
        throw new IllegalStateException("Unknown current state: " + currentState.name());
    }
  }

  /**
   * For a given state/event combination, run any actions which are by-products of leaving the state
   * because of a given event. Since this is a deterministic state machine, we can easily do that
   * without any additional information.
   *
   * @param currentState State which we are leaving
   * @param event Event which is causing us to leave the state
   */
  private void performActionsForStateEvent(
      final Context context, final ManagerState currentState, final ManagerEvent event) {
    // NB: network state might be queried via getCurrentInformation at any time; pre-rewrite
    // behaviour was
    // that network state was updated whenever enableNotifications was called. To avoid deviating
    // from previous behaviour and causing weird side-effects, we call
    // updateNetworkStateAndConnectionType
    // whenever notifications are enabled.
    switch (currentState) {
      case OffNoListeners:
        if (event == ManagerEvent.start) {
          updateNetworkStateAndConnectionType(context);
          registerBroadcastReceiver(context, this);
        }
        if (event == ManagerEvent.enableNotifications) {
          updateNetworkStateAndConnectionType(context);
        }
        break;
      case OnNoListeners:
        if (event == ManagerEvent.receivedUpdate) {
          updateNetworkStateAndConnectionType(context);
          sendNetworkStateToListeners(context);
        }
        if (event == ManagerEvent.enableNotifications) {
          updateNetworkStateAndConnectionType(context);
          registerBroadcastReceiver(context, this);
        }
        if (event == ManagerEvent.stop) {
          unregisterBroadcastReceiver(context, this);
        }
        break;
      case OnWithListeners:
        if (event == ManagerEvent.receivedUpdate) {
          updateNetworkStateAndConnectionType(context);
          sendNetworkStateToListeners(context);
        }
        if (event == ManagerEvent.stop) {
          unregisterBroadcastReceiver(context, this);
        }
        /* no-op event: ManagerEvent.disableNotifications */
        break;
      case OffWithListeners:
        if (event == ManagerEvent.start) {
          registerBroadcastReceiver(context, this);
        }
        /* no-op event: ManagerEvent.disableNotifications */
        break;
      default:
        throw new IllegalStateException("Unknown current state: " + currentState.name());
    }
  }

  /** Update current network state and connection types. */
  private void updateNetworkStateAndConnectionType(final Context context) {
    final ConnectivityManager connectivityManager =
        (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
    // Type/status getters below all have a defined behaviour for when connectivityManager == null
    if (connectivityManager == null) {
      Log.e(LOGTAG, "ConnectivityManager does not exist.");
    }
    mCurrentConnectionType = NetworkUtils.getConnectionType(connectivityManager);
    mCurrentNetworkStatus = NetworkUtils.getNetworkStatus(connectivityManager);
    mCurrentConnectionSubtype = NetworkUtils.getConnectionSubType(connectivityManager);
    Log.d(
        LOGTAG,
        "New network state: "
            + mCurrentNetworkStatus
            + ", "
            + mCurrentConnectionType
            + ", "
            + mCurrentConnectionSubtype);
  }

  @WrapForJNI(dispatchTo = "gecko")
  private static native void onConnectionChanged(
      int type, String subType, boolean isWifi, int dhcpGateway);

  @WrapForJNI(dispatchTo = "gecko")
  private static native void onStatusChanged(String status);

  @WrapForJNI(dispatchTo = "gecko")
  private static native void onProxyChanged(
      String host, int port, String pacFileUrl, String[] exclusionList);

  /** Send current network state and connection type to whomever is listening. */
  private void sendNetworkStateToListeners(final Context context) {
    final boolean connectionTypeOrSubtypeChanged =
        mCurrentConnectionType != mPreviousConnectionType
            || mCurrentConnectionSubtype != mPreviousConnectionSubtype;
    if (connectionTypeOrSubtypeChanged) {
      mPreviousConnectionType = mCurrentConnectionType;
      mPreviousConnectionSubtype = mCurrentConnectionSubtype;

      final boolean isWifi = mCurrentConnectionType == ConnectionType.WIFI;
      final int gateway = !isWifi ? 0 : wifiDhcpGatewayAddress(context);

      if (GeckoThread.isRunning()) {
        onConnectionChanged(
            mCurrentConnectionType.value, mCurrentConnectionSubtype.value, isWifi, gateway);
      } else {
        GeckoThread.queueNativeCall(
            GeckoNetworkManager.class,
            "onConnectionChanged",
            mCurrentConnectionType.value,
            String.class,
            mCurrentConnectionSubtype.value,
            isWifi,
            gateway);
      }
    }

    // If neither network status nor network configuration changed, do nothing.
    if (mCurrentNetworkStatus == mPreviousNetworkStatus && !connectionTypeOrSubtypeChanged) {
      return;
    }

    // If network status remains the same, send "changed". Otherwise, send new network status.
    // See Bug 1330836 for relevant details.
    final String status;
    if (mCurrentNetworkStatus == mPreviousNetworkStatus) {
      status = LINK_DATA_CHANGED;
    } else {
      mPreviousNetworkStatus = mCurrentNetworkStatus;
      status = mCurrentNetworkStatus.value;
    }

    if (GeckoThread.isRunning()) {
      onStatusChanged(status);
    } else {
      GeckoThread.queueNativeCall(
          GeckoNetworkManager.class, "onStatusChanged", String.class, status);
    }
  }

  /** Stop listening for network state updates. */
  private static void unregisterBroadcastReceiver(
      final Context context, final BroadcastReceiver receiver) {
    context.unregisterReceiver(receiver);
  }

  /** Start listening for network state updates. */
  private static void registerBroadcastReceiver(
      final Context context, final BroadcastReceiver receiver) {
    final IntentFilter filter = new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION);
    filter.addAction(Proxy.PROXY_CHANGE_ACTION);
    context.registerReceiver(receiver, filter);
  }

  private static int wifiDhcpGatewayAddress(final Context context) {
    if (context == null) {
      return 0;
    }

    try {
      final WifiManager mgr =
          (WifiManager) context.getApplicationContext().getSystemService(Context.WIFI_SERVICE);
      if (mgr == null) {
        return 0;
      }

      @SuppressLint("MissingPermission")
      final DhcpInfo d = mgr.getDhcpInfo();
      if (d == null) {
        return 0;
      }

      return d.gateway;

    } catch (final Exception ex) {
      // getDhcpInfo() is not documented to require any permissions, but on some devices
      // requires android.permission.ACCESS_WIFI_STATE. Just catch the generic exception
      // here and returning 0. Not logging because this could be noisy.
      return 0;
    }
  }
}
