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

import static com.android.server.autofill.Helper.sDebug;
import static com.android.server.autofill.Helper.sVerbose;

import android.annotation.NonNull;
import android.annotation.Nullable;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.IntentSender;
import android.graphics.drawable.Drawable;
import android.metrics.LogMaker;
import android.os.Bundle;
import android.os.Handler;
import android.os.IBinder;
import android.os.RemoteException;
import android.service.autofill.Dataset;
import android.service.autofill.FillResponse;
import android.service.autofill.SaveInfo;
import android.service.autofill.ValueFinder;
import android.text.TextUtils;
import android.util.Slog;
import android.view.KeyEvent;
import android.view.autofill.AutofillId;
import android.view.autofill.AutofillManager;
import android.view.autofill.IAutofillWindowPresenter;
import android.widget.Toast;

import com.android.internal.logging.MetricsLogger;
import com.android.internal.logging.nano.MetricsProto.MetricsEvent;
import com.android.server.LocalServices;
import com.android.server.UiModeManagerInternal;
import com.android.server.UiThread;
import com.android.server.autofill.Helper;

import java.io.PrintWriter;

/**
 * Handles all autofill related UI tasks. The UI has two components:
 * fill UI that shows a popup style window anchored at the focused
 * input field for choosing a dataset to fill or trigger the response
 * authentication flow; save UI that shows a toast style window for
 * managing saving of user edits.
 */
public final class AutoFillUI {
    private static final String TAG = "AutofillUI";

    private final Handler mHandler = UiThread.getHandler();
    private final @NonNull Context mContext;

    private @Nullable FillUi mFillUi;
    private @Nullable SaveUi mSaveUi;

    private @Nullable AutoFillUiCallback mCallback;

    private final MetricsLogger mMetricsLogger = new MetricsLogger();

    private final @NonNull OverlayControl mOverlayControl;
    private final @NonNull UiModeManagerInternal mUiModeMgr;

    private @Nullable Runnable mCreateFillUiRunnable;
    private @Nullable AutoFillUiCallback mSaveUiCallback;

    public interface AutoFillUiCallback {
        void authenticate(int requestId, int datasetIndex, @NonNull IntentSender intent,
                @Nullable Bundle extras, boolean authenticateInline);
        void fill(int requestId, int datasetIndex, @NonNull Dataset dataset);
        void save();
        void cancelSave();
        void requestShowFillUi(AutofillId id, int width, int height,
                IAutofillWindowPresenter presenter);
        void requestHideFillUi(AutofillId id);
        void startIntentSenderAndFinishSession(IntentSender intentSender);
        void startIntentSender(IntentSender intentSender, Intent intent);
        void dispatchUnhandledKey(AutofillId id, KeyEvent keyEvent);
        void cancelSession();
    }

    public AutoFillUI(@NonNull Context context) {
        mContext = context;
        mOverlayControl = new OverlayControl(context);
        mUiModeMgr = LocalServices.getService(UiModeManagerInternal.class);
    }

    public void setCallback(@NonNull AutoFillUiCallback callback) {
        mHandler.post(() -> {
            if (mCallback != callback) {
                if (mCallback != null) {
                    if (isSaveUiShowing()) {
                        // keeps showing the save UI
                        hideFillUiUiThread(callback, true);
                    } else {
                        hideAllUiThread(mCallback);
                    }
                }
                mCallback = callback;
            }
        });
    }

    public void clearCallback(@NonNull AutoFillUiCallback callback) {
        mHandler.post(() -> {
            if (mCallback == callback) {
                hideAllUiThread(callback);
                mCallback = null;
            }
        });
    }

    /**
     * Displays an error message to the user.
     */
    public void showError(int resId, @NonNull AutoFillUiCallback callback) {
        showError(mContext.getString(resId), callback);
    }

    /**
     * Displays an error message to the user.
     */
    public void showError(@Nullable CharSequence message, @NonNull AutoFillUiCallback callback) {
        Slog.w(TAG, "showError(): " + message);

        mHandler.post(() -> {
            if (mCallback != callback) {
                return;
            }
            hideAllUiThread(callback);
            if (!TextUtils.isEmpty(message)) {
                Toast.makeText(mContext, message, Toast.LENGTH_LONG).show();
            }
        });
    }

    /**
     * Hides the fill UI.
     */
    public void hideFillUi(@NonNull AutoFillUiCallback callback) {
        mHandler.post(() -> hideFillUiUiThread(callback, true));
    }

    /**
     * Filters the options in the fill UI.
     *
     * @param filterText The filter prefix.
     */
    public void filterFillUi(@Nullable String filterText, @NonNull AutoFillUiCallback callback) {
        mHandler.post(() -> {
            if (callback != mCallback) {
                return;
            }
            if (mFillUi != null) {
                mFillUi.setFilterText(filterText);
            }
        });
    }

    /**
     * Shows the fill UI, removing the previous fill UI if the has changed.
     *
     * @param focusedId the currently focused field
     * @param response the current fill response
     * @param filterText text of the view to be filled
     * @param servicePackageName package name of the autofill service filling the activity
     * @param componentName component name of the activity that is filled
     * @param serviceLabel label of autofill service
     * @param serviceIcon icon of autofill service
     * @param callback identifier for the caller
     * @param sessionId id of the autofill session
     * @param compatMode whether the app is being autofilled in compatibility mode.
     */
    public void showFillUi(@NonNull AutofillId focusedId, @NonNull FillResponse response,
            @Nullable String filterText, @Nullable String servicePackageName,
            @NonNull ComponentName componentName, @NonNull CharSequence serviceLabel,
            @NonNull Drawable serviceIcon, @NonNull AutoFillUiCallback callback, int sessionId,
            boolean compatMode) {
        if (sDebug) {
            final int size = filterText == null ? 0 : filterText.length();
            Slog.d(TAG, "showFillUi(): id=" + focusedId + ", filter=" + size + " chars");
        }
        final LogMaker log = Helper
                .newLogMaker(MetricsEvent.AUTOFILL_FILL_UI, componentName, servicePackageName,
                        sessionId, compatMode)
                .addTaggedData(MetricsEvent.FIELD_AUTOFILL_FILTERTEXT_LEN,
                        filterText == null ? 0 : filterText.length())
                .addTaggedData(MetricsEvent.FIELD_AUTOFILL_NUM_DATASETS,
                        response.getDatasets() == null ? 0 : response.getDatasets().size());

        final Runnable createFillUiRunnable = () -> {
            if (callback != mCallback) {
                return;
            }
            hideAllUiThread(callback);
            mFillUi = new FillUi(mContext, response, focusedId,
                    filterText, mOverlayControl, serviceLabel, serviceIcon,
                    mUiModeMgr.isNightMode(),
                    new FillUi.Callback() {
                @Override
                public void onResponsePicked(FillResponse response) {
                    log.setType(MetricsEvent.TYPE_DETAIL);
                    hideFillUiUiThread(callback, true);
                    if (mCallback != null) {
                        mCallback.authenticate(response.getRequestId(),
                                AutofillManager.AUTHENTICATION_ID_DATASET_ID_UNDEFINED,
                                response.getAuthentication(), response.getClientState(),
                                /* authenticateInline= */ false);
                    }
                }

                @Override
                public void onDatasetPicked(Dataset dataset) {
                    log.setType(MetricsEvent.TYPE_ACTION);
                    hideFillUiUiThread(callback, true);
                    if (mCallback != null) {
                        final int datasetIndex = response.getDatasets().indexOf(dataset);
                        mCallback.fill(response.getRequestId(), datasetIndex, dataset);
                    }
                }

                @Override
                public void onCanceled() {
                    log.setType(MetricsEvent.TYPE_DISMISS);
                    hideFillUiUiThread(callback, true);
                }

                @Override
                public void onDestroy() {
                    if (log.getType() == MetricsEvent.TYPE_UNKNOWN) {
                        log.setType(MetricsEvent.TYPE_CLOSE);
                    }
                    mMetricsLogger.write(log);
                }

                @Override
                public void requestShowFillUi(int width, int height,
                        IAutofillWindowPresenter windowPresenter) {
                    if (mCallback != null) {
                        mCallback.requestShowFillUi(focusedId, width, height, windowPresenter);
                    }
                }

                @Override
                public void requestHideFillUi() {
                    if (mCallback != null) {
                        mCallback.requestHideFillUi(focusedId);
                    }
                }

                @Override
                public void startIntentSender(IntentSender intentSender) {
                    if (mCallback != null) {
                        mCallback.startIntentSenderAndFinishSession(intentSender);
                    }
                }

                @Override
                public void dispatchUnhandledKey(KeyEvent keyEvent) {
                    if (mCallback != null) {
                        mCallback.dispatchUnhandledKey(focusedId, keyEvent);
                    }
                }

                @Override
                public void cancelSession() {
                    if (mCallback != null) {
                        mCallback.cancelSession();
                    }
                }
            });
        };

        if (isSaveUiShowing()) {
            // postpone creating the fill UI for showing the save UI
            if (sDebug) Slog.d(TAG, "postpone fill UI request..");
            mCreateFillUiRunnable = createFillUiRunnable;
        } else {
            mHandler.post(createFillUiRunnable);
        }
    }

    /**
     * Shows the UI asking the user to save for autofill.
     */
    public void showSaveUi(@NonNull CharSequence serviceLabel, @NonNull Drawable serviceIcon,
            @Nullable String servicePackageName, @NonNull SaveInfo info,
            @NonNull ValueFinder valueFinder, @NonNull ComponentName componentName,
            @NonNull AutoFillUiCallback callback, @NonNull PendingUi pendingSaveUi,
            boolean isUpdate, boolean compatMode) {
        if (sVerbose) {
            Slog.v(TAG, "showSaveUi(update=" + isUpdate + ") for " + componentName.toShortString()
                    + ": " + info);
        }
        int numIds = 0;
        numIds += info.getRequiredIds() == null ? 0 : info.getRequiredIds().length;
        numIds += info.getOptionalIds() == null ? 0 : info.getOptionalIds().length;

        final LogMaker log = Helper
                .newLogMaker(MetricsEvent.AUTOFILL_SAVE_UI, componentName, servicePackageName,
                        pendingSaveUi.sessionId, compatMode)
                .addTaggedData(MetricsEvent.FIELD_AUTOFILL_NUM_IDS, numIds);
        if (isUpdate) {
            log.addTaggedData(MetricsEvent.FIELD_AUTOFILL_UPDATE, 1);
        }

        mHandler.post(() -> {
            if (callback != mCallback) {
                return;
            }
            hideAllUiThread(callback);
            mSaveUiCallback = callback;
            mSaveUi = new SaveUi(mContext, pendingSaveUi, serviceLabel, serviceIcon,
                    servicePackageName, componentName, info, valueFinder, mOverlayControl,
                    new SaveUi.OnSaveListener() {
                @Override
                public void onSave() {
                    log.setType(MetricsEvent.TYPE_ACTION);
                    hideSaveUiUiThread(callback);
                    callback.save();
                    destroySaveUiUiThread(pendingSaveUi, true);
                }

                @Override
                public void onCancel(IntentSender listener) {
                    log.setType(MetricsEvent.TYPE_DISMISS);
                    hideSaveUiUiThread(callback);
                    if (listener != null) {
                        try {
                            listener.sendIntent(mContext, 0, null, null, null);
                        } catch (IntentSender.SendIntentException e) {
                            Slog.e(TAG, "Error starting negative action listener: "
                                    + listener, e);
                        }
                    }
                    callback.cancelSave();
                    destroySaveUiUiThread(pendingSaveUi, true);
                }

                @Override
                public void onDestroy() {
                    if (log.getType() == MetricsEvent.TYPE_UNKNOWN) {
                        log.setType(MetricsEvent.TYPE_CLOSE);

                        callback.cancelSave();
                    }
                    mMetricsLogger.write(log);
                }

                @Override
                public void startIntentSender(IntentSender intentSender, Intent intent) {
                    callback.startIntentSender(intentSender, intent);
                }
            }, mUiModeMgr.isNightMode(), isUpdate, compatMode);
        });
    }

    /**
     * Executes an operation in the pending save UI, if any.
     */
    public void onPendingSaveUi(int operation, @NonNull IBinder token) {
        mHandler.post(() -> {
            if (mSaveUi != null) {
                mSaveUi.onPendingUi(operation, token);
            } else {
                Slog.w(TAG, "onPendingSaveUi(" + operation + "): no save ui");
            }
        });
    }

    /**
     * Hides all autofill UIs.
     */
    public void hideAll(@Nullable AutoFillUiCallback callback) {
        mHandler.post(() -> hideAllUiThread(callback));
    }

    /**
     * Destroy all autofill UIs.
     */
    public void destroyAll(@Nullable PendingUi pendingSaveUi,
            @Nullable AutoFillUiCallback callback, boolean notifyClient) {
        mHandler.post(() -> destroyAllUiThread(pendingSaveUi, callback, notifyClient));
    }

    public boolean isSaveUiShowing() {
        return mSaveUi == null ? false : mSaveUi.isShowing();
    }

    public void dump(PrintWriter pw) {
        pw.println("Autofill UI");
        final String prefix = "  ";
        final String prefix2 = "    ";
        pw.print(prefix); pw.print("Night mode: "); pw.println(mUiModeMgr.isNightMode());
        if (mFillUi != null) {
            pw.print(prefix); pw.println("showsFillUi: true");
            mFillUi.dump(pw, prefix2);
        } else {
            pw.print(prefix); pw.println("showsFillUi: false");
        }
        if (mSaveUi != null) {
            pw.print(prefix); pw.println("showsSaveUi: true");
            mSaveUi.dump(pw, prefix2);
        } else {
            pw.print(prefix); pw.println("showsSaveUi: false");
        }
    }

    @android.annotation.UiThread
    private void hideFillUiUiThread(@Nullable AutoFillUiCallback callback, boolean notifyClient) {
        if (mFillUi != null && (callback == null || callback == mCallback)) {
            mFillUi.destroy(notifyClient);
            mFillUi = null;
        }
    }

    @android.annotation.UiThread
    @Nullable
    private PendingUi hideSaveUiUiThread(@Nullable AutoFillUiCallback callback) {
        if (sVerbose) {
            Slog.v(TAG, "hideSaveUiUiThread(): mSaveUi=" + mSaveUi + ", callback=" + callback
                    + ", mCallback=" + mCallback);
        }

        if (mSaveUi != null && mSaveUiCallback == callback) {
            return mSaveUi.hide();
        }
        return null;
    }

    @android.annotation.UiThread
    private void destroySaveUiUiThread(@Nullable PendingUi pendingSaveUi, boolean notifyClient) {
        if (mSaveUi == null) {
            // Calling destroySaveUiUiThread() twice is normal - it usually happens when the
            // first call is made after the SaveUI is hidden and the second when the session is
            // finished.
            if (sDebug) Slog.d(TAG, "destroySaveUiUiThread(): already destroyed");
            return;
        }

        if (sDebug) Slog.d(TAG, "destroySaveUiUiThread(): " + pendingSaveUi);
        mSaveUi.destroy();
        mSaveUi = null;
        mSaveUiCallback = null;
        if (pendingSaveUi != null && notifyClient) {
            try {
                if (sDebug) Slog.d(TAG, "destroySaveUiUiThread(): notifying client");
                pendingSaveUi.client.setSaveUiState(pendingSaveUi.sessionId, false);
            } catch (RemoteException e) {
                Slog.e(TAG, "Error notifying client to set save UI state to hidden: " + e);
            }
        }

        if (mCreateFillUiRunnable != null) {
            if (sDebug) Slog.d(TAG, "start the pending fill UI request..");
            mHandler.post(mCreateFillUiRunnable);
            mCreateFillUiRunnable = null;
        }
    }

    @android.annotation.UiThread
    private void destroyAllUiThread(@Nullable PendingUi pendingSaveUi,
            @Nullable AutoFillUiCallback callback, boolean notifyClient) {
        hideFillUiUiThread(callback, notifyClient);
        destroySaveUiUiThread(pendingSaveUi, notifyClient);
    }

    @android.annotation.UiThread
    private void hideAllUiThread(@Nullable AutoFillUiCallback callback) {
        hideFillUiUiThread(callback, true);
        final PendingUi pendingSaveUi = hideSaveUiUiThread(callback);
        if (pendingSaveUi != null && pendingSaveUi.getState() == PendingUi.STATE_FINISHED) {
            if (sDebug) {
                Slog.d(TAG, "hideAllUiThread(): "
                        + "destroying Save UI because pending restoration is finished");
            }
            destroySaveUiUiThread(pendingSaveUi, true);
        }
    }
}
