/*
 * Copyright (C) 2017 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License"); you may not
 * use this file except in compliance with the License. You may obtain a copy of
 * the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 * License for the specific language governing permissions and limitations under
 * the License.
 */

package android.bluetooth.le;

import static android.bluetooth.le.BluetoothLeUtils.getSyncTimeout;

import android.annotation.Nullable;
import android.annotation.RequiresPermission;
import android.annotation.SuppressLint;
import android.bluetooth.Attributable;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.IBluetoothGatt;
import android.bluetooth.IBluetoothManager;
import android.bluetooth.annotations.RequiresBluetoothLocationPermission;
import android.bluetooth.annotations.RequiresBluetoothScanPermission;
import android.bluetooth.annotations.RequiresLegacyBluetoothAdminPermission;
import android.content.AttributionSource;
import android.os.Handler;
import android.os.Looper;
import android.os.RemoteException;
import android.util.Log;

import com.android.modules.utils.SynchronousResultReceiver;

import java.util.IdentityHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.TimeoutException;

/**
 * This class provides methods to perform periodic advertising related
 * operations. An application can register for periodic advertisements using
 * {@link PeriodicAdvertisingManager#registerSync}.
 * <p>
 * Use {@link BluetoothAdapter#getPeriodicAdvertisingManager()} to get an
 * instance of {@link PeriodicAdvertisingManager}.
 *
 * @hide
 */
public final class PeriodicAdvertisingManager {

    private static final String TAG = "PeriodicAdvertisingManager";

    private static final int SKIP_MIN = 0;
    private static final int SKIP_MAX = 499;
    private static final int TIMEOUT_MIN = 10;
    private static final int TIMEOUT_MAX = 16384;

    private static final int SYNC_STARTING = -1;

    private final BluetoothAdapter mBluetoothAdapter;
    private final IBluetoothManager mBluetoothManager;
    private final AttributionSource mAttributionSource;

    /* maps callback, to callback wrapper and sync handle */
    Map<PeriodicAdvertisingCallback,
            IPeriodicAdvertisingCallback /* callbackWrapper */> mCallbackWrappers;

    /**
     * Use {@link BluetoothAdapter#getBluetoothLeScanner()} instead.
     *
     * @param bluetoothManager BluetoothManager that conducts overall Bluetooth Management.
     * @hide
     */
    public PeriodicAdvertisingManager(BluetoothAdapter bluetoothAdapter) {
        mBluetoothAdapter = Objects.requireNonNull(bluetoothAdapter);
        mBluetoothManager = mBluetoothAdapter.getBluetoothManager();
        mAttributionSource = mBluetoothAdapter.getAttributionSource();
        mCallbackWrappers = new IdentityHashMap<>();
    }

    /**
     * Synchronize with periodic advertising pointed to by the {@code scanResult}.
     * The {@code scanResult} used must contain a valid advertisingSid. First
     * call to registerSync will use the {@code skip} and {@code timeout} provided.
     * Subsequent calls from other apps, trying to sync with same set will reuse
     * existing sync, thus {@code skip} and {@code timeout} values will not take
     * effect. The values in effect will be returned in
     * {@link PeriodicAdvertisingCallback#onSyncEstablished}.
     *
     * @param scanResult Scan result containing advertisingSid.
     * @param skip The number of periodic advertising packets that can be skipped after a successful
     * receive. Must be between 0 and 499.
     * @param timeout Synchronization timeout for the periodic advertising. One unit is 10ms. Must
     * be between 10 (100ms) and 16384 (163.84s).
     * @param callback Callback used to deliver all operations status.
     * @throws IllegalArgumentException if {@code scanResult} is null or {@code skip} is invalid or
     * {@code timeout} is invalid or {@code callback} is null.
     */
    @RequiresLegacyBluetoothAdminPermission
    @RequiresBluetoothScanPermission
    @RequiresBluetoothLocationPermission
    @RequiresPermission(android.Manifest.permission.BLUETOOTH_SCAN)
    public void registerSync(ScanResult scanResult, int skip, int timeout,
            PeriodicAdvertisingCallback callback) {
        registerSync(scanResult, skip, timeout, callback, null);
    }

    /**
     * Synchronize with periodic advertising pointed to by the {@code scanResult}.
     * The {@code scanResult} used must contain a valid advertisingSid. First
     * call to registerSync will use the {@code skip} and {@code timeout} provided.
     * Subsequent calls from other apps, trying to sync with same set will reuse
     * existing sync, thus {@code skip} and {@code timeout} values will not take
     * effect. The values in effect will be returned in
     * {@link PeriodicAdvertisingCallback#onSyncEstablished}.
     *
     * @param scanResult Scan result containing advertisingSid.
     * @param skip The number of periodic advertising packets that can be skipped after a successful
     * receive. Must be between 0 and 499.
     * @param timeout Synchronization timeout for the periodic advertising. One unit is 10ms. Must
     * be between 10 (100ms) and 16384 (163.84s).
     * @param callback Callback used to deliver all operations status.
     * @param handler thread upon which the callbacks will be invoked.
     * @throws IllegalArgumentException if {@code scanResult} is null or {@code skip} is invalid or
     * {@code timeout} is invalid or {@code callback} is null.
     */
    @RequiresLegacyBluetoothAdminPermission
    @RequiresBluetoothScanPermission
    @RequiresBluetoothLocationPermission
    @RequiresPermission(android.Manifest.permission.BLUETOOTH_SCAN)
    public void registerSync(ScanResult scanResult, int skip, int timeout,
            PeriodicAdvertisingCallback callback, Handler handler) {
        if (callback == null) {
            throw new IllegalArgumentException("callback can't be null");
        }

        if (scanResult == null) {
            throw new IllegalArgumentException("scanResult can't be null");
        }

        if (scanResult.getAdvertisingSid() == ScanResult.SID_NOT_PRESENT) {
            throw new IllegalArgumentException("scanResult must contain a valid sid");
        }

        if (skip < SKIP_MIN || skip > SKIP_MAX) {
            throw new IllegalArgumentException(
                    "timeout must be between " + TIMEOUT_MIN + " and " + TIMEOUT_MAX);
        }

        if (timeout < TIMEOUT_MIN || timeout > TIMEOUT_MAX) {
            throw new IllegalArgumentException(
                    "timeout must be between " + TIMEOUT_MIN + " and " + TIMEOUT_MAX);
        }

        IBluetoothGatt gatt;
        try {
            gatt = mBluetoothManager.getBluetoothGatt();
        } catch (RemoteException e) {
            Log.e(TAG, "Failed to get Bluetooth gatt - ", e);
            callback.onSyncEstablished(0, scanResult.getDevice(), scanResult.getAdvertisingSid(),
                    skip, timeout,
                    PeriodicAdvertisingCallback.SYNC_NO_RESOURCES);
            return;
        }

        if (handler == null) {
            handler = new Handler(Looper.getMainLooper());
        }

        IPeriodicAdvertisingCallback wrapped = wrap(callback, handler);
        mCallbackWrappers.put(callback, wrapped);

        try {
            final SynchronousResultReceiver recv = SynchronousResultReceiver.get();
            gatt.registerSync(scanResult, skip, timeout, wrapped, mAttributionSource, recv);
            recv.awaitResultNoInterrupt(getSyncTimeout()).getValue(null);
        } catch (TimeoutException | RemoteException e) {
            Log.e(TAG, "Failed to register sync - ", e);
            return;
        }
    }

    /**
     * Cancel pending attempt to create sync, or terminate existing sync.
     *
     * @param callback Callback used to deliver all operations status.
     * @throws IllegalArgumentException if {@code callback} is null, or not a properly registered
     * callback.
     */
    @RequiresLegacyBluetoothAdminPermission
    @RequiresBluetoothScanPermission
    @RequiresPermission(android.Manifest.permission.BLUETOOTH_SCAN)
    public void unregisterSync(PeriodicAdvertisingCallback callback) {
        if (callback == null) {
            throw new IllegalArgumentException("callback can't be null");
        }

        IBluetoothGatt gatt;
        try {
            gatt = mBluetoothManager.getBluetoothGatt();
        } catch (RemoteException e) {
            Log.e(TAG, "Failed to get Bluetooth gatt - ", e);
            return;
        }

        IPeriodicAdvertisingCallback wrapper = mCallbackWrappers.remove(callback);
        if (wrapper == null) {
            throw new IllegalArgumentException("callback was not properly registered");
        }

        try {
            final SynchronousResultReceiver recv = SynchronousResultReceiver.get();
            gatt.unregisterSync(wrapper, mAttributionSource, recv);
            recv.awaitResultNoInterrupt(getSyncTimeout()).getValue(null);
        } catch (TimeoutException | RemoteException e) {
            Log.e(TAG, "Failed to cancel sync creation - ", e);
            return;
        }
    }

    /**
     * Transfer periodic sync
     *
     * @hide
     */
    public void transferSync(BluetoothDevice bda, int serviceData, int syncHandle) {
        IBluetoothGatt gatt;
        try {
            gatt = mBluetoothManager.getBluetoothGatt();
        } catch (RemoteException e) {
            Log.e(TAG, "Failed to get Bluetooth gatt - ", e);
            PeriodicAdvertisingCallback callback = null;
            for (PeriodicAdvertisingCallback cb : mCallbackWrappers.keySet()) {
                callback = cb;
            }
            if (callback != null) {
                callback.onSyncTransferred(bda,
                        PeriodicAdvertisingCallback.SYNC_NO_RESOURCES);
            }
            return;
        }
        try {
            final SynchronousResultReceiver recv = SynchronousResultReceiver.get();
            gatt.transferSync(bda, serviceData , syncHandle, mAttributionSource, recv);
            recv.awaitResultNoInterrupt(getSyncTimeout()).getValue(null);
        } catch (TimeoutException | RemoteException e) {
            Log.e(TAG, "Failed to register sync - ", e);
            return;
        }
    }

    /**
     * Transfer set info
     *
     * @hide
     */
    public void transferSetInfo(BluetoothDevice bda, int serviceData,
                                int advHandle, PeriodicAdvertisingCallback callback) {
        transferSetInfo(bda, serviceData, advHandle, callback, null);
    }

    /**
     * Transfer set info
     *
     * @hide
     */
    public void transferSetInfo(BluetoothDevice bda, int serviceData,
                                int advHandle, PeriodicAdvertisingCallback callback,
                                @Nullable Handler handler) {
        if (callback == null) {
            throw new IllegalArgumentException("callback can't be null");
        }
        IBluetoothGatt gatt;
        try {
            gatt = mBluetoothManager.getBluetoothGatt();
        } catch (RemoteException e) {
            Log.e(TAG, "Failed to get Bluetooth gatt - ", e);
            return;
        }
        if (handler == null) {
            handler = new Handler(Looper.getMainLooper());
        }
        IPeriodicAdvertisingCallback wrapper = wrap(callback, handler);
        if (wrapper == null) {
            throw new IllegalArgumentException("callback was not properly registered");
        }
        try {
            final SynchronousResultReceiver recv = SynchronousResultReceiver.get();
            gatt.transferSetInfo(bda, serviceData , advHandle, wrapper, mAttributionSource, recv);
            recv.awaitResultNoInterrupt(getSyncTimeout()).getValue(null);
        } catch (RemoteException | TimeoutException e) {
            Log.e(TAG, "Failed to register sync - ", e);
            return;
        }

    }

    @SuppressLint("AndroidFrameworkBluetoothPermission")
    private IPeriodicAdvertisingCallback wrap(PeriodicAdvertisingCallback callback,
            Handler handler) {
        return new IPeriodicAdvertisingCallback.Stub() {
            public void onSyncEstablished(int syncHandle, BluetoothDevice device,
                    int advertisingSid, int skip, int timeout, int status) {
                Attributable.setAttributionSource(device, mAttributionSource);
                handler.post(new Runnable() {
                    @Override
                    public void run() {
                        callback.onSyncEstablished(syncHandle, device, advertisingSid, skip,
                                timeout,
                                status);

                        if (status != PeriodicAdvertisingCallback.SYNC_SUCCESS) {
                            // App can still unregister the sync until notified it failed. Remove
                            // callback
                            // after app was notifed.
                            mCallbackWrappers.remove(callback);
                        }
                    }
                });
            }

            public void onPeriodicAdvertisingReport(PeriodicAdvertisingReport report) {
                handler.post(new Runnable() {
                    @Override
                    public void run() {
                        callback.onPeriodicAdvertisingReport(report);
                    }
                });
            }

            public void onSyncLost(int syncHandle) {
                handler.post(new Runnable() {
                    @Override
                    public void run() {
                        callback.onSyncLost(syncHandle);
                        // App can still unregister the sync until notified it's lost.
                        // Remove callback after app was notifed.
                        mCallbackWrappers.remove(callback);
                    }
                });
            }

            public void onSyncTransferred(BluetoothDevice device, int status) {
                handler.post(new Runnable() {
                    @Override
                    public void run() {
                        callback.onSyncTransferred(device, status);
                    }
                });
            }

            public void onBigInfoAdvertisingReport(int syncHandle, boolean encrypted) {
                handler.post(new Runnable() {
                    @Override
                    public void run() {
                        callback.onBigInfoAdvertisingReport(syncHandle, encrypted);
                    }
                });
            }
        };
    }
}
