package net.sf.jabref.logic.bibtex;

import java.io.IOException;
import java.io.Writer;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
import java.util.function.Predicate;

import net.sf.jabref.logic.TypedBibEntry;
import net.sf.jabref.logic.util.OS;
import net.sf.jabref.model.EntryTypes;
import net.sf.jabref.model.database.BibDatabaseMode;
import net.sf.jabref.model.entry.BibEntry;
import net.sf.jabref.model.entry.EntryType;
import net.sf.jabref.model.entry.InternalBibtexFields;
import net.sf.jabref.model.strings.StringUtil;

public class BibEntryWriter {

    private final LatexFieldFormatter fieldFormatter;
    private final boolean write;


    public BibEntryWriter(LatexFieldFormatter fieldFormatter, boolean write) {
        this.fieldFormatter = fieldFormatter;
        this.write = write;
    }

    public void write(BibEntry entry, Writer out, BibDatabaseMode bibDatabaseMode) throws IOException {
        write(entry, out, bibDatabaseMode, false);
    }

    /**
     * Writes the given BibEntry using the given writer
     *
     * @param entry           The entry to write
     * @param out             The writer to use
     * @param bibDatabaseMode The database mode (bibtex or biblatex)
     * @param reformat        Should the entry be in any case, even if no change occurred?
     */
    public void write(BibEntry entry, Writer out, BibDatabaseMode bibDatabaseMode, Boolean reformat) throws IOException {
        // if the entry has not been modified, write it as it was
        if (!reformat && !entry.hasChanged()) {
            out.write(entry.getParsedSerialization());
            return;
        }

        writeUserComments(entry, out);
        out.write(OS.NEWLINE);
        writeRequiredFieldsFirstRemainingFieldsSecond(entry, out, bibDatabaseMode);
        out.write(OS.NEWLINE);
    }

    private void writeUserComments(BibEntry entry, Writer out) throws IOException {
        String userComments = entry.getUserComments();

        if(!userComments.isEmpty()) {
            out.write(userComments + OS.NEWLINE);
        }
    }

    public void writeWithoutPrependedNewlines(BibEntry entry, Writer out, BibDatabaseMode bibDatabaseMode) throws IOException {
        // if the entry has not been modified, write it as it was
        if (!entry.hasChanged()) {
            out.write(entry.getParsedSerialization().trim());
            return;
        }

        writeRequiredFieldsFirstRemainingFieldsSecond(entry, out, bibDatabaseMode);
    }

    /**
     * Write fields in the order of requiredFields, optionalFields and other fields, but does not sort the fields.
     *
     * @param entry
     * @param out
     * @throws IOException
     */
    private void writeRequiredFieldsFirstRemainingFieldsSecond(BibEntry entry, Writer out,
                                                               BibDatabaseMode bibDatabaseMode) throws IOException {
        // Write header with type and bibtex-key.
        TypedBibEntry typedEntry = new TypedBibEntry(entry, bibDatabaseMode);
        out.write('@' + typedEntry.getTypeForDisplay() + '{');

        writeKeyField(entry, out);

        Set<String> written = new HashSet<>();
        written.add(BibEntry.KEY_FIELD);
        int indentation = getLengthOfLongestFieldName(entry);

        EntryType type = EntryTypes.getTypeOrDefault(entry.getType(), bibDatabaseMode);

        // Write required fields first.
        List<String> fields = type.getRequiredFieldsFlat();
        if (fields != null) {
            for (String value : fields) {
                writeField(entry, out, value, indentation);
                written.add(value);
            }
        }
        // Then optional fields.
        fields = type.getOptionalFields();
        if (fields != null) {
            for (String value : fields) {
                if (!written.contains(value)) { // If field appears both in req. and opt. don't repeat.
                    writeField(entry, out, value, indentation);
                    written.add(value);
                }
            }
        }
        // Then write remaining fields in alphabetic order.
        Set<String> remainingFields = new TreeSet<>();
        for (String key : entry.getFieldNames()) {
            boolean writeIt = write ? InternalBibtexFields.isWriteableField(key) :
                    InternalBibtexFields.isDisplayableField(key);
            if (!written.contains(key) && writeIt) {
                remainingFields.add(key);
            }
        }
        for (String field : remainingFields) {
            writeField(entry, out, field, indentation);
        }

        // Finally, end the entry.
        out.write('}');
    }

    private void writeKeyField(BibEntry entry, Writer out) throws IOException {
        String keyField = StringUtil.shaveString(entry.getCiteKeyOptional().orElse(""));
        out.write(keyField + ',' + OS.NEWLINE);
    }

    /**
     * Write a single field, if it has any content.
     *
     * @param entry the entry to write
     * @param out   the target of the write
     * @param name  The field name
     * @throws IOException In case of an IO error
     */
    private void writeField(BibEntry entry, Writer out, String name, int indentation) throws IOException {
        Optional<String> field = entry.getField(name);
        // only write field if is is not empty
        // field.ifPresent does not work as an IOException may be thrown
        if (field.isPresent() && !field.get().trim().isEmpty()) {
            out.write("  " + getFieldDisplayName(name, indentation));

            try {
                out.write(fieldFormatter.format(field.get(), name));
                out.write(',' + OS.NEWLINE);
            } catch (IOException ex) {
                throw new IOException("Error in field '" + name + "': " + ex.getMessage());
            }
        }
    }

    private int getLengthOfLongestFieldName(BibEntry entry) {
        Predicate<String> isNotBibtexKey = field -> !BibEntry.KEY_FIELD.equals(field);
        return entry.getFieldNames().stream().filter(isNotBibtexKey).mapToInt(String::length).max().orElse(0);
    }

    /**
     * Get display version of a entry field.
     * <p>
     * BibTeX is case-insensitive therefore there is no difference between:
     * howpublished, HOWPUBLISHED, HowPublished, etc.
     * <p>
     * The was a long discussion about how JabRef should write the fields.
     * See https://github.com/JabRef/jabref/issues/116
     * <p>
     * The team decided to do the biblatex way and use lower case for the field names.
     *
     * @param field The name of the field.
     * @return The display version of the field name.
     */
    private String getFieldDisplayName(String field, int intendation) {
        String actualField = field;
        if (actualField.isEmpty()) {
            // hard coded "UNKNOWN" is assigned to a field without any name
            actualField = "UNKNOWN";
        }

        return actualField.toLowerCase() + StringUtil.repeatSpaces(intendation - actualField.length()) + " = ";
    }
}
