/*
 * Copyright (C) 2017 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 android.view.textclassifier.logging;

import android.annotation.IntDef;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.annotation.UnsupportedAppUsage;
import android.content.Context;
import android.metrics.LogMaker;
import android.util.Log;
import android.view.textclassifier.TextClassification;
import android.view.textclassifier.TextClassifier;
import android.view.textclassifier.TextSelection;

import com.android.internal.logging.MetricsLogger;
import com.android.internal.logging.nano.MetricsProto.MetricsEvent;
import com.android.internal.util.Preconditions;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.Objects;
import java.util.UUID;

/**
 * A selection event tracker.
 * @hide
 */
//TODO: Do not allow any crashes from this class.
public final class SmartSelectionEventTracker {

    private static final String LOG_TAG = "SmartSelectEventTracker";
    private static final boolean DEBUG_LOG_ENABLED = true;

    private static final int START_EVENT_DELTA = MetricsEvent.FIELD_SELECTION_SINCE_START;
    private static final int PREV_EVENT_DELTA = MetricsEvent.FIELD_SELECTION_SINCE_PREVIOUS;
    private static final int INDEX = MetricsEvent.FIELD_SELECTION_SESSION_INDEX;
    private static final int WIDGET_TYPE = MetricsEvent.FIELD_SELECTION_WIDGET_TYPE;
    private static final int WIDGET_VERSION = MetricsEvent.FIELD_SELECTION_WIDGET_VERSION;
    private static final int MODEL_NAME = MetricsEvent.FIELD_TEXTCLASSIFIER_MODEL;
    private static final int ENTITY_TYPE = MetricsEvent.FIELD_SELECTION_ENTITY_TYPE;
    private static final int SMART_START = MetricsEvent.FIELD_SELECTION_SMART_RANGE_START;
    private static final int SMART_END = MetricsEvent.FIELD_SELECTION_SMART_RANGE_END;
    private static final int EVENT_START = MetricsEvent.FIELD_SELECTION_RANGE_START;
    private static final int EVENT_END = MetricsEvent.FIELD_SELECTION_RANGE_END;
    private static final int SESSION_ID = MetricsEvent.FIELD_SELECTION_SESSION_ID;

    private static final String ZERO = "0";
    private static final String TEXTVIEW = "textview";
    private static final String EDITTEXT = "edittext";
    private static final String UNSELECTABLE_TEXTVIEW = "nosel-textview";
    private static final String WEBVIEW = "webview";
    private static final String EDIT_WEBVIEW = "edit-webview";
    private static final String CUSTOM_TEXTVIEW = "customview";
    private static final String CUSTOM_EDITTEXT = "customedit";
    private static final String CUSTOM_UNSELECTABLE_TEXTVIEW = "nosel-customview";
    private static final String UNKNOWN = "unknown";

    @Retention(RetentionPolicy.SOURCE)
    @IntDef({WidgetType.UNSPECIFIED, WidgetType.TEXTVIEW, WidgetType.WEBVIEW,
            WidgetType.EDITTEXT, WidgetType.EDIT_WEBVIEW})
    public @interface WidgetType {
        int UNSPECIFIED = 0;
        int TEXTVIEW = 1;
        int WEBVIEW = 2;
        int EDITTEXT = 3;
        int EDIT_WEBVIEW = 4;
        int UNSELECTABLE_TEXTVIEW = 5;
        int CUSTOM_TEXTVIEW = 6;
        int CUSTOM_EDITTEXT = 7;
        int CUSTOM_UNSELECTABLE_TEXTVIEW = 8;
    }

    private final MetricsLogger mMetricsLogger = new MetricsLogger();
    private final int mWidgetType;
    @Nullable private final String mWidgetVersion;
    private final Context mContext;

    @Nullable private String mSessionId;
    private final int[] mSmartIndices = new int[2];
    private final int[] mPrevIndices = new int[2];
    private int mOrigStart;
    private int mIndex;
    private long mSessionStartTime;
    private long mLastEventTime;
    private boolean mSmartSelectionTriggered;
    private String mModelName;

    @UnsupportedAppUsage
    public SmartSelectionEventTracker(@NonNull Context context, @WidgetType int widgetType) {
        mWidgetType = widgetType;
        mWidgetVersion = null;
        mContext = Preconditions.checkNotNull(context);
    }

    public SmartSelectionEventTracker(
            @NonNull Context context, @WidgetType int widgetType, @Nullable String widgetVersion) {
        mWidgetType = widgetType;
        mWidgetVersion = widgetVersion;
        mContext = Preconditions.checkNotNull(context);
    }

    /**
     * Logs a selection event.
     *
     * @param event the selection event
     */
    @UnsupportedAppUsage
    public void logEvent(@NonNull SelectionEvent event) {
        Preconditions.checkNotNull(event);

        if (event.mEventType != SelectionEvent.EventType.SELECTION_STARTED && mSessionId == null
                && DEBUG_LOG_ENABLED) {
            Log.d(LOG_TAG, "Selection session not yet started. Ignoring event");
            return;
        }

        final long now = System.currentTimeMillis();
        switch (event.mEventType) {
            case SelectionEvent.EventType.SELECTION_STARTED:
                mSessionId = startNewSession();
                Preconditions.checkArgument(event.mEnd == event.mStart + 1);
                mOrigStart = event.mStart;
                mSessionStartTime = now;
                break;
            case SelectionEvent.EventType.SMART_SELECTION_SINGLE:  // fall through
            case SelectionEvent.EventType.SMART_SELECTION_MULTI:
                mSmartSelectionTriggered = true;
                mModelName = getModelName(event);
                mSmartIndices[0] = event.mStart;
                mSmartIndices[1] = event.mEnd;
                break;
            case SelectionEvent.EventType.SELECTION_MODIFIED:  // fall through
            case SelectionEvent.EventType.AUTO_SELECTION:
                if (mPrevIndices[0] == event.mStart && mPrevIndices[1] == event.mEnd) {
                    // Selection did not change. Ignore event.
                    return;
                }
        }
        writeEvent(event, now);

        if (event.isTerminal()) {
            endSession();
        }
    }

    private void writeEvent(SelectionEvent event, long now) {
        final long prevEventDelta = mLastEventTime == 0 ? 0 : now - mLastEventTime;
        final LogMaker log = new LogMaker(MetricsEvent.TEXT_SELECTION_SESSION)
                .setType(getLogType(event))
                .setSubtype(MetricsEvent.TEXT_SELECTION_INVOCATION_MANUAL)
                .setPackageName(mContext.getPackageName())
                .addTaggedData(START_EVENT_DELTA, now - mSessionStartTime)
                .addTaggedData(PREV_EVENT_DELTA, prevEventDelta)
                .addTaggedData(INDEX, mIndex)
                .addTaggedData(WIDGET_TYPE, getWidgetTypeName())
                .addTaggedData(WIDGET_VERSION, mWidgetVersion)
                .addTaggedData(MODEL_NAME, mModelName)
                .addTaggedData(ENTITY_TYPE, event.mEntityType)
                .addTaggedData(SMART_START, getSmartRangeDelta(mSmartIndices[0]))
                .addTaggedData(SMART_END, getSmartRangeDelta(mSmartIndices[1]))
                .addTaggedData(EVENT_START, getRangeDelta(event.mStart))
                .addTaggedData(EVENT_END, getRangeDelta(event.mEnd))
                .addTaggedData(SESSION_ID, mSessionId);
        mMetricsLogger.write(log);
        debugLog(log);
        mLastEventTime = now;
        mPrevIndices[0] = event.mStart;
        mPrevIndices[1] = event.mEnd;
        mIndex++;
    }

    private String startNewSession() {
        endSession();
        mSessionId = createSessionId();
        return mSessionId;
    }

    private void endSession() {
        // Reset fields.
        mOrigStart = 0;
        mSmartIndices[0] = mSmartIndices[1] = 0;
        mPrevIndices[0] = mPrevIndices[1] = 0;
        mIndex = 0;
        mSessionStartTime = 0;
        mLastEventTime = 0;
        mSmartSelectionTriggered = false;
        mModelName = getModelName(null);
        mSessionId = null;
    }

    private static int getLogType(SelectionEvent event) {
        switch (event.mEventType) {
            case SelectionEvent.ActionType.OVERTYPE:
                return MetricsEvent.ACTION_TEXT_SELECTION_OVERTYPE;
            case SelectionEvent.ActionType.COPY:
                return MetricsEvent.ACTION_TEXT_SELECTION_COPY;
            case SelectionEvent.ActionType.PASTE:
                return MetricsEvent.ACTION_TEXT_SELECTION_PASTE;
            case SelectionEvent.ActionType.CUT:
                return MetricsEvent.ACTION_TEXT_SELECTION_CUT;
            case SelectionEvent.ActionType.SHARE:
                return MetricsEvent.ACTION_TEXT_SELECTION_SHARE;
            case SelectionEvent.ActionType.SMART_SHARE:
                return MetricsEvent.ACTION_TEXT_SELECTION_SMART_SHARE;
            case SelectionEvent.ActionType.DRAG:
                return MetricsEvent.ACTION_TEXT_SELECTION_DRAG;
            case SelectionEvent.ActionType.ABANDON:
                return MetricsEvent.ACTION_TEXT_SELECTION_ABANDON;
            case SelectionEvent.ActionType.OTHER:
                return MetricsEvent.ACTION_TEXT_SELECTION_OTHER;
            case SelectionEvent.ActionType.SELECT_ALL:
                return MetricsEvent.ACTION_TEXT_SELECTION_SELECT_ALL;
            case SelectionEvent.ActionType.RESET:
                return MetricsEvent.ACTION_TEXT_SELECTION_RESET;
            case SelectionEvent.EventType.SELECTION_STARTED:
                return MetricsEvent.ACTION_TEXT_SELECTION_START;
            case SelectionEvent.EventType.SELECTION_MODIFIED:
                return MetricsEvent.ACTION_TEXT_SELECTION_MODIFY;
            case SelectionEvent.EventType.SMART_SELECTION_SINGLE:
                return MetricsEvent.ACTION_TEXT_SELECTION_SMART_SINGLE;
            case SelectionEvent.EventType.SMART_SELECTION_MULTI:
                return MetricsEvent.ACTION_TEXT_SELECTION_SMART_MULTI;
            case SelectionEvent.EventType.AUTO_SELECTION:
                return MetricsEvent.ACTION_TEXT_SELECTION_AUTO;
            default:
                return MetricsEvent.VIEW_UNKNOWN;
        }
    }

    private static String getLogTypeString(int logType) {
        switch (logType) {
            case MetricsEvent.ACTION_TEXT_SELECTION_OVERTYPE:
                return "OVERTYPE";
            case MetricsEvent.ACTION_TEXT_SELECTION_COPY:
                return "COPY";
            case MetricsEvent.ACTION_TEXT_SELECTION_PASTE:
                return "PASTE";
            case MetricsEvent.ACTION_TEXT_SELECTION_CUT:
                return "CUT";
            case MetricsEvent.ACTION_TEXT_SELECTION_SHARE:
                return "SHARE";
            case MetricsEvent.ACTION_TEXT_SELECTION_SMART_SHARE:
                return "SMART_SHARE";
            case MetricsEvent.ACTION_TEXT_SELECTION_DRAG:
                return "DRAG";
            case MetricsEvent.ACTION_TEXT_SELECTION_ABANDON:
                return "ABANDON";
            case MetricsEvent.ACTION_TEXT_SELECTION_OTHER:
                return "OTHER";
            case MetricsEvent.ACTION_TEXT_SELECTION_SELECT_ALL:
                return "SELECT_ALL";
            case MetricsEvent.ACTION_TEXT_SELECTION_RESET:
                return "RESET";
            case MetricsEvent.ACTION_TEXT_SELECTION_START:
                return "SELECTION_STARTED";
            case MetricsEvent.ACTION_TEXT_SELECTION_MODIFY:
                return "SELECTION_MODIFIED";
            case MetricsEvent.ACTION_TEXT_SELECTION_SMART_SINGLE:
                return "SMART_SELECTION_SINGLE";
            case MetricsEvent.ACTION_TEXT_SELECTION_SMART_MULTI:
                return "SMART_SELECTION_MULTI";
            case MetricsEvent.ACTION_TEXT_SELECTION_AUTO:
                return "AUTO_SELECTION";
            default:
                return UNKNOWN;
        }
    }

    private int getRangeDelta(int offset) {
        return offset - mOrigStart;
    }

    private int getSmartRangeDelta(int offset) {
        return mSmartSelectionTriggered ? getRangeDelta(offset) : 0;
    }

    private String getWidgetTypeName() {
        switch (mWidgetType) {
            case WidgetType.TEXTVIEW:
                return TEXTVIEW;
            case WidgetType.WEBVIEW:
                return WEBVIEW;
            case WidgetType.EDITTEXT:
                return EDITTEXT;
            case WidgetType.EDIT_WEBVIEW:
                return EDIT_WEBVIEW;
            case WidgetType.UNSELECTABLE_TEXTVIEW:
                return UNSELECTABLE_TEXTVIEW;
            case WidgetType.CUSTOM_TEXTVIEW:
                return CUSTOM_TEXTVIEW;
            case WidgetType.CUSTOM_EDITTEXT:
                return CUSTOM_EDITTEXT;
            case WidgetType.CUSTOM_UNSELECTABLE_TEXTVIEW:
                return CUSTOM_UNSELECTABLE_TEXTVIEW;
            default:
                return UNKNOWN;
        }
    }

    private String getModelName(@Nullable SelectionEvent event) {
        return event == null
                ? SelectionEvent.NO_VERSION_TAG
                : Objects.toString(event.mVersionTag, SelectionEvent.NO_VERSION_TAG);
    }

    private static String createSessionId() {
        return UUID.randomUUID().toString();
    }

    private static void debugLog(LogMaker log) {
        if (!DEBUG_LOG_ENABLED) return;

        final String widgetType = Objects.toString(log.getTaggedData(WIDGET_TYPE), UNKNOWN);
        final String widgetVersion = Objects.toString(log.getTaggedData(WIDGET_VERSION), "");
        final String widget = widgetVersion.isEmpty()
                ? widgetType : widgetType + "-" + widgetVersion;
        final int index = Integer.parseInt(Objects.toString(log.getTaggedData(INDEX), ZERO));
        if (log.getType() == MetricsEvent.ACTION_TEXT_SELECTION_START) {
            String sessionId = Objects.toString(log.getTaggedData(SESSION_ID), "");
            sessionId = sessionId.substring(sessionId.lastIndexOf("-") + 1);
            Log.d(LOG_TAG, String.format("New selection session: %s (%s)", widget, sessionId));
        }

        final String model = Objects.toString(log.getTaggedData(MODEL_NAME), UNKNOWN);
        final String entity = Objects.toString(log.getTaggedData(ENTITY_TYPE), UNKNOWN);
        final String type = getLogTypeString(log.getType());
        final int smartStart = Integer.parseInt(
                Objects.toString(log.getTaggedData(SMART_START), ZERO));
        final int smartEnd = Integer.parseInt(
                Objects.toString(log.getTaggedData(SMART_END), ZERO));
        final int eventStart = Integer.parseInt(
                Objects.toString(log.getTaggedData(EVENT_START), ZERO));
        final int eventEnd = Integer.parseInt(
                Objects.toString(log.getTaggedData(EVENT_END), ZERO));

        Log.d(LOG_TAG, String.format("%2d: %s/%s, range=%d,%d - smart_range=%d,%d (%s/%s)",
                index, type, entity, eventStart, eventEnd, smartStart, smartEnd, widget, model));
    }

    /**
     * A selection event.
     * Specify index parameters as word token indices.
     */
    public static final class SelectionEvent {

        /**
         * Use this to specify an indeterminate positive index.
         */
        public static final int OUT_OF_BOUNDS = Integer.MAX_VALUE;

        /**
         * Use this to specify an indeterminate negative index.
         */
        public static final int OUT_OF_BOUNDS_NEGATIVE = Integer.MIN_VALUE;

        private static final String NO_VERSION_TAG = "";

        @Retention(RetentionPolicy.SOURCE)
        @IntDef({ActionType.OVERTYPE, ActionType.COPY, ActionType.PASTE, ActionType.CUT,
                ActionType.SHARE, ActionType.SMART_SHARE, ActionType.DRAG, ActionType.ABANDON,
                ActionType.OTHER, ActionType.SELECT_ALL, ActionType.RESET})
        public @interface ActionType {
        /** User typed over the selection. */
        int OVERTYPE = 100;
        /** User copied the selection. */
        int COPY = 101;
        /** User pasted over the selection. */
        int PASTE = 102;
        /** User cut the selection. */
        int CUT = 103;
        /** User shared the selection. */
        int SHARE = 104;
        /** User clicked the textAssist menu item. */
        int SMART_SHARE = 105;
        /** User dragged+dropped the selection. */
        int DRAG = 106;
        /** User abandoned the selection. */
        int ABANDON = 107;
        /** User performed an action on the selection. */
        int OTHER = 108;

        /* Non-terminal actions. */
        /** User activated Select All */
        int SELECT_ALL = 200;
        /** User reset the smart selection. */
        int RESET = 201;
        }

        @Retention(RetentionPolicy.SOURCE)
        @IntDef({ActionType.OVERTYPE, ActionType.COPY, ActionType.PASTE, ActionType.CUT,
                ActionType.SHARE, ActionType.SMART_SHARE, ActionType.DRAG, ActionType.ABANDON,
                ActionType.OTHER, ActionType.SELECT_ALL, ActionType.RESET,
                EventType.SELECTION_STARTED, EventType.SELECTION_MODIFIED,
                EventType.SMART_SELECTION_SINGLE, EventType.SMART_SELECTION_MULTI,
                EventType.AUTO_SELECTION})
        private @interface EventType {
        /** User started a new selection. */
        int SELECTION_STARTED = 1;
        /** User modified an existing selection. */
        int SELECTION_MODIFIED = 2;
        /** Smart selection triggered for a single token (word). */
        int SMART_SELECTION_SINGLE = 3;
        /** Smart selection triggered spanning multiple tokens (words). */
        int SMART_SELECTION_MULTI = 4;
        /** Something else other than User or the default TextClassifier triggered a selection. */
        int AUTO_SELECTION = 5;
        }

        private final int mStart;
        private final int mEnd;
        private @EventType int mEventType;
        private final @TextClassifier.EntityType String mEntityType;
        private final String mVersionTag;

        private SelectionEvent(
                int start, int end, int eventType,
                @TextClassifier.EntityType String entityType, String versionTag) {
            Preconditions.checkArgument(end >= start, "end cannot be less than start");
            mStart = start;
            mEnd = end;
            mEventType = eventType;
            mEntityType = Preconditions.checkNotNull(entityType);
            mVersionTag = Preconditions.checkNotNull(versionTag);
        }

        /**
         * Creates a "selection started" event.
         *
         * @param start  the word index of the selected word
         */
        @UnsupportedAppUsage
        public static SelectionEvent selectionStarted(int start) {
            return new SelectionEvent(
                    start, start + 1, EventType.SELECTION_STARTED,
                    TextClassifier.TYPE_UNKNOWN, NO_VERSION_TAG);
        }

        /**
         * Creates a "selection modified" event.
         * Use when the user modifies the selection.
         *
         * @param start  the start word (inclusive) index of the selection
         * @param end  the end word (exclusive) index of the selection
         */
        @UnsupportedAppUsage
        public static SelectionEvent selectionModified(int start, int end) {
            return new SelectionEvent(
                    start, end, EventType.SELECTION_MODIFIED,
                    TextClassifier.TYPE_UNKNOWN, NO_VERSION_TAG);
        }

        /**
         * Creates a "selection modified" event.
         * Use when the user modifies the selection and the selection's entity type is known.
         *
         * @param start  the start word (inclusive) index of the selection
         * @param end  the end word (exclusive) index of the selection
         * @param classification  the TextClassification object returned by the TextClassifier that
         *      classified the selected text
         */
        @UnsupportedAppUsage
        public static SelectionEvent selectionModified(
                int start, int end, @NonNull TextClassification classification) {
            final String entityType = classification.getEntityCount() > 0
                    ? classification.getEntity(0)
                    : TextClassifier.TYPE_UNKNOWN;
            final String versionTag = getVersionInfo(classification.getId());
            return new SelectionEvent(
                    start, end, EventType.SELECTION_MODIFIED, entityType, versionTag);
        }

        /**
         * Creates a "selection modified" event.
         * Use when a TextClassifier modifies the selection.
         *
         * @param start  the start word (inclusive) index of the selection
         * @param end  the end word (exclusive) index of the selection
         * @param selection  the TextSelection object returned by the TextClassifier for the
         *      specified selection
         */
        @UnsupportedAppUsage
        public static SelectionEvent selectionModified(
                int start, int end, @NonNull TextSelection selection) {
            final boolean smartSelection = getSourceClassifier(selection.getId())
                    .equals(TextClassifier.DEFAULT_LOG_TAG);
            final int eventType;
            if (smartSelection) {
                eventType = end - start > 1
                        ? EventType.SMART_SELECTION_MULTI
                        : EventType.SMART_SELECTION_SINGLE;

            } else {
                eventType = EventType.AUTO_SELECTION;
            }
            final String entityType = selection.getEntityCount() > 0
                    ? selection.getEntity(0)
                    : TextClassifier.TYPE_UNKNOWN;
            final String versionTag = getVersionInfo(selection.getId());
            return new SelectionEvent(start, end, eventType, entityType, versionTag);
        }

        /**
         * Creates an event specifying an action taken on a selection.
         * Use when the user clicks on an action to act on the selected text.
         *
         * @param start  the start word (inclusive) index of the selection
         * @param end  the end word (exclusive) index of the selection
         * @param actionType  the action that was performed on the selection
         */
        @UnsupportedAppUsage
        public static SelectionEvent selectionAction(
                int start, int end, @ActionType int actionType) {
            return new SelectionEvent(
                    start, end, actionType, TextClassifier.TYPE_UNKNOWN, NO_VERSION_TAG);
        }

        /**
         * Creates an event specifying an action taken on a selection.
         * Use when the user clicks on an action to act on the selected text and the selection's
         * entity type is known.
         *
         * @param start  the start word (inclusive) index of the selection
         * @param end  the end word (exclusive) index of the selection
         * @param actionType  the action that was performed on the selection
         * @param classification  the TextClassification object returned by the TextClassifier that
         *      classified the selected text
         */
        @UnsupportedAppUsage
        public static SelectionEvent selectionAction(
                int start, int end, @ActionType int actionType,
                @NonNull TextClassification classification) {
            final String entityType = classification.getEntityCount() > 0
                    ? classification.getEntity(0)
                    : TextClassifier.TYPE_UNKNOWN;
            final String versionTag = getVersionInfo(classification.getId());
            return new SelectionEvent(start, end, actionType, entityType, versionTag);
        }

        private static String getVersionInfo(String signature) {
            final int start = signature.indexOf("|");
            final int end = signature.indexOf("|", start);
            if (start >= 0 && end >= start) {
                return signature.substring(start, end);
            }
            return "";
        }

        private static String getSourceClassifier(String signature) {
            final int end = signature.indexOf("|");
            if (end >= 0) {
                return signature.substring(0, end);
            }
            return "";
        }

        private boolean isTerminal() {
            switch (mEventType) {
                case ActionType.OVERTYPE:  // fall through
                case ActionType.COPY:  // fall through
                case ActionType.PASTE:  // fall through
                case ActionType.CUT:  // fall through
                case ActionType.SHARE:  // fall through
                case ActionType.SMART_SHARE:  // fall through
                case ActionType.DRAG:  // fall through
                case ActionType.ABANDON:  // fall through
                case ActionType.OTHER:  // fall through
                    return true;
                default:
                    return false;
            }
        }
    }
}
