/*
 * @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.searchengines;

import android.content.Context;
import android.os.LocaleList;
import android.os.Parcelable;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.util.Pair;

import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.function.Supplier;

import com.hardbacknutter.nevertoomanybooks.BuildConfig;
import com.hardbacknutter.nevertoomanybooks.DEBUG_SWITCHES;
import com.hardbacknutter.nevertoomanybooks.ServiceLocator;
import com.hardbacknutter.nevertoomanybooks.core.parsers.DateParser;
import com.hardbacknutter.nevertoomanybooks.core.parsers.FullDateParser;
import com.hardbacknutter.nevertoomanybooks.core.parsers.ISODateParser;
import com.hardbacknutter.nevertoomanybooks.core.parsers.MoneyParser;
import com.hardbacknutter.nevertoomanybooks.core.parsers.RealNumberParser;
import com.hardbacknutter.nevertoomanybooks.entities.codes.ProductCode;
import com.hardbacknutter.nevertoomanybooks.entities.codes.ProductCodeType;
import com.hardbacknutter.nevertoomanybooks.entities.codes.ISBN;
import com.hardbacknutter.nevertoomanybooks.core.utils.LocaleListUtils;
import com.hardbacknutter.nevertoomanybooks.core.utils.Money;
import com.hardbacknutter.nevertoomanybooks.database.DBKey;
import com.hardbacknutter.nevertoomanybooks.entities.Book;
import com.hardbacknutter.nevertoomanybooks.entities.Identifier;
import com.hardbacknutter.nevertoomanybooks.utils.Languages;
import com.hardbacknutter.nevertoomanybooks.utils.mappers.Mapper;
import com.hardbacknutter.nevertoomanybooks.utils.mappers.MapperFactory;
import com.hardbacknutter.util.logger.LoggerFactory;

class ResultsAccumulator {

    private static final String TAG = "ResultsAccumulator";

    private static final Set<String> LIST_KEYS = Set.of(
            Book.BKEY_AUTHOR_LIST,
            Book.BKEY_BOOKSHELF_LIST,
            Identifier.Value.BKEY_LIST,
            Book.BKEY_PUBLISHER_LIST,
            Book.BKEY_SERIES_LIST,
            Book.BKEY_TAG_LIST,
            Book.BKEY_TOC_LIST,
            CoverFileSpecArray.BKEY_FILE_SPEC_ARRAY[0],
            CoverFileSpecArray.BKEY_FILE_SPEC_ARRAY[1]);

    /** Mappers to apply. */
    private final Collection<Mapper> mappers;
    @NonNull
    private final ISODateParser isoDateParser;
    @NonNull
    private final Supplier<Languages> languagesSupplier;

    /**
     * Constructor.
     *
     * @param context           Current context
     * @param languagesSupplier deferred supplier for the {@link Languages}
     */
    ResultsAccumulator(@NonNull final Context context,
                       @NonNull final Supplier<Languages> languagesSupplier) {

        this.languagesSupplier = languagesSupplier;
        isoDateParser = new ISODateParser(ServiceLocator.getInstance()
                                                        .getSystemLocaleList()
                                                        .get(0));

        mappers = MapperFactory.create(context);
    }

    private static void dbgLogValueCopied(@NonNull final String method,
                                          @NonNull final String key,
                                          @Nullable final Object dataToAdd) {
        LoggerFactory.getLogger().d(TAG, method, "copied",
                                    "key=" + key,
                                    "value=`" + dataToAdd + '`');
    }

    @SuppressWarnings("SameParameterValue")
    private static void dbgLogValueAppended(@NonNull final String method,
                                            @NonNull final String key,
                                            @Nullable final Object dataToAdd) {
        LoggerFactory.getLogger().d(TAG, method, "appended",
                                    "key=" + key,
                                    "value=`" + dataToAdd + '`');
    }

    private static void dbgLogValueSkipped(@NonNull final String method,
                                           @NonNull final String key,
                                           @Nullable final Object dataToAdd) {
        LoggerFactory.getLogger().d(TAG, method, "skipping",
                                    "key=" + key,
                                    "value=`" + dataToAdd + '`');
    }

    /**
     * Accumulate all data from the given sites.
     * <p>
     * NEWTHINGS: when adding a new Search task that adds non-string based data,
     *  also handle that here.
     *
     * @param context Current context
     * @param results list of Locale/Book data to process
     * @param book    to update; this is the Book which will be returned as
     *                the final result for this search.
     */
    void process(@NonNull final Context context,
                 @NonNull final List<Pair<Locale, Book>> results,
                 @NonNull final Book book) {

        final LocaleList userLocales = context.getResources().getConfiguration().getLocales();

        for (final Pair<Locale, Book> localeBookPair : results) {
            final Locale locale = localeBookPair.first;
            final Book result = localeBookPair.second;
            final List<Locale> allLocales = LocaleListUtils.asList(locale, userLocales);
            final RealNumberParser realNumberParser = new RealNumberParser(allLocales);
            final MoneyParser moneyParser = new MoneyParser(locale, allLocales);
            final DateParser<LocalDateTime> dateParser =
                    new FullDateParser(isoDateParser, allLocales);

            for (final String key : result.keySet()) {
                if (DBKey.getDateKeys().contains(key)) {
                    processDate(key, result, book, dateParser);

                } else if (DBKey.getMoneyKeys().contains(key)) {
                    processMoney(key, result, book, moneyParser, realNumberParser);

                } else if (DBKey.getLanguageKeys().contains(key)) {
                    processLanguage(key, result, book, locale);

                } else if (LIST_KEYS.contains(key)) {
                    processList(key, result, book);

                } else if (DBKey.RATING.equals(key)) {
                    //ENHANCE: don't take first rating, but calc the average
                    processRating(key, result, book, realNumberParser);

                } else if (DBKey.ISBN.equals(key)) {
                    // We need to check isbn-13 versus isbn-10 values specifically
                    processIsbn(key, result, book);
                } else {
                    // when we get here, we should only have String, int, or long data
                    processGenericKey(key, result, book, realNumberParser);
                }
            }
        }

        // run the mappers
        mappers.forEach(mapper -> mapper.map(context, book));

        // Pick the best covers for each list (if any) and clean/delete all others.
        CoverFileSpecArray.process(book);
    }

    /**
     * Compare the user-input they searched for with the ISBN/code we got from the
     * SearchEngine/Site and use the most appropriate one.
     *
     * @param key      Key of data
     * @param siteData Source Bundle
     * @param book     Destination bundle
     */
    private void processIsbn(@NonNull final String key,
                             @NonNull final Book siteData,
                             @NonNull final Book book) {
        // No new data ? we're done.
        final String dataToAdd = siteData.getString(key, null);
        if (dataToAdd == null || dataToAdd.isBlank()) {
            return;
        }

        final String previous = book.getString(key, null);
        if (previous == null || previous.isEmpty()) {
            // copy the new data
            book.putString(key, dataToAdd);

        } else {
            // The user-input for the search.
            final ProductCode productCodeSearched = ISBN.parse(previous);
            // The code we got from the website.
            final ProductCode productCodeFromSite = ISBN.parse(dataToAdd);

            // If the user searched for an isbn-13,
            // and the website returned an isbn-10
            // AND they are really the same, THEN we preserve the isbn-10
            // If the codes are different, we KEEP the one the user searched for.
            if (productCodeSearched.getType() == ProductCodeType.Isbn13
                && productCodeFromSite.getType() == ProductCodeType.Isbn10
                && productCodeFromSite.equals(productCodeSearched)) {
                book.putString(key, dataToAdd);
            }
        }
    }

    /**
     * Grabs the 'new' language and checks if it's parsable.
     * If so, then check if the previous language was actually valid at all.
     * if not, use new language.
     * <p>
     * The data is always expected to be a {@code String}.
     *
     * @param key      Key of data
     * @param siteData Source Bundle
     * @param book     Destination bundle
     * @param locale   for parsing
     */
    private void processLanguage(@NonNull final String key,
                                 @NonNull final Book siteData,
                                 @NonNull final Book book,
                                 @NonNull final Locale locale) {
        // No new data ? we're done.
        String dataToAdd = siteData.getString(key, null);
        if (dataToAdd == null || dataToAdd.isBlank()) {
            return;
        }

        // If we already have previous data, we're done
        final String previous = book.getString(key, null);
        if (previous != null && !previous.isEmpty()) {
            if (BuildConfig.DEBUG && DEBUG_SWITCHES.SEARCH_RESULTS_ACCUMULATOR) {
                dbgLogValueSkipped("processLanguage", key, dataToAdd);
            }
            return;
        }

        // If more than 3 characters, it's likely a 'display' name of a language.
        if (dataToAdd.length() > 3) {
            dataToAdd = languagesSupplier.get().getISO3FromDisplayLanguage(dataToAdd, locale);
        }

        // copy the new data
        book.putString(key, dataToAdd);

        if (BuildConfig.DEBUG && DEBUG_SWITCHES.SEARCH_RESULTS_ACCUMULATOR) {
            dbgLogValueCopied("processLanguage", key, dataToAdd);
        }
    }

    /**
     * Grabs the 'new' date and checks if it's parsable.
     * If so, then check if the previous date was actually valid at all.
     * if not, use new date.
     * <p>
     * The data is always expected to be a {@code String}.
     *
     * @param key        for the field
     * @param srcBook    Source Bundle
     * @param dstBook    Destination bundle
     * @param dateParser shared for the current site
     */
    private void processDate(@NonNull final String key,
                             @NonNull final Book srcBook,
                             @NonNull final Book dstBook,
                             @NonNull final DateParser<LocalDateTime> dateParser) {
        // No new data ? we're done.
        final String dataToAdd = srcBook.getString(key, null);
        if (dataToAdd == null || dataToAdd.isBlank()) {
            return;
        }

        // No previous data ? Copy the new data, even if the incoming date
        // might not be valid. We'll deal with that later.
        // We do this as we WILL accept partial dates (e.g. just a year)
        final String previous = dstBook.getString(key, null);
        if (previous == null || previous.isBlank()) {
            dstBook.putString(key, dataToAdd);
            if (BuildConfig.DEBUG && DEBUG_SWITCHES.SEARCH_RESULTS_ACCUMULATOR) {
                dbgLogValueCopied("processDate", key, dataToAdd);
            }
            return;
        }


        // FIXME: there is overlap with some SearchEngines which already do a full
        //  validity check on the dates they gather. We should avoid a double-check.
        //
        // Overwrite with the new date IF we can parse it, i.e. it's a FULL date
        // AND if the previous one was NOT a full date.
        dateParser.parse(dataToAdd).ifPresent(newDate -> {
            // URGENT: this call is using the CURRENT Locales.. but the 'previous'
            //  data is from a different site! but see below... paranoia
            if (dateParser.parse(previous).isEmpty()) {
                // previous date was invalid or partial, use the new one instead.
                dstBook.putString(key, newDate.format(DateTimeFormatter.ISO_LOCAL_DATE));
            }
        });

        if (BuildConfig.DEBUG && DEBUG_SWITCHES.SEARCH_RESULTS_ACCUMULATOR) {
            dbgLogValueSkipped("processDate", key, dataToAdd);
        }
    }

    /**
     * Accumulate ParcelableArrayList data.
     * <p>
     * Data is always appended to any previous data.
     * There are <strong>NO CHECKS FOR DUPLICATION</strong>:
     * After a search, the final resulting Book (data) is sent to the child
     * classes of {@code EditBookBaseFragment},
     * where in the {@code onPopulateViews} method the lists are pruned.
     * We do it there because each list might/does need some specific
     * handling. Similar pruning is done when an "Update from ..." is run during
     * the database save.
     *
     * @param <T>      type of items in the ArrayList
     * @param key      Key of data
     * @param siteData Source Bundle
     * @param book     Destination bundle
     */
    private <T extends Parcelable> void processList(@NonNull final String key,
                                                    @NonNull final Book siteData,
                                                    @NonNull final Book book) {
        final List<T> dataToAdd = siteData.getParcelableArrayList(key);
        if (dataToAdd.isEmpty()) {
            return;
        }

        final List<T> list = book.getParcelableArrayList(key);

        if (BuildConfig.DEBUG && DEBUG_SWITCHES.SEARCH_RESULTS_ACCUMULATOR) {
            if (list.isEmpty()) {
                dbgLogValueCopied("processList", key, dataToAdd);
            } else {
                dbgLogValueAppended("processList", key, dataToAdd);
            }
        }

        list.addAll(dataToAdd);
        book.putParcelableCollection(key, list);
    }

    /**
     * Accumulate price data.
     *
     * @param key              Key of data
     * @param siteData         Source Bundle
     * @param book             Destination bundle
     * @param moneyParser      shared for the current site
     * @param realNumberParser shared for the current site
     */
    private void processMoney(@NonNull final String key,
                              @NonNull final Book siteData,
                              @NonNull final Book book,
                              @NonNull final MoneyParser moneyParser,
                              @NonNull final RealNumberParser realNumberParser) {
        // Fetch as Object, as engines MAY store differently typed data
        final Object dataToAdd = siteData.get(key, realNumberParser);
        if (dataToAdd == null || dataToAdd.toString().isBlank()) {
            return;
        }

        // If we already have previous data, we're done
        // Fetch as String to test on presence
        final String previous = book.getString(key, null);
        if (previous != null && !previous.isBlank()) {
            if (BuildConfig.DEBUG && DEBUG_SWITCHES.SEARCH_RESULTS_ACCUMULATOR) {
                dbgLogValueSkipped("processMoney", key, dataToAdd);
            }
            return;
        }

        // Money, BigDecimal, double or float ? Copy the new data.
        if (dataToAdd instanceof Money) {
            book.putMoney(key, (Money) dataToAdd);
            if (BuildConfig.DEBUG && DEBUG_SWITCHES.SEARCH_RESULTS_ACCUMULATOR) {
                dbgLogValueCopied("processMoney", key, dataToAdd);
            }
            return;
        }
        if (dataToAdd instanceof BigDecimal) {
            book.putBigDecimal(key, (BigDecimal) dataToAdd);

            if (BuildConfig.DEBUG && DEBUG_SWITCHES.SEARCH_RESULTS_ACCUMULATOR) {
                dbgLogValueCopied("processMoney", key, dataToAdd.toString());
            }
            return;
        }

        // This really should never be the case, but paranoia... just convert these
        if (dataToAdd instanceof Double || dataToAdd instanceof Float) {
            // convert to BigDecimal
            book.putBigDecimal(key, new BigDecimal(dataToAdd.toString()));

            if (BuildConfig.DEBUG && DEBUG_SWITCHES.SEARCH_RESULTS_ACCUMULATOR) {
                dbgLogValueCopied("processMoney", key, dataToAdd.toString());
            }
            return;
        }

        // this is a fallback in case the SearchEngine has not already parsed the data!
        moneyParser.parse(dataToAdd.toString())
                   .ifPresent(money -> book.putMoney(key, money));
    }

    /**
     * Accumulate {@code double} or {@code float} rating data.
     * <p>
     * The result is <strong>ALWAYS stored as a {@code float}</strong>
     *
     * @param key              Key of data
     * @param siteData         Source Bundle
     * @param book             Destination bundle
     * @param realNumberParser shared for the current site
     */
    private void processRating(@NonNull final String key,
                               @NonNull final Book siteData,
                               @NonNull final Book book,
                               @NonNull final RealNumberParser realNumberParser) {
        // Fetch as Object, as engines MAY store typed data
        final Object dataToAdd = siteData.get(key, realNumberParser);
        if (dataToAdd == null || dataToAdd.toString().isEmpty()) {
            return;
        }

        // If we already have previous data, we're done
        // (fetch as String; we don't care about the actual data-type)
        final String previous = book.getString(key, null);
        if (previous != null && !previous.isEmpty()) {
            if (BuildConfig.DEBUG && DEBUG_SWITCHES.SEARCH_RESULTS_ACCUMULATOR) {
                dbgLogValueSkipped("processRealNumberToFloat", key, dataToAdd);
            }
            return;
        }

        // double or float ? Just copy the new data.
        if (dataToAdd instanceof Float) {
            book.putFloat(key, (float) dataToAdd);

            if (BuildConfig.DEBUG && DEBUG_SWITCHES.SEARCH_RESULTS_ACCUMULATOR) {
                dbgLogValueCopied("processRealNumberToFloat", key, dataToAdd);
            }
            return;
        }
        if (dataToAdd instanceof Double) {
            book.putFloat(key, ((Double) dataToAdd).floatValue());

            if (BuildConfig.DEBUG && DEBUG_SWITCHES.SEARCH_RESULTS_ACCUMULATOR) {
                dbgLogValueCopied("processRealNumberToFloat", key, dataToAdd);
            }
            return;
        }

        //noinspection OverlyBroadCatchBlock
        try {
            // this is a fallback in case the SearchEngine has not already parsed the data!
            book.putFloat(key, realNumberParser.toFloat(dataToAdd));

            if (BuildConfig.DEBUG && DEBUG_SWITCHES.SEARCH_RESULTS_ACCUMULATOR) {
                dbgLogValueCopied("processRealNumberToFloat", key, dataToAdd);
            }
        } catch (@NonNull final IllegalArgumentException e) {
            // covers NumberFormatException
            if (BuildConfig.DEBUG && DEBUG_SWITCHES.SEARCH_RESULTS_ACCUMULATOR) {
                LoggerFactory.getLogger().d(TAG, "processRealNumberToFloat", e,
                                            "key=" + key,
                                            "data=`" + dataToAdd + '`');
            }
        }
    }

    /**
     * Accumulate generic keys not processed already.
     *
     * @param key              Key of data
     * @param siteData         Source Bundle
     * @param book             Destination bundle
     * @param realNumberParser shared for the current site
     */
    private void processGenericKey(@NonNull final String key,
                                   @NonNull final Book siteData,
                                   @NonNull final Book book,
                                   @NonNull final RealNumberParser realNumberParser) {
        // Fetch as Object, as engines MAY store typed data
        final Object dataToAdd = siteData.get(key, realNumberParser);
        if (dataToAdd == null || dataToAdd.toString().isEmpty()) {
            return;
        }

        // If we already have previous data, we're done
        // (fetch as String; we don't care about the actual data-type)
        final String previous = book.getString(key, null);
        if (previous != null && !previous.isEmpty()) {
            if (BuildConfig.DEBUG && DEBUG_SWITCHES.SEARCH_RESULTS_ACCUMULATOR) {
                dbgLogValueSkipped("processGenericKey", key, dataToAdd);
            }
            return;
        }

        // Copy the data using the incoming type.
        book.put(key, dataToAdd);

        if (BuildConfig.DEBUG && DEBUG_SWITCHES.SEARCH_RESULTS_ACCUMULATOR) {
            dbgLogValueCopied("processGenericKey", key, dataToAdd);
        }
    }
}
