/*
 * Copyright (C) 2015-2018 Emanuel Moecklin
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.onegravity.rteditor.media;

import android.app.Activity;
import android.app.ProgressDialog;
import android.os.Bundle;
import android.os.Handler;
import android.support.v7.app.AppCompatActivity;

import com.onegravity.rteditor.R;
import com.onegravity.rteditor.api.RTApi;

import java.util.ArrayList;

public class MonitoredActivity extends AppCompatActivity {

    protected Handler mHandler;

    private final ArrayList<LifeCycleListener> mListeners = new ArrayList<LifeCycleListener>();

    // ****************************************** MonitoredActivity Methods *******************************************

    public static interface LifeCycleListener {
        public void onActivityCreated(Activity activity);

        public void onActivityDestroyed(Activity activity);

        public void onActivityPaused(Activity activity);

        public void onActivityResumed(Activity activity);

        public void onActivityStarted(Activity activity);

        public void onActivityStopped(Activity activity);
    }

    public static class LifeCycleAdapter implements LifeCycleListener {
        public void onActivityCreated(Activity activity) {
        }

        public void onActivityDestroyed(Activity activity) {
        }

        public void onActivityPaused(Activity activity) {
        }

        public void onActivityResumed(Activity activity) {
        }

        public void onActivityStarted(Activity activity) {
        }

        public void onActivityStopped(Activity activity) {
        }
    }

    public void addLifeCycleListener(LifeCycleListener listener) {
        if (!mListeners.contains(listener)) {
            mListeners.add(listener);
        }
    }

    public void removeLifeCycleListener(LifeCycleListener listener) {
        mListeners.remove(listener);
    }

    // ****************************************** Lifecycle Methods *******************************************

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        // set theme
        setTheme(RTApi.useDarkTheme() ? R.style.RTE_BaseThemeDark : R.style.RTE_BaseThemeLight);

        if (!isFinishing()) {
            mHandler = new Handler();

            for (LifeCycleListener listener : mListeners) {
                listener.onActivityCreated(this);
            }
        }
    }

    @Override
    protected void onStart() {
        super.onStart();

        for (LifeCycleListener listener : mListeners) {
            listener.onActivityStarted(this);
        }
    }

    @Override
    protected void onPause() {
        super.onPause();

        for (LifeCycleListener listener : mListeners) {
            listener.onActivityPaused(this);
        }
    }

    @Override
    protected void onResume() {
        super.onResume();

        for (LifeCycleListener listener : mListeners) {
            listener.onActivityResumed(this);
        }
    }

    @Override
    protected void onStop() {
        super.onStop();

        for (LifeCycleListener listener : mListeners) {
            listener.onActivityStopped(this);
        }
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();

        for (LifeCycleListener listener : mListeners) {
            listener.onActivityDestroyed(this);
        }
    }

    // ****************************************** Foreground Jobs (Modal) *******************************************

    /**
     * Start a foreground job showing a progress bar as long as the job runs.
     * A foreground job shows a modal dialog that can't be cancelled while the app
     * does some processing. This should be used for short jobs only that block the ui.
     * Of course this contradicts many of the Android guidelines but with short jobs
     * we mean < 1 second and only in rare cases should it me more (e.g. when the garbage
     * collector kicks in which would normally lead to stuttering).
     * Using foreground jobs gives a better user experience in these rare cases.
     * The user has to wait but he/she knows it because there's a progess bar.
     */
    public <T> T startForegroundJob(int msgId, ForegroundJob<T> job) {
        // make the progress dialog uncancelable, so that we can guarantee
        // that the thread is done before the activity gets destroyed
        ProgressDialog dialog = ProgressDialog.show(this, null, getString(msgId), true, false);
        Job<T> managedJob = new Job<T>(job, dialog);
        return managedJob.runForegroundJob();
    }

    /**
     * Start a background job showing a progress bar as long as the job runs.
     * This seems contradictory but with background job we mean one that runs off
     * the ui thread to prevent an ANR.
     * We still have to wait for the processing to be done because we need the result.
     */
    public void startBackgroundJob(int msgId, Runnable runnable) {
        // make the progress dialog uncancelable, so that we can guarantee
        // that the thread is done before the activity gets destroyed
        ProgressDialog dialog = ProgressDialog.show(this, null, getString(msgId), true, false);
        Job<Object> managedJob = new Job<Object>(runnable, dialog);
        managedJob.runBackgroundJob();
    }

    public interface ForegroundJob<T> {
        public T runForegroundJob();
    }

    private class Job<T> extends LifeCycleAdapter implements ForegroundJob<T> {

        private final ProgressDialog mDialog;
        private final Runnable mRunnable;
        private final ForegroundJob<T> mJob;

        private final Runnable mCleanupRunner = new Runnable() {
            public void run() {
                removeLifeCycleListener(Job.this);
                if (mDialog.getWindow() != null) {
                    mDialog.dismiss();
                }
            }
        };

        public Job(ForegroundJob<T> job, ProgressDialog dialog) {
            mDialog = dialog;
            mJob = job;
            mRunnable = null;
            addLifeCycleListener(this);
        }

        public Job(Runnable runnable, ProgressDialog dialog) {
            mDialog = dialog;
            mJob = null;
            mRunnable = runnable;
            addLifeCycleListener(this);
        }

        @Override
        public T runForegroundJob() {
            try {
                return mJob.runForegroundJob();
            } finally {
                mHandler.post(mCleanupRunner);
            }
        }

        public void runBackgroundJob() {
            try {
                mRunnable.run();
            } finally {
                mHandler.post(mCleanupRunner);
            }
        }

        @Override
        public void onActivityDestroyed(Activity activity) {
            // We get here only when the onDestroyed being called before
            // the mCleanupRunner. So, run it now and remove it from the queue
            mCleanupRunner.run();
            mHandler.removeCallbacks(mCleanupRunner);
        }

        @Override
        public void onActivityStopped(Activity activity) {
            mDialog.hide();
        }

        @Override
        public void onActivityStarted(Activity activity) {
            mDialog.show();
        }
    }

}


/*
 * Copyright (C) 2015-2018 Emanuel Moecklin
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.onegravity.rteditor.converter;

import android.annotation.SuppressLint;
import android.text.Layout;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.style.QuoteSpan;
import android.text.style.RelativeSizeSpan;

import com.onegravity.rteditor.api.RTMediaFactory;
import com.onegravity.rteditor.api.format.RTFormat;
import com.onegravity.rteditor.api.format.RTHtml;
import com.onegravity.rteditor.api.format.RTSpanned;
import com.onegravity.rteditor.api.media.RTAudio;
import com.onegravity.rteditor.api.media.RTImage;
import com.onegravity.rteditor.api.media.RTVideo;
import com.onegravity.rteditor.converter.tagsoup.HTMLSchema;
import com.onegravity.rteditor.converter.tagsoup.Parser;
import com.onegravity.rteditor.fonts.FontManager;
import com.onegravity.rteditor.fonts.RTTypeface;
import com.onegravity.rteditor.spans.AbsoluteSizeSpan;
import com.onegravity.rteditor.spans.AlignmentSpan;
import com.onegravity.rteditor.spans.BackgroundColorSpan;
import com.onegravity.rteditor.spans.BoldSpan;
import com.onegravity.rteditor.spans.BulletSpan;
import com.onegravity.rteditor.spans.ForegroundColorSpan;
import com.onegravity.rteditor.spans.ImageSpan;
import com.onegravity.rteditor.spans.IndentationSpan;
import com.onegravity.rteditor.spans.ItalicSpan;
import com.onegravity.rteditor.spans.LinkSpan;
import com.onegravity.rteditor.spans.NumberSpan;
import com.onegravity.rteditor.spans.StrikethroughSpan;
import com.onegravity.rteditor.spans.SubscriptSpan;
import com.onegravity.rteditor.spans.SuperscriptSpan;
import com.onegravity.rteditor.spans.TypefaceSpan;
import com.onegravity.rteditor.spans.UnderlineSpan;
import com.onegravity.rteditor.utils.Helper;

import org.xml.sax.Attributes;
import org.xml.sax.ContentHandler;
import org.xml.sax.InputSource;
import org.xml.sax.Locator;
import org.xml.sax.SAXException;
import org.xml.sax.SAXNotRecognizedException;
import org.xml.sax.SAXNotSupportedException;

import java.io.File;
import java.io.IOException;
import java.io.StringReader;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
import java.util.Stack;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * Converts html to Spanned text using TagSoup
 */
public class ConverterHtmlToSpanned implements ContentHandler {

    private static final float[] HEADER_SIZES = {
            1.5f, 1.4f, 1.3f, 1.2f, 1.1f, 1f,
    };

    private String mSource;
    private RTMediaFactory<? extends RTImage, ? extends RTAudio, ? extends RTVideo> mMediaFactory;
    private Parser mParser;
    private SpannableStringBuilder mResult;

    private Stack<AccumulatedParagraphStyle> mParagraphStyles = new Stack<AccumulatedParagraphStyle>();

    /**
     * If this is set to True we ignore all characters till it's set to false again.
     * This way be can ignore e.g. style information or even the whole header
     */
    private boolean mIgnoreContent;

    private static final Set<String> sIgnoreTags = new HashSet<String>();

    static {
        sIgnoreTags.add("header");
        sIgnoreTags.add("style");
        sIgnoreTags.add("meta");
    }

    /**
     * Lazy initialization holder for HTML parser. This class will
     * a) be pre-loaded by zygote, or
     * b) not loaded until absolutely necessary.
     */
    private static class HtmlParser {
        private static final HTMLSchema SCHEMA = new HTMLSchema();
    }

    public RTSpanned convert(RTHtml<? extends RTImage, ? extends RTAudio, ? extends RTVideo> input,
                             RTMediaFactory<? extends RTImage, ? extends RTAudio, ? extends RTVideo> mediaFactory) {
        mSource = input.getText();
        mMediaFactory = mediaFactory;

        mParser = new Parser();
        try {
            mParser.setProperty(Parser.schemaProperty, HtmlParser.SCHEMA);
        } catch (SAXNotRecognizedException shouldNotHappen) {
            throw new RuntimeException(shouldNotHappen);
        } catch (SAXNotSupportedException shouldNotHappen) {
            throw new RuntimeException(shouldNotHappen);
        }

        mResult = new SpannableStringBuilder();
        mIgnoreContent = false;
        mParagraphStyles.clear();

        mParser.setContentHandler(this);
        try {
            mParser.parse(new InputSource(new StringReader(mSource)));
        } catch (IOException e) {
            // We are reading from a string. There should not be IO problems.
            throw new RuntimeException(e);
        } catch (SAXException e) {
            // TagSoup doesn't throw parse exceptions.
            throw new RuntimeException(e);
        }

        // remove trailing line breaks
        removeTrailingLineBreaks();

        // replace all TemporarySpans by the "real" spans
        for (TemporarySpan span : mResult.getSpans(0, mResult.length(), TemporarySpan.class)) {
            span.swapIn(mResult);
        }

        return new RTSpanned(mResult);
    }

    private void removeTrailingLineBreaks() {
        int end = mResult.length();
        while (end > 0 && mResult.charAt(end - 1) == '\n') {
            end--;
        }
        if (end < mResult.length()) {
            mResult = SpannableStringBuilder.valueOf(mResult.subSequence(0, end));
        }
    }

    // ****************************************** org.xml.sax.ContentHandler *******************************************

    @Override
    public void setDocumentLocator(Locator locator) {
    }

    @Override
    public void startDocument() throws SAXException {
    }

    @Override
    public void endDocument() throws SAXException {
    }

    @Override
    public void startPrefixMapping(String prefix, String uri) throws SAXException {
    }

    @Override
    public void endPrefixMapping(String prefix) throws SAXException {
    }

    @Override
    public void startElement(String uri, String localName, String qName, Attributes attributes) throws SAXException {
        handleStartTag(localName, attributes);
    }

    @Override
    public void endElement(String uri, String localName, String qName) throws SAXException {
        handleEndTag(localName);
    }

    @Override
    public void characters(char ch[], int start, int length) throws SAXException {
        if (mIgnoreContent) return;

        StringBuilder sb = new StringBuilder();

        /*
         * Ignore whitespace that immediately follows other whitespace; newlines count as spaces.
         */

        for (int i = 0; i < length; i++) {
            char c = ch[i + start];
            if (c == ' ' || c == '\n') {
                char pred;
                int len = sb.length();

                if (len == 0) {
                    len = mResult.length();

                    if (len == 0) {
                        pred = '\n';
                    } else {
                        pred = mResult.charAt(len - 1);
                    }
                } else {
                    pred = sb.charAt(len - 1);
                }

                if (pred != ' ' && pred != '\n') {
                    sb.append(' ');
                }
            } else {
                sb.append(c);
            }
        }

        mResult.append(sb);
    }

    @Override
    public void ignorableWhitespace(char ch[], int start, int length) throws SAXException {
    }

    @Override
    public void processingInstruction(String target, String data) throws SAXException {
    }

    @Override
    public void skippedEntity(String name) throws SAXException {
    }

    // ****************************************** Handle Tags *******************************************

    private void handleStartTag(String tag, Attributes attributes) {
        if (tag.equalsIgnoreCase("br")) {
            // We don't need to handle this. TagSoup will ensure that there's a </br> for each <br>
            // so we can safely omit the line breaks when we handle the close tag.
        } else if (tag.equalsIgnoreCase("p")) {
            handleP();
        } else if (tag.equalsIgnoreCase("div")) {
            startDiv(attributes);
        } else if (tag.equalsIgnoreCase("ul")) {
            startList(false, attributes);
        } else if (tag.equalsIgnoreCase("ol")) {
            startList(true, attributes);
        } else if (tag.equalsIgnoreCase("li")) {
            startList(attributes);
        } else if (tag.equalsIgnoreCase("strong")) {
            start(new Bold());
        } else if (tag.equalsIgnoreCase("b")) {
            start(new Bold());
        } else if (tag.equalsIgnoreCase("em")) {
            start(new Italic());
        } else if (tag.equalsIgnoreCase("cite")) {
            start(new Italic());
        } else if (tag.equalsIgnoreCase("dfn")) {
            start(new Italic());
        } else if (tag.equalsIgnoreCase("i")) {
            start(new Italic());
        } else if (tag.equalsIgnoreCase("strike")) {
            start(new Strikethrough());
        } else if (tag.equalsIgnoreCase("del")) {
            start(new Strikethrough());
        } else if (tag.equalsIgnoreCase("big")) {
            start(new Big());
        } else if (tag.equalsIgnoreCase("small")) {
            start(new Small());
        } else if (tag.equalsIgnoreCase("font")) {
            startFont(attributes);
        } else if (tag.equalsIgnoreCase("blockquote")) {
            handleP();
            start(new Blockquote());
        } else if (tag.equalsIgnoreCase("tt")) {
            start(new Monospace());
        } else if (tag.equalsIgnoreCase("a")) {
            startAHref(attributes);
        } else if (tag.equalsIgnoreCase("u")) {
            start(new Underline());
        } else if (tag.equalsIgnoreCase("sup")) {
            start(new Super());
        } else if (tag.equalsIgnoreCase("sub")) {
            start(new Sub());
        } else if (tag.length() == 2 &&
                Character.toLowerCase(tag.charAt(0)) == 'h' &&
                tag.charAt(1) >= '1' && tag.charAt(1) <= '6') {
            handleP();
            start(new Header(tag.charAt(1) - '1'));
        } else if (tag.equalsIgnoreCase("img")) {
            startImg(attributes);
        } else if (tag.equalsIgnoreCase("video")) {
            startVideo(attributes);
        } else if (tag.equalsIgnoreCase("embed")) {
            startAudio(attributes);
        } else if (sIgnoreTags.contains(tag.toLowerCase(Locale.getDefault()))) {
            mIgnoreContent = true;
        }
    }

    private void handleEndTag(String tag) {
        if (tag.equalsIgnoreCase("br")) {
            handleBr();
        } else if (tag.equalsIgnoreCase("p")) {
            handleP();
        } else if (tag.equalsIgnoreCase("div")) {
            endDiv();
        } else if (tag.equalsIgnoreCase("ul")) {
            endList(false);
        } else if (tag.equalsIgnoreCase("ol")) {
            endList(true);
        } else if (tag.equalsIgnoreCase("li")) {
            endList();
        } else if (tag.equalsIgnoreCase("strong")) {
            end(Bold.class, new BoldSpan());
        } else if (tag.equalsIgnoreCase("b")) {
            end(Bold.class, new BoldSpan());
        } else if (tag.equalsIgnoreCase("em")) {
            end(Italic.class, new ItalicSpan());
        } else if (tag.equalsIgnoreCase("cite")) {
            end(Italic.class, new ItalicSpan());
        } else if (tag.equalsIgnoreCase("dfn")) {
            end(Italic.class, new ItalicSpan());
        } else if (tag.equalsIgnoreCase("i")) {
            end(Italic.class, new ItalicSpan());
        } else if (tag.equalsIgnoreCase("strike")) {
            end(Strikethrough.class, new StrikethroughSpan());
        } else if (tag.equalsIgnoreCase("del")) {
            end(Strikethrough.class, new StrikethroughSpan());
        } else if (tag.equalsIgnoreCase("big")) {
            int size = Helper.convertPxToSp(32);
            end(Big.class, new AbsoluteSizeSpan(size));
        } else if (tag.equalsIgnoreCase("small")) {
            int size = Helper.convertPxToSp(14);
            end(Small.class, new AbsoluteSizeSpan(size));
        } else if (tag.equalsIgnoreCase("font")) {
            endFont();
        } else if (tag.equalsIgnoreCase("blockquote")) {
            handleP();
            end(Blockquote.class, new QuoteSpan());
        } else if (tag.equalsIgnoreCase("a")) {
            endAHref();
        } else if (tag.equalsIgnoreCase("u")) {
            end(Underline.class, new UnderlineSpan());
        } else if (tag.equalsIgnoreCase("sup")) {
            end(Super.class, new SuperscriptSpan());
        } else if (tag.equalsIgnoreCase("sub")) {
            end(Sub.class, new SubscriptSpan());
        } else if (tag.length() == 2 &&
                Character.toLowerCase(tag.charAt(0)) == 'h' &&
                tag.charAt(1) >= '1' && tag.charAt(1) <= '6') {
            handleP();
            endHeader();
        } else if (sIgnoreTags.contains(tag.toLowerCase(Locale.getDefault()))) {
            mIgnoreContent = false;
        }
    }

    private void startDiv(Attributes attributes) {
        String sAlign = attributes.getValue("align");
        int len = mResult.length();
        mResult.setSpan(new Div(sAlign), len, len, Spanned.SPAN_MARK_MARK);
    }

    private void endDiv() {
        int end = mResult.length();
        Object obj = getLast(mResult, Div.class);
        int start = mResult.getSpanStart(obj);

        mResult.removeSpan(obj);
        if (start != end) {
            if (!checkDuplicateSpan(mResult, start, AlignmentSpan.class)) {
                Div divObj = (Div) obj;
                Layout.Alignment align = divObj.mAlign.equalsIgnoreCase("center") ? Layout.Alignment.ALIGN_CENTER :
                        divObj.mAlign.equalsIgnoreCase("right") ? Layout.Alignment.ALIGN_OPPOSITE : Layout.Alignment.ALIGN_NORMAL;
                if (align != null) {
                    if (mResult.charAt(end - 1) != '\n') {
                        // yes we need that linefeed, or we will get crashes
                        mResult.append('\n');
                    }
                    // use SPAN_EXCLUSIVE_EXCLUSIVE here, will be replaced later anyway when the cleanup function is called
                    boolean isRTL = Helper.isRTL(mResult, start, end);
                    mResult.setSpan(new AlignmentSpan(align, isRTL), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
                }
            }
        }
    }

    /**
     * Handles OL and UL start tags
     */
    private void startList(boolean isOrderedList, Attributes attributes) {
        boolean isIndentation = isIndentation(attributes);

        ParagraphType newType = isIndentation && isOrderedList ? ParagraphType.INDENTATION_OL :
                isIndentation && !isOrderedList ? ParagraphType.INDENTATION_UL :
                        isOrderedList ? ParagraphType.NUMBERING :
                                ParagraphType.BULLET;

        AccumulatedParagraphStyle currentStyle = mParagraphStyles.isEmpty() ? null : mParagraphStyles.peek();

        if (currentStyle == null) {
            // no previous style found -> create new AccumulatedParagraphStyle with indentations of 1
            AccumulatedParagraphStyle newStyle = new AccumulatedParagraphStyle(newType, 1, 1);
            mParagraphStyles.push(newStyle);
        } else if (currentStyle.getType() == newType) {
            // same style found -> increase indentations by 1
            currentStyle.setAbsoluteIndent(currentStyle.getAbsoluteIndent() + 1);
            currentStyle.setRelativeIndent(currentStyle.getRelativeIndent() + 1);
        } else {
            // different style found -> create new AccumulatedParagraphStyle with incremented indentations
            AccumulatedParagraphStyle newStyle = new AccumulatedParagraphStyle(newType, currentStyle.getAbsoluteIndent() + 1, 1);
            mParagraphStyles.push(newStyle);
        }
    }

    /**
     * Handles OL and UL end tags
     */
    private void endList(boolean orderedList) {
        if (!mParagraphStyles.isEmpty()) {
            AccumulatedParagraphStyle style = mParagraphStyles.peek();
            ParagraphType type = style.getType();

            if ((orderedList && (type.isNumbering() || type == ParagraphType.INDENTATION_OL)) ||
                    (!orderedList && (type.isBullet() || type == ParagraphType.INDENTATION_UL))) {

                // the end tag matches the current style
                int indent = style.getRelativeIndent();
                if (indent > 1) {
                    style.setRelativeIndent(indent - 1);
                    style.setAbsoluteIndent(style.getAbsoluteIndent() - 1);
                } else {
                    mParagraphStyles.pop();
                }

            } else {

                // the end tag doesn't match the current style
                mParagraphStyles.pop();
                endList(orderedList);    // find the next matching style

            }
        }
    }

    /**
     * Handles LI tags
     */
    private void startList(Attributes attributes) {
        List listTag = null;

        if (!mParagraphStyles.isEmpty()) {
            AccumulatedParagraphStyle currentStyle = mParagraphStyles.peek();
            ParagraphType type = currentStyle.getType();
            int indent = currentStyle.getAbsoluteIndent();
            boolean isIndentation = isIndentation(attributes);

            if (type.isIndentation() || isIndentation) {
                listTag = new UL(indent, true);
            } else if (type.isNumbering()) {
                listTag = new OL(indent, false);
            } else if (type.isBullet()) {
                listTag = new UL(indent, false);
            }
        } else {
            listTag = new UL(0, false);
        }

        if (listTag != null) start(listTag);
    }

    /**
     * Handles LI tags
     */
    private void endList() {
        List list = (List) getLast(List.class);
        if (list != null) {
            if (mResult.length() == 0 || mResult.charAt(mResult.length() - 1) != '\n') {
                mResult.append('\n');
            }
            int start = mResult.getSpanStart(list);
            int end = mResult.length();

            int nrOfIndents = list.mNrOfIndents;
            if (!list.mIsIndentation) {
                nrOfIndents--;
                int margin = Helper.getLeadingMarging();
                // use SPAN_EXCLUSIVE_EXCLUSIVE here, will be replaced later anyway when the cleanup function is called
                Object span = list instanceof UL ?
                        new BulletSpan(margin, start == end, false, false) :
                        new NumberSpan(1, margin, start == end, false, false);
                mResult.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
            }

            if (nrOfIndents > 0) {
                int margin = nrOfIndents * Helper.getLeadingMarging();
                // use SPAN_EXCLUSIVE_EXCLUSIVE here, will be replaced later anyway when the cleanup function is called
                IndentationSpan span = new IndentationSpan(margin, start == end, false, false);
                mResult.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
            }

            mResult.removeSpan(list);
        }
    }

    private boolean isIndentation(Attributes attributes) {
        String style = attributes.getValue("style");
        return style != null && style.toLowerCase(Locale.US).contains("list-style-type:none");
    }

    private boolean checkDuplicateSpan(SpannableStringBuilder text, int where, Class<?> kind) {
        Object[] spans = text.getSpans(where, where, kind);
        if (spans != null && spans.length > 0) {
            for (int i = 0; i < spans.length; i++) {
                if (text.getSpanStart(spans[i]) == where) {
                    return true;
                }
            }
        }
        return false;
    }

    private Object getLast(Spanned text, Class<?> kind) {
        /*
         * This knows that the last returned object from getSpans()
         * will be the most recently added.
         */
        Object[] objs = text.getSpans(0, text.length(), kind);
        return objs.length == 0 ? null : objs[objs.length - 1];
    }

    private void handleP() {
        int len = mResult.length();
        if (len >= 1 && mResult.charAt(len - 1) == '\n') {
            if (len < 2 || mResult.charAt(len - 2) != '\n') {
                mResult.append("\n");
            }
        } else if (len != 0) {
            mResult.append("\n\n");
        }
    }

    private void handleBr() {
        mResult.append("\n");
    }

    private Object getLast(Class<? extends Object> kind) {
        /*
         * This knows that the last returned object from getSpans()
         * will be the most recently added.
         */
        Object[] objs = mResult.getSpans(0, mResult.length(), kind);
        return objs.length == 0 ? null : objs[objs.length - 1];
    }

    private void start(Object mark) {
        int len = mResult.length();
        mResult.setSpan(mark, len, len, Spanned.SPAN_MARK_MARK);
    }

    private void end(Class<? extends Object> kind, Object repl) {
        int len = mResult.length();
        Object obj = getLast(kind);
        int where = mResult.getSpanStart(obj);

        mResult.removeSpan(obj);

        if (where != len) {
            // Note: use SPAN_EXCLUSIVE_EXCLUSIVE, the TemporarySpan will be replaced by a SPAN_EXCLUSIVE_INCLUSIVE span
            mResult.setSpan(new TemporarySpan(repl), where, len, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
        }
    }

    private void startImg(Attributes attributes) {
        int len = mResult.length();
        String src = attributes.getValue("", "src");
        RTImage image = mMediaFactory.createImage(src);

        if (image != null && image.exists()) {
            String path = image.getFilePath(RTFormat.SPANNED);
            File file = new File(path);
            if (file.isDirectory()) {
                // there were crashes when an image was a directory all of a sudden...
                // the root cause is unknown and this is a desparate work around
                return;
            }

            // Unicode Character 'OBJECT REPLACEMENT CHARACTER' (U+FFFC)
            // see http://www.fileformat.info/info/unicode/char/fffc/index.htm
            mResult.append("\uFFFC");
            ImageSpan imageSpan = new ImageSpan(image, true);
            mResult.setSpan(imageSpan, len, len + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
        }
    }

    private void startVideo(Attributes attributes) {
    }

    private void startAudio(Attributes attributes) {
    }

    /*
     * Examples:
     * <font style="font-size:25px;background-color:#00ff00;color:#ff0000">This is heading 1</font>
     * <font style="font-size:50px;background-color:#0000FF;color:#FFFF00">This is heading 2</font>
     */
    private static final Pattern FONT_SIZE = Pattern.compile("\\d+");
    private static final Pattern FONT_COLOR = Pattern.compile("#[a-f0-9]+");

    private void startFont(Attributes attributes) {
        int size = Integer.MIN_VALUE;
        String fgColor = null;
        String bgColor = null;
        String fontName = null;

        String style = attributes.getValue("", "style");
        if (style != null) {
            for (String part : style.toLowerCase(Locale.ENGLISH).split(";")) {
                if (part.startsWith("font-size")) {
                    Matcher matcher = FONT_SIZE.matcher(part);
                    if (matcher.find(0)) {
                        int start = matcher.start();
                        int end = matcher.end();
                        try {
                            size = Integer.parseInt(part.substring(start, end));
                        } catch (NumberFormatException ignore) {
                        }
                    }
                } else if (part.startsWith("color")) {
                    Matcher matcher = FONT_COLOR.matcher(part);
                    if (matcher.find(0)) {
                        int start = matcher.start();
                        int end = matcher.end();
                        fgColor = part.substring(start, end);
                    }
                } else if (part.startsWith("background-color")) {
                    Matcher matcher = FONT_COLOR.matcher(part);
                    if (matcher.find(0)) {
                        int start = matcher.start();
                        int end = matcher.end();
                        bgColor = part.substring(start, end);
                    }
                }
            }
        }

        fontName = attributes.getValue("", "face");

        int len = mResult.length();
        Font font = new Font()
                .setSize(size)
                .setFGColor(fgColor)
                .setBGColor(bgColor)
                .setFontFace(fontName);
        mResult.setSpan(font, len, len, Spanned.SPAN_MARK_MARK);
    }

    private void endFont() {
        int len = mResult.length();
        Object obj = getLast(Font.class);
        int where = mResult.getSpanStart(obj);

        mResult.removeSpan(obj);

        if (where != len) {
            Font font = (Font) obj;

            // font type face
            if (font.hasFontFace()) {
                // Note: use SPAN_EXCLUSIVE_EXCLUSIVE, the TemporarySpan will be replaced by a SPAN_EXCLUSIVE_INCLUSIVE span
                RTTypeface typeface = FontManager.getTypeface(font.mFontFace);
                if (typeface != null) {
                    TemporarySpan span = new TemporarySpan(new TypefaceSpan(typeface));
                    mResult.setSpan(span, where, len, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
                }
            }

            // text size
            if (font.hasSize()) {
                // Note: use SPAN_EXCLUSIVE_EXCLUSIVE, the TemporarySpan will later be replaced by a SPAN_EXCLUSIVE_INCLUSIVE span
                int size = Helper.convertPxToSp(font.mSize);
                TemporarySpan span = new TemporarySpan(new AbsoluteSizeSpan(size));
                mResult.setSpan(span, where, len, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
            }

            // font color
            if (font.hasFGColor()) {
                int c = getHtmlColor(font.mFGColor);
                if (c != -1) {
                    // Note: use SPAN_EXCLUSIVE_EXCLUSIVE, the TemporarySpan will be replaced by a SPAN_EXCLUSIVE_INCLUSIVE span
                    TemporarySpan span = new TemporarySpan(new ForegroundColorSpan(c | 0xFF000000));
                    mResult.setSpan(span, where, len, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
                }
            }

            // font background color
            if (font.hasBGColor()) {
                int c = getHtmlColor(font.mBGColor);
                if (c != -1) {
                    // Note: use SPAN_EXCLUSIVE_EXCLUSIVE, the TemporarySpan will be replaced by a SPAN_EXCLUSIVE_INCLUSIVE span
                    TemporarySpan span = new TemporarySpan(new BackgroundColorSpan(c | 0xFF000000));
                    mResult.setSpan(span, where, len, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
                }
            }
        }
    }

    private void startAHref(Attributes attributes) {
        String href = attributes.getValue("", "href");
        int len = mResult.length();
        mResult.setSpan(new Href(href), len, len, Spanned.SPAN_MARK_MARK);
    }

    private void endAHref() {
        int len = mResult.length();
        Object obj = getLast(Href.class);
        int where = mResult.getSpanStart(obj);

        mResult.removeSpan(obj);

        if (where != len) {
            Href h = (Href) obj;
            if (h.mHref != null) {
                mResult.setSpan(new LinkSpan(h.mHref), where, len, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
            }
        }
    }

    private void endHeader() {
        int len = mResult.length();
        Object obj = getLast(Header.class);

        int where = mResult.getSpanStart(obj);

        mResult.removeSpan(obj);

        // Back off not to change only the text, not the blank line.
        while (len > where && mResult.charAt(len - 1) == '\n') {
            len--;
        }

        if (where != len) {
            Header h = (Header) obj;
            mResult.setSpan(new RelativeSizeSpan(HEADER_SIZES[h.mLevel]), where, len, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
            mResult.setSpan(new BoldSpan(), where, len, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
        }
    }

    // ****************************************** Helper Data Structures *******************************************

    /*
     * While the spanned text is build we need to use SPAN_EXCLUSIVE_EXCLUSIVE instead of SPAN_EXCLUSIVE_INCLUSIVE
     * or each span would expand to the end of the text as we append more text.
     * Therefore we use a TemporarySpan which will be replaced by the "real" span once the full spanned text is built.
     */
    private static class TemporarySpan {
        Object mSpan;

        TemporarySpan(Object span) {
            mSpan = span;
        }

        void swapIn(SpannableStringBuilder builder) {
            int start = builder.getSpanStart(this);
            int end = builder.getSpanEnd(this);
            builder.removeSpan(this);
            if (start >= 0 && end > start && end <= builder.length()) {
                builder.setSpan(mSpan, start, end, Spanned.SPAN_EXCLUSIVE_INCLUSIVE);
            }
        }
    }

    private static class Div {
        String mAlign = "left";

        Div(String align) {
            if (align != null) mAlign = align;
        }
    }

    private static class Bold {}
    private static class Italic {}
    private static class Underline {}
    private static class Strikethrough {}
    private static class Super {}
    private static class Sub {}
    private static class Big {}
    private static class Small {}
    private static class Monospace {}
    private static class Blockquote {}

    private abstract static class List {
        int mNrOfIndents;
        boolean mIsIndentation;

        List(int nrOfIndents, boolean isIndentation) {
            mNrOfIndents = nrOfIndents;
            mIsIndentation = isIndentation;
        }
    }

    private static class UL extends List {
        UL(int nrOfIndents, boolean isIndentation) {
            super(nrOfIndents, isIndentation);
        }
    }

    private static class OL extends List {
        OL(int nrOfIndents, boolean isIndentation) {
            super(nrOfIndents, isIndentation);
        }
    }

    private static class Font {
        int mSize = Integer.MIN_VALUE;
        String mFGColor;
        String mBGColor;
        String mFontFace;

        Font setSize(int size) {
            mSize = size;
            return this;
        }

        Font setFGColor(String color) {
            mFGColor = color;
            return this;
        }

        Font setBGColor(String color) {
            mBGColor = color;
            return this;
        }

        private Font setFontFace(String fontFace) {
            mFontFace = fontFace;
            return this;
        }

        boolean hasSize() {
            return mSize > 0;
        }

        boolean hasFGColor() {
            return !TextUtils.isEmpty(mFGColor);
        }

        boolean hasBGColor() {
            return !TextUtils.isEmpty(mBGColor);
        }

        boolean hasFontFace() {
            return !TextUtils.isEmpty(mFontFace);
        }
    }

    private static class Href {
        String mHref;

        Href(String href) {
            mHref = href;
        }
    }

    private static class Header {
        int mLevel;

        Header(int level) {
            mLevel = level;
        }
    }

    // ****************************************** Color Methods *******************************************

    private static HashMap<String, Integer> COLORS = new HashMap<String, Integer>();

    static {
        COLORS.put("aqua", 0x00FFFF);
        COLORS.put("black", 0x000000);
        COLORS.put("blue", 0x0000FF);
        COLORS.put("fuchsia", 0xFF00FF);
        COLORS.put("green", 0x008000);
        COLORS.put("grey", 0x808080);
        COLORS.put("lime", 0x00FF00);
        COLORS.put("maroon", 0x800000);
        COLORS.put("navy", 0x000080);
        COLORS.put("olive", 0x808000);
        COLORS.put("purple", 0x800080);
        COLORS.put("red", 0xFF0000);
        COLORS.put("silver", 0xC0C0C0);
        COLORS.put("teal", 0x008080);
        COLORS.put("white", 0xFFFFFF);
        COLORS.put("yellow", 0xFFFF00);
    }

    /**
     * Converts an HTML color (named or numeric) to an integer RGB value.
     *
     * @param color Non-null color string.
     * @return A color value, or {@code -1} if the color string could not be interpreted.
     */
    @SuppressLint("DefaultLocale")
    private static int getHtmlColor(String color) {
        Integer i = COLORS.get(color.toLowerCase());
        if (i != null) {
            return i;
        } else {
            try {
                return convertValueToInt(color, -1);
            } catch (NumberFormatException nfe) {
                return -1;
            }
        }
    }

    private static final int convertValueToInt(CharSequence charSeq, int defaultValue) {
        if (null == charSeq)
            return defaultValue;

        String nm = charSeq.toString();

        // XXX This code is copied from Integer.decode() so we don't
        // have to instantiate an Integer!

        int sign = 1;
        int index = 0;
        int len = nm.length();
        int base = 10;

        if ('-' == nm.charAt(0)) {
            sign = -1;
            index++;
        }

        if ('0' == nm.charAt(index)) {
            // Quick check for a zero by itself
            if (index == (len - 1))
                return 0;

            char c = nm.charAt(index + 1);

            if ('x' == c || 'X' == c) {
                index += 2;
                base = 16;
            } else {
                index++;
                base = 8;
            }
        } else if ('#' == nm.charAt(index)) {
            index++;
            base = 16;
        }

        return Integer.parseInt(nm.substring(index), base) * sign;
    }

}


/*
 * Copyright (C) 2007 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.onegravity.rteditor.media.crop;

import android.content.res.Resources;
import android.graphics.Canvas;
import android.graphics.Matrix;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.Rect;
import android.graphics.RectF;
import android.graphics.Region;
import android.graphics.drawable.Drawable;
import android.view.View;

import com.onegravity.rteditor.R;

// This class is used by CropImage to display a highlighted cropping rectangle
// overlayed with the image. There are two coordinate spaces in use. One is
// image, another is screen. computeLayout() uses mMatrix to map from image
// space to screen space.
class HighlightView {

    @SuppressWarnings("unused")
    private static final String TAG = "HighlightView";
    View mContext; // The View displaying the image.

    public static final int GROW_NONE = (1 << 0);
    public static final int GROW_LEFT_EDGE = (1 << 1);
    public static final int GROW_RIGHT_EDGE = (1 << 2);
    public static final int GROW_TOP_EDGE = (1 << 3);
    public static final int GROW_BOTTOM_EDGE = (1 << 4);
    public static final int MOVE = (1 << 5);

    public HighlightView(View ctx) {

        mContext = ctx;
    }

    private void init() {
        Resources resources = mContext.getResources();
        mResizeDrawableWidth = resources.getDrawable(R.drawable.camera_crop_width);
        mResizeDrawableHeight = resources.getDrawable(R.drawable.camera_crop_height);
        mResizeDrawableDiagonal = resources.getDrawable(R.drawable.indicator_autocrop);
    }

    boolean mIsFocused;
    boolean mHidden;

    public boolean hasFocus() {
        return mIsFocused;
    }

    public void setFocus(boolean f) {
        mIsFocused = f;
    }

    public void setHidden(boolean hidden) {
        mHidden = hidden;
    }

    protected void draw(Canvas canvas) {
        if (mHidden) {
            return;
        }

        Path path = new Path();
        if (!hasFocus()) {
            mOutlinePaint.setColor(0xFF000000);
            canvas.drawRect(mDrawRect, mOutlinePaint);
        } else {
            Rect viewDrawingRect = new Rect();
            mContext.getDrawingRect(viewDrawingRect);
            if (mCircle) {

                canvas.save();

                float width = mDrawRect.width();
                float height = mDrawRect.height();
                path.addCircle(mDrawRect.left + (width / 2), mDrawRect.top
                        + (height / 2), width / 2, Path.Direction.CW);
                mOutlinePaint.setColor(0xFFEF04D6);

                canvas.clipPath(path, Region.Op.DIFFERENCE);
                canvas.drawRect(viewDrawingRect, hasFocus() ? mFocusPaint
                        : mNoFocusPaint);

                canvas.restore();

            } else {

                Rect topRect = new Rect(viewDrawingRect.left,
                        viewDrawingRect.top, viewDrawingRect.right,
                        mDrawRect.top);
                if (topRect.width() > 0 && topRect.height() > 0) {
                    canvas.drawRect(topRect, hasFocus() ? mFocusPaint
                            : mNoFocusPaint);
                }
                Rect bottomRect = new Rect(viewDrawingRect.left,
                        mDrawRect.bottom, viewDrawingRect.right,
                        viewDrawingRect.bottom);
                if (bottomRect.width() > 0 && bottomRect.height() > 0) {
                    canvas.drawRect(bottomRect, hasFocus() ? mFocusPaint
                            : mNoFocusPaint);
                }
                Rect leftRect = new Rect(viewDrawingRect.left, topRect.bottom,
                        mDrawRect.left, bottomRect.top);
                if (leftRect.width() > 0 && leftRect.height() > 0) {
                    canvas.drawRect(leftRect, hasFocus() ? mFocusPaint
                            : mNoFocusPaint);
                }
                Rect rightRect = new Rect(mDrawRect.right, topRect.bottom,
                        viewDrawingRect.right, bottomRect.top);
                if (rightRect.width() > 0 && rightRect.height() > 0) {
                    canvas.drawRect(rightRect, hasFocus() ? mFocusPaint
                            : mNoFocusPaint);
                }

                path.addRect(new RectF(mDrawRect), Path.Direction.CW);

                mOutlinePaint.setColor(0xFFFF8A00);

            }

            canvas.drawPath(path, mOutlinePaint);

            if (mMode == ModifyMode.Grow) {
                if (mCircle) {
                    int width = mResizeDrawableDiagonal.getIntrinsicWidth();
                    int height = mResizeDrawableDiagonal.getIntrinsicHeight();

                    int d = (int) Math.round(Math.cos(/* 45deg */Math.PI / 4D)
                            * (mDrawRect.width() / 2D));
                    int x = mDrawRect.left + (mDrawRect.width() / 2) + d
                            - width / 2;
                    int y = mDrawRect.top + (mDrawRect.height() / 2) - d
                            - height / 2;
                    mResizeDrawableDiagonal.setBounds(x, y, x
                            + mResizeDrawableDiagonal.getIntrinsicWidth(), y
                            + mResizeDrawableDiagonal.getIntrinsicHeight());
                    mResizeDrawableDiagonal.draw(canvas);
                } else {
                    int left = mDrawRect.left + 1;
                    int right = mDrawRect.right + 1;
                    int top = mDrawRect.top + 4;
                    int bottom = mDrawRect.bottom + 3;

                    int widthWidth = mResizeDrawableWidth.getIntrinsicWidth() / 2;
                    int widthHeight = mResizeDrawableWidth.getIntrinsicHeight() / 2;
                    int heightHeight = mResizeDrawableHeight
                            .getIntrinsicHeight() / 2;
                    int heightWidth = mResizeDrawableHeight.getIntrinsicWidth() / 2;

                    int xMiddle = mDrawRect.left
                            + ((mDrawRect.right - mDrawRect.left) / 2);
                    int yMiddle = mDrawRect.top
                            + ((mDrawRect.bottom - mDrawRect.top) / 2);

                    mResizeDrawableWidth.setBounds(left - widthWidth, yMiddle
                            - widthHeight, left + widthWidth, yMiddle
                            + widthHeight);
                    mResizeDrawableWidth.draw(canvas);

                    mResizeDrawableWidth.setBounds(right - widthWidth, yMiddle
                            - widthHeight, right + widthWidth, yMiddle
                            + widthHeight);
                    mResizeDrawableWidth.draw(canvas);

                    mResizeDrawableHeight.setBounds(xMiddle - heightWidth, top
                            - heightHeight, xMiddle + heightWidth, top
                            + heightHeight);
                    mResizeDrawableHeight.draw(canvas);

                    mResizeDrawableHeight.setBounds(xMiddle - heightWidth,
                            bottom - heightHeight, xMiddle + heightWidth,
                            bottom + heightHeight);
                    mResizeDrawableHeight.draw(canvas);
                }
            }
        }
    }

    public ModifyMode getMode() {
        return mMode;
    }

    public void setMode(ModifyMode mode) {
        if (mode != mMode) {
            mMode = mode;
            mContext.invalidate();
        }
    }

    // Determines which edges are hit by touching at (x, y).
    public int getHit(float x, float y) {
        Rect r = computeLayout();
        final float hysteresis = 20F;
        int retval = GROW_NONE;

        if (mCircle) {
            float distX = x - r.centerX();
            float distY = y - r.centerY();
            int distanceFromCenter = (int) Math.sqrt(distX * distX + distY
                    * distY);
            int radius = mDrawRect.width() / 2;
            int delta = distanceFromCenter - radius;
            if (Math.abs(delta) <= hysteresis) {
                if (Math.abs(distY) > Math.abs(distX)) {
                    if (distY < 0) {
                        retval = GROW_TOP_EDGE;
                    } else {
                        retval = GROW_BOTTOM_EDGE;
                    }
                } else {
                    if (distX < 0) {
                        retval = GROW_LEFT_EDGE;
                    } else {
                        retval = GROW_RIGHT_EDGE;
                    }
                }
            } else if (distanceFromCenter < radius) {
                retval = MOVE;
            } else {
                retval = GROW_NONE;
            }
        } else {
            // verticalCheck makes sure the position is between the top and
            // the bottom edge (with some tolerance). Similar for horizCheck.
            boolean verticalCheck = (y >= r.top - hysteresis)
                    && (y < r.bottom + hysteresis);
            boolean horizCheck = (x >= r.left - hysteresis)
                    && (x < r.right + hysteresis);

            // Check whether the position is near some edge(s).
            if ((Math.abs(r.left - x) < hysteresis) && verticalCheck) {
                retval |= GROW_LEFT_EDGE;
            }
            if ((Math.abs(r.right - x) < hysteresis) && verticalCheck) {
                retval |= GROW_RIGHT_EDGE;
            }
            if ((Math.abs(r.top - y) < hysteresis) && horizCheck) {
                retval |= GROW_TOP_EDGE;
            }
            if ((Math.abs(r.bottom - y) < hysteresis) && horizCheck) {
                retval |= GROW_BOTTOM_EDGE;
            }

            // Not near any edge but inside the rectangle: move.
            if (retval == GROW_NONE && r.contains((int) x, (int) y)) {
                retval = MOVE;
            }
        }
        return retval;
    }

    // Handles motion (dx, dy) in screen space.
    // The "edge" parameter specifies which edges the user is dragging.
    void handleMotion(int edge, float dx, float dy) {
        Rect r = computeLayout();
        if (edge == GROW_NONE) {
            return;
        } else if (edge == MOVE) {
            // Convert to image space before sending to moveBy().
            moveBy(dx * (mCropRect.width() / r.width()),
                    dy * (mCropRect.height() / r.height()));
        } else {
            if (((GROW_LEFT_EDGE | GROW_RIGHT_EDGE) & edge) == 0) {
                dx = 0;
            }

            if (((GROW_TOP_EDGE | GROW_BOTTOM_EDGE) & edge) == 0) {
                dy = 0;
            }

            // Convert to image space before sending to growBy().
            float xDelta = dx * (mCropRect.width() / r.width());
            float yDelta = dy * (mCropRect.height() / r.height());
            growBy((((edge & GROW_LEFT_EDGE) != 0) ? -1 : 1) * xDelta,
                    (((edge & GROW_TOP_EDGE) != 0) ? -1 : 1) * yDelta);
        }
    }

    // Grows the cropping rectange by (dx, dy) in image space.
    void moveBy(float dx, float dy) {
        Rect invalRect = new Rect(mDrawRect);

        mCropRect.offset(dx, dy);

        // Put the cropping rectangle inside image rectangle.
        mCropRect.offset(Math.max(0, mImageRect.left - mCropRect.left),
                Math.max(0, mImageRect.top - mCropRect.top));

        mCropRect.offset(Math.min(0, mImageRect.right - mCropRect.right),
                Math.min(0, mImageRect.bottom - mCropRect.bottom));

        mDrawRect = computeLayout();
        invalRect.union(mDrawRect);
        invalRect.inset(-10, -10);
        mContext.invalidate(invalRect);
    }

    // Grows the cropping rectange by (dx, dy) in image space.
    void growBy(float dx, float dy) {
        if (mMaintainAspectRatio) {
            if (dx != 0) {
                dy = dx / mInitialAspectRatio;
            } else if (dy != 0) {
                dx = dy * mInitialAspectRatio;
            }
        }

        // Don't let the cropping rectangle grow too fast.
        // Grow at most half of the difference between the image rectangle and
        // the cropping rectangle.
        RectF r = new RectF(mCropRect);
        if (dx > 0F && r.width() + 2 * dx > mImageRect.width()) {
            float adjustment = (mImageRect.width() - r.width()) / 2F;
            dx = adjustment;
            if (mMaintainAspectRatio) {
                dy = dx / mInitialAspectRatio;
            }
        }
        if (dy > 0F && r.height() + 2 * dy > mImageRect.height()) {
            float adjustment = (mImageRect.height() - r.height()) / 2F;
            dy = adjustment;
            if (mMaintainAspectRatio) {
                dx = dy * mInitialAspectRatio;
            }
        }

        r.inset(-dx, -dy);

        // Don't let the cropping rectangle shrink too fast.
        final float widthCap = 25F;
        if (r.width() < widthCap) {
            r.inset(-(widthCap - r.width()) / 2F, 0F);
        }
        float heightCap = mMaintainAspectRatio ? (widthCap / mInitialAspectRatio)
                : widthCap;
        if (r.height() < heightCap) {
            r.inset(0F, -(heightCap - r.height()) / 2F);
        }

        // Put the cropping rectangle inside the image rectangle.
        if (r.left < mImageRect.left) {
            r.offset(mImageRect.left - r.left, 0F);
        } else if (r.right > mImageRect.right) {
            r.offset(-(r.right - mImageRect.right), 0);
        }
        if (r.top < mImageRect.top) {
            r.offset(0F, mImageRect.top - r.top);
        } else if (r.bottom > mImageRect.bottom) {
            r.offset(0F, -(r.bottom - mImageRect.bottom));
        }

        mCropRect.set(r);
        mDrawRect = computeLayout();
        mContext.invalidate();
    }

    // Returns the cropping rectangle in image space.
    public Rect getCropRect() {
        return new Rect((int) mCropRect.left, (int) mCropRect.top, (int) mCropRect.right, (int) mCropRect.bottom);
    }

    // Maps the cropping rectangle from image space to screen space.
    private Rect computeLayout() {
        RectF r = new RectF(mCropRect.left, mCropRect.top, mCropRect.right, mCropRect.bottom);
        mMatrix.mapRect(r);
        return new Rect(Math.round(r.left), Math.round(r.top), Math.round(r.right), Math.round(r.bottom));
    }

    public void invalidate() {

        mDrawRect = computeLayout();
    }

    public void setup(Matrix m, Rect imageRect, RectF cropRect, boolean circle, boolean maintainAspectRatio) {
        if (circle) {
            maintainAspectRatio = true;
        }
        mMatrix = new Matrix(m);

        mCropRect = cropRect;
        mImageRect = new RectF(imageRect);
        mMaintainAspectRatio = maintainAspectRatio;
        mCircle = circle;

        mInitialAspectRatio = mCropRect.width() / mCropRect.height();
        mDrawRect = computeLayout();

        mFocusPaint.setARGB(125, 50, 50, 50);
        mNoFocusPaint.setARGB(125, 50, 50, 50);
        mOutlinePaint.setStrokeWidth(3F);
        mOutlinePaint.setStyle(Paint.Style.STROKE);
        mOutlinePaint.setAntiAlias(true);

        mMode = ModifyMode.None;
        init();
    }

    enum ModifyMode {
        None, Move, Grow
    }

    private ModifyMode mMode = ModifyMode.None;

    Rect mDrawRect; // in screen space
    private RectF mImageRect; // in image space
    RectF mCropRect; // in image space
    Matrix mMatrix;

    private boolean mMaintainAspectRatio = false;
    private float mInitialAspectRatio;
    private boolean mCircle = false;

    private Drawable mResizeDrawableWidth;
    private Drawable mResizeDrawableHeight;
    private Drawable mResizeDrawableDiagonal;

    private final Paint mFocusPaint = new Paint();
    private final Paint mNoFocusPaint = new Paint();
    private final Paint mOutlinePaint = new Paint();
}


/*
 * Copyright 2015 Emanuel Moecklin
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 *  Unless required by applicable law or agreed to in writing, software
 *  distributed under the License is distributed on an "AS IS" BASIS,
 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *  See the License for the specific language governing permissions and
 *  limitations under the License.
 */

package com.onegravity.rteditor;

import android.content.Context;
import android.graphics.Rect;
import android.os.Parcel;
import android.os.Parcelable;
import android.text.Editable;
import android.text.SpanWatcher;
import android.text.Spannable;
import android.text.Spanned;
import android.text.TextWatcher;
import android.text.style.ParagraphStyle;
import android.util.AttributeSet;
import android.widget.EditText;
import android.widget.TextView;

import com.onegravity.rteditor.api.RTMediaFactory;
import com.onegravity.rteditor.api.format.RTEditable;
import com.onegravity.rteditor.api.format.RTFormat;
import com.onegravity.rteditor.api.format.RTHtml;
import com.onegravity.rteditor.api.format.RTPlainText;
import com.onegravity.rteditor.api.format.RTText;
import com.onegravity.rteditor.api.media.RTAudio;
import com.onegravity.rteditor.api.media.RTImage;
import com.onegravity.rteditor.api.media.RTMedia;
import com.onegravity.rteditor.api.media.RTVideo;
import com.onegravity.rteditor.effects.Effect;
import com.onegravity.rteditor.effects.Effects;
import com.onegravity.rteditor.spans.BulletSpan;
import com.onegravity.rteditor.spans.LinkSpan;
import com.onegravity.rteditor.spans.LinkSpan.LinkSpanListener;
import com.onegravity.rteditor.spans.MediaSpan;
import com.onegravity.rteditor.spans.NumberSpan;
import com.onegravity.rteditor.spans.RTSpan;
import com.onegravity.rteditor.utils.Paragraph;
import com.onegravity.rteditor.utils.RTLayout;
import com.onegravity.rteditor.utils.Selection;

import java.util.ArrayList;
import java.util.HashSet;
import java.util.Set;

/**
 * The actual rich text editor (extending android.widget.EditText).
 */
public class RTEditText extends EditText implements TextWatcher, SpanWatcher, LinkSpanListener {

    // don't allow any formatting in text mode
    private boolean mUseRTFormatting = true;

    // for performance reasons we compute a new layout only if the text has changed
    private boolean mLayoutChanged;
    private RTLayout mRTLayout;    // don't call this mLayout because TextView has a mLayout too (no shadowing as both are private but still...)

    // while onSaveInstanceState() is running, don't modify any spans
    private boolean mIsSaving;

    /// while selection is changing don't apply any effects
    private boolean mIsSelectionChanging = false;

    // text has changed
    private boolean mTextChanged;

    // this indicates whether text is selected or not -> ignore window focus changes (by spinners)
    private boolean mTextSelected;

    private RTEditTextListener mListener;

    private RTMediaFactory<RTImage, RTAudio, RTVideo> mMediaFactory;

    // used to check if selection has changed
    private int mOldSelStart = -1;
    private int mOldSelEnd = -1;
    // we don't want to call Effects.cleanupParagraphs() if the paragraphs are already up to date
    private boolean mParagraphsAreUp2Date;
    // while Effects.cleanupParagraphs() is called, we ignore changes that would alter mParagraphsAreUp2Date
    private boolean mIgnoreParagraphChanges;

    /* Used for the undo / redo functions */

    // if True then text changes are not registered for undo/redo
    // we need this during the actual undo/redo operation (or an undo would create a change event itself)
    private boolean mIgnoreTextChanges;

    // Keep track of ordered list spans.
    // If one of them is selected, then append zero with char after new line.
    // This fixes the bug when bullet/number span is not applied to empty string
    private boolean mIsBulletSpanSelected;
    private boolean mIsNumberSpanSelected;
    // The length of text before new char is added
    private int mPreviousTextLength;

    private int mSelStartBefore;        // selection start before text changed
    private int mSelEndBefore;          // selection end before text changed
    private String mOldText;            // old text before it changed
    private String mNewText;            // new text after it changed (needed in afterTextChanged to see if the text has changed)
    private Spannable mOldSpannable;    // undo/redo

    // we need to keep track of the media for this editor to be able to clean up after we're done
    private Set<RTMedia> mOriginalMedia = new HashSet<RTMedia>();
    private Set<RTMedia> mAddedMedia = new HashSet<RTMedia>();

    // ****************************************** Lifecycle Methods *******************************************

    public RTEditText(Context context) {
        super(context);
        init();
    }

    public RTEditText(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    public RTEditText(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
        init();
    }

    private void init() {
        addTextChangedListener(this);
        // we need this or links won't be clickable
        setMovementMethod(RTEditorMovementMethod.getInstance());
    }

    /**
     * @param isSaved True if the text is saved, False if it's dismissed
     */
    void onDestroy(boolean isSaved) {
        // make sure all obsolete MediaSpan files are removed from the file system:
        // - when saving the text delete the MediaSpan if it was deleted
        // - when dismissing the text delete the MediaSpan if it was deleted and not saved before

        // collect all media the editor contains currently
        Set<RTMedia> mCurrentMedia = new HashSet<RTMedia>();
        Spannable text = getText();
        for (MediaSpan span : text.getSpans(0, text.length(), MediaSpan.class)) {
            mCurrentMedia.add(span.getMedia());
        }

        // now delete all those that aren't needed any longer
        Set<RTMedia> mMedia2Delete = isSaved ? mOriginalMedia : mCurrentMedia;
        mMedia2Delete.addAll(mAddedMedia);
        Set<RTMedia> mMedia2Keep = isSaved ? mCurrentMedia : mOriginalMedia;
        for (RTMedia media : mMedia2Delete) {
            if (!mMedia2Keep.contains(media)) {
                media.remove();
            }
        }
    }

    /**
     * Needs to be called if a media is added to the editor.
     * Important to be able to delete obsolete media once we're done editing.
     */
    void onAddMedia(RTMedia media) {
        mAddedMedia.add(media);
    }

    /**
     * This needs to be called before anything else because we need the media
     * factory.
     *
     * @param listener     The RTEditTextListener (the RTManager)
     * @param mediaFactory The RTMediaFactory
     */
    void register(RTEditTextListener listener, RTMediaFactory<RTImage, RTAudio, RTVideo> mediaFactory) {
        mListener = listener;
        mMediaFactory = mediaFactory;
    }

    /**
     * Usually called from the RTManager.onDestroy() method
     */
    void unregister() {
        mListener = null;
        mMediaFactory = null;
    }

    /**
     * Return all paragraphs as as array of selection objects
     */
    public ArrayList<Paragraph> getParagraphs() {
        return getRTLayout().getParagraphs();
    }

    /**
     * Find the start and end of the paragraph(s) encompassing the current selection.
     * A paragraph spans from one \n (exclusive) to the next one (inclusive)
     */
    public Selection getParagraphsInSelection() {
        RTLayout layout = getRTLayout();

        Selection selection = new Selection(this);
        int firstLine = layout.getLineForOffset(selection.start());
        int end = selection.isEmpty() ? selection.end() : selection.end() - 1;
        int lastLine = layout.getLineForOffset(end);

        return new Selection(layout.getLineStart(firstLine), layout.getLineEnd(lastLine));
    }

    private RTLayout getRTLayout() {
        synchronized (this) {
            if (mRTLayout == null || mLayoutChanged) {
                mRTLayout = new RTLayout(getText());
                mLayoutChanged = false;
            }
        }
        return mRTLayout;
    }

    /**
     * This method returns the Selection which makes sure that selection start is <= selection end.
     * Note: getSelectionStart()/getSelectionEnd() refer to the order in which text was selected.
     */
    Selection getSelection() {
        int selStart = getSelectionStart();
        int selEnd = getSelectionEnd();
        return new Selection(selStart, selEnd);
    }

    /**
     * @return the selected text (needed when creating links)
     */
    String getSelectedText() {
        Spannable text = getText();
        Selection sel = getSelection();
        if (sel.start() >= 0 && sel.end() >= 0 && sel.end() <= text.length()) {
            return text.subSequence(sel.start(), sel.end()).toString();
        }
        return null;
    }

    public Spannable cloneSpannable() {
        CharSequence text = super.getText();
        return new ClonedSpannableString(text != null ? text : "");
    }

    // ****************************************** Set/Get Text Methods *******************************************

    /**
     * Sets the edit mode to plain or rich text. The text will be converted
     * automatically to rich/plain text if autoConvert is True.
     *
     * @param useRTFormatting True if the edit mode should be rich text, False if the edit
     *                        mode should be plain text
     * @param autoConvert     Automatically convert the content to plain or rich text if
     *                        this is True
     */
    public void setRichTextEditing(boolean useRTFormatting, boolean autoConvert) {
        assertRegistration();

        if (useRTFormatting != mUseRTFormatting) {
            mUseRTFormatting = useRTFormatting;

            if (autoConvert) {
                RTFormat targetFormat = useRTFormatting ? RTFormat.PLAIN_TEXT : RTFormat.HTML;
                setText(getRichText(targetFormat));
            }

            if (mListener != null) {
                mListener.onRichTextEditingChanged(this, mUseRTFormatting);
            }
        }
    }

    /**
     * Sets the edit mode to plain or rich text and updates the content at the
     * same time. The caller needs to make sure the content matches the correct
     * format (if you pass in html code as plain text the editor will show the
     * html code).
     *
     * @param useRTFormatting True if the edit mode should be rich text, False if the edit
     *                        mode should be plain text
     * @param content         The new content
     */
    public void setRichTextEditing(boolean useRTFormatting, String content) {
        assertRegistration();

        if (useRTFormatting != mUseRTFormatting) {
            mUseRTFormatting = useRTFormatting;

            if (mListener != null) {
                mListener.onRichTextEditingChanged(this, mUseRTFormatting);
            }
        }

        RTText rtText = useRTFormatting ?
                new RTHtml<RTImage, RTAudio, RTVideo>(RTFormat.HTML, content) :
                new RTPlainText(content);

        setText(rtText);
    }

    /**
     * Set the text for this editor.
     *

     * It will convert the text from rich text to plain text if the editor's
     * mode is set to use plain text. or to a spanned text (only supported
     * formatting) if the editor's mode is set to use rich text
     *

     * We need to prevent onSelectionChanged() to do anything as long as
     * setText() hasn't finished because the Layout doesn't seem to update
     * before setText has finished but onSelectionChanged will still be called
     * during setText and will receive the out-dated Layout which doesn't allow
     * us to apply styles and such.
     */
    public void setText(RTText rtText) {
        assertRegistration();

        if (rtText.getFormat() instanceof RTFormat.Html) {
            if (mUseRTFormatting) {
                RTText rtSpanned = rtText.convertTo(RTFormat.SPANNED, mMediaFactory);

                super.setText(rtSpanned.getText(), TextView.BufferType.EDITABLE);
                addSpanWatcher();

                // collect all current media
                Spannable text = getText();
                for (MediaSpan span : text.getSpans(0, text.length(), MediaSpan.class)) {
                    mOriginalMedia.add(span.getMedia());
                }

                Effects.cleanupParagraphs(this);
            } else {
                RTText rtPlainText = rtText.convertTo(RTFormat.PLAIN_TEXT, mMediaFactory);
                super.setText(rtPlainText.getText());
            }
        } else if (rtText.getFormat() instanceof RTFormat.PlainText) {
            CharSequence text = rtText.getText();
            super.setText(text == null ? "" : text.toString());
        }

        onSelectionChanged(0, 0);
    }

    public boolean usesRTFormatting() {
        return mUseRTFormatting;
    }

    /**
     * Returns the content of this editor as a String. The caller is responsible
     * to call only formats that are supported by RTEditable (which is the rich
     * text editor's format and always the source format).
     *
     * @param format The RTFormat the text should be converted to.
     * @throws UnsupportedOperationException if the target format isn't supported.
     */
    public String getText(RTFormat format) {
        return getRichText(format).getText().toString();
    }

    /**
     * Same as "String getText(RTFormat format)" but this method returns the
     * RTText instead of just the actual text.
     */
    public RTText getRichText(RTFormat format) {
        assertRegistration();

        RTEditable rtEditable = new RTEditable(this);
        return rtEditable.convertTo(format, mMediaFactory);
    }

    private void assertRegistration() {
        if (mMediaFactory == null) {
            throw new IllegalStateException("The RTMediaFactory is null. Please make sure to register the editor at the RTManager before using it.");
        }
    }

    // ****************************************** TextWatcher / SpanWatcher *******************************************

    public boolean hasChanged() {
        return mTextChanged;
    }

    public void resetHasChanged() {
        mTextChanged = false;
        setParagraphsAreUp2Date(false);
    }

    /**
     * Ignore changes that would trigger a RTEditTextListener.onTextChanged()
     * method call. We need this during the actual undo/redo operation (or an
     * undo would create a change event itself).
     */
    synchronized void ignoreTextChanges() {
        mIgnoreTextChanges = true;
    }

    /**
     * If changes happen call RTEditTextListener.onTextChanged().
     * This is needed for the undo/redo functionality.
     */
    synchronized void registerTextChanges() {
        mIgnoreTextChanges = false;
    }

    @Override
    /* TextWatcher */
    public synchronized void beforeTextChanged(CharSequence s, int start, int count, int after) {
        // we use a String to get a static copy of the CharSequence (the CharSequence changes when the text changes...)
        String oldText = mOldText == null ? "" : mOldText;
        if (!mIgnoreTextChanges && !s.toString().equals(oldText)) {
            mSelStartBefore = getSelectionStart();
            mSelEndBefore = getSelectionEnd();
            mOldText = s.toString();
            mNewText = mOldText;
            mOldSpannable = cloneSpannable();
        }
        mLayoutChanged = true;
        mPreviousTextLength = s.length();
    }

    @Override
    /* TextWatcher */
    public synchronized void onTextChanged(CharSequence s, int start, int before, int count) {
        mLayoutChanged = true;
    }

    @Override
    /* TextWatcher */
    public synchronized void afterTextChanged(Editable s) {
        if (mIsBulletSpanSelected || mIsNumberSpanSelected) {
            boolean mBackSpace = mPreviousTextLength >= s.length();
            if (!mBackSpace && s.toString().endsWith("\n")) {
                // append zero width character
                this.append("\u200B");
            }
        }
        String theText = s.toString();
        String newText = mNewText == null ? "" : mNewText;
        if (mListener != null && !mIgnoreTextChanges && !newText.equals(theText)) {
            Spannable newSpannable = cloneSpannable();
            mListener.onTextChanged(this, mOldSpannable, newSpannable, mSelStartBefore, mSelEndBefore, getSelectionStart(), getSelectionEnd());
            mNewText = theText;
        }
        mLayoutChanged = true;
        mTextChanged = true;
        setParagraphsAreUp2Date(false);
        addSpanWatcher();
    }

    @Override
    /* SpanWatcher */
    public void onSpanAdded(Spannable text, Object what, int start, int end) {
        mTextChanged = true;
        // we need to keep track of ordered list spans
        if (what instanceof BulletSpan) {
            mIsBulletSpanSelected = true;
            // if text was empty then append zero width char
            // in order for the bullet to be shown when the span is selected
            if (text.toString().isEmpty()) {
                this.append("\u200B");
            }
        } else if (what instanceof NumberSpan) {
            mIsNumberSpanSelected = true;
            // if text was empty then append zero width char
            // in order for the number to be shown when the span is selected
            if (text.toString().isEmpty()) {
                this.append("\u200B");
            }
        }

        if (what instanceof RTSpan && what instanceof ParagraphStyle) {
            setParagraphsAreUp2Date(false);
        }
    }

    @Override
    /* SpanWatcher */
    public void onSpanChanged(Spannable text, Object what, int ostart, int oend, int nstart, int nend) {
        mTextChanged = true;
        if (what instanceof RTSpan && what instanceof ParagraphStyle) {
            setParagraphsAreUp2Date(false);
        }
    }

    @Override
    /* SpanWatcher */
    public void onSpanRemoved(Spannable text, Object what, int start, int end) {
        mTextChanged = true;
        // we need to keep track of ordered list spans
        if (what instanceof BulletSpan) {
            mIsBulletSpanSelected = false;
        } else if (what instanceof NumberSpan) {
            mIsNumberSpanSelected = false;
        }

        if (what instanceof RTSpan && what instanceof ParagraphStyle) {
            setParagraphsAreUp2Date(false);
        }
    }

    /**
     * Add a SpanWatcher for the Changeable implementation
     */
    private void addSpanWatcher() {
        Spannable spannable = getText();
        if (spannable.getSpans(0, spannable.length(), getClass()) != null) {
            spannable.setSpan(this, 0, spannable.length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE);
        }
    }

    synchronized private void setParagraphsAreUp2Date(boolean value) {
        if (! mIgnoreParagraphChanges) {
            mParagraphsAreUp2Date = value;
        }
    }

    // ****************************************** Listener Methods *******************************************

    @Override
    public void onWindowFocusChanged(boolean hasWindowFocus) {
        // if text is selected we ignore a loss of focus to prevent Android from terminating
        // text selection when one of the spinners opens (text size, color, bg color)
        if (!mUseRTFormatting || hasWindowFocus || !mTextSelected) {
            super.onWindowFocusChanged(hasWindowFocus);
        }
    }

    @Override
    protected void onFocusChanged(boolean focused, int direction, Rect previouslyFocusedRect) {
        super.onFocusChanged(focused, direction, previouslyFocusedRect);

        if (mUseRTFormatting && mListener != null) {
            mListener.onFocusChanged(this, focused);
        }
    }

    @Override
    public Parcelable onSaveInstanceState() {
        mIsSaving = true;

        Parcelable superState = super.onSaveInstanceState();
        String content = getText(mUseRTFormatting ? RTFormat.HTML : RTFormat.PLAIN_TEXT);
        SavedState savedState = new SavedState(superState, mUseRTFormatting, content);

        mIsSaving = false;
        return savedState;
    }

    @Override
    public void onRestoreInstanceState(Parcelable state) {
        if(state instanceof SavedState) {
            SavedState savedState = (SavedState)state;
            super.onRestoreInstanceState(savedState.getSuperState());
            setRichTextEditing(savedState.useRTFormatting(), savedState.getContent());
        }
        else {
            super.onRestoreInstanceState(state);
        }

        if (mListener != null) {
            mListener.onRestoredInstanceState(this);
        }
    }

    private static class SavedState extends BaseSavedState {
        private String mContent;
        private boolean mUseRTFormatting;

        SavedState(Parcelable superState, boolean useRTFormatting, String content) {
            super(superState);

            mUseRTFormatting = useRTFormatting;
            mContent = content;
        }

        private String getContent() {
            return mContent;
        }

        private boolean useRTFormatting() {
            return mUseRTFormatting;
        }

        private SavedState(Parcel in) {
            super(in);

            mUseRTFormatting = in.readInt() == 1;
            mContent = in.readString();
        }

        @Override
        public void writeToParcel(Parcel out, int flags) {
            super.writeToParcel(out, flags);

            out.writeInt(mUseRTFormatting ? 1 : 0);
            out.writeString(mContent);
        }

        public static final Parcelable.Creator<SavedState> CREATOR =
                new Parcelable.Creator<SavedState>() {
                    public SavedState createFromParcel(Parcel in) {
                        return new SavedState(in);
                    }
                    public SavedState[] newArray(int size) {
                        return new SavedState[size];
                    }
                };
    }

    @Override
    protected void onSelectionChanged(int start, int end) {
        if (mOldSelStart != start || mOldSelEnd != end) {
            mOldSelStart = start;
            mOldSelEnd = end;

            mTextSelected = (end > start);

            super.onSelectionChanged(start, end);

            if (mUseRTFormatting) {

                if (!mIsSaving && !mParagraphsAreUp2Date) {
                    mIgnoreParagraphChanges = true;
                    Effects.cleanupParagraphs(this);
                    mIgnoreParagraphChanges = false;
                    setParagraphsAreUp2Date(true);
                }

                if (mListener != null) {
                    mIsSelectionChanging = true;
                    mListener.onSelectionChanged(this, start, end);
                    mIsSelectionChanging = false;
                }

            }
        }
    }

    /**
     * Call this to have an effect applied to the current selection.
     * You get the Effect object via the static data members (e.g., RTEditText.BOLD).
     * The value for most effects is a Boolean, indicating whether to add or remove the effect.
     */
    public <V extends Object, C extends RTSpan<V>> void applyEffect(Effect<V, C> effect, V value) {
        if (mUseRTFormatting && !mIsSelectionChanging && !mIsSaving) {
            Spannable oldSpannable = mIgnoreTextChanges ? null : cloneSpannable();

            effect.applyToSelection(this, value);

            synchronized (this) {
                if (mListener != null && !mIgnoreTextChanges) {
                    Spannable newSpannable = cloneSpannable();
                    mListener.onTextChanged(this, oldSpannable, newSpannable, getSelectionStart(), getSelectionEnd(),
                                            getSelectionStart(), getSelectionEnd());
                }
                mLayoutChanged = true;
            }
        }
    }

    @Override
    /* LinkSpanListener */
    public void onClick(LinkSpan linkSpan) {
        if (mUseRTFormatting && mListener != null) {
            mListener.onClick(this, linkSpan);
        }
    }

}


/*
 * Copyright (C) 2015-2018 Emanuel Moecklin
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.onegravity.rteditor.converter;

import android.text.Annotation;
import android.text.Editable;
import android.text.Html;
import android.text.Spanned;

import com.onegravity.rteditor.api.format.RTHtml;
import com.onegravity.rteditor.api.format.RTPlainText;
import com.onegravity.rteditor.api.media.RTAudio;
import com.onegravity.rteditor.api.media.RTImage;
import com.onegravity.rteditor.api.media.RTVideo;

import org.xml.sax.XMLReader;

import java.util.Collections;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;

/**
 * Converts html to plain text
 */
public class ConverterHtmlToText {

    /**
     * When generating previews, Spannable objects that can't be converted into a String are
     * represented as 0xfffc. When displayed, these show up as undisplayed squares. These constants
     * define the object character and the replacement character.
     */
    private static final char PREVIEW_OBJECT_CHARACTER = (char) 0xfffc;
    private static final char PREVIEW_OBJECT_REPLACEMENT = (char) 0x20;  // space

    /**
     * toHtml() converts non-breaking spaces into the UTF-8 non-breaking space, which doesn't get
     * rendered properly in some clients. Replace it with a simple space.
     */
    private static final char NBSP_CHARACTER = (char) 0x00a0;    // utf-8 non-breaking space
    private static final char NBSP_REPLACEMENT = (char) 0x20;    // space

    public static RTPlainText convert(RTHtml<? extends RTImage, ? extends RTAudio, ? extends RTVideo> input) {
        String result = Html.fromHtml(input.getText(), null, new HtmlToTextTagHandler())
                .toString()
                .replace(PREVIEW_OBJECT_CHARACTER, PREVIEW_OBJECT_REPLACEMENT)
                .replace(NBSP_CHARACTER, NBSP_REPLACEMENT);
        return new RTPlainText(result);
    }

    public static String convert(String text) {
        return Html.fromHtml(text, null, new HtmlToTextTagHandler())
                .toString()
                .replace(PREVIEW_OBJECT_CHARACTER, PREVIEW_OBJECT_REPLACEMENT)
                .replace(NBSP_CHARACTER, NBSP_REPLACEMENT);
    }

    /**
     * Custom tag handler to use when converting HTML messages to text. It currently handles text
     * representations of HTML tags that Android's built-in parser doesn't understand and hides code
     * contained in STYLE and SCRIPT blocks.
     */
    private static class HtmlToTextTagHandler implements Html.TagHandler {
        // List of tags whose content should be ignored.
        private static final Set<String> TAGS_WITH_IGNORED_CONTENT;

        static {
            Set<String> set = new HashSet<String>();
            set.add("style");
            set.add("script");
            set.add("title");
            set.add("!");   // comments
            TAGS_WITH_IGNORED_CONTENT = Collections.unmodifiableSet(set);
        }

        @Override
        public void handleTag(boolean opening, String tag, Editable output, XMLReader xmlReader) {
            tag = tag.toLowerCase(Locale.US);
            if (tag.equals("hr") && opening) {
                // In the case of an <hr>, replace it with a bunch of underscores. This is roughly
                // the behaviour of Outlook in Rich Text mode.
                output.append("_____________________________________________\n");
            } else if (TAGS_WITH_IGNORED_CONTENT.contains(tag)) {
                handleIgnoredTag(opening, output);
            }
        }

        private static final String IGNORED_ANNOTATION_KEY = "RT_ANNOTATION";
        private static final String IGNORED_ANNOTATION_VALUE = "hiddenSpan";

        /**
         * When we come upon an ignored tag, we mark it with an Annotation object with a specific key
         * and value as above. We don't really need to be checking these values since Html.fromHtml()
         * doesn't use Annotation spans, but we should do it now to be safe in case they do start using
         * it in the future.
         *
         * @param opening If this is an opening tag or not.
         * @param output  Spannable string that we're working with.
         */
        private void handleIgnoredTag(boolean opening, Editable output) {
            int len = output.length();
            if (opening) {
                output.setSpan(new Annotation(IGNORED_ANNOTATION_KEY, IGNORED_ANNOTATION_VALUE), len,
                        len, Spanned.SPAN_MARK_MARK);
            } else {
                Object start = getOpeningAnnotation(output);
                if (start != null) {
                    int where = output.getSpanStart(start);
                    // Remove the temporary Annotation span.
                    output.removeSpan(start);
                    // Delete everything between the start of the Annotation and the end of the string
                    // (what we've generated so far).
                    output.delete(where, len);
                }
            }
        }

        /**
         * Fetch the matching opening Annotation object and verify that it's the one added by us.
         *
         * @param output Spannable string we're working with.
         * @return Starting Annotation object.
         */
        private Object getOpeningAnnotation(Editable output) {
            Object[] objs = output.getSpans(0, output.length(), Annotation.class);
            for (int i = objs.length - 1; i >= 0; i--) {
                Annotation span = (Annotation) objs[i];
                if (output.getSpanFlags(objs[i]) == Spanned.SPAN_MARK_MARK
                        && span.getKey().equals(IGNORED_ANNOTATION_KEY)
                        && span.getValue().equals(IGNORED_ANNOTATION_VALUE)) {
                    return objs[i];
                }
            }
            return null;
        }
    }

}


/*
 * Copyright (C) 2015-2018 Emanuel Moecklin
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.onegravity.rteditor.api.format;

import com.onegravity.rteditor.api.RTMediaFactory;
import com.onegravity.rteditor.api.media.RTAudio;
import com.onegravity.rteditor.api.media.RTImage;
import com.onegravity.rteditor.api.media.RTVideo;

/**
 * The RTText is the base class for all classes representing a rich text.
 *

 * A rich text is defined by its format (RTFormat) and its content
 * (CharSequence). Subclasses extend it to implement representations of concrete
 * rich text types like Spanned rich text (used in the actual editor) or html
 * rich text (usually used to store the rich content).
 */
public abstract class RTText {

    final private RTFormat mRTFormat;
    final private CharSequence mRTText;

    /**
     * Use this constructor if the class supports exactly one rich text format
     * and that format is immutable meaning sub classes won't be able to change
     * its format (e.g. RTPlainText).
     *
     * @param rtFormat The rich text format
     */
    public RTText(RTFormat rtFormat, CharSequence text) {
        mRTFormat = rtFormat;
        mRTText = text;
    }

    /**
     * Return the format of this rich text.
     */
    public RTFormat getFormat() {
        return mRTFormat;
    }

    /**
     * Return the content of this rich text.
     */
    public CharSequence getText() {
        return mRTText;
    }

    /**
     * Converts this rich text to another rich text. The default implementation
     * doesn't support any conversion except the one to itself (which is
     * technically no conversion).
     *

     * The method has to make sure that the original rich text isn't modified.
     * It does however not make sure that the returned RTText isn't referencing
     * the original RTText meaning modifying the resulting object might also
     * modify the original object.
     *
     * @param destFormat   The rich text we want to convert to.
     * @param mediaFactory The media factory we might need for the conversion.
     * @return The converted RTText
     * @throws UnsupportedOperationException if this RTText doesn't support the conversion.
     */
    public RTText convertTo(RTFormat destFormat, RTMediaFactory<RTImage, RTAudio, RTVideo> mediaFactory) {
        if (destFormat == mRTFormat) {
            return this;
        }

        throw new UnsupportedOperationException("Can't convert from "
                + mRTFormat.getClass().getSimpleName() + " to "
                + destFormat.getClass().getSimpleName());
    }

}