/*
* Copyright 2015 LinkedIn Corp. All rights reserved.
*
* 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.
*/

package com.linkedin.android.spyglass.ui;

import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Color;
import android.text.Editable;
import android.text.InputFilter;
import android.text.InputType;
import android.text.Layout;
import android.text.TextWatcher;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.AdapterView;
import android.widget.EditText;
import android.widget.ListView;
import android.widget.RelativeLayout;
import android.widget.TextView;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.linkedin.android.spyglass.R;
import com.linkedin.android.spyglass.mentions.MentionSpan;
import com.linkedin.android.spyglass.mentions.MentionSpanConfig;
import com.linkedin.android.spyglass.mentions.Mentionable;
import com.linkedin.android.spyglass.mentions.MentionsEditable;
import com.linkedin.android.spyglass.suggestions.SuggestionsAdapter;
import com.linkedin.android.spyglass.suggestions.SuggestionsResult;
import com.linkedin.android.spyglass.suggestions.impl.BasicSuggestionsListBuilder;
import com.linkedin.android.spyglass.suggestions.interfaces.OnSuggestionsVisibilityChangeListener;
import com.linkedin.android.spyglass.suggestions.interfaces.SuggestionsListBuilder;
import com.linkedin.android.spyglass.suggestions.interfaces.SuggestionsResultListener;
import com.linkedin.android.spyglass.suggestions.interfaces.SuggestionsVisibilityManager;
import com.linkedin.android.spyglass.tokenization.QueryToken;
import com.linkedin.android.spyglass.tokenization.impl.WordTokenizer;
import com.linkedin.android.spyglass.tokenization.impl.WordTokenizerConfig;
import com.linkedin.android.spyglass.tokenization.interfaces.QueryTokenReceiver;
import com.linkedin.android.spyglass.tokenization.interfaces.Tokenizer;

import java.util.ArrayList;
import java.util.List;

/**
 * Custom view for the RichEditor. Manages three subviews:
 *

 * 1. EditText - contains text typed by user

 * 2. TextView - displays count of the number of characters in the EditText

 * 3. ListView - displays mention suggestions when relevant
 *

 * XML attributes
 *

 * See {@link R.styleable#RichEditorView Attributes}
 *
 * @attr ref R.styleable#RichEditorView_mentionTextColor
 * @attr ref R.styleable#RichEditorView_mentionTextBackgroundColor
 * @attr ref R.styleable#RichEditorView_selectedMentionTextColor
 * @attr ref R.styleable#RichEditorView_selectedMentionTextBackgroundColor
 */
public class RichEditorView extends RelativeLayout implements TextWatcher, QueryTokenReceiver, SuggestionsResultListener, SuggestionsVisibilityManager {

    private MentionsEditText mMentionsEditText;
    private int mOriginalInputType = InputType.TYPE_CLASS_TEXT; // Default to plain text
    private TextView mTextCounterView;
    private ListView mSuggestionsList;

    private QueryTokenReceiver mHostQueryTokenReceiver;
    private SuggestionsAdapter mSuggestionsAdapter;
    private OnSuggestionsVisibilityChangeListener mActionListener;

    private ViewGroup.LayoutParams mPrevEditTextParams;
    private boolean mEditTextShouldWrapContent = false; // Default to match parent in height
    private int mPrevEditTextBottomPadding;

    private int mTextCountLimit = -1;
    private int mWithinCountLimitTextColor = Color.BLACK;
    private int mBeyondCountLimitTextColor = Color.RED;

    private boolean mWaitingForFirstResult = false;

    // --------------------------------------------------
    // Constructors & Initialization
    // --------------------------------------------------

    public RichEditorView(@NonNull Context context) {
        super(context);
        init(context, null, 0);
    }

    public RichEditorView(@NonNull Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init(context, attrs, 0);
    }

    public RichEditorView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(context, attrs, defStyleAttr);
    }

    public void init(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        // Inflate view from XML layout file
        LayoutInflater inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
        inflater.inflate(R.layout.editor_view, this, true);

        // Get the inner views
        mMentionsEditText = (MentionsEditText) findViewById(R.id.text_editor);
        mTextCounterView = (TextView) findViewById(R.id.text_counter);
        mSuggestionsList = (ListView) findViewById(R.id.suggestions_list);

        // Get the MentionSpanConfig from custom XML attributes and set it
        MentionSpanConfig mentionSpanConfig = parseMentionSpanConfigFromAttributes(attrs, defStyleAttr);
        mMentionsEditText.setMentionSpanConfig(mentionSpanConfig);

        // Create the tokenizer to use for the editor
        // TODO: Allow customization of configuration via XML attributes
        WordTokenizerConfig tokenizerConfig = new WordTokenizerConfig.Builder().build();
        WordTokenizer tokenizer = new WordTokenizer(tokenizerConfig);
        mMentionsEditText.setTokenizer(tokenizer);

        // Set various delegates on the MentionEditText to the RichEditorView
        mMentionsEditText.setSuggestionsVisibilityManager(this);
        mMentionsEditText.addTextChangedListener(this);
        mMentionsEditText.setQueryTokenReceiver(this);
        mMentionsEditText.setAvoidPrefixOnTap(true);

        // Set the suggestions adapter
        SuggestionsListBuilder listBuilder = new BasicSuggestionsListBuilder();
        mSuggestionsAdapter = new SuggestionsAdapter(context, this, listBuilder);
        mSuggestionsList.setAdapter(mSuggestionsAdapter);

        // Set the item click listener
        mSuggestionsList.setOnItemClickListener(new AdapterView.OnItemClickListener() {
            @Override
            public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
                Mentionable mention = (Mentionable) mSuggestionsAdapter.getItem(position);
                if (mMentionsEditText != null) {
                    mMentionsEditText.insertMention(mention);
                    mSuggestionsAdapter.clear();
                }
            }
        });

        // Display and update the editor text counter (starts it at 0)
        updateEditorTextCount();

        // Wrap the EditText content height if necessary (ideally, allow this to be controlled via custom XML attribute)
        setEditTextShouldWrapContent(mEditTextShouldWrapContent);
        mPrevEditTextBottomPadding = mMentionsEditText.getPaddingBottom();
    }

    private MentionSpanConfig parseMentionSpanConfigFromAttributes(@Nullable AttributeSet attrs, int defStyleAttr) {
        final Context context = getContext();
        MentionSpanConfig.Builder builder = new MentionSpanConfig.Builder();
        if (attrs == null) {
            return builder.build();
        }

        TypedArray attributes = context.getTheme().obtainStyledAttributes(attrs,
                                                                          R.styleable.RichEditorView,
                                                                          defStyleAttr,
                                                                          0);
        @ColorInt int normalTextColor = attributes.getColor(R.styleable.RichEditorView_mentionTextColor, -1);
        builder.setMentionTextColor(normalTextColor);
        @ColorInt int normalBgColor = attributes.getColor(R.styleable.RichEditorView_mentionTextBackgroundColor, -1);
        builder.setMentionTextBackgroundColor(normalBgColor);
        @ColorInt int selectedTextColor = attributes.getColor(R.styleable.RichEditorView_selectedMentionTextColor, -1);
        builder.setSelectedMentionTextColor(selectedTextColor);
        @ColorInt int selectedBgColor = attributes.getColor(R.styleable.RichEditorView_selectedMentionTextBackgroundColor, -1);
        builder.setSelectedMentionTextBackgroundColor(selectedBgColor);

        attributes.recycle();

        return builder.build();
    }

    // --------------------------------------------------
    // Public Span & UI Methods
    // --------------------------------------------------

	/**
	 * Allows filters in the input element.
	 *
	 * Example: obj.setInputFilters(new InputFilter[]{new InputFilter.LengthFilter(30)});
	 *
	 * @param filters
	 */
    public void setInputFilters(InputFilter... filters) {
		mMentionsEditText.setFilters(filters);

    }

    /**
     * @return a list of {@link MentionSpan} objects currently in the editor
     */
    @NonNull
    public List<MentionSpan> getMentionSpans() {
        return (mMentionsEditText != null) ? mMentionsEditText.getMentionsText().getMentionSpans() : new ArrayList<MentionSpan>();
    }

    /**
     * Determine whether the internal {@link EditText} should match the full height of the {@link RichEditorView}
     * initially or if it should wrap its content in height and expand to fill it as the user types.
     *

     * Note: The {@link EditText} will always match the parent (i.e. the {@link RichEditorView} in width.
     * Additionally, the {@link ListView} containing mention suggestions will always fill the rest
     * of the height in the {@link RichEditorView}.
     *
     * @param enabled true if the {@link EditText} should wrap its content in height
     */
    public void setEditTextShouldWrapContent(boolean enabled) {
        mEditTextShouldWrapContent = enabled;
        if (mMentionsEditText == null) {
            return;
        }
        mPrevEditTextParams = mMentionsEditText.getLayoutParams();
        int wrap = (enabled) ? LayoutParams.WRAP_CONTENT : LayoutParams.MATCH_PARENT;
        if (mPrevEditTextParams != null && mPrevEditTextParams.height == wrap) {
            return;
        }
        ViewGroup.LayoutParams newParams = new LayoutParams(LayoutParams.MATCH_PARENT, wrap);
        mMentionsEditText.setLayoutParams(newParams);
        requestLayout();
        invalidate();
    }

    /**
     * @return current line number of the cursor, or -1 if no cursor
     */
    public int getCurrentCursorLine() {
        int selectionStart = mMentionsEditText.getSelectionStart();
        Layout layout = mMentionsEditText.getLayout();
        if (layout != null && !(selectionStart == -1)) {
            return layout.getLineForOffset(selectionStart);
        }
        return -1;
    }

    /**
     * Show or hide the text counter view.
     *
     * @param display true to display the text counter view
     */
    public void displayTextCounter(boolean display) {
        if (display) {
            mTextCounterView.setVisibility(TextView.VISIBLE);
        } else {
            mTextCounterView.setVisibility(TextView.GONE);
        }
    }

    /**
     * @return true if the text counter view is currently visible to the user
     */
    public boolean isDisplayingTextCounter() {
        return mTextCounterView != null && mTextCounterView.getVisibility() == TextView.VISIBLE;
    }

    // --------------------------------------------------
    // TextWatcher Implementation
    // --------------------------------------------------

    /**
     * {@inheritDoc}
     */
    @Override
    public void beforeTextChanged(CharSequence s, int start, int count, int after) {
        // Do nothing
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void onTextChanged(CharSequence s, int start, int before, int count) {
        // Do nothing
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void afterTextChanged(Editable s) {
        updateEditorTextCount();
    }

    // --------------------------------------------------
    // QueryTokenReceiver Implementation
    // --------------------------------------------------

    /**
     * {@inheritDoc}
     */
    @Override
    public List<String> onQueryReceived(@NonNull QueryToken queryToken) {
        List<String> buckets = null;
        // Pass the query token to a host receiver
        if (mHostQueryTokenReceiver != null) {
            buckets = mHostQueryTokenReceiver.onQueryReceived(queryToken);
            mSuggestionsAdapter.notifyQueryTokenReceived(queryToken, buckets);
        }
        return buckets;
    }

    // --------------------------------------------------
    // SuggestionsResultListener Implementation
    // --------------------------------------------------

    /**
     * {@inheritDoc}
     */
    @Override
    public void onReceiveSuggestionsResult(final @NonNull SuggestionsResult result, final @NonNull String bucket) {
        // Add the mentions and notify the editor/dropdown of the changes on the UI thread
        post(new Runnable() {
            @Override
            public void run() {
                if (mSuggestionsAdapter != null) {
                    mSuggestionsAdapter.addSuggestions(result, bucket, mMentionsEditText);
                }
                // Make sure the list is scrolled to the top once you receive the first query result
                if (mWaitingForFirstResult && mSuggestionsList != null) {
                    mSuggestionsList.setSelection(0);
                    mWaitingForFirstResult = false;
                }
            }
        });
    }

    // --------------------------------------------------
    // SuggestionsManager Implementation
    // --------------------------------------------------

    /**
     * {@inheritDoc}
     */
    public void displaySuggestions(boolean display) {

        // If nothing to change, return early
        if (display == isDisplayingSuggestions() || mMentionsEditText == null) {
            return;
        }

        // Change view depending on whether suggestions are being shown or not
        if (display) {
            disableSpellingSuggestions(true);
            mTextCounterView.setVisibility(View.GONE);
            mSuggestionsList.setVisibility(View.VISIBLE);
            mPrevEditTextParams = mMentionsEditText.getLayoutParams();
            mPrevEditTextBottomPadding = mMentionsEditText.getPaddingBottom();
            mMentionsEditText.setPadding(mMentionsEditText.getPaddingLeft(), mMentionsEditText.getPaddingTop(), mMentionsEditText.getPaddingRight(), mMentionsEditText.getPaddingTop());
            int height = mMentionsEditText.getPaddingTop() + mMentionsEditText.getLineHeight() + mMentionsEditText.getPaddingBottom();
            mMentionsEditText.setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, height));
            mMentionsEditText.setVerticalScrollBarEnabled(false);
            int cursorLine = getCurrentCursorLine();
            Layout layout = mMentionsEditText.getLayout();
            if (layout != null) {
                int lineTop = layout.getLineTop(cursorLine);
                mMentionsEditText.scrollTo(0, lineTop);
            }
            // Notify action listener that list was shown
            if (mActionListener != null) {
                mActionListener.onSuggestionsDisplayed();
            }
        } else {
            disableSpellingSuggestions(false);
            mTextCounterView.setVisibility(View.VISIBLE);
            mSuggestionsList.setVisibility(View.GONE);
            mMentionsEditText.setPadding(mMentionsEditText.getPaddingLeft(), mMentionsEditText.getPaddingTop(), mMentionsEditText.getPaddingRight(), mPrevEditTextBottomPadding);
            if (mPrevEditTextParams == null) {
                mPrevEditTextParams = new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
            }
            mMentionsEditText.setLayoutParams(mPrevEditTextParams);
            mMentionsEditText.setVerticalScrollBarEnabled(true);
            // Notify action listener that list was hidden
            if (mActionListener != null) {
                mActionListener.onSuggestionsHidden();
            }
        }

        requestLayout();
        invalidate();
    }

    /**
     * {@inheritDoc}
     */
    public boolean isDisplayingSuggestions() {
        return mSuggestionsList.getVisibility() == View.VISIBLE;
    }

    /**
     * Disables spelling suggestions from the user's keyboard.
     * This is necessary because some keyboards will replace the input text with
     * spelling suggestions automatically, which changes the suggestion results.
     * This results in a confusing user experience.
     *
     * @param disable {@code true} if spelling suggestions should be disabled, otherwise {@code false}
     */
    private void disableSpellingSuggestions(boolean disable) {
        // toggling suggestions often resets the cursor location, but we don't want that to happen
        int start = mMentionsEditText.getSelectionStart();
        int end = mMentionsEditText.getSelectionEnd();
        // -1 means there is no selection or cursor.
        if (start == -1 || end == -1) {
            return;
        }
        if (disable) {
            // store the previous input type
            mOriginalInputType = mMentionsEditText.getInputType();
        }
        mMentionsEditText.setRawInputType(disable ? InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS : mOriginalInputType);
        mMentionsEditText.setSelection(start, end);
    }

    // --------------------------------------------------
    // Private Methods
    // --------------------------------------------------

    /**
     * Updates the TextView counting the number of characters in the editor. Sets not only the content
     * of the TextView, but also the color of the text depending if the limit has been reached.
     */
    private void updateEditorTextCount() {
        if (mMentionsEditText != null && mTextCounterView != null) {
            int textCount = mMentionsEditText.getMentionsText().length();
            mTextCounterView.setText(String.valueOf(textCount));

            if (mTextCountLimit > 0 && textCount > mTextCountLimit) {
                mTextCounterView.setTextColor(mBeyondCountLimitTextColor);
            } else {
                mTextCounterView.setTextColor(mWithinCountLimitTextColor);
            }
        }
    }

    // --------------------------------------------------
    // Pass-Through Methods to the MentionsEditText
    // --------------------------------------------------

    /**
     * Convenience method for {@link MentionsEditText#getCurrentTokenString()}.
     *
     * @return a string representing currently being considered for a possible query, as the user typed it
     */
    @NonNull
    public String getCurrentTokenString() {
        if (mMentionsEditText == null) {
            return "";
        }
        return mMentionsEditText.getCurrentTokenString();
    }

    /**
     * Convenience method for {@link MentionsEditText#getCurrentKeywordsString()}.
     *
     * @return a String representing current keywords in the underlying {@link EditText}
     */
    @NonNull
    public String getCurrentKeywordsString() {
        if (mMentionsEditText == null) {
            return "";
        }
        return mMentionsEditText.getCurrentKeywordsString();
    }

    /**
     * Resets the given {@link MentionSpan} in the editor, forcing it to redraw with its latest drawable state.
     *
     * @param span the {@link MentionSpan} to update
     */
    public void updateSpan(MentionSpan span) {
        if (mMentionsEditText != null) {
            mMentionsEditText.updateSpan(span);
        }
    }

    /**
     * Deselects any spans in the editor that are currently selected.
     */
    public void deselectAllSpans() {
        if (mMentionsEditText != null) {
            mMentionsEditText.deselectAllSpans();
        }
    }

    /**
     * Adds an {@link TextWatcher} to the internal {@link MentionsEditText}.
     *
     * @param hostTextWatcher the {TextWatcher} to add
     */
    public void addTextChangedListener(final @Nullable TextWatcher hostTextWatcher) {
        if (mMentionsEditText != null) {
            mMentionsEditText.addTextChangedListener(hostTextWatcher);
        }
    }

    /**
     * @return the {@link MentionsEditable} within the embedded {@link MentionsEditText}
     */
    @NonNull
    public MentionsEditable getText() {
        return (mMentionsEditText != null) ? ((MentionsEditable) mMentionsEditText.getText()) : new MentionsEditable("");
    }

    /**
     * @return the {@link Tokenizer} in use
     */
    @Nullable
    public Tokenizer getTokenizer() {
        return (mMentionsEditText != null) ? mMentionsEditText.getTokenizer() : null;
    }

    /**
     * Sets the text being displayed within the {@link RichEditorView}. Note that this removes the
     * {@link TextWatcher} temporarily to avoid changing the text while listening to text changes
     * (which could result in an infinite loop).
     *
     * @param text the text to display
     */
    public void setText(final @NonNull CharSequence text) {
        if (mMentionsEditText != null) {
            mMentionsEditText.setText(text);
        }
    }

    /**
     * Sets the text hint to use within the embedded {@link MentionsEditText}.
     *
     * @param hint the text hint to use
     */
    public void setHint(final @NonNull CharSequence hint) {
        if (mMentionsEditText != null) {
            mMentionsEditText.setHint(hint);
        }
    }

    /**
     * Sets the input type of the embedded {@link MentionsEditText}.
     *
     * @param type the input type of the {@link MentionsEditText}
     */
    public void setInputType(final int type) {
        if (mMentionsEditText != null) {
            mMentionsEditText.setInputType(type);
        }
    }

    /**
     * Sets the selection within the embedded {@link MentionsEditText}.
     *
     * @param index the index of the selection within the embedded {@link MentionsEditText}
     */
    public void setSelection(final int index) {
        if (mMentionsEditText != null) {
            mMentionsEditText.setSelection(index);
        }
    }

    /**
     * Sets the {@link Tokenizer} for the {@link MentionsEditText} to use.
     *
     * @param tokenizer the {@link Tokenizer} to use
     */
    public void setTokenizer(final @NonNull Tokenizer tokenizer) {
        if (mMentionsEditText != null) {
            mMentionsEditText.setTokenizer(tokenizer);
        }
    }

    /**
     * Sets the factory used to create MentionSpans within this class.
     *
     * @param factory the {@link MentionsEditText.MentionSpanFactory} to use
     */
    public void setMentionSpanFactory(@NonNull final MentionsEditText.MentionSpanFactory factory) {
        if (mMentionsEditText != null) {
            mMentionsEditText.setMentionSpanFactory(factory);
        }
    }

    // --------------------------------------------------
    // RichEditorView-specific Setters
    // --------------------------------------------------

    /**
     * Sets the limit on the maximum number of characters allowed to be entered into the
     * {@link MentionsEditText} before the text counter changes color.
     *
     * @param limit the maximum number of characters allowed before the text counter changes color
     */
    public void setTextCountLimit(final int limit) {
        mTextCountLimit = limit;
    }

    /**
     * Sets the color of the text within the text counter while the user has entered fewer than the
     * limit of characters.
     *
     * @param color the color of the text within the text counter below the limit
     */
    public void setWithinCountLimitTextColor(final int color) {
        mWithinCountLimitTextColor = color;
    }

    /**
     * Sets the color of the text within the text counter while the user has entered more text than
     * the current limit.
     *
     * @param color the color of the text within the text counter beyond the limit
     */
    public void setBeyondCountLimitTextColor(final int color) {
        mBeyondCountLimitTextColor = color;
    }

    /**
     * Sets the receiver of any tokens generated by the embedded {@link MentionsEditText}. The
     * receive should act on the queries as they are received and call
     * {@link #onReceiveSuggestionsResult(SuggestionsResult, String)} once the suggestions are ready.
     *
     * @param client the object that can receive {@link QueryToken} objects and generate suggestions from them
     */
    public void setQueryTokenReceiver(final @Nullable QueryTokenReceiver client) {
        mHostQueryTokenReceiver = client;
    }

    /**
     * Sets a listener for anyone interested in specific actions of the {@link RichEditorView}.
     *
     * @param listener the object that wants to listen to specific actions of the {@link RichEditorView}
     */
    public void setOnRichEditorActionListener(final @Nullable OnSuggestionsVisibilityChangeListener listener) {
        mActionListener = listener;
    }

    /**
     * Sets the {@link com.linkedin.android.spyglass.suggestions.interfaces.SuggestionsVisibilityManager} to use (determines which and how the suggestions are displayed).
     *
     * @param suggestionsVisibilityManager the {@link com.linkedin.android.spyglass.suggestions.interfaces.SuggestionsVisibilityManager} to use
     */
    public void setSuggestionsManager(final @NonNull SuggestionsVisibilityManager suggestionsVisibilityManager) {
        if (mMentionsEditText != null && mSuggestionsAdapter != null) {
            mMentionsEditText.setSuggestionsVisibilityManager(suggestionsVisibilityManager);
            mSuggestionsAdapter.setSuggestionsManager(suggestionsVisibilityManager);
        }
    }

    /**
     * Sets the {@link SuggestionsListBuilder} to use.
     *
     * @param suggestionsListBuilder the {@link SuggestionsListBuilder} to use
     */
    public void setSuggestionsListBuilder(final @NonNull SuggestionsListBuilder suggestionsListBuilder) {
        if (mSuggestionsAdapter != null) {
            mSuggestionsAdapter.setSuggestionsListBuilder(suggestionsListBuilder);
        }
    }

}


/*
* Copyright 2015 LinkedIn Corp. All rights reserved.
*
* 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.
*/

package com.linkedin.android.spyglass.sample.samples;

import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;

import com.bumptech.glide.Glide;
import com.linkedin.android.spyglass.sample.R;
import com.linkedin.android.spyglass.sample.data.models.Person;
import com.linkedin.android.spyglass.suggestions.SuggestionsResult;
import com.linkedin.android.spyglass.suggestions.interfaces.Suggestible;
import com.linkedin.android.spyglass.suggestions.interfaces.SuggestionsResultListener;
import com.linkedin.android.spyglass.suggestions.interfaces.SuggestionsVisibilityManager;
import com.linkedin.android.spyglass.tokenization.QueryToken;
import com.linkedin.android.spyglass.tokenization.impl.WordTokenizer;
import com.linkedin.android.spyglass.tokenization.impl.WordTokenizerConfig;
import com.linkedin.android.spyglass.tokenization.interfaces.QueryTokenReceiver;
import com.linkedin.android.spyglass.ui.MentionsEditText;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

/**
 * Demonstrates how mentions are handled when there is a delay retrieving the suggestions (i.e. over a network).
 */
public class GridMentions extends AppCompatActivity implements QueryTokenReceiver, SuggestionsResultListener, SuggestionsVisibilityManager {

    private static final String BUCKET = "people-network";
    private static final WordTokenizerConfig tokenizerConfig = new WordTokenizerConfig
            .Builder()
            .setWordBreakChars(", ")
            .setExplicitChars("")
            .setMaxNumKeywords(2)
            .setThreshold(1)
            .build();

    private RecyclerView recyclerView;
    private MentionsEditText editor;
    private PersonMentionAdapter adapter;
    private Person.PersonLoader people;

    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.grid_mentions);

        recyclerView = (RecyclerView) findViewById(R.id.mentions_grid);
        recyclerView.setLayoutManager(new GridLayoutManager(this, 2));
        adapter = new PersonMentionAdapter(new ArrayList<Person>());
        recyclerView.setAdapter(adapter);

        editor = (MentionsEditText) findViewById(R.id.editor);
        editor.setTokenizer(new WordTokenizer(tokenizerConfig));
        editor.setQueryTokenReceiver(this);
        editor.setSuggestionsVisibilityManager(this);
        editor.setHint(getResources().getString(R.string.type_person));

        people = new Person.PersonLoader(getResources());
    }

    // --------------------------------------------------
    // QueryTokenReceiver Implementation
    // --------------------------------------------------

    @Override
    public List<String> onQueryReceived(final @NonNull QueryToken queryToken) {
        List<String> buckets = Arrays.asList(BUCKET);
        List<Person> suggestions = people.getSuggestions(queryToken);
        SuggestionsResult result = new SuggestionsResult(queryToken, suggestions);
        // Have suggestions, now call the listener (which is this activity)
        onReceiveSuggestionsResult(result, BUCKET);
        return buckets;
    }

    // --------------------------------------------------
    // SuggestionsResultListener Implementation
    // --------------------------------------------------

    @Override
    public void onReceiveSuggestionsResult(@NonNull SuggestionsResult result, @NonNull String bucket) {
        List<? extends Suggestible> suggestions = result.getSuggestions();
        adapter = new PersonMentionAdapter(result.getSuggestions());
        recyclerView.swapAdapter(adapter, true);
        boolean display = suggestions != null && suggestions.size() > 0;
        displaySuggestions(display);
    }

    // --------------------------------------------------
    // SuggestionsManager Implementation
    // --------------------------------------------------

    @Override
    public void displaySuggestions(boolean display) {
        if (display) {
            recyclerView.setVisibility(RecyclerView.VISIBLE);
        } else {
            recyclerView.setVisibility(RecyclerView.GONE);
        }
    }

    @Override
    public boolean isDisplayingSuggestions() {
        return recyclerView.getVisibility() == RecyclerView.VISIBLE;
    }

    // --------------------------------------------------
    // PersonMentionAdapter Class
    // --------------------------------------------------

    private static class ViewHolder extends RecyclerView.ViewHolder {
        public TextView name;
        public ImageView picture;

        public ViewHolder(View itemView) {
            super(itemView);
            name = (TextView) itemView.findViewById(R.id.person_name);
            picture = (ImageView) itemView.findViewById(R.id.person_image);
        }
    }

    private class PersonMentionAdapter extends RecyclerView.Adapter<ViewHolder> {

        private List<? extends Suggestible> suggestions = new ArrayList<>();

        public PersonMentionAdapter(List<? extends Suggestible> people) {
            suggestions = people;
        }

        @Override
        public ViewHolder onCreateViewHolder(ViewGroup viewGroup, int i) {
            View v = LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.grid_mention_item, viewGroup, false);
            return new ViewHolder(v);
        }

        @Override
        public void onBindViewHolder(ViewHolder viewHolder, int i) {
            Suggestible suggestion = suggestions.get(i);
            if (!(suggestion instanceof Person)) {
                return;
            }

            final Person person = (Person) suggestion;
            viewHolder.name.setText(person.getFullName());
            Glide.with(viewHolder.picture.getContext())
                    .load(person.getPictureURL())
                    .crossFade()
                    .into(viewHolder.picture);

            viewHolder.itemView.setOnClickListener(new View.OnClickListener() {
                @Override
                public void onClick(View v) {
                    editor.insertMention(person);
                    recyclerView.swapAdapter(new PersonMentionAdapter(new ArrayList<Person>()), true);
                    displaySuggestions(false);
                    editor.requestFocus();
                }
            });
        }

        @Override
        public int getItemCount() {
            return suggestions.size();
        }
    }

}


/*
* Copyright 2015 LinkedIn Corp. All rights reserved.
*
* 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.
*/

package com.linkedin.android.spyglass.suggestions.impl;

import android.content.Context;
import android.content.res.Resources;
import android.graphics.Color;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.linkedin.android.spyglass.suggestions.SuggestionsResult;
import com.linkedin.android.spyglass.suggestions.interfaces.Suggestible;
import com.linkedin.android.spyglass.suggestions.interfaces.SuggestionsListBuilder;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;

/**
 * Basic implementation of the {@link SuggestionsListBuilder} interface.
 */
public class BasicSuggestionsListBuilder implements SuggestionsListBuilder {

    /**
     * {@inheritDoc}
     */
    @Override
    @NonNull
    public List<Suggestible> buildSuggestions(final @NonNull Map<String, SuggestionsResult> latestResults,
                                              final @NonNull String currentTokenString) {
        List<Suggestible> results = new ArrayList<Suggestible>();
        for (Map.Entry<String, SuggestionsResult> entry : latestResults.entrySet()) {
            SuggestionsResult result = entry.getValue();
            if (currentTokenString.equalsIgnoreCase(result.getQueryToken().getTokenString())) {
                results.addAll(result.getSuggestions());
            }
        }
        return results;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public View getView(final @NonNull Suggestible suggestion, @Nullable View convertView, ViewGroup parent,
                        final @NonNull Context context, final @NonNull LayoutInflater inflater, final @NonNull Resources resources) {
        View view;
        if (convertView == null) {
            view = inflater.inflate(android.R.layout.simple_list_item_1, parent, false);
        } else {
            view = convertView;
        }

        if (view instanceof TextView) {
            TextView text = (TextView) view;
            text.setText(suggestion.getSuggestiblePrimaryText());
            text.setTextColor(Color.BLACK);
            text.setBackgroundColor(Color.WHITE);
        }

        return view;
    }

}


/*
* Copyright 2015 LinkedIn Corp. All rights reserved.
*
* 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.
*/

package com.linkedin.android.spyglass.ui;

import android.annotation.TargetApi;
import android.content.ClipData;
import android.content.ClipDescription;
import android.content.ClipboardManager;
import android.content.Context;
import android.content.Intent;
import android.content.res.TypedArray;
import android.os.Build;
import android.os.Bundle;
import android.os.Parcel;
import android.os.Parcelable;
import android.text.Editable;
import android.text.Layout;
import android.text.Selection;
import android.text.Spannable;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.text.method.ArrowKeyMovementMethod;
import android.text.method.LinkMovementMethod;
import android.text.method.MovementMethod;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.accessibility.AccessibilityEvent;
import android.view.inputmethod.InputMethodManager;
import android.widget.EditText;
import android.widget.TextView;
import androidx.annotation.ColorInt;
import androidx.annotation.IntRange;
import androidx.annotation.MenuRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

import com.linkedin.android.spyglass.R;
import com.linkedin.android.spyglass.mentions.MentionSpan;
import com.linkedin.android.spyglass.mentions.MentionSpanConfig;
import com.linkedin.android.spyglass.mentions.Mentionable;
import com.linkedin.android.spyglass.mentions.MentionsEditable;
import com.linkedin.android.spyglass.suggestions.interfaces.SuggestionsVisibilityManager;
import com.linkedin.android.spyglass.tokenization.QueryToken;
import com.linkedin.android.spyglass.tokenization.interfaces.QueryTokenReceiver;
import com.linkedin.android.spyglass.tokenization.interfaces.TokenSource;
import com.linkedin.android.spyglass.tokenization.interfaces.Tokenizer;

import java.util.ArrayList;
import java.util.List;

/**
 * Class that overrides {@link EditText} in order to have more control over touch events and selection ranges for use in
 * the {@link RichEditorView}.
 *

 * XML attributes
 *

 * See {@link R.styleable#MentionsEditText Attributes}
 *
 * @attr ref R.styleable#MentionsEditText_mentionTextColor
 * @attr ref R.styleable#MentionsEditText_mentionTextBackgroundColor
 * @attr ref R.styleable#MentionsEditText_selectedMentionTextColor
 * @attr ref R.styleable#MentionsEditText_selectedMentionTextBackgroundColor
 */
public class MentionsEditText extends EditText implements TokenSource {

    private static final String KEY_MENTION_SPANS = "mention_spans";
    private static final String KEY_MENTION_SPAN_STARTS = "mention_span_starts";

    private Tokenizer mTokenizer;
    private QueryTokenReceiver mQueryTokenReceiver;
    private SuggestionsVisibilityManager mSuggestionsVisibilityManager;

    private List<MentionWatcher> mMentionWatchers = new ArrayList<>();
    private List<TextWatcher> mExternalTextWatchers = new ArrayList<>();
    private final MyWatcher mInternalTextWatcher = new MyWatcher();
    private boolean mBlockCompletion = false;
    private boolean mIsWatchingText = false;
    private boolean mAvoidPrefixOnTap = false;
    private String mAvoidedPrefix;

    private MentionSpanFactory mentionSpanFactory;
    private MentionSpanConfig mentionSpanConfig;
    private boolean isLongPressed;
    private CheckLongClickRunnable longClickRunnable;

    public MentionsEditText(@NonNull Context context) {
        super(context);
        init(null, 0);
    }

    public MentionsEditText(@NonNull Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init(attrs, 0);
    }

    public MentionsEditText(@NonNull Context context, @Nullable AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
        init(attrs, defStyle);
    }

    /**
     * Initialization method called by all constructors.
     */
    private void init(@Nullable AttributeSet attrs, int defStyleAttr) {
        // Get the mention span config from custom attributes
        mentionSpanConfig = parseMentionSpanConfigFromAttributes(attrs, defStyleAttr);

        // Must set movement method in order for MentionSpans to be clickable
        setMovementMethod(MentionsMovementMethod.getInstance());

        // Use MentionsEditable instead of default Editable
        setEditableFactory(MentionsEditableFactory.getInstance());

        // Start watching itself for text changes
        addTextChangedListener(mInternalTextWatcher);

        // Use default MentionSpanFactory initially
        mentionSpanFactory = new MentionSpanFactory();
    }

    private MentionSpanConfig parseMentionSpanConfigFromAttributes(@Nullable AttributeSet attrs, int defStyleAttr) {
        final Context context = getContext();
        MentionSpanConfig.Builder builder = new MentionSpanConfig.Builder();
        if (attrs == null) {
            return builder.build();
        }

        TypedArray attributes = context.getTheme().obtainStyledAttributes(attrs, R.styleable.MentionsEditText, defStyleAttr, 0);
        @ColorInt int normalTextColor = attributes.getColor(R.styleable.MentionsEditText_mentionTextColor, -1);
        builder.setMentionTextColor(normalTextColor);
        @ColorInt int normalBgColor = attributes.getColor(R.styleable.MentionsEditText_mentionTextBackgroundColor, -1);
        builder.setMentionTextBackgroundColor(normalBgColor);
        @ColorInt int selectedTextColor = attributes.getColor(R.styleable.MentionsEditText_selectedMentionTextColor, -1);
        builder.setSelectedMentionTextColor(selectedTextColor);
        @ColorInt int selectedBgColor = attributes.getColor(R.styleable.MentionsEditText_selectedMentionTextBackgroundColor, -1);
        builder.setSelectedMentionTextBackgroundColor(selectedBgColor);

        attributes.recycle();

        return builder.build();
    }

    // --------------------------------------------------
    // TokenSource Interface Implementation
    // --------------------------------------------------

    /**
     * {@inheritDoc}
     */
    @Override
    @NonNull
    public String getCurrentTokenString() {
        // Get the text and ensure a valid tokenizer is set
        Editable text = getText();
        if (mTokenizer == null || text == null) {
            return "";
        }

        // Use current text to determine token string
        int cursor = Math.max(getSelectionStart(), 0);
        int start = mTokenizer.findTokenStart(text, cursor);
        int end = mTokenizer.findTokenEnd(text, cursor);
        String contentString = text.toString();
        return TextUtils.isEmpty(contentString) ? "" : contentString.substring(start, end);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    @Nullable
    public QueryToken getQueryTokenIfValid() {
        if (mTokenizer == null) {
            return null;
        }

        // Use current text to determine the start and end index of the token
        MentionsEditable text = getMentionsText();
        int cursor = Math.max(getSelectionStart(), 0);
        int start = mTokenizer.findTokenStart(text, cursor);
        int end = mTokenizer.findTokenEnd(text, cursor);

        if (!mTokenizer.isValidMention(text, start, end)) {
            return null;
        }

        String tokenString = text.subSequence(start, end).toString();
        char firstChar = tokenString.charAt(0);
        boolean isExplicit = mTokenizer.isExplicitChar(tokenString.charAt(0));
        return (isExplicit ? new QueryToken(tokenString, firstChar) : new QueryToken(tokenString));
    }

    // --------------------------------------------------
    // Touch Event Methods
    // --------------------------------------------------

    /**
     * Called whenever the user touches this {@link EditText}. This was one of the primary reasons for overriding
     * EditText in this library. This method ensures that when a user taps on a {@link MentionSpan} in this EditText,
     * {@link MentionSpan#onClick(View)} is called before the onClick method of this {@link EditText}.
     *
     * @param event the given {@link MotionEvent}
     *
     * @return true if the {@link MotionEvent} has been handled
     */
    @Override
    public boolean onTouchEvent(@NonNull MotionEvent event) {
        final MentionSpan touchedSpan = getTouchedSpan(event);

        // Android 6 occasionally throws a NullPointerException inside Editor.onTouchEvent()
        // for ACTION_UP when attempting to display (uninitialised) text handles.
        boolean superResult;
        if (android.os.Build.VERSION.SDK_INT == Build.VERSION_CODES.M &&
            event.getActionMasked() == MotionEvent.ACTION_UP) {
            try {
                superResult = super.onTouchEvent(event);
            } catch (NullPointerException ignored) {
                // Ignore this (see above) - since we're now in an unknown state let's clear all
                // selection (which is still better than an arbitrary crash that we can't control):
                clearFocus();
                superResult = true;
            }
        } else {
            superResult = super.onTouchEvent(event);
        }

        if (event.getAction() == MotionEvent.ACTION_UP) {
            // Don't call the onclick on mention if MotionEvent.ACTION_UP is for long click action,
            if (!isLongPressed && touchedSpan != null) {
                // Manually click span and show soft keyboard
                touchedSpan.onClick(this);
                Context context = getContext();
                if (context != null) {
                    InputMethodManager imm = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE);
                    imm.showSoftInput(this, 0);
                }
                return true;
            }
        } else if (event.getAction() == MotionEvent.ACTION_DOWN) {
            isLongPressed = false;
            if (isLongClickable() && touchedSpan != null) {
                if (longClickRunnable == null) {
                    longClickRunnable = new CheckLongClickRunnable();
                }
                longClickRunnable.touchedSpan = touchedSpan;
                removeCallbacks(longClickRunnable);
                postDelayed(longClickRunnable, ViewConfiguration.getLongPressTimeout());
            }
        } else if (event.getAction() == MotionEvent.ACTION_CANCEL) {
            isLongPressed = false;
        }

        // Check if user clicked on the EditText while showing the suggestions list
        // If so, avoid the current prefix
        if (mAvoidPrefixOnTap
                && mSuggestionsVisibilityManager != null
                && mSuggestionsVisibilityManager.isDisplayingSuggestions()) {
            mSuggestionsVisibilityManager.displaySuggestions(false);
            String keywords = getCurrentKeywordsString();
            String[] words = keywords.split(" ");
            if (words.length > 0) {
                String prefix = words[words.length - 1];
                // Note that prefix == "" when user types an explicit character and taps the EditText
                // We must not allow the user to avoid suggestions for the empty string prefix
                // Otherwise, explicit mentions would be broken, see MOB-38080
                if (prefix.length() > 0) {
                    setAvoidedPrefix(prefix);
                }
            }
        }
        return superResult;
    }

    @Override
    public boolean onTextContextMenuItem(@MenuRes int id) {
        MentionsEditable text = getMentionsText();
        int min = Math.max(0, getSelectionStart());
        int selectionEnd = getSelectionEnd();
        int max = selectionEnd >= 0 ? selectionEnd : text.length();
        // Ensuring that min is always less than or equal to max.
        min = Math.min(min, max);
        switch (id) {
            case android.R.id.cut:
                // First copy the span and then remove it from the current EditText
                copy(min, max);
                MentionSpan[] span = text.getSpans(min, max, MentionSpan.class);
                for (MentionSpan mentionSpan : span) {
                    text.removeSpan(mentionSpan);
                }
                text.delete(min, max);
                return true;
            case android.R.id.copy:
                copy(min, max);
                return true;
            case android.R.id.paste:
                paste(min, max);
                return true;
            default:
                return super.onTextContextMenuItem(id);
        }
    }

    /**
     * Copy the text between start and end in clipboard.
     * If no span is present, text is saved as plain text but if span is present
     * save it in Clipboard using intent.
     */
    private void copy(@IntRange(from = 0) int start, @IntRange(from = 0) int end) {
        MentionsEditable text = getMentionsText();
        SpannableStringBuilder copiedText = (SpannableStringBuilder) text.subSequence(start, end);
        MentionSpan[] spans = text.getSpans(start, end, MentionSpan.class);
        Intent intent = null;
        if (spans.length > 0) {
            // Save MentionSpan and it's start offset.
            intent = new Intent();
            intent.putExtra(KEY_MENTION_SPANS, spans);
            int[] spanStart = new int[spans.length];
            for (int i = 0; i < spans.length; i++) {
                spanStart[i] = copiedText.getSpanStart(spans[i]);
            }
            intent.putExtra(KEY_MENTION_SPAN_STARTS, spanStart);
        }
        saveToClipboard(copiedText, intent);
    }

    /**
     * Paste clipboard content between min and max positions.
     */
    private void paste(@IntRange(from = 0) int min, @IntRange(from = 0) int max) {
        if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.HONEYCOMB) {
            android.text.ClipboardManager clipboard = (android.text.ClipboardManager) getContext().getSystemService(Context.CLIPBOARD_SERVICE);
            MentionsEditable text = getMentionsText();
            text.replace(text.length(), text.length(), clipboard.getText());
        } else {
            pasteHoneycombImpl(min, max);
        }
    }

    /**
     * Paste clipboard content between min and max positions. This method is supported for all the api above the 10.
     * If clipboard content contain the MentionSpan, set the span in copied text.
     */
    @TargetApi(Build.VERSION_CODES.HONEYCOMB)
    private void pasteHoneycombImpl(@IntRange(from = 0) int min, @IntRange(from = 0) int max) {
        ClipboardManager clipboard = (ClipboardManager) getContext().getSystemService(Context.CLIPBOARD_SERVICE);
        ClipData clip = clipboard.getPrimaryClip();
        if (clip != null) {
            for (int i = 0; i < clip.getItemCount(); i++) {
                ClipData.Item item = clip.getItemAt(i);
                String selectedText = item.coerceToText(getContext()).toString();
                MentionsEditable text = getMentionsText();
                MentionSpan[] spans = text.getSpans(min, max, MentionSpan.class);
                /*
                 * We need to remove the span between min and max. This is required because in
                 * {@link SpannableStringBuilder#replace(int, int, CharSequence)} existing spans within
                 * the Editable that entirely cover the replaced range are retained, but any that
                 * were strictly within the range that was replaced are removed. In our case the existing
                 * spans are retained if the selection entirely covers the span. So, we just remove
                 * the existing span and replace the new text with that span.
                 */
                for (MentionSpan span : spans) {
                    if (text.getSpanEnd(span) == min) {
                        // We do not want to remove the span, when we want to paste anything just next
                        // to the existing span. In this case "text.getSpanEnd(span)" will be equal
                        // to min.
                        continue;
                    }
                    text.removeSpan(span);
                }

                Intent intent = item.getIntent();
                // Just set the plain text if we do not have mentions data in the intent/bundle
                if (intent == null) {
                    text.replace(min, max, selectedText);
                    continue;
                }
                Bundle bundle = intent.getExtras();
                if (bundle == null) {
                    text.replace(min, max, selectedText);
                    continue;
                }
                bundle.setClassLoader(getContext().getClassLoader());
                int[] spanStart = bundle.getIntArray(KEY_MENTION_SPAN_STARTS);
                Parcelable[] parcelables = bundle.getParcelableArray(KEY_MENTION_SPANS);
                if (parcelables == null || parcelables.length <= 0 || spanStart == null || spanStart.length <= 0) {
                    text.replace(min, max, selectedText);
                    continue;
                }

                // Set the MentionSpan in text.
                SpannableStringBuilder s = new SpannableStringBuilder(selectedText);
                for (int j = 0; j < parcelables.length; j++) {
                    MentionSpan mentionSpan = (MentionSpan) parcelables[j];
                    s.setSpan(mentionSpan, spanStart[j], spanStart[j] + mentionSpan.getDisplayString().length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
                }
                text.replace(min, max, s);
            }
        }
    }

    /**
     * Save the selected text and intent in ClipboardManager
     */
    private void saveToClipboard(@NonNull CharSequence selectedText, @Nullable Intent intent) {
        if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.HONEYCOMB) {
            android.text.ClipboardManager clipboard = (android.text.ClipboardManager) getContext().getSystemService(Context.CLIPBOARD_SERVICE);
            clipboard.setText(selectedText);
        } else {
            android.content.ClipboardManager clipboard = (android.content.ClipboardManager) getContext().getSystemService(Context.CLIPBOARD_SERVICE);
            ClipData.Item item = new ClipData.Item(selectedText, intent, null);
            android.content.ClipData clip = new ClipData(null, new String[]{ClipDescription.MIMETYPE_TEXT_PLAIN}, item);
            clipboard.setPrimaryClip(clip);
        }
    }

    /**
     * Gets the {@link MentionSpan} from the {@link MentionsEditText} that was tapped.
     *

     * Note: Almost all of this code is taken directly from the Android source code, see:
     * {@link LinkMovementMethod#onTouchEvent(TextView, Spannable, MotionEvent)}
     *
     * @param event the given (@link MotionEvent}
     *
     * @return the tapped {@link MentionSpan}, or null if one was not tapped
     */
    @Nullable
    protected MentionSpan getTouchedSpan(MotionEvent event) {
        Layout layout = getLayout();
        // Note: Layout can be null if text or width has recently changed, see MOB-38193
        if (event == null || layout == null) {
            return null;
        }

        int x = (int) event.getX();
        int y = (int) event.getY();

        x -= getTotalPaddingLeft();
        y -= getTotalPaddingTop();

        x += getScrollX();
        y += getScrollY();

        int line = layout.getLineForVertical(y);
        int off = layout.getOffsetForHorizontal(line, x);
        Editable text = getText();
        if (text != null && off >= getText().length()) {
            return null;
        }

        // Get the MentionSpans in the area that the user tapped
        // If one exists, call the onClick method manually
        MentionSpan[] spans = getText().getSpans(off, off, MentionSpan.class);
        if (spans.length > 0) {
            return spans[0];
        }
        return null;
    }

    // --------------------------------------------------
    // Cursor & Selection Event Methods
    // --------------------------------------------------

    /**
     * Called whenever the selection within the {@link EditText} has changed.
     *
     * @param selStart starting position of the selection
     * @param selEnd   ending position of the selection
     */
    @Override
    protected void onSelectionChanged(final int selStart, final int selEnd) {
        // Handle case where there is only one cursor (i.e. not selecting a range, just moving cursor)
        if (selStart == selEnd) {
            if (!onCursorChanged(selStart)) {
                super.onSelectionChanged(selStart, selEnd);
            }
            return;
        } else {
            updateSelectionIfRequired(selStart, selEnd);
        }
        super.onSelectionChanged(selStart, selEnd);
    }

    /**
     * Don't allow user to set starting position or ending position of selection within the mention.
     */
    private void updateSelectionIfRequired(final int selStart, final int selEnd) {
        MentionsEditable text = getMentionsText();
        MentionSpan startMentionSpan = text.getMentionSpanAtOffset(selStart);
        MentionSpan endMentionSpan = text.getMentionSpanAtOffset(selEnd);
        boolean selChanged = false;
        int start = selStart;
        int end = selEnd;
        if (text.getSpanStart(startMentionSpan) < selStart && selStart < text.getSpanEnd(startMentionSpan)) {
            start = text.getSpanStart(startMentionSpan);
            selChanged = true;
        }
        if (text.getSpanStart(endMentionSpan) < selEnd && selEnd < text.getSpanEnd(endMentionSpan)) {
            end = text.getSpanEnd(endMentionSpan);
            selChanged = true;
        }
        if (selChanged) {
            setSelection(start, end);
        }
    }

    /**
     * Method to handle the cursor changing positions. Returns true if handled, false if it should
     * be passed to the super method.
     *
     * @param index int position of cursor within the text
     *
     * @return true if handled
     */
    private boolean onCursorChanged(final int index) {
        Editable text = getText();
        if (text == null) {
            return false;
        }

        MentionSpan[] allSpans = text.getSpans(0, text.length(), MentionSpan.class);
        for (MentionSpan span : allSpans) {
            // Deselect span if the cursor is not on the span.
            if (span.isSelected() && (index < text.getSpanStart(span) || index > text.getSpanEnd(span))) {
                span.setSelected(false);
                updateSpan(span);
            }
        }

        // Do not allow the user to set the cursor within a span. If the user tries to do so, select
        // move the cursor to the end of it.
        MentionSpan[] currentSpans = text.getSpans(index, index, MentionSpan.class);
        if (currentSpans.length != 0) {
            MentionSpan span = currentSpans[0];
            int start = text.getSpanStart(span);
            int end = text.getSpanEnd(span);
            if (index > start && index < end) {
                super.setSelection(end);
                return true;
            }
        }

        return false;
    }

    // --------------------------------------------------
    // TextWatcher Implementation
    // --------------------------------------------------

    private class MyWatcher implements TextWatcher {

        /**
         * {@inheritDoc}
         */
        @Override
        public void beforeTextChanged(CharSequence text, int start, int before, int after) {
            if (mBlockCompletion) {
                return;
            }

            // Mark a span for deletion later if necessary
            boolean changed = markSpans(before, after);

            // If necessary, temporarily remove any MentionSpans that could potentially interfere with composing text
            if (!changed) {
                replaceMentionSpansWithPlaceholdersAsNecessary(text);
            }

            // Call any watchers for text changes
            sendBeforeTextChanged(text, start, before, after);
        }

        /**
         * {@inheritDoc}
         */
        @Override
        public void onTextChanged(CharSequence text, int start, int before, int count) {
            if (mBlockCompletion || !(text instanceof Editable) || getTokenizer() == null) {
                return;
            }

            // If the editor tries to insert duplicated text, mark the duplicated text for deletion later
            Editable editable = (Editable) text;
            int index = Selection.getSelectionStart(editable);
            Tokenizer tokenizer = getTokenizer();
            if (tokenizer != null) {
                markDuplicatedTextForDeletionLater((Editable) text, index, tokenizer);
            }

            // Call any watchers for text changes
            sendOnTextChanged(text, start, before, count);
        }

        /**
         * {@inheritDoc}
         */
        @Override
        public void afterTextChanged(Editable text) {
            if (mBlockCompletion || text == null) {
                return;
            }

            // Block text change handling while we're changing the text (otherwise, may cause infinite loop)
            mBlockCompletion = true;

            // Text may have been marked to be removed in (before/on)TextChanged, remove that text now
            removeTextWithinDeleteSpans(text);

            // Some mentions may have been replaced by placeholders temporarily when altering the text, reinsert the
            // mention spans now
            replacePlaceholdersWithCorrespondingMentionSpans(text);

            // Ensure that the text in all the MentionSpans remains unchanged and valid
            ensureMentionSpanIntegrity(text);

            // Handle the change in text (can modify it freely here)
            handleTextChanged();

            // Allow class to listen for changes to the text again
            mBlockCompletion = false;

            // Call any watchers for text changes after we have handled it
            sendAfterTextChanged(text);
        }
    }

    /**
     * Marks a span for deletion later if necessary by checking if the last character in a MentionSpan
     * is deleted by this change. If so, mark the span to be deleted later when
     * {@link #ensureMentionSpanIntegrity(Editable)} is called in {@link #handleTextChanged()}.
     *
     * @param count length of affected text before change starting at start in text
     * @param after length of affected text after change
     *
     * @return  true if there is a span before the cursor that is going to change state
     */
    private boolean markSpans(int count, int after) {
        int cursor = getSelectionStart();
        MentionsEditable text = getMentionsText();
        MentionSpan prevSpan = text.getMentionSpanEndingAt(cursor);

        if (count == (after + 1) && prevSpan != null) {

            // Cursor was directly behind a span and was moved back one, so delete it if selected,
            // or select it if not already selected
            if (prevSpan.isSelected()) {
                Mentionable mention = prevSpan.getMention();
                Mentionable.MentionDeleteStyle deleteStyle = mention.getDeleteStyle();
                Mentionable.MentionDisplayMode displayMode = prevSpan.getDisplayMode();
                // Determine new DisplayMode given previous DisplayMode and MentionDeleteStyle
                if (deleteStyle == Mentionable.MentionDeleteStyle.PARTIAL_NAME_DELETE
                        && displayMode == Mentionable.MentionDisplayMode.FULL) {
                    prevSpan.setDisplayMode(Mentionable.MentionDisplayMode.PARTIAL);
                } else {
                    prevSpan.setDisplayMode(Mentionable.MentionDisplayMode.NONE);
                }
            } else {
                // Span was not selected, so select it
                prevSpan.setSelected(true);
            }

            return true;
        }

        return false;
    }

    /**
     * Temporarily remove MentionSpans that may interfere with composing text. Note that software keyboards are allowed
     * to place arbitrary spans over the text. This was resulting in several bugs in edge cases while handling the
     * MentionSpans while composing text (with different issues for different keyboards). The easiest solution for this
     * is to remove any MentionSpans that could cause issues while the user is changing text.
     *
     * Note: The MentionSpans are added again in {@link #replacePlaceholdersWithCorrespondingMentionSpans(Editable)}
     *
     * @param text the current text before it changes
     */
    private void replaceMentionSpansWithPlaceholdersAsNecessary(@NonNull CharSequence text) {
        int index = getSelectionStart();
        int wordStart = findStartOfWord(text, index);
        Editable editable = getText();
        MentionSpan[] mentionSpansInCurrentWord = editable.getSpans(wordStart, index, MentionSpan.class);
        for (MentionSpan span : mentionSpansInCurrentWord) {
            if (span.getDisplayMode() != Mentionable.MentionDisplayMode.NONE) {
                int spanStart = editable.getSpanStart(span);
                int spanEnd = editable.getSpanEnd(span);
                editable.setSpan(new PlaceholderSpan(span, spanStart, spanEnd),
                        spanStart, spanEnd, Spanned.SPAN_EXCLUSIVE_INCLUSIVE);
                editable.removeSpan(span);
            }
        }
    }

    /**
     * Helper utility to determine the beginning of a word using the current tokenizer.
     *
     * @param text  the text to examine
     * @param index index of the cursor in the text
     * @return  index of the beginning of the word in text (will be less than or equal to index)
     */
    private int findStartOfWord(@NonNull CharSequence text, int index) {
        int wordStart = index;
        while (wordStart > 0 && mTokenizer != null && !mTokenizer.isWordBreakingChar(text.charAt(wordStart - 1))) {
            wordStart--;
        }
        return wordStart;
    }

    /**
     * Mark text that was duplicated during text composition to delete it later.
     *
     * @param text          the given text
     * @param cursor        the index of the cursor in text
     * @param tokenizer     the {@link Tokenizer} to use
     */
    private void markDuplicatedTextForDeletionLater(@NonNull Editable text, int cursor, @NonNull Tokenizer tokenizer) {
        while (cursor > 0 && tokenizer.isWordBreakingChar(text.charAt(cursor - 1))) {
            cursor--;
        }
        int wordStart = findStartOfWord(text, cursor);
        PlaceholderSpan[] placeholderSpans = text.getSpans(wordStart, wordStart + 1, PlaceholderSpan.class);
        for (PlaceholderSpan span : placeholderSpans) {
            int spanEnd = span.originalEnd;
            int copyEnd = spanEnd + (spanEnd - wordStart);
            if (copyEnd > spanEnd && copyEnd <= text.length()) {
                CharSequence endOfMention = text.subSequence(wordStart, spanEnd);
                CharSequence copyOfEndOfMentionText = text.subSequence(spanEnd, copyEnd);
                // Note: Comparing strings since we do not want to compare any other aspects of spanned strings
                if (endOfMention.toString().equals(copyOfEndOfMentionText.toString())) {
                    text.setSpan(new DeleteSpan(),
                            spanEnd,
                            copyEnd,
                            Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
                }
            }
        }
    }

    /**
     * Removes any {@link com.linkedin.android.spyglass.ui.MentionsEditText.DeleteSpan}s and the text within them from
     * the given text.
     *
     * @param text the editable containing DeleteSpans to remove
     */
    private void removeTextWithinDeleteSpans(@NonNull Editable text) {
        DeleteSpan[] deleteSpans = text.getSpans(0, text.length(), DeleteSpan.class);
        for (DeleteSpan span : deleteSpans) {
            int spanStart = text.getSpanStart(span);
            int spanEnd = text.getSpanEnd(span);
            text.replace(spanStart, spanEnd, "");
            text.removeSpan(span);
        }
    }

    /**
     * Replaces any {@link com.linkedin.android.spyglass.ui.MentionsEditText.PlaceholderSpan} within the given text with
     * the {@link MentionSpan} it contains.
     *
     * Note: These PlaceholderSpans are added in {@link #replaceMentionSpansWithPlaceholdersAsNecessary(CharSequence)}
     *
     * @param text the final version of the text after it was changed
     */
    private void replacePlaceholdersWithCorrespondingMentionSpans(@NonNull Editable text) {
        PlaceholderSpan[] tempSpans = text.getSpans(0, text.length(), PlaceholderSpan.class);
        for (PlaceholderSpan span : tempSpans) {
            int spanStart = text.getSpanStart(span);
            String mentionDisplayString = span.holder.getDisplayString();
            int end = Math.min(spanStart + mentionDisplayString.length(), text.length());
            text.replace(spanStart, end, mentionDisplayString);
            text.setSpan(span.holder, spanStart, spanStart + mentionDisplayString.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
            text.removeSpan(span);
        }
    }

    /**
     * Ensures that the text within each {@link MentionSpan} in the {@link Editable} correctly
     * matches what it should be outputting. If not, replace it with the correct value.
     *
     * @param text the {@link Editable} to examine
     */
    private void ensureMentionSpanIntegrity(Editable text) {
        if (text == null) {
            return;
        }

        MentionSpan[] spans = text.getSpans(0, text.length(), MentionSpan.class);
        boolean spanAltered = false;
        for (MentionSpan span : spans) {
            int start = text.getSpanStart(span);
            int end = text.getSpanEnd(span);
            CharSequence spanText = text.subSequence(start, end).toString();
            Mentionable.MentionDisplayMode displayMode = span.getDisplayMode();

            switch (displayMode) {

                case PARTIAL:
                case FULL:
                    String name = span.getDisplayString();
                    if (!name.equals(spanText) && start >= 0 && start < end && end <= text.length()) {
                        // Mention display name does not match what is being shown,
                        // replace text in span with proper display name
                        int cursor = getSelectionStart();
                        int diff = cursor - end;
                        text.removeSpan(span);
                        text.replace(start, end, name);
                        if (diff > 0 && start + end + diff < text.length()) {
                            text.replace(start + end, start + end + diff, "");
                        }
                        if (name.length() > 0) {
                            text.setSpan(span, start, start + name.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
                        }
                        // Notify for partially deleted mentions.
                        if (mMentionWatchers.size() > 0 && displayMode == Mentionable.MentionDisplayMode.PARTIAL) {
                            notifyMentionPartiallyDeletedWatchers(span.getMention(), name, start, end);
                        }
                        spanAltered = true;
                    }
                    break;

                case NONE:
                default:
                    // Mention with DisplayMode == NONE should be deleted from the text
                    boolean hasListeners = mMentionWatchers.size() > 0;
                    final String textBeforeDelete = hasListeners ? text.toString() : null;
                    text.delete(start, end);
                    setSelection(start);
                    if (hasListeners) {
                        notifyMentionDeletedWatchers(span.getMention(), textBeforeDelete, start, end);
                    }
                    spanAltered = true;
                    break;
            }
        }

        // Reset input method if spans have been changed (updates suggestions)
        if (spanAltered) {
            restartInput();
        }
    }

    /**
     * Called after the {@link Editable} text within the {@link EditText} has been changed. Note that
     * editing text in this function is guaranteed to be safe and not cause an infinite loop.
     */
    private void handleTextChanged() {
        // Ignore requests if the last word in keywords is prefixed by the currently avoided prefix
        if (mAvoidedPrefix != null) {
            String[] keywords = getCurrentKeywordsString().split(" ");
            // Add null and length check to avoid the ArrayIndexOutOfBoundsException
            if (keywords.length == 0) {
                return;
            }
            String lastKeyword = keywords[keywords.length - 1];
            if (lastKeyword.startsWith(mAvoidedPrefix)) {
                return;
            } else {
                setAvoidedPrefix(null);
            }
        }

        // Request suggestions from the QueryClient
        QueryToken queryToken = getQueryTokenIfValid();
        if (queryToken != null && mQueryTokenReceiver != null) {
            // Valid token, so send query to the app for processing
            mQueryTokenReceiver.onQueryReceived(queryToken);
        } else {
            // Ensure that the suggestions are hidden
            if (mSuggestionsVisibilityManager != null) {
                mSuggestionsVisibilityManager.displaySuggestions(false);
            }
        }
    }

    // --------------------------------------------------
    // Public Methods
    // --------------------------------------------------

    /**
     * Gets the keywords that the {@link Tokenizer} is currently considering for mention suggestions. Note that this is
     * the keywords string and will not include any explicit character, if present.
     *
     * @return a String representing current keywords in the {@link EditText}
     */
    @NonNull
    public String getCurrentKeywordsString() {
        String keywordsString = getCurrentTokenString();
        if (keywordsString.length() > 0 && mTokenizer.isExplicitChar(keywordsString.charAt(0))) {
            keywordsString = keywordsString.substring(1);
        }
        return keywordsString;
    }

    /**
     * Resets the given {@link MentionSpan} in the editor, forcing it to redraw with its latest drawable state.
     *
     * @param span the {@link MentionSpan} to update
     */
    public void updateSpan(MentionSpan span) {
        mBlockCompletion = true;
        Editable text = getText();
        int start = text.getSpanStart(span);
        int end = text.getSpanEnd(span);
        if (start >= 0 && end > start && end <= text.length()) {
            text.removeSpan(span);
            text.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
        }
        mBlockCompletion = false;
    }

    /**
     * Deselects any spans in the editor that are currently selected.
     */
    public void deselectAllSpans() {
        mBlockCompletion = true;
        Editable text = getText();
        MentionSpan[] spans = text.getSpans(0, text.length(), MentionSpan.class);
        for (MentionSpan span : spans) {
            if (span.isSelected()) {
                span.setSelected(false);
                updateSpan(span);
            }
        }
        mBlockCompletion = false;
    }

    /**
     * Inserts a mention into the token being considered currently.
     *
     * @param mention {@link Mentionable} to insert a span for
     */
    public void insertMention(@NonNull Mentionable mention) {
        if (mTokenizer == null) {
            return;
        }

        // Setup variables and ensure they are valid
        Editable text = getEditableText();
        int cursor = getSelectionStart();
        int start = mTokenizer.findTokenStart(text, cursor);
        int end = mTokenizer.findTokenEnd(text, cursor);
        if (start < 0 || start >= end || end > text.length()) {
            return;
        }

        insertMentionInternal(mention, text, start, end);
    }

    /**
     * Inserts a mention. This will not take any token into consideration. This method is useful
     * when you want to insert a mention which doesn't have a token.
     *
     * @param mention {@link Mentionable} to insert a span for
     */
    public void insertMentionWithoutToken(@NonNull Mentionable mention) {
        // Setup variables and ensure they are valid
        Editable text = getEditableText();
        int index = getSelectionStart();
        index = index > 0 ? index : 0;

        insertMentionInternal(mention, text, index, index);
    }

    private void insertMentionInternal(@NonNull Mentionable mention, @NonNull Editable text, int start, int end) {
        // Insert the span into the editor
        MentionSpan mentionSpan = mentionSpanFactory.createMentionSpan(mention, mentionSpanConfig);
        String name = mention.getSuggestiblePrimaryText();

        mBlockCompletion = true;
        text.replace(start, end, name);
        int endOfMention = start + name.length();
        text.setSpan(mentionSpan, start, endOfMention, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
        Selection.setSelection(text, endOfMention);
        ensureMentionSpanIntegrity(text);
        mBlockCompletion = false;

        // Notify listeners of added mention
        if (mMentionWatchers.size() > 0) {
            notifyMentionAddedWatchers(mention, text.toString(), start, endOfMention);
        }

        // Hide the suggestions and clear adapter
        if (mSuggestionsVisibilityManager != null) {
            mSuggestionsVisibilityManager.displaySuggestions(false);
        }

        // Reset input method since text has been changed (updates mention draw states)
        restartInput();
    }

    /**
     * Determines if the {@link Tokenizer} is looking at an explicit token right now.
     *
     * @return true if the {@link Tokenizer} is currently considering an explicit query
     */
    public boolean isCurrentlyExplicit() {
        String tokenString = getCurrentTokenString();
        return tokenString.length() > 0 && mTokenizer != null && mTokenizer.isExplicitChar(tokenString.charAt(0));
    }

    /**
     * Populate an {@link AccessibilityEvent} with information about this text view. Note that this implementation uses
     * a copy of the text that is explicitly not an instance of {@link MentionsEditable}. This is due to the fact that
     * AccessibilityEvent will use the default system classloader when unparcelling the data within the event. This
     * results in a ClassNotFoundException. For more details, see: https://github.com/linkedin/Spyglass/issues/10
     *
     * @param event the populated AccessibilityEvent
     */
    @Override
    public void onPopulateAccessibilityEvent(@NonNull AccessibilityEvent event) {
        super.onPopulateAccessibilityEvent(event);
        List<CharSequence> textList = event.getText();
        CharSequence mentionLessText = getTextWithoutMentions();
        for (int i = 0; i < textList.size(); i++) {
            CharSequence text = textList.get(i);
            if (text instanceof MentionsEditable) {
                textList.set(i, mentionLessText);
            }
        }
    }

    /**
     * Allows a class to watch for text changes. Note that adding this class to itself will add it
     * to the super class. Other instances of {@link TextWatcher} will be notified by this class
     * as appropriate (helps prevent infinite loops when text keeps changing).
     *
     * @param watcher the {@link TextWatcher} to add
     */
    @Override
    public void addTextChangedListener(TextWatcher watcher) {
        if (watcher == mInternalTextWatcher) {
            if (!mIsWatchingText) {
                super.addTextChangedListener(mInternalTextWatcher);
                mIsWatchingText = true;
            }
        } else {
            mExternalTextWatchers.add(watcher);
        }
    }

    /**
     * Removes a {@link TextWatcher} from this class. Note that this function servers as the
     * counterpart to {@link #addTextChangedListener(TextWatcher)}).
     *
     * @param watcher the {@link TextWatcher} to remove
     */
    @Override
    public void removeTextChangedListener(TextWatcher watcher) {
        if (watcher == mInternalTextWatcher) {
            if (mIsWatchingText) {
                super.removeTextChangedListener(mInternalTextWatcher);
                mIsWatchingText = false;
            }
        } else {
            // Other watchers are added
            mExternalTextWatchers.remove(watcher);
        }
    }

    /**
     * Register a {@link com.linkedin.android.spyglass.ui.MentionsEditText.MentionWatcher} in order to receive callbacks
     * when mentions are changed.
     *
     * @param watcher the {@link com.linkedin.android.spyglass.ui.MentionsEditText.MentionWatcher} to add
     */
    @SuppressWarnings("unused")
    public void addMentionWatcher(@NonNull MentionWatcher watcher) {
        if (!mMentionWatchers.contains(watcher)) {
            mMentionWatchers.add(watcher);
        }
    }

    /**
     * Remove a {@link com.linkedin.android.spyglass.ui.MentionsEditText.MentionWatcher} from receiving anymore callbacks
     * when mentions are changed.
     *
     * @param watcher the {@link com.linkedin.android.spyglass.ui.MentionsEditText.MentionWatcher} to remove
     */
    @SuppressWarnings("unused")
    public void removeMentionWatcher(@NonNull MentionWatcher watcher) {
        mMentionWatchers.remove(watcher);
    }

    // --------------------------------------------------
    // Private Helper Methods
    // --------------------------------------------------

    private void restartInput() {
        InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
        if (imm != null) {
            imm.restartInput(this);
        }
    }

    /**
     * @return the text as an {@link Editable} (note: not a {@link MentionsEditable} and does not contain mentions)
     */
    private Editable getTextWithoutMentions() {
        Editable text = getText();
        SpannableStringBuilder sb = new SpannableStringBuilder(text);
        MentionSpan[] spans = sb.getSpans(0, sb.length(), MentionSpan.class);
        for (MentionSpan span: spans) {
            sb.removeSpan(span);
        }
        return sb;
    }

    /**
     * Notify external text watchers that the text is about to change.
     * See {@link TextWatcher#beforeTextChanged(CharSequence, int, int, int)}.
     */
    private void sendBeforeTextChanged(CharSequence text, int start, int before, int after) {
        final List<TextWatcher> list = mExternalTextWatchers;
        final int count = list.size();
        for (int i = 0; i < count; i++) {
            TextWatcher watcher = list.get(i);
            if (watcher != this) {
                watcher.beforeTextChanged(text, start, before, after);
            }
        }
    }

    /**
     * Notify external text watchers that the text is changing.
     * See {@link TextWatcher#onTextChanged(CharSequence, int, int, int)}.
     */
    private void sendOnTextChanged(CharSequence text, int start, int before, int after) {
        final List<TextWatcher> list = mExternalTextWatchers;
        final int count = list.size();
        for (int i = 0; i < count; i++) {
            TextWatcher watcher = list.get(i);
            if (watcher != this) {
                watcher.onTextChanged(text, start, before, after);
            }
        }
    }

    /**
     * Notify external text watchers that the text has changed.
     * See {@link TextWatcher#afterTextChanged(Editable)}.
     */
    private void sendAfterTextChanged(Editable text) {
        final List<TextWatcher> list = mExternalTextWatchers;
        final int count = list.size();
        for (int i = 0; i < count; i++) {
            TextWatcher watcher = list.get(i);
            if (watcher != this) {
                watcher.afterTextChanged(text);
            }
        }
    }

    private void notifyMentionAddedWatchers(@NonNull Mentionable mention, @NonNull String text, int start, int end) {
        for (MentionWatcher watcher : mMentionWatchers) {
            watcher.onMentionAdded(mention, text, start, end);
        }
    }

    private void notifyMentionDeletedWatchers(@NonNull Mentionable mention, @NonNull String text, int start, int end) {
        for (MentionWatcher watcher : mMentionWatchers) {
            watcher.onMentionDeleted(mention, text, start, end);
        }
    }

    private void notifyMentionPartiallyDeletedWatchers(@NonNull Mentionable mention, @NonNull String text, int start, int end) {
        for (MentionWatcher watcher : mMentionWatchers) {
            watcher.onMentionPartiallyDeleted(mention, text, start, end);
        }
    }

    // --------------------------------------------------
    // Private Classes
    // --------------------------------------------------

    /**
     * Simple class to hold onto a {@link MentionSpan} temporarily while the text is changing.
     */
    private class PlaceholderSpan {

        public final MentionSpan holder;
        public final int originalStart;
        public final int originalEnd;

        public PlaceholderSpan(MentionSpan holder, int originalStart, int originalEnd) {
            this.holder = holder;
            this.originalStart = originalStart;
            this.originalEnd = originalEnd;
        }
    }

    /**
     * Simple class to mark a span of text to delete later.
     */
    private class DeleteSpan {}

    /**
     * Runnable which detects the long click action.
     */
    private class CheckLongClickRunnable implements Runnable {
        private MentionSpan touchedSpan;

        @Override
        public void run() {
            if (isPressed()) {
                isLongPressed = true;
                if (touchedSpan == null) {
                    return;
                }
                MentionsEditable text = getMentionsText();
                // Set the selection anchor to start and end of the long clicked span and deselect all the span.
                setSelection(text.getSpanStart(touchedSpan), text.getSpanEnd(touchedSpan));
                deselectAllSpans();
            }
        }
    }

    // --------------------------------------------------
    // MentionsEditable Factory
    // --------------------------------------------------

    /**
     * Custom EditableFactory designed so that we can use the customized {@link MentionsEditable} in place of the
     * default {@link Editable}.
     */
    public static class MentionsEditableFactory extends Editable.Factory {

        private static MentionsEditableFactory sInstance = new MentionsEditableFactory();

        @NonNull
        public static MentionsEditableFactory getInstance() {
            return sInstance;
        }

        @Override
        public Editable newEditable(@NonNull CharSequence source) {
            MentionsEditable text = new MentionsEditable(source);
            Selection.setSelection(text, 0);
            return text;
        }
    }

    // --------------------------------------------------
    // MentionSpan Factory
    // --------------------------------------------------

    /**
     * Custom factory used when creating a {@link MentionSpan}.
     */
    public static class MentionSpanFactory {

        @NonNull
        public MentionSpan createMentionSpan(@NonNull Mentionable mention,
                                             @Nullable MentionSpanConfig config) {
            return (config != null) ? new MentionSpan(mention, config) : new MentionSpan(mention);
        }
    }

    // --------------------------------------------------
    // MentionsMovementMethod Class
    // --------------------------------------------------

    /**
     * Custom {@link MovementMethod} for this class used to override specific behavior in {@link ArrowKeyMovementMethod}.
     */
    public static class MentionsMovementMethod extends ArrowKeyMovementMethod {

        private static MentionsMovementMethod sInstance;

        public static MovementMethod getInstance() {
            if (sInstance == null)
                sInstance = new MentionsMovementMethod();

            return sInstance;
        }

        @Override
        public void initialize(TextView widget, Spannable text) {
            // Do nothing. Note that ArrowKeyMovementMethod calls setSelection(0) here, but we would
            // like to override that behavior (otherwise, cursor is set to beginning of EditText when
            // this method is called).
        }

    }

    // --------------------------------------------------
    // Getters & Setters
    // --------------------------------------------------

    /**
     * Convenience method for getting the {@link MentionsEditable} associated with this class.
     *
     * @return the current text as a {@link MentionsEditable} specifically
     */
    @NonNull
    public MentionsEditable getMentionsText() {
        return (MentionsEditable) super.getText();
    }

    /**
     * @return the {@link Tokenizer} in use
     */
    @Nullable
    public Tokenizer getTokenizer() {
        return mTokenizer;
    }

    /**
     * Sets the tokenizer used by this class. The tokenizer determines how {@link QueryToken} objects
     * are generated.
     *
     * @param tokenizer the {@link Tokenizer} to use
     */
    public void setTokenizer(@Nullable final Tokenizer tokenizer) {
        mTokenizer = tokenizer;
    }

    /**
     * Sets the receiver of query tokens used by this class. The query token receiver will use the
     * tokens to generate suggestions, which can then be inserted back into this edit text.
     *
     * @param queryTokenReceiver the {@link QueryTokenReceiver} to use
     */
    public void setQueryTokenReceiver(@Nullable final QueryTokenReceiver queryTokenReceiver) {
        mQueryTokenReceiver = queryTokenReceiver;
    }

    /**
     * Sets the suggestions manager used by this class.
     *
     * @param suggestionsVisibilityManager the {@link SuggestionsVisibilityManager} to use
     */
    public void setSuggestionsVisibilityManager(@Nullable final SuggestionsVisibilityManager suggestionsVisibilityManager) {
        mSuggestionsVisibilityManager = suggestionsVisibilityManager;
    }

    /**
     * Sets the factory used to create MentionSpans within this class.
     *
     * @param factory the {@link MentionSpanFactory} to use
     */
    public void setMentionSpanFactory(@NonNull final MentionSpanFactory factory) {
        mentionSpanFactory = factory;
    }

    /**
     * Sets the configuration options used when creating MentionSpans.
     *
     * @param config the {@link MentionSpanConfig} to use
     */
    public void setMentionSpanConfig(@NonNull final MentionSpanConfig config) {
        mentionSpanConfig = config;
    }

    /**
     * Sets the string prefix to avoid creating and displaying suggestions.
     *
     * @param prefix prefix to avoid suggestions
     */
    public void setAvoidedPrefix(String prefix) {
        mAvoidedPrefix = prefix;
    }

    /**
     * Determines whether the edit text should avoid the current prefix if the user taps on it while
     * it is displaying suggestions (defaults to false).
     *
     * @param avoidPrefixOnTap true if the prefix should be avoided after a tap
     */
    public void setAvoidPrefixOnTap(boolean avoidPrefixOnTap) {
        mAvoidPrefixOnTap = avoidPrefixOnTap;
    }

    // --------------------------------------------------
    // Save & Restore State
    // --------------------------------------------------

    @Override
    public Parcelable onSaveInstanceState() {
        Parcelable parcelable = super.onSaveInstanceState();
        return new SavedState(parcelable, getMentionsText());
    }

    @Override
    public void onRestoreInstanceState(Parcelable state) {
        if (!(state instanceof SavedState)) {
            super.onRestoreInstanceState(state);
            return;
        }

        SavedState savedState = (SavedState) state;
        super.onRestoreInstanceState(savedState.getSuperState());
        setText(savedState.mentionsEditable);
    }

    /**
     * Convenience class to save/restore the MentionsEditable state.
     */
    protected static class SavedState extends BaseSavedState {
        public MentionsEditable mentionsEditable;

        private SavedState(Parcelable superState, MentionsEditable mentionsEditable) {
            super(superState);
            this.mentionsEditable = mentionsEditable;
        }

        private SavedState(Parcel in) {
            super(in);
            mentionsEditable = in.readParcelable(MentionsEditable.class.getClassLoader());
        }

        @Override
        public void writeToParcel(@NonNull Parcel dest, int flags) {
            super.writeToParcel(dest, flags);
            dest.writeParcelable(mentionsEditable, flags);
        }

        public static final Parcelable.Creator<SavedState> CREATOR = new Creator<SavedState>() {

            public SavedState createFromParcel(Parcel in) {
                return new SavedState(in);
            }

            public SavedState[] newArray(int size) {
                return new SavedState[size];
            }
        };
    }

    // --------------------------------------------------
    // MentionWatcher Interface & Simple Implementation
    // --------------------------------------------------

    /**
     * Interface to receive a callback for mention events.
     */
    public interface MentionWatcher {
        /**
         * Callback for when a mention is added.
         *
         * @param mention   the {@link Mentionable} that was added
         * @param text      the text after the mention was added
         * @param start     the starting index of where the mention was added
         * @param end       the ending index of where the mention was added
         */
        void onMentionAdded(@NonNull Mentionable mention, @NonNull String text, int start, int end);

        /**
         * Callback for when a mention is deleted.
         *
         * @param mention   the {@link Mentionable} that was deleted
         * @param text      the text before the mention was deleted
         * @param start     the starting index of where the mention was deleted
         * @param end       the ending index of where the mention was deleted
         */
        void onMentionDeleted(@NonNull Mentionable mention, @NonNull String text, int start, int end);

        /**
         * Callback for when a mention is partially deleted.
         *
         * @param mention   the {@link Mentionable} that was deleted
         * @param text      the text after the mention was partially deleted
         * @param start     the starting index of where the partial mention starts
         * @param end       the ending index of where the partial mention ends
         */
        void onMentionPartiallyDeleted(@NonNull Mentionable mention, @NonNull String text, int start, int end);
    }

    /**
     * Simple implementation of the {@link com.linkedin.android.spyglass.ui.MentionsEditText.MentionWatcher} interface
     * if you do not want to implement all methods.
     */
    @SuppressWarnings("unused")
    public class SimpleMentionWatcher implements MentionWatcher {
        @Override
        public void onMentionAdded(@NonNull Mentionable mention, @NonNull String text, int start, int end) {}

        @Override
        public void onMentionDeleted(@NonNull Mentionable mention, @NonNull String text, int start, int end) {}

        @Override
        public void onMentionPartiallyDeleted(@NonNull Mentionable mention, @NonNull String text, int start, int end) {}
    }
}