/*
 * Copyright (C) 2015-2023 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.Spanned;
import android.text.style.AbsoluteSizeSpan;
import android.text.style.BackgroundColorSpan;
import android.text.style.CharacterStyle;
import android.text.style.ForegroundColorSpan;
import android.text.style.ParagraphStyle;
import android.text.style.StrikethroughSpan;
import android.text.style.SubscriptSpan;
import android.text.style.SuperscriptSpan;
import android.text.style.URLSpan;
import com.onegravity.rteditor.spans.UnderlineSpan;

import com.onegravity.rteditor.api.format.RTFormat;
import com.onegravity.rteditor.api.format.RTHtml;
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.util.StringEscapeUtils;
import com.onegravity.rteditor.spans.AudioSpan;
import com.onegravity.rteditor.spans.BoldSpan;
import com.onegravity.rteditor.spans.TypefaceSpan;
import com.onegravity.rteditor.spans.ImageSpan;
import com.onegravity.rteditor.spans.ItalicSpan;
import com.onegravity.rteditor.spans.LinkSpan;
import com.onegravity.rteditor.spans.VideoSpan;
import com.onegravity.rteditor.utils.Helper;
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.Arrays;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.SortedSet;
import java.util.Stack;
import java.util.TreeSet;

/**
 * Converts Spanned text to html
 */
public class ConverterSpannedToHtml {

    private static final String BR = "<br/>\n";
    private static final String LT = "&lt;";
    private static final String GT = "&gt;";
    private static final String AMP = "&amp;";
    private static final String NBSP = "&nbsp;";

    private StringBuilder mOut;
    private Spanned mText;
    private RTFormat mRTFormat;
    private List<RTImage> mImages;
    private Stack<AccumulatedParagraphStyle> mParagraphStyles = new Stack<AccumulatedParagraphStyle>();

    /**
     * Converts a spanned text to HTML
     */
    public RTHtml<RTImage, RTAudio, RTVideo> convert(final Spanned text, RTFormat.Html rtFormat) {
        mText = text;
        mRTFormat = rtFormat;

        mOut = new StringBuilder();
        mImages = new ArrayList<>();
        mParagraphStyles.clear();

        // convert paragraphs
        convertParagraphs();

        return new RTHtml<>(rtFormat, mOut.toString(), mImages);
    }

    // ****************************************** Process Paragraphs *******************************************

    private void convertParagraphs() {
        RTLayout rtLayout = new RTLayout(mText);

        // a manual for loop is faster than the for-each loop for an ArrayList:
        // see https://developer.android.com/training/articles/perf-tips.html#Loops
        ArrayList<Paragraph> paragraphs = rtLayout.getParagraphs();
        for (int i = 0, size = paragraphs.size(); i < size; i++) {
            Paragraph paragraph = paragraphs.get(i);

            // retrieve all spans for this paragraph
            Set<SingleParagraphStyle> styles = getParagraphStyles(mText, paragraph);

            // get the alignment span if there is any
            ParagraphType alignmentType = null;
            for (SingleParagraphStyle style : styles) {
                if (style.getType().isAlignment()) {
                    alignmentType = style.getType();
                    break;
                }
            }

            /*
             * start tag: bullet points, numbering and indentation
             */
            int newIndent = 0;
            ParagraphType newType = ParagraphType.NONE;
            for (SingleParagraphStyle style : styles) {
                newIndent += style.getIndentation();
                ParagraphType type = style.getType();
                newType = type.isBullet() ? ParagraphType.BULLET :
                        type.isNumbering() ? ParagraphType.NUMBERING :
                                type.isIndentation() && newType.isUndefined() ? ParagraphType.INDENTATION_UL : newType;
            }
            // process leading margin style
            processLeadingMarginStyle(new AccumulatedParagraphStyle(newType, newIndent, 0));
            // add start list tag
            mOut.append(newType.getListStartTag());

            /*
             * start tag: alignment (left, center, right)
             */
            if (alignmentType != null) {
                mOut.append(alignmentType.getStartTag());
            }

            /*
             * Convert the plain text
             */
            withinParagraph(mText, paragraph.start(), paragraph.end());

            /*
             * end tag: alignment (left, center, right)
             */
            if (alignmentType != null) {
                removeTrailingLineBreak(alignmentType);
                mOut.append(alignmentType.getEndTag());
            }


            // add end list tag
            removeTrailingLineBreak(newType);
            mOut.append(newType.getListEndTag());
        }

        /*
         * end tag: bullet points and indentation
         */
        while (!mParagraphStyles.isEmpty()) {
            removeParagraph();
        }
    }

    private void removeTrailingLineBreak(ParagraphType type) {
        if (type.endTagAddsLineBreak() && mOut.length() >= BR.length()) {
            int start = mOut.length() - BR.length();
            int end = mOut.length();
            if (mOut.subSequence(start, end).equals(BR)) {
                mOut.delete(start, end);
            }
        }
    }

    private Set<SingleParagraphStyle> getParagraphStyles(final Spanned text, Selection selection) {
        Set<SingleParagraphStyle> styles = new HashSet<SingleParagraphStyle>();

        for (ParagraphStyle style : text.getSpans(selection.start(), selection.end(), ParagraphStyle.class)) {
            ParagraphType type = ParagraphType.getInstance(style);
            if (type != null) {
                styles.add(new SingleParagraphStyle(type, style));
            }
        }

        return styles;
    }

    private void processLeadingMarginStyle(AccumulatedParagraphStyle newStyle) {
        int currentIndent = 0;
        ParagraphType currentType = ParagraphType.NONE;
        if (!mParagraphStyles.isEmpty()) {
            AccumulatedParagraphStyle currentStyle = mParagraphStyles.peek();
            currentIndent = currentStyle.getAbsoluteIndent();
            currentType = currentStyle.getType();
        }

        if (newStyle.getAbsoluteIndent() > currentIndent) {
            newStyle.setRelativeIndent(newStyle.getAbsoluteIndent() - currentIndent);
            addParagraph(newStyle);
        } else if (newStyle.getAbsoluteIndent() < currentIndent) {
            removeParagraph();
            processLeadingMarginStyle(newStyle);
        } else if (newStyle.getType() != currentType) {
            newStyle.setRelativeIndent(removeParagraph());
            addParagraph(newStyle);
        }
    }

    private int removeParagraph() {
        if (!mParagraphStyles.isEmpty()) {
            AccumulatedParagraphStyle style = mParagraphStyles.pop();
            String tag = style.getType().getEndTag();
            int indent = style.getRelativeIndent();
            for (int i = 0; i < indent; i++) {
                mOut.append(tag);
            }
            return style.getRelativeIndent();
        }
        return 0;
    }

    private void addParagraph(AccumulatedParagraphStyle style) {
        String tag = style.getType().getStartTag();
        int indent = style.getRelativeIndent();
        for (int i = 0; i < indent; i++) {
            mOut.append(tag);
        }
        mParagraphStyles.push(style);
    }

    // ****************************************** Process Text *******************************************

    /**
     * Convert a spanned text within a paragraph
     */
    private void withinParagraph(final Spanned text, int start, int end) {
        // create sorted set of CharacterStyles
        SortedSet<CharacterStyle> sortedSpans = new TreeSet<>((s1, s2) -> {
            int start1 = text.getSpanStart(s1);
            int start2 = text.getSpanStart(s2);
            if (start1 != start2)
                return start1 - start2;        // span which starts first comes first

            int end1 = text.getSpanEnd(s1);
            int end2 = text.getSpanEnd(s2);
            if (end1 != end2) return end2 - end1;                // longer span comes first

            // if the paragraphs have the same span [start, end] we compare their name
            // compare the name only because local + anonymous classes have no canonical name
            return s1.getClass().getName().compareTo(s2.getClass().getName());
        });
        List<CharacterStyle> spanList = Arrays.asList(text.getSpans(start, end, CharacterStyle.class));
        sortedSpans.addAll(spanList);

        // process paragraphs/divs
        convertText(text, start, end, sortedSpans);
    }

    private void convertText(Spanned text, int start, int end, SortedSet<CharacterStyle> spans) {
        while (start < end) {

            // get first CharacterStyle
            CharacterStyle span = spans.isEmpty() ? null : spans.first();
            int spanStart = span == null ? Integer.MAX_VALUE : text.getSpanStart(span);
            int spanEnd = span == null ? Integer.MAX_VALUE : text.getSpanEnd(span);

            if (start < spanStart) {

                // no paragraph, just plain text
                escape(text, start, Math.min(end, spanStart));
                start = spanStart;

            } else {

                // CharacterStyle found

                spans.remove(span);

                if (handleStartTag(span)) {
                    convertText(text, Math.max(spanStart, start), Math.min(spanEnd, end), spans);
                }
                handleEndTag(span);

                start = spanEnd;
            }

        }
    }

    /**
     * @return True if the text between the tags should be converted too, False if it should be skipped (ImageSpan e.g.)
     */
    private boolean handleStartTag(CharacterStyle style) {
        if (style instanceof BoldSpan) {
            mOut.append("<b>");
        } else if (style instanceof ItalicSpan) {
            mOut.append("<i>");
        } else if (style instanceof UnderlineSpan) {
            mOut.append("<u>");
        } else if (style instanceof SuperscriptSpan) {
            mOut.append("<sup>");
        } else if (style instanceof SubscriptSpan) {
            mOut.append("<sub>");
        } else if (style instanceof StrikethroughSpan) {
            mOut.append("<strike>");
        }
        /* Examples for fonts styles:
           <font face="verdana" style="font-size:25px;background-color:#00ff00;color:#ff0000">This is heading 1</font>
           <font face="DroidSans" style="font-size:50px;background-color:#0000FF;color:#FFFF00">This is heading 2</font>
        */
        else if (style instanceof TypefaceSpan) {
            mOut.append("<font face=\"");
            String fontName = ((TypefaceSpan) style).getValue().getName();
            mOut.append(StringEscapeUtils.escapeHtml4(fontName));
            mOut.append("\">");
        } else if (style instanceof AbsoluteSizeSpan) {
            mOut.append("<font style=\"font-size:");
            int size = ((AbsoluteSizeSpan) style).getSize();
            size = Helper.convertSpToPx(size);
            mOut.append(size);
            mOut.append("px\">");
        } else if (style instanceof ForegroundColorSpan) {
            mOut.append("<font style=\"color:#");
            String color = Integer.toHexString(((ForegroundColorSpan) style).getForegroundColor() + 0x01000000);
            while (color.length() < 6) {
                color = "0" + color;
            }
            mOut.append(color);
            mOut.append("\">");
        } else if (style instanceof BackgroundColorSpan) {
            mOut.append("<font style=\"background-color:#");
            String color = Integer.toHexString(((BackgroundColorSpan) style).getBackgroundColor() + 0x01000000);
            while (color.length() < 6) {
                color = "0" + color;
            }
            mOut.append(color);
            mOut.append("\">");
        } else if (style instanceof LinkSpan) {
            mOut.append("<a href=\"");
            mOut.append(((URLSpan) style).getURL());
            mOut.append("\">");
        } else if (style instanceof ImageSpan) {
            ImageSpan span = ((ImageSpan) style);
            RTImage image = span.getImage();
            mImages.add(image);
            String filePath = image.getFilePath(mRTFormat);
            mOut.append("<img src=\"" + filePath + "\">");
            return false;    // don't output the dummy character underlying the image.
        } else if (style instanceof AudioSpan) {
            AudioSpan span = ((AudioSpan) style);
            RTAudio audio = span.getAudio();
            String filePath = audio.getFilePath(mRTFormat);
            mOut.append("<embed src=\"" + filePath + "\">");
            return false;    // don't output the dummy character underlying the audio file.
        } else if (style instanceof VideoSpan) {
            VideoSpan span = ((VideoSpan) style);
            RTVideo video = span.getVideo();
            String filePath = video.getFilePath(mRTFormat);
            mOut.append("<video controls src=\"" + filePath + "\">");
            return false;    // don't output the dummy character underlying the video.
        }
        return true;
    }

    private void handleEndTag(CharacterStyle style) {
        if (style instanceof URLSpan) {
            mOut.append("</a>");
        } else if (style instanceof TypefaceSpan) {
            mOut.append("</font>");
        } else if (style instanceof ForegroundColorSpan) {
            mOut.append("</font>");
        } else if (style instanceof BackgroundColorSpan) {
            mOut.append("</font>");
        } else if (style instanceof AbsoluteSizeSpan) {
            mOut.append("</font>");
        } else if (style instanceof StrikethroughSpan) {
            mOut.append("</strike>");
        } else if (style instanceof SubscriptSpan) {
            mOut.append("</sub>");
        } else if (style instanceof SuperscriptSpan) {
            mOut.append("</sup>");
        } else if (style instanceof UnderlineSpan) {
            mOut.append("</u>");
        } else if (style instanceof BoldSpan) {
            mOut.append("</b>");
        } else if (style instanceof ItalicSpan) {
            mOut.append("</i>");
        }
    }

    /**
     * Escape plain text parts: <, >, &, Space --> ^lt;, &gt; etc.
     */
    private void escape(CharSequence text, int start, int end) {
        for (int i = start; i < end; i++) {
            char c = text.charAt(i);
            if (c == '\n') {
                mOut.append(BR);
            } else if (c == '<') {
                mOut.append(LT);
            } else if (c == '>') {
                mOut.append(GT);
            } else if (c == '&') {
                mOut.append(AMP);
            } else if (c == ' ') {
                while (i + 1 < end && text.charAt(i + 1) == ' ') {
                    mOut.append(NBSP);
                    i++;
                }
                mOut.append(' ');
            }
            // removed the c > 0x7E check to leave emoji unaltered
            else if (/*c > 0x7E || */c < ' ') {
                mOut.append("&#" + ((int) c) + ";");
            } else {
                mOut.append(c);
            }
        }
    }

}
