package com.vortexel.codoc.impl.java;

import com.vortexel.codoc.api.Parser;
import com.vortexel.codoc.models.*;

import java.io.IOException;
import java.io.InputStream;
import java.util.*;

import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;

/**
 * Parser for JavaDoc HTML files.
 *
 * @author Drew Heintz
 */
public class JavadocParser implements Parser {

    ///////////////
    // Constants //
    ///////////////

    /** Constant for the Array-declaration syntax */
    private static final String ARRAY_DECLARE = "[]";
    /** Constant for varargs syntax */
    private static final String VARARGS_DECLARE = "...";
    /** The text to search for to see if a method gets the @Override annotation */
    private static final String OVERRIDES_ANNOT = "Overrides:";

    /** CSS selector for the Method Detail header so we can find all the methods. */
    private static final String SELECT_METHOD_DETAILS = "h3:contains(Method Detail)";
    private static final String SELECT_CLASS_DOC = ".description .block";
    private static final String SELECT_CLASS_MODIFIERS = ".description ul li pre";

    /** Map keywords to modifier values. I'm calling this a constant */
    private static final Map<String, Modifier> MODIFIER_MAP = new HashMap<>();

    static {
        // Initialize the MODIFIER_MAP with valid Java modifiers and their keywords
        MODIFIER_MAP.put("public", Modifier.MOD_PUBLIC);
        MODIFIER_MAP.put("private", Modifier.MOD_PRIVATE);
        MODIFIER_MAP.put("protected", Modifier.MOD_PROTECTED);
        MODIFIER_MAP.put("static", Modifier.MOD_STATIC);
        MODIFIER_MAP.put("final", Modifier.MOD_CONST);
        // MODIFIER_MAP.put("transient", Modifier.MOD_TRANSIENT);
        // MODIFIER_MAP.put("synchronized", Modifier.MOD_SYNCHRONIZED);

    }


    ////////////
    // Fields //
    ////////////

    /** JSoup Document we are parsing */
    private Document document;

    /** List of definitions we are generating */
    private ArrayList<Definition> definitions;

    /** Where the final model is stored */
    private ClassModel model;

    /**
     * Used for resolving class names to FQNs (Fully Qualified Names).
     * e.g. IOException -> java.io.IOException
     */
    private Map<String,String> classNameMap;


    ///////////////////////
    // Important Methods //
    ///////////////////////

    /**
     * Basic no-args constructor.
     */
    public JavadocParser() {
        // Create and populate the classNameMap
        classNameMap = new TreeMap<>();
        populateClassNameMap();
    }

    @Override
    public void parse(InputStream in) throws IOException {
        // Parse the document
        document = Jsoup.parse(in, null, "");
        // Create new set of definitions
        definitions = new ArrayList<>();

        // First we get the class name and it's super class
        Elements inheritance = document.getElementsByClass("inheritance");
        // get last item
        String className = inheritance.last().text();
        // second to last
        String parentClass = inheritance.get(inheritance.size() - 2).child(0).text();

        // Next we grab the class's docstring.
        Elements classDocEl = document.select(SELECT_CLASS_DOC);
        if (classDocEl.size() > 0) {
            System.err.println("Multiple class descriptions found! Using first one.");
        }

        Documentation classDoc = new Documentation(classDocEl.first().html());

        // find the modifiers for the class
        Set<Modifier> modifiers = parseClassModifiers(document);

        // Grab the list of all function definitions
        // To do that we need to find the Method Detail text
        Elements methodDetailHeader = document.select(SELECT_METHOD_DETAILS);
        assertF(methodDetailHeader.size() == 1, "There is more than one \"Method Detail\" header");
        Elements methodList = methodDetailHeader.first().parent().select("ul.blockList");
        // For some reason the file method has class=blockListLast
        methodList.add(methodDetailHeader.first().parent().select("ul.blockListLast").first());
        // now go through each
        for (Element el : methodList) {
            Element child = el.child(0);
            assertF(child.tagName().equals("li"), "Function tags must be <li> elements");
            Function func = parseFunction(child);
            definitions.add(func);
        }

        // Create the final class definition.
        model = new ClassModel(ClassName.parse(className), ClassName.parse(parentClass), definitions);
        model.setModifiers(modifiers);
    }

    private Set<Modifier> parseClassModifiers(Element root) {
        Elements modifiersEl = root.select(SELECT_CLASS_MODIFIERS);
        String[] modifierSplit = modifiersEl.text().split(" ");
        Set<Modifier> modifiers = new HashSet<>();
        int idx = 0;
        while (!modifierSplit[idx].equals("class")) {
            Modifier mod = MODIFIER_MAP.get(modifierSplit[idx]);
            if (mod != null) {
                modifiers.add(mod);
            } else {
                System.err.println("Unknown class modifier " + modifierSplit[idx]);
            }
            idx++;
        }
        return modifiers;
    }

    /**
     * Parse the HTML of a function definition. The element should be a &lt;li class="blockList&gt;
     * inside of a &lt;ul&gt; also with the class="blockList".
     *
     * @param element element to parse
     * @return
     */
    private Function parseFunction(Element element) {
        HashSet<Modifier> modifiers = new HashSet<>();
        List<Variable> params = new ArrayList<>();

        String name;
        String returnType;
        String ctext;
        Modifier currentMod;

        // This is the function name
        name = element.select("h4").first().text();

        // get the <pre> element
        Element pre = element.select("pre").first();

        // build a mapping of class names to FQNs (Fully Qualified Names)
        for (Element el : pre.select("a[title]")) {
            classNameMap.put(el.text(), parseClass(el));
        }

        // First get the header text, normalize it and remove parens
        String header = normalizeText(pre.text(), "),");
        // split the string at spaces and commas
        String[] headerSplit = header.split("[ \\(]");
        // Sometimes we get empty strings in the array.
        // eg) ["public", "void", "flush", "", "throws", "IOException"]
        // Remove them or bad things will happen.
        headerSplit = removeEmpty(headerSplit);

        // We use idx to iterate through the split array
        int idx = 0;

        // Get the text of the current thing and if it's in the list of modifiers add the modifier
        // then increment idx. If it's not in the list of valid modifiers assume we're done and move on.
        while( (currentMod = MODIFIER_MAP.get(headerSplit[idx])) != null ) {
            modifiers.add(currentMod);
            idx++;
        }

        // TODO what if the function itself is generic? It will have an <E> as a "modifier"

        returnType = resolveType(headerSplit[idx++]);
        // A few assertions. If these fail then the parsing failed and we need to throw an exception
        assertF(returnType != null, "Failed to parse return type");
        assertF(name.equals(headerSplit[idx++]), "Name does not match expected name");

        // Now we parse through the function parameters. Function parameters end if we hit a "throws"
        // clause or the end of the split array
        while ( !(ctext = headerSplit[idx]).equals("throws") && idx < headerSplit.length ) {
            // get the param name and type
            String paramType = resolveType(ctext);
            String paramName = headerSplit[++idx];
            idx++;
            // check if this param is varargs
            boolean isParamVarargs = paramName.endsWith(VARARGS_DECLARE);
            if (isParamVarargs) {
                paramName = paramName.substring(0, paramName.length() - VARARGS_DECLARE.length());
            }
            Variable param = new Variable(paramName, ClassName.parse(paramType));
            if (isParamVarargs) {
                // make this param varargs and also note that the function is varargs
                param.getModifiers().add(Modifier.MOD_VARARGS);
                modifiers.add(Modifier.MOD_VARARGS);
            }
            params.add(param);
        }

        // Now parse `throws` or we're at the end of headerSplit so just be done with it
        if (idx < headerSplit.length) {
            assertF(headerSplit[idx].equals("throws"), "Expected \"throws\" got \"" + headerSplit[idx] + "\"");
            idx++;
            while(idx < headerSplit.length) {
                String exceptionClass = resolveType(headerSplit[idx]);
                assertF(exceptionClass != null, "Unable to resolve exception type \"" + headerSplit[idx] + "\"");
                modifiers.add(new ThrowsModifier(ClassName.parse(exceptionClass)));
                idx++;
            }
        }

        // Now we need to know if the function has the @Override annotation
        for (Element el : element.select("dl > dt")) {
            if (el.text().equals(OVERRIDES_ANNOT)) {
                modifiers.add(Modifier.MOD_OVERRIDE);
                break;
            }
        }

        // Now we parse the function documentation.
        Documentation functionDoc = buildFunctionDocumentation(element);

        Function func = new Function(name, ClassName.parse(returnType), params);
        func.setModifiers(modifiers);
        func.setDocumentation(functionDoc);
        return func;
    } // end parseFunction()

    /**
     * Read Javadoc starting from the root element (a &lt;li class="blocklist"&gt; element).
     * Build the documentation and set it as the function's documentation.
     *
     * This parses things such as {@code @param, @throws}, etc.
     *
     * @param root root element of the function definition
     */
    private Documentation buildFunctionDocumentation(Element root) {
        StringBuilder sb = new StringBuilder(root.select("div.block").html());

        // TODO rewrite the storage of this data so it can be properly formatted by the generator
        // This is partially handled by section.formatDocumentation returning a Document object
        // but still
        JavadocSection section = JavadocSection.None;
        for(Element el : root.select("dl").first().children()) {
            // If it's a title then we need to switch our JavadocSection
            if (el.tagName().equals("dt")) {
                // default section to None
                section = JavadocSection.None;
                for (JavadocSection sec : JavadocSection.values()) {
                    if (el.text().equals(sec.getTitleText())) {
                        section = sec;
                        break;
                    }
                }
            } else if (section != JavadocSection.None){
                // Otherwise we parse the text based on the section
                sb.append(section.formatDocumentation(el.text()).getText());
                sb.append('\n'); // then add a newline.
            }
            // if section is None then do nothing
        }

        // replace <code> tags with {@code ...} tags
        String docString = sb.toString().replace("<code>", "{@code ").replace("</code>", "}");
        return new Documentation(docString);
    }


    /////////////////////
    // Utility Methods //
    /////////////////////

    /**
     * Converts HTML text with things such as non-breaking spaces into regular text.
     * Also remove duplicate spaces so we end up with a single line of text.
     * @param input text to normalize
     * @param removeChars characters to remove
     * @return a converted string.
     */
    private static String normalizeText(String input, String removeChars) {
        char[] data = input.toCharArray();
        boolean wasSpace = false;
        for (int i = 0; i < data.length; i++) {
            char c = data[i]; // current char
            char oc = c;      // replacement char

            switch (c) {
                // replace non-breaking space with regular space
                case 0xA0:
                    c = ' ';
                    oc = ' ';
                    break;
            }

            // remove the character if it's in list of chars to remove
            if (removeChars.indexOf(c) != -1) {
                oc = 0x00;
            }

            // handle duplicate spaces
            if (Character.isWhitespace(c)) {
                // if we have a duplicate space then set char to 0 otherwise change it a space
                if (wasSpace) {
                    oc = 0x00;
                } else {
                    wasSpace = true;
                    oc = ' ';
                }
            } else {
                wasSpace = false;
            }
            data[i] = oc;
        }

        // "compress" the string by removing null characters
        int length = 0;
        for (int i = 0; i < data.length; i++) {
            // if char is not null move it to the next spot
            if (data[i] != 0) {
                data[length++] = data[i];
            }
        }
        return new String(data, 0, length);
    }

    private static String[] removeEmpty(String[] input) {
        String[] output;
        int count = 0;
        // first we determine the new length of the array
        for (int i = 0; i < input.length; i++) {
            if (!input[i].equals("")) {
                count++;
            }
        }
        output = new String[count];
        int pos = 0;
        for (int i = 0; i < input.length; i++) {
            if (!input[i].equals("")) {
                output[pos++] = input[i];
            }
        }
        return output;
    }

    /**
     * Resolve a fully-qualified type based on a given short type. This function also handles
     * things such as array specifiers.
     *
     * e.g. File[] -> java.io.File[]
     *
     * @param typename the type name visible in the Java Doc
     * @return a fully-qualified type or null if the type cannot be verified
     */
    private String resolveType(String typename) {
        boolean isArray = typename.endsWith(ARRAY_DECLARE);
        String result = typename;
        // if it's an array remove the []
        if (isArray) {
            result = typename.substring(0, typename.length() - ARRAY_DECLARE.length());
        }
        result = classNameMap.get(result);
        if (result == null) {
            return null;
        }
        // add back the [] if it's an array
        if (isArray) {
            result += ARRAY_DECLARE;
        }
        return result;
    }

    /**
     * Basic assert function which throws an IllegalArgumentException if b is false.
     *
     * @param b result of the assert.
     * @param message message to use in the exception.
     */
    private static void assertF(boolean b, String message) {
        if (!b) {
            throw new IllegalArgumentException(message);
        }
    }

    /**
     * Takes an &lt;a&gt; element with a "title" attribute in the form of
     * "class in com.example.test" and returns "com.example.text.ClassName".
     *
     * @return the fully-qualified class name
     */
    private static String parseClass(Element element) {
        // Find the last space char. The char after that is the first char of the package name
        String title = element.attr("title");
        int packageIndex = title.lastIndexOf(' ') + 1;
        return title.substring(packageIndex) + '.' + element.text();
    }

    /**
     * Populate the classNameMap with primitives. This should be called when the classNameMap is created.
     */
    private void populateClassNameMap() {
        classNameMap.put("byte", "byte");
        classNameMap.put("short", "short");
        classNameMap.put("int", "int");
        classNameMap.put("long", "long");
        classNameMap.put("float", "float");
        classNameMap.put("double", "double");
        classNameMap.put("boolean", "boolean");
        classNameMap.put("char", "char");
        classNameMap.put("void", "void");
    }


    ////////////////////////////////////
    // Methods Overridden from Parser //
    ////////////////////////////////////

    @Override
    public String getName() {
        return "javadoc";
    }

    @Override
    public String getDescription() {
        return "Java Doc HTML files";
    }

    @Override
    public List<Definition> getDefinitions() {
        // We return an ArrayList with a single element which is our class model
        return Arrays.asList((Definition)model);
    }
}
