/*
 * Copyright (C) 2014 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.telecom;

import android.net.Uri;
import android.os.Bundle;
import android.os.IBinder;
import android.os.IBinder.DeathRecipient;
import android.os.RemoteException;
import android.os.ResultReceiver;
import android.telecom.Logging.Session;

import com.android.internal.telecom.IConnectionService;
import com.android.internal.telecom.IConnectionServiceAdapter;
import com.android.internal.telecom.IVideoProvider;
import com.android.internal.telecom.RemoteServiceCallback;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;

/**
 * Remote connection service which other connection services can use to place calls on their behalf.
 *
 * @hide
 */
final class RemoteConnectionService {

    // Note: Casting null to avoid ambiguous constructor reference.
    private static final RemoteConnection NULL_CONNECTION =
            new RemoteConnection("NULL", null, (ConnectionRequest) null);

    private static final RemoteConference NULL_CONFERENCE =
            new RemoteConference("NULL", null);

    private final IConnectionServiceAdapter mServantDelegate = new IConnectionServiceAdapter() {
        @Override
        public void handleCreateConnectionComplete(
                String id,
                ConnectionRequest request,
                ParcelableConnection parcel,
                Session.Info info) {
            RemoteConnection connection =
                    findConnectionForAction(id, "handleCreateConnectionSuccessful");
            if (connection != NULL_CONNECTION && mPendingConnections.contains(connection)) {
                mPendingConnections.remove(connection);
                // Unconditionally initialize the connection ...
                connection.setConnectionCapabilities(parcel.getConnectionCapabilities());
                connection.setConnectionProperties(parcel.getConnectionProperties());
                if (parcel.getHandle() != null
                    || parcel.getState() != Connection.STATE_DISCONNECTED) {
                    connection.setAddress(parcel.getHandle(), parcel.getHandlePresentation());
                }
                if (parcel.getCallerDisplayName() != null
                    || parcel.getState() != Connection.STATE_DISCONNECTED) {
                    connection.setCallerDisplayName(
                            parcel.getCallerDisplayName(),
                            parcel.getCallerDisplayNamePresentation());
                }
                // Set state after handle so that the client can identify the connection.
                if (parcel.getState() == Connection.STATE_DISCONNECTED) {
                    connection.setDisconnected(parcel.getDisconnectCause());
                } else {
                    connection.setState(parcel.getState());
                }
                List<RemoteConnection> conferenceable = new ArrayList<>();
                for (String confId : parcel.getConferenceableConnectionIds()) {
                    if (mConnectionById.containsKey(confId)) {
                        conferenceable.add(mConnectionById.get(confId));
                    }
                }
                connection.setConferenceableConnections(conferenceable);
                connection.setVideoState(parcel.getVideoState());
                if (connection.getState() == Connection.STATE_DISCONNECTED) {
                    // ... then, if it was created in a disconnected state, that indicates
                    // failure on the providing end, so immediately mark it destroyed
                    connection.setDestroyed();
                }
                connection.setStatusHints(parcel.getStatusHints());
                connection.setIsVoipAudioMode(parcel.getIsVoipAudioMode());
                connection.setRingbackRequested(parcel.isRingbackRequested());
                connection.putExtras(parcel.getExtras());
            }
        }

        @Override
        public void handleCreateConferenceComplete(
                String id,
                ConnectionRequest request,
                ParcelableConference parcel,
                Session.Info info) {
        }

        @Override
        public void setActive(String callId, Session.Info sessionInfo) {
            if (mConnectionById.containsKey(callId)) {
                findConnectionForAction(callId, "setActive")
                        .setState(Connection.STATE_ACTIVE);
            } else {
                findConferenceForAction(callId, "setActive")
                        .setState(Connection.STATE_ACTIVE);
            }
        }

        @Override
        public void setRinging(String callId, Session.Info sessionInfo) {
            findConnectionForAction(callId, "setRinging")
                    .setState(Connection.STATE_RINGING);
        }

        @Override
        public void setDialing(String callId, Session.Info sessionInfo) {
            findConnectionForAction(callId, "setDialing")
                    .setState(Connection.STATE_DIALING);
        }

        @Override
        public void setPulling(String callId, Session.Info sessionInfo) {
            findConnectionForAction(callId, "setPulling")
                    .setState(Connection.STATE_PULLING_CALL);
        }

        @Override
        public void setDisconnected(String callId, DisconnectCause disconnectCause,
                Session.Info sessionInfo) {
            if (mConnectionById.containsKey(callId)) {
                findConnectionForAction(callId, "setDisconnected")
                        .setDisconnected(disconnectCause);
            } else {
                findConferenceForAction(callId, "setDisconnected")
                        .setDisconnected(disconnectCause);
            }
        }

        @Override
        public void setOnHold(String callId, Session.Info sessionInfo) {
            if (mConnectionById.containsKey(callId)) {
                findConnectionForAction(callId, "setOnHold")
                        .setState(Connection.STATE_HOLDING);
            } else {
                findConferenceForAction(callId, "setOnHold")
                        .setState(Connection.STATE_HOLDING);
            }
        }

        @Override
        public void setRingbackRequested(String callId, boolean ringing, Session.Info sessionInfo) {
            findConnectionForAction(callId, "setRingbackRequested")
                    .setRingbackRequested(ringing);
        }

        @Override
        public void setConnectionCapabilities(String callId, int connectionCapabilities,
                Session.Info sessionInfo) {
            if (mConnectionById.containsKey(callId)) {
                findConnectionForAction(callId, "setConnectionCapabilities")
                        .setConnectionCapabilities(connectionCapabilities);
            } else {
                findConferenceForAction(callId, "setConnectionCapabilities")
                        .setConnectionCapabilities(connectionCapabilities);
            }
        }

        @Override
        public void setConnectionProperties(String callId, int connectionProperties,
                Session.Info sessionInfo) {
            if (mConnectionById.containsKey(callId)) {
                findConnectionForAction(callId, "setConnectionProperties")
                        .setConnectionProperties(connectionProperties);
            } else {
                findConferenceForAction(callId, "setConnectionProperties")
                        .setConnectionProperties(connectionProperties);
            }
        }

        @Override
        public void setIsConferenced(String callId, String conferenceCallId,
                Session.Info sessionInfo) {
            // Note: callId should not be null; conferenceCallId may be null
            RemoteConnection connection =
                    findConnectionForAction(callId, "setIsConferenced");
            if (connection != NULL_CONNECTION) {
                if (conferenceCallId == null) {
                    // 'connection' is being split from its conference
                    if (connection.getConference() != null) {
                        connection.getConference().removeConnection(connection);
                    }
                } else {
                    RemoteConference conference =
                            findConferenceForAction(conferenceCallId, "setIsConferenced");
                    if (conference != NULL_CONFERENCE) {
                        conference.addConnection(connection);
                    }
                }
            }
        }

        @Override
        public void setConferenceMergeFailed(String callId, Session.Info sessionInfo) {
            // Nothing to do here.
            // The event has already been handled and there is no state to update
            // in the underlying connection or conference objects
        }

        @Override
        public void onPhoneAccountChanged(String callId, PhoneAccountHandle pHandle,
                Session.Info sessionInfo) {
        }

        @Override
        public void onConnectionServiceFocusReleased(Session.Info sessionInfo) {}

        @Override
        public void addConferenceCall(
                final String callId, ParcelableConference parcel, Session.Info sessionInfo) {
            RemoteConference conference = new RemoteConference(callId,
                    mOutgoingConnectionServiceRpc);

            for (String id : parcel.getConnectionIds()) {
                RemoteConnection c = mConnectionById.get(id);
                if (c != null) {
                    conference.addConnection(c);
                }
            }
            // We used to skip adding empty conferences; however in the world of IMS conference
            // calls we need to add them to the remote connection service because they will always
            // start with no participants.

            conference.setState(parcel.getState());
            conference.setConnectionCapabilities(parcel.getConnectionCapabilities());
            conference.setConnectionProperties(parcel.getConnectionProperties());
            conference.putExtras(parcel.getExtras());
            mConferenceById.put(callId, conference);

            // Stash the original connection ID as it exists in the source ConnectionService.
            // Telecom will use this to avoid adding duplicates later.
            // See comments on Connection.EXTRA_ORIGINAL_CONNECTION_ID for more information.
            Bundle newExtras = new Bundle();
            newExtras.putString(Connection.EXTRA_ORIGINAL_CONNECTION_ID, callId);
            // Track the fact this request was relayed through the remote connection service.
            newExtras.putParcelable(Connection.EXTRA_REMOTE_PHONE_ACCOUNT_HANDLE,
                    parcel.getPhoneAccount());
            conference.putExtras(newExtras);

            conference.registerCallback(new RemoteConference.Callback() {
                @Override
                public void onDestroyed(RemoteConference c) {
                    mConferenceById.remove(callId);
                    maybeDisconnectAdapter();
                }
            });

            mOurConnectionServiceImpl.addRemoteConference(conference);
        }

        @Override
        public void removeCall(String callId, Session.Info sessionInfo) {
            if (mConnectionById.containsKey(callId)) {
                findConnectionForAction(callId, "removeCall")
                        .setDestroyed();
            } else {
                findConferenceForAction(callId, "removeCall")
                        .setDestroyed();
            }
        }

        @Override
        public void onPostDialWait(String callId, String remaining, Session.Info sessionInfo) {
            findConnectionForAction(callId, "onPostDialWait")
                    .setPostDialWait(remaining);
        }

        @Override
        public void onPostDialChar(String callId, char nextChar, Session.Info sessionInfo) {
            findConnectionForAction(callId, "onPostDialChar")
                    .onPostDialChar(nextChar);
        }

        @Override
        public void queryRemoteConnectionServices(RemoteServiceCallback callback,
                String callingPackage, Session.Info sessionInfo) {
            // Not supported from remote connection service.
        }

        @Override
        public void setVideoProvider(String callId, IVideoProvider videoProvider,
                Session.Info sessionInfo) {

            String callingPackage = mOurConnectionServiceImpl.getApplicationContext()
                    .getOpPackageName();
            int targetSdkVersion = mOurConnectionServiceImpl.getApplicationInfo().targetSdkVersion;
            RemoteConnection.VideoProvider remoteVideoProvider = null;
            if (videoProvider != null) {
                remoteVideoProvider = new RemoteConnection.VideoProvider(videoProvider,
                        callingPackage, targetSdkVersion);
            }
            findConnectionForAction(callId, "setVideoProvider")
                    .setVideoProvider(remoteVideoProvider);
        }

        @Override
        public void setVideoState(String callId, int videoState, Session.Info sessionInfo) {
            findConnectionForAction(callId, "setVideoState")
                    .setVideoState(videoState);
        }

        @Override
        public void setIsVoipAudioMode(String callId, boolean isVoip, Session.Info sessionInfo) {
            findConnectionForAction(callId, "setIsVoipAudioMode")
                    .setIsVoipAudioMode(isVoip);
        }

        @Override
        public void setStatusHints(String callId, StatusHints statusHints,
                Session.Info sessionInfo) {
            findConnectionForAction(callId, "setStatusHints")
                    .setStatusHints(statusHints);
        }

        @Override
        public void setAddress(String callId, Uri address, int presentation,
                Session.Info sessionInfo) {
            findConnectionForAction(callId, "setAddress")
                    .setAddress(address, presentation);
        }

        @Override
        public void setCallerDisplayName(String callId, String callerDisplayName,
                int presentation, Session.Info sessionInfo) {
            findConnectionForAction(callId, "setCallerDisplayName")
                    .setCallerDisplayName(callerDisplayName, presentation);
        }

        @Override
        public IBinder asBinder() {
            throw new UnsupportedOperationException();
        }

        @Override
        public final void setConferenceableConnections(String callId,
                List<String> conferenceableConnectionIds, Session.Info sessionInfo) {
            List<RemoteConnection> conferenceable = new ArrayList<>();
            for (String id : conferenceableConnectionIds) {
                if (mConnectionById.containsKey(id)) {
                    conferenceable.add(mConnectionById.get(id));
                }
            }

            if (hasConnection(callId)) {
                findConnectionForAction(callId, "setConferenceableConnections")
                        .setConferenceableConnections(conferenceable);
            } else {
                findConferenceForAction(callId, "setConferenceableConnections")
                        .setConferenceableConnections(conferenceable);
            }
        }

        @Override
        public void addExistingConnection(String callId, ParcelableConnection connection,
                Session.Info sessionInfo) {
            Log.i(RemoteConnectionService.this, "addExistingConnection: callId=%s, conn=%s", callId,
                    connection);
            String callingPackage = mOurConnectionServiceImpl.getApplicationContext().
                    getOpPackageName();
            int callingTargetSdkVersion = mOurConnectionServiceImpl.getApplicationInfo()
                    .targetSdkVersion;
            RemoteConnection remoteConnection = new RemoteConnection(callId,
                    mOutgoingConnectionServiceRpc, connection, callingPackage,
                    callingTargetSdkVersion);
            // Track that it is via a remote connection.
            Bundle newExtras = new Bundle();
            newExtras.putParcelable(Connection.EXTRA_REMOTE_PHONE_ACCOUNT_HANDLE,
                    connection.getPhoneAccount());
            if (connection.getParentCallId() != null) {
                RemoteConference parentConf = mConferenceById.get(connection.getParentCallId());
                // If there is a parent being set, we need to stash the conference ID here.
                // Telephony can add an existing connection while specifying a parent conference.
                // There is no equivalent version of that operation as part of the remote connection
                // API, so we will stash the pre-defined parent's ID in the extras.  When the
                // connectionmanager copies over the extras from the remote connection to the
                // actual one, it'll get passed to Telecom so that it can make the association.
                if (parentConf != null) {
                    newExtras.putString(Connection.EXTRA_ADD_TO_CONFERENCE_ID, parentConf.getId());
                    Log.i(this, "addExistingConnection: stash parent of %s as %s",
                            connection.getParentCallId(), parentConf.getId());
                }
            }
            remoteConnection.putExtras(newExtras);
            mConnectionById.put(callId, remoteConnection);
            remoteConnection.registerCallback(new RemoteConnection.Callback() {
                @Override
                public void onDestroyed(RemoteConnection connection) {
                    mConnectionById.remove(callId);
                    maybeDisconnectAdapter();
                }
            });
            mOurConnectionServiceImpl.addRemoteExistingConnection(remoteConnection);
        }

        @Override
        public void putExtras(String callId, Bundle extras, Session.Info sessionInfo) {
            if (hasConnection(callId)) {
                findConnectionForAction(callId, "putExtras").putExtras(extras);
            } else {
                findConferenceForAction(callId, "putExtras").putExtras(extras);
            }
        }

        @Override
        public void removeExtras(String callId, List<String> keys, Session.Info sessionInfo) {
            if (hasConnection(callId)) {
                findConnectionForAction(callId, "removeExtra").removeExtras(keys);
            } else {
                findConferenceForAction(callId, "removeExtra").removeExtras(keys);
            }
        }

        @Override
        public void setAudioRoute(String callId, int audioRoute, String bluetoothAddress,
                Session.Info sessionInfo) {
            if (hasConnection(callId)) {
                // TODO(3pcalls): handle this for remote connections.
                // Likely we don't want to do anything since it doesn't make sense for self-managed
                // connections to go through a connection mgr.
            }
        }

        @Override
        public void onConnectionEvent(String callId, String event, Bundle extras,
                Session.Info sessionInfo) {
            if (mConnectionById.containsKey(callId)) {
                findConnectionForAction(callId, "onConnectionEvent").onConnectionEvent(event,
                        extras);
            }
        }

        @Override
        public void onRttInitiationSuccess(String callId, Session.Info sessionInfo)
                throws RemoteException {
            if (hasConnection(callId)) {
                findConnectionForAction(callId, "onRttInitiationSuccess")
                        .onRttInitiationSuccess();
            } else {
                Log.w(this, "onRttInitiationSuccess called on a remote conference");
            }
        }

        @Override
        public void onRttInitiationFailure(String callId, int reason, Session.Info sessionInfo)
                throws RemoteException {
            if (hasConnection(callId)) {
                findConnectionForAction(callId, "onRttInitiationFailure")
                        .onRttInitiationFailure(reason);
            } else {
                Log.w(this, "onRttInitiationFailure called on a remote conference");
            }
        }

        @Override
        public void onRttSessionRemotelyTerminated(String callId, Session.Info sessionInfo)
                throws RemoteException {
            if (hasConnection(callId)) {
                findConnectionForAction(callId, "onRttSessionRemotelyTerminated")
                        .onRttSessionRemotelyTerminated();
            } else {
                Log.w(this, "onRttSessionRemotelyTerminated called on a remote conference");
            }
        }

        @Override
        public void onRemoteRttRequest(String callId, Session.Info sessionInfo)
                throws RemoteException {
            if (hasConnection(callId)) {
                findConnectionForAction(callId, "onRemoteRttRequest")
                        .onRemoteRttRequest();
            } else {
                Log.w(this, "onRemoteRttRequest called on a remote conference");
            }
        }

        @Override
        public void resetConnectionTime(String callId, Session.Info sessionInfo) {
            // Do nothing
        }

        @Override
        public void setConferenceState(String callId, boolean isConference,
                Session.Info sessionInfo) {
            // Do nothing
        }

        @Override
        public void setCallDirection(String callId, int direction, Session.Info sessionInfo) {
            // Do nothing
        }

        @Override
        public void requestCallEndpointChange(String callId, CallEndpoint endpoint,
                ResultReceiver callback, Session.Info sessionInfo) {
            // Do nothing
        }

        @Override
        public void queryLocation(String callId, long timeoutMillis, String provider,
                ResultReceiver callback, Session.Info sessionInfo) {
            // Do nothing
        }
    };

    private final ConnectionServiceAdapterServant mServant =
            new ConnectionServiceAdapterServant(mServantDelegate);

    private final DeathRecipient mDeathRecipient = new DeathRecipient() {
        @Override
        public void binderDied() {
            for (RemoteConnection c : mConnectionById.values()) {
                c.setDestroyed();
            }
            for (RemoteConference c : mConferenceById.values()) {
                c.setDestroyed();
            }
            mConnectionById.clear();
            mConferenceById.clear();
            mPendingConnections.clear();
            mOutgoingConnectionServiceRpc.asBinder().unlinkToDeath(mDeathRecipient, 0);
        }
    };

    private final IConnectionService mOutgoingConnectionServiceRpc;
    private final ConnectionService mOurConnectionServiceImpl;
    private final Map<String, RemoteConnection> mConnectionById = new HashMap<>();
    private final Map<String, RemoteConference> mConferenceById = new HashMap<>();
    private final Set<RemoteConnection> mPendingConnections = new HashSet<>();

    RemoteConnectionService(
            IConnectionService outgoingConnectionServiceRpc,
            ConnectionService ourConnectionServiceImpl) throws RemoteException {
        mOutgoingConnectionServiceRpc = outgoingConnectionServiceRpc;
        mOutgoingConnectionServiceRpc.asBinder().linkToDeath(mDeathRecipient, 0);
        mOurConnectionServiceImpl = ourConnectionServiceImpl;
    }

    @Override
    public String toString() {
        return "[RemoteCS - " + mOutgoingConnectionServiceRpc.asBinder().toString() + "]";
    }

    final RemoteConnection createRemoteConnection(
            PhoneAccountHandle connectionManagerPhoneAccount,
            ConnectionRequest request,
            boolean isIncoming) {
        final String id = UUID.randomUUID().toString();
        Bundle extras = new Bundle();
        if (request.getExtras() != null) {
            extras.putAll(request.getExtras());
        }
        // We will set the package name for the originator of the remote request; this lets the
        // receiving ConnectionService know that the request originated from a remote connection
        // service so that it can provide tracking information for Telecom.
        extras.putString(Connection.EXTRA_REMOTE_CONNECTION_ORIGINATING_PACKAGE_NAME,
                mOurConnectionServiceImpl.getApplicationContext().getOpPackageName());

        final ConnectionRequest newRequest = new ConnectionRequest.Builder()
                .setAccountHandle(request.getAccountHandle())
                .setAddress(request.getAddress())
                .setExtras(extras)
                .setVideoState(request.getVideoState())
                .setRttPipeFromInCall(request.getRttPipeFromInCall())
                .setRttPipeToInCall(request.getRttPipeToInCall())
                .build();
        try {
            if (mConnectionById.isEmpty()) {
                mOutgoingConnectionServiceRpc.addConnectionServiceAdapter(mServant.getStub(),
                        null /*Session.Info*/);
            }
            RemoteConnection connection =
                    new RemoteConnection(id, mOutgoingConnectionServiceRpc, newRequest);
            mPendingConnections.add(connection);
            mConnectionById.put(id, connection);
            mOutgoingConnectionServiceRpc.createConnection(
                    connectionManagerPhoneAccount,
                    id,
                    newRequest,
                    isIncoming,
                    false /* isUnknownCall */,
                    null /*Session.info*/);
            connection.registerCallback(new RemoteConnection.Callback() {
                @Override
                public void onDestroyed(RemoteConnection connection) {
                    mConnectionById.remove(id);
                    maybeDisconnectAdapter();
                }
            });
            return connection;
        } catch (RemoteException e) {
            return RemoteConnection.failure(
                    new DisconnectCause(DisconnectCause.ERROR, e.toString()));
        }
    }

    RemoteConference createRemoteConference(
            PhoneAccountHandle connectionManagerPhoneAccount,
            ConnectionRequest request,
            boolean isIncoming) {
        final String id = UUID.randomUUID().toString();
        try {
            if (mConferenceById.isEmpty()) {
                mOutgoingConnectionServiceRpc.addConnectionServiceAdapter(mServant.getStub(),
                        null /*Session.Info*/);
            }
            RemoteConference conference = new RemoteConference(id, mOutgoingConnectionServiceRpc);
            mOutgoingConnectionServiceRpc.createConference(connectionManagerPhoneAccount,
                    id,
                    request,
                    isIncoming,
                    false /* isUnknownCall */,
                    null /*Session.info*/);
            conference.registerCallback(new RemoteConference.Callback() {
                @Override
                public void onDestroyed(RemoteConference conference) {
                    mConferenceById.remove(id);
                    maybeDisconnectAdapter();
                }
            });
            conference.putExtras(request.getExtras());
            return conference;
        } catch (RemoteException e) {
            return RemoteConference.failure(
                    new DisconnectCause(DisconnectCause.ERROR, e.toString()));
        }
    }

    private boolean hasConnection(String callId) {
        return mConnectionById.containsKey(callId);
    }

    private RemoteConnection findConnectionForAction(
            String callId, String action) {
        if (mConnectionById.containsKey(callId)) {
            return mConnectionById.get(callId);
        }
        Log.w(this, "%s - Cannot find Connection %s", action, callId);
        return NULL_CONNECTION;
    }

    private RemoteConference findConferenceForAction(
            String callId, String action) {
        if (mConferenceById.containsKey(callId)) {
            return mConferenceById.get(callId);
        }
        Log.w(this, "%s - Cannot find Conference %s", action, callId);
        return NULL_CONFERENCE;
    }

    private void maybeDisconnectAdapter() {
        if (mConnectionById.isEmpty() && mConferenceById.isEmpty()) {
            try {
                mOutgoingConnectionServiceRpc.removeConnectionServiceAdapter(mServant.getStub(),
                        null /*Session.info*/);
            } catch (RemoteException e) {
            }
        }
    }
}
