package org.jabref.logic.database;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

import org.jabref.model.database.BibDatabase;
import org.jabref.model.database.BibDatabaseContext;
import org.jabref.model.database.BibDatabaseModeDetection;
import org.jabref.model.entry.BibEntry;
import org.jabref.model.entry.BibEntryTypesManager;
import org.jabref.model.entry.BibtexString;
import org.jabref.model.entry.KeywordList;
import org.jabref.model.entry.field.StandardField;
import org.jabref.model.groups.AllEntriesGroup;
import org.jabref.model.groups.ExplicitGroup;
import org.jabref.model.groups.GroupHierarchyType;
import org.jabref.model.metadata.ContentSelector;
import org.jabref.model.metadata.MetaData;

import org.jspecify.annotations.NonNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class DatabaseMerger {

    private static final Logger LOGGER = LoggerFactory.getLogger(DatabaseMerger.class);
    private final char keywordDelimiter;

    public DatabaseMerger(char keywordDelimiter) {
        this.keywordDelimiter = keywordDelimiter;
    }

    /// Merges all entries and strings of the other database into the target database. Any duplicates are ignored.
    /// In case a string has a different content, it is added with a new unique name.
    /// The unique name is generated by suffix "_i", where i runs from 1 onwards.
    ///
    /// @param other The other databases that is merged into this database
    public synchronized void merge(BibDatabase target, BibDatabase other) {
        mergeEntries(target, other);
        mergeStrings(target, other);
    }

    /// Merges all entries, strings, and metaData of the other database context into the target database context. Any duplicates are ignored.
    /// In case a string has a different content, it is added with a new unique name.
    /// The unique name is generated by suffix "_i", where i runs from 1 onwards.
    ///
    /// @param other The other databases that is merged into this database
    public synchronized void merge(BibDatabaseContext target, BibDatabaseContext other, String otherFileName) {
        mergeEntries(target.getDatabase(), other.getDatabase());
        mergeStrings(target.getDatabase(), other.getDatabase());
        mergeMetaData(target.getMetaData(), other.getMetaData(), otherFileName, other.getEntries());
    }

    /// Merges all entries of the other database into the target database.
    /// Duplicate entries are not inserted. If a duplicate is detected, its groups field is union-merged into the existing entry.
    private void mergeEntries(BibDatabase target, BibDatabase other) {
        DuplicateCheck duplicateCheck = new DuplicateCheck(new BibEntryTypesManager());
        List<BibEntry> entriesToInsert = new ArrayList<>();

        for (BibEntry incoming : other.getEntries()) {
            Optional<BibEntry> existingDuplicate = duplicateCheck.containsDuplicate(
                    target, incoming, BibDatabaseModeDetection.inferMode(target));
            if (existingDuplicate.isPresent()) {
                mergeGroupsField(existingDuplicate.get(), incoming);
            } else {
                entriesToInsert.add(incoming);
            }
        }

        target.insertEntries(entriesToInsert);
    }

    private void mergeGroupsField(BibEntry surviving, BibEntry dropped) {
        String droppedGroups = dropped.getField(StandardField.GROUPS).orElse("");
        if (droppedGroups.isEmpty()) {
            return;
        }
        String survivingGroups = surviving.getField(StandardField.GROUPS).orElse("");
        String merged = KeywordList.merge(survivingGroups, droppedGroups, keywordDelimiter)
                                   .getAsString(keywordDelimiter);
        surviving.setField(StandardField.GROUPS, merged);
    }

    public void mergeStrings(BibDatabase target, BibDatabase other) {
        for (BibtexString bibtexString : other.getStringValues()) {
            String bibtexStringName = bibtexString.getName();
            if (target.hasStringByName(bibtexStringName)) {
                String importedContent = bibtexString.getContent();
                String existingContent = target.getStringByName(bibtexStringName).get().getContent();
                if (!importedContent.equals(existingContent)) {
                    LOGGER.info("String contents differ for {}: {} != {}", bibtexStringName, importedContent, existingContent);
                    int suffix = 1;
                    String newName = bibtexStringName + "_" + suffix;
                    while (target.hasStringByName(newName)) {
                        suffix++;
                        newName = bibtexStringName + "_" + suffix;
                    }
                    BibtexString newBibtexString = new BibtexString(newName, importedContent);
                    // TODO undo/redo
                    target.addString(newBibtexString);
                    LOGGER.info("New string added: {} = {}", newBibtexString.getName(), newBibtexString.getContent());
                }
            } else {
                // TODO undo/redo
                target.addString(bibtexString);
            }
        }
    }

    /// @param target        the metaData that is  the merge target
    /// @param other         the metaData to merge into the target
    /// @param otherFilename the filename of the other library. Pass "unknown" if not known.
    public void mergeMetaData(@NonNull MetaData target,
                              @NonNull MetaData other,
                              @NonNull String otherFilename,
                              @NonNull List<BibEntry> allOtherEntries) {
        mergeGroups(target, other, otherFilename, allOtherEntries);
        mergeContentSelectors(target, other);
    }

    private void mergeGroups(@NonNull MetaData target,
                             @NonNull MetaData other,
                             @NonNull String otherFilename,
                             @NonNull List<BibEntry> allOtherEntries) {
        // Adds the specified node as a child of the current root. The group contained in <b>newGroups</b> must not be of
        // type AllEntriesGroup, since every tree has exactly one AllEntriesGroup (its root). The <b>newGroups</b> are
        // inserted directly, i.e. they are not deepCopy()'d.
        other.getGroups().ifPresent(newGroups -> {
            // ensure that there is always only one AllEntriesGroup in the resulting database
            // "Rename" the AllEntriesGroup of the imported database to "Imported"
            if (newGroups.getGroup() instanceof AllEntriesGroup) {
                // create a dummy group
                try {
                    // This will cause a bug if the group already exists
                    // There will be group where the two groups are merged
                    ExplicitGroup group = new ExplicitGroup(
                            "Imported " + otherFilename,
                            GroupHierarchyType.INDEPENDENT,
                            keywordDelimiter);
                    newGroups.setGroup(group);
                    group.add(allOtherEntries);
                } catch (IllegalArgumentException e) {
                    LOGGER.error("Problem appending entries to group", e);
                }
            }
            target.getGroups().ifPresentOrElse(
                    newGroups::moveTo,
                    // target does not contain any groups, so we can just use the new groups
                    () -> target.setGroups(newGroups));
        });
        target.groupsBinding().invalidate();
    }

    private void mergeContentSelectors(MetaData target, MetaData other) {
        for (ContentSelector selector : other.getContentSelectorsSorted()) {
            target.addContentSelector(selector);
        }
    }
}
