/*
 * Copyright 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;

import android.annotation.FloatRange;
import android.annotation.IntDef;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.content.Context;
import android.os.Bundle;
import android.os.LocaleList;
import android.os.Parcel;
import android.os.Parcelable;
import android.text.Spannable;
import android.text.method.MovementMethod;
import android.text.style.ClickableSpan;
import android.text.style.URLSpan;
import android.view.View;
import android.view.textclassifier.TextClassifier.EntityConfig;
import android.view.textclassifier.TextClassifier.EntityType;
import android.widget.TextView;

import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.annotations.VisibleForTesting.Visibility;
import com.android.internal.util.Preconditions;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;

/**
 * A collection of links, representing subsequences of text and the entity types (phone number,
 * address, url, etc) they may be.
 */
public final class TextLinks implements Parcelable {

    /**
     * Return status of an attempt to apply TextLinks to text.
     * @hide
     */
    @Retention(RetentionPolicy.SOURCE)
    @IntDef({STATUS_LINKS_APPLIED, STATUS_NO_LINKS_FOUND, STATUS_NO_LINKS_APPLIED,
            STATUS_DIFFERENT_TEXT, STATUS_UNSUPPORTED_CHARACTER})
    public @interface Status {}

    /** Links were successfully applied to the text. */
    public static final int STATUS_LINKS_APPLIED = 0;

    /** No links exist to apply to text. Links count is zero. */
    public static final int STATUS_NO_LINKS_FOUND = 1;

    /** No links applied to text. The links were filtered out. */
    public static final int STATUS_NO_LINKS_APPLIED = 2;

    /** The specified text does not match the text used to generate the links. */
    public static final int STATUS_DIFFERENT_TEXT = 3;

    /** The specified text contains unsupported characters. */
    public static final int STATUS_UNSUPPORTED_CHARACTER = 4;

    /** @hide */
    @Retention(RetentionPolicy.SOURCE)
    @IntDef({APPLY_STRATEGY_IGNORE, APPLY_STRATEGY_REPLACE})
    public @interface ApplyStrategy {}

    /**
     * Do not replace {@link ClickableSpan}s that exist where the {@link TextLinkSpan} needs to
     * be applied to. Do not apply the TextLinkSpan.
     */
    public static final int APPLY_STRATEGY_IGNORE = 0;

    /**
     * Replace any {@link ClickableSpan}s that exist where the {@link TextLinkSpan} needs to be
     * applied to.
     */
    public static final int APPLY_STRATEGY_REPLACE = 1;

    private final String mFullText;
    private final List<TextLink> mLinks;
    private final Bundle mExtras;

    private TextLinks(String fullText, ArrayList<TextLink> links, Bundle extras) {
        mFullText = fullText;
        mLinks = Collections.unmodifiableList(links);
        mExtras = extras;
    }

    /**
     * Returns the text that was used to generate these links.
     */
    @NonNull
    public CharSequence getText() {
        return mFullText;
    }

    /**
     * Returns an unmodifiable Collection of the links.
     */
    @NonNull
    public Collection<TextLink> getLinks() {
        return mLinks;
    }

    /**
     * Returns the extended data.
     *
     * <p><b>NOTE: </b>Do not modify this bundle.
     */
    @NonNull
    public Bundle getExtras() {
        return mExtras;
    }

    /**
     * Annotates the given text with the generated links. It will fail if the provided text doesn't
     * match the original text used to create the TextLinks.
     *
     * <p><strong>NOTE: </strong>It may be necessary to set a LinkMovementMethod on the TextView
     * widget to properly handle links. See {@link TextView#setMovementMethod(MovementMethod)}
     *
     * @param text the text to apply the links to. Must match the original text
     * @param applyStrategy the apply strategy used to determine how to apply links to text.
     *      e.g {@link TextLinks#APPLY_STRATEGY_IGNORE}
     * @param spanFactory a custom span factory for converting TextLinks to TextLinkSpans.
     *      Set to {@code null} to use the default span factory.
     *
     * @return a status code indicating whether or not the links were successfully applied
     *      e.g. {@link #STATUS_LINKS_APPLIED}
     */
    @Status
    public int apply(
            @NonNull Spannable text,
            @ApplyStrategy int applyStrategy,
            @Nullable Function<TextLink, TextLinkSpan> spanFactory) {
        Objects.requireNonNull(text);
        return new TextLinksParams.Builder()
                .setApplyStrategy(applyStrategy)
                .setSpanFactory(spanFactory)
                .build()
                .apply(text, this);
    }

    @Override
    public String toString() {
        return String.format(Locale.US, "TextLinks{fullText=%s, links=%s}", mFullText, mLinks);
    }

    @Override
    public int describeContents() {
        return 0;
    }

    @Override
    public void writeToParcel(Parcel dest, int flags) {
        dest.writeString(mFullText);
        dest.writeTypedList(mLinks);
        dest.writeBundle(mExtras);
    }

    public static final @android.annotation.NonNull Parcelable.Creator<TextLinks> CREATOR =
            new Parcelable.Creator<TextLinks>() {
                @Override
                public TextLinks createFromParcel(Parcel in) {
                    return new TextLinks(in);
                }

                @Override
                public TextLinks[] newArray(int size) {
                    return new TextLinks[size];
                }
            };

    private TextLinks(Parcel in) {
        mFullText = in.readString();
        mLinks = in.createTypedArrayList(TextLink.CREATOR);
        mExtras = in.readBundle();
    }

    /**
     * A link, identifying a substring of text and possible entity types for it.
     */
    public static final class TextLink implements Parcelable {
        private final EntityConfidence mEntityScores;
        private final int mStart;
        private final int mEnd;
        private final Bundle mExtras;
        @Nullable private final URLSpan mUrlSpan;

        /**
         * Create a new TextLink.
         *
         * @param start The start index of the identified subsequence
         * @param end The end index of the identified subsequence
         * @param entityConfidence A mapping of entity type to confidence score
         * @param extras A bundle containing custom data related to this TextLink
         * @param urlSpan An optional URLSpan to delegate to. NOTE: Not parcelled
         *
         * @throws IllegalArgumentException if {@code entityConfidence} is null or empty
         * @throws IllegalArgumentException if {@code start} is greater than {@code end}
         */
        private TextLink(int start, int end, @NonNull EntityConfidence entityConfidence,
                @NonNull Bundle extras, @Nullable URLSpan urlSpan) {
            Objects.requireNonNull(entityConfidence);
            Preconditions.checkArgument(!entityConfidence.getEntities().isEmpty());
            Preconditions.checkArgument(start <= end);
            Objects.requireNonNull(extras);
            mStart = start;
            mEnd = end;
            mEntityScores = entityConfidence;
            mUrlSpan = urlSpan;
            mExtras = extras;
        }

        /**
         * Returns the start index of this link in the original text.
         *
         * @return the start index
         */
        public int getStart() {
            return mStart;
        }

        /**
         * Returns the end index of this link in the original text.
         *
         * @return the end index
         */
        public int getEnd() {
            return mEnd;
        }

        /**
         * Returns the number of entity types that have confidence scores.
         *
         * @return the entity count
         */
        public int getEntityCount() {
            return mEntityScores.getEntities().size();
        }

        /**
         * Returns the entity type at a given index. Entity types are sorted by confidence.
         *
         * @return the entity type at the provided index
         */
        @NonNull public @EntityType String getEntity(int index) {
            return mEntityScores.getEntities().get(index);
        }

        /**
         * Returns the confidence score for a particular entity type.
         *
         * @param entityType the entity type
         */
        public @FloatRange(from = 0.0, to = 1.0) float getConfidenceScore(
                @EntityType String entityType) {
            return mEntityScores.getConfidenceScore(entityType);
        }

        /**
         * Returns a bundle containing custom data related to this TextLink.
         */
        @NonNull
        public Bundle getExtras() {
            return mExtras;
        }

        @Override
        public String toString() {
            return String.format(Locale.US,
                    "TextLink{start=%s, end=%s, entityScores=%s, urlSpan=%s}",
                    mStart, mEnd, mEntityScores, mUrlSpan);
        }

        @Override
        public int describeContents() {
            return 0;
        }

        @Override
        public void writeToParcel(Parcel dest, int flags) {
            mEntityScores.writeToParcel(dest, flags);
            dest.writeInt(mStart);
            dest.writeInt(mEnd);
            dest.writeBundle(mExtras);
        }

        private static TextLink readFromParcel(Parcel in) {
            final EntityConfidence entityConfidence = EntityConfidence.CREATOR.createFromParcel(in);
            final int start = in.readInt();
            final int end = in.readInt();
            final Bundle extras = in.readBundle();
            return new TextLink(start, end, entityConfidence, extras, null /* urlSpan */);
        }

        public static final @android.annotation.NonNull Parcelable.Creator<TextLink> CREATOR =
                new Parcelable.Creator<TextLink>() {
                    @Override
                    public TextLink createFromParcel(Parcel in) {
                        return readFromParcel(in);
                    }

                    @Override
                    public TextLink[] newArray(int size) {
                        return new TextLink[size];
                    }
                };
    }

    /**
     * A request object for generating TextLinks.
     */
    public static final class Request implements Parcelable {

        private final CharSequence mText;
        @Nullable private final LocaleList mDefaultLocales;
        @Nullable private final EntityConfig mEntityConfig;
        private final boolean mLegacyFallback;
        private final Bundle mExtras;
        @Nullable private final ZonedDateTime mReferenceTime;
        @Nullable private SystemTextClassifierMetadata mSystemTcMetadata;

        private Request(
                CharSequence text,
                LocaleList defaultLocales,
                EntityConfig entityConfig,
                boolean legacyFallback,
                ZonedDateTime referenceTime,
                Bundle extras) {
            mText = text;
            mDefaultLocales = defaultLocales;
            mEntityConfig = entityConfig;
            mLegacyFallback = legacyFallback;
            mReferenceTime = referenceTime;
            mExtras = extras;
        }

        /**
         * Returns the text to generate links for.
         */
        @NonNull
        public CharSequence getText() {
            return mText;
        }

        /**
         * Returns an ordered list of locale preferences that can be used to disambiguate the
         * provided text.
         */
        @Nullable
        public LocaleList getDefaultLocales() {
            return mDefaultLocales;
        }

        /**
         * Returns the config representing the set of entities to look for
         *
         * @see Builder#setEntityConfig(EntityConfig)
         */
        @Nullable
        public EntityConfig getEntityConfig() {
            return mEntityConfig;
        }

        /**
         * Returns whether the TextClassifier can fallback to legacy links if smart linkify is
         * disabled.
         * <strong>Note: </strong>This is not parcelled.
         * @hide
         */
        public boolean isLegacyFallback() {
            return mLegacyFallback;
        }

        /**
         * Returns reference time based on which relative dates (e.g. "tomorrow") should be
         * interpreted.
         */
        @Nullable
        public ZonedDateTime getReferenceTime() {
            return mReferenceTime;
        }

        /**
         * Returns the name of the package that sent this request.
         * This returns {@code null} if no calling package name is set.
         */
        @Nullable
        public String getCallingPackageName() {
            return mSystemTcMetadata != null ? mSystemTcMetadata.getCallingPackageName() : null;
        }

        /**
         * Sets the information about the {@link SystemTextClassifier} that sent this request.
         *
         * @hide
         */
        @VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE)
        public void setSystemTextClassifierMetadata(
                @Nullable SystemTextClassifierMetadata systemTcMetadata) {
            mSystemTcMetadata = systemTcMetadata;
        }

        /**
         * Returns the information about the {@link SystemTextClassifier} that sent this request.
         *
         * @hide
         */
        @Nullable
        public SystemTextClassifierMetadata getSystemTextClassifierMetadata() {
            return mSystemTcMetadata;
        }

        /**
         * Returns the extended data.
         *
         * <p><b>NOTE: </b>Do not modify this bundle.
         */
        @NonNull
        public Bundle getExtras() {
            return mExtras;
        }

        /**
         * A builder for building TextLinks requests.
         */
        public static final class Builder {

            private final CharSequence mText;

            @Nullable private LocaleList mDefaultLocales;
            @Nullable private EntityConfig mEntityConfig;
            private boolean mLegacyFallback = true; // Use legacy fall back by default.
            @Nullable private Bundle mExtras;
            @Nullable private ZonedDateTime mReferenceTime;

            public Builder(@NonNull CharSequence text) {
                mText = Objects.requireNonNull(text);
            }

            /**
             * Sets ordered list of locale preferences that may be used to disambiguate the
             * provided text.
             *
             * @param defaultLocales ordered list of locale preferences that may be used to
             *                       disambiguate the provided text. If no locale preferences exist,
             *                       set this to null or an empty locale list.
             * @return this builder
             */
            @NonNull
            public Builder setDefaultLocales(@Nullable LocaleList defaultLocales) {
                mDefaultLocales = defaultLocales;
                return this;
            }

            /**
             * Sets the entity configuration to use. This determines what types of entities the
             * TextClassifier will look for.
             * Set to {@code null} for the default entity config and the TextClassifier will
             * automatically determine what links to generate.
             *
             * @return this builder
             */
            @NonNull
            public Builder setEntityConfig(@Nullable EntityConfig entityConfig) {
                mEntityConfig = entityConfig;
                return this;
            }

            /**
             * Sets whether the TextClassifier can fallback to legacy links if smart linkify is
             * disabled.
             *
             * <p><strong>Note: </strong>This is not parcelled.
             *
             * @return this builder
             * @hide
             */
            @NonNull
            public Builder setLegacyFallback(boolean legacyFallback) {
                mLegacyFallback = legacyFallback;
                return this;
            }

            /**
             * Sets the extended data.
             *
             * @return this builder
             */
            public Builder setExtras(@Nullable Bundle extras) {
                mExtras = extras;
                return this;
            }

            /**
             * Sets the reference time based on which relative dates (e.g.
             * "tomorrow") should be interpreted.
             *
             * @param referenceTime reference time based on which relative dates. This should
             *                      usually be the time when the text was originally composed.
             *
             * @return this builder
             */
            @NonNull
            public Builder setReferenceTime(@Nullable ZonedDateTime referenceTime) {
                mReferenceTime = referenceTime;
                return this;
            }

            /**
             * Builds and returns the request object.
             */
            @NonNull
            public Request build() {
                return new Request(
                        mText, mDefaultLocales, mEntityConfig,
                        mLegacyFallback, mReferenceTime,
                        mExtras == null ? Bundle.EMPTY : mExtras);
            }
        }

        @Override
        public int describeContents() {
            return 0;
        }

        @Override
        public void writeToParcel(Parcel dest, int flags) {
            dest.writeString(mText.toString());
            dest.writeParcelable(mDefaultLocales, flags);
            dest.writeParcelable(mEntityConfig, flags);
            dest.writeBundle(mExtras);
            dest.writeString(mReferenceTime == null ? null : mReferenceTime.toString());
            dest.writeParcelable(mSystemTcMetadata, flags);
        }

        private static Request readFromParcel(Parcel in) {
            final String text = in.readString();
            final LocaleList defaultLocales = in.readParcelable(null, android.os.LocaleList.class);
            final EntityConfig entityConfig = in.readParcelable(null, android.view.textclassifier.TextClassifier.EntityConfig.class);
            final Bundle extras = in.readBundle();
            final String referenceTimeString = in.readString();
            final ZonedDateTime referenceTime = referenceTimeString == null
                    ? null : ZonedDateTime.parse(referenceTimeString);
            final SystemTextClassifierMetadata systemTcMetadata = in.readParcelable(null, android.view.textclassifier.SystemTextClassifierMetadata.class);

            final Request request = new Request(text, defaultLocales, entityConfig,
                    /* legacyFallback= */ true, referenceTime, extras);
            request.setSystemTextClassifierMetadata(systemTcMetadata);
            return request;
        }

        public static final @android.annotation.NonNull Parcelable.Creator<Request> CREATOR =
                new Parcelable.Creator<Request>() {
                    @Override
                    public Request createFromParcel(Parcel in) {
                        return readFromParcel(in);
                    }

                    @Override
                    public Request[] newArray(int size) {
                        return new Request[size];
                    }
                };
    }

    /**
     * A ClickableSpan for a TextLink.
     *
     * <p>Applies only to TextViews.
     */
    public static class TextLinkSpan extends ClickableSpan {

        /**
         * How the clickspan is triggered.
         * @hide
         */
        @Retention(RetentionPolicy.SOURCE)
        @IntDef({INVOCATION_METHOD_UNSPECIFIED, INVOCATION_METHOD_TOUCH,
                INVOCATION_METHOD_KEYBOARD})
        public @interface InvocationMethod {}

        /** @hide */
        public static final int INVOCATION_METHOD_UNSPECIFIED = -1;
        /** @hide */
        public static final int INVOCATION_METHOD_TOUCH = 0;
        /** @hide */
        public static final int INVOCATION_METHOD_KEYBOARD = 1;

        private final TextLink mTextLink;

        public TextLinkSpan(@NonNull TextLink textLink) {
            mTextLink = textLink;
        }

        @Override
        public void onClick(View widget) {
            onClick(widget, INVOCATION_METHOD_UNSPECIFIED);
        }

        /** @hide */
        public final void onClick(View widget, @InvocationMethod int invocationMethod) {
            if (widget instanceof TextView) {
                final TextView textView = (TextView) widget;
                final Context context = textView.getContext();
                if (TextClassificationManager.getSettings(context).isSmartLinkifyEnabled()) {
                    switch (invocationMethod) {
                        case INVOCATION_METHOD_TOUCH:
                            textView.requestActionMode(this);
                            break;
                        case INVOCATION_METHOD_KEYBOARD:// fall though
                        case INVOCATION_METHOD_UNSPECIFIED:  // fall through
                        default:
                            textView.handleClick(this);
                            break;
                    }
                } else {
                    if (mTextLink.mUrlSpan != null) {
                        mTextLink.mUrlSpan.onClick(textView);
                    } else {
                        textView.handleClick(this);
                    }
                }
            }
        }

        public final TextLink getTextLink() {
            return mTextLink;
        }

        /** @hide */
        @VisibleForTesting(visibility = Visibility.PRIVATE)
        @Nullable
        public final String getUrl() {
            if (mTextLink.mUrlSpan != null) {
                return mTextLink.mUrlSpan.getURL();
            }
            return null;
        }
    }

    /**
     * A builder to construct a TextLinks instance.
     */
    public static final class Builder {
        private final String mFullText;
        private final ArrayList<TextLink> mLinks;
        private Bundle mExtras;

        /**
         * Create a new TextLinks.Builder.
         *
         * @param fullText The full text to annotate with links
         */
        public Builder(@NonNull String fullText) {
            mFullText = Objects.requireNonNull(fullText);
            mLinks = new ArrayList<>();
        }

        /**
         * Adds a TextLink.
         *
         * @param start The start index of the identified subsequence
         * @param end The end index of the identified subsequence
         * @param entityScores A mapping of entity type to confidence score
         *
         * @throws IllegalArgumentException if entityScores is null or empty.
         */
        @NonNull
        public Builder addLink(int start, int end, @NonNull Map<String, Float> entityScores) {
            return addLink(start, end, entityScores, Bundle.EMPTY, null);
        }

        /**
         * Adds a TextLink.
         *
         * @see #addLink(int, int, Map)
         * @param extras An optional bundle containing custom data related to this TextLink
         */
        @NonNull
        public Builder addLink(int start, int end, @NonNull Map<String, Float> entityScores,
                @NonNull Bundle extras) {
            return addLink(start, end, entityScores, extras, null);
        }

        /**
         * Adds a TextLink.
         *
         * @see #addLink(int, int, Map)
         * @param urlSpan An optional URLSpan to delegate to. NOTE: Not parcelled.
         */
        @NonNull
        Builder addLink(int start, int end, @NonNull Map<String, Float> entityScores,
                @Nullable URLSpan urlSpan) {
            return addLink(start, end, entityScores, Bundle.EMPTY, urlSpan);
        }

        private Builder addLink(int start, int end, @NonNull Map<String, Float> entityScores,
                @NonNull Bundle extras, @Nullable URLSpan urlSpan) {
            mLinks.add(new TextLink(
                    start, end, new EntityConfidence(entityScores), extras, urlSpan));
            return this;
        }

        /**
         * Removes all {@link TextLink}s.
         */
        @NonNull
        public Builder clearTextLinks() {
            mLinks.clear();
            return this;
        }

        /**
         * Sets the extended data.
         *
         * @return this builder
         */
        @NonNull
        public Builder setExtras(@Nullable Bundle extras) {
            mExtras = extras;
            return this;
        }

        /**
         * Constructs a TextLinks instance.
         *
         * @return the constructed TextLinks
         */
        @NonNull
        public TextLinks build() {
            return new TextLinks(mFullText, mLinks,
                    mExtras == null ? Bundle.EMPTY : mExtras);
        }
    }
}
