package com.mapswithme.maps.location;

import android.app.Activity;
import android.content.Context;
import android.location.Location;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.UiThread;
import com.google.android.gms.common.ConnectionResult;
import com.google.android.gms.common.GoogleApiAvailability;
import com.mapswithme.maps.Framework;
import com.mapswithme.maps.MwmApplication;
import com.mapswithme.maps.base.Initializable;
import com.mapswithme.maps.bookmarks.data.FeatureId;
import com.mapswithme.maps.bookmarks.data.MapObject;
import com.mapswithme.maps.routing.RoutingController;
import com.mapswithme.maps.routing.RoutingInfo;
import com.mapswithme.util.Config;
import com.mapswithme.util.Listeners;
import com.mapswithme.util.LocationUtils;
import com.mapswithme.util.PermissionsUtils;
import com.mapswithme.util.Utils;
import com.mapswithme.util.log.Logger;
import com.mapswithme.util.log.LoggerFactory;

public enum LocationHelper implements Initializable<Context>
{
  INSTANCE;

  // These constants should correspond to values defined in platform/location.hpp
  // Leave 0-value as no any error.
  public static final int ERROR_NOT_SUPPORTED = 1;
  public static final int ERROR_DENIED = 2;
  public static final int ERROR_GPS_OFF = 3;
  public static final int ERROR_UNKNOWN = 0;

  private static final long INTERVAL_FOLLOW_AND_ROTATE_MS = 3000;
  private static final long INTERVAL_FOLLOW_MS = 1000;
  private static final long INTERVAL_NOT_FOLLOW_MS = 3000;
  private static final long INTERVAL_NAVIGATION_VEHICLE_MS = 500;

  // TODO (trashkalmar): Correct value
  private static final long INTERVAL_NAVIGATION_BICYCLE_MS = 1000;
  private static final long INTERVAL_NAVIGATION_PEDESTRIAN_MS = 1000;

  @SuppressWarnings("NotNullFieldNotInitialized")
  @NonNull
  private Context mContext;

  @NonNull
  private final LocationListener mCoreLocationListener = new LocationListener()
  {
    @Override
    public void onLocationUpdated(Location location)
    {
      // If we are still in the first run mode, i.e. user is staying on the first run screens,
      // not on the map, we mustn't post location update to the core. Only this preserving allows us
      // to play nice zoom animation once a user will leave first screens and will see a map.
      if (mInFirstRun)
      {
        mLogger.d(TAG, "Location update is obtained and must be ignored, " +
                       "because the app is in a first run mode");
        return;
      }

      nativeLocationUpdated(location.getTime(),
                            location.getLatitude(),
                            location.getLongitude(),
                            location.getAccuracy(),
                            location.getAltitude(),
                            location.getSpeed(),
                            location.getBearing());

      if (mUiCallback != null)
        mUiCallback.onLocationUpdated(location);
    }

    @Override
    public void onCompassUpdated(long time, double north)
    {
      if (mCompassData == null)
        mCompassData = new CompassData();

      mCompassData.update(mContext, north);

      if (mUiCallback != null)
        mUiCallback.onCompassUpdated(mCompassData);
    }


    @Override
    public void onLocationError(int errorCode)
    {
      mLogger.d(TAG, "onLocationError errorCode = " + errorCode, new Throwable());
      mSavedLocation = null;
      nativeOnLocationError(errorCode);
      mLogger.d(TAG, "nativeOnLocationError errorCode = " + errorCode +
                ", current state = " + LocationState.nameOf(getMyPositionMode()));

      if (mUiCallback == null)
        return;

      mUiCallback.onLocationError();
    }

    @Override
    public String toString()
    {
      return "LocationHelper.mCoreLocationListener";
    }
  };

  private final static String TAG = LocationHelper.class.getSimpleName();
  private final Logger mLogger = LoggerFactory.INSTANCE.getLogger(LoggerFactory.Type.LOCATION);
  @NonNull
  private final Listeners<LocationListener> mListeners = new Listeners<>();
  @Nullable
  private Location mSavedLocation;
  private MapObject mMyPosition;
  private long mSavedLocationTime;
  @NonNull
  private SensorHelper mSensorHelper;
  @Nullable
  private BaseLocationProvider mLocationProvider;
  @Nullable
  private UiCallback mUiCallback;
  private long mInterval;
  private CompassData mCompassData;
  private boolean mInFirstRun;
  private boolean mLocationUpdateStoppedByUser;

  @SuppressWarnings("FieldCanBeLocal")
  private final LocationState.ModeChangeListener mMyPositionModeListener =
      new LocationState.ModeChangeListener()
  {
    @Override
    public void onMyPositionModeChanged(int newMode)
    {
      notifyMyPositionModeChanged(newMode);
      mLogger.d(TAG, "onMyPositionModeChanged mode = " + LocationState.nameOf(newMode));

      if (mUiCallback == null)
        mLogger.d(TAG, "UI is not ready to listen my position changes, i.e. it's not attached yet.");
    }
  };

  @SuppressWarnings("FieldCanBeLocal")
  private final LocationState.LocationPendingTimeoutListener mLocationPendingTimeoutListener =
      () -> {
        stop();
        if (LocationUtils.areLocationServicesTurnedOn(mContext))
          notifyLocationNotFound();
      };

  @Override
  public void initialize(@Nullable Context context)
  {
    mContext = context;
    mSensorHelper = new SensorHelper(context);
    initProvider();
    LocationState.nativeSetListener(mMyPositionModeListener);
    LocationState.nativeSetLocationPendingTimeoutListener(mLocationPendingTimeoutListener);
    TransitionListener transitionListener = new TransitionListener(mContext);
    MwmApplication.backgroundTracker(context).addListener(transitionListener);
  }

  @Override
  public void destroy()
  {
    // No op.
  }

  private void initProvider()
  {
    mLogger.d(TAG, "initProvider", new Throwable());
    final boolean containsGoogleServices = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(mContext) == ConnectionResult.SUCCESS;
    final boolean googleServicesTurnedInSettings = Config.useGoogleServices();
    if (containsGoogleServices && googleServicesTurnedInSettings)
    {
      mLogger.d(TAG, "Use fused provider.");
      mLocationProvider = new GoogleFusedLocationProvider(new FusedLocationFixChecker(), mContext);
    }
    else
    {
      initNativeProvider();
    }
  }

  void initNativeProvider()
  {
    mLogger.d(TAG, "Use native provider");
    mLocationProvider = new AndroidNativeProvider(new DefaultLocationFixChecker(), mContext);
  }

  public void onLocationUpdated(@NonNull Location location)
  {
    mSavedLocation = location;
    mMyPosition = null;
    mSavedLocationTime = System.currentTimeMillis();
  }

  /**
   * @return MapObject.MY_POSITION, null if location is not yet determined or "My position" button is switched off.
   */
  @Nullable
  public MapObject getMyPosition()
  {
    if (!LocationState.isTurnedOn())
    {
      mMyPosition = null;
      return null;
    }

    if (mSavedLocation == null)
      return null;

    if (mMyPosition == null)
      mMyPosition = MapObject.createMapObject(FeatureId.EMPTY, MapObject.MY_POSITION, "", "",
                                  mSavedLocation.getLatitude(), mSavedLocation.getLongitude());

    return mMyPosition;
  }

  /**
   * <p>Obtains last known saved location. It depends on "My position" button mode and is erased on "No follow, no position" one.
   * <p>If you need the location regardless of the button's state, use {@link #getLastKnownLocation()}.
   * @return {@code null} if no location is saved or "My position" button is in "No follow, no position" mode.
   */
  @Nullable
  public Location getSavedLocation() { return mSavedLocation; }

  public long getSavedLocationTime() { return mSavedLocationTime; }

  public void switchToNextMode()
  {
    mLogger.d(TAG, "switchToNextMode()");
    LocationState.nativeSwitchToNextMode();
  }

  /**
   * Indicates about whether a location provider is polling location updates right now or not.
   * @see BaseLocationProvider#isActive()
   */
  public boolean isActive()
  {
    return mLocationProvider != null && mLocationProvider.isActive();
  }

  public void setStopLocationUpdateByUser(boolean isStopped)
  {
    mLogger.d(TAG, "Set stop location update by user: " + isStopped);
    mLocationUpdateStoppedByUser = isStopped;
  }

  private boolean isLocationUpdateStoppedByUser()
  {
    return mLocationUpdateStoppedByUser;
  }

  void notifyCompassUpdated(long time, double north)
  {
    for (LocationListener listener : mListeners)
      listener.onCompassUpdated(time, north);
    mListeners.finishIterate();
  }

  void notifyLocationUpdated()
  {
    if (mSavedLocation == null)
    {
      mLogger.d(TAG, "No saved location - skip");
      return;
    }

    for (LocationListener listener : mListeners)
      listener.onLocationUpdated(mSavedLocation);
    mListeners.finishIterate();

    // TODO: consider to create callback mechanism to transfer 'ROUTE_IS_FINISHED' event from
    // the core to the platform code (https://jira.mail.ru/browse/MAPSME-3675),
    // because calling the native method 'nativeIsRouteFinished'
    // too often can result in poor UI performance.
    if (RoutingController.get().isNavigating() && Framework.nativeIsRouteFinished())
    {
      mLogger.d(TAG, "End point is reached");
      restart();
      if (mUiCallback != null)
        mUiCallback.onRoutingFinish();
      RoutingController.get().cancel();
    }
  }

  private void notifyLocationUpdated(LocationListener listener)
  {
    mLogger.d(TAG, "notifyLocationUpdated(), listener: " + listener);

    if (mSavedLocation == null)
    {
      mLogger.d(TAG, "No saved location - skip");
      return;
    }

    listener.onLocationUpdated(mSavedLocation);
  }

  private void notifyLocationError(int errCode)
  {
    mLogger.d(TAG, "notifyLocationError(): " + errCode);

    for (LocationListener listener : mListeners)
      listener.onLocationError(errCode);
    mListeners.finishIterate();
  }

  private void notifyMyPositionModeChanged(int newMode)
  {
    mLogger.d(TAG, "notifyMyPositionModeChanged(): " + LocationState.nameOf(newMode), new Throwable());

    if (mUiCallback != null)
      mUiCallback.onMyPositionModeChanged(newMode);
  }

  private void notifyLocationNotFound()
  {
    mLogger.d(TAG, "notifyLocationNotFound()");
    if (mUiCallback != null)
      mUiCallback.onLocationNotFound();
  }

  /**
   * Registers listener to obtain location updates.
   *
   * @param listener    listener to be registered.
   * @param forceUpdate instantly notify given listener about available location, if any.
   */
  @UiThread
  public void addListener(@NonNull LocationListener listener, boolean forceUpdate)
  {
    mLogger.d(TAG, "addListener(): " + listener + ", forceUpdate: " + forceUpdate);
    mLogger.d(TAG, " - listener count was: " + mListeners.getSize());

    mListeners.register(listener);

    if (forceUpdate)
      notifyLocationUpdated(listener);
  }

  /**
   * Registers listener to obtain location updates.
   *
   * @param listener listener to be registered.
   */
  @UiThread
  public void addListener(@NonNull LocationListener listener)
  {
    addListener(listener, false);
  }

  @UiThread
  /**
   * Removes given location listener.
   * @param listener listener to unregister.
   */
  public void removeListener(@NonNull LocationListener listener)
  {
    mLogger.d(TAG, "removeListener(), listener: " + listener);
    mLogger.d(TAG, " - listener count was: " + mListeners.getSize());
    mListeners.unregister(listener);
  }

  void startSensors()
  {
    mSensorHelper.start();
  }

  private void calcLocationUpdatesInterval()
  {
    mLogger.d(TAG, "calcLocationUpdatesInterval()");
    if (RoutingController.get().isNavigating())
    {
      mLogger.d(TAG, "calcLocationUpdatesInterval(), it's navigation mode");
      final @Framework.RouterType int router = Framework.nativeGetRouter();
      switch (router)
      {
      case Framework.ROUTER_TYPE_PEDESTRIAN:
        mInterval = INTERVAL_NAVIGATION_PEDESTRIAN_MS;
        break;

      case Framework.ROUTER_TYPE_VEHICLE:
        mInterval = INTERVAL_NAVIGATION_VEHICLE_MS;
        break;

      case Framework.ROUTER_TYPE_BICYCLE:
        mInterval = INTERVAL_NAVIGATION_BICYCLE_MS;
        break;

      case Framework.ROUTER_TYPE_TRANSIT:
        // TODO: what is the interval should be for transit type?
        mInterval = INTERVAL_NAVIGATION_PEDESTRIAN_MS;
        break;

      default:
        throw new IllegalArgumentException("Unsupported router type: " + router);
      }

      return;
    }

    int mode = getMyPositionMode();
    switch (mode)
    {
      case LocationState.FOLLOW:
        mInterval = INTERVAL_FOLLOW_MS;
        break;

      case LocationState.FOLLOW_AND_ROTATE:
        mInterval = INTERVAL_FOLLOW_AND_ROTATE_MS;
        break;

      default:
        mInterval = INTERVAL_NOT_FOLLOW_MS;
        break;
    }
  }

  long getInterval()
  {
    return mInterval;
  }

  /**
   * Stops the current provider. Then initialize the location provider again,
   * because location settings could be changed and a new location provider can be used,
   * such as Google fused provider. And we think that Google fused provider is preferable
   * for the most cases. And starts the initialized location provider.
   *
   * @see #start()
   *
   */
  public void restart()
  {
    mLogger.d(TAG, "restart()");
    checkProviderInitialization();
    stopInternal();
    initProvider();
    start();
  }

  /**
   * Adds the {@link #mCoreLocationListener} to listen location updates and notify UI.
   * Notifies about {@link #ERROR_DENIED} if there are no enabled location providers.
   * Calculates minimum time interval for location updates.
   * Starts polling location updates.
   */
  public void start()
  {
    if (isLocationUpdateStoppedByUser())
    {
      mLogger.d(TAG, "Location updates are stopped by the user manually, so skip provider start"
                     + " until the user starts it manually.");
      return;
    }

    checkProviderInitialization();
    //noinspection ConstantConditions
    if (mLocationProvider.isActive())
      throw new AssertionError("Location provider '" + mLocationProvider
                               + "' must be stopped first");

    addListener(mCoreLocationListener, true);

    if (!LocationUtils.checkProvidersAvailability(mContext))
    {
      // No need to notify about an error in first run mode
      if (!mInFirstRun)
        notifyLocationError(ERROR_DENIED);
      return;
    }

    long oldInterval = mInterval;
    mLogger.d(TAG, "Old time interval (ms): " + oldInterval);
    calcLocationUpdatesInterval();
    mLogger.d(TAG, "start(), params: " + mInterval);
    startInternal();
  }

  /**
   * Stops the polling location updates, i.e. removes the {@link #mCoreLocationListener} and stops
   * the current active provider.
   */
  private void stop()
  {
    mLogger.d(TAG, "stop()");
    checkProviderInitialization();
    //noinspection ConstantConditions
    if (!mLocationProvider.isActive())
    {
      mLogger.i(TAG, "Provider '" + mLocationProvider + "' is already stopped");
      return;
    }

    removeListener(mCoreLocationListener);
    stopInternal();
  }

  /**
   * Actually starts location polling.
   */
  private void startInternal()
  {
    mLogger.d(TAG, "startInternal(), current provider is '" + mLocationProvider
                   + "' , my position mode = " + LocationState.nameOf(getMyPositionMode())
                   + ", mInFirstRun = " + mInFirstRun);
    if (!PermissionsUtils.isLocationGranted(mContext))
    {
      mLogger.w(TAG, "Dynamic permission ACCESS_COARSE_LOCATION/ACCESS_FINE_LOCATION is not granted",
                new Throwable());
      return;
    }
    checkProviderInitialization();
    //noinspection ConstantConditions
    mLocationProvider.start();
    mLogger.d(TAG, mLocationProvider.isActive() ? "SUCCESS" : "FAILURE");
  }

  private void checkProviderInitialization()
  {
    if (mLocationProvider == null)
    {
      String error = "A location provider must be initialized!";
      mLogger.e(TAG, error, new Throwable());
      throw new AssertionError(error);
    }
  }

  /**
   * Actually stops location polling.
   */
  private void stopInternal()
  {
    mLogger.d(TAG, "stopInternal()");
    checkProviderInitialization();
    //noinspection ConstantConditions
    mLocationProvider.stop();
    mSensorHelper.stop();
  }

  /**
   * Attach UI to helper.
   */
  @UiThread
  public void attach(@NonNull UiCallback callback)
  {
    mLogger.d(TAG, "attach() callback = " + callback);

    if (mUiCallback != null)
    {
      mLogger.d(TAG, " - already attached. Skip.");
      return;
    }

    mUiCallback = callback;

    Utils.keepScreenOn(true, mUiCallback.getActivity().getWindow());

    mUiCallback.onMyPositionModeChanged(getMyPositionMode());
    if (mCompassData != null)
      mUiCallback.onCompassUpdated(mCompassData);

    checkProviderInitialization();
    //noinspection ConstantConditions
    if (mLocationProvider.isActive())
    {
      mLogger.d(TAG, "attach() provider '" + mLocationProvider + "' is active, just add the listener");
      addListener(mCoreLocationListener, true);
    }
    else
    {
      restart();
    }
  }

  /**
   * Detach UI from helper.
   */
  @UiThread
  public void detach(boolean delayed)
  {
    mLogger.d(TAG, "detach(), delayed: " + delayed);

    if (mUiCallback == null)
    {
      mLogger.d(TAG, " - already detached. Skip.");
      return;
    }

    Utils.keepScreenOn(false, mUiCallback.getActivity().getWindow());
    mUiCallback = null;
  }

  @UiThread
  public void onEnteredIntoFirstRun()
  {
    mLogger.i(TAG, "onEnteredIntoFirstRun");
    mInFirstRun = true;
  }

  @UiThread
  public void onExitFromFirstRun()
  {
    mLogger.i(TAG, "onExitFromFirstRun");
    if (!mInFirstRun)
      throw new AssertionError("Must be called only after 'onEnteredIntoFirstRun' method!");

    mInFirstRun = false;

    if (getMyPositionMode() != LocationState.NOT_FOLLOW_NO_POSITION)
      throw new AssertionError("My position mode must be equal NOT_FOLLOW_NO_POSITION");

    // If there is a location we need just to pass it to the listeners, so that
    // my position state machine will be switched to the FOLLOW state.
    Location location = getSavedLocation();
    if (location != null)
    {
      notifyLocationUpdated();
      mLogger.d(TAG, "Current location is available, so play the nice zoom animation");
      Framework.nativeRunFirstLaunchAnimation();
      return;
    }

    checkProviderInitialization();
    // If the location hasn't been obtained yet we need to switch to the next mode and wait for locations.
    // Otherwise, try to restart location updates polling.
    // noinspection ConstantConditions
    if (mLocationProvider.isActive())
      switchToNextMode();
    else
      restart();
  }

  /**
   * Obtains last known location regardless of "My position" button state.
   * @return {@code null} on failure.
   */
  @Nullable
  public Location getLastKnownLocation()
  {
    if (mSavedLocation != null)
      return mSavedLocation;

    return AndroidNativeProvider.findBestLocation(mContext);
  }

  @Nullable
  public CompassData getCompassData()
  {
    return mCompassData;
  }

  @LocationState.Value
  public int getMyPositionMode()
  {
    return LocationState.nativeGetMode();
  }

  private static native void nativeOnLocationError(int errorCode);
  private static native void nativeLocationUpdated(long time, double lat, double lon, float accuracy,
                                                   double altitude, float speed, float bearing);

  public interface UiCallback
  {
    Activity getActivity();
    void onMyPositionModeChanged(int newMode);
    void onLocationUpdated(@NonNull Location location);
    void onCompassUpdated(@NonNull CompassData compass);
    void onLocationError();
    void onLocationNotFound();
    void onRoutingFinish();
  }
}
