package org.jabref.gui.entryeditor.citationrelationtab;

import java.io.IOException;
import java.io.StringWriter;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.BooleanSupplier;

import javax.swing.undo.UndoManager;

import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.css.PseudoClass;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.geometry.HPos;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.geometry.VPos;
import javafx.scene.Node;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonType;
import javafx.scene.control.ComboBox;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.DialogPane;
import javafx.scene.control.Hyperlink;
import javafx.scene.control.Label;
import javafx.scene.control.MenuItem;
import javafx.scene.control.ProgressIndicator;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.SplitPane;
import javafx.scene.control.ToggleButton;
import javafx.scene.control.Tooltip;
import javafx.scene.input.ClipboardContent;
import javafx.scene.input.DragEvent;
import javafx.scene.input.Dragboard;
import javafx.scene.input.MouseButton;
import javafx.scene.input.MouseEvent;
import javafx.scene.input.TransferMode;
import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.ColumnConstraints;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import javafx.scene.text.Text;

import org.jabref.gui.DialogService;
import org.jabref.gui.DragAndDropDataFormats;
import org.jabref.gui.LibraryTab;
import org.jabref.gui.StateManager;
import org.jabref.gui.desktop.os.NativeDesktop;
import org.jabref.gui.entryeditor.EntryEditorPreferences;
import org.jabref.gui.entryeditor.EntryEditorTab;
import org.jabref.gui.entryeditor.EntryEditorTabModel;
import org.jabref.gui.icon.IconTheme;
import org.jabref.gui.maintable.MainTableTooltip;
import org.jabref.gui.mergeentries.threewaymerge.EntriesMergeResult;
import org.jabref.gui.mergeentries.threewaymerge.MergeEntriesDialog;
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.ControlHelper;
import org.jabref.gui.util.NoSelectionModel;
import org.jabref.gui.util.URLs;
import org.jabref.gui.util.ViewModelListCellFactory;
import org.jabref.logic.bibtex.BibEntryWriter;
import org.jabref.logic.bibtex.FieldPreferences;
import org.jabref.logic.bibtex.FieldWriter;
import org.jabref.logic.citation.SearchCitationsRelationsService;
import org.jabref.logic.database.DuplicateCheck;
import org.jabref.logic.exporter.BibWriter;
import org.jabref.logic.importer.FetcherException;
import org.jabref.logic.importer.fetcher.CrossRef;
import org.jabref.logic.importer.fetcher.citation.CitationFetcher;
import org.jabref.logic.importer.fetcher.citation.CitationFetcherType;
import org.jabref.logic.l10n.Localization;
import org.jabref.logic.os.OS;
import org.jabref.logic.util.BackgroundTask;
import org.jabref.logic.util.TaskExecutor;
import org.jabref.logic.util.strings.StringUtil;
import org.jabref.model.database.BibDatabase;
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 org.jabref.model.entry.field.StandardField;
import org.jabref.model.entry.identifier.DOI;
import org.jabref.model.sciteTallies.TalliesResponse;
import org.jabref.model.util.FileUpdateMonitor;

import com.tobiasdiez.easybind.EasyBind;
import org.controlsfx.control.CheckListView;
import org.fxmisc.flowless.VirtualizedScrollPane;
import org.fxmisc.richtext.CodeArea;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/// GUI for tab displaying an articles citation relations in two lists based on the currently selected BibEntry
public class CitationRelationsTab extends EntryEditorTab {

    private static final Logger LOGGER = LoggerFactory.getLogger(CitationRelationsTab.class);

    // Tasks used to implement asynchronous fetching of related articles
    private static BackgroundTask<List<CitationRelationItem>> citingTask;
    private static BackgroundTask<List<CitationRelationItem>> citedByTask;
    private final DialogService dialogService;
    private final GuiPreferences preferences;
    private final TaskExecutor taskExecutor;
    private final SearchCitationsRelationsService searchCitationsRelationsService;
    private final CitationsRelationsTabViewModel citationsRelationsTabViewModel;
    private final DuplicateCheck duplicateCheck;
    private final BibEntryTypesManager entryTypesManager;
    private final StateManager stateManager;
    private final UndoManager undoManager;

    private final ProgressIndicator progressIndicator;
    private final GridPane sciteResultsPane;
    private final EntryEditorPreferences entryEditorPreferences;
    private final MainTableTooltip previewTooltip;
    private ComboBox<CitationFetcherType> fetcherCombo;

    private boolean shouldClearSelectionOnDrop = false;

    public CitationRelationsTab(DialogService dialogService,
                                UndoManager undoManager,
                                StateManager stateManager,
                                FileUpdateMonitor fileUpdateMonitor,
                                GuiPreferences preferences,
                                TaskExecutor taskExecutor,
                                BibEntryTypesManager bibEntryTypesManager,
                                SearchCitationsRelationsService searchCitationsRelationsService) {
        this.dialogService = dialogService;
        this.preferences = preferences;
        this.taskExecutor = taskExecutor;
        this.undoManager = undoManager;
        this.stateManager = stateManager;
        setText(EntryEditorTabModel.BuiltIn.CITATION_INFORMATION.displayName());
        setTooltip(new Tooltip(Localization.lang("Show articles related by citation")));
        setId("citationRelationsTab");

        this.entryTypesManager = bibEntryTypesManager;
        this.duplicateCheck = new DuplicateCheck(entryTypesManager);
        this.searchCitationsRelationsService = searchCitationsRelationsService;

        this.citationsRelationsTabViewModel = new CitationsRelationsTabViewModel(
                preferences,
                undoManager,
                stateManager,
                dialogService,
                fileUpdateMonitor,
                taskExecutor
        );

        EasyBind.subscribe(citationsRelationsTabViewModel.lastImportedEntryProperty(), entry -> {
            if (entry != null) { // on load, the entry is null
                stateManager.activeTabProperty().get().ifPresent(tab -> tab.showAndEdit(entry));
            }
        });

        this.progressIndicator = new ProgressIndicator();
        this.sciteResultsPane = new GridPane();
        setSciteResultsPane();

        this.entryEditorPreferences = preferences.getEntryEditorPreferences();

        this.previewTooltip = new MainTableTooltip(dialogService, preferences, taskExecutor);
    }

    private void setSciteResultsPane() {
        progressIndicator.setMaxSize(100, 100);
        sciteResultsPane.add(progressIndicator, 0, 0);

        ColumnConstraints column = new ColumnConstraints();
        column.setPercentWidth(100);
        column.setHalignment(HPos.CENTER);

        sciteResultsPane.getColumnConstraints().setAll(column);
        sciteResultsPane.setId("scitePane");
        setContent(sciteResultsPane);

        EasyBind.subscribe(citationsRelationsTabViewModel.statusProperty(), status -> {
            sciteResultsPane.getChildren().clear();
            switch (status) {
                case IN_PROGRESS ->
                        onSciteLookUp();
                case FOUND ->
                        citationsRelationsTabViewModel.getCurrentResult().ifPresent(result -> sciteResultsPane.add(getTalliesPane(result), 0, 0));
                case ERROR ->
                        sciteResultsPane.add(getErrorPane(), 0, 0);
                case DOI_MISSING ->
                        onDoiMissing();
                case DOI_LOOK_UP ->
                        onDoiLookUp();
                case DOI_LOOK_UP_ERROR ->
                        onDoiLookUpError();
            }
        });
    }

    private void onDoiLookUpError() {
        onDoiMissing();
        dialogService.notify(Localization.lang("No DOI found."));
    }

    private void onSciteLookUp() {
        sciteResultsPane.getChildren().clear();

        VBox vBox = new VBox();
        vBox.getChildren().add(progressIndicator);
        vBox.getStyleClass().add("align-center");

        sciteResultsPane.add(vBox, 0, 0);

        GridPane.setHalignment(vBox, HPos.CENTER);
        GridPane.setValignment(vBox, VPos.CENTER);

        GridPane.setHgrow(vBox, Priority.ALWAYS);
        GridPane.setVgrow(vBox, Priority.ALWAYS);
    }

    private void onDoiLookUp() {
        sciteResultsPane.getChildren().clear();

        Label label = new Label(Localization.lang("Looking up DOI..."));

        VBox vBox = new VBox();
        vBox.getChildren().add(progressIndicator);
        vBox.getChildren().add(label);
        vBox.setSpacing(2d);
        vBox.getStyleClass().add("align-center");

        sciteResultsPane.add(vBox, 0, 0);

        GridPane.setHalignment(vBox, HPos.CENTER);
        GridPane.setValignment(vBox, VPos.CENTER);

        GridPane.setHgrow(vBox, Priority.ALWAYS);
        GridPane.setVgrow(vBox, Priority.ALWAYS);
    }

    private void onDoiMissing() {
        sciteResultsPane.getChildren().clear();

        Label label = new Label(Localization.lang("The selected entry doesn't have a DOI linked to it."));
        Hyperlink link = new Hyperlink(Localization.lang("Look up a DOI and try again."));
        link.setOnAction(doiLookUp());

        HBox hBox = new HBox();
        hBox.getChildren().add(label);
        hBox.getChildren().add(link);
        hBox.setSpacing(2d);
        hBox.getStyleClass().add("align-center");

        sciteResultsPane.add(hBox, 0, 0);

        GridPane.setHalignment(hBox, HPos.CENTER);
        GridPane.setValignment(hBox, VPos.CENTER);

        GridPane.setHgrow(hBox, Priority.ALWAYS);
        GridPane.setVgrow(hBox, Priority.ALWAYS);
    }

    private EventHandler<ActionEvent> doiLookUp() {
        return actionEvent -> {
            citationsRelationsTabViewModel.lookUpDoi(getCurrentEntry());
        };
    }

    private VBox getErrorPane() {
        Label titleLabel = new Label(Localization.lang("Error"));
        titleLabel.setId("scite-error-label");
        Text errorMessageText = new Text(citationsRelationsTabViewModel.searchErrorProperty().get());
        VBox errorMessageBox = new VBox(30, titleLabel, errorMessageText);
        errorMessageBox.getStyleClass().add("scite-error-box");
        return errorMessageBox;
    }

    private BorderPane getTalliesPane(TalliesResponse tallModel) {
        HBox tallies = new HBox();
        tallies.setPadding(new Insets(0, 0, 10, 0));
        tallies.setAlignment(Pos.CENTER_LEFT);

        Text metrics = new Text(Localization.lang("Metrics:"));
        metrics.getStyleClass().add("markdown-bold");
        Text totalCitations = new Text(Localization.lang("Total Citations: %0", tallModel.total()));
        Text supporting = new Text(Localization.lang("Supporting: %0", tallModel.supporting()));
        Text contradicting = new Text(Localization.lang("Contradicting: %0", tallModel.contradicting()));
        Text mentioning = new Text(Localization.lang("Mentioning: %0", tallModel.mentioning()));
        Text unclassified = new Text(Localization.lang("Unclassified: %0", tallModel.unclassified()));
        Text citingPublications = new Text(Localization.lang("Citing Publications: %0", tallModel.citingPublications()));

        Text[] elements = {totalCitations, supporting, contradicting, mentioning, unclassified, citingPublications};

        tallies.getChildren().add(metrics);
        tallies.getChildren().add(new Text(" "));
        for (Text element : elements) {
            tallies.getChildren().add(element);
            Text separator = new Text(" | ");
            tallies.getChildren().add(separator);
        }

        String url = URLs.SCITE_REPORTS_URL_BASE + URLEncoder.encode(tallModel.doi(), StandardCharsets.UTF_8);
        Hyperlink link = new Hyperlink(Localization.lang("See full report"));
        link.setOnAction(event -> {
            if (event.getSource() instanceof Hyperlink) {
                try {
                    NativeDesktop.openBrowser(url, preferences.getExternalApplicationsPreferences());
                } catch (IOException ioex) {
                    // Can't throw a checked exception from here, so display a message to the user instead.
                    dialogService.showErrorDialogAndWait(
                            "An error occurred opening web browser",
                            "JabRef was unable to open a web browser for link:\n\n" + url + "\n\nError Message:\n\n" + ioex.getMessage(),
                            ioex
                    );
                }
            }
        });
        tallies.getChildren().add(link);

        Button providedByButtonMetrics = ControlHelper.iconButton(IconTheme.JabRefIcons.HELP);
        providedByButtonMetrics.setTooltip(new Tooltip(Localization.lang("Metrics provided by scite.ai")));
        providedByButtonMetrics.setOnAction(_ -> {
            try {
                NativeDesktop.openBrowser(URLs.SCITE_URL, preferences.getExternalApplicationsPreferences());
            } catch (IOException ioex) {
                // Can't throw a checked exception from here, so display a message to the user instead.
                dialogService.showErrorDialogAndWait(
                        "An error occurred opening web browser",
                        "JabRef was unable to open a web browser for link:\n\n" + URLs.SCITE_URL + "\n\nError Message:\n\n" + ioex.getMessage(),
                        ioex
                );
            }
        });
        tallies.getChildren().add(providedByButtonMetrics);

        Button providedByButtonRelations = ControlHelper.iconButton(IconTheme.JabRefIcons.HELP);
        providedByButtonRelations.setTooltip(new Tooltip(Localization.lang("Relations provided by Semantic Scholar")));
        providedByButtonRelations.setOnAction(_ -> {
            try {
                NativeDesktop.openBrowser(URLs.SEMANTIC_SCHOLAR_URL, preferences.getExternalApplicationsPreferences());
            } catch (IOException ioex) {
                // Can't throw a checked exception from here, so display a message to the user instead.
                dialogService.showErrorDialogAndWait(
                        "An error occurred opening web browser",
                        "JabRef was unable to open a web browser for link:\n\n" + URLs.SEMANTIC_SCHOLAR_URL + "\n\nError Message:\n\n" + ioex.getMessage(),
                        ioex
                );
            }
        });

        BorderPane bottomLine = new BorderPane();
        bottomLine.setLeft(tallies);
        bottomLine.setRight(providedByButtonRelations);

        return bottomLine;
    }

    /// Method to create main SplitPane holding all lists, buttons and labels for tab and starts search
    ///
    /// @param entry BibEntry which is currently selected in JabRef Database
    /// @return SplitPane to display
    private SplitPane getPaneAndStartSearch(BibEntry entry) {
        // Create Layout Containers
        VBox citingVBox = new VBox();
        VBox citedByVBox = new VBox();
        citingVBox.setFillWidth(true);
        citedByVBox.setFillWidth(true);
        citingVBox.setAlignment(Pos.TOP_CENTER);
        citedByVBox.setAlignment(Pos.TOP_CENTER);
        AnchorPane citingHBox = new AnchorPane();
        citingHBox.setPrefHeight(40);
        AnchorPane citedByHBox = new AnchorPane();
        citedByHBox.setPrefHeight(40);

        // Create Headings
        // See ADR-0047
        String citationKey = entry.getCitationKey().orElse("this entry");

        Label citingLabel = new Label(Localization.lang("References cited in %0", citationKey));
        styleLabel(citingLabel, Localization.lang("Also called \"backward citations\""));
        citingLabel.setMinWidth(100);
        citingLabel.setTextOverrun(javafx.scene.control.OverrunStyle.ELLIPSIS);
        HBox.setHgrow(citingLabel, javafx.scene.layout.Priority.ALWAYS);
        Label citedByLabel = new Label(Localization.lang("References that cite %0", citationKey));
        styleLabel(citedByLabel, Localization.lang("Also called \"forward citations\""));
        citedByLabel.setMinWidth(100);
        citedByLabel.setTextOverrun(javafx.scene.control.OverrunStyle.ELLIPSIS);
        HBox.setHgrow(citedByLabel, javafx.scene.layout.Priority.ALWAYS);

        // Create ListViews
        CheckListView<CitationRelationItem> citingListView = new CheckListView<>();
        CheckListView<CitationRelationItem> citedByListView = new CheckListView<>();
        // Allow the list views to expand/shrink with the window size
        citingListView.setMaxHeight(Double.MAX_VALUE);
        citingListView.setMinHeight(0);
        citedByListView.setMaxHeight(Double.MAX_VALUE);
        citedByListView.setMinHeight(0);

        // Create refresh Buttons for both sides
        Button refreshCitingButton = ControlHelper.iconButton(IconTheme.JabRefIcons.REFRESH);
        refreshCitingButton.setTooltip(new Tooltip(Localization.lang("Restart search")));
        styleTopBarNode(refreshCitingButton, 15.0);
        Button refreshCitedByButton = ControlHelper.iconButton(IconTheme.JabRefIcons.REFRESH);
        refreshCitedByButton.setTooltip(new Tooltip(Localization.lang("Restart search")));
        styleTopBarNode(refreshCitedByButton, 15.0);

        fetcherCombo = new ComboBox<>(
                FXCollections.observableList(List.of(CitationFetcherType.values()))
        );
        fetcherCombo.setTooltip(new Tooltip(Localization.lang("Select citation fetcher")));
        fetcherCombo.setPrefWidth(160);
        new ViewModelListCellFactory<CitationFetcherType>()
                .withText(CitationFetcherType::getName)
                .install(fetcherCombo);
        styleTopBarNode(fetcherCombo, 75.0);
        fetcherCombo.valueProperty().bindBidirectional(entryEditorPreferences.citationFetcherTypeProperty());

        // Add context menus to labels for opening API URLs
        citingLabel.setContextMenu(createCitationContextMenu(entry, CitationFetcher.SearchType.CITES));
        citedByLabel.setContextMenu(createCitationContextMenu(entry, CitationFetcher.SearchType.CITED_BY));

        // Create abort buttons for both sides
        Button abortCitingButton = ControlHelper.iconButton(IconTheme.JabRefIcons.CLOSE);
        abortCitingButton.getGraphic().resize(30, 30);
        abortCitingButton.setTooltip(new Tooltip(Localization.lang("Cancel search")));
        styleTopBarNode(abortCitingButton, 15.0);
        Button abortCitedButton = ControlHelper.iconButton(IconTheme.JabRefIcons.CLOSE);
        abortCitedButton.getGraphic().resize(30, 30);
        abortCitedButton.setTooltip(new Tooltip(Localization.lang("Cancel search")));
        styleTopBarNode(abortCitedButton, 15.0);

        ProgressIndicator citingProgress = new ProgressIndicator();
        citingProgress.setMaxSize(25, 25);
        styleTopBarNode(citingProgress, 50.0);
        ProgressIndicator citedByProgress = new ProgressIndicator();
        citedByProgress.setMaxSize(25, 25);
        styleTopBarNode(citedByProgress, 50.0);

        // Create import buttons for both sides
        Button importCitingButton = ControlHelper.iconButton(IconTheme.JabRefIcons.ADD_ENTRY);
        importCitingButton.setTooltip(new Tooltip(Localization.lang("Add selected entry(s) to library")));
        styleTopBarNode(importCitingButton, 50.0);
        Button importCitedByButton = ControlHelper.iconButton(IconTheme.JabRefIcons.ADD_ENTRY);
        importCitedByButton.setTooltip(new Tooltip(Localization.lang("Add selected entry(s) to library")));
        styleTopBarNode(importCitedByButton, 50.0);
        hideNodes(importCitingButton, importCitedByButton);

        citingHBox.getChildren().addAll(citingLabel, fetcherCombo, refreshCitingButton, importCitingButton, citingProgress, abortCitingButton);
        citedByHBox.getChildren().addAll(citedByLabel, refreshCitedByButton, importCitedByButton, citedByProgress, abortCitedButton);

        VBox.setVgrow(citingListView, Priority.ALWAYS);
        VBox.setVgrow(citedByListView, Priority.ALWAYS);
        citingVBox.getChildren().addAll(citingHBox, citingListView);
        citedByVBox.getChildren().addAll(citedByHBox, citedByListView);

        CitationComponents citingComponents = new CitationComponents(
                entry,
                citingListView,
                abortCitingButton,
                refreshCitingButton,
                CitationFetcher.SearchType.CITES,
                importCitingButton,
                citingProgress);

        CitationComponents citedByComponents = new CitationComponents(
                entry,
                citedByListView,
                abortCitedButton,
                refreshCitedByButton,
                CitationFetcher.SearchType.CITED_BY,
                importCitedByButton,
                citedByProgress);

        // click refresh button will trigger refresh from the remote
        refreshCitingButton.setOnMouseClicked(_ -> handleRefresh(citingComponents, citedByComponents));
        refreshCitedByButton.setOnMouseClicked(_ -> handleRefresh(citedByComponents, citingComponents));

        fetcherCombo.getSelectionModel().selectedItemProperty().addListener((_, _, newValue) -> {
            if (citingComponents.entry().getDOI().isEmpty()) {
                return;
            }

            // switch the fetcher will not trigger refresh from the remote, therefore we trigger it explicitly.
            searchForRelations(citingComponents, citedByComponents, false);
            searchForRelations(citedByComponents, citingComponents, false);
        });

        // Create SplitPane to hold all nodes above
        SplitPane container = new SplitPane(citingVBox, citedByVBox);
        styleFetchedListView(citedByListView, citedByComponents);
        styleFetchedListView(citingListView, citingComponents);

        // switch to the tab will not trigger refresh from the remote
        searchForRelations(citingComponents, citedByComponents, false);
        searchForRelations(citedByComponents, citingComponents, false);

        return container;
    }

    /// Styles a given CheckListView to display BibEntries either with a hyperlink or an add button.
    /// Also adds the handling of mouse clicks.
    ///
    /// @param listView CheckListView to style
    private void styleFetchedListView(CheckListView<CitationRelationItem> listView, CitationComponents citationComponents) {
        PseudoClass entrySelected = PseudoClass.getPseudoClass("selected");
        new ViewModelListCellFactory<CitationRelationItem>()
                .withGraphic(entry -> {

                    HBox separator = new HBox();
                    HBox.setHgrow(separator, Priority.SOMETIMES);
                    Node entryNode = BibEntryView.getEntryNode(entry.entry());
                    HBox.setHgrow(entryNode, Priority.ALWAYS);
                    HBox hContainer = new HBox();
                    hContainer.prefWidthProperty().bind(listView.widthProperty().subtract(25));

                    VBox vContainer = new VBox();

                    if (entry.isLocal()) {
                        hContainer.getStyleClass().add("duplicate-entry");
                        Button jumpTo = ControlHelper.iconButton(IconTheme.JabRefIcons.LINK);
                        jumpTo.setTooltip(new Tooltip(Localization.lang("Jump to entry in library")));
                        jumpTo.getStyleClass().add("addEntryButton");
                        jumpTo.setOnMouseClicked(_ -> jumpToEntry(entry));
                        vContainer.getChildren().add(jumpTo);

                        Button compareButton = ControlHelper.iconButton(IconTheme.JabRefIcons.MERGE_ENTRIES);
                        compareButton.setTooltip(new Tooltip(Localization.lang("Compare with existing entry")));
                        compareButton.setOnMouseClicked(_ -> openPossibleDuplicateEntriesWindow(entry, listView));
                        vContainer.getChildren().add(compareButton);
                    } else {
                        ToggleButton addToggle = ControlHelper.iconToggleButton(IconTheme.JabRefIcons.ADD);
                        addToggle.setTooltip(new Tooltip(Localization.lang("Select entry")));
                        EasyBind.subscribe(addToggle.selectedProperty(), selected -> {
                            if (selected) {
                                addToggle.setGraphic(IconTheme.JabRefIcons.ADD_FILLED.withColor(IconTheme.SELECTED_COLOR).getGraphicNode());
                            } else {
                                addToggle.setGraphic(IconTheme.JabRefIcons.ADD.getGraphicNode());
                            }
                        });
                        addToggle.getStyleClass().add("addEntryButton");
                        addToggle.selectedProperty().bindBidirectional(listView.getItemBooleanProperty(entry));
                        vContainer.getChildren().add(addToggle);
                    }

                    if (entry.entry().getDOI().isPresent() || entry.entry().getField(StandardField.URL).isPresent()) {
                        Button openWeb = ControlHelper.iconButton(IconTheme.JabRefIcons.OPEN_LINK);
                        openWeb.setTooltip(new Tooltip(Localization.lang("Open URL or DOI")));
                        openWeb.setOnMouseClicked(_ -> {
                            String url = entry.entry().getDOI().flatMap(DOI::getExternalURI).map(URI::toString)
                                              .or(() -> entry.entry().getField(StandardField.URL)).orElse("");
                            if (StringUtil.isNullOrEmpty(url)) {
                                return;
                            }
                            try {
                                NativeDesktop.openBrowser(url, preferences.getExternalApplicationsPreferences());
                            } catch (IOException ex) {
                                dialogService.notify(Localization.lang("Unable to open link."));
                            }
                        });
                        vContainer.getChildren().addLast(openWeb);
                    }

                    Button showEntrySource = ControlHelper.iconButton(IconTheme.JabRefIcons.SOURCE);
                    showEntrySource.setTooltip(new Tooltip(Localization.lang("%0 source", "BibTeX")));
                    showEntrySource.setOnMouseClicked(_ -> showEntrySourceDialog(entry.entry()));

                    vContainer.getChildren().addLast(showEntrySource);

                    hContainer.getChildren().addAll(entryNode, separator, vContainer);
                    hContainer.getStyleClass().add("entry-container");

                    // [impl->req~entry-editor.citations.hover-preview~1]
                    hContainer.setOnMouseEntered(_ -> {
                        stateManager.getActiveDatabase().ifPresent(databaseContext -> {
                            previewTooltip.createPreviewTooltip(databaseContext, entry.entry());
                        });
                    });
                    Tooltip.install(hContainer, previewTooltip);

                    return hContainer;
                })
                .withOnMouseClickedEvent((item, event) -> handleItemClick(item, event, listView, citationComponents))
                .setOnDragDetected((item, event) -> handleDragDetected(listView, item, event))
                .setOnDragDone((_, event) -> handleDragDone(listView, event))
                .withPseudoClass(entrySelected, listView::getItemBooleanProperty)
                .install(listView);

        listView.setSelectionModel(new NoSelectionModel<>());
    }

    private void handleItemClick(CitationRelationItem item,
                                 MouseEvent event,
                                 CheckListView<CitationRelationItem> listView,
                                 CitationComponents citationComponents) {
        if (event.getClickCount() == 2 && event.getButton() == MouseButton.PRIMARY) {
            event.consume();
            listView.getCheckModel().check(item);

            if (item.isLocal()) {
                // Jump if item is already in the library
                jumpToEntry(item);
            } else {
                // Entry not in library -> import it
                importEntries(List.of(item), citationComponents.searchType(), getCurrentEntry());
            }
            return;
        }
        if (!item.isLocal()) {
            // standard behavior with one click
            listView.getCheckModel().toggleCheckState(item);
        }
    }

    private void handleDragDetected(CheckListView<CitationRelationItem> listView, CitationRelationItem item, MouseEvent event) {
        shouldClearSelectionOnDrop = false;
        List<BibEntry> entriesToDrag = new ArrayList<>();
        ObservableList<CitationRelationItem> checkedItems = listView.getCheckModel().getCheckedItems();

        if (checkedItems.contains(item)) {
            shouldClearSelectionOnDrop = true;
            entriesToDrag.addAll(checkedItems.stream().map(CitationRelationItem::entry).toList());
        } else {
            entriesToDrag.add(item.entry());
        }

        if (!entriesToDrag.isEmpty()) {
            Dragboard dragboard = listView.startDragAndDrop(TransferMode.COPY);
            ClipboardContent content = new ClipboardContent();
            content.put(DragAndDropDataFormats.ENTRIES, "");
            dragboard.setContent(content);
            stateManager.getLocalDragboard().putBibEntries(entriesToDrag);
        }

        event.consume();
    }

    private void handleDragDone(CheckListView<CitationRelationItem> listView, DragEvent event) {
        if (event.getTransferMode() != null && shouldClearSelectionOnDrop) {
            listView.getCheckModel().clearChecks();
        }
        event.consume();
    }

    private void jumpToEntry(CitationRelationItem entry) {
        if (citingTask != null) {
            citingTask.cancel(false);
        }
        if (citedByTask != null) {
            citedByTask.cancel(false);
        }
        stateManager.activeTabProperty().get().ifPresent(tab -> tab.showAndEdit(entry.localEntry()));
    }

    /// @implNote This code is similar to {@link org.jabref.gui.collab.entrychange.PreviewWithSourceTab}.
    private String getSourceString(BibEntry entry, BibDatabaseMode type, FieldPreferences fieldPreferences, BibEntryTypesManager entryTypesManager) throws IOException {
        StringWriter writer = new StringWriter();
        BibWriter bibWriter = new BibWriter(writer, OS.NEWLINE);
        FieldWriter fieldWriter = new FieldWriter(fieldPreferences);
        new BibEntryWriter(fieldWriter, entryTypesManager).write(entry, bibWriter, type);
        return writer.toString();
    }

    private void showEntrySourceDialog(BibEntry entry) {
        CodeArea ca = new CodeArea();
        try {
            BibDatabaseMode mode = stateManager.getActiveDatabase().map(BibDatabaseContext::getMode)
                                               .orElse(BibDatabaseMode.BIBLATEX);
            ca.appendText(getSourceString(entry, mode, preferences.getFieldPreferences(), this.entryTypesManager));
        } catch (IOException e) {
            LOGGER.warn("Incorrect entry, could not load source:", e);
            return;
        }

        ca.setWrapText(true);
        ca.setPadding(new Insets(0, 10, 0, 10));
        ca.showParagraphAtTop(0);

        ScrollPane scrollPane = new ScrollPane();
        scrollPane.setFitToWidth(true);
        scrollPane.setFitToHeight(true);
        scrollPane.setContent(new VirtualizedScrollPane<>(ca));

        DialogPane dialogPane = new DialogPane();
        dialogPane.setPrefSize(800, 400);
        dialogPane.setContent(scrollPane);
        String title = Localization.lang("Show BibTeX source");

        dialogService.showCustomDialogAndWait(title, dialogPane, ButtonType.OK);
    }

    /// Method to style heading labels
    ///
    /// @param label       label to style
    /// @param tooltipText tooltip text
    private void styleLabel(Label label, String tooltipText) {
        label.getStyleClass().add("padding-5px");
        label.setAlignment(Pos.CENTER);
        label.setTooltip(new Tooltip(tooltipText));
        label.setMaxWidth(Double.MAX_VALUE);
    }

    /// Creates a context menu for citation relation labels with an option to open the API URL in browser
    ///
    /// @param entry      the BibEntry to get the API URL for
    /// @param searchType the type of search (CITES for references, CITED_BY for citations)
    /// @return a ContextMenu with the "Open API URL in browser" option
    private ContextMenu createCitationContextMenu(BibEntry entry, CitationFetcher.SearchType searchType) {
        ContextMenu contextMenu = new ContextMenu();

        MenuItem openApiUrl = new MenuItem(Localization.lang("Open API URL in browser"));

        /// Create a binding that checks if URI is available
        BooleanBinding uriAvailable = Bindings.createBooleanBinding(
                () -> {
                    Optional<URI> uri = searchType == CitationFetcher.SearchType.CITES
                                        ? searchCitationsRelationsService.getReferencesApiUri(entry)
                                        : searchCitationsRelationsService.getCitationsApiUri(entry);
                    return uri.isPresent();
                },
                fetcherCombo.valueProperty() /// revaluate when fetcher changes
        );

        /// Disable the menu item when URI is not available
        openApiUrl.disableProperty().bind(uriAvailable.not());

        /// Set action to open browser (only executes when enabled)
        openApiUrl.setOnAction(_ -> {
            Optional<URI> uri = searchType == CitationFetcher.SearchType.CITES
                                ? searchCitationsRelationsService.getReferencesApiUri(entry)
                                : searchCitationsRelationsService.getCitationsApiUri(entry);

            /// URI should always be present because menu item is disabled otherwise
            /// check for safety
            uri.ifPresent(apiUri -> {
                try {
                    NativeDesktop.openBrowser(apiUri, preferences.getExternalApplicationsPreferences());
                } catch (IOException e) {
                    LOGGER.warn("Could not open API URL in browser: {}", apiUri, e);
                    dialogService.notify(Localization.lang("Unable to open link."));
                }
            });
        });

        contextMenu.getItems().add(openApiUrl);
        return contextMenu;
    }

    /// Method to style refresh buttons
    ///
    /// @param node node to style
    private void styleTopBarNode(Node node, double offset) {
        AnchorPane.setTopAnchor(node, 0.0);
        AnchorPane.setBottomAnchor(node, 0.0);
        AnchorPane.setRightAnchor(node, offset);
    }

    @Override
    protected void bindToEntry(BibEntry entry) {
        citationsRelationsTabViewModel.bindToEntry(entry);

        // TODO: All this should go to ViewModel
        if (citingTask != null && !citingTask.isCancelled()) {
            citingTask.cancel(false);
            citingTask = null;
        }
        if (citedByTask != null && !citedByTask.isCancelled()) {
            citedByTask.cancel(false);
            citedByTask = null;
        }

        SplitPane splitPane = getPaneAndStartSearch(entry);
        splitPane.setMaxSize(Double.MAX_VALUE, Double.MAX_VALUE);
        splitPane.setMinSize(0, 0);

        BorderPane root = new BorderPane();
        root.setCenter(splitPane);
        root.setBottom(sciteResultsPane);
        root.setMaxSize(Double.MAX_VALUE, Double.MAX_VALUE);

        setContent(root);
    }

    private void searchForRelations(CitationComponents citationComponents,
                                    CitationComponents otherCitationComponents,
                                    boolean bypassCache) {
        if (citationComponents.entry().getDOI().isEmpty()) {
            setUpEmptyPanel(citationComponents, otherCitationComponents);
            return;
        }
        executeSearch(citationComponents, bypassCache);
    }

    private void setUpEmptyPanel(CitationComponents citationComponents,
                                 CitationComponents otherCitationComponents) {
        hideNodes(citationComponents.abortButton(), citationComponents.progress());
        showNodes(citationComponents.refreshButton());

        HBox hBox = new HBox();
        Label label = new Label(Localization.lang("The selected entry doesn't have a DOI linked to it."));
        Hyperlink link = new Hyperlink(Localization.lang("Look up a DOI and try again."));

        link.setOnAction(_ -> {
            CrossRef doiFetcher = new CrossRef(preferences.getImporterPreferences());

            BackgroundTask.wrap(() -> doiFetcher.findIdentifier(citationComponents.entry()))
                          .onRunning(() -> {
                              showNodes(citationComponents.progress(), otherCitationComponents.progress());
                              setLabelOn(citationComponents.listView(), Localization.lang("Looking up DOI..."));
                              setLabelOn(otherCitationComponents.listView(), Localization.lang("Looking up DOI..."));
                          })
                          .onSuccess(identifier -> {
                              if (identifier.isPresent()) {
                                  citationComponents.entry().setField(StandardField.DOI, identifier.get().asString());
                                  // if the DOI is successfully looked up (requested by the user), trigger refresh from the remote
                                  executeSearch(citationComponents, true);
                                  executeSearch(otherCitationComponents, true);
                              } else {
                                  dialogService.notify(Localization.lang("No DOI found."));
                                  setUpEmptyPanel(citationComponents, otherCitationComponents);
                                  setUpEmptyPanel(otherCitationComponents, citationComponents);
                              }
                          }).onFailure(ex -> {
                              LOGGER.error("Error while looking up DOI", ex);
                              hideNodes(citationComponents.progress(), otherCitationComponents.progress());
                              setLabelOn(citationComponents.listView(), Localization.lang("Error while looking up DOI."));
                              setLabelOn(otherCitationComponents.listView(), Localization.lang("Error while looking up DOI."));
                          }).executeWith(taskExecutor);
        });

        hBox.getChildren().add(label);
        hBox.getChildren().add(link);
        hBox.setSpacing(2d);
        hBox.getStyleClass().add("align-center");
        hBox.setFillHeight(true);

        citationComponents.listView().getItems().clear();
        citationComponents.listView().setPlaceholder(hBox);
    }

    private static void setLabelOn(CheckListView<CitationRelationItem> listView, String message) {
        Label lookingUpDoiLabel = new Label(message);
        listView.getItems().clear();
        listView.setPlaceholder(lookingUpDoiLabel);
    }

    /// Snapshots the active library on the JavaFX Application Thread, so the background matching task never
    /// iterates the live entry list, which the user can modify while the search runs.
    ///
    /// TODO: This could be a wrong database, because the user might have switched to another library.
    ///       Snapshotting when the search is triggered instead of when its result arrives narrows the window,
    ///       but does not close it - this is still the *active* library, not the entry's own one.
    ///       If we were on fixing this, we would need to a) associate a BibEntry with a database or
    ///       b) pass the database at "bindToEntry".
    private LibrarySnapshot snapshotActiveLibrary() {
        Optional<BibDatabaseContext> databaseContext = stateManager.getActiveDatabase();
        List<BibEntry> libraryEntries = databaseContext.map(context -> List.copyOf(context.getDatabase().getEntries()))
                                                       .orElseGet(List::of);
        BibDatabaseMode databaseMode = databaseContext.map(BibDatabaseContext::getMode)
                                                      .orElse(BibDatabaseMode.BIBLATEX);
        return new LibrarySnapshot(libraryEntries, databaseMode);
    }

    private void executeSearch(CitationComponents citationComponents, boolean bypassCache) {
        LibrarySnapshot librarySnapshot = snapshotActiveLibrary();
        ObservableList<CitationRelationItem> observableList = FXCollections.observableArrayList();
        citationComponents.listView().setItems(observableList);

        if (citationComponents.searchType() == CitationFetcher.SearchType.CITES && citingTask != null && !citingTask.isCancelled()) {
            citingTask.cancel(false);
        } else if (citationComponents.searchType() == CitationFetcher.SearchType.CITED_BY && citedByTask != null && !citedByTask.isCancelled()) {
            citedByTask.cancel(false);
        }

        this.createBackgroundTask(citationComponents.entry(), citationComponents.searchType(), bypassCache, librarySnapshot.entries(), librarySnapshot.mode())
            .consumeOnRunning(task -> prepareToSearchForRelations(citationComponents, task))
            .onSuccess(citationRelationItems -> onSearchForRelationsSucceed(
                    citationComponents,
                    citationRelationItems,
                    observableList
            ))
            .onFailure(exception -> {
                LOGGER.error("Error while fetching {} papers", citationComponents.searchType() == CitationFetcher.SearchType.CITES ? "cited" : "citing", exception);
                hideNodes(citationComponents.abortButton(), citationComponents.progress(), citationComponents.importButton());
                String labelText = citationComponents.searchType() == CitationFetcher.SearchType.CITES
                                   ? Localization.lang("Error while fetching cited entries.")
                                   : Localization.lang("Error while fetching citing entries.");
                Label placeholder = new Label(labelText);
                placeholder.setWrapText(true);
                citationComponents.listView().setPlaceholder(placeholder);
                citationComponents.refreshButton().setVisible(true);
                dialogService.notify(labelText);
            })
            .executeWith(taskExecutor);
    }

    /// TODO: Make the method return a callable and let the calling method create the background task.
    private BackgroundTask<List<CitationRelationItem>> createBackgroundTask(
            BibEntry entry, CitationFetcher.SearchType searchType, boolean bypassCache,
            List<BibEntry> libraryEntries, BibDatabaseMode databaseMode
    ) {
        return switch (searchType) {
            case CitationFetcher.SearchType.CITES -> {
                citingTask = createSearchTask(
                        isCancelled -> this.searchCitationsRelationsService.searchCites(entry, bypassCache, isCancelled),
                        libraryEntries, databaseMode);
                yield citingTask;
            }
            case CitationFetcher.SearchType.CITED_BY -> {
                citedByTask = createSearchTask(
                        isCancelled -> this.searchCitationsRelationsService.searchCitedBy(entry, bypassCache, isCancelled),
                        libraryEntries, databaseMode);
                yield citedByTask;
            }
        };
    }

    /// Wraps fetching and duplicate matching into a task whose cancellation checks are bound to *that very task*.
    /// They must not read [#citingTask]/[#citedByTask] instead: those fields are reassigned - or set to `null` -
    /// on the JavaFX Application Thread as soon as another entry is selected, so a check evaluated after the fetch
    /// returned would either hit `null` or observe an unrelated, newer task.
    private BackgroundTask<List<CitationRelationItem>> createSearchTask(CitationFetch fetch,
                                                                        List<BibEntry> libraryEntries,
                                                                        BibDatabaseMode databaseMode) {
        AtomicReference<BackgroundTask<List<CitationRelationItem>>> selfReference = new AtomicReference<>();
        BooleanSupplier isCancelled = () -> {
            BackgroundTask<List<CitationRelationItem>> self = selfReference.get();
            return (self != null) && self.isCancelled();
        };
        BackgroundTask<List<CitationRelationItem>> task = BackgroundTask.wrap(
                () -> matchAgainstLibrary(fetch.fetch(isCancelled), libraryEntries, databaseMode, isCancelled));
        selfReference.set(task);
        return task;
    }

    private List<CitationRelationItem> matchAgainstLibrary(List<BibEntry> fetchedList,
                                                           List<BibEntry> libraryEntries,
                                                           BibDatabaseMode databaseMode,
                                                           BooleanSupplier isCancelled) {
        List<CitationRelationItem> citationRelationItems = new ArrayList<>(fetchedList.size());
        for (BibEntry fetchedEntry : fetchedList) {
            if (isCancelled.getAsBoolean()) {
                return List.of();
            }
            citationRelationItems.add(
                    duplicateCheck.containsDuplicate(libraryEntries, fetchedEntry, databaseMode)
                                  .map(localEntry -> new CitationRelationItem(fetchedEntry, localEntry, true))
                                  .orElseGet(() -> new CitationRelationItem(fetchedEntry, false)));
        }
        return citationRelationItems;
    }

    private void onSearchForRelationsSucceed(CitationComponents citationComponents,
                                             List<CitationRelationItem> citationRelationItems,
                                             ObservableList<CitationRelationItem> observableList) {
        hideNodes(citationComponents.abortButton(), citationComponents.progress());

        observableList.setAll(citationRelationItems);

        if (observableList.isEmpty()) {
            Label placeholder = new Label(Localization.lang("No articles found"));
            citationComponents.listView().setPlaceholder(placeholder);
        } else {
            citationComponents.listView().refresh();
        }
        BooleanBinding booleanBind = Bindings.isEmpty(citationComponents.listView().getCheckModel().getCheckedItems());
        citationComponents.importButton().disableProperty().bind(booleanBind);
        citationComponents.importButton().setOnMouseClicked(event -> importEntries(citationComponents.listView().getCheckModel().getCheckedItems(), citationComponents.searchType(), citationComponents.entry()));
        showNodes(citationComponents.refreshButton(), citationComponents.importButton());
    }

    private void prepareToSearchForRelations(CitationComponents citationComponents, BackgroundTask<List<CitationRelationItem>> task) {
        showNodes(citationComponents.abortButton(), citationComponents.progress());
        hideNodes(citationComponents.refreshButton(), citationComponents.importButton());

        citationComponents.abortButton().setOnAction(_ -> {
            hideNodes(citationComponents.abortButton(), citationComponents.progress(), citationComponents.importButton());
            showNodes(citationComponents.refreshButton());
            task.cancel(false);
            dialogService.notify(Localization.lang("Search aborted."));
        });
    }

    private void hideNodes(Node... nodes) {
        Arrays.stream(nodes).forEach(node -> node.setVisible(false));
    }

    private void showNodes(Node... nodes) {
        Arrays.stream(nodes).forEach(node -> node.setVisible(true));
    }

    /// Function to import selected entries to the database. Also writes the entries to import to the CITING/CITED field
    ///
    /// @param entriesToImport entries to import
    private void importEntries(List<CitationRelationItem> entriesToImport, CitationFetcher.SearchType searchType, BibEntry existingEntry) {
        if (citingTask != null) {
            citingTask.cancel(false);
        }
        if (citedByTask != null) {
            citedByTask.cancel(false);
        }
        citationsRelationsTabViewModel.importEntries(entriesToImport, searchType, existingEntry);
        dialogService.notify(Localization.lang("%0 entry(s) imported", entriesToImport.size()));
    }

    /// Function to open possible duplicate entries window to compare duplicate entries
    ///
    /// @param citationRelationItem duplicate in the citation relations tab
    /// @param listView             CheckListView to display citations
    private void openPossibleDuplicateEntriesWindow(CitationRelationItem citationRelationItem, CheckListView<CitationRelationItem> listView) {
        BibEntry libraryEntry = citationRelationItem.localEntry();
        BibEntry citationEntry = citationRelationItem.entry();
        String leftHeader = Localization.lang("Library Entry");
        String rightHeader = Localization.lang("Citation Entry");

        MergeEntriesDialog dialog = new MergeEntriesDialog(libraryEntry, citationEntry, leftHeader, rightHeader, preferences, stateManager);
        dialog.setTitle(Localization.lang("Possible duplicate entries"));

        Optional<EntriesMergeResult> entriesMergeResult = dialogService.showCustomDialogAndWait(dialog);
        entriesMergeResult.ifPresentOrElse(mergeResult -> {

            BibEntry mergedEntry = mergeResult.mergedEntry();
            // update local entry of selected citation relation item
            listView.getItems().set(listView.getItems().indexOf(citationRelationItem), new CitationRelationItem(citationRelationItem.entry(), mergedEntry, true));

            // Merge method is similar to MergeTwoEntriesAction#execute
            Optional<LibraryTab> libraryTab = stateManager.activeTabProperty().get();
            if (libraryTab.isEmpty()) {
                dialogService.notify(Localization.lang("No library present"));
                return;
            }

            BibDatabase database = libraryTab.get().getDatabase();
            database.removeEntry(mergeResult.originalLeftEntry());
            libraryTab.get().getMainTable().setCitationMergeMode(true);
            database.insertEntry(mergedEntry);

            NamedCompoundEdit compoundEdit = new NamedCompoundEdit(Localization.lang("Merge entries"));
            compoundEdit.addEdit(new UndoableRemoveEntries(database, mergeResult.originalLeftEntry()));
            compoundEdit.addEdit(new UndoableInsertEntries(database, mergedEntry));
            compoundEdit.end();

            undoManager.addEdit(compoundEdit);

            dialogService.notify(Localization.lang("Merged entries"));
        }, () -> dialogService.notify(Localization.lang("Canceled merging entries")));
    }

    private void handleRefresh(CitationComponents citationComponents, CitationComponents otherCitationComponents) {
        BibEntry entry = citationComponents.entry();
        boolean isCites = citationComponents.searchType() == CitationFetcher.SearchType.CITES;

        boolean hasError = citationComponents.listView().getPlaceholder() instanceof Label label
                && label.getText().startsWith(Localization.lang("Error"));

        if (hasError) {
            searchForRelations(citationComponents, otherCitationComponents, true);
            return;
        }

        boolean isUpdatable = isCites
                              ? searchCitationsRelationsService.isReferencesUpdatable(entry)
                              : searchCitationsRelationsService.isCitationsUpdatable(entry);

        if (!isUpdatable) {
            boolean confirmed = dialogService.showConfirmationDialogAndWait(
                    Localization.lang("Refetch citations"),
                    Localization.lang("The citations were fetched recently. Are you sure you want to refetch?")
            );
            if (!confirmed) {
                return;
            }
        }

        searchForRelations(citationComponents, otherCitationComponents, true);
    }

    /// Immutable snapshot of the active library, taken on the JavaFX Application Thread and consumed by the
    /// background duplicate matching in [#matchAgainstLibrary].
    private record LibrarySnapshot(List<BibEntry> entries, BibDatabaseMode mode) {
    }

    /// One of the two citation searches of [SearchCitationsRelationsService], parameterized over the cancellation
    /// check so that [#createSearchTask] can supply the one belonging to the task it is currently building.
    @FunctionalInterface
    private interface CitationFetch {
        List<BibEntry> fetch(BooleanSupplier isCancelled) throws FetcherException;
    }
}
