/*
 * Copyright 2013 Raffael Herzog
 *
 * This file is part of markdown-doclet.
 *
 * markdown-doclet is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * markdown-doclet is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with markdown-doclet.  If not, see <http://www.gnu.org/licenses/>.
 */
package ch.raffael.mddoclet.integrations.idea;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Pattern;

import com.google.common.base.CharMatcher;
import com.google.common.base.MoreObjects;
import com.intellij.codeInsight.javadoc.JavaDocUtil;
import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.module.Module;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.roots.ProjectFileIndex;
import com.intellij.openapi.roots.ProjectRootManager;
import com.intellij.openapi.vfs.VfsUtilCore;
import com.intellij.psi.JavaDocTokenType;
import com.intellij.psi.JavaPsiFacade;
import com.intellij.psi.PsiDocCommentOwner;
import com.intellij.psi.PsiElement;
import com.intellij.psi.PsiElementFactory;
import com.intellij.psi.PsiElementVisitor;
import com.intellij.psi.PsiFile;
import com.intellij.psi.PsiManager;
import com.intellij.psi.PsiWhiteSpace;
import com.intellij.psi.javadoc.PsiDocComment;
import com.intellij.psi.javadoc.PsiDocTag;
import com.intellij.psi.javadoc.PsiDocToken;
import com.intellij.psi.javadoc.PsiInlineDocTag;
import com.intellij.psi.tree.IElementType;
import com.intellij.psi.util.PsiTreeUtil;
import com.sun.javadoc.ClassDoc;
import com.sun.javadoc.Doc;
import com.sun.javadoc.MemberDoc;
import com.sun.javadoc.PackageDoc;
import com.sun.javadoc.SeeTag;
import com.sun.javadoc.SourcePosition;
import com.sun.javadoc.Tag;
import net.sourceforge.plantuml.SourceStringReader;
import org.pegdown.LinkRenderer;
import org.pegdown.ToHtmlSerializer;

import ch.raffael.mddoclet.DocletSerializer;
import ch.raffael.mddoclet.MarkdownDoclet;
import ch.raffael.mddoclet.Options;
import ch.raffael.mddoclet.mdtaglet.MarkdownTaglets;
import ch.raffael.mddoclet.tags.SeeTagRenderer;
import ch.raffael.mddoclet.tags.TagRendering;


/**
 * The work-horse, renders the JavaDoc comments using Markdown and creates a new
 * PsiDocComment that can be passed to IDEA's default QuickDoc implementation.
 *
 * @author <a href="mailto:[email protected]">Raffael Herzog</a>
 */
public class DocCommentProcessor {

    private static final Logger LOG = Logger.getInstance(DocCommentProcessor.class);

    private final static Pattern LEADING_ASTERISK_RE = Pattern.compile("^[ \\t]*\\*+ ?", Pattern.MULTILINE);
    public static final Pattern ABSOLUTE_IMG_RE = Pattern.compile("([a-zA-Z0-9+.-]+:|/).*");

    private final PsiFile file;
    private final Project project;
    private final PsiElementFactory psiElementFactory;
    private final MarkdownOptions.RenderingOptions markdownOptions;

    public DocCommentProcessor(PsiFile file) {
        this.file = file;
        if ( file == null ) {
            project = null;
            markdownOptions = null;
            psiElementFactory = null;
        }
        else {
            project = file.getProject();
            psiElementFactory = JavaPsiFacade.getInstance(project).getElementFactory();
            ProjectFileIndex fileIndex = ProjectRootManager.getInstance(project).getFileIndex();
            Module module = fileIndex.getModuleForFile(file.getVirtualFile());
            if ( module == null ) {
                markdownOptions = null;
            }
            else if ( !fileIndex.isInSourceContent(file.getVirtualFile()) ) {
                markdownOptions = null;
            }
            else if ( !Plugin.moduleConfiguration(module).isMarkdownEnabled() ) {
                markdownOptions = null;
            }
            else {
                markdownOptions = Plugin.moduleConfiguration(module).getRenderingOptions();
            }
        }
    }

    /**
     * Checks whether Markdown is enabled for the current file.
     *
     * @return `true`, if Markdown is enabled.
     */
    public boolean isEnabled() {
        return markdownOptions != null;
    }

    /**
     * Process the given `PsiDocComment` using Markdown and return a new `PsiDocComment`
     * representing the resulting HTML. The resulting `PsiDocComment` can then be passed
     * to IDEA's default QuickDoc implementation.
     *
     * @todo Error reporting ({@link com.sun.javadoc.DocErrorReporter} without {@link com.sun.javadoc.RootDoc}).
     *
     * @param docComment    The `PsiDocComment` to process.
     *
     * @return A `PsiDocComment` representing the resulting HTML.
     */
    public PsiDocComment processDocComment(PsiDocComment docComment) {
        if ( !isEnabled() || docComment == null ) {
            return docComment;
        }
        final Map<String, URL> umlDiagrams = generateUmlDiagrams(docComment);
        Options options = new Options() {
            @Override
            protected ToHtmlSerializer createDocletSerializer() {
                return new DocletSerializer(this, getLinkRenderer()) {
                    @Override
                    protected void printImageTag(LinkRenderer.Rendering rendering) {
                        URL diagram = umlDiagrams.get(rendering.href);
                        if ( diagram != null ) {
                            super.printImageTag(mapHref(rendering, diagram.toString()));
                        }
                        else if ( !ABSOLUTE_IMG_RE.matcher(rendering.href).matches() || rendering.href.contains("{@}") ) {
                            URL baseUrl = VfsUtilCore.convertToURL(file.getVirtualFile().getUrl());
                            if ( baseUrl != null ) {
                                try {
                                    super.printImageTag(mapHref(rendering, new URL(baseUrl, rendering.href).toString()));
                                }
                                catch ( MalformedURLException e ) {
                                    super.printImageTag(rendering);
                                }
                            }
                        }
                        else {
                            super.printImageTag(rendering);
                        }
                    }
                    private LinkRenderer.Rendering mapHref(LinkRenderer.Rendering rendering, String newHref) {
                        LinkRenderer.Rendering mapped = new LinkRenderer.Rendering(
                                newHref, rendering.text);
                        mapped.attributes.addAll(rendering.attributes);
                        return mapped;
                    }
                };
            }
        };
        PsiDocCommentOwner context = PsiTreeUtil.getParentOfType(docComment, PsiDocCommentOwner.class);
        markdownOptions.applyTo(options);
        MarkdownDoclet doclet = new MarkdownDoclet(options, null);
        MarkdownTaglets.instance().setDocErrorReporter(doclet);
        StringBuilder buf = new StringBuilder();
        StringBuilder tagBlock = new StringBuilder();
        boolean start = true;
        //List<String> inlineTags = new ArrayList<>();
        //List<String> tagBlockInlineTags = new ArrayList<>();
        for ( PsiElement elem : docComment.getChildren() ) {
            if ( elem instanceof PsiDocToken ) {
                IElementType tokenType = ((PsiDocToken)elem).getTokenType();
                if ( tokenType == JavaDocTokenType.DOC_COMMENT_START || tokenType == JavaDocTokenType.DOC_COMMENT_END ) {
                    continue;
                }
            }
            if ( start && elem instanceof PsiWhiteSpace ) {
                continue;
            }
            else if ( elem instanceof PsiInlineDocTag ) {
                start = false;
                if ( tagBlock.length() == 0 ) {
                    //inlineTags.add(elem.getText());
                    buf.append(elem.getText());
                }
                else {
                    //tagBlockInlineTags.add(elem.getText());
                    tagBlock.append(elem.getText());
                }
            }
            else if ( elem instanceof PsiDocTag ) {
                PsiDocTag docTag = (PsiDocTag)elem;
                switch ( docTag.getName() ) {
                    case "see":
                        tagBlock.append('\n');
                        renderSeeTag(doclet, tagBlock, docTag);
                        break;
                    case "param":
                    case "throws":
                    case "exception":
                        renderSimpleTag(doclet, tagBlock, docTag, true);
                        break;
                    case "return":
                        renderSimpleTag(doclet, tagBlock, docTag, false);
                        break;
                    case "todo":
                        renderTodoTag(doclet, tagBlock, docTag);
                        break;
                    default:
                        tagBlock.append('\n').append(stripLead(elem.getText()));
                        break;
                }
            }
            else {
                start = false;
                if ( tagBlock.length() == 0 ) {
                    buf.append(elem.getText());
                }
                else {
                    tagBlock.append(elem.getText());
                }
            }
        }
        String markdown = stripLead(buf.toString());

        Plugin.print("Markdown source", markdown);
        String docCommentText = "/**\n" + escapeAsterisks(doclet.toHtml(markdown, false))
                + "\n" + escapeAsterisks(tagBlock.toString()) + "\n*/";
        Plugin.print("Processed DocComment", docCommentText);
        PsiDocComment processedDocComment = docCommentFromText(context, docCommentText);
        LinkExpander linkExpander = new LinkExpander(context, processedDocComment, docCommentText);
        processedDocComment = linkExpander.apply();
        return processedDocComment;
    }

    private PsiDocComment docCommentFromText(PsiElement context, CharSequence text) {
        return psiElementFactory.createDocCommentFromText(text.toString(), context);
    }

    /**
     * Generates all PlantUML diagrams in the given `PsiDocComment`. It returns a Map of
     * file names and the URLs where the image for that file has been saved to. Use this
     * URL for the `<img>` tag.
     *
     * @param docComment    The `PsiDocComment`.
     *
     * @return A map mapping the file names to the image URLs.
     *
     * @see TempFileManager#saveTempFile(byte[], String)
     */
    private Map<String, URL> generateUmlDiagrams(PsiDocComment docComment) {
        TempFileManager tempFiles = Plugin.tempFileManager();
        Map<String, URL> urls = null;
        for ( PsiDocTag tag : docComment.getTags() ) {
            if ( tag instanceof PsiInlineDocTag ) {
                continue;
            }
            else if ( tag.getName().equals("uml") || tag.getName().equals("startuml") ) {
                if ( urls == null ) {
                    urls = new HashMap<>();
                }
                String text = stripLead(tag.getText());
                text = stripFirstWord(text)[1];
                String[] stripped = stripFirstWord(text);
                String fileName = stripped[0];
                text = stripped[1];
                String plantUml = "@startuml " + fileName + "\n"
                        + "skinparam backgroundColor transparent\n"
                        + text
                        + "\[email protected]";
                Plugin.print("UML Diagram Source", plantUml);
                ByteArrayOutputStream image = new ByteArrayOutputStream();
                try {
                    new SourceStringReader(plantUml).generateImage(image);
                }
                catch ( IOException e ) {
                    LOG.error("Error generating UML", e, fileName, String.valueOf(docComment.toString()), String.valueOf(docComment.getContainingFile()));
                }
                try {
                    urls.put(fileName, tempFiles.saveTempFile(image.toByteArray(), "png"));
                }
                catch ( IOException e ) {
                    LOG.error("Error generating UML", e, fileName, String.valueOf(docComment.toString()), String.valueOf(docComment.getContainingFile()));
                }
            }
        }
        return MoreObjects.firstNonNull(urls, Collections.<String, URL>emptyMap());
    }

    private void renderSeeTag(MarkdownDoclet doclet, StringBuilder tagBlock, PsiDocTag docTag) {
        final String seeText = toString(docTag, false);
        if ( seeText.startsWith("\"") ) {
            SeeTag tag = new SeeTag() {
                @Override
                public String label() {
                    return null;
                }
                @Override
                public PackageDoc referencedPackage() {
                    return null;
                }
                @Override
                public String referencedClassName() {
                    return null;
                }
                @Override
                public ClassDoc referencedClass() {
                    return null;
                }
                @Override
                public String referencedMemberName() {
                    return null;
                }
                @Override
                public MemberDoc referencedMember() {
                    return null;
                }
                @Override
                public String name() {
                    return "@see";
                }
                @Override
                public Doc holder() {
                    return null;
                }
                @Override
                public String kind() {
                    return "@see";
                }
                @Override
                public String text() {
                    return seeText;
                }
                @Override
                public Tag[] inlineTags() {
                    return new Tag[0];
                }
                @Override
                public Tag[] firstSentenceTags() {
                    return new Tag[0];
                }
                @Override
                public SourcePosition position() {
                    return null;
                }
            };
            SeeTagRenderer.INSTANCE.render(tag, tagBlock, doclet);
        }
        else {
            tagBlock.append("\[email protected]").append(docTag.getName());
            tagBlock.append(' ').append(seeText);
        }
    }

    private void renderSimpleTag(MarkdownDoclet doclet, StringBuilder tagBlock, PsiDocTag docTag, boolean stripFirstWord) {
        tagBlock.append("\[email protected]").append(docTag.getName()).append(' ');
        String firstWord = null;
        String text = toString(docTag, false);
        if ( stripFirstWord ) {
            String[] stripped = stripFirstWord(text);
            firstWord = stripped[0];
            text = stripped[1].trim();
        }
        text = TagRendering.simplifySingleParagraph(doclet.toHtml(text, false));
        if ( firstWord != null ) {
            tagBlock.append(firstWord).append(' ');
        }
        tagBlock.append(text).append('\n');
    }

    private void renderTodoTag(MarkdownDoclet doclet, StringBuilder tagBlock, PsiDocTag docTag) {
        tagBlock.append("\n<DL style=\"border:solid 1px;padding:5px;\"><DT><B>To Do</B></DT><DD>");
        tagBlock.append(toString(docTag, false));
        tagBlock.append("\n</DD></DL>");
    }

    /**
     * Strip leading asterisks as specified by the JavaDoc specification.
     *
     * @param doc    The JavaDoc comment.
     *
     * @return A JavaDoc comment with leading asterisks stripped.
     */
    private static String stripLead(String doc) {
        return LEADING_ASTERISK_RE.matcher(doc).replaceAll("");
    }

    /**
     * HTML-escape all asterisks in the given doc comment. After all leading asterisks
     * have been {@link #stripLead(String) stripped}, previously non-leading asterisks
     * would now be interpreted as leading (and therefore be ignored). Escaping them
     * avoids this confusion.
     *
     * @param doc    The doc comment where leading (ignorable) asterisks have been
     *               stripped.
     *
     * @return A doc comment where all asterisks have been HTML-escaped.
     */
    private static String escapeAsterisks(String doc) {
        return doc.replace("*", "&#42;");
    }

    /**
     * Convert a given `PsiDocTag` to a string.
     *
     * @param docTag            The `PsiDocTag` to be converted to a string.
     * @param stripFirstWord    `true`, if the first word should be stripped (e.g. the
     *                          parameter name of a `@param` tag.
     *
     * @return The `PsiDocTag` as string.
     */
    private static String toString(PsiDocTag docTag, boolean stripFirstWord) {
        String tagText = stripLead(docTag.getText());
        tagText = stripFirstWord(tagText)[1]; // remove the tag itself
        if ( stripFirstWord ) {
            String[] stripped = stripFirstWord(tagText);
            return stripped[0] + " " + stripped[1].trim();
        }
        else {
            return tagText.trim();
        }
    }

    private static String[] stripFirstWord(String string) {
        string = CharMatcher.WHITESPACE.trimLeadingFrom(string);
        int pos = CharMatcher.WHITESPACE.indexIn(string);
        if ( pos >= 0 ) {
            return new String[] {
                    string.substring(0, pos),
                    CharMatcher.WHITESPACE.trimLeadingFrom(string.substring(pos))
            };
        }
        else {
            return new String[] {
                    string.trim(),
                    ""
            };
        }
    }

    /**
     * Because the PsiDocComment returned is rooted in a method without any root, resolving relative
     * links like `{{@literal @}link #myMethod}` won't be resolved correctly. It resolves from the
     * {@link PsiDocTag} upwards using {@link PsiDocTag#getParent()}, completely ignoring the given
     * `context` (this is probably a bug of IDEA, method
     * {@link JavaDocUtil#findReferenceTarget(PsiManager, String, PsiElement, boolean)}).
     *
     * Expanding the links to absolute references before processing them fixes this. We can use the
     * methods of {@link JavaDocUtil} to do this.
     *
     * @see "[Issue #39 on GitHub](https://github.com/Abnaxos/markdown-doclet/issues/39)"
     * @see JavaDocUtil#findReferenceTarget(PsiManager, String, PsiElement, boolean)
     */
    private class LinkExpander extends PsiElementVisitor {

        private final PsiDocCommentOwner docContext;
        private final PsiDocComment originalDocComment;
        private final String docText;
        private int docTextPosition = 0;
        @SuppressWarnings("StringBufferField")
        private StringBuilder buffer = null;

        LinkExpander(PsiDocCommentOwner docContext, PsiDocComment originalDocComment, String docText) {
            this.docContext = docContext;
            this.originalDocComment = originalDocComment;
            this.docText = docText;
        }

        PsiDocComment apply() {
            if ( docContext == null ) {
                return originalDocComment;
            }
            docTextPosition = 0;
            buffer = null;
            originalDocComment.acceptChildren(LinkExpander.this);
            if ( buffer == null ) {
                return originalDocComment;
            }
            else {
                buffer.append(docText, docTextPosition, docText.length());
                String text = buffer.toString();
                Plugin.print("After expanding links", text);
                return docCommentFromText(docContext, text);
            }
        }

        @Override
        public void visitElement(PsiElement element) {
            if ( element instanceof PsiDocTag ) {
                PsiDocTag tag = (PsiDocTag)element;
                String tagName = null;
                switch ( tag.getName() ) {
                    case "link":
                    case "linkplain":
                    case "see":
                        // todo: @ssee
                        tagName = tag.getName();
                }
                if ( tagName != null ) {
                    int inlineOffset = (tag instanceof PsiInlineDocTag) ? 1 : 0;
                    String linkText = tag.getText().substring(inlineOffset + 1 + tagName.length(), tag.getTextLength() - inlineOffset).trim();
                    if ( !linkText.startsWith("#") ) {
                        return;
                    }
                    StringBuilder newLink = new StringBuilder(100);
                    if ( inlineOffset > 0 ) {
                        newLink.append('{');
                    }
                    newLink.append('@').append(tagName).append(' ');
                    int refEndIndex = JavaDocUtil.extractReference(linkText);
                    String refText = linkText.substring(0, refEndIndex);
                    PsiElement target = JavaDocUtil.findReferenceTarget(docContext.getManager(), refText, docContext, true);
                    if ( target == null ) {
                        return;
                    }
                    newLink.append(JavaDocUtil.getReferenceText(project, target)).append(' ');
                    String labelText = linkText.substring(refEndIndex).trim();
                    if ( labelText.isEmpty() ) {
                        labelText = JavaDocUtil.getLabelText(project, docContext.getManager(), refText, docContext);
                    }
                    newLink.append(labelText);
                    if ( inlineOffset > 0 ) {
                        newLink.append('}');
                    }
                    int start = getStartOffsetInComment(element);
                    if ( buffer == null ) {
                        buffer = new StringBuilder(docText.length() + 100);
                    }
                    buffer.append(docText, docTextPosition, start);
                    buffer.append(newLink);
                    docTextPosition += start - docTextPosition + element.getTextLength();
                }
            }
            element.acceptChildren(this);
        }

        private int getStartOffsetInComment(PsiElement element) {
            int offset = 0;
            while ( element != null && !(element instanceof PsiDocComment) ) {
                offset += element.getStartOffsetInParent();
                element = element.getParent();
            }
            return offset;
        }

    }

}