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

import static com.android.internal.util.function.pooled.PooledLambda.obtainMessage;
import static com.android.server.autofill.Helper.sDebug;
import static com.android.server.autofill.Helper.sVerbose;

import android.annotation.BinderThread;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.content.ComponentName;
import android.os.Bundle;
import android.os.Handler;
import android.os.RemoteException;
import android.util.Slog;
import android.view.autofill.AutofillId;
import android.view.inputmethod.InlineSuggestion;
import android.view.inputmethod.InlineSuggestionsRequest;
import android.view.inputmethod.InlineSuggestionsResponse;

import com.android.internal.annotations.GuardedBy;
import com.android.internal.view.IInlineSuggestionsRequestCallback;
import com.android.internal.view.IInlineSuggestionsResponseCallback;
import com.android.internal.view.InlineSuggestionsRequestInfo;
import com.android.server.autofill.ui.InlineFillUi;
import com.android.server.inputmethod.InputMethodManagerInternal;

import java.lang.ref.WeakReference;
import java.util.List;
import java.util.Optional;
import java.util.function.Consumer;

/**
 * Maintains an inline suggestion session with the IME.
 *
 * <p> Each session corresponds to one request from the Autofill manager service to create an
 * {@link InlineSuggestionsRequest}. It's responsible for receiving callbacks from the IME and
 * sending {@link android.view.inputmethod.InlineSuggestionsResponse} to IME.
 */
final class AutofillInlineSuggestionsRequestSession {

    private static final String TAG = AutofillInlineSuggestionsRequestSession.class.getSimpleName();

    @NonNull
    private final InputMethodManagerInternal mInputMethodManagerInternal;
    private final int mUserId;
    @NonNull
    private final ComponentName mComponentName;
    @NonNull
    private final Object mLock;
    @NonNull
    private final Handler mHandler;
    @NonNull
    private final Bundle mUiExtras;
    @NonNull
    private final InlineFillUi.InlineUiEventCallback mUiCallback;

    @GuardedBy("mLock")
    @NonNull
    private AutofillId mAutofillId;
    @GuardedBy("mLock")
    @Nullable
    private Consumer<InlineSuggestionsRequest> mImeRequestConsumer;

    @GuardedBy("mLock")
    private boolean mImeRequestReceived;
    @GuardedBy("mLock")
    @Nullable
    private InlineSuggestionsRequest mImeRequest;
    @GuardedBy("mLock")
    @Nullable
    private IInlineSuggestionsResponseCallback mResponseCallback;

    @GuardedBy("mLock")
    @Nullable
    private AutofillId mImeCurrentFieldId;
    @GuardedBy("mLock")
    private boolean mImeInputStarted;
    @GuardedBy("mLock")
    private boolean mImeInputViewStarted;
    @GuardedBy("mLock")
    @Nullable
    private InlineFillUi mInlineFillUi;
    @GuardedBy("mLock")
    private Boolean mPreviousResponseIsNotEmpty = null;

    @GuardedBy("mLock")
    private boolean mDestroyed = false;
    @GuardedBy("mLock")
    private boolean mPreviousHasNonPinSuggestionShow;
    @GuardedBy("mLock")
    private boolean mImeSessionInvalidated = false;

    AutofillInlineSuggestionsRequestSession(
            @NonNull InputMethodManagerInternal inputMethodManagerInternal, int userId,
            @NonNull ComponentName componentName, @NonNull Handler handler, @NonNull Object lock,
            @NonNull AutofillId autofillId,
            @NonNull Consumer<InlineSuggestionsRequest> requestConsumer, @NonNull Bundle uiExtras,
            @NonNull InlineFillUi.InlineUiEventCallback callback) {
        mInputMethodManagerInternal = inputMethodManagerInternal;
        mUserId = userId;
        mComponentName = componentName;
        mHandler = handler;
        mLock = lock;
        mUiExtras = uiExtras;
        mUiCallback = callback;

        mAutofillId = autofillId;
        mImeRequestConsumer = requestConsumer;
    }

    @GuardedBy("mLock")
    @NonNull
    AutofillId getAutofillIdLocked() {
        return mAutofillId;
    }

    /**
     * Returns the {@link InlineSuggestionsRequest} provided by IME.
     *
     * <p> The caller is responsible for making sure Autofill hears back from IME before calling
     * this method, using the {@link #mImeRequestConsumer}.
     */
    @GuardedBy("mLock")
    Optional<InlineSuggestionsRequest> getInlineSuggestionsRequestLocked() {
        if (mDestroyed) {
            return Optional.empty();
        }
        return Optional.ofNullable(mImeRequest);
    }

    /**
     * Requests showing the inline suggestion in the IME when the IME becomes visible and is focused
     * on the {@code autofillId}.
     *
     * @return false if the IME callback is not available.
     */
    @GuardedBy("mLock")
    boolean onInlineSuggestionsResponseLocked(@NonNull InlineFillUi inlineFillUi) {
        if (mDestroyed) {
            return false;
        }
        if (sDebug) {
            Slog.d(TAG,
                    "onInlineSuggestionsResponseLocked called for:" + inlineFillUi.getAutofillId());
        }
        if (mImeRequest == null || mResponseCallback == null || mImeSessionInvalidated) {
            return false;
        }
        // TODO(b/151123764): each session should only correspond to one field.
        mAutofillId = inlineFillUi.getAutofillId();
        mInlineFillUi = inlineFillUi;
        maybeUpdateResponseToImeLocked();
        return true;
    }

    /**
     * Prevents further interaction with the IME. Must be called before starting a new request
     * session to avoid unwanted behavior from two overlapping requests.
     */
    @GuardedBy("mLock")
    void destroySessionLocked() {
        mDestroyed = true;

        if (!mImeRequestReceived) {
            Slog.w(TAG,
                    "Never received an InlineSuggestionsRequest from the IME for " + mAutofillId);
        }
    }

    /**
     * Requests the IME to create an {@link InlineSuggestionsRequest}.
     *
     * <p> This method should only be called once per session.
     */
    @GuardedBy("mLock")
    void onCreateInlineSuggestionsRequestLocked() {
        if (mDestroyed) {
            return;
        }
        mImeSessionInvalidated = false;
        if (sDebug) Slog.d(TAG, "onCreateInlineSuggestionsRequestLocked called: " + mAutofillId);
        mInputMethodManagerInternal.onCreateInlineSuggestionsRequest(mUserId,
                new InlineSuggestionsRequestInfo(mComponentName, mAutofillId, mUiExtras),
                new InlineSuggestionsRequestCallbackImpl(this));
    }

    /**
     * Clear the locally cached inline fill UI, but don't clear the suggestion in IME.
     *
     * See also {@link AutofillInlineSessionController#resetInlineFillUiLocked()}
     */
    @GuardedBy("mLock")
    void resetInlineFillUiLocked() {
        mInlineFillUi = null;
    }

    /**
     * Optionally sends inline response to the IME, depending on the current state.
     */
    @GuardedBy("mLock")
    private void maybeUpdateResponseToImeLocked() {
        if (sVerbose) Slog.v(TAG, "maybeUpdateResponseToImeLocked called");
        if (mDestroyed || mResponseCallback == null) {
            return;
        }
        if (mImeInputViewStarted && mInlineFillUi != null && match(mAutofillId,
                mImeCurrentFieldId)) {
            // if IME is visible, and response is not null, send the response
            InlineSuggestionsResponse response = mInlineFillUi.getInlineSuggestionsResponse();
            boolean isEmptyResponse = response.getInlineSuggestions().isEmpty();
            if (isEmptyResponse && Boolean.FALSE.equals(mPreviousResponseIsNotEmpty)) {
                // No-op if both the previous response and current response are empty.
                return;
            }
            maybeNotifyFillUiEventLocked(response.getInlineSuggestions());
            updateResponseToImeUncheckLocked(response);
            mPreviousResponseIsNotEmpty = !isEmptyResponse;
        }
    }

    /**
     * Sends the {@code response} to the IME, assuming all the relevant checks are already done.
     */
    @GuardedBy("mLock")
    private void updateResponseToImeUncheckLocked(InlineSuggestionsResponse response) {
        if (mDestroyed) {
            return;
        }
        if (sDebug) Slog.d(TAG, "Send inline response: " + response.getInlineSuggestions().size());
        try {
            mResponseCallback.onInlineSuggestionsResponse(mAutofillId, response);
        } catch (RemoteException e) {
            Slog.e(TAG, "RemoteException sending InlineSuggestionsResponse to IME");
        }
    }

    @GuardedBy("mLock")
    private void maybeNotifyFillUiEventLocked(@NonNull List<InlineSuggestion> suggestions) {
        if (mDestroyed) {
            return;
        }
        boolean hasSuggestionToShow = false;
        for (int i = 0; i < suggestions.size(); i++) {
            InlineSuggestion suggestion = suggestions.get(i);
            // It is possible we don't have any match result but we still have pinned
            // suggestions. Only notify we have non-pinned suggestions to show
            if (!suggestion.getInfo().isPinned()) {
                hasSuggestionToShow = true;
                break;
            }
        }
        if (sDebug) {
            Slog.d(TAG, "maybeNotifyFillUiEventLoked(): hasSuggestionToShow=" + hasSuggestionToShow
                    + ", mPreviousHasNonPinSuggestionShow=" + mPreviousHasNonPinSuggestionShow);
        }
        // Use mPreviousHasNonPinSuggestionShow to save previous status, if the display status
        // change, we can notify the event.
        if (hasSuggestionToShow && !mPreviousHasNonPinSuggestionShow) {
            // From no suggestion to has suggestions to show
            mUiCallback.notifyInlineUiShown(mAutofillId);
        } else if (!hasSuggestionToShow && mPreviousHasNonPinSuggestionShow) {
            // From has suggestions to no suggestions to show
            mUiCallback.notifyInlineUiHidden(mAutofillId);
        }
        // Update the latest status
        mPreviousHasNonPinSuggestionShow = hasSuggestionToShow;
    }

    /**
     * Handles the {@code request} and {@code callback} received from the IME.
     *
     * <p> Should only invoked in the {@link #mHandler} thread.
     */
    private void handleOnReceiveImeRequest(@Nullable InlineSuggestionsRequest request,
            @Nullable IInlineSuggestionsResponseCallback callback) {
        synchronized (mLock) {
            if (mDestroyed || mImeRequestReceived) {
                return;
            }
            mImeRequestReceived = true;
            mImeSessionInvalidated = false;

            if (request != null && callback != null) {
                mImeRequest = request;
                mResponseCallback = callback;
                handleOnReceiveImeStatusUpdated(mAutofillId, true, false);
            }
            if (mImeRequestConsumer != null) {
                // Note that mImeRequest is only set if both request and callback are non-null.
                mImeRequestConsumer.accept(mImeRequest);
                mImeRequestConsumer = null;
            }
        }
    }

    /**
     * Handles the IME status updates received from the IME.
     *
     * <p> Should only be invoked in the {@link #mHandler} thread.
     */
    private void handleOnReceiveImeStatusUpdated(boolean imeInputStarted,
            boolean imeInputViewStarted) {
        synchronized (mLock) {
            if (mDestroyed) {
                return;
            }
            if (mImeCurrentFieldId != null) {
                boolean imeInputStartedChanged = (mImeInputStarted != imeInputStarted);
                boolean imeInputViewStartedChanged = (mImeInputViewStarted != imeInputViewStarted);
                mImeInputStarted = imeInputStarted;
                mImeInputViewStarted = imeInputViewStarted;
                if (imeInputStartedChanged || imeInputViewStartedChanged) {
                    maybeUpdateResponseToImeLocked();
                }
            }
        }
    }

    /**
     * Handles the IME status updates received from the IME.
     *
     * <p> Should only be invoked in the {@link #mHandler} thread.
     */
    private void handleOnReceiveImeStatusUpdated(@Nullable AutofillId imeFieldId,
            boolean imeInputStarted, boolean imeInputViewStarted) {
        synchronized (mLock) {
            if (mDestroyed) {
                return;
            }
            if (imeFieldId != null) {
                mImeCurrentFieldId = imeFieldId;
            }
            handleOnReceiveImeStatusUpdated(imeInputStarted, imeInputViewStarted);
        }
    }

    /**
     * Handles the IME session status received from the IME.
     *
     * <p> Should only be invoked in the {@link #mHandler} thread.
     */
    private void handleOnReceiveImeSessionInvalidated() {
        synchronized (mLock) {
            if (mDestroyed) {
                return;
            }
            mImeSessionInvalidated = true;
        }
    }

    /**
     * Internal implementation of {@link IInlineSuggestionsRequestCallback}.
     */
    private static final class InlineSuggestionsRequestCallbackImpl extends
            IInlineSuggestionsRequestCallback.Stub {

        private final WeakReference<AutofillInlineSuggestionsRequestSession> mSession;

        private InlineSuggestionsRequestCallbackImpl(
                AutofillInlineSuggestionsRequestSession session) {
            mSession = new WeakReference<>(session);
        }

        @BinderThread
        @Override
        public void onInlineSuggestionsUnsupported() throws RemoteException {
            if (sDebug) Slog.d(TAG, "onInlineSuggestionsUnsupported() called.");
            final AutofillInlineSuggestionsRequestSession session = mSession.get();
            if (session != null) {
                session.mHandler.sendMessage(obtainMessage(
                        AutofillInlineSuggestionsRequestSession::handleOnReceiveImeRequest, session,
                        null, null));
            }
        }

        @BinderThread
        @Override
        public void onInlineSuggestionsRequest(InlineSuggestionsRequest request,
                IInlineSuggestionsResponseCallback callback) {
            if (sDebug) Slog.d(TAG, "onInlineSuggestionsRequest() received: " + request);
            final AutofillInlineSuggestionsRequestSession session = mSession.get();
            if (session != null) {
                session.mHandler.sendMessage(obtainMessage(
                        AutofillInlineSuggestionsRequestSession::handleOnReceiveImeRequest, session,
                        request, callback));
            }
        }

        @Override
        public void onInputMethodStartInput(AutofillId imeFieldId) throws RemoteException {
            if (sVerbose) Slog.v(TAG, "onInputMethodStartInput() received on " + imeFieldId);
            final AutofillInlineSuggestionsRequestSession session = mSession.get();
            if (session != null) {
                session.mHandler.sendMessage(obtainMessage(
                        AutofillInlineSuggestionsRequestSession::handleOnReceiveImeStatusUpdated,
                        session, imeFieldId, true, false));
            }
        }

        @Override
        public void onInputMethodShowInputRequested(boolean requestResult) throws RemoteException {
            if (sVerbose) {
                Slog.v(TAG, "onInputMethodShowInputRequested() received: " + requestResult);
            }
        }

        @BinderThread
        @Override
        public void onInputMethodStartInputView() {
            if (sVerbose) Slog.v(TAG, "onInputMethodStartInputView() received");
            final AutofillInlineSuggestionsRequestSession session = mSession.get();
            if (session != null) {
                session.mHandler.sendMessage(obtainMessage(
                        AutofillInlineSuggestionsRequestSession::handleOnReceiveImeStatusUpdated,
                        session, true, true));
            }
        }

        @BinderThread
        @Override
        public void onInputMethodFinishInputView() {
            if (sVerbose) Slog.v(TAG, "onInputMethodFinishInputView() received");
            final AutofillInlineSuggestionsRequestSession session = mSession.get();
            if (session != null) {
                session.mHandler.sendMessage(obtainMessage(
                        AutofillInlineSuggestionsRequestSession::handleOnReceiveImeStatusUpdated,
                        session, true, false));
            }
        }

        @Override
        public void onInputMethodFinishInput() throws RemoteException {
            if (sVerbose) Slog.v(TAG, "onInputMethodFinishInput() received");
            final AutofillInlineSuggestionsRequestSession session = mSession.get();
            if (session != null) {
                session.mHandler.sendMessage(obtainMessage(
                        AutofillInlineSuggestionsRequestSession::handleOnReceiveImeStatusUpdated,
                        session, false, false));
            }
        }

        @BinderThread
        @Override
        public void onInlineSuggestionsSessionInvalidated() throws RemoteException {
            if (sDebug) Slog.d(TAG, "onInlineSuggestionsSessionInvalidated() called.");
            final AutofillInlineSuggestionsRequestSession session = mSession.get();
            if (session != null) {
                session.mHandler.sendMessage(obtainMessage(
                        AutofillInlineSuggestionsRequestSession
                                ::handleOnReceiveImeSessionInvalidated, session));
            }
        }
    }

    private static boolean match(@Nullable AutofillId autofillId,
            @Nullable AutofillId imeClientFieldId) {
        // The IME doesn't have information about the virtual view id for the child views in the
        // web view, so we are only comparing the parent view id here. This means that for cases
        // where there are two input fields in the web view, they will have the same view id
        // (although different virtual child id), and we will not be able to distinguish them.
        return autofillId != null && imeClientFieldId != null
                && autofillId.getViewId() == imeClientFieldId.getViewId();
    }
}
