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

import android.annotation.NonNull;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.os.Binder;
import android.os.Handler;
import android.os.IBinder;
import android.os.RemoteException;
import android.util.Slog;
import android.service.textclassifier.ITextClassifierService;
import android.service.textclassifier.ITextClassificationCallback;
import android.service.textclassifier.ITextLinksCallback;
import android.service.textclassifier.ITextSelectionCallback;
import android.service.textclassifier.TextClassifierService;
import android.view.textclassifier.TextClassification;
import android.view.textclassifier.TextClassifier;
import android.view.textclassifier.TextLinks;
import android.view.textclassifier.TextSelection;

import com.android.internal.annotations.GuardedBy;
import com.android.internal.util.Preconditions;
import com.android.server.SystemService;

import java.util.LinkedList;
import java.util.Queue;
import java.util.concurrent.Callable;
import java.util.concurrent.TimeUnit;

/**
 * A manager for TextClassifier services.
 * Apps bind to the TextClassificationManagerService for text classification. This service
 * reroutes calls to it to a {@link TextClassifierService} that it manages.
 */
public final class TextClassificationManagerService extends ITextClassifierService.Stub {

    private static final String LOG_TAG = "TextClassificationManagerService";

    // How long after the last interaction with the service we would unbind
    private static final long TIMEOUT_IDLE_BIND_MILLIS = TimeUnit.MINUTES.toMillis(1);

    public static final class Lifecycle extends SystemService {

        private final TextClassificationManagerService mManagerService;

        public Lifecycle(Context context) {
            super(context);
            mManagerService = new TextClassificationManagerService(context);
        }

        @Override
        public void onStart() {
            try {
                publishBinderService(Context.TEXT_CLASSIFICATION_SERVICE, mManagerService);
            } catch (Throwable t) {
                // Starting this service is not critical to the running of this device and should
                // therefore not crash the device. If it fails, log the error and continue.
                Slog.e(LOG_TAG, "Could not start the TextClassificationManagerService.", t);
            }
        }
    }

    private final Context mContext;
    private final Handler mHandler;
    private final Intent mServiceIntent;
    private final ServiceConnection mConnection;
    private final Runnable mUnbind;
    private final Object mLock;
    @GuardedBy("mLock")
    private final Queue<PendingRequest> mPendingRequests;

    @GuardedBy("mLock")
    private ITextClassifierService mService;
    @GuardedBy("mLock")
    private boolean mBinding;

    private TextClassificationManagerService(Context context) {
        mContext = Preconditions.checkNotNull(context);
        mHandler = new Handler();
        mServiceIntent = new Intent(TextClassifierService.SERVICE_INTERFACE)
                .setComponent(TextClassifierService.getServiceComponentName(mContext));
        mConnection = new ServiceConnection() {
            @Override
            public void onServiceConnected(ComponentName name, IBinder service) {
                synchronized (mLock) {
                    mService = ITextClassifierService.Stub.asInterface(service);
                    setBindingLocked(false);
                    handlePendingRequestsLocked();
                }
            }

            @Override
            public void onServiceDisconnected(ComponentName name) {
                cleanupService();
            }

            @Override
            public void onBindingDied(ComponentName name) {
                cleanupService();
            }

            @Override
            public void onNullBinding(ComponentName name) {
                cleanupService();
            }

            private void cleanupService() {
                synchronized (mLock) {
                    mService = null;
                    setBindingLocked(false);
                    handlePendingRequestsLocked();
                }
            }
        };
        mPendingRequests = new LinkedList<>();
        mUnbind = this::unbind;
        mLock = new Object();
    }

    @Override
    public void onSuggestSelection(
            CharSequence text, int selectionStartIndex, int selectionEndIndex,
            TextSelection.Options options, ITextSelectionCallback callback)
            throws RemoteException {
        // TODO(b/72481438): All remote calls need to take userId.
        validateInput(text, selectionStartIndex, selectionEndIndex, callback);

        if (!bind()) {
            callback.onFailure();
            return;
        }

        synchronized (mLock) {
            if (isBoundLocked()) {
                mService.onSuggestSelection(
                        text, selectionStartIndex, selectionEndIndex, options, callback);
                scheduleUnbindLocked();
            } else {
                final Callable<Void> request = () -> {
                    onSuggestSelection(
                            text, selectionStartIndex, selectionEndIndex,
                            options, callback);
                    return null;
                };
                final Callable<Void> onServiceFailure = () -> {
                    callback.onFailure();
                    return null;
                };
                enqueueRequestLocked(request, onServiceFailure, callback.asBinder());
            }
        }
    }

    @Override
    public void onClassifyText(
            CharSequence text, int startIndex, int endIndex,
            TextClassification.Options options, ITextClassificationCallback callback)
            throws RemoteException {
        validateInput(text, startIndex, endIndex, callback);

        if (!bind()) {
            callback.onFailure();
            return;
        }

        synchronized (mLock) {
            if (isBoundLocked()) {
                mService.onClassifyText(text, startIndex, endIndex, options, callback);
                scheduleUnbindLocked();
            } else {
                final Callable<Void> request = () -> {
                    onClassifyText(text, startIndex, endIndex, options, callback);
                    return null;
                };
                final Callable<Void> onServiceFailure = () -> {
                    callback.onFailure();
                    return null;
                };
                enqueueRequestLocked(request, onServiceFailure, callback.asBinder());
            }
        }
    }

    @Override
    public void onGenerateLinks(
            CharSequence text, TextLinks.Options options, ITextLinksCallback callback)
            throws RemoteException {
        validateInput(text, callback);

        if (!bind()) {
            callback.onFailure();
            return;
        }

        synchronized (mLock) {
            if (isBoundLocked()) {
                mService.onGenerateLinks(text, options, callback);
                scheduleUnbindLocked();
            } else {
                final Callable<Void> request = () -> {
                    onGenerateLinks(text, options, callback);
                    return null;
                };
                final Callable<Void> onServiceFailure = () -> {
                    callback.onFailure();
                    return null;
                };
                enqueueRequestLocked(request, onServiceFailure, callback.asBinder());
            }
        }
    }

    /**
     * @return true if the service is bound or in the process of being bound.
     *      Returns false otherwise.
     */
    private boolean bind() {
        synchronized (mLock) {
            if (isBoundLocked() || isBindingLocked()) {
                return true;
            }

            // TODO: Handle bind timeout.
            final boolean willBind;
            final long identity = Binder.clearCallingIdentity();
            try {
                Slog.d(LOG_TAG, "Binding to " + mServiceIntent.getComponent());
                willBind = mContext.bindServiceAsUser(
                        mServiceIntent, mConnection,
                        Context.BIND_AUTO_CREATE | Context.BIND_FOREGROUND_SERVICE,
                        Binder.getCallingUserHandle());
                setBindingLocked(willBind);
            } finally {
                Binder.restoreCallingIdentity(identity);
            }
            return willBind;
        }
    }

    @GuardedBy("mLock")
    private boolean isBoundLocked() {
        return mService != null;
    }

    @GuardedBy("mLock")
    private boolean isBindingLocked() {
        return mBinding;
    }

    @GuardedBy("mLock")
    private void setBindingLocked(boolean binding) {
        mBinding = binding;
    }

    private void unbind() {
        synchronized (mLock) {
            if (!isBoundLocked()) {
                return;
            }

            Slog.d(LOG_TAG, "Unbinding from " + mServiceIntent.getComponent());
            mContext.unbindService(mConnection);

            synchronized (mLock) {
                mService = null;
            }
        }
    }

    @GuardedBy("mLock")
    private void scheduleUnbindLocked() {
        mHandler.removeCallbacks(mUnbind);
        mHandler.postDelayed(mUnbind, TIMEOUT_IDLE_BIND_MILLIS);
    }

    @GuardedBy("mLock")
    private void enqueueRequestLocked(
            Callable<Void> request, Callable<Void> onServiceFailure, IBinder binder) {
        mPendingRequests.add(new PendingRequest(request, onServiceFailure, binder));
    }

    @GuardedBy("mLock")
    private void handlePendingRequestsLocked() {
        // TODO(b/72481146): Implement PendingRequest similar to that in RemoteFillService.
        final PendingRequest[] pendingRequests =
                mPendingRequests.toArray(new PendingRequest[mPendingRequests.size()]);
        for (PendingRequest pendingRequest : pendingRequests) {
            if (isBoundLocked()) {
                pendingRequest.executeLocked();
            } else {
                pendingRequest.notifyServiceFailureLocked();
            }
        }
    }

    private final class PendingRequest implements IBinder.DeathRecipient {

        private final Callable<Void> mRequest;
        private final Callable<Void> mOnServiceFailure;
        private final IBinder mBinder;

        /**
         * Initializes a new pending request.
         *
         * @param request action to perform when the service is bound
         * @param onServiceFailure action to perform when the service dies or disconnects
         * @param binder binder to the process that made this pending request
         */
        PendingRequest(
                @NonNull Callable<Void> request, @NonNull Callable<Void> onServiceFailure,
                @NonNull IBinder binder) {
            mRequest = Preconditions.checkNotNull(request);
            mOnServiceFailure = Preconditions.checkNotNull(onServiceFailure);
            mBinder = Preconditions.checkNotNull(binder);
            try {
                mBinder.linkToDeath(this, 0);
            } catch (RemoteException e) {
                e.printStackTrace();
            }
        }

        @GuardedBy("mLock")
        void executeLocked() {
            removeLocked();
            try {
                mRequest.call();
            } catch (Exception e) {
                Slog.d(LOG_TAG, "Error handling pending request: " + e.getMessage());
            }
        }

        @GuardedBy("mLock")
        void notifyServiceFailureLocked() {
            removeLocked();
            try {
                mOnServiceFailure.call();
            } catch (Exception e) {
                Slog.d(LOG_TAG, "Error notifying callback of service failure: "
                        + e.getMessage());
            }
        }

        @Override
        public void binderDied() {
            synchronized (mLock) {
                // No need to handle this pending request anymore. Remove.
                removeLocked();
            }
        }

        @GuardedBy("mLock")
        private void removeLocked() {
            mPendingRequests.remove(this);
            mBinder.unlinkToDeath(this, 0);
        }
    }

    private static void validateInput(
            CharSequence text, int startIndex, int endIndex, Object callback)
            throws RemoteException {
        try {
            TextClassifier.Utils.validate(text, startIndex, endIndex, true /* allowInMainThread */);
            Preconditions.checkNotNull(callback);
        } catch (IllegalArgumentException | NullPointerException e) {
            throw new RemoteException(e.getMessage());
        }
    }

    private static void validateInput(CharSequence text, Object callback) throws RemoteException {
        try {
            TextClassifier.Utils.validate(text, true /* allowInMainThread */);
            Preconditions.checkNotNull(callback);
        } catch (IllegalArgumentException | NullPointerException e) {
            throw new RemoteException(e.getMessage());
        }
    }
}
