package org.jabref.gui.duplicationFinder;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.Future;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;

import javafx.beans.binding.Bindings;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.beans.property.SimpleStringProperty;

import org.jabref.gui.DialogService;
import org.jabref.gui.LibraryTab;
import org.jabref.gui.StateManager;
import org.jabref.gui.actions.SimpleCommand;
import org.jabref.gui.duplicationFinder.DuplicateResolverDialog.DuplicateResolverResult;
import org.jabref.gui.duplicationFinder.DuplicateResolverDialog.DuplicateResolverType;
import org.jabref.gui.preferences.GuiPreferences;
import org.jabref.gui.undo.NamedCompoundEdit;
import org.jabref.gui.undo.UndoableInsertEntries;
import org.jabref.gui.undo.UndoableRemoveEntries;
import org.jabref.gui.util.UiTaskExecutor;
import org.jabref.logic.database.DuplicateCheck;
import org.jabref.logic.l10n.Localization;
import org.jabref.logic.util.BackgroundTask;
import org.jabref.logic.util.HeadlessExecutorService;
import org.jabref.logic.util.TaskExecutor;
import org.jabref.model.database.BibDatabaseContext;
import org.jabref.model.database.BibDatabaseMode;
import org.jabref.model.entry.BibEntry;
import org.jabref.model.entry.BibEntryTypesManager;

import static org.jabref.gui.actions.ActionHelper.needsDatabase;

public class DuplicateSearch extends SimpleCommand {

    private final Supplier<LibraryTab> tabSupplier;
    private final BlockingQueue<List<BibEntry>> duplicates = new LinkedBlockingQueue<>();

    private final AtomicBoolean libraryAnalyzed = new AtomicBoolean();
    private final AtomicBoolean duplicateSearchCancelled = new AtomicBoolean();
    private final AtomicBoolean autoRemoveExactDuplicates = new AtomicBoolean();
    private final AtomicInteger duplicateCount = new AtomicInteger();
    private final SimpleStringProperty duplicateCountObservable = new SimpleStringProperty();
    private final SimpleStringProperty duplicateTotal = new SimpleStringProperty();
    private final SimpleIntegerProperty duplicateProgress = new SimpleIntegerProperty(0);
    private final DialogService dialogService;
    private final StateManager stateManager;

    private final GuiPreferences preferences;
    private final BibEntryTypesManager entryTypesManager;
    private final TaskExecutor taskExecutor;

    // For "apply to all entries" functionality.
    private DuplicateResolverResult rememberedDecision;
    private Future<?> duplicateSearchTask;

    public DuplicateSearch(Supplier<LibraryTab> tabSupplier,
                           DialogService dialogService,
                           StateManager stateManager,
                           GuiPreferences preferences,
                           BibEntryTypesManager entryTypesManager,
                           TaskExecutor taskExecutor) {
        this.tabSupplier = tabSupplier;
        this.dialogService = dialogService;
        this.stateManager = stateManager;
        this.preferences = preferences;
        this.entryTypesManager = entryTypesManager;
        this.taskExecutor = taskExecutor;

        this.executable.bind(needsDatabase(stateManager));
    }

    @Override
    public void execute() {
        BibDatabaseContext database = stateManager.getActiveDatabase().orElseThrow(() -> new NullPointerException("Database null"));
        dialogService.notify(Localization.lang("Searching for duplicates..."));

        List<BibEntry> entries = database.getEntries();
        duplicates.clear();
        libraryAnalyzed.set(false);
        duplicateSearchCancelled.set(false);
        autoRemoveExactDuplicates.set(false);
        duplicateCount.set(0);
        rememberedDecision = null;

        if (entries.size() < 2) {
            return;
        }

        duplicateCountObservable.addListener((obj, oldValue, newValue) -> UiTaskExecutor.runAndWaitInJavaFXThread(() -> duplicateTotal.set(newValue)));

        duplicateSearchTask = HeadlessExecutorService.INSTANCE.executeInterruptableTask(() -> searchPossibleDuplicates(entries, database.getMode()), "DuplicateSearcher");
        BackgroundTask.wrap(this::verifyDuplicates)
                      .onSuccess(this::handleDuplicates)
                      .executeWith(taskExecutor);
    }

    private void searchPossibleDuplicates(List<BibEntry> entries, BibDatabaseMode databaseMode) {
        for (int i = 0; i < (entries.size() - 1); i++) {
            for (int j = i + 1; j < entries.size(); j++) {
                if (duplicateSearchCancelled.get() || Thread.interrupted()) {
                    return;
                }

                BibEntry first = entries.get(i);
                BibEntry second = entries.get(j);

                if (new DuplicateCheck(entryTypesManager).isDuplicate(first, second, databaseMode)) {
                    duplicates.add(Arrays.asList(first, second));
                    duplicateCountObservable.set(String.valueOf(duplicateCount.incrementAndGet()));
                }
            }
        }
        libraryAnalyzed.set(true);
    }

    private DuplicateSearchResult verifyDuplicates() {
        DuplicateSearchResult result = new DuplicateSearchResult();

        while (!libraryAnalyzed.get() || !duplicates.isEmpty()) {
            List<BibEntry> dups;
            try {
                // poll with timeout in case the library is not analyzed completely, but contains no more duplicates
                dups = this.duplicates.poll(100, TimeUnit.MILLISECONDS);
                if (dups == null) {
                    continue;
                }
            } catch (InterruptedException e) {
                return null;
            }

            BibEntry first = dups.getFirst();
            BibEntry second = dups.get(1);

            if (!result.isToRemove(first) && !result.isToRemove(second)) {
                // Check if they are exact duplicates:
                boolean askAboutExact = false;
                if (DuplicateCheck.compareEntriesStrictly(first, second) > 1) {
                    if (autoRemoveExactDuplicates.get()) {
                        result.remove(second);
                        continue;
                    }
                    askAboutExact = true;
                }

                DuplicateResolverType resolverType = askAboutExact ? DuplicateResolverType.DUPLICATE_SEARCH_WITH_EXACT : DuplicateResolverType.DUPLICATE_SEARCH;

                // Increment only when a pair is actually shown to the user
                duplicateProgress.set(duplicateProgress.getValue() + 1);
                UiTaskExecutor.runAndWaitInJavaFXThread(() -> askResolveStrategy(result, first, second, resolverType));
            }
        }

        return result;
    }

    private void askResolveStrategy(DuplicateSearchResult result, BibEntry first, BibEntry second, DuplicateResolverType resolverType) {
        DuplicateResolverDialog dialog = new DuplicateResolverDialog(first, second, resolverType, stateManager, dialogService, preferences);

        dialog.titleProperty().bind(Bindings.concat(dialog.getTitle()).concat(" (").concat(duplicateProgress).concat("/").concat(duplicateTotal).concat(")"));

        DuplicateResolverResult resolverResult;

        if (preferences.getMergeDialogPreferences().shouldMergeApplyToAllEntries() && rememberedDecision != null) {
            resolverResult = rememberedDecision;
        } else {
            resolverResult = dialogService.showCustomDialogAndWait(dialog)
                                          .orElse(DuplicateResolverResult.BREAK);

            if (preferences.getMergeDialogPreferences().shouldMergeApplyToAllEntries()) {
                rememberedDecision = resolverResult;
            }
        }

        applyDecisionToResult(result, first, second, resolverResult, dialog);
    }

    private void applyDecisionToResult(DuplicateSearchResult result, BibEntry first, BibEntry second, DuplicateResolverResult resolverResult, DuplicateResolverDialog dialog) {
        if ((resolverResult == DuplicateResolverResult.KEEP_LEFT)
                || (resolverResult == DuplicateResolverResult.AUTOREMOVE_EXACT)) {
            result.remove(second);
            result.replace(first, dialog.getNewLeftEntry());
            if (resolverResult == DuplicateResolverResult.AUTOREMOVE_EXACT) {
                autoRemoveExactDuplicates.set(true); // Remember choice
            }
        } else if (resolverResult == DuplicateResolverResult.KEEP_RIGHT) {
            result.remove(first);
            result.replace(second, dialog.getNewRightEntry());
        } else if (resolverResult == DuplicateResolverResult.BREAK) {
            cancelDuplicateSearch();
        } else if (resolverResult == DuplicateResolverResult.KEEP_MERGE) {
            result.replace(first, second, dialog.getMergedEntry());
        } else if (resolverResult == DuplicateResolverResult.KEEP_BOTH) {
            result.replace(first, dialog.getNewLeftEntry());
            result.replace(second, dialog.getNewRightEntry());
        }
    }

    private void cancelDuplicateSearch() {
        duplicateSearchCancelled.set(true);
        libraryAnalyzed.set(true);
        duplicates.clear();

        if (duplicateSearchTask != null) {
            duplicateSearchTask.cancel(true);
        }
    }

    private void handleDuplicates(DuplicateSearchResult result) {
        if (result == null) {
            return;
        }

        LibraryTab libraryTab = tabSupplier.get();
        final NamedCompoundEdit compoundEdit = new NamedCompoundEdit(Localization.lang("duplicate removal"));
        // Now, do the actual removal:
        if (!result.getToRemove().isEmpty()) {
            compoundEdit.addEdit(new UndoableRemoveEntries(libraryTab.getDatabase(), result.getToRemove()));
            libraryTab.getDatabase().removeEntries(result.getToRemove());
            libraryTab.markBaseChanged();
        }
        // and adding merged entries:
        if (!result.getToAdd().isEmpty()) {
            compoundEdit.addEdit(new UndoableInsertEntries(libraryTab.getDatabase(), result.getToAdd()));
            libraryTab.getDatabase().insertEntries(result.getToAdd());
            libraryTab.markBaseChanged();
        }

        duplicateProgress.set(0);

        dialogService.notify(Localization.lang("Duplicates found") + ": " + duplicateCount.get() + ' '
                + Localization.lang("pairs processed") + ": " + result.getDuplicateCount());
        compoundEdit.end();
        libraryTab.getUndoManager().addEdit(compoundEdit);
    }

    /// Result of a duplicate search.
    /// Uses {@link System#identityHashCode(Object)} for identifying objects for removal, as completely identical
    /// {@link BibEntry BibEntries} are equal to each other.
    static class DuplicateSearchResult {

        private final Map<String, BibEntry> toRemove = new HashMap<>();
        private final List<BibEntry> toAdd = new ArrayList<>();

        private int duplicates = 0;

        public synchronized List<BibEntry> getToRemove() {
            return new ArrayList<>(toRemove.values());
        }

        public synchronized List<BibEntry> getToAdd() {
            return toAdd;
        }

        public synchronized void remove(BibEntry entry) {
            // ADR-0038
            toRemove.put(entry.getId(), entry);
            duplicates++;
        }

        public synchronized void replace(BibEntry first, BibEntry second, BibEntry replacement) {
            remove(first);
            remove(second);
            toAdd.add(replacement);
            duplicates++;
        }

        public synchronized void replace(BibEntry entry, BibEntry replacement) {
            remove(entry);
            getToAdd().add(replacement);
        }

        public synchronized boolean isToRemove(BibEntry entry) {
            // ADR-0038
            return toRemove.containsKey(entry.getId());
        }

        public synchronized int getDuplicateCount() {
            return duplicates;
        }
    }
}
