package net.sf.jabref.logic.importer.fileformat;

import java.io.BufferedReader;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.regex.Pattern;

import javax.xml.bind.JAXBContext;
import javax.xml.bind.JAXBElement;
import javax.xml.bind.JAXBException;
import javax.xml.bind.Unmarshaller;
import javax.xml.datatype.XMLGregorianCalendar;

import net.sf.jabref.logic.importer.Importer;
import net.sf.jabref.logic.importer.ParserResult;
import net.sf.jabref.logic.importer.fileformat.bibtexml.Entry;
import net.sf.jabref.logic.importer.fileformat.bibtexml.File;
import net.sf.jabref.logic.importer.fileformat.bibtexml.Inbook;
import net.sf.jabref.logic.importer.fileformat.bibtexml.Incollection;
import net.sf.jabref.logic.util.FileExtensions;
import net.sf.jabref.model.entry.BibEntry;
import net.sf.jabref.model.entry.FieldName;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

/**
 * Importer for the BibTeXML format.
 * <p>
 * check here for details on the format
 * http://bibtexml.sourceforge.net/
 */
public class BibTeXMLImporter extends Importer {

    private static final Log LOGGER = LogFactory.getLog(BibTeXMLImporter.class);

    private static final Pattern START_PATTERN = Pattern.compile("<(bibtex:)?file .*");

    private static final List<String> IGNORED_METHODS = Arrays.asList("getClass", "getAnnotate", "getContents",
            "getPrice", "getSize", "getChapter");


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

    @Override
    public FileExtensions getExtensions() {
        return FileExtensions.BIBTEXML;
    }

    @Override
    public String getDescription() {
        return "Importer for the BibTeXML format.";
    }

    @Override
    public boolean isRecognizedFormat(BufferedReader reader) throws IOException {
        // Our strategy is to look for the "<bibtex:file *" line.
        String str;
        while ((str = reader.readLine()) != null) {
            if (START_PATTERN.matcher(str).find()) {
                return true;
            }
        }
        return false;
    }

    @Override
    public ParserResult importDatabase(BufferedReader reader) throws IOException {
        Objects.requireNonNull(reader);

        List<BibEntry> bibItems = new ArrayList<>();

        try {
            JAXBContext context = JAXBContext.newInstance("net.sf.jabref.logic.importer.fileformat.bibtexml");
            Unmarshaller unmarshaller = context.createUnmarshaller();
            File file = (File) unmarshaller.unmarshal(reader);

            List<Entry> entries = file.getEntry();
            Map<String, String> fields = new HashMap<>();

            for (Entry entry : entries) {
                BibEntry bibEntry = new BibEntry();
                if (entry.getArticle() != null) {
                    bibEntry.setType("article");
                    parse(entry.getArticle(), fields);
                } else if (entry.getBook() != null) {
                    bibEntry.setType("book");
                    parse(entry.getBook(), fields);
                } else if (entry.getBooklet() != null) {
                    bibEntry.setType("booklet");
                    parse(entry.getBooklet(), fields);
                } else if (entry.getConference() != null) {
                    bibEntry.setType("conference");
                    parse(entry.getConference(), fields);
                } else if (entry.getInbook() != null) {
                    bibEntry.setType("inbook");
                    parseInbook(entry.getInbook(), fields);
                } else if (entry.getIncollection() != null) {
                    bibEntry.setType("incollection");
                    Incollection incollection = entry.getIncollection();
                    if (incollection.getChapter() != null) {
                        fields.put(FieldName.CHAPTER, String.valueOf(incollection.getChapter()));
                    }
                    parse(incollection, fields);
                } else if (entry.getInproceedings() != null) {
                    bibEntry.setType("inproceedings");
                    parse(entry.getInproceedings(), fields);
                } else if (entry.getManual() != null) {
                    bibEntry.setType("manual");
                    parse(entry.getManual(), fields);
                } else if (entry.getMastersthesis() != null) {
                    bibEntry.setType("mastersthesis");
                    parse(entry.getMastersthesis(), fields);
                } else if (entry.getMisc() != null) {
                    bibEntry.setType("misc");
                    parse(entry.getMisc(), fields);
                } else if (entry.getPhdthesis() != null) {
                    bibEntry.setType("phdthesis");
                    parse(entry.getPhdthesis(), fields);
                } else if (entry.getProceedings() != null) {
                    bibEntry.setType("proceedings");
                    parse(entry.getProceedings(), fields);
                } else if (entry.getTechreport() != null) {
                    bibEntry.setType("techreport");
                    parse(entry.getTechreport(), fields);
                } else if (entry.getUnpublished() != null) {
                    bibEntry.setType("unpublished");
                    parse(entry.getUnpublished(), fields);
                }

                if (entry.getId() != null) {
                    bibEntry.setCiteKey(entry.getId());
                }
                bibEntry.setField(fields);
                bibItems.add(bibEntry);
            }
        } catch (JAXBException e) {
            LOGGER.error("Error with XML parser configuration", e);
            return ParserResult.fromErrorMessage(e.getLocalizedMessage());
        }
        return new ParserResult(bibItems);
    }

    /**
     * We use a generic method and not work on the real classes, because they all have the same behaviour. They call all get methods
     * that are needed and use the return value. So this will prevent writing similar methods for every type.
     * <p>
     * In this method, all <Code>get</Code> methods that entryType has will be used and their value will be put to fields,
     * if it is not null. So for example if entryType has the method <Code>getAbstract</Code>, then
     * "abstract" will be put as key to fields and the value of <Code>getAbstract</Code> will be put as value to fields.
     * Some <Code>get</Code> methods shouldn't be mapped to fields, so <Code>getClass</Code> for example will be skipped.
     *
     * @param entryType This can be all possible BibTeX types. It contains all fields of the entry and their values.
     * @param fields A map where the name and the value of all fields that the entry contains will be put.
     */
    private <T> void parse(T entryType, Map<String, String> fields) {
        Method[] declaredMethods = entryType.getClass().getDeclaredMethods();
        for (Method method : declaredMethods) {
            try {
                if (method.getName().equals("getYear")) {
                    putYear(fields, (XMLGregorianCalendar) method.invoke(entryType));
                    continue;
                } else if (method.getName().equals("getNumber")) {
                    putNumber(fields, (BigInteger) method.invoke(entryType));
                    continue;
                } else if (isMethodToIgnore(method.getName())) {
                    continue;
                } else if (method.getName().startsWith("get")) {
                    putIfValueNotNull(fields, method.getName().replace("get", ""), (String) method.invoke(entryType));
                }
            } catch (IllegalArgumentException | InvocationTargetException | IllegalAccessException e) {
                LOGGER.error("Could not invoke method", e);
            }
        }
    }

    /**
     * Returns whether the value of the given method name should be mapped or whether the method can be ignored.
     *
     * @param methodName The name of the method as String
     * @return true if the method can be ignored, else false
     */
    private boolean isMethodToIgnore(String methodName) {
        return IGNORED_METHODS.contains(methodName);
    }

    /**
     * Inbook needs a special Treatment, because <Code>inbook.getContent()</Code> returns a list of <Code>JAXBElements</Code>.
     * The other types have just <Code>get</Code> methods, which return the values as Strings.
     */
    private void parseInbook(Inbook inbook, Map<String, String> fields) {
        List<JAXBElement<?>> content = inbook.getContent();
        for (JAXBElement<?> element : content) {
            String localName = element.getName().getLocalPart();
            Object elementValue = element.getValue();
            if (elementValue instanceof String) {
                String value = (String) elementValue;
                putIfValueNotNull(fields, localName, value);
            } else if (elementValue instanceof BigInteger) {
                BigInteger value = (BigInteger) elementValue;
                if (FieldName.NUMBER.equals(localName)) {
                    fields.put(FieldName.NUMBER, String.valueOf(value));
                } else if (FieldName.CHAPTER.equals(localName)) {
                    fields.put(FieldName.CHAPTER, String.valueOf(value));
                }
            } else if (elementValue instanceof XMLGregorianCalendar) {
                XMLGregorianCalendar value = (XMLGregorianCalendar) elementValue;
                if (FieldName.YEAR.equals(localName)) {
                    putYear(fields, value);
                } else {
                    LOGGER.info("Unexpected field was found");
                }
            } else {
                LOGGER.info("Unexpected field was found");
            }
        }
    }

    private void putYear(Map<String, String> fields, XMLGregorianCalendar year) {
        if (year != null) {
            fields.put(FieldName.YEAR, String.valueOf(year));
        }
    }

    private void putNumber(Map<String, String> fields, BigInteger number) {
        if (number != null) {
            fields.put(FieldName.NUMBER, String.valueOf(number));
        }
    }

    private void putIfValueNotNull(Map<String, String> fields, String key, String value) {
        if (value != null) {
            fields.put(key, value);
        }
    }
}
