/*
 * Copyright 2020 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.internal.telephony;

import android.annotation.UserIdInt;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.os.Build;
import android.os.Handler;
import android.os.IBinder;
import android.os.Looper;
import android.os.Message;
import android.os.RemoteException;
import android.os.UserHandle;
import android.telephony.IBootstrapAuthenticationCallback;
import android.telephony.SubscriptionManager;
import android.telephony.TelephonyManager;
import android.telephony.gba.GbaAuthRequest;
import android.telephony.gba.GbaService;
import android.telephony.gba.IGbaService;
import android.text.TextUtils;
import android.util.SparseArray;

import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.telephony.flags.FeatureFlags;
import com.android.internal.telephony.metrics.RcsStats;
import com.android.internal.telephony.util.WorkerThread;
import com.android.telephony.Rlog;

import java.util.NoSuchElementException;
import java.util.concurrent.ConcurrentLinkedQueue;

/**
 * Class that serves as the layer between GbaService and ServiceStateTracker. It helps binding,
 * sending request, receiving callback, and registering for state change to GbaService.
 */
public class GbaManager {
    private static final boolean DBG = Build.IS_DEBUGGABLE;
    private static final int EVENT_BIND_SERVICE = 1;
    private static final int EVENT_UNBIND_SERVICE = 2;
    private static final int EVENT_BIND_FAIL = 3;
    private static final int EVENT_BIND_SUCCESS = 4;
    private static final int EVENT_CONFIG_CHANGED = 5;
    private static final int EVENT_REQUESTS_RECEIVED = 6;

    @VisibleForTesting
    public static final int RETRY_TIME_MS = 3000;
    @VisibleForTesting
    public static final int MAX_RETRY = 5;
    @VisibleForTesting
    public static final int REQUEST_TIMEOUT_MS = 5000;
    private final RcsStats mRcsStats;

    private final FeatureFlags mFeatureFlags;

    private final String mLogTag;
    private final Context mContext;
    private final int mSubId;

    private IGbaService mIGbaService;
    private GbaDeathRecipient mDeathRecipient;
    private String mTargetBindingPackageName;
    private GbaServiceConnection mServiceConnection;
    private Handler mHandler;

    private String mServicePackageName;
    @UserIdInt
    private int mUserId = UserHandle.USER_SYSTEM;
    private int mReleaseTime;
    private int mRetryTimes = 0;

    //the requests to be sent to the GBA service
    private final ConcurrentLinkedQueue<GbaAuthRequest> mRequestQueue =
            new ConcurrentLinkedQueue<>();
    //the callbacks of the pending requests which have been sent to the GBA service
    private final SparseArray<IBootstrapAuthenticationCallback> mCallbacks = new SparseArray<>();

    private static final SparseArray<GbaManager> sGbaManagers = new SparseArray<>();

    private final class GbaManagerHandler extends Handler {
        GbaManagerHandler(Looper looper) {
            super(looper);
        }

        @Override
        public void handleMessage(Message msg) {
            logv("handle msg:" + msg.what);
            switch (msg.what) {
                case EVENT_BIND_SERVICE:
                    if (mRetryTimes++ < MAX_RETRY) {
                        rebindService(false);
                    } else {
                        loge("Too many retries, stop now!");
                        sendEmptyMessage(EVENT_BIND_FAIL);
                    }
                    break;
                case EVENT_UNBIND_SERVICE:
                    //do nothing if new requests are coming
                    if (mRequestQueue.isEmpty()) {
                        clearCallbacksAndNotifyFailure();
                        unbindService();
                    }
                    break;
                case EVENT_BIND_FAIL:
                case EVENT_BIND_SUCCESS:
                    mRetryTimes = 0;
                    processRequests();
                    break;
                case EVENT_REQUESTS_RECEIVED:
                    if (isServiceConnected()) {
                        processRequests();
                    } else {
                        if (!mHandler.hasMessages(EVENT_BIND_SERVICE)) {
                            mHandler.sendEmptyMessage(EVENT_BIND_SERVICE);
                        }
                    }
                    break;
                case EVENT_CONFIG_CHANGED:
                    mRetryTimes = 0;
                    if (isServiceConnetable() || isServiceConnected()) {
                        //force to rebind when config is changed
                        rebindService(true);
                    }
                    break;
                default:
                    loge("Unhandled event " + msg.what);
            }
        }
    }

    private final class GbaDeathRecipient implements IBinder.DeathRecipient {

        private final ComponentName mComponentName;
        private IBinder mBinder;

        GbaDeathRecipient(ComponentName name) {
            mComponentName = name;
        }

        public void linkToDeath(IBinder service) throws RemoteException {
            if (service != null) {
                mBinder = service;
                mBinder.linkToDeath(this, 0);
            }
        }

        public synchronized void unlinkToDeath() {
            if (mBinder != null) {
                try {
                    mBinder.unlinkToDeath(this, 0);
                } catch (NoSuchElementException e) {
                    // do nothing
                }
                mBinder = null;
            }
        }

        @Override
        public void binderDied() {
            logd("GbaService(" + mComponentName + ") has died.");
            unlinkToDeath();
            //retry if died
            retryBind();
        }
    }

    private final class GbaServiceConnection implements ServiceConnection {
        @Override
        public void onServiceConnected(ComponentName name, IBinder service) {
            logd("service " + name + " for Gba is connected.");
            mIGbaService = IGbaService.Stub.asInterface(service);
            mDeathRecipient = new GbaDeathRecipient(name);
            try {
                mDeathRecipient.linkToDeath(service);
            } catch (RemoteException exception) {
                // Remote exception means that the binder already died.
                mDeathRecipient.binderDied();
                logd("RemoteException " + exception);
            }
            mHandler.sendEmptyMessage(EVENT_BIND_SUCCESS);
        }

        @Override
        public void onServiceDisconnected(ComponentName name) {
            logd("service " + name + " is now disconnected.");
            mTargetBindingPackageName = null;
        }
    }

    @VisibleForTesting
    public GbaManager(Context context, int subId, String servicePackageName, int releaseTime,
            RcsStats rcsStats, Looper looper, FeatureFlags featureFlags) {
        mContext = context;
        mSubId = subId;
        mLogTag = "GbaManager[" + subId + "]";

        mServicePackageName = servicePackageName;
        mReleaseTime = releaseTime;

        mFeatureFlags = featureFlags;

        mHandler = new GbaManagerHandler(looper);

        if (mReleaseTime < 0) {
            mHandler.sendEmptyMessage(EVENT_BIND_SERVICE);
        }
        mRcsStats = rcsStats;
    }

    /**
     * create a GbaManager instance for a sub
     */
    public static GbaManager make(Context context, int subId,
            String servicePackageName, int releaseTime, FeatureFlags featureFlags) {
        GbaManager gm;
        gm = new GbaManager(context, subId, servicePackageName, releaseTime,
                RcsStats.getInstance(), WorkerThread.get().getLooper(), featureFlags);
        synchronized (sGbaManagers) {
            sGbaManagers.put(subId, gm);
        }
        return gm;
    }

    /**
     * get singleton instance of GbaManager
     * @return GbaManager
     */
    public static GbaManager getInstance(int subId) {
        synchronized (sGbaManagers) {
            return sGbaManagers.get(subId);
        }
    }

    /**
     * handle the bootstrap authentication request
     * @hide
     */
    public void bootstrapAuthenticationRequest(GbaAuthRequest req) {
        logv("bootstrapAuthenticationRequest: " + req);
        //No GBA service configured
        if (TextUtils.isEmpty(getServicePackage())) {
            logd("do not support!");
            try {
                req.getCallback().onAuthenticationFailure(req.getToken(),
                        TelephonyManager.GBA_FAILURE_REASON_FEATURE_NOT_SUPPORTED);
            } catch (RemoteException exception) {
                loge("exception to call service: " + exception);
            }
            return;
        }

        mRequestQueue.offer(req);
        if (!mHandler.hasMessages(EVENT_REQUESTS_RECEIVED)) {
            mHandler.sendEmptyMessage(EVENT_REQUESTS_RECEIVED);
        }
    }

    private final IBootstrapAuthenticationCallback mServiceCallback =
            new IBootstrapAuthenticationCallback.Stub() {
                @Override
                public void onKeysAvailable(int token, byte[] gbaKey, String btId) {
                    logv("onKeysAvailable: " + Integer.toHexString(token) + ", id: " + btId);

                    IBootstrapAuthenticationCallback cb = null;
                    synchronized (mCallbacks) {
                        cb = mCallbacks.get(token);
                    }
                    if (cb != null) {
                        try {
                            cb.onKeysAvailable(token, gbaKey, btId);
                            mRcsStats.onGbaSuccessEvent(mSubId);
                        } catch (RemoteException exception) {
                            logd("RemoteException " + exception);
                        }
                        synchronized (mCallbacks) {
                            mCallbacks.remove(token);
                            if (mCallbacks.size() == 0) {
                                releaseServiceAsNeeded(0);
                            }
                        }
                    }
                }

                @Override
                public void onAuthenticationFailure(int token, int reason) {
                    logd("onAuthenticationFailure: "
                            + Integer.toHexString(token) + " for: " + reason);

                    IBootstrapAuthenticationCallback cb = null;
                    synchronized (mCallbacks) {
                        cb = mCallbacks.get(token);
                    }
                    if (cb != null) {
                        try {
                            cb.onAuthenticationFailure(token, reason);
                            mRcsStats.onGbaFailureEvent(mSubId, reason);
                        } catch (RemoteException exception) {
                            logd("RemoteException " + exception);
                        }
                        synchronized (mCallbacks) {
                            mCallbacks.remove(token);
                            if (mCallbacks.size() == 0) {
                                releaseServiceAsNeeded(0);
                            }
                        }
                    }
                }
            };

    private void processRequests() {
        if (isServiceConnected()) {
            try {
                while (!mRequestQueue.isEmpty()) {
                    GbaAuthRequest request = new GbaAuthRequest(mRequestQueue.peek());
                    synchronized (mCallbacks) {
                        mCallbacks.put(request.getToken(), request.getCallback());
                    }
                    request.setCallback(mServiceCallback);
                    mIGbaService.authenticationRequest(request);
                    mRequestQueue.poll();
                }
            } catch (RemoteException exception) {
                // Remote exception means that the binder already died.
                mDeathRecipient.binderDied();
                logd("RemoteException " + exception);
            }
        } else {
            while (!mRequestQueue.isEmpty()) {
                GbaAuthRequest req = mRequestQueue.poll();
                try {
                    req.getCallback().onAuthenticationFailure(req.getToken(),
                            TelephonyManager.GBA_FAILURE_REASON_FEATURE_NOT_SUPPORTED);
                } catch (RemoteException exception) {
                    logd("RemoteException " + exception);
                }
            }
        }

        releaseServiceAsNeeded(REQUEST_TIMEOUT_MS);
    }

    private void releaseServiceAsNeeded(int timeout) {
        int configReleaseTime = getReleaseTime();
        //always on
        if (configReleaseTime < 0) {
            return;
        }
        //schedule to release service
        int delayTime = configReleaseTime > timeout ? configReleaseTime : timeout;
        if (mHandler.hasMessages(EVENT_UNBIND_SERVICE)) {
            mHandler.removeMessages(EVENT_UNBIND_SERVICE);
        }
        mHandler.sendEmptyMessageDelayed(EVENT_UNBIND_SERVICE, delayTime);
    }

    private void clearCallbacksAndNotifyFailure() {
        synchronized (mCallbacks) {
            for (int i = 0; i < mCallbacks.size(); i++) {
                IBootstrapAuthenticationCallback cb = mCallbacks.valueAt(i);
                try {
                    cb.onAuthenticationFailure(mCallbacks.keyAt(i),
                            TelephonyManager.GBA_FAILURE_REASON_UNKNOWN);
                } catch (RemoteException exception) {
                    logd("RemoteException " + exception);
                }
            }
            mCallbacks.clear();
        }
    }

    /** return if GBA service has been connected */
    @VisibleForTesting
    public boolean isServiceConnected() {
        //current bound service should be the updated service package.
        synchronized (this) {
            return (mIGbaService != null) && (mIGbaService.asBinder().isBinderAlive())
                    && TextUtils.equals(mServicePackageName, mTargetBindingPackageName);
        }
    }

    private boolean isServiceConnetable() {
        synchronized (this) {
            return mTargetBindingPackageName != null || (
                    mReleaseTime < 0 && !TextUtils.isEmpty(mServicePackageName));
        }
    }

    private void unbindService() {
        if (mDeathRecipient != null) {
            mDeathRecipient.unlinkToDeath();
        }
        if (mServiceConnection != null) {
            logv("unbind service.");
            mContext.unbindService(mServiceConnection);
        }
        mDeathRecipient = null;
        mIGbaService = null;
        mServiceConnection = null;
        mTargetBindingPackageName = null;
    }

    private void bindService() {
        if (mContext == null || !SubscriptionManager.isValidSubscriptionId(mSubId)) {
            loge("Can't bind service with invalid sub Id.");
            return;
        }

        String servicePackage = getServicePackage();
        if (TextUtils.isEmpty(servicePackage)) {
            loge("Can't find the binding package");
            return;
        }

        Intent intent = new Intent(GbaService.SERVICE_INTERFACE);
        intent.setPackage(servicePackage);

        try {
            logv("Trying to bind " + servicePackage);
            mServiceConnection = new GbaServiceConnection();
            if (!mContext.bindServiceAsUser(intent, mServiceConnection,
                    Context.BIND_AUTO_CREATE | Context.BIND_FOREGROUND_SERVICE,
                    UserHandle.of(mUserId))) {
                logd("Cannot bind to the service.");
                retryBind();
                return;
            }
            mTargetBindingPackageName = servicePackage;
        } catch (SecurityException exception) {
            loge("bindService failed " + exception);
        }
    }

    private void retryBind() {
        //do nothing if binding service has been scheduled
        if (mHandler.hasMessages(EVENT_BIND_SERVICE)) {
            logv("wait for pending retry.");
            return;
        }

        logv("starting retry:" + mRetryTimes);

        mHandler.sendEmptyMessageDelayed(EVENT_BIND_SERVICE, RETRY_TIME_MS);
    }

    private void rebindService(boolean isForce) {
        // Do nothing if no need to rebind.
        if (!isForce && isServiceConnected()) {
            logv("Service " + getServicePackage() + " already bound or being bound.");
            return;
        }

        unbindService();
        bindService();
    }

    /** override GBA service package name to be connected */
    public boolean overrideServicePackage(String packageName, @UserIdInt int userId) {
        synchronized (this) {
            if (!TextUtils.equals(mServicePackageName, packageName) || userId != mUserId) {
                logv("Service package name is changed from " + mServicePackageName
                        + " to " + packageName + ", user id from " + mUserId + " to " + userId);
                mServicePackageName = packageName;
                mUserId = userId;
                if (!mHandler.hasMessages(EVENT_CONFIG_CHANGED)) {
                    mHandler.sendEmptyMessage(EVENT_CONFIG_CHANGED);
                }
                return true;
            }
        }
        return false;
    }

    /** return GBA service package name */
    public String getServicePackage() {
        synchronized (this) {
            return mServicePackageName;
        }
    }

    /** override the release time to unbind GBA service after the request is handled */
    public boolean overrideReleaseTime(int interval) {
        synchronized (this) {
            if (mReleaseTime != interval) {
                logv("Service release time is changed from " + mReleaseTime
                        + " to " + interval);
                mReleaseTime = interval;
                if (!mHandler.hasMessages(EVENT_CONFIG_CHANGED)) {
                    mHandler.sendEmptyMessage(EVENT_CONFIG_CHANGED);
                }
                return true;
            }
        }
        return false;
    }

    /** return the release time to unbind GBA service after the request is handled */
    public int getReleaseTime() {
        synchronized (this) {
            return mReleaseTime;
        }
    }

    @VisibleForTesting
    public Handler getHandler() {
        return mHandler;
    }

    /** only for testing */
    @VisibleForTesting
    public void destroy() {
        mHandler.removeCallbacksAndMessages(null);
        mRequestQueue.clear();
        mCallbacks.clear();
        unbindService();
        synchronized (sGbaManagers) {
            sGbaManagers.remove(mSubId);
        }
    }

    private void logv(String msg) {
        if (DBG) {
            Rlog.d(mLogTag, msg);
        }
    }

    private void logd(String msg) {
        Rlog.d(mLogTag, msg);
    }

    private void loge(String msg) {
        Rlog.e(mLogTag, msg);
    }
}
