/*
 * @Copyright 2018-2026 HardBackNutter
 * @License GNU General Public License
 *
 * This file is part of NeverTooManyBooks.
 *
 * NeverTooManyBooks is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * NeverTooManyBooks is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
 * See the GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with NeverTooManyBooks. If not, see <http://www.gnu.org/licenses/>.
 */
package com.hardbacknutter.nevertoomanybooks;

import android.content.Context;
import android.os.Bundle;
import android.os.Debug;

import androidx.annotation.IntRange;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.core.util.Pair;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModel;
import androidx.recyclerview.widget.RecyclerView;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.StringJoiner;
import java.util.stream.Collectors;

import com.hardbacknutter.nevertoomanybooks.bookedit.EditBookOutput;
import com.hardbacknutter.nevertoomanybooks.settings.bookshelves.EditBookshelvesOutput;
import com.hardbacknutter.nevertoomanybooks.search.SearchBookUpdatesInput;
import com.hardbacknutter.nevertoomanybooks.backup.ImportResults;
import com.hardbacknutter.nevertoomanybooks.booklist.BoBTask;
import com.hardbacknutter.nevertoomanybooks.booklist.Booklist;
import com.hardbacknutter.nevertoomanybooks.booklist.BooklistNode;
import com.hardbacknutter.nevertoomanybooks.booklist.RebuildBooklist;
import com.hardbacknutter.nevertoomanybooks.booklist.TopRowListPosition;
import com.hardbacknutter.nevertoomanybooks.booklist.adapter.BooklistAdapter;
import com.hardbacknutter.nevertoomanybooks.booklist.filters.Filter;
import com.hardbacknutter.nevertoomanybooks.booklist.grouping.BooklistGroup;
import com.hardbacknutter.nevertoomanybooks.booklist.grouping.ReadStatus;
import com.hardbacknutter.nevertoomanybooks.booklist.header.BooklistHeader;
import com.hardbacknutter.nevertoomanybooks.booklist.style.CoverScale;
import com.hardbacknutter.nevertoomanybooks.booklist.style.FieldVisibility;
import com.hardbacknutter.nevertoomanybooks.booklist.style.ScreenLayout;
import com.hardbacknutter.nevertoomanybooks.booklist.style.Style;
import com.hardbacknutter.nevertoomanybooks.core.tasks.ASyncExecutor;
import com.hardbacknutter.nevertoomanybooks.core.tasks.TaskProgress;
import com.hardbacknutter.nevertoomanybooks.core.utils.ParcelUtils;
import com.hardbacknutter.nevertoomanybooks.database.DBKey;
import com.hardbacknutter.nevertoomanybooks.database.dao.AuthorDao;
import com.hardbacknutter.nevertoomanybooks.database.dao.BookDao;
import com.hardbacknutter.nevertoomanybooks.database.dao.BookshelfDao;
import com.hardbacknutter.nevertoomanybooks.database.dao.LoaneeDao;
import com.hardbacknutter.nevertoomanybooks.database.dao.LocationDao;
import com.hardbacknutter.nevertoomanybooks.database.dao.PublisherDao;
import com.hardbacknutter.nevertoomanybooks.database.dao.SeriesDao;
import com.hardbacknutter.nevertoomanybooks.database.dao.StylesHelper;
import com.hardbacknutter.nevertoomanybooks.database.dao.TagDao;
import com.hardbacknutter.nevertoomanybooks.entities.Author;
import com.hardbacknutter.nevertoomanybooks.entities.Book;
import com.hardbacknutter.nevertoomanybooks.entities.Bookshelf;
import com.hardbacknutter.nevertoomanybooks.entities.DataHolder;
import com.hardbacknutter.nevertoomanybooks.entities.Entity;
import com.hardbacknutter.nevertoomanybooks.entities.Publisher;
import com.hardbacknutter.nevertoomanybooks.entities.Series;
import com.hardbacknutter.nevertoomanybooks.entities.Tag;
import com.hardbacknutter.nevertoomanybooks.localsearch.LocalSearchCriteria;
import com.hardbacknutter.nevertoomanybooks.settings.styles.EditPreferredStylesContract;
import com.hardbacknutter.nevertoomanybooks.settings.styles.EditStyleOutput;
import com.hardbacknutter.nevertoomanybooks.settings.styles.PreferredStylesOutput;
import com.hardbacknutter.nevertoomanybooks.widgets.popupmenu.ExtMenuButton;
import com.hardbacknutter.util.livedataevent.LiveDataEvent;
import com.hardbacknutter.util.logger.Logger;
import com.hardbacknutter.util.logger.LoggerFactory;

public class BooksOnBookshelfViewModel
        extends ViewModel {

    /** Log tag. */
    private static final String TAG = "BooksOnBookshelfViewModel";

    static final String BKEY_BOOK_IDS = TAG + ":bookIds";

    private static final String ERROR_GROUP_NOT_DEFINED = "Group not defined: ";
    private static final String ERROR_NULL_BOOKLIST = "booklist";
    private static final String ERROR_NO_EXTRAS = "No extras?";
    private static final String ERROR_NO_BOOK_IDS = "No bookIds?";

    @SuppressWarnings("NonFinalStaticVariableUsedInClassInitialization")
    private static final Map<Integer, BLGRecord> BLG_RECORD = Map.ofEntries(
            Map.entry(BooklistGroup.AUTHOR,
                      new BLGRecord(DBKey.FK_AUTHOR,
                                    R.string.lbl_author,
                                    DBKey.AUTHOR.FORMATTED_FULL_NAME,
                                    R.string.bob_empty_author)),
            Map.entry(BooklistGroup.SERIES,
                      new BLGRecord(DBKey.FK_SERIES,
                                    R.string.lbl_series,
                                    DBKey.SERIES.TITLE,
                                    R.string.bob_empty_series)),
            Map.entry(BooklistGroup.PUBLISHER,
                      new BLGRecord(DBKey.FK_PUBLISHER,
                                    R.string.lbl_publisher,
                                    DBKey.PUBLISHER.NAME,
                                    R.string.bob_empty_publisher)),
            Map.entry(BooklistGroup.BOOKSHELF,
                      new BLGRecord(DBKey.FK_BOOKSHELF,
                                    R.string.lbl_bookshelf,
                                    DBKey.BOOKSHELF.NAME,
                                    // not used; Books are always on a shelf.
                                    R.string.lbl_bookshelf))
    );

    @SuppressWarnings("NonFinalStaticVariableUsedInClassInitialization")
    private static final Map<Integer, BLGDateRecord> BLG_DATE_RECORD = Map.ofEntries(
            Map.entry(BooklistGroup.DATE_ACQUIRED_YEAR,
                      new BLGDateRecord(R.string.lbl_date_acquired,
                                        BooklistGroup.BlgDBKey.ACQUIRED_YEAR)),
            Map.entry(BooklistGroup.DATE_ACQUIRED_MONTH,
                      new BLGDateRecord(R.string.lbl_date_acquired,
                                        BooklistGroup.BlgDBKey.ACQUIRED_YEAR,
                                        BooklistGroup.BlgDBKey.ACQUIRED_MONTH)),
            Map.entry(BooklistGroup.DATE_ACQUIRED_DAY,
                      new BLGDateRecord(R.string.lbl_date_acquired,
                                        BooklistGroup.BlgDBKey.ACQUIRED_YEAR,
                                        BooklistGroup.BlgDBKey.ACQUIRED_MONTH,
                                        BooklistGroup.BlgDBKey.ACQUIRED_DAY)),

            Map.entry(BooklistGroup.DATE_ADDED_YEAR,
                      new BLGDateRecord(R.string.lbl_date_added,
                                        BooklistGroup.BlgDBKey.ADDED_YEAR)),
            Map.entry(BooklistGroup.DATE_ADDED_MONTH,
                      new BLGDateRecord(R.string.lbl_date_added,
                                        BooklistGroup.BlgDBKey.ADDED_YEAR,
                                        BooklistGroup.BlgDBKey.ADDED_DAY)),
            Map.entry(BooklistGroup.DATE_ADDED_DAY,
                      new BLGDateRecord(R.string.lbl_date_added,
                                        BooklistGroup.BlgDBKey.ADDED_YEAR,
                                        BooklistGroup.BlgDBKey.ADDED_DAY,
                                        BooklistGroup.BlgDBKey.ADDED_MONTH)),

            Map.entry(BooklistGroup.DATE_PUBLISHED_YEAR,
                      new BLGDateRecord(R.string.lbl_date_published,
                                        BooklistGroup.BlgDBKey.PUB_YEAR)),
            Map.entry(BooklistGroup.DATE_PUBLISHED_MONTH,
                      new BLGDateRecord(R.string.lbl_date_published,
                                        BooklistGroup.BlgDBKey.PUB_YEAR,
                                        BooklistGroup.BlgDBKey.PUB_MONTH)),

            Map.entry(BooklistGroup.DATE_FIRST_PUBLICATION_YEAR,
                      new BLGDateRecord(R.string.lbl_date_first_publication,
                                        BooklistGroup.BlgDBKey.FIRST_PUB_YEAR)),
            Map.entry(BooklistGroup.DATE_FIRST_PUBLICATION_MONTH,
                      new BLGDateRecord(R.string.lbl_date_first_publication,
                                        BooklistGroup.BlgDBKey.FIRST_PUB_YEAR,
                                        BooklistGroup.BlgDBKey.FIRST_PUB_MONTH))
    );
    /**
     * The postDelay() in milliseconds. Used to more or less delay
     * scrolling until cover images are loaded.
     * The value is based on tests in the emulator. It's likely
     * too large for modern devices but might be too small for older ones.
     * FIXME: find a better solution for SCROLL_POST_DELAY_MS
     */
    private static final int SCROLL_POST_DELAY_MS = 200;

    /** Cache for all bookshelves. */
    private final List<Bookshelf> bookshelfList = new ArrayList<>();
    private final BoBTask boBTask = new BoBTask();
    private final MutableLiveData<List<Integer>> positionsUpdated = new MutableLiveData<>();
    private final MutableLiveData<Pair<Integer, Integer>> highlightSelection =
            new MutableLiveData<>();
    private final MutableLiveData<LiveDataEvent<Boolean>> triggerRebuildList =
            new MutableLiveData<>();
    private final MutableLiveData<Boolean> searchCriteriaAreActive =
            new MutableLiveData<>();

    /** Holder for all search criteria. See {@link LocalSearchCriteria} for more info. */
    @Nullable
    private LocalSearchCriteria searchCriteria;

    /** Database Access. */
    private AuthorDao authorDao;
    private BookDao bookDao;
    private BookshelfDao bookshelfDao;
    private LoaneeDao loaneeDao;
    private LocationDao locationDao;
    private PublisherDao publisherDao;
    private SeriesDao seriesDao;
    private TagDao tagDao;

    /** Preferred booklist state in next rebuild. */
    private RebuildBooklist rebuildMode;
    /** Current displayed list. */
    @Nullable
    private Booklist booklist;

    /**
     * Flag (potentially) set when coming back from another Activity.
     * Indicates if list rebuild is needed in {@link BooksOnBookshelf}#onResume.
     */
    private boolean forceRebuildInOnResume;

    /** Flag to prompt the user to make a backup after startup. */
    private boolean proposeBackup;

    /** Currently selected {@link Bookshelf}. */
    @Nullable
    private Bookshelf bookshelf;
    /**
     * Set when the LayoutManager is created.
     * <p>
     * When either Bookshelf or Style is changed, we'll reset to this to {@code null}.
     * This will FORCE a recreation of the LayoutManager during BooksOnBookshelf#onResume
     * even when the type itself happens to be the same between 2 bookshelves or styles.
     */
    @Nullable
    private ScreenLayout currentLayout;
    /**
     * The book id we want the new list to display more-or-less in the centre.
     */
    private long selectedBookId;
    /**
     * The currently selected (highlighted) adapter position.
     */
    private int selectedAdapterPosition = RecyclerView.NO_POSITION;

    /**
     * Observable to receive progress.
     *
     * @return a {@link TaskProgress} with the progress counter, a text message, ...
     */
    @NonNull
    LiveData<LiveDataEvent<TaskProgress>> onProgress() {
        return boBTask.onProgress();
    }

    @NonNull
    LiveData<LiveDataEvent<BoBTask.Output>> onCancelled() {
        return boBTask.onCancelled();
    }

    /**
     * Observable to receive failure.
     *
     * @return the result is the Exception
     */
    @NonNull
    LiveData<LiveDataEvent<Throwable>> onFailure() {
        return boBTask.onFailure();
    }

    /**
     * Observable to receive success.
     *
     * @return the {@link BoBTask.Output} which can be considered to be complete and correct.
     */
    @NonNull
    LiveData<LiveDataEvent<BoBTask.Output>> onFinished() {
        return boBTask.onFinished();
    }

    /**
     * Observable to receive a trigger to start rebuild of the book list.
     *
     * @return flag, whether the <strong>LayoutManager</strong> needs to be recreated or not.
     */
    @NonNull
    LiveData<LiveDataEvent<Boolean>> onTriggerRebuildList() {
        return triggerRebuildList;
    }

    /**
     * Observable: select (highlight) the current row.
     *
     * @return first: previous adapter position which should be un-selected
     *         second: current adapter position to select; can be {@code RecyclerView.NO_POSITION}
     */
    @NonNull
    LiveData<Pair<Integer, Integer>> onHighlightSelection() {
        return highlightSelection;
    }

    @NonNull
    LiveData<List<Integer>> onPositionsUpdated() {
        return positionsUpdated;
    }

    @Override
    protected void onCleared() {
        if (booklist != null) {
            booklist.close();
        }
    }

    /**
     * Pseudo constructor.
     *
     * @param context Current context
     * @param args    all arguments
     */
    void init(@NonNull final Context context,
              @Nullable final BooksOnBookshelfInput args) {

        if (authorDao == null) {
            final ServiceLocator serviceLocator = ServiceLocator.getInstance();
            authorDao = serviceLocator.getAuthorDao();
            bookDao = serviceLocator.getBookDao();
            bookshelfDao = serviceLocator.getBookshelfDao();
            loaneeDao = serviceLocator.getLoaneeDao();
            locationDao = serviceLocator.getLocationDao();
            publisherDao = serviceLocator.getPublisherDao();
            seriesDao = serviceLocator.getSeriesDao();
            tagDao = serviceLocator.getTagDao();

            // first start of the activity, read from user preference
            rebuildMode = RebuildBooklist.getPreferredMode();

            if (args != null) {
                proposeBackup = args.isProposeBackup();
                searchCriteria = args.getCriteria();
                // There MAY be a list of book id's which overrules the criteria
                // if present
                args.getBookIdList().ifPresent(searchCriteria::setBookIdList);

                // allow the caller to override the user preference
                final RebuildBooklist rebuildBooklist = args.getRebuildBooklist();
                if (rebuildBooklist != null) {
                    rebuildMode = rebuildBooklist;
                }

                // check for an explicit bookshelf set
                // 2025-06-02: currently this is only used by {@link ShowBookLauncher}
                // when we want to show a list of books retrieved from a TocEntry.
                // (reminder: this comment might not be updated lately and hence incorrect)
                bookshelf = args.getBookshelfId()
                                .flatMap(id -> bookshelfDao.getBookshelf(context, id))
                                .orElse(null);
            }
        } else {
            // always preserve the state when the hosting fragment was revived
            rebuildMode = RebuildBooklist.FromSaved;
        }

        // create if not explicitly set above
        if (searchCriteria == null) {
            searchCriteria = new LocalSearchCriteria();
        }

        searchCriteriaAreActive.setValue(!searchCriteria.isEmpty());

        // Set the last/preferred bookshelf if not explicitly set above
        // or use the default == first start of the app
        if (bookshelf == null) {
            bookshelf = bookshelfDao.getCurrent().orElseGet(bookshelfDao::getDefault);
        }
    }

    /**
     * Get a "Group-name: value" string representation from the given row-data.
     *
     * @param context Current context
     * @param rowData to read the group/label from.
     *
     * @return "Group-name: value"
     */
    @NonNull
    String getRowLabel(@NonNull final Context context,
                       @NonNull final DataHolder rowData) {
        @BooklistGroup.Id
        final int rowGroupId = rowData.getInt(DBKey.BL_NODE.GROUP);

        final BooklistGroup group = getStyle().requireGroupById(rowGroupId);
        final String domainName = group.getDisplayDomainExpression()
                                       .getDomain()
                                       .getName();

        final String label = group.getLabel(context);
        final String value;
        //noinspection SwitchStatementWithTooFewBranches
        switch (rowGroupId) {
            case BooklistGroup.READ_STATUS: {
                final int status = rowData.getInt(domainName);
                value = ReadStatus.byId(status).getLabel(context);
                break;
            }
            default: {
                // Simple string data
                value = rowData.getString(domainName);
                break;
            }
        }

        return context.getString(R.string.name_colon_value, label, value);
    }

    boolean isProposeBackup() {
        // We only offer ONCE
        final boolean tmp = proposeBackup;
        proposeBackup = false;
        return tmp;
    }

    //TEST: do we need resetPreferredListRebuildMode?
    // we don't always call it when the style changes
    void resetPreferredListRebuildMode() {
        rebuildMode = RebuildBooklist.getPreferredMode();
    }

    /**
     * Get the {@link Bookshelf} list to show in the Spinner.
     * Will be empty until a call to {@link #reloadBookshelfList(Context)} is made.
     *
     * @return list
     */
    @NonNull
    List<Bookshelf> getBookshelfList() {
        return bookshelfList;
    }

    /**
     * Construct the {@link Bookshelf} list to show in the Spinner.
     *
     * @param context Current context.
     */
    void reloadBookshelfList(@NonNull final Context context) {
        bookshelfList.clear();
        bookshelfList.add(bookshelfDao.getBookshelf(context, Bookshelf.ALL_BOOKS).orElseThrow());
        bookshelfList.addAll(bookshelfDao.getAll());
    }

    /**
     * Get the list of all defined bookshelves, excluding the virtual ALL_BOOKS.
     *
     * @return list
     */
    @NonNull
    List<Bookshelf> getBookshelvesList() {
        return bookshelfDao.getAll();
    }

    /**
     * Get the list of all Locations.
     *
     * @return list
     */
    @NonNull
    List<String> getLocationList() {
        return locationDao.getList();
    }

    /**
     * Find the position of the currently set {@link Bookshelf} in the Spinner.
     * (with fallback to the default, or to 0 if needed)
     *
     * @param context Current context.
     *
     * @return the position that reflects the current {@link Bookshelf}.
     */
    int getSelectedBookshelfSpinnerPosition(@NonNull final Context context) {
        Objects.requireNonNull(bookshelf, Bookshelf.TAG);

        // Not strictly needed, but guard against future changes
        if (bookshelfList.isEmpty()) {
            reloadBookshelfList(context);
        }

        // position we want to find
        Integer selectedPosition = null;
        // fallback if no selection found
        Integer defaultPosition = null;

        final long defBookshelfId = bookshelfDao.getDefault().getId();
        for (int i = 0; i < bookshelfList.size(); i++) {
            final long id = bookshelfList.get(i).getId();
            // find the position of the default shelf.
            if (id == defBookshelfId) {
                defaultPosition = i;
            }
            // find the position of the selected shelf
            if (id == bookshelf.getId()) {
                selectedPosition = i;
            }
        }

        if (selectedPosition != null) {
            return selectedPosition;

        } else {
            return Objects.requireNonNullElse(defaultPosition, 0);
        }
    }

    @NonNull
    public Bookshelf getBookshelf() {
        Objects.requireNonNull(bookshelf, Bookshelf.TAG);
        return bookshelf;
    }

    /**
     * Load and set the desired/current {@link Bookshelf}.
     *
     * @param context     Current context
     * @param bookshelfId of the {@link Bookshelf}
     */
    void selectBookshelf(@NonNull final Context context,
                         @IntRange(from = Bookshelf.ALL_BOOKS) final long bookshelfId) {
        final long previousBookshelfId = bookshelf == null ? 0 : bookshelf.getId();

        if (bookshelfId == 0) {
            bookshelf = bookshelfDao.getDefault();
        } else {
            // Note the fallback is to the Bookshelf.ALL_BOOKS and not the default one.
            bookshelf = bookshelfDao
                    .findById(bookshelfId)
                    .or(() -> bookshelfDao.getCurrent())
                    .or(() -> bookshelfDao.getBookshelf(context, Bookshelf.ALL_BOOKS))
                    .orElseThrow();
        }
        bookshelfDao.setCurrent(bookshelf);

        if (previousBookshelfId != bookshelf.getId()) {
            currentLayout = null;
            // just set, don't propagate
            selectedBookId = 0;
            selectedAdapterPosition = RecyclerView.NO_POSITION;
        }
    }

    private void reloadSelectedBookshelf(@NonNull final Context context) {
        // Note the fallback is to the Bookshelf.ALL_BOOKS and not the default one.
        final Bookshelf newBookshelf = bookshelfDao
                .getCurrent()
                .or(() -> bookshelfDao.getBookshelf(context, Bookshelf.ALL_BOOKS))
                .orElseThrow();
        if (newBookshelf.equals(bookshelf)) {
            return;
        }
        bookshelf = newBookshelf;
    }

    /**
     * Get the style of the current {@link Bookshelf}.
     *
     * @return style
     */
    @NonNull
    public Style getStyle() {
        Objects.requireNonNull(bookshelf, Bookshelf.TAG);
        return bookshelf.getStyle();
    }

    /**
     * Should be called after <strong>a style was changed/selected</strong>.
     * The style should exist (id != 0), or if it doesn't, the default style will be used instead.
     *
     * @param context   Current context
     * @param styleUuid the style to apply
     */
    void onStyleChanged(@NonNull final Context context,
                        @NonNull final String styleUuid) {
        // Validate and use the default if needed.
        final StylesHelper stylesHelper = ServiceLocator.getInstance().getStyles();
        final Style style = stylesHelper.getStyle(styleUuid).orElseGet(stylesHelper::getDefault);

        Objects.requireNonNull(bookshelf, Bookshelf.TAG);

        // set as the global default.
        stylesHelper.setDefault(style.getUuid());
        // save the new bookshelf/style combination
        bookshelfDao.setCurrent(bookshelf);
        bookshelf.setStyle(context, style);
        currentLayout = null;
    }

    /**
     * Get the layout we need to use.
     *
     * @param hasEmbeddedDetailsFrame whether the display Activity is showing
     *                                the embedded details-frame.
     *
     * @return layout to use
     *
     * @see #hasLayoutChanged(boolean)
     */
    @NonNull
    ScreenLayout getNewLayout(final boolean hasEmbeddedDetailsFrame) {
        // remember it for use from #onResume where we need to check/compare it again
        currentLayout = getStyle().getLayout(hasEmbeddedDetailsFrame);
        return currentLayout;
    }

    /**
     * Check if the layout has changed since we created it.
     *
     * @param hasEmbeddedDetailsFrame whether the display Activity is showing
     *                                the embedded details-frame.
     *
     * @return flag
     *
     * @see #getNewLayout(boolean)
     */
    boolean hasLayoutChanged(final boolean hasEmbeddedDetailsFrame) {
        return getStyle().getLayout(hasEmbeddedDetailsFrame) != currentLayout;
    }

    /**
     * Create the adapter, and set the Booklist on it.
     *
     * @param context                 Current context
     * @param hasEmbeddedDetailsFrame whether the display Activity is showing
     *                                the embedded details-frame.
     *
     * @return the adapter
     */
    @NonNull
    BooklistAdapter createBooklistAdapter(@NonNull final Context context,
                                          final boolean hasEmbeddedDetailsFrame) {
        final Style style = getStyle();
        final ScreenLayout layout = style.getLayout(hasEmbeddedDetailsFrame);

        final CoverScale coverScale;
        switch (layout) {
            case Grid: {
                // always show a cover on ScreenLayout.Grid
                coverScale = style.getCoverScale();
                break;
            }
            case List:
            default: {
                if (style.isShowField(FieldVisibility.Screen.List, DBKey.COVER[0])) {
                    if (hasEmbeddedDetailsFrame) {
                        // use a scale one smaller than on the non-embedded view.
                        coverScale = style.getCoverScale().smaller();
                    } else {
                        coverScale = style.getCoverScale();
                    }
                } else {
                    coverScale = CoverScale.Hidden;
                }
                break;
            }
        }

        final BooklistAdapter adapter = new BooklistAdapter(context, style, layout, coverScale);
        adapter.setBooklist(booklist);
        return adapter;
    }

    /**
     * Get the mode to use for the context-menu's.
     *
     * @param hasEmbeddedDetailsFrame whether the display Activity is showing
     *                                the embedded details-frame.
     *
     * @return the mode to use
     */
    @NonNull
    ExtMenuButton getShowContextMenuMode(final boolean hasEmbeddedDetailsFrame) {
        final ExtMenuButton preferredMode = ExtMenuButton.getPreferredMode();
        if (preferredMode == ExtMenuButton.IfRoom && hasEmbeddedDetailsFrame) {
            return ExtMenuButton.None;
        } else {
            return preferredMode;
        }
    }

    @NonNull
    TopRowListPosition getBookshelfTopRowPosition() {
        Objects.requireNonNull(bookshelf, Bookshelf.TAG);
        return bookshelf.getTopRowPosition();
    }

    /**
     * Save the current booklist adapter position on the current {@link Bookshelf}.
     *
     * @param context        Current context
     * @param topRowPosition The booklist position of the first visible view.
     */
    void saveBookshelfTopRowPosition(@NonNull final Context context,
                                     @NonNull final TopRowListPosition topRowPosition) {
        Objects.requireNonNull(bookshelf, Bookshelf.TAG);
        bookshelf.saveTopRowPosition(context, topRowPosition);
    }

    /**
     * Check if a rebuild is needed in {@code Activity#onResume()}.
     * <p>
     * <strong>Important</strong>: this method will reset the flag to {@code false}
     *
     * @return {@code true} if a rebuild is needed
     */
    boolean isForceRebuildInOnResume() {
        final boolean force = forceRebuildInOnResume;
        // always reset for next iteration.
        forceRebuildInOnResume = false;
        return force;
    }

    /**
     * Request a rebuild at the next {@code Activity#onResume()}.
     */
    void setForceRebuildInOnResume() {
        forceRebuildInOnResume = true;
    }

    /**
     * Check if the list table is available.
     *
     * @return flag.
     */
    boolean isListAvailable() {
        return booklist != null && booklist.isOpen();
    }

    @IntRange(from = 0)
    long getSelectedBookId() {
        return selectedBookId;
    }

    /**
     * Set the book id around which we want to centre the list after a rebuild.
     * Set and propagate the new adapter position to highlight the current row.
     * <p>
     * Dev. note: reminder: book can appear on multiple positions hence the need for both params.
     *
     * @param bookId          to store
     *                        use {@code 0} to disable centering
     * @param adapterPosition to highlight;
     *                        use {@code RecyclerView.NO_POSITION} to remove all highlighting
     */
    public void setSelectedBook(@IntRange(from = 0) final long bookId,
                                @IntRange(from = RecyclerView.NO_POSITION) final int adapterPosition) {
        this.selectedBookId = bookId;

        // Call with previous selectedAdapterPosition and new position
        highlightSelection.setValue(new Pair<>(selectedAdapterPosition, adapterPosition));
        // and replace previous with new.
        selectedAdapterPosition = adapterPosition;
    }

    /**
     * Check if this book is lend out, or not.
     *
     * @param rowData with data
     *
     * @return {@code true} if this book is available for lending.
     */
    public boolean isAvailable(@NonNull final DataHolder rowData) {
        final String loanee;
        if (rowData.contains(DBKey.LOANEE_NAME)) {
            loanee = rowData.getString(DBKey.LOANEE_NAME);
        } else {
            loanee = loaneeDao.findLoaneeByBookId(rowData.getLong(DBKey.FK_BOOK));
        }
        return loanee == null || loanee.isEmpty();
    }

    @NonNull
    LocalSearchCriteria getSearchCriteria() {
        return Objects.requireNonNull(searchCriteria);
    }

    /**
     * Clear the search-criteria and update the observable.
     */
    void clearSearchCriteria() {
        Objects.requireNonNull(searchCriteria);
        searchCriteria.clear();
        searchCriteriaAreActive.setValue(false);
    }

    @NonNull
    LiveData<Boolean> getSearchCriteriaAreActive() {
        return searchCriteriaAreActive;
    }

    /**
     * This is used to re-display the list in onResume.
     * i.e. {@link #selectedBookId} was set, but a rebuild was not needed.
     *
     * @return the node(s), can be empty, but never {@code null}
     */
    @NonNull
    List<BooklistNode> getTargetNodes() {
        if (selectedBookId != 0) {
            Objects.requireNonNull(booklist, ERROR_NULL_BOOKLIST);
            return booklist.getVisibleBookNodes(selectedBookId);
        }

        return new ArrayList<>();
    }

    long calculateScrollDelay() {
        final boolean covers = getStyle()
                .isShowField(FieldVisibility.Screen.List, DBKey.COVER[0]);
        // Assume that cached covers will appear faster than File based covers.
        final boolean imageCachingEnabled =
                ServiceLocator.getInstance().getCoverStorage().isImageCachingEnabled();

        return covers && !imageCachingEnabled ? SCROLL_POST_DELAY_MS : 0;
    }


    /**
     * Set the desired state on the given node.
     *
     * @param nodeRowId          list-view row id of the node in the list
     * @param nextState          the state to set the node to
     * @param relativeChildLevel up to and including this (relative to the node) child level;
     *
     * @return the node
     */
    @SuppressWarnings("UnusedReturnValue")
    @NonNull
    BooklistNode setNode(final long nodeRowId,
                         @NonNull final BooklistNode.NextState nextState,
                         final int relativeChildLevel) {
        Objects.requireNonNull(booklist, ERROR_NULL_BOOKLIST);
        return booklist.setNode(nodeRowId, nextState, relativeChildLevel);
    }

    void expandAllNodes(@IntRange(from = 1) final int topLevel,
                        final boolean expand) {
        Objects.requireNonNull(booklist, ERROR_NULL_BOOKLIST);
        booklist.setAllNodes(topLevel, expand);
    }

    @NonNull
    Optional<BooklistNode> getNextBookWithoutCover(final long rowId) {
        Objects.requireNonNull(booklist, ERROR_NULL_BOOKLIST);
        return booklist.getNextBookWithoutCover(rowId);
    }

    @NonNull
    BooklistHeader getHeaderContent(@NonNull final Context context) {
        Objects.requireNonNull(booklist, ERROR_NULL_BOOKLIST);
        Objects.requireNonNull(bookshelf, "bookshelf");
        return new BooklistHeader(context, getStyle(),
                                  booklist.countBooks(), booklist.countDistinctBooks(),
                                  bookshelf.getFilters(),
                                  searchCriteria);
    }

    @NonNull
    String getNavigationTableName() {
        Objects.requireNonNull(booklist, ERROR_NULL_BOOKLIST);
        return booklist.getNavigationTableName();
    }

    @IntRange(from = 0)
    int getNavigationTablePosition(final long listTableRowId) {
        Objects.requireNonNull(booklist, ERROR_NULL_BOOKLIST);
        return booklist.getNavigationTablePosition(listTableRowId);
    }

    /**
     * DEBUG/LOG USE ONLY.
     *
     * @return the list
     */
    @Nullable
    public Booklist getBooklist() {
        return booklist;
    }

    @NonNull
    public List<Author> getAuthorsByBookId(@IntRange(from = 1) final long bookId) {
        return authorDao.getByBookId(bookId);
    }

    /**
     * Create the contract input object for the currently displayed book list.
     *
     * @param context Current context
     *
     * @return a fully initialised input object
     */
    @NonNull
    SearchBookUpdatesInput createUpdateBooklistContractInput(
            @NonNull final Context context) {
        //noinspection DataFlowIssue
        final List<Long> books = booklist.getCurrentBookIdList();

        final String title = context.getString(R.string.name_colon_value,
                                               context.getString(R.string.lbl_bookshelf),
                                               getBookshelf().getName());
        return new SearchBookUpdatesInput(books, title, null);
    }

    /**
     * Create the contract input object for one the groups in {@link #BLG_RECORD}.
     *
     * @param context       Current context
     * @param rowData       the data at the selected row
     * @param onlyThisShelf flag
     *
     * @return a fully initialised input object
     *
     * @throws IllegalArgumentException for an unsupported group
     */
    @NonNull
    SearchBookUpdatesInput createUpdateBooklistContractInput(
            @NonNull final Context context,
            @NonNull final DataHolder rowData,
            final boolean onlyThisShelf) {

        @BooklistGroup.Id
        final int groupId = rowData.getInt(DBKey.BL_NODE.GROUP);
        final BLGRecord blgRecord = Objects.requireNonNull(
                BLG_RECORD.get(groupId),
                () -> ERROR_GROUP_NOT_DEFINED + groupId);

        final List<Long> books;

        final long id = rowData.getLong(blgRecord.dbKey);
        // the id should never be 0. But paranoia...
        if (onlyThisShelf || id == 0) {
            // We're going to update all book under THIS node only (regardless of node type).
            final String nodeKey = rowData.getString(DBKey.BL_NODE.KEY);
            final int level = rowData.getInt(DBKey.BL_NODE.LEVEL);

            Objects.requireNonNull(booklist, ERROR_NULL_BOOKLIST);
            books = booklist.getBookIdsForNodeKey(nodeKey, level);
        } else {
            // The 'id' represents a specific author, series, ...
            // We're going to update ALL books referenced by that id, for ALL bookshelves.
            switch (groupId) {
                case BooklistGroup.AUTHOR:
                    books = authorDao.getBookIds(id);
                    break;
                case BooklistGroup.SERIES:
                    books = seriesDao.getBookIds(id);
                    break;
                case BooklistGroup.PUBLISHER:
                    books = publisherDao.getBookIds(id);
                    break;
                case BooklistGroup.BOOKSHELF:
                    books = bookshelfDao.getBookIds(id);
                    break;
                default:
                    throw new IllegalArgumentException(String.valueOf(groupId));
            }
        }

        final String title;
        if (id != 0) {
            title = context.getString(R.string.name_colon_value,
                                      context.getString(blgRecord.labelResId),
                                      rowData.getString(blgRecord.labelKey));
        } else {
            title = context.getString(R.string.name_colon_value,
                                      context.getString(blgRecord.labelResId),
                                      context.getString(blgRecord.emptyItemTextResId));
        }

        return new SearchBookUpdatesInput(books, title, null);
    }

    /**
     * Create the contract input object for one the groups in {@link #BLG_DATE_RECORD}.
     *
     * @param context Current context
     * @param rowData the data at the selected row
     *
     * @return a fully initialised input object
     */
    @NonNull
    SearchBookUpdatesInput createDateRowUpdateBooklistContractInput(
            @NonNull final Context context,
            @NonNull final DataHolder rowData) {

        final String nodeKey = rowData.getString(DBKey.BL_NODE.KEY);
        final int level = rowData.getInt(DBKey.BL_NODE.LEVEL);

        Objects.requireNonNull(booklist, ERROR_NULL_BOOKLIST);
        final List<Long> books = booklist.getBookIdsForNodeKey(nodeKey, level);

        @BooklistGroup.Id
        final int groupId = rowData.getInt(DBKey.BL_NODE.GROUP);
        final BLGDateRecord blgRecord = Objects.requireNonNull(
                BLG_DATE_RECORD.get(groupId),
                () -> ERROR_GROUP_NOT_DEFINED + groupId);

        final StringJoiner sj = new StringJoiner("-");
        for (final String key : blgRecord.dbKeys) {
            final String text = rowData.getString(key);
            if (text.isBlank()) {
                // stop at first 'blank'
                break;
            }
            sj.add(text);
        }

        final String title = context.getString(R.string.name_colon_value,
                                               context.getString(blgRecord.labelResId),
                                               sj.toString());
        return new SearchBookUpdatesInput(books, title, null);
    }

    /**
     * Update the 'complete' status of the given Author.
     * <p>
     * Triggers a {@link BooklistAdapter#requery(List)} for the changed positions.
     *
     * @param author   Author to update
     * @param complete new status
     */
    public void setAuthorComplete(@NonNull final Author author,
                                  final boolean complete) {
        ASyncExecutor.STORAGE_WRITES.execute(() -> {
            if (authorDao.setComplete(author, complete)) {
                Objects.requireNonNull(booklist, ERROR_NULL_BOOKLIST);
                final List<Integer> positions =
                        booklist.updateAuthorComplete(author.getId(), author.isComplete())
                                .stream()
                                .map(BooklistNode::getAdapterPosition)
                                .collect(Collectors.toList());
                positionsUpdated.postValue(positions);
            }
        });
    }

    /**
     * Update the 'complete' status of the given Series.
     * <p>
     * Triggers a {@link BooklistAdapter#requery(List)} for the changed positions.
     *
     * @param series   Series to update
     * @param complete new status
     */
    public void setSeriesComplete(@NonNull final Series series,
                                  final boolean complete) {
        ASyncExecutor.STORAGE_WRITES.execute(() -> {
            if (seriesDao.setComplete(series, complete)) {
                Objects.requireNonNull(booklist, ERROR_NULL_BOOKLIST);
                final List<Integer> positions =
                        booklist.updateSeriesComplete(series.getId(), series.isComplete())
                                .stream()
                                .map(BooklistNode::getAdapterPosition)
                                .collect(Collectors.toList());
                positionsUpdated.postValue(positions);
            }
        });
    }

    /**
     * Add/move the given books to the given list of Bookshelves.
     *
     * @param context      Current context
     * @param bookshelfIds bookshelf ids to set
     * @param extras       contains {@link #BKEY_BOOK_IDS}
     *
     * @throws IllegalArgumentException if the extras or bookIds are missing
     */
    void setBookshelves(@NonNull final Context context,
                        @NonNull final Set<Long> bookshelfIds,
                        @Nullable final Bundle extras) {

        if (extras == null) {
            throw new IllegalArgumentException(ERROR_NO_EXTRAS);
        }

        final List<Long> bookIds = ParcelUtils.unwrap(extras, BKEY_BOOK_IDS);
        if (bookIds == null || bookIds.isEmpty()) {
            throw new IllegalArgumentException(ERROR_NO_BOOK_IDS);
        }

        ASyncExecutor.STORAGE_WRITES.execute(() -> {
            final List<Bookshelf> bookshelves = bookshelfDao
                    .getAll()
                    .stream()
                    .filter(bookshelf -> bookshelfIds.contains(bookshelf.getId()))
                    .collect(Collectors.toList());

            bookDao.setBookshelves(context, bookIds, bookshelves);
            // ALWAYS rebuild
            triggerRebuildList.postValue(LiveDataEvent.of(false));
        });
    }

    /**
     * Bulk update the location for the given set of books.
     *
     * @param location to set
     * @param extras   contains {@link #BKEY_BOOK_IDS}
     *
     * @throws IllegalArgumentException if the extras or bookIds are missing
     */
    void setLocation(@NonNull final String location,
                     @Nullable final Bundle extras) {

        if (extras == null) {
            throw new IllegalArgumentException(ERROR_NO_EXTRAS);
        }

        final List<Long> bookIds = ParcelUtils.unwrap(extras, BKEY_BOOK_IDS);
        if (bookIds == null || bookIds.isEmpty()) {
            throw new IllegalArgumentException(ERROR_NO_BOOK_IDS);
        }

        ASyncExecutor.STORAGE_WRITES.execute(() -> {
            bookDao.setLocation(bookIds, location);
            // ALWAYS rebuild
            triggerRebuildList.postValue(LiveDataEvent.of(false));
        });
    }

    /**
     * Update the 'read' status of the given book.
     *
     * @param id   Book to update
     * @param read new status
     */
    public void setBookRead(@IntRange(from = 1) final long id,
                            final boolean read) {
        final Book book = Book.from(id);
        if (bookDao.setRead(book, read)) {
            onBookReadStatusChanged(book);
        }
    }

    /**
     * Should be called when the read-status of a book was changed.
     *
     * @param book which was changed
     */
    private void onBookReadStatusChanged(@NonNull final Book book) {
        if (getStyle().hasGroup(BooklistGroup.READ_STATUS)) {
            // The book might move to another group - no choice, we must rebuild
            triggerRebuildList.setValue(LiveDataEvent.of(false));

        } else {
            // The change will not affect the group the book is in,
            // update the <strong>book-list</strong> 'read' status of the given book.
            Objects.requireNonNull(booklist, ERROR_NULL_BOOKLIST);
            final List<Integer> positions = booklist
                    .updateBookReadStatus(book.getId(),
                                          book.isRead(),
                                          book.getString(DBKey.READ_PROGRESS))
                    .stream()
                    .map(BooklistNode::getAdapterPosition)
                    .collect(Collectors.toList());
            positionsUpdated.setValue(positions);
        }
    }

    /**
     * Delete the 'loanee' for the given book.
     *
     * @param bookId Book to return
     */
    public void deleteLoan(@IntRange(from = 1) final long bookId) {
        if (loaneeDao.delete(bookId)) {
            onBookLoaneeChanged(bookId, null);
        }
    }

    /**
     * Should be called when a loanee of a book was changed.
     *
     * @param bookId the id of the lent book
     * @param loanee the name of the loanee,
     *               or {@code null} / {@code ""} for a returned book
     */
    public void onBookLoaneeChanged(@IntRange(from = 1) final long bookId,
                                    @Nullable final String loanee) {
        Objects.requireNonNull(bookshelf, Bookshelf.TAG);

        if (getStyle().hasGroup(BooklistGroup.LENDING)
            || bookshelf.getFilters()
                        .stream()
                        .anyMatch(pFilter -> DBKey.LOANEE_NAME.equals(pFilter.getDBKey()))) {
            // The lending status changed and/or
            // the book might move to another group.
            // No choice, we must rebuild
            triggerRebuildList.setValue(LiveDataEvent.of(false));

        } else {
            // The change will not affect the group the book is in,
            // update the <strong>book-list</strong> 'loanee' of the given book.
            Objects.requireNonNull(booklist, ERROR_NULL_BOOKLIST);
            final List<Integer> positions = booklist.updateBookLoanee(bookId, loanee)
                                                    .stream()
                                                    .map(BooklistNode::getAdapterPosition)
                                                    .collect(Collectors.toList());
            positionsUpdated.setValue(positions);
        }
    }

    /**
     * Should be called when a cover of a book was changed.
     *
     * @param bookId which was changed
     */
    private void onBookCoverChanged(@IntRange(from = 1) final long bookId) {
        // The change will not affect the group the book is in.
        final List<Integer> positions = getVisibleBookNodes(bookId)
                .stream()
                .map(BooklistNode::getAdapterPosition)
                .collect(Collectors.toList());
        positionsUpdated.setValue(positions);
    }

    /**
     * Receives notifications that an inline-string column was updated.
     * This is a global update, affecting <strong>all</strong> books.
     *
     * @param groupId  the group, from the update event
     * @param original the original text which was passed in to be edited
     * @param modified the modified text
     */
    public void onRowGroupUpdate(@BooklistGroup.Id final int groupId,
                                 @NonNull final String original,
                                 @NonNull final String modified) {
        // Books might move between groups when they (groups) are modified.
        // So we have no choice and must always rebuild.
        triggerRebuildList.setValue(LiveDataEvent.of(false));
    }

    /**
     * Receives notifications that a group {@link Entity} (but NOT a Book) was updated.
     *
     * @param groupId the group, from the update event
     * @param entity  the entity that (potentially) was updated
     */
    public void onRowGroupUpdate(@BooklistGroup.Id final int groupId,
                                 @NonNull final Entity entity) {
        // Books might move between groups when they (groups) are modified.
        // So we have no choice and must always rebuild.
        triggerRebuildList.setValue(LiveDataEvent.of(false));
    }

    /**
     * Receives notifications that a {@link Book} potentially was updated.
     * <p>
     * For a limited set of keys, we directly update the list table which is very fast.
     * <p>
     * Other keys, or full books, will always trigger a list rebuild.
     * <p>
     * <strong>This method is only called when the book-details are embedded</strong>
     * i.e. when we're running on a tablet/big screen.
     *
     * @param book the book
     * @param keys the item(s) that potentially were changed,
     *             or {@code null} to indicate ALL data was potentially changed.
     */
    void onAfterUpdate(@NonNull final Book book,
                       @Nullable final String... keys) {
        // Reminder: the actual Book table and/or relations are ALREADY UPDATED.
        // The only thing we are updating here is the temporary BookList table
        // as itself and/or the displayed data

        if (keys != null) {
            final List<String> keyList = Arrays.asList(keys);

            // 2024-12-03: for now we never receive a combination of the below keys,
            // hence we can 'return' when one (set of) key is present

            if (keyList.contains(DBKey.READ__BOOL) || keyList.contains(DBKey.READ_PROGRESS)) {
                onBookReadStatusChanged(book);
                return;
            }

            if (keyList.contains(DBKey.LOANEE_NAME)) {
                onBookLoaneeChanged(book.getId(), book.getLoanee().orElse(null));
                return;
            }

            if (keyList.contains(DBKey.COVER[0])) {
                onBookCoverChanged(book.getId());
                return;
            }
        }

        // ENHANCE: update the modified row without a rebuild: support more keys above
        triggerRebuildList.setValue(LiveDataEvent.of(false));
    }

    /**
     * Delete the given {@link Series}.
     *
     * @param context Current context
     * @param series  to delete
     */
    public void delete(@NonNull final Context context,
                @NonNull final Series series) {
        ASyncExecutor.STORAGE_WRITES.execute(() -> {
            if (seriesDao.delete(context, series)) {
                triggerRebuildList.postValue(LiveDataEvent.of(false));
            }
        });
    }

    /**
     * Delete the given {@link Publisher}.
     *
     * @param context   Current context
     * @param publisher to delete
     */
    public void delete(@NonNull final Context context,
                @NonNull final Publisher publisher) {
        ASyncExecutor.STORAGE_WRITES.execute(() -> {
            if (publisherDao.delete(context, publisher)) {
                triggerRebuildList.postValue(LiveDataEvent.of(false));
            }
        });
    }

    /**
     * Delete the given {@link Bookshelf}.
     *
     * @param context   Current context
     * @param bookshelf to delete
     */
    public void delete(@NonNull final Context context,
                       @NonNull final Bookshelf bookshelf) {
        ASyncExecutor.STORAGE_WRITES.execute(() -> {
            if (bookshelfDao.delete(context, bookshelf)) {
                triggerRebuildList.postValue(LiveDataEvent.of(false));
            }
        });
    }

    /**
     * Delete the given {@link Tag}.
     *
     * @param context Current context
     * @param tag     to delete
     */
    public void delete(@NonNull final Context context,
                       @NonNull final Tag tag) {
        ASyncExecutor.STORAGE_WRITES.execute(() -> {
            if (tagDao.delete(tag)) {
                triggerRebuildList.postValue(LiveDataEvent.of(false));
            }
        });
    }

    /**
     * Delete the given Book.
     *
     * @param bookId to delete
     */
    public void deleteBook(@IntRange(from = 1) final long bookId) {
        if (bookDao.delete(bookId)) {
            onBookDeleted(bookId);
        }
    }

    /**
     * Should be called when a a book was deleted.
     *
     * @param bookId which was deleted
     */
    void onBookDeleted(final long bookId) {
        if (bookId == 0 || bookId == selectedBookId) {
            setSelectedBook(0, RecyclerView.NO_POSITION);
        }
        // We don't try to remove the row without a rebuild as this could quickly become complex...
        // e.g. if there is(was) only a single book on the level, we'd have to recursively
        // clean up each level above the book
        triggerRebuildList.setValue(LiveDataEvent.of(false));
    }

    /**
     * Entry point after a Book (or list of) was edited; either manually by the user,
     * or automatically with an internet update action.
     *
     * @param data as returned from the contract
     */
    void onBookEditFinished(@NonNull final EditBookOutput data) {
        if (data.isModified()) {
            //URGENT: if we processed a single book, we should NOT do a full rebuild
            forceRebuildInOnResume = true;
            // to find out if we updated a list, use data.getLastBookIdProcessed()
        }

        // If we got a reposition id back, make any potential rebuild re-position to it.
        if (data.getRepositionToBookId() > 0) {
            selectedBookId = data.getRepositionToBookId();
        }
    }

    /**
     * This method is called after the user edited the styles in
     * the {@link EditPreferredStylesContract}.
     *
     * @param context Current context
     * @param data    returned from the view/edit contract
     */
    void onEditStylesFinished(@NonNull final Context context,
                              @NonNull final PreferredStylesOutput data) {
        data.getSelectedStyleUuid().ifPresent(uuid -> onStyleChanged(context, uuid));

        // This is independent of the above style having been modified or not.
        if (data.isModified()) {
            forceRebuildInOnResume = true;
        }
    }

    /**
     * This method is called after the user edited a style in {@code StylePickerDialogFragment}.
     *
     * @param context Current context
     * @param data    returned from the view/edit contract
     */
    void onEditStyleFinished(@NonNull final Context context,
                             @NonNull final EditStyleOutput data) {
        // We get here from the StylePickerDialogFragment (i.e. the style menu)
        // when the user choose to EDIT a style.
        if (data.getStyleUuid().isPresent()) {
            onStyleChanged(context, data.getStyleUuid().get());

            // ALWAYS rebuild here, even when the style was not modified
            // as we're handling this as a style-change.
            // We could do checks... but it's not worth the effort.
            forceRebuildInOnResume = true;
        }
    }

    void onManageBookshelvesFinished(@NonNull final Context context,
                                     @NonNull final EditBookshelvesOutput data) {
        // Always update the list of bookshelves
        reloadBookshelfList(context);

        // If it's different from the currently displayed shelf,
        // store it, and force a rebuild.
        // This also covers the situation where the current one was deleted.
        // i.e. #getBookshelf() will actually be the deleted one,
        // but it will immediately be replaced in #selectBookshelf(..) with the new one
        if (data.getSelectedBookshelfId() != getBookshelf().getId()) {
            selectBookshelf(context, data.getSelectedBookshelfId());
            forceRebuildInOnResume = true;
        }
    }

    /**
     * Display the results from an FTS search after using
     * the "Advanced Local Search" Activity.
     * <p>
     * Results are displayed on the "All Books" Bookshelf.
     *
     * @param context  Current context
     * @param criteria to construct the booklist with
     */
    void onFtsSearch(@NonNull final Context context,
                     @NonNull final LocalSearchCriteria criteria) {
        forceRebuildInOnResume = true;

        searchCriteria = criteria;
        selectBookshelf(context, Bookshelf.ALL_BOOKS);

        searchCriteriaAreActive.setValue(!searchCriteria.isEmpty());
    }

    /**
     * Display the results from an FTS search after using
     * the local Search View.
     * <p>
     * Results are displayed on the current Bookshelf.
     *
     * @param query the ftp keywords
     */
    void onFtsSearch(@Nullable final String query) {
        Objects.requireNonNull(searchCriteria);

        searchCriteria.setFtsKeywords(query);
        searchCriteriaAreActive.setValue(!searchCriteria.isEmpty());
    }

    /**
     * Called when the user has finished an Import.
     * <p>
     * This method is called from a ActivityResultContract after the result intent is parsed.
     *
     * @param context       Current context
     * @param importResults returned from the import
     */
    void onImportFinished(@NonNull final Context context,
                          @NonNull final ImportResults importResults) {
        if (importResults.preferences > 0) {
            // Refresh the preferred bookshelf. This also refreshes its style.
            reloadSelectedBookshelf(context);
        }

        // styles, prefs, books, covers,... it all requires a rebuild.
        forceRebuildInOnResume = true;
    }

    /**
     * Queue a rebuild of the underlying cursor and data.
     *
     * @param context Current context
     */
    void buildBookList(@NonNull final Context context) {
        if (BuildConfig.DEBUG && DEBUG_SWITCHES.BOB_THE_BUILDER_TIMERS) {
            Debug.startMethodTracing("trace-" + LocalDateTime
                    .now().withNano(0)
                    .format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
        }

        Objects.requireNonNull(bookshelf, ERROR_NULL_BOOKLIST);
        Objects.requireNonNull(searchCriteria, "searchCriteria");

        // make sure we pick up the current style, filters, ...
        final Locale locale = context.getResources().getConfiguration().getLocales().get(0);
        bookshelfDao.refresh(context, bookshelf, locale);

        // someday the searchCriteria will BE filters... for now, convert them here.
        final Collection<Filter> criteriaFilters;
        if (searchCriteria.isEmpty()) {
            criteriaFilters = List.of();
        } else {
            criteriaFilters = ServiceLocator.getInstance()
                                            .getFtsDaoHelper()
                                            .toFilters(searchCriteria);
            // when criteria are used, the build should always expand the book list.
            rebuildMode = RebuildBooklist.Expanded;
        }

        boBTask.start(bookshelf, rebuildMode, criteriaFilters, selectedBookId);
    }

    boolean isBuilding() {
        return boBTask.isActive();
    }

    /**
     * Called when the list build succeeded.
     *
     * @param output from the task; contains the (optional) target rows.
     */
    void onBuildFinished(@NonNull final BoBTask.Output output) {
        if (BuildConfig.DEBUG && DEBUG_SWITCHES.BOB_THE_BUILDER_TIMERS) {
            Debug.stopMethodTracing();
        }

        // the new build is completely done. We can safely discard the previous one.
        if (booklist != null) {
            booklist.close();
        }

        if (BuildConfig.DEBUG && DEBUG_SWITCHES.BOB_INIT_BOOK_LIST) {
            final Logger logger = LoggerFactory.getLogger();
            logger.d(TAG, output);
        }

        booklist = output.getList();

        // preserve the new state by default
        rebuildMode = RebuildBooklist.FromSaved;
    }

    @NonNull
    List<BooklistNode> getVisibleBookNodes(final long bookId) {
        Objects.requireNonNull(booklist, ERROR_NULL_BOOKLIST);
        return booklist.getVisibleBookNodes(bookId);
    }

    /**
     * Update the style to the given layout choice, and trigger a full rebuild of the BoB.
     *
     * @param context Current context
     * @param layout  to set
     */
    void setStyleLayout(@NonNull final Context context,
                        @NonNull final ScreenLayout layout) {
        final Style style = getStyle();
        style.setLayout(layout);
        ServiceLocator.getInstance().getStyles().update(context, style);

        // rebuild with a LayoutManager rebuild
        triggerRebuildList.setValue(LiveDataEvent.of(true));
    }

    private static class BLGDateRecord {
        @NonNull
        final String[] dbKeys;
        @StringRes
        final int labelResId;

        BLGDateRecord(@StringRes final int labelResId,
                      @NonNull final String... dbKeys) {
            this.dbKeys = dbKeys;
            this.labelResId = labelResId;
        }
    }

    private static class BLGRecord {
        @NonNull
        final String dbKey;
        @StringRes
        final int labelResId;
        @NonNull
        final String labelKey;
        @StringRes
        final int emptyItemTextResId;

        BLGRecord(@NonNull final String dbKey,
                  @StringRes final int labelResId,
                  @NonNull final String labelKey,
                  @StringRes final int emptyItemTextResId) {
            this.dbKey = dbKey;
            this.labelResId = labelResId;
            this.labelKey = labelKey;
            this.emptyItemTextResId = emptyItemTextResId;
        }
    }

}
