/*
 * Copyright 2022, 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 com.android.server.telecom;

import android.content.Context;
import android.bluetooth.BluetoothDevice;
import android.os.Bundle;
import android.os.ParcelUuid;
import android.os.ResultReceiver;
import android.telecom.CallAudioState;
import android.telecom.CallEndpoint;
import android.telecom.CallEndpointException;
import android.telecom.Log;

import com.android.internal.annotations.VisibleForTesting;
import com.android.server.telecom.flags.FeatureFlags;

import java.util.HashMap;
import java.util.Map;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;

/**
 * Provides to {@link CallsManager} the service that can request change of CallEndpoint to the
 * {@link CallAudioManager}. And notify change of CallEndpoint status to {@link CallsManager}
 */
public class CallEndpointController extends CallsManagerListenerBase {
    public static final int CHANGE_TIMEOUT_SEC = 2;
    public static final int RESULT_REQUEST_SUCCESS = 0;
    public static final int RESULT_ENDPOINT_DOES_NOT_EXIST = 1;
    public static final int RESULT_REQUEST_TIME_OUT = 2;
    public static final int RESULT_ANOTHER_REQUEST = 3;
    public static final int RESULT_UNSPECIFIED_ERROR = 4;

    private final Context mContext;
    private final CallsManager mCallsManager;
    private final FeatureFlags mFeatureFlags;
    private final HashMap<Integer, Integer> mRouteToTypeMap;
    private final HashMap<Integer, Integer> mTypeToRouteMap;
    private final Map<ParcelUuid, String> mBluetoothAddressMap = new HashMap<>();
    private final Set<CallEndpoint> mAvailableCallEndpoints = new HashSet<>();
    private CallEndpoint mActiveCallEndpoint;
    private ParcelUuid mRequestedEndpointId;
    private CompletableFuture<Integer> mPendingChangeRequest;

    public CallEndpointController(Context context, CallsManager callsManager, FeatureFlags flags) {
        mContext = context;
        mCallsManager = callsManager;
        mFeatureFlags = flags;
        mRouteToTypeMap = new HashMap<>(5);
        mRouteToTypeMap.put(CallAudioState.ROUTE_EARPIECE, CallEndpoint.TYPE_EARPIECE);
        mRouteToTypeMap.put(CallAudioState.ROUTE_BLUETOOTH, CallEndpoint.TYPE_BLUETOOTH);
        mRouteToTypeMap.put(CallAudioState.ROUTE_WIRED_HEADSET, CallEndpoint.TYPE_WIRED_HEADSET);
        mRouteToTypeMap.put(CallAudioState.ROUTE_SPEAKER, CallEndpoint.TYPE_SPEAKER);
        mRouteToTypeMap.put(CallAudioState.ROUTE_STREAMING, CallEndpoint.TYPE_STREAMING);

        mTypeToRouteMap = new HashMap<>(5);
        mTypeToRouteMap.put(CallEndpoint.TYPE_EARPIECE, CallAudioState.ROUTE_EARPIECE);
        mTypeToRouteMap.put(CallEndpoint.TYPE_BLUETOOTH, CallAudioState.ROUTE_BLUETOOTH);
        mTypeToRouteMap.put(CallEndpoint.TYPE_WIRED_HEADSET, CallAudioState.ROUTE_WIRED_HEADSET);
        mTypeToRouteMap.put(CallEndpoint.TYPE_SPEAKER, CallAudioState.ROUTE_SPEAKER);
        mTypeToRouteMap.put(CallEndpoint.TYPE_STREAMING, CallAudioState.ROUTE_STREAMING);
    }

    @VisibleForTesting
    public CallEndpoint getCurrentCallEndpoint() {
        return mActiveCallEndpoint;
    }

    @VisibleForTesting
    public Set<CallEndpoint> getAvailableEndpoints() {
        return mAvailableCallEndpoints;
    }

    public void requestCallEndpointChange(CallEndpoint endpoint, ResultReceiver callback) {
        Log.i(this, "requestCallEndpointChange %s", endpoint);
        int route = mTypeToRouteMap.get(endpoint.getEndpointType());
        String bluetoothAddress = getBluetoothAddress(endpoint);

        if (findMatchingTypeEndpoint(endpoint.getEndpointType()) == null ||
                (route == CallAudioState.ROUTE_BLUETOOTH && bluetoothAddress == null)) {
            callback.send(CallEndpoint.ENDPOINT_OPERATION_FAILED,
                    getErrorResult(RESULT_ENDPOINT_DOES_NOT_EXIST));
            return;
        }

        if (isCurrentEndpointRequestedEndpoint(route, bluetoothAddress)) {
            callback.send(CallEndpoint.ENDPOINT_OPERATION_SUCCESS, new Bundle());
            return;
        }

        if (mPendingChangeRequest != null && !mPendingChangeRequest.isDone()) {
            mPendingChangeRequest.complete(RESULT_ANOTHER_REQUEST);
            mPendingChangeRequest = null;
            mRequestedEndpointId = null;
        }

        mPendingChangeRequest = new CompletableFuture<Integer>()
                .completeOnTimeout(RESULT_REQUEST_TIME_OUT, CHANGE_TIMEOUT_SEC, TimeUnit.SECONDS);

        mPendingChangeRequest.thenAcceptAsync((result) -> {
            if (result == RESULT_REQUEST_SUCCESS) {
                callback.send(CallEndpoint.ENDPOINT_OPERATION_SUCCESS, new Bundle());
            } else {
                callback.send(CallEndpoint.ENDPOINT_OPERATION_FAILED, getErrorResult(result));
            }
        });
        mRequestedEndpointId = endpoint.getIdentifier();
        mCallsManager.getCallAudioManager().setAudioRoute(route, bluetoothAddress);
    }

    public boolean isCurrentEndpointRequestedEndpoint(int requestedRoute, String requestedAddress) {
        if (mCallsManager.getCallAudioManager() == null
                || mCallsManager.getCallAudioManager().getCallAudioState() == null) {
            return false;
        }
        CallAudioState currentAudioState = mCallsManager.getCallAudioManager().getCallAudioState();
        if (requestedRoute == currentAudioState.getRoute()) {
            if (requestedRoute != CallAudioState.ROUTE_BLUETOOTH) {
                // The audio route (earpiece, speaker, etc.) is already active
                // and Telecom can ignore the spam request!
                Log.i(this, "iCERE: user requested a non-BT route that is already active");
                return true;
            } else if (hasSameBluetoothAddress(currentAudioState, requestedAddress)) {
                // if the requested (BT route, device) is active, ignore the request...
                Log.i(this, "iCERE: user requested a BT endpoint that is already active");
                return true;
            }
        }
        return false;
    }

    public boolean hasSameBluetoothAddress(CallAudioState audioState, String requestedAddress) {
        boolean hasActiveBtDevice = audioState.getActiveBluetoothDevice() != null;
        return hasActiveBtDevice && requestedAddress.equals(
                audioState.getActiveBluetoothDevice().getAddress());
    }

    private Bundle getErrorResult(int result) {
        String message;
        int resultCode;
        switch (result) {
            case RESULT_ENDPOINT_DOES_NOT_EXIST:
                message = "Requested CallEndpoint does not exist";
                resultCode = CallEndpointException.ERROR_ENDPOINT_DOES_NOT_EXIST;
                break;
            case RESULT_REQUEST_TIME_OUT:
                message = "The operation was not completed on time";
                resultCode = CallEndpointException.ERROR_REQUEST_TIME_OUT;
                break;
            case RESULT_ANOTHER_REQUEST:
                message = "The operation was canceled by another request";
                resultCode = CallEndpointException.ERROR_ANOTHER_REQUEST;
                break;
            default:
                message = "The operation has failed due to an unknown or unspecified error";
                resultCode = CallEndpointException.ERROR_UNSPECIFIED;
        }
        CallEndpointException exception = new CallEndpointException(message, resultCode);
        Bundle extras = new Bundle();
        extras.putParcelable(CallEndpointException.CHANGE_ERROR, exception);
        return extras;
    }

    @VisibleForTesting
    public String getBluetoothAddress(CallEndpoint endpoint) {
        return mBluetoothAddressMap.get(endpoint.getIdentifier());
    }

    private void notifyCallEndpointChange() {
        if (mActiveCallEndpoint == null) {
            Log.i(this, "notifyCallEndpointChange, invalid CallEndpoint");
            return;
        }

        if (mRequestedEndpointId != null && mPendingChangeRequest != null &&
                mRequestedEndpointId.equals(mActiveCallEndpoint.getIdentifier())) {
            mPendingChangeRequest.complete(RESULT_REQUEST_SUCCESS);
            mPendingChangeRequest = null;
            mRequestedEndpointId = null;
        }
        mCallsManager.updateCallEndpoint(mActiveCallEndpoint);

        Set<Call> calls = mCallsManager.getTrackedCalls();
        for (Call call : calls) {
            if (mFeatureFlags.cacheCallAudioCallbacks()) {
                onCallEndpointChangedOrCache(call);
            } else {
                if (call != null && call.getConnectionService() != null) {
                    call.getConnectionService().onCallEndpointChanged(call, mActiveCallEndpoint);
                } else if (call != null && call.getTransactionServiceWrapper() != null) {
                    call.getTransactionServiceWrapper()
                            .onCallEndpointChanged(call, mActiveCallEndpoint);
                }
            }
        }
    }

    private void onCallEndpointChangedOrCache(Call call) {
        if (call == null) {
            return;
        }
        CallSourceService service = call.getService();
        if (service != null) {
            service.onCallEndpointChanged(call, mActiveCallEndpoint);
        } else {
            call.cacheServiceCallback(new CachedCurrentEndpointChange(mActiveCallEndpoint));
        }
    }

    private void notifyAvailableCallEndpointsChange() {
        mCallsManager.updateAvailableCallEndpoints(mAvailableCallEndpoints);

        Set<Call> calls = mCallsManager.getTrackedCalls();
        for (Call call : calls) {
            if (mFeatureFlags.cacheCallAudioCallbacks()) {
                onAvailableEndpointsChangedOrCache(call);
            } else {
                if (call != null && call.getConnectionService() != null) {
                    call.getConnectionService().onAvailableCallEndpointsChanged(call,
                            mAvailableCallEndpoints);
                } else if (call != null && call.getTransactionServiceWrapper() != null) {
                    call.getTransactionServiceWrapper().onAvailableCallEndpointsChanged(call,
                            mAvailableCallEndpoints);
                }
            }
        }
    }

    private void onAvailableEndpointsChangedOrCache(Call call) {
        if (call == null) {
            return;
        }
        CallSourceService service = call.getService();
        if (service != null) {
            service.onAvailableCallEndpointsChanged(call, mAvailableCallEndpoints);
        } else {
            call.cacheServiceCallback(new CachedAvailableEndpointsChange(mAvailableCallEndpoints));
        }
    }

    private void notifyMuteStateChange(boolean isMuted) {
        mCallsManager.updateMuteState(isMuted);

        Set<Call> calls = mCallsManager.getTrackedCalls();
        for (Call call : calls) {
            if (mFeatureFlags.cacheCallAudioCallbacks()) {
                onMuteStateChangedOrCache(call, isMuted);
            } else {
                if (call != null && call.getConnectionService() != null) {
                    call.getConnectionService().onMuteStateChanged(call, isMuted);
                } else if (call != null && call.getTransactionServiceWrapper() != null) {
                    call.getTransactionServiceWrapper().onMuteStateChanged(call, isMuted);
                }
            }
        }
    }

    private void onMuteStateChangedOrCache(Call call, boolean isMuted){
        if (call == null) {
            return;
        }
        CallSourceService service = call.getService();
        if (service != null) {
            service.onMuteStateChanged(call, isMuted);
        } else {
            call.cacheServiceCallback(new CachedMuteStateChange(isMuted));
        }
    }

    private void createAvailableCallEndpoints(CallAudioState state) {
        Set<CallEndpoint> newAvailableEndpoints = new HashSet<>();
        Map<ParcelUuid, String> newBluetoothDevices = new HashMap<>();

        mRouteToTypeMap.forEach((route, type) -> {
            if ((state.getSupportedRouteMask() & route) != 0) {
                if (type == CallEndpoint.TYPE_STREAMING) {
                    if (state.getRoute() == CallAudioState.ROUTE_STREAMING) {
                        if (mActiveCallEndpoint == null
                                || mActiveCallEndpoint.getEndpointType() != type) {
                            mActiveCallEndpoint = new CallEndpoint(getEndpointName(type) != null
                                    ? getEndpointName(type) : "", type);
                        }
                    }
                } else if (type == CallEndpoint.TYPE_BLUETOOTH) {
                    for (BluetoothDevice device : state.getSupportedBluetoothDevices()) {
                        CallEndpoint endpoint = findMatchingBluetoothEndpoint(device);
                        if (endpoint == null) {
                            String deviceName = device.getName();
                            endpoint = new CallEndpoint(
                                    deviceName != null ? deviceName : "",
                                    CallEndpoint.TYPE_BLUETOOTH);
                        }
                        newAvailableEndpoints.add(endpoint);
                        newBluetoothDevices.put(endpoint.getIdentifier(), device.getAddress());

                        BluetoothDevice activeDevice = state.getActiveBluetoothDevice();
                        if (state.getRoute() == route && device.equals(activeDevice)) {
                            mActiveCallEndpoint = endpoint;
                        }
                    }
                } else {
                    CallEndpoint endpoint = findMatchingTypeEndpoint(type);
                    if (endpoint == null) {
                        endpoint = new CallEndpoint(
                                getEndpointName(type) != null ? getEndpointName(type) : "", type);
                    }
                    newAvailableEndpoints.add(endpoint);
                    if (state.getRoute() == route) {
                        mActiveCallEndpoint = endpoint;
                    }
                }
            }
        });
        mAvailableCallEndpoints.clear();
        mAvailableCallEndpoints.addAll(newAvailableEndpoints);
        mBluetoothAddressMap.clear();
        mBluetoothAddressMap.putAll(newBluetoothDevices);
    }

    private CallEndpoint findMatchingTypeEndpoint(int targetType) {
        for (CallEndpoint endpoint : mAvailableCallEndpoints) {
            if (endpoint.getEndpointType() == targetType) {
                return endpoint;
            }
        }
        return null;
    }

    private CallEndpoint findMatchingBluetoothEndpoint(BluetoothDevice device) {
        final String targetAddress = device.getAddress();
        if (targetAddress != null) {
            for (CallEndpoint endpoint : mAvailableCallEndpoints) {
                final String address = mBluetoothAddressMap.get(endpoint.getIdentifier());
                if (targetAddress.equals(address)) {
                    return endpoint;
                }
            }
        }
        return null;
    }

    private boolean isAvailableEndpointChanged(CallAudioState oldState, CallAudioState newState) {
        if (oldState == null) {
            return true;
        }
        if ((oldState.getSupportedRouteMask() ^ newState.getSupportedRouteMask()) != 0) {
            return true;
        }
        if (oldState.getSupportedBluetoothDevices().size() !=
                newState.getSupportedBluetoothDevices().size()) {
            return true;
        }
        for (BluetoothDevice device : newState.getSupportedBluetoothDevices()) {
            if (!oldState.getSupportedBluetoothDevices().contains(device)) {
                return true;
            }
        }
        return false;
    }

    private boolean isEndpointChanged(CallAudioState oldState, CallAudioState newState) {
        if (oldState == null) {
            return true;
        }
        if (oldState.getRoute() != newState.getRoute()) {
            return true;
        }
        if (newState.getRoute() == CallAudioState.ROUTE_BLUETOOTH) {
            if (oldState.getActiveBluetoothDevice() == null) {
                if (newState.getActiveBluetoothDevice() == null) {
                    return false;
                }
                return true;
            }
            return !oldState.getActiveBluetoothDevice().equals(newState.getActiveBluetoothDevice());
        }
        return false;
    }

    private boolean isMuteStateChanged(CallAudioState oldState, CallAudioState newState) {
        if (oldState == null) {
            return true;
        }
        return oldState.isMuted() != newState.isMuted();
    }

    private CharSequence getEndpointName(int endpointType) {
        switch (endpointType) {
            case CallEndpoint.TYPE_EARPIECE:
                return mContext.getText(R.string.callendpoint_name_earpiece);
            case CallEndpoint.TYPE_BLUETOOTH:
                return mContext.getText(R.string.callendpoint_name_bluetooth);
            case CallEndpoint.TYPE_WIRED_HEADSET:
                return mContext.getText(R.string.callendpoint_name_wiredheadset);
            case CallEndpoint.TYPE_SPEAKER:
                return mContext.getText(R.string.callendpoint_name_speaker);
            case CallEndpoint.TYPE_STREAMING:
                return mContext.getText(R.string.callendpoint_name_streaming);
            default:
                return mContext.getText(R.string.callendpoint_name_unknown);
        }
    }

    @Override
    public void onCallAudioStateChanged(CallAudioState oldState, CallAudioState newState) {
        Log.i(this, "onCallAudioStateChanged, audioState: %s -> %s", oldState, newState);

        if (newState == null) {
            Log.i(this, "onCallAudioStateChanged, invalid audioState");
            return;
        }

        createAvailableCallEndpoints(newState);

        boolean isforce = true;
        if (isAvailableEndpointChanged(oldState, newState)) {
            notifyAvailableCallEndpointsChange();
            isforce = false;
        }

        if (isEndpointChanged(oldState, newState)) {
            notifyCallEndpointChange();
            isforce = false;
        }

        if (isMuteStateChanged(oldState, newState)) {
            notifyMuteStateChange(newState.isMuted());
            isforce = false;
        }

        if (isforce) {
            notifyAvailableCallEndpointsChange();
            notifyCallEndpointChange();
            notifyMuteStateChange(newState.isMuted());
        }
    }
}