package org.jabref.gui.groups;

import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

import javafx.beans.InvalidationListener;
import javafx.beans.WeakInvalidationListener;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.beans.binding.IntegerBinding;
import javafx.beans.property.ReadOnlyIntegerWrapper;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.scene.input.Dragboard;
import javafx.scene.paint.Color;

import org.jabref.gui.DragAndDropDataFormats;
import org.jabref.gui.StateManager;
import org.jabref.gui.icon.IconTheme;
import org.jabref.gui.icon.JabRefIcon;
import org.jabref.gui.preferences.GuiPreferences;
import org.jabref.gui.util.CustomLocalDragboard;
import org.jabref.gui.util.DroppingMouseLocation;
import org.jabref.gui.util.UiTaskExecutor;
import org.jabref.logic.groups.GroupsFactory;
import org.jabref.logic.layout.format.LatexToUnicodeFormatter;
import org.jabref.logic.search.SearchContext;
import org.jabref.logic.util.BackgroundTask;
import org.jabref.logic.util.TaskExecutor;
import org.jabref.logic.util.strings.StringUtil;
import org.jabref.model.FieldChange;
import org.jabref.model.database.BibDatabaseContext;
import org.jabref.model.entry.BibEntry;
import org.jabref.model.groups.AbstractGroup;
import org.jabref.model.groups.AllEntriesGroup;
import org.jabref.model.groups.AutomaticDateGroup;
import org.jabref.model.groups.AutomaticEntryTypeGroup;
import org.jabref.model.groups.AutomaticGroup;
import org.jabref.model.groups.AutomaticKeywordGroup;
import org.jabref.model.groups.AutomaticPersonsGroup;
import org.jabref.model.groups.DateGroup;
import org.jabref.model.groups.EntryTypeGroup;
import org.jabref.model.groups.ExplicitGroup;
import org.jabref.model.groups.GroupEntryChanger;
import org.jabref.model.groups.GroupTreeNode;
import org.jabref.model.groups.KeywordGroup;
import org.jabref.model.groups.LastNameGroup;
import org.jabref.model.groups.RegexKeywordGroup;
import org.jabref.model.groups.SearchGroup;
import org.jabref.model.groups.TexGroup;
import org.jabref.model.search.event.IndexAddedOrUpdatedEvent;
import org.jabref.model.search.event.IndexClosedEvent;
import org.jabref.model.search.event.IndexRemovedEvent;
import org.jabref.model.search.event.IndexStartedEvent;

import com.google.common.eventbus.Subscribe;
import com.tobiasdiez.easybind.EasyBind;
import com.tobiasdiez.easybind.EasyObservableList;
import io.github.adr.linked.ADR;
import org.jspecify.annotations.NonNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class GroupNodeViewModel {

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

    private final SimpleObjectProperty<String> displayName;
    private final boolean isRoot;
    private final ObservableList<GroupNodeViewModel> children;
    private final BibDatabaseContext databaseContext;
    private final StateManager stateManager;
    private final GroupTreeNode groupNode;
    /// Internal cache of matched entry IDs.
    ///
    /// Kept as a plain `Set` instead of an `ObservableSet`, because group refreshes and search-index updates can
    /// overlap while the UI stays responsive (for example inside modal dialogs). Exposing collection change events
    /// for this cache made bulk refreshes re-entrant and could trigger `ConcurrentModificationException`.
    @ADR(38)
    private final Set<String> matchedEntries = new HashSet<>();
    /// Guards both the matched-entry cache and the derived hit-count property so readers observe a consistent pair.
    private final Object matchedEntriesLock = new Object();
    /// UI-facing count derived from [#matchedEntries]. The sidebar binds to this property instead of the cache itself.
    private final ReadOnlyIntegerWrapper matchedEntriesCount = new ReadOnlyIntegerWrapper(0);
    private final SimpleBooleanProperty hasChildren;
    private final SimpleBooleanProperty expandedProperty = new SimpleBooleanProperty();
    private final BooleanBinding anySelectedEntriesMatched;
    private final BooleanBinding allSelectedEntriesMatched;
    private final TaskExecutor taskExecutor;
    private final CustomLocalDragboard localDragBoard;
    private final GuiPreferences preferences;
    @SuppressWarnings("FieldCanBeLocal")
    private final ObservableList<BibEntry> entriesList;
    @SuppressWarnings("FieldCanBeLocal")
    private final InvalidationListener onInvalidatedGroup = _ -> refreshGroup();
    private boolean matchedEntriesInitialized;
    private boolean matchedEntriesUpdateInProgress;
    private boolean matchedEntriesUpdatePending;

    public GroupNodeViewModel(@NonNull BibDatabaseContext databaseContext,
                              @NonNull StateManager stateManager,
                              @NonNull TaskExecutor taskExecutor,
                              @NonNull GroupTreeNode groupNode,
                              @NonNull CustomLocalDragboard localDragBoard,
                              @NonNull GuiPreferences preferences) {
        this.databaseContext = databaseContext;
        this.taskExecutor = taskExecutor;
        this.stateManager = stateManager;
        this.groupNode = groupNode;
        this.localDragBoard = localDragBoard;
        this.preferences = preferences;

        displayName = new SimpleObjectProperty<>(new LatexToUnicodeFormatter().format(groupNode.getName()));
        isRoot = groupNode.isRoot();
        if (groupNode.getGroup() instanceof AutomaticGroup automaticGroup) {
            children = automaticGroup.createSubgroups(this.databaseContext.getDatabase().getEntries())
                                     .stream()
                                     .map(this::toViewModel)
                                     .sorted((group1, group2) -> group1.getDisplayName().compareToIgnoreCase(group2.getDisplayName()))
                                     .collect(Collectors.toCollection(FXCollections::observableArrayList));
        } else {
            children = EasyBind.mapBacked(groupNode.getChildren(), this::toViewModel);
        }
        if (groupNode.getGroup() instanceof TexGroup) {
            databaseContext.getMetaData().groupsBinding().addListener(new WeakInvalidationListener(onInvalidatedGroup));
        } else if (groupNode.getGroup() instanceof SearchGroup searchGroup) {
            SearchContext searchContext = stateManager.getSearchContext(databaseContext);
            searchGroup.setMatchedEntries(searchContext.search(searchGroup.getSearchQuery()).getMatchedEntries());
            refreshGroup();
            databaseContext.getMetaData().groupsBinding().invalidate();
        }

        hasChildren = new SimpleBooleanProperty();
        hasChildren.bind(Bindings.isNotEmpty(children));
        expandedProperty.set(groupNode.getGroup().isExpanded());
        expandedProperty.addListener((_, _, newValue) -> groupNode.getGroup().setExpanded(newValue));

        // Register listener
        // The wrapper created by the FXCollections will set a weak listener on the wrapped list. This weak listener gets garbage collected. Hence, we need to maintain a reference to this list.
        entriesList = databaseContext.getDatabase().getEntries();
        entriesList.addListener(this::onDatabaseChanged);

        EasyObservableList<Boolean> selectedEntriesMatchStatus = EasyBind.map(stateManager.getSelectedEntries(), groupNode::matches);
        anySelectedEntriesMatched = selectedEntriesMatchStatus.anyMatch(matched -> matched);
        // 'all' returns 'true' for empty streams, so this has to be checked explicitly
        allSelectedEntriesMatched = selectedEntriesMatchStatus.isEmptyBinding().not().and(selectedEntriesMatchStatus.allMatch(matched -> matched));

        this.databaseContext.getDatabase().registerListener(new SearchIndexListener());
    }

    public GroupNodeViewModel(BibDatabaseContext databaseContext, StateManager stateManager, TaskExecutor taskExecutor, AbstractGroup group, CustomLocalDragboard localDragboard, GuiPreferences preferences) {
        this(databaseContext, stateManager, taskExecutor, new GroupTreeNode(group), localDragboard, preferences);
    }

    static GroupNodeViewModel getAllEntriesGroup(BibDatabaseContext newDatabase, StateManager stateManager, TaskExecutor taskExecutor, CustomLocalDragboard localDragBoard, GuiPreferences preferences) {
        return new GroupNodeViewModel(newDatabase, stateManager, taskExecutor, GroupsFactory.createAllEntriesGroup(), localDragBoard, preferences);
    }

    private GroupNodeViewModel toViewModel(GroupTreeNode child) {
        return new GroupNodeViewModel(databaseContext, stateManager, taskExecutor, child, localDragBoard, preferences);
    }

    public List<FieldChange> addEntriesToGroup(List<BibEntry> entries) {
        // TODO: warn if assignment has undesired side effects (modifies a field != keywords)
        // if (!WarnAssignmentSideEffects.warnAssignmentSideEffects(group, groupSelector.frame))
        // {
        //    return; // user aborted operation
        // }

        List<FieldChange> changes = groupNode.addEntriesToGroup(entries);

        // Update appearance of group
        anySelectedEntriesMatched.invalidate();
        allSelectedEntriesMatched.invalidate();

        return changes;
        // TODO: Store undo
        // if (!undo.isEmpty()) {
        // groupSelector.concludeAssignment(UndoableChangeEntriesOfGroup.getUndoableEdit(target, undo), target.getNode(), assignedEntries);
    }

    public SimpleBooleanProperty expandedProperty() {
        return expandedProperty;
    }

    public BooleanBinding anySelectedEntriesMatchedProperty() {
        return anySelectedEntriesMatched;
    }

    public BooleanBinding allSelectedEntriesMatchedProperty() {
        return allSelectedEntriesMatched;
    }

    public SimpleBooleanProperty hasChildrenProperty() {
        return hasChildren;
    }

    public String getDisplayName() {
        return displayName.get();
    }

    public void setDisplayName(String newName) {
        displayName.set(newName);
    }

    public boolean isRoot() {
        return isRoot;
    }

    public String getDescription() {
        return groupNode.getGroup().getDescription().orElse("");
    }

    public IntegerBinding getHits() {
        return Bindings.createIntegerBinding(matchedEntriesCount::get, matchedEntriesCount.getReadOnlyProperty());
    }

    void ensureMatchedEntriesLoaded() {
        // Also guard on "in progress": this method only needs the initial load, and cells re-render
        // frequently — queueing a pending re-run here would rescan the whole database once per burst.
        if (!matchedEntriesInitialized && !matchedEntriesUpdateInProgress) {
            updateMatchedEntries();
        }
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if ((o == null) || (getClass() != o.getClass())) {
            return false;
        }

        GroupNodeViewModel that = (GroupNodeViewModel) o;

        return groupNode.equals(that.groupNode);
    }

    @Override
    public String toString() {
        Set<String> matchedEntriesSnapshot;
        // Snapshot under the same lock used by writers so debug output never iterates the live set mid-mutation.
        synchronized (matchedEntriesLock) {
            matchedEntriesSnapshot = Set.copyOf(matchedEntries);
        }

        return "GroupNodeViewModel{" +
                "displayName='" + displayName + '\'' +
                ", isRoot=" + isRoot +
                ", icon='" + getIcon() + '\'' +
                ", children=" + children +
                ", databaseContext=" + databaseContext +
                ", groupNode=" + groupNode +
                ", matchedEntries=" + matchedEntriesSnapshot +
                '}';
    }

    @Override
    public int hashCode() {
        return groupNode.hashCode();
    }

    public JabRefIcon getIcon() {
        Optional<String> iconName = groupNode.getGroup().getIconName();
        return iconName.flatMap(this::parseIcon)
                       .orElseGet(this::createDefaultIcon);
    }

    private JabRefIcon createDefaultIcon() {
        Color color = groupNode.getGroup().getColor().map(Color::valueOf).orElse(IconTheme.DEFAULT_GROUP_COLOR);
        return IconTheme.JabRefIcons.DEFAULT_GROUP_ICON_COLORED.withColor(color);
    }

    private Optional<JabRefIcon> parseIcon(String iconCode) {
        return IconTheme.findGroupIcon(iconCode)
                        .map(icon -> icon.withColor(getColor()));
    }

    public ObservableList<GroupNodeViewModel> getChildren() {
        return children;
    }

    public GroupTreeNode getGroupNode() {
        return groupNode;
    }

    /// Gets invoked if an entry in the current database changes.
    ///
    /// @implNote Search groups are updated in {@link SearchIndexListener}.
    private void onDatabaseChanged(ListChangeListener.Change<? extends BibEntry> change) {
        if (groupNode.getGroup() instanceof SearchGroup) {
            return;
        }
        while (change.next()) {
            if (change.wasPermutated()) {
                // Nothing to do, as permutation doesn't change matched entries
            } else if (change.wasUpdated()) {
                for (BibEntry changedEntry : change.getList().subList(change.getFrom(), change.getTo())) {
                    if (isMatchEffective(this, changedEntry)) {
                        // ADR-0038
                        addMatchedEntry(changedEntry.getId());
                    } else {
                        // ADR-0038
                        removeMatchedEntry(changedEntry.getId());
                    }
                }
            } else {
                for (BibEntry removedEntry : change.getRemoved()) {
                    // ADR-0038
                    removeMatchedEntry(removedEntry.getId());
                }
                for (BibEntry addedEntry : change.getAddedSubList()) {
                    if (isMatchEffective(this, addedEntry)) {
                        // ADR-0038
                        addMatchedEntry(addedEntry.getId());
                    }
                }
            }
        }
    }

    private void refreshGroup() {
        UiTaskExecutor.runInJavaFXThread(() -> {
            updateMatchedEntries(); // Update the entries matched by the group
            // "Re-add" to the selected groups if it were selected, this refreshes the entries the user views
            ObservableList<GroupTreeNode> selectedGroups = this.stateManager.getSelectedGroups(this.databaseContext);
            if (selectedGroups.remove(this.groupNode)) {
                selectedGroups.add(this.groupNode);
            }
        });
    }

    void updateMatchedEntries() {
        // [impl->req~ux.active-library.preview-responsiveness~1]
        if (!preferences.getGroupsPreferences().shouldDisplayGroupCount()) {
            // A skipped recompute leaves the cache stale: force a reload when counts are re-enabled,
            // and clear now so rebinding never briefly shows the outdated number
            matchedEntriesInitialized = false;
            clearMatchedEntries();
            return;
        }

        if (matchedEntriesUpdateInProgress) {
            matchedEntriesUpdatePending = true;
            return;
        }

        matchedEntriesUpdateInProgress = true;
        BackgroundTask<List<BibEntry>> updateTask = BackgroundTask
                .wrap(() -> databaseContext.getDatabase().getEntries().stream()
                                           .filter(e -> isMatchEffective(this, e))
                                           .toList())
                .onSuccess(entries -> {
                    replaceMatchedEntries(entries);
                    matchedEntriesInitialized = true;
                    completeMatchedEntriesUpdate();
                })
                .onFailure(e -> {
                    LOGGER.warn("Could not update matched entries for group {}", groupNode.getName(), e);
                    completeMatchedEntriesUpdate();
                });
        // schedule() routes to the executor's separate scheduled pool, keeping the main worker pool
        // free for preview rendering — do not "simplify" to executeWith()
        taskExecutor.schedule(updateTask, 0, TimeUnit.MILLISECONDS);
    }

    private void completeMatchedEntriesUpdate() {
        matchedEntriesUpdateInProgress = false;
        if (matchedEntriesUpdatePending) {
            matchedEntriesUpdatePending = false;
            updateMatchedEntries();
        }
    }

    private void addMatchedEntry(String entryId) {
        synchronized (matchedEntriesLock) {
            if (matchedEntries.add(entryId)) {
                matchedEntriesCount.set(matchedEntries.size());
            }
        }
    }

    private void removeMatchedEntry(String entryId) {
        synchronized (matchedEntriesLock) {
            if (matchedEntries.remove(entryId)) {
                matchedEntriesCount.set(matchedEntries.size());
            }
        }
    }

    private void clearMatchedEntries() {
        synchronized (matchedEntriesLock) {
            if (!matchedEntries.isEmpty()) {
                matchedEntries.clear();
                matchedEntriesCount.set(0);
            }
        }
    }

    private void replaceMatchedEntries(List<BibEntry> entries) {
        synchronized (matchedEntriesLock) {
            matchedEntries.clear();
            entries.stream()
                   .map(BibEntry::getId)
                   .forEach(matchedEntries::add);
            matchedEntriesCount.set(matchedEntries.size());
        }
    }

    public GroupTreeNode addSubgroup(AbstractGroup subgroup) {
        return groupNode.addSubgroup(subgroup);
    }

    void toggleExpansion() {
        expandedProperty().set(!expandedProperty().get());
    }

    boolean isMatchedBy(String searchString) {
        return StringUtil.isBlank(searchString) || StringUtil.containsIgnoreCase(getDisplayName(), searchString);
    }

    public Color getColor() {
        return groupNode.getGroup().getColor().map(Color::valueOf).orElse(IconTheme.DEFAULT_GROUP_COLOR);
    }

    public String getPath() {
        return groupNode.getPath();
    }

    public Optional<GroupNodeViewModel> getChildByPath(String pathToSource) {
        return groupNode.getChildByPath(pathToSource).map(this::toViewModel);
    }

    /// Decides if the content stored in the given {@link Dragboard} can be dropped on the given target row. Currently, the following sources are allowed:
    ///
    /// - another group (will be added as subgroup on drop)
    /// - entries if the group implements {@link GroupEntryChanger} (will be assigned to group on drop)
    ///
    public boolean acceptableDrop(Dragboard dragboard) {
        // TODO: we should also check isNodeDescendant
        boolean canDropOtherGroup = dragboard.hasContent(DragAndDropDataFormats.GROUP);
        boolean canDropEntries = localDragBoard.hasBibEntries() && (groupNode.getGroup() instanceof GroupEntryChanger);
        boolean canDropFiles = dragboard.hasFiles();
        return canDropOtherGroup || canDropEntries || canDropFiles;
    }

    public void moveTo(GroupNodeViewModel target) {
        // TODO: Add undo and display message
        // MoveGroupChange undo = new MoveGroupChange(((GroupTreeNodeViewModel)source.getParent()).getNode(),
        //        source.getNode().getPositionInParent(), target.getNode(), target.getChildCount());

        getGroupNode().moveTo(target.getGroupNode());
        // panel.getUndoManager().addEdit(new UndoableMoveGroup(this.groupsRoot, moveChange));
        // panel.markBaseChanged();
        // frame.output(Localization.lang("Moved group \"%0\".", node.getNode().getGroup().getName()));
    }

    public void moveTo(GroupTreeNode target, int targetIndex) {
        getGroupNode().moveTo(target, targetIndex);
    }

    public Optional<GroupTreeNode> getParent() {
        return groupNode.getParent();
    }

    public void draggedOn(GroupNodeViewModel target, DroppingMouseLocation mouseLocation) {
        // No action, if the target is the same as the source
        if (this.equals(target)) {
            return;
        }

        Optional<GroupTreeNode> targetParent = target.getParent();
        if (targetParent.isPresent()) {
            int targetIndex = target.getPositionInParent();
            // In case we want to move an item in the same parent
            // and the item is moved down, we need to adjust the target index
            if (targetParent.equals(getParent())) {
                int sourceIndex = this.getPositionInParent();
                if (sourceIndex < targetIndex) {
                    targetIndex--;
                }
            }

            // Different actions depending on where the user releases the drop in the target row
            // Bottom + top -> insert source row before / after this row
            // Center -> add as child
            switch (mouseLocation) {
                case BOTTOM ->
                        this.moveTo(targetParent.get(), targetIndex + 1);
                case CENTER ->
                        this.moveTo(target);
                case TOP ->
                        this.moveTo(targetParent.get(), targetIndex);
            }
        } else {
            // No parent = root -> just add
            this.moveTo(target);
        }
    }

    private int getPositionInParent() {
        return groupNode.getPositionInParent();
    }

    public boolean hasSubgroups() {
        return !getChildren().isEmpty();
    }

    public boolean isAllEntriesGroup() {
        return groupNode.getGroup() instanceof AllEntriesGroup;
    }

    public boolean hasSimilarSearchGroup(SearchGroup searchGroup) {
        return getChildren().stream()
                            .filter(child -> child.getGroupNode().getGroup() instanceof SearchGroup)
                            .map(child -> (SearchGroup) child.getGroupNode().getGroup())
                            .anyMatch(group -> group.equals(searchGroup));
    }

    public boolean hasAllSuggestedGroups() {
        return hasSimilarSearchGroup(GroupsFactory.createWithoutFilesGroup())
                && hasSimilarSearchGroup(GroupsFactory.createWithoutGroupsGroup());
    }

    public boolean canAddEntriesIn() {
        AbstractGroup group = groupNode.getGroup();
        if (group instanceof AllEntriesGroup) {
            return false;
        } else if (group instanceof ExplicitGroup) {
            return true;
        } else if (group instanceof LastNameGroup || group instanceof RegexKeywordGroup) {
            return groupNode.getParent()
                            .map(GroupTreeNode::getGroup)
                            .map(groupParent -> groupParent instanceof AutomaticKeywordGroup || groupParent instanceof AutomaticPersonsGroup)
                            .orElse(false);
        } else if (group instanceof KeywordGroup) {
            // also covers WordKeywordGroup
            return true;
        } else if (group instanceof SearchGroup) {
            return false;
        } else if (group instanceof AutomaticKeywordGroup) {
            return false;
        } else if (group instanceof AutomaticPersonsGroup) {
            return false;
        } else if (group instanceof TexGroup) {
            return false;
        } else if (group instanceof AutomaticDateGroup) {
            return false;
        } else if (group instanceof DateGroup) {
            return false;
        } else if (group instanceof AutomaticEntryTypeGroup) {
            return false;
        } else if (group instanceof EntryTypeGroup) {
            return false;
        } else {
            throw new UnsupportedOperationException("canAddEntriesIn method not yet implemented in group: " + group.getClass().getName());
        }
    }

    public boolean canBeDragged() {
        AbstractGroup group = groupNode.getGroup();
        return switch (group) {
            case AllEntriesGroup _ ->
                    false;
            case ExplicitGroup _,
                 SearchGroup _,
                 AutomaticKeywordGroup _,
                 AutomaticPersonsGroup _,
                 AutomaticDateGroup _,
                 AutomaticEntryTypeGroup _,
                 EntryTypeGroup _,
                 TexGroup _ ->
                    true;
            case KeywordGroup _ ->
                // KeywordGroup is parent of LastNameGroup, RegexKeywordGroup and WordKeywordGroup
                    groupNode.getParent()
                             .map(GroupTreeNode::getGroup)
                             .map(groupParent ->
                                     !(groupParent instanceof AutomaticKeywordGroup || groupParent instanceof AutomaticPersonsGroup))
                             .orElse(false);

            case null ->
                    throw new IllegalArgumentException("Group cannot be null");
            default ->
                    throw new UnsupportedOperationException("canBeDragged method not yet implemented in group: " + group.getClass().getName());
        };
    }

    public boolean canAddGroupsIn() {
        AbstractGroup group = groupNode.getGroup();
        return switch (group) {
            case AllEntriesGroup _,
                 ExplicitGroup _,
                 SearchGroup _,
                 TexGroup _ ->
                    true;
            case AutomaticKeywordGroup _,
                 AutomaticPersonsGroup _,
                 AutomaticDateGroup _,
                 DateGroup _,
                 AutomaticEntryTypeGroup _,
                 EntryTypeGroup _ ->
                    false;
            case KeywordGroup _ ->
                // KeywordGroup is parent of LastNameGroup, RegexKeywordGroup and WordKeywordGroup
                    groupNode.getParent()
                             .map(GroupTreeNode::getGroup)
                             .map(groupParent -> !(groupParent instanceof AutomaticKeywordGroup || groupParent instanceof AutomaticPersonsGroup))
                             .orElse(false);
            case null ->
                    throw new IllegalArgumentException("Group cannot be null");
            default ->
                    throw new UnsupportedOperationException("canAddGroupsIn method not yet implemented in group: " + group.getClass().getName());
        };
    }

    public boolean canRemove() {
        AbstractGroup group = groupNode.getGroup();
        return switch (group) {
            case AllEntriesGroup _ ->
                    false;
            case ExplicitGroup _,
                 SearchGroup _,
                 AutomaticKeywordGroup _,
                 AutomaticPersonsGroup _,
                 AutomaticDateGroup _,
                 AutomaticEntryTypeGroup _,
                 DateGroup _,
                 EntryTypeGroup _,
                 TexGroup _ ->
                    true;
            case KeywordGroup _ ->
                // KeywordGroup is parent of LastNameGroup, RegexKeywordGroup and WordKeywordGroup
                    groupNode.getParent()
                             .map(GroupTreeNode::getGroup)
                             .map(groupParent -> !(groupParent instanceof AutomaticKeywordGroup || groupParent instanceof AutomaticPersonsGroup))
                             .orElse(false);
            case null ->
                    throw new IllegalArgumentException("Group cannot be null");
            default ->
                    throw new UnsupportedOperationException("canRemove method not yet implemented in group: " + group.getClass().getName());
        };
    }

    public boolean isEditable() {
        AbstractGroup group = groupNode.getGroup();
        return switch (group) {
            case AllEntriesGroup _,
                 DateGroup _,
                 EntryTypeGroup _ ->
                    false;
            case ExplicitGroup _,
                 SearchGroup _,
                 AutomaticKeywordGroup _,
                 AutomaticPersonsGroup _,
                 AutomaticDateGroup _,
                 AutomaticEntryTypeGroup _,
                 TexGroup _ ->
                    true;
            case KeywordGroup _ ->
                // KeywordGroup is parent of LastNameGroup, RegexKeywordGroup and WordKeywordGroup
                    groupNode.getParent()
                             .map(GroupTreeNode::getGroup)
                             .map(groupParent -> !(groupParent instanceof AutomaticKeywordGroup || groupParent instanceof AutomaticPersonsGroup))
                             .orElse(false);

            case null ->
                    throw new IllegalArgumentException("Group cannot be null");
            default ->
                    throw new UnsupportedOperationException("isEditable method not yet implemented in group: " + group.getClass().getName());
        };
    }

    /// Returns whether the given entry should be considered part of this group
    /// for the purpose of the groups sidebar (hit counter, highlighting, etc.).
    ///
    /// We cannot simply use groupNode.matches(entry) here. That only checks
    /// the rule of this single group and ignores the configured hierarchy type and
    /// any child groups created in the view model (for example automatic subgroups)
    ///
    /// This method applies the hierarchy type:
    /// INDEPENDENT: match this group only,
    /// INCLUDING: match this group or any child group,
    /// REFINING: match this group and all ancestor groups.
    private boolean isMatchEffective(GroupNodeViewModel vm, BibEntry entry) {
        GroupTreeNode node = vm.groupNode;
        return switch (node.getGroup().getHierarchicalContext()) {
            case INDEPENDENT ->
                    node.matches(entry);

            case INCLUDING -> {
                if (node.matches(entry)) {
                    yield true;
                }
                // recursively check VM-children (including auto-groups)
                yield vm.children.stream().anyMatch(childVm -> isMatchEffective(childVm, entry));
            }

            case REFINING -> {
                if (!node.matches(entry)) {
                    yield false;
                }
                Optional<GroupTreeNode> parent = node.getParent();
                while (parent.isPresent()) {
                    if (!parent.get().matches(entry)) {
                        yield false;
                    }
                    parent = parent.get().getParent();
                }
                yield true;
            }
        };
    }

    class SearchIndexListener {
        @Subscribe
        public void listen(IndexStartedEvent event) {
            if (groupNode.getGroup() instanceof SearchGroup searchGroup) {
                SearchContext searchContext = stateManager.getSearchContext(databaseContext);
                searchGroup.setMatchedEntries(searchContext.search(searchGroup.getSearchQuery()).getMatchedEntries());
                refreshGroup();
                databaseContext.getMetaData().groupsBinding().invalidate();
            }
        }

        @Subscribe
        public void listen(IndexAddedOrUpdatedEvent event) {
            if (groupNode.getGroup() instanceof SearchGroup searchGroup) {
                SearchContext searchContext = stateManager.getSearchContext(databaseContext);
                BackgroundTask.wrap(() -> {
                    for (BibEntry entry : event.entries()) {
                        searchGroup.updateMatches(entry, searchContext.isEntryMatched(entry, searchGroup.getSearchQuery()));
                    }
                }).onFinished(() -> {
                    for (BibEntry entry : event.entries()) {
                        if (GroupNodeViewModel.this.isMatchEffective(GroupNodeViewModel.this, entry)) {
                            addMatchedEntry(entry.getId());
                        } else {
                            removeMatchedEntry(entry.getId());
                        }
                    }
                    databaseContext.getMetaData().groupsBinding().invalidate();
                }).executeWith(taskExecutor);
            }
        }

        @Subscribe
        public void listen(IndexRemovedEvent event) {
            if (groupNode.getGroup() instanceof SearchGroup searchGroup) {
                for (BibEntry entry : event.entries()) {
                    searchGroup.updateMatches(entry, false);
                    removeMatchedEntry(entry.getId());
                }
                databaseContext.getMetaData().groupsBinding().invalidate();
            }
        }

        @Subscribe
        public void listen(IndexClosedEvent event) {
            if (groupNode.getGroup() instanceof SearchGroup group) {
                databaseContext.getDatabase().unregisterListener(this);
            }
        }
    }
}
