package eu.siacs.conversations.services;

import android.Manifest;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.IBinder;
import android.preference.PreferenceManager;
import android.telecom.Connection;
import android.telecom.ConnectionRequest;
import android.telecom.ConnectionService;
import android.telecom.DisconnectCause;
import android.telecom.PhoneAccount;
import android.telecom.PhoneAccountHandle;
import android.telecom.TelecomManager;
import android.telecom.VideoProfile;
import android.util.Log;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat;
import com.google.common.collect.ImmutableSet;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.SettableFuture;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.R;
import eu.siacs.conversations.entities.Account;
import eu.siacs.conversations.entities.Contact;
import eu.siacs.conversations.ui.RtpSessionActivity;
import eu.siacs.conversations.xmpp.Jid;
import eu.siacs.conversations.xmpp.jingle.AbstractJingleConnection;
import eu.siacs.conversations.xmpp.jingle.JingleConnectionManager;
import eu.siacs.conversations.xmpp.jingle.JingleRtpConnection;
import eu.siacs.conversations.xmpp.jingle.Media;
import eu.siacs.conversations.xmpp.jingle.RtpEndUserState;
import eu.siacs.conversations.xmpp.jingle.stanzas.Reason;
import java.util.Arrays;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

public class CallIntegrationConnectionService extends ConnectionService {

    private static final String EXTRA_ADDRESS = "eu.siacs.conversations.address";
    public static final String EXTRA_SESSION_ID = "eu.siacs.conversations.sid";

    private static final ExecutorService ACCOUNT_REGISTRATION_EXECUTOR =
            Executors.newSingleThreadExecutor();

    private ListenableFuture<ServiceConnectionService> serviceFuture;

    @Override
    public void onCreate() {
        Log.d(Config.LOGTAG, "CallIntegrationService.onCreate()");
        super.onCreate();
        this.serviceFuture = ServiceConnectionService.bindService(this);
    }

    @Override
    public void onDestroy() {
        Log.d(Config.LOGTAG, "destroying CallIntegrationConnectionService");
        super.onDestroy();
        final ServiceConnection serviceConnection;
        try {
            serviceConnection = serviceFuture.get().serviceConnection;
        } catch (final Exception e) {
            Log.d(Config.LOGTAG, "could not fetch service connection", e);
            return;
        }
        this.unbindService(serviceConnection);
    }

    private static Connection createOutgoingRtpConnection(
            final XmppConnectionService service,
            final String phoneAccountHandle,
            final Jid with,
            final Set<Media> media) {
        if (service == null) {
            Log.d(
                    Config.LOGTAG,
                    "CallIntegrationConnection service was unable to bind to"
                            + " XmppConnectionService");
            return Connection.createFailedConnection(
                    new DisconnectCause(DisconnectCause.ERROR, "service connection not found"));
        }
        final var account = service.findAccountByUuid(phoneAccountHandle);
        return createOutgoingRtpConnection(service, account, with, media);
    }

    private static Connection createOutgoingRtpConnection(
            @NonNull final XmppConnectionService service,
            @NonNull final Account account,
            final Jid with,
            final Set<Media> media) {
        Log.d(Config.LOGTAG, "create outgoing rtp connection!");
        final Intent intent = new Intent(service, RtpSessionActivity.class);
        intent.setAction(Intent.ACTION_VIEW);
        intent.putExtra(RtpSessionActivity.EXTRA_ACCOUNT, account.getJid().toString());
        intent.putExtra(RtpSessionActivity.EXTRA_WITH, with.toString());
        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK);
        final Connection callIntegration;
        if (with.isBareJid()) {
            final var contact = account.getRoster().getContact(with);
            if (Config.JINGLE_MESSAGE_INIT_STRICT_OFFLINE_CHECK
                    && contact.getPresences().isEmpty()) {
                intent.putExtra(
                        RtpSessionActivity.EXTRA_LAST_REPORTED_STATE,
                        RtpEndUserState.CONTACT_OFFLINE.toString());
                callIntegration =
                        Connection.createFailedConnection(
                                new DisconnectCause(DisconnectCause.ERROR, "contact is offline"));
                // we can use a JMI 'finish' message to notify the contact of a call we never
                // actually attempted
                // sendJingleFinishMessage(service, contact, Reason.CONNECTIVITY_ERROR);
            } else {
                final JingleConnectionManager.RtpSessionProposal proposal;
                try {
                    proposal =
                            service.getJingleConnectionManager()
                                    .proposeJingleRtpSession(account, with, media);
                } catch (final IllegalStateException e) {
                    return Connection.createFailedConnection(
                            new DisconnectCause(
                                    DisconnectCause.ERROR,
                                    "Phone is busy. Probably race condition. Try again in a"
                                            + " moment"));
                }
                if (proposal == null) {
                    // TODO instead of just null checking try to get the sessionID
                    return Connection.createFailedConnection(
                            new DisconnectCause(
                                    DisconnectCause.ERROR, "a call is already in progress"));
                }
                intent.putExtra(
                        RtpSessionActivity.EXTRA_LAST_REPORTED_STATE,
                        RtpEndUserState.FINDING_DEVICE.toString());
                intent.putExtra(RtpSessionActivity.EXTRA_PROPOSED_SESSION_ID, proposal.sessionId);
                callIntegration = proposal.getCallIntegration();
            }
            if (Media.audioOnly(media)) {
                intent.putExtra(
                        RtpSessionActivity.EXTRA_LAST_ACTION,
                        RtpSessionActivity.ACTION_MAKE_VOICE_CALL);
            } else {
                intent.putExtra(
                        RtpSessionActivity.EXTRA_LAST_ACTION,
                        RtpSessionActivity.ACTION_MAKE_VIDEO_CALL);
            }
        } else {
            final JingleRtpConnection jingleRtpConnection =
                    service.getJingleConnectionManager().initializeRtpSession(account, with, media);
            final String sessionId = jingleRtpConnection.getId().sessionId;
            intent.putExtra(RtpSessionActivity.EXTRA_SESSION_ID, sessionId);
            callIntegration = jingleRtpConnection.getCallIntegration();
        }
        service.startActivity(intent);
        return callIntegration;
    }

    private static void sendJingleFinishMessage(
            final XmppConnectionService service, final Contact contact, final Reason reason) {
        service.getJingleConnectionManager()
                .sendJingleMessageFinish(contact, UUID.randomUUID().toString(), reason);
    }

    @Override
    public Connection onCreateOutgoingConnection(
            final PhoneAccountHandle phoneAccountHandle, final ConnectionRequest request) {
        Log.d(Config.LOGTAG, "onCreateOutgoingConnection(" + request.getAddress() + ")");
        final var uri = request.getAddress();
        final var extras = request.getExtras();
        if (uri == null || !Arrays.asList("xmpp", "tel").contains(uri.getScheme())) {
            return Connection.createFailedConnection(
                    new DisconnectCause(DisconnectCause.ERROR, "invalid address"));
        }
        final Jid jid;
        if ("tel".equals(uri.getScheme())) {
            jid = Jid.of(extras.getString(EXTRA_ADDRESS));
        } else {
            jid = Jid.of(uri.getSchemeSpecificPart());
        }
        final int videoState = extras.getInt(TelecomManager.EXTRA_START_CALL_WITH_VIDEO_STATE);
        final Set<Media> media =
                videoState == VideoProfile.STATE_AUDIO_ONLY
                        ? ImmutableSet.of(Media.AUDIO)
                        : ImmutableSet.of(Media.AUDIO, Media.VIDEO);
        Log.d(Config.LOGTAG, "jid=" + jid);
        Log.d(Config.LOGTAG, "phoneAccountHandle:" + phoneAccountHandle.getId());
        Log.d(Config.LOGTAG, "media " + media);
        final var service = ServiceConnectionService.get(this.serviceFuture);
        return createOutgoingRtpConnection(service, phoneAccountHandle.getId(), jid, media);
    }

    @Override
    public Connection onCreateIncomingConnection(
            final PhoneAccountHandle phoneAccountHandle, final ConnectionRequest request) {
        Log.d(Config.LOGTAG, "onCreateIncomingConnection()");
        final var service = ServiceConnectionService.get(this.serviceFuture);
        final Bundle extras = request.getExtras();
        final Bundle extraExtras = extras.getBundle(TelecomManager.EXTRA_INCOMING_CALL_EXTRAS);
        final String incomingCallAddress =
                extras.getString(TelecomManager.EXTRA_INCOMING_CALL_ADDRESS);
        final String sid = extraExtras == null ? null : extraExtras.getString(EXTRA_SESSION_ID);
        Log.d(Config.LOGTAG, "sid " + sid);
        final Uri uri = incomingCallAddress == null ? null : Uri.parse(incomingCallAddress);
        Log.d(Config.LOGTAG, "uri=" + uri);
        if (uri == null || sid == null) {
            return Connection.createFailedConnection(
                    new DisconnectCause(
                            DisconnectCause.ERROR,
                            "connection request is missing required information"));
        }
        if (service == null) {
            return Connection.createFailedConnection(
                    new DisconnectCause(DisconnectCause.ERROR, "service connection not found"));
        }
        final var jid = Jid.of(uri.getSchemeSpecificPart());
        final Account account = service.findAccountByUuid(phoneAccountHandle.getId());
        final var weakReference =
                service.getJingleConnectionManager().findJingleRtpConnection(account, jid, sid);
        if (weakReference == null) {
            Log.d(Config.LOGTAG, "no connection found for " + jid + " and sid=" + sid);
            return Connection.createFailedConnection(
                    new DisconnectCause(DisconnectCause.ERROR, "no incoming connection found"));
        }
        final var jingleRtpConnection = weakReference.get();
        if (jingleRtpConnection == null) {
            Log.d(Config.LOGTAG, "connection has been terminated");
            return Connection.createFailedConnection(
                    new DisconnectCause(DisconnectCause.ERROR, "connection has been terminated"));
        }
        Log.d(Config.LOGTAG, "registering call integration for incoming call");
        return jingleRtpConnection.getCallIntegration();
    }

    public static void togglePhoneAccountAsync(final Context context, final Account account) {
        ACCOUNT_REGISTRATION_EXECUTOR.execute(() -> togglePhoneAccount(context, account));
    }

    private static void togglePhoneAccount(final Context context, final Account account) {
        if (account.isEnabled()) {
            registerPhoneAccount(context, account);
        } else {
            unregisterPhoneAccount(context, account);
        }
    }

    private static void registerPhoneAccount(final Context context, final Account account) {
        try {
            registerPhoneAccountOrThrow(context, account);
        } catch (final IllegalArgumentException | SecurityException e) {
            Log.w(
                    Config.LOGTAG,
                    "could not register phone account for " + account.getJid().asBareJid(),
                    e);
            ContextCompat.getMainExecutor(context)
                    .execute(() -> showCallIntegrationNotAvailable(context));
        }
    }

    private static void showCallIntegrationNotAvailable(final Context context) {
        Toast.makeText(context, R.string.call_integration_not_available, Toast.LENGTH_LONG).show();
    }

    private static void registerPhoneAccountOrThrow(final Context context, final Account account) {
        final var handle = getHandle(context, account);
        final var telecomManager = context.getSystemService(TelecomManager.class);
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            if (telecomManager.getOwnSelfManagedPhoneAccounts().contains(handle)) {
                Log.d(
                        Config.LOGTAG,
                        "a phone account for " + account.getJid().asBareJid() + " already exists");
                return;
            }
        }
        final var builder =
                PhoneAccount.builder(getHandle(context, account), account.getJid().asBareJid());
        builder.setSupportedUriSchemes(Collections.singletonList("xmpp"));
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            builder.setCapabilities(
                    PhoneAccount.CAPABILITY_SELF_MANAGED
                            | PhoneAccount.CAPABILITY_SUPPORTS_VIDEO_CALLING);
        }
        final var phoneAccount = builder.build();
        telecomManager.registerPhoneAccount(phoneAccount);
    }

    public static void togglePhoneAccountsAsync(
            final Context context, final Collection<Account> accounts) {
        ACCOUNT_REGISTRATION_EXECUTOR.execute(() -> togglePhoneAccounts(context, accounts));
    }

    private static void togglePhoneAccounts(
            final Context context, final Collection<Account> accounts) {
        for (final Account account : accounts) {
            if (account.isEnabled()) {
                try {
                    registerPhoneAccountOrThrow(context, account);
                } catch (final IllegalArgumentException | SecurityException e) {
                    Log.w(
                            Config.LOGTAG,
                            "could not register phone account for " + account.getJid().asBareJid(),
                            e);
                }
            } else {
                unregisterPhoneAccount(context, account);
            }
        }
    }

    public static void unregisterPhoneAccount(final Context context, final Account account) {
        final var handle = getHandle(context, account);
        final var telecomManager = context.getSystemService(TelecomManager.class);
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            if (telecomManager.getOwnSelfManagedPhoneAccounts().contains(handle)) {
                telecomManager.unregisterPhoneAccount(handle);
            }
        } else {
            telecomManager.unregisterPhoneAccount(handle);
        }
    }

    public static PhoneAccountHandle getHandle(final Context context, final Account account) {
        final var competentName =
                new ComponentName(context, CallIntegrationConnectionService.class);
        return new PhoneAccountHandle(competentName, account.getUuid());
    }

    public static void placeCall(
            final XmppConnectionService service,
            final Account account,
            final Jid with,
            final Set<Media> media) {
        if (CallIntegration.selfManaged(service)) {
            final var extras = new Bundle();
            extras.putParcelable(
                    TelecomManager.EXTRA_PHONE_ACCOUNT_HANDLE, getHandle(service, account));
            extras.putInt(
                    TelecomManager.EXTRA_START_CALL_WITH_VIDEO_STATE,
                    Media.audioOnly(media)
                            ? VideoProfile.STATE_AUDIO_ONLY
                            : VideoProfile.STATE_BIDIRECTIONAL);
            if (service.checkSelfPermission(Manifest.permission.MANAGE_OWN_CALLS)
                    != PackageManager.PERMISSION_GRANTED) {
                Toast.makeText(service, R.string.no_permission_to_place_call, Toast.LENGTH_SHORT)
                        .show();
                return;
            }
            final Uri address;
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
                // Android 9+ supports putting xmpp uris into the address
                address = CallIntegration.address(with);
            } else {
                // for Android 8 we need to put in a fake tel uri
                final var outgoingCallExtras = new Bundle();
                outgoingCallExtras.putString(EXTRA_ADDRESS, with.toString());
                extras.putBundle(TelecomManager.EXTRA_OUTGOING_CALL_EXTRAS, outgoingCallExtras);
                address = Uri.parse("tel:0");
            }
            try {
                service.getSystemService(TelecomManager.class).placeCall(address, extras);
                return;
            } catch (final SecurityException e) {
                Log.e(Config.LOGTAG, "call integration not available", e);
            }
        }

        final var connection = createOutgoingRtpConnection(service, account, with, media);
        if (connection != null) {
            Log.d(
                    Config.LOGTAG,
                    "not adding outgoing call to TelecomManager on Android "
                            + Build.VERSION.RELEASE
                            + " ("
                            + Build.DEVICE
                            + ")");
        }
    }

    private static ArrayList<PhoneAccountHandle> findPhoneAccount(final Context context, final AbstractJingleConnection.Id id) {
        final var def = CallIntegrationConnectionService.getHandle(context, id.account);
        final var lst = new ArrayList<PhoneAccountHandle>();
        if (CallIntegration.selfManaged(context)) lst.add(def);
        if (Build.VERSION.SDK_INT < 23) return lst;

        final var prefs = PreferenceManager.getDefaultSharedPreferences(context);
        if (!prefs.getBoolean("dialler_integration_incoming", true)) return lst;

        if (context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
            // We cannot request audio permission in Dialer UI
            // when Dialer is shown over keyguard, the user cannot even necessarily
            // see notifications.
            return lst;
        }

        /* Are video calls really coming in from a PSTN gateway?
        if (media.size() != 1 || !media.contains(Media.AUDIO)) {
            // Currently our ConnectionService only handles single audio calls
            Log.w(Config.LOGTAG, "only audio calls can be handled by cheogram connection service");
            return def;
        }*/

        for (Contact contact : id.account.getRoster().getContacts()) {
            if (!contact.getJid().getDomain().equals(id.with.getDomain())) {
                continue;
            }

            if (!contact.getPresences().anyIdentity("gateway", "pstn")) {
                continue;
            }

            final var handle = contact.phoneAccountHandle();
            if (handle != null) lst.add(0, handle);
        }

        return lst;
    }

    public static boolean addNewIncomingCall(
            final Context context, final AbstractJingleConnection.Id id) {
        if (NotificationService.isQuietHours(context, id.getContact().getAccount())) return true;
        final var phoneAccountHandles = findPhoneAccount(context, id);
        if (phoneAccountHandles.isEmpty()) {
            Log.d(
                    Config.LOGTAG,
                    "not adding incoming call to TelecomManager on Android "
                            + Build.VERSION.RELEASE
                            + " ("
                            + Build.DEVICE
                            + ")");
            return false;
        }
        final var bundle = new Bundle();
        bundle.putString(
                TelecomManager.EXTRA_INCOMING_CALL_ADDRESS,
                CallIntegration.address(id.with).toString());
        final var extras = new Bundle();
        extras.putString(EXTRA_SESSION_ID, id.sessionId);
        extras.putString("account", id.account.getJid().toString());
        extras.putString("with", id.with.toString());
        bundle.putBundle(TelecomManager.EXTRA_INCOMING_CALL_EXTRAS, extras);
        for (final var phoneAccountHandle : phoneAccountHandles) {
            try {
                context.getSystemService(TelecomManager.class)
                        .addNewIncomingCall(phoneAccountHandle, bundle);
                return true;
            } catch (final SecurityException e) {
                Log.e(
                        Config.LOGTAG,
                        id.account.getJid().asBareJid() + ": call integration not available",
                        e);
            }
        }
        return false;
    }

    public static class ServiceConnectionService {
        private final ServiceConnection serviceConnection;
        private final XmppConnectionService service;

        public ServiceConnectionService(
                final ServiceConnection serviceConnection, final XmppConnectionService service) {
            this.serviceConnection = serviceConnection;
            this.service = service;
        }

        public static XmppConnectionService get(
                final ListenableFuture<ServiceConnectionService> future) {
            try {
                return future.get(2, TimeUnit.SECONDS).service;
            } catch (final ExecutionException | InterruptedException | TimeoutException e) {
                return null;
            }
        }

        public static ListenableFuture<ServiceConnectionService> bindService(
                final Context context) {
            final SettableFuture<ServiceConnectionService> serviceConnectionFuture =
                    SettableFuture.create();
            final var intent = new Intent(context, XmppConnectionService.class);
            intent.setAction(XmppConnectionService.ACTION_CALL_INTEGRATION_SERVICE_STARTED);
            final var serviceConnection =
                    new ServiceConnection() {

                        @Override
                        public void onServiceConnected(
                                final ComponentName name, final IBinder iBinder) {
                            final XmppConnectionService.XmppConnectionBinder binder =
                                    (XmppConnectionService.XmppConnectionBinder) iBinder;
                            serviceConnectionFuture.set(
                                    new ServiceConnectionService(this, binder.getService()));
                        }

                        @Override
                        public void onServiceDisconnected(final ComponentName name) {}
                    };
            context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE);
            return serviceConnectionFuture;
        }
    }
}
