/*
 * Copyright (C) 2012-2017 Tobias Brunner
 * HSR Hochschule fuer Technik Rapperswil
 *
 * This program is free software; you can redistribute it and/or modify it
 * under the terms of the GNU General Public License as published by the
 * Free Software Foundation; either version 2 of the License, or (at your
 * option) any later version.  See <http://www.fsf.org/copyleft/gpl.txt>.
 *
 * This program is distributed in the hope that it will be useful, but
 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
 * or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
 * for more details.
 */

package org.strongswan.android.logic;

import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.os.Binder;
import android.os.Bundle;
import android.os.Handler;
import android.os.IBinder;
import android.os.Message;
import android.os.SystemClock;

import org.strongswan.android.R;
import org.strongswan.android.data.VpnProfile;
import org.strongswan.android.data.VpnProfileDataSource;
import org.strongswan.android.data.VpnType;
import org.strongswan.android.logic.imc.ImcState;
import org.strongswan.android.logic.imc.RemediationInstruction;
import org.strongswan.android.ui.VpnProfileControlActivity;

import java.lang.ref.WeakReference;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.concurrent.Callable;

import androidx.core.content.ContextCompat;

public class VpnStateService extends Service
{
	private final HashSet<VpnStateListener> mListeners = new HashSet<VpnStateListener>();
	private final IBinder mBinder = new LocalBinder();
	private long mConnectionID = 0;
	private Handler mHandler;
	private VpnProfile mProfile;
	private State mState = State.DISABLED;
	private ErrorState mError = ErrorState.NO_ERROR;
	private ImcState mImcState = ImcState.UNKNOWN;
	private final LinkedList<RemediationInstruction> mRemediationInstructions = new LinkedList<RemediationInstruction>();
	private static long RETRY_INTERVAL = 1000;
	/* cap the retry interval at 2 minutes */
	private static long MAX_RETRY_INTERVAL = 120000;
	private static int RETRY_MSG = 1;
	private RetryTimeoutProvider mTimeoutProvider = new RetryTimeoutProvider();
	private long mRetryTimeout;
	private long mRetryIn;

	public enum State
	{
		DISABLED,
		CONNECTING,
		CONNECTED,
		DISCONNECTING,
	}

	public enum ErrorState
	{
		NO_ERROR,
		AUTH_FAILED,
		PEER_AUTH_FAILED,
		LOOKUP_FAILED,
		UNREACHABLE,
		GENERIC_ERROR,
		PASSWORD_MISSING,
		CERTIFICATE_UNAVAILABLE,
	}

	/**
	 * Listener interface for bound clients that are interested in changes to
	 * this Service.
	 */
	public interface VpnStateListener
	{
		public void stateChanged();
	}

	/**
	 * Simple Binder that allows to directly access this Service class itself
	 * after binding to it.
	 */
	public class LocalBinder extends Binder
	{
		public VpnStateService getService()
		{
			return VpnStateService.this;
		}
	}

	@Override
	public void onCreate()
	{
		/* this handler allows us to notify listeners from the UI thread and
		 * not from the threads that actually report any state changes */
		mHandler = new RetryHandler(this);
	}

	@Override
	public IBinder onBind(Intent intent)
	{
		return mBinder;
	}

	@Override
	public void onDestroy()
	{
	}

	/**
	 * Register a listener with this Service. We assume this is called from
	 * the main thread so no synchronization is happening.
	 *
	 * @param listener listener to register
	 */
	public void registerListener(VpnStateListener listener)
	{
		mListeners.add(listener);
	}

	/**
	 * Unregister a listener from this Service.
	 *
	 * @param listener listener to unregister
	 */
	public void unregisterListener(VpnStateListener listener)
	{
		mListeners.remove(listener);
	}

	/**
	 * Get the current VPN profile.
	 *
	 * @return profile
	 */
	public VpnProfile getProfile()
	{	/* only updated from the main thread so no synchronization needed */
		return mProfile;
	}

	/**
	 * Get the current connection ID.  May be used to track which state
	 * changes have already been handled.
	 *
	 * Is increased when startConnection() is called.
	 *
	 * @return connection ID
	 */
	public long getConnectionID()
	{	/* only updated from the main thread so no synchronization needed */
		return mConnectionID;
	}

	/**
	 * Get the total number of seconds until there is an automatic retry to reconnect.
	 * @return total number of seconds until the retry
	 */
	public int getRetryTimeout()
	{
		return (int)(mRetryTimeout / 1000);
	}

	/**
	 * Get the number of seconds until there is an automatic retry to reconnect.
	 * @return number of seconds until the retry
	 */
	public int getRetryIn()
	{
		return (int)(mRetryIn / 1000);
	}

	/**
	 * Get the current state.
	 *
	 * @return state
	 */
	public State getState()
	{	/* only updated from the main thread so no synchronization needed */
		return mState;
	}

	/**
	 * Get the current error, if any.
	 *
	 * @return error
	 */
	public ErrorState getErrorState()
	{	/* only updated from the main thread so no synchronization needed */
		return mError;
	}

	/**
	 * Get a description of the current error, if any.
	 *
	 * @return error description text id
	 */
	public int getErrorText()
	{
		switch (mError)
		{
			case AUTH_FAILED:
				if (mImcState == ImcState.BLOCK)
				{
					return R.string.error_assessment_failed;
				}
				else
				{
					return R.string.error_auth_failed;
				}
			case PEER_AUTH_FAILED:
				return R.string.error_peer_auth_failed;
			case LOOKUP_FAILED:
				return R.string.error_lookup_failed;
			case UNREACHABLE:
				return R.string.error_unreachable;
			case PASSWORD_MISSING:
				return R.string.error_password_missing;
			case CERTIFICATE_UNAVAILABLE:
				return R.string.error_certificate_unavailable;
			default:
				return R.string.error_generic;
		}
	}

	/**
	 * Get the current IMC state, if any.
	 *
	 * @return imc state
	 */
	public ImcState getImcState()
	{	/* only updated from the main thread so no synchronization needed */
		return mImcState;
	}

	/**
	 * Get the remediation instructions, if any.
	 *
	 * @return read-only list of instructions
	 */
	public List<RemediationInstruction> getRemediationInstructions()
	{	/* only updated from the main thread so no synchronization needed */
		return Collections.unmodifiableList(mRemediationInstructions);
	}

	/**
	 * Disconnect any existing connection and shutdown the daemon, the
	 * VpnService is not stopped but it is reset so new connections can be
	 * started.
	 */
	public void disconnect()
	{
		/* reset any potential retry timer and error state */
		resetRetryTimer();
		setError(ErrorState.NO_ERROR);

		/* as soon as the TUN device is created by calling establish() on the
		 * VpnService.Builder object the system binds to the service and keeps
		 * bound until the file descriptor of the TUN device is closed.  thus
		 * calling stopService() here would not stop (destroy) the service yet,
		 * instead we call startService() with a specific action which shuts down
		 * the daemon (and closes the TUN device, if any) */
		Context context = getApplicationContext();
		Intent intent = new Intent(context, CharonVpnService.class);
		intent.setAction(CharonVpnService.DISCONNECT_ACTION);
		context.startService(intent);
	}

	/**
	 * Connect (or reconnect) a profile
	 * @param profileInfo optional profile info (basically the UUID and password), taken from the
	 *                    previous profile if null
	 * @param fromScratch true if this is a manual retry/reconnect or a completely new connection
	 */
	public void connect(Bundle profileInfo, boolean fromScratch)
	{
		/* we assume we have the necessary permission */
		Context context = getApplicationContext();
		Intent intent = new Intent(context, CharonVpnService.class);
		if (profileInfo == null)
		{
			profileInfo = new Bundle();
			profileInfo.putString(VpnProfileDataSource.KEY_UUID, mProfile.getUUID().toString());
			/* pass the previous password along */
			profileInfo.putString(VpnProfileDataSource.KEY_PASSWORD, mProfile.getPassword());
		}
		if (fromScratch)
		{
			/* reset if this is a manual retry or a new connection */
			mTimeoutProvider.reset();
		}
		else
		{	/* mark this as an automatic retry */
			profileInfo.putBoolean(CharonVpnService.KEY_IS_RETRY, true);
		}
		intent.putExtras(profileInfo);
		ContextCompat.startForegroundService(context, intent);
	}

	/**
	 * Reconnect to the previous profile.
	 */
	public void reconnect()
	{
		if (mProfile == null)
		{
			return;
		}
		if (mProfile.getVpnType().has(VpnType.VpnTypeFeature.USER_PASS))
		{
			if (mProfile.getPassword() == null ||
				mError == ErrorState.AUTH_FAILED)
			{	/* show a dialog if we either don't have the password or if it might be the wrong
				 * one (which is or isn't stored with the profile, let the activity decide)  */
				Intent intent = new Intent(this, VpnProfileControlActivity.class);
				intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
				intent.setAction(VpnProfileControlActivity.START_PROFILE);
				intent.putExtra(VpnProfileControlActivity.EXTRA_VPN_PROFILE_ID, mProfile.getUUID().toString());
				startActivity(intent);
				/* reset the retry timer immediately in case the user needs more time to enter the password */
				notifyListeners(() -> {
					resetRetryTimer();
					return true;
				});
				return;
			}
		}
		connect(null, true);
	}

	/**
	 * Update state and notify all listeners about the change. By using a Handler
	 * this is done from the main UI thread and not the initial reporter thread.
	 * Also, in doing the actual state change from the main thread, listeners
	 * see all changes and none are skipped.
	 *
	 * @param change the state update to perform before notifying listeners, returns true if state changed
	 */
	private void notifyListeners(final Callable<Boolean> change)
	{
		mHandler.post(new Runnable() {
			@Override
			public void run()
			{
				try
				{
					if (change.call())
					{	/* otherwise there is no need to notify the listeners */
						for (VpnStateListener listener : mListeners)
						{
							listener.stateChanged();
						}
					}
				}
				catch (Exception e)
				{
					e.printStackTrace();
				}
			}
		});
	}

	/**
	 * Called when a connection is started.  Sets the currently active VPN
	 * profile, resets IMC and Error state variables, sets the State to
	 * CONNECTING, increases the connection ID, and notifies all listeners.
	 *
	 * May be called from threads other than the main thread.
	 *
	 * @param profile current profile
	 */
	public void startConnection(final VpnProfile profile)
	{
		notifyListeners(new Callable<Boolean>() {
			@Override
			public Boolean call() throws Exception
			{
				resetRetryTimer();
				VpnStateService.this.mConnectionID++;
				VpnStateService.this.mProfile = profile;
				VpnStateService.this.mState = State.CONNECTING;
				VpnStateService.this.mError = ErrorState.NO_ERROR;
				VpnStateService.this.mImcState = ImcState.UNKNOWN;
				VpnStateService.this.mRemediationInstructions.clear();
				return true;
			}
		});
	}

	/**
	 * Update the state and notify all listeners, if changed.
	 *
	 * May be called from threads other than the main thread.
	 *
	 * @param state new state
	 */
	public void setState(final State state)
	{
		notifyListeners(new Callable<Boolean>() {
			@Override
			public Boolean call() throws Exception
			{
				if (state == State.CONNECTED)
				{	/* reset counter in case there is an error later on */
					mTimeoutProvider.reset();
				}
				if (VpnStateService.this.mState != state)
				{
					VpnStateService.this.mState = state;
					return true;
				}
				return false;
			}
		});
	}

	/**
	 * Set the current error state and notify all listeners, if changed.
	 *
	 * May be called from threads other than the main thread.
	 *
	 * @param error error state
	 */
	public void setError(final ErrorState error)
	{
		notifyListeners(new Callable<Boolean>() {
			@Override
			public Boolean call() throws Exception
			{
				if (VpnStateService.this.mError != error)
				{
					if (VpnStateService.this.mError == ErrorState.NO_ERROR)
					{
						setRetryTimer(error);
					}
					else if (error == ErrorState.NO_ERROR)
					{
						resetRetryTimer();
					}
					VpnStateService.this.mError = error;
					return true;
				}
				return false;
			}
		});
	}

	/**
	 * Set the current IMC state and notify all listeners, if changed.
	 *
	 * Setting the state to UNKNOWN clears all remediation instructions.
	 *
	 * May be called from threads other than the main thread.
	 *
	 * @param state IMC state
	 */
	public void setImcState(final ImcState state)
	{
		notifyListeners(new Callable<Boolean>() {
			@Override
			public Boolean call() throws Exception
			{
				if (state == ImcState.UNKNOWN)
				{
					VpnStateService.this.mRemediationInstructions.clear();
				}
				if (VpnStateService.this.mImcState != state)
				{
					VpnStateService.this.mImcState = state;
					return true;
				}
				return false;
			}
		});
	}

	/**
	 * Add the given remediation instruction to the internal list.  Listeners
	 * are not notified.
	 *
	 * Instructions are cleared if the IMC state is set to UNKNOWN.
	 *
	 * May be called from threads other than the main thread.
	 *
	 * @param instruction remediation instruction
	 */
	public void addRemediationInstruction(final RemediationInstruction instruction)
	{
		mHandler.post(new Runnable() {
			@Override
			public void run()
			{
				VpnStateService.this.mRemediationInstructions.add(instruction);
			}
		});
	}

	/**
	 * Sets the retry timer
	 */
	private void setRetryTimer(ErrorState error)
	{
		mRetryTimeout = mRetryIn = mTimeoutProvider.getTimeout(error);
		if (mRetryTimeout <= 0)
		{
			return;
		}
		mHandler.sendMessageAtTime(mHandler.obtainMessage(RETRY_MSG), SystemClock.uptimeMillis() + RETRY_INTERVAL);
	}

	/**
	 * Reset the retry timer
	 */
	private void resetRetryTimer()
	{
		mRetryTimeout = 0;
		mRetryIn = 0;
	}

	/**
	 * Special Handler subclass that handles the retry countdown (more accurate than CountDownTimer)
	 */
	private static class RetryHandler extends Handler {
		WeakReference<VpnStateService> mService;

		public RetryHandler(VpnStateService service)
		{
			mService = new WeakReference<>(service);
		}

		@Override
		public void handleMessage(Message msg)
		{
			/* handle retry countdown */
			if (mService.get().mRetryTimeout <= 0)
			{
				return;
			}
			mService.get().mRetryIn -= RETRY_INTERVAL;
			if (mService.get().mRetryIn > 0)
			{
				/* calculate next interval before notifying listeners */
				long next = SystemClock.uptimeMillis() + RETRY_INTERVAL;

				for (VpnStateListener listener : mService.get().mListeners)
				{
					listener.stateChanged();
				}
				sendMessageAtTime(obtainMessage(RETRY_MSG), next);
			}
			else
			{
				mService.get().connect(null, false);
			}
		}
	}

	/**
	 * Class that handles an exponential backoff for retry timeouts
	 */
	private static class RetryTimeoutProvider
	{
		private long mRetry;

		private long getBaseTimeout(ErrorState error)
		{
			switch (error)
			{
				case AUTH_FAILED:
					return 10000;
				case PEER_AUTH_FAILED:
					return 5000;
				case LOOKUP_FAILED:
					return 5000;
				case UNREACHABLE:
					return 5000;
				case PASSWORD_MISSING:
					/* this needs user intervention (entering the password) */
					return 0;
				case CERTIFICATE_UNAVAILABLE:
					/* if this is because the device has to be unlocked we might be able to reconnect */
					return 5000;
				default:
					return 10000;
			}
		}

		/**
		 * Called each time a new retry timeout is started. The timeout increases until reset() is
		 * called and the base timeout is returned again.
		 * @param error Error state
		 */
		public long getTimeout(ErrorState error)
		{
			long timeout = (long)(getBaseTimeout(error) * Math.pow(2, mRetry++));
			/* return the result rounded to seconds */
			return Math.min((timeout / 1000) * 1000, MAX_RETRY_INTERVAL);
		}

		/**
		 * Reset the retry counter.
		 */
		public void reset()
		{
			mRetry = 0;
		}
	}
}
