package no.nordicsemi.android.ble;

import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothGatt;
import android.bluetooth.BluetoothGattCharacteristic;
import android.bluetooth.BluetoothGattDescriptor;
import android.os.Handler;

import androidx.annotation.IntRange;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import no.nordicsemi.android.ble.callback.FailCallback;
import no.nordicsemi.android.ble.callback.SuccessCallback;
import no.nordicsemi.android.ble.exception.BluetoothDisabledException;
import no.nordicsemi.android.ble.exception.DeviceDisconnectedException;
import no.nordicsemi.android.ble.exception.InvalidRequestException;
import no.nordicsemi.android.ble.exception.RequestFailedException;

public abstract class TimeoutableRequest extends Request {
	private Runnable timeoutCallback;
	protected long timeout;

	TimeoutableRequest(@NonNull final Type type) {
		super(type);
	}

	TimeoutableRequest(@NonNull final Type type, @Nullable final BluetoothGattCharacteristic characteristic) {
		super(type, characteristic);
	}

	TimeoutableRequest(@NonNull final Type type, @Nullable final BluetoothGattDescriptor descriptor) {
		super(type, descriptor);
	}

	@NonNull
	@Override
	TimeoutableRequest setRequestHandler(@NonNull final RequestHandler requestHandler) {
		super.setRequestHandler(requestHandler);
		return this;
	}

	@NonNull
	@Override
	public TimeoutableRequest setHandler(@NonNull final Handler handler) {
		super.setHandler(handler);
		return this;
	}

	/**
	 * Sets the operation timeout.
	 * When the timeout occurs, the request will fail with {@link FailCallback#REASON_TIMEOUT}.
	 *
	 * @param timeout the request timeout in milliseconds, 0 to disable timeout.
	 * @return the callback.
	 * @throws IllegalStateException         thrown when the request has already been started.
	 * @throws UnsupportedOperationException thrown when the timeout is not allowed for this request,
	 *                                       as the callback from the system is required.
	 */
	@NonNull
	public TimeoutableRequest timeout(@IntRange(from = 0) final long timeout) {
		if (timeoutCallback != null)
			throw new IllegalStateException("Request already started");
		this.timeout = timeout;
		return this;
	}

	/**
	 * Enqueues the request for asynchronous execution.
	 * <p>
	 * Use {@link #timeout(long)} to set the maximum time the manager should wait until the device
	 * is ready. When the timeout occurs, the request will fail with
	 * {@link FailCallback#REASON_TIMEOUT} and the device will get disconnected.
	 */
	@Override
	public final void enqueue() {
		super.enqueue();
	}

	/**
	 * Enqueues the request for asynchronous execution.
	 * <p>
	 * When the timeout occurs, the request will fail with {@link FailCallback#REASON_TIMEOUT}
	 * and the device will get disconnected.
	 *
	 * @param timeout the request timeout in milliseconds, 0 to disable timeout. This value will
	 *                override one set in {@link #timeout(long)}.
	 * @deprecated Use {@link #timeout(long)} and {@link #enqueue()} instead.
	 */
	@Deprecated
	public final void enqueue(@IntRange(from = 0) final long timeout) {
		timeout(timeout).enqueue();
	}

	/**
	 * Synchronously waits until the request is done.
	 * <p>
	 * Use {@link #timeout(long)} to set the maximum time the manager should wait until the request
	 * is ready. When the timeout occurs, the {@link InterruptedException} will be thrown.
	 * <p>
	 * Callbacks set using {@link #done(SuccessCallback)} and {@link #fail(FailCallback)}
	 * will be ignored.
	 * <p>
	 * This method may not be called from the main (UI) thread.
	 *
	 * @throws RequestFailedException      thrown when the BLE request finished with status other
	 *                                     than {@link BluetoothGatt#GATT_SUCCESS}.
	 * @throws InterruptedException        thrown if the timeout occurred before the request has
	 *                                     finished.
	 * @throws IllegalStateException       thrown when you try to call this method from the main
	 *                                     (UI) thread.
	 * @throws DeviceDisconnectedException thrown when the device disconnected before the request
	 *                                     was completed.
	 * @throws BluetoothDisabledException  thrown when the Bluetooth adapter has been disabled.
	 * @throws InvalidRequestException     thrown when the request was called before the device was
	 *                                     connected at least once (unknown device).
	 * @see #enqueue()
	 */
	public final void await() throws RequestFailedException, DeviceDisconnectedException,
			BluetoothDisabledException, InvalidRequestException, InterruptedException {
		assertNotMainThread();

		final SuccessCallback sc = successCallback;
		final FailCallback fc = failCallback;
		try {
			syncLock.close();
			final RequestCallback callback = new RequestCallback();
			done(callback).fail(callback).invalid(callback).enqueue();

			if (!syncLock.block(timeout)) {
				throw new InterruptedException();
			}
			if (!callback.isSuccess()) {
				if (callback.status == FailCallback.REASON_DEVICE_DISCONNECTED) {
					throw new DeviceDisconnectedException();
				}
				if (callback.status == FailCallback.REASON_BLUETOOTH_DISABLED) {
					throw new BluetoothDisabledException();
				}
				if (callback.status == RequestCallback.REASON_REQUEST_INVALID) {
					throw new InvalidRequestException(this);
				}
				throw new RequestFailedException(this, callback.status);
			}
		} finally {
			successCallback = sc;
			failCallback = fc;
		}
	}

	/**
	 * Synchronously waits, for as most as the given number of milliseconds, until the request
	 * is ready.
	 * <p>
	 * When the timeout occurs, the {@link InterruptedException} will be thrown.
	 * <p>
	 * Callbacks set using {@link #done(SuccessCallback)} and {@link #fail(FailCallback)}
	 * will be ignored.
	 * <p>
	 * This method may not be called from the main (UI) thread.
	 *
	 * @param timeout optional timeout in milliseconds, 0 to disable timeout. This will
	 *                override the timeout set using {@link #timeout(long)}.
	 * @throws RequestFailedException      thrown when the BLE request finished with status other
	 *                                     than {@link BluetoothGatt#GATT_SUCCESS}.
	 * @throws InterruptedException        thrown if the timeout occurred before the request has
	 *                                     finished.
	 * @throws IllegalStateException       thrown when you try to call this method from the main
	 *                                     (UI) thread.
	 * @throws DeviceDisconnectedException thrown when the device disconnected before the request
	 *                                     was completed.
	 * @throws BluetoothDisabledException  thrown when the Bluetooth adapter has been disabled.
	 * @throws InvalidRequestException     thrown when the request was called before the device was
	 *                                     connected at least once (unknown device).
	 * @deprecated Use {@link #timeout(long)} and {@link #await()} instead.
	 */
	@Deprecated
	public final void await(@IntRange(from = 0) final long timeout) throws RequestFailedException,
			InterruptedException, DeviceDisconnectedException, BluetoothDisabledException,
			InvalidRequestException {
		timeout(timeout).await();
	}

	@Override
	void notifyStarted(@NonNull final BluetoothDevice device) {
		if (timeout > 0L) {
			timeoutCallback = () -> {
				timeoutCallback = null;
				if (!finished) {
					notifyFail(device, FailCallback.REASON_TIMEOUT);
					requestHandler.onRequestTimeout(this);
				}
			};
			handler.postDelayed(timeoutCallback, timeout);
		}
		super.notifyStarted(device);
	}

	@Override
	boolean notifySuccess(@NonNull final BluetoothDevice device) {
		if (!finished) {
			handler.removeCallbacks(timeoutCallback);
			timeoutCallback = null;
		}
		return super.notifySuccess(device);
	}

	@Override
	void notifyFail(@NonNull final BluetoothDevice device, final int status) {
		if (!finished) {
			handler.removeCallbacks(timeoutCallback);
			timeoutCallback = null;
		}
		super.notifyFail(device, status);
	}

	@Override
	void notifyInvalidRequest() {
		if (!finished) {
			handler.removeCallbacks(timeoutCallback);
			timeoutCallback = null;
		}
		super.notifyInvalidRequest();
	}
}