/*
 * @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.backup.csv.bc;

import android.content.Context;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

import com.hardbacknutter.nevertoomanybooks.R;
import com.hardbacknutter.nevertoomanybooks.ServiceLocator;
import com.hardbacknutter.nevertoomanybooks.backup.csv.BookCoder;
import com.hardbacknutter.nevertoomanybooks.backup.csv.util.DateVerifier;
import com.hardbacknutter.nevertoomanybooks.backup.csv.util.SimplePublisherCoder;
import com.hardbacknutter.nevertoomanybooks.backup.csv.util.StringList;
import com.hardbacknutter.nevertoomanybooks.booklist.style.Style;
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.RatingParser;
import com.hardbacknutter.nevertoomanybooks.core.parsers.RealNumberParser;
import com.hardbacknutter.nevertoomanybooks.database.DBDefinitions;
import com.hardbacknutter.nevertoomanybooks.database.DBKey;
import com.hardbacknutter.nevertoomanybooks.database.dao.IdentifierDao;
import com.hardbacknutter.nevertoomanybooks.database.updates.GenreMigration;
import com.hardbacknutter.nevertoomanybooks.database.updates.IdentifierMigration;
import com.hardbacknutter.nevertoomanybooks.entities.Author;
import com.hardbacknutter.nevertoomanybooks.entities.Book;
import com.hardbacknutter.nevertoomanybooks.entities.Bookshelf;
import com.hardbacknutter.nevertoomanybooks.entities.Identifier;
import com.hardbacknutter.nevertoomanybooks.entities.Publisher;
import com.hardbacknutter.nevertoomanybooks.entities.Series;
import com.hardbacknutter.nevertoomanybooks.entities.TocEntry;
import com.hardbacknutter.nevertoomanybooks.entities.codes.ISBN;
import com.hardbacknutter.nevertoomanybooks.io.DataReader;
import com.hardbacknutter.nevertoomanybooks.io.DataReaderException;
import com.hardbacknutter.nevertoomanybooks.searchengines.SearchEngineUtils;
import com.hardbacknutter.nevertoomanybooks.utils.mappers.Mapper;
import com.hardbacknutter.nevertoomanybooks.utils.mappers.MapperFactory;

/**
 * Note: the keys for the CSV columns are not the same as the internal Book keys
 * due to backward compatibility.
 * <p>
 * <strong>LIMITATIONS:</strong> Calibre book data is handled, but Calibre library is NOT.
 * The Calibre native string-id is written out with the book.
 * <p>
 * When reading, the Calibre native string-id is checked against already existing data,
 * but if there is no match all Calibre data for the book is discarded.
 * <p>
 * In other words: this coder is NOT a full backup/restore!
 */
@SuppressWarnings("SameParameterValue")
public class DefaultBookCoder
        implements BookCoder {

    static final char ELEMENT_SEPARATOR = '|';

    /** string-encoded column compatible with BC and NTMB 1.x-3.x. CSV files. */
    private static final String CSV_COLUMN_TOC = "anthology_titles";
    /** string-encoded column compatible with BC and NTMB 1.x-3.x. CSV files. */
    private static final String CSV_COLUMN_SERIES = "series_details";
    /** string-encoded column compatible with BC and NTMB 1.x-3.x. CSV files. */
    private static final String CSV_COLUMN_AUTHORS = "author_details";
    /** string-encoded column compatible with BC and NTMB 1.x-3.x. CSV files. */
    private static final String CSV_COLUMN_PUBLISHERS = "publisher";
    /** Obsolete/alternative header as used in BC: full given+family author name. */
    private static final String LEGACY_AUTHOR_NAME = "author_name";
    /** Obsolete/alternative header as used in BC: bookshelf name. */
    private static final String LEGACY_BOOKSHELF_TEXT = "bookshelf_text";
    /** Obsolete as used in BC, not used/needed, skipped. */
    private static final String LEGACY_BOOKSHELF_ID = "bookshelf_id";
    /** Obsolete/alternative header only used by NTMB pre-1.2 versions: bookshelf name. */
    private static final String LEGACY_BOOKSHELF_1_1 = "bookshelf";
    /** Obsolete header from BC. */
    private static final String LEGACY_LAST_GOODREADS_SYNC_DATE = "last_goodreads_sync_date";
    /** Migrated to tags starting in app version 7.0. */
    private static final String LEGACY_GENRE = "genre";

    private final Author unknownAuthor;

    private final List<Locale> userLocales;
    private final List<String> csvColumnNames;

    private final RatingParser ratingParser;
    private final MoneyParser moneyParser;
    private final DateVerifier dateVerifier;
    private final Collection<Mapper> mappers;

    private final StringList<Author> authorCoder;
    private final StringList<Bookshelf> bookshelfCoder;
    private final StringList<Publisher> publisherCoder;
    private final StringList<Series> seriesCoder;
    private final StringList<TocEntry> tocCoder;

    private final IdentifierDao identifierDao;

    // Created when needed      1+

    @Nullable
    private Map<String, Long> calibreLibraryStr2IdMap;

    /**
     * Constructor.
     *
     * @param context        Current context
     * @param defaultStyle   the default style to use for {@link Bookshelf}s
     * @param userLocales    to use for parsing
     * @param csvColumnNames the list with the field(column) names
     * @param updateOption   to use
     *
     * @throws DataReaderException when the import cannot go ahead
     */
    public DefaultBookCoder(@NonNull final Context context,
                            @NonNull final Style defaultStyle,
                            @NonNull final List<Locale> userLocales,
                            @NonNull final List<String> csvColumnNames,
                            @NonNull final DataReader.Updates updateOption)
            throws DataReaderException {

        // If a sync was requested, we'll need this column or cannot proceed.
        if (updateOption == DataReader.Updates.OnlyNewer) {
            requireColumnOrThrow(context, csvColumnNames, DBKey.DATE_LAST_UPDATED__UTC);
        }

        // map potential old Identifier column names
        this.csvColumnNames = csvColumnNames
                .stream()
                .map(name -> name.toLowerCase(Locale.ENGLISH))
                .map(name -> {
                    final String mapped = IdentifierMigration.MAPPINGS.get(name);
                    return mapped == null ? name : mapped;
                })
                .collect(Collectors.toList());

        this.userLocales = userLocales;

        moneyParser = createMoneyParser(userLocales);

        final Locale systemLocale = ServiceLocator.getInstance().getSystemLocaleList().get(0);
        final FullDateParser dateParser = new FullDateParser(new ISODateParser(systemLocale),
                                                             this.userLocales);

        ratingParser = new RatingParser(new RealNumberParser(this.userLocales), 5);
        dateVerifier = new DateVerifier(dateParser);
        mappers = MapperFactory.create(context);

        identifierDao = ServiceLocator.getInstance().getIdentifierDao();

        authorCoder = new StringList<>(new AuthorCoder());
        bookshelfCoder = new StringList<>(new BookshelfCoder(defaultStyle));
        seriesCoder = new StringList<>(new SeriesCoder());
        tocCoder = new StringList<>(new TocEntryCoder());
        publisherCoder = SimplePublisherCoder.create(ELEMENT_SEPARATOR);

        unknownAuthor = Author.createUnknownAuthor(context);

    }

    @NonNull
    private static MoneyParser createMoneyParser(final List<Locale> userLocales) {
        // Special treatment for the floating point parsers,
        // due to CSV files potentially coming from "foreign" websites.

        // Make sure US is added so we can parse "dot" decimal-separator
        // even when the user Locale is a comma decimal-separator.
        final List<Locale> moneyLocales = new ArrayList<>(userLocales);
        if (!userLocales.contains(Locale.US)) {
            moneyLocales.add(Locale.US);
        }
        // Make sure FRANCE is added so we can parse "comma" decimal-separator
        // even when the user Locale is a dot decimal-separator.
        if (!userLocales.contains(Locale.FRANCE)) {
            moneyLocales.add(Locale.FRANCE);
        }
        return new MoneyParser(userLocales.get(0), moneyLocales);
    }

    /**
     * Database access is strictly limited to fetching ID's for the list elements.
     * <p>
     * Both csv lists <strong>must</strong> be the same length.
     *
     * @param context        Current context
     * @param csvDataRow     the list with the field data
     *
     * @return the decoded book
     */
    @NonNull
    @Override
    public Book decode(@NonNull final Context context,
                       @NonNull final List<String> csvDataRow) {

        final Book book = new Book();

        // Read all non-empty columns of the current row into the Bundle.
        // Note that some of them require further processing before being valid.
        for (int i = 0; i < csvColumnNames.size(); i++) {
            final String data = csvDataRow.get(i);
            if (!data.isEmpty()) {
                book.putString(csvColumnNames.get(i), data);
            }
        }

        // never used, remove if present
        book.remove(LEGACY_BOOKSHELF_ID);
        book.remove(LEGACY_LAST_GOODREADS_SYNC_DATE);

        // we MUST have a title.
        if (book.getTitle().isEmpty()) {
            book.setTitle(context.getString(R.string.unknown_title));
        }

        // check/fix the language
        book.getLocaleAndUpdateLanguage(userLocales.get(0), true);

        // Database access is strictly limited to fetching ID's for any list elements.
        processIsbn(book);
        processAuthors(book);
        processSeries(book);
        processPublishers(book);
        processToc(book);
        processBookshelves(book);
        processCalibreData(book);
        processRating(book);
        processPrice(book);
        processGenre(book);
        processIdentifiers(book);

        // Full DateTime stamp.
        dateVerifier.verify(book, DBKey.getDateTimeKeys(), false, true);
        // Full Date stamp, no time
        dateVerifier.verify(book, DBKey.getFullDateKeys(), false, false);
        // Partial Date stamp, no time
        dateVerifier.verify(book, DBKey.getPartialDateKeys(), true, false);

        // BC (sometimes?) produces CSV files where a book can have:
        // "read"=0
        // "read_end"="a valid date"
        // GitHub #205: force the "read" flag to =1 if a read_end" is present
        // after the above validation of the date fields.
        if (book.contains(DBKey.READ_END__DATE)) {
            book.putBoolean(DBKey.READ__BOOL, true);
        }

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

        return book;
    }

    /**
     * Process alternative keys for the ISBN and clean the ISBN text as needed.
     *
     * @param book to process
     */
    private void processIsbn(@NonNull final Book book) {
        if (book.hasProductCode()) {
            // ALWAYS try to clean the ISBN.
            final String codeStr = book.getRawProductCode();

            // We have seen the string:  "9.78E+12"
            // The original writer must have been writing isbn numbers as floating-point values.
            // We're explicitly discarding these.
            if ("9.78E+12".equals(codeStr) || "9.79E+12".equals(codeStr)) {
                book.remove(DBKey.ISBN);
                return;
            }

            // We've seen Goodreads csv file with nightmares like this:
            //     "=""9789027409294"""  and "="""""
            // Note that we clean the string, but do NOT check on the length here.
            // We want all types/values to pass through
            book.setRawProductCode(ISBN.cleanText(codeStr));
        }
    }

    /**
     * Process the list of Authors.
     * <p>
     * Database access is strictly limited to fetching ID's.
     * <p>
     * Get the list of authors from whatever source is available.
     * If none found, a generic "[Unknown author]" will be used.
     *
     * @param book to process
     */
    private void processAuthors(@NonNull final Book book) {
        final List<Author> list = book.getAuthors();

        processAuthor(book, CSV_COLUMN_AUTHORS, list);
        processAuthor(book, DBKey.AUTHOR.FORMATTED_FULL_NAME, list);
        processAuthor(book, LEGACY_AUTHOR_NAME, list);

        // check for individual author family/given fields in the input
        if (book.contains(DBKey.AUTHOR.FAMILY_NAME)) {
            final String family = book.getString(DBKey.AUTHOR.FAMILY_NAME, null);
            if (family != null && !family.isEmpty()) {
                final String given = book.getString(DBKey.AUTHOR.GIVEN_NAMES, null);
                list.add(new Author(family, given));
            }
            book.remove(DBKey.AUTHOR.FAMILY_NAME);
            book.remove(DBKey.AUTHOR.GIVEN_NAMES);
        }

        // we MUST have an author.
        if (list.isEmpty()) {
            list.add(unknownAuthor);
        }

        book.setAuthors(list);
    }

    private void processAuthor(@NonNull final Book book,
                               @NonNull final String key,
                               @NonNull final List<Author> list) {
        if (book.contains(key)) {
            final String encodedList = book.getString(key, null);
            book.remove(key);

            if (encodedList != null && !encodedList.isEmpty()) {
                // Do not add if already there.
                // We need to do this here (before going to the database)
                // so we can keep them in the exact order as they come in.
                authorCoder.decodeList(encodedList)
                           .stream()
                           .filter(author -> list.stream().noneMatch(
                                   a -> a.isSameName(author)))
                           .forEach(list::add);
                // In addition, we can have the following duplicates:
                //
                // Author,Author l-f,Additional Authors
                // Liu Cixin,"Cixin, Liu","Ken Liu, Cixin Liu"
                // First field is skipped
                // Second field is the primary Author in "Last, Firstname" format.
                // Third fields is a comma sep. list
                // PROBLEM: normally the 3rd field is e.g. "Isaac Asimov"
                // in other words, "FIRSTNAME LASTNAME" and the decoding will work properly.
                // For chinese names (which we want to test explicitly here) the
                // notation is always "LASTNAME FIRSTNAME" and we end up with a duplicate
                // in the wrong order.

                // [Author{id=1, familyName=`Cixin`, givenNames=`Liu`, ...
                // Author{id=2, familyName=`Liu`, givenNames=`Ken`, ...
                // Author{id=3, familyName=`Liu`, givenNames=`Cixin`, ...

                // FIXME: do NOT do this in the AuthorDao#prune method; do it HERE
                //  AuthorDao#prune method is used in locations where the user supposedly
                //  already cleaned Author names; so we might get false positives.
                // But HERE, we already warned the user that a CSV import is not foolproof.
                // and based on the currently supported sources:
                // BC/NTMB: should not be an issue unless the data is simply bad.
                // Goodreads: see above, doing it here WILL generate correct data.
            }
        }
    }

    /**
     * Process the list of Series.
     * <p>
     * Database access is strictly limited to fetching ID's.
     *
     * @param book to process
     */
    private void processSeries(@NonNull final Book book) {
        final List<Series> list = book.getSeries();

        processSeries(book, CSV_COLUMN_SERIES, list);

        // check for individual series title/number fields in the input
        if (book.contains(DBKey.SERIES.TITLE)) {
            final String title = book.getString(DBKey.SERIES.TITLE, null);
            if (title != null && !title.isEmpty()) {
                final Series series = new Series(title);
                // number will be "" if it's not present
                series.setNumber(book.getString(DBKey.SERIES.BOOK_SERIES_NUMBER, null));
                list.add(series);
            }
            book.remove(DBKey.SERIES.TITLE);
            book.remove(DBKey.SERIES.BOOK_SERIES_NUMBER);
        }

        SearchEngineUtils.parseSeriesNameInTitle(book);

        if (!list.isEmpty()) {
            book.setSeries(list);
        }
    }

    private void processSeries(@NonNull final Book book,
                               @NonNull final String key,
                               @NonNull final List<Series> list) {
        if (book.contains(key)) {
            final String encodedList = book.getString(key, null);
            book.remove(key);

            if (encodedList != null && !encodedList.isEmpty()) {
                // Weeding out duplicates here is likely overkill but oh well.
                seriesCoder.decodeList(encodedList)
                           .stream()
                           .filter(series -> list.stream().noneMatch(
                                   bs -> bs.isSameName(series)
                                         && bs.getNumber().equals(series.getNumber())))
                           .forEach(list::add);
            }
        }
    }

    /**
     * Process the list of Publishers.
     * <p>
     * Database access is strictly limited to fetching ID's.
     *
     * @param book to process
     */
    private void processPublishers(@NonNull final Book book) {
        final List<Publisher> list = book.getPublishers();

        processPublisher(book, CSV_COLUMN_PUBLISHERS, list);
        processPublisher(book, DBKey.PUBLISHER.NAME, list);

        if (!list.isEmpty()) {
            book.setPublishers(list);
        }
    }

    private void processPublisher(@NonNull final Book book,
                                  @NonNull final String key,
                                  @NonNull final List<Publisher> list) {
        if (book.contains(key)) {
            final String encodedList = book.getString(key, null);
            book.remove(key);

            if (encodedList != null && !encodedList.isEmpty()) {
                // Weeding out duplicates here is likely overkill but oh well.
                publisherCoder.decodeList(encodedList)
                              .stream()
                              .filter(publisher -> list.stream().noneMatch(
                                      bs -> bs.isSameName(publisher)))
                              .forEach(list::add);
            }
        }
    }

    /**
     * Process the list of Toc entries.
     * <p>
     * Database access is strictly limited to fetching ID's.
     * <p>
     * Ignores the actual value of the {@link DBDefinitions#DOM_BOOK_CONTENT_TYPE}.
     * It will be computed when storing the book data.
     *
     * @param book to process
     */
    private void processToc(@NonNull final Book book) {
        final List<TocEntry> list = book.getToc();

        processToc(book, CSV_COLUMN_TOC, list);

        if (!list.isEmpty()) {
            book.setToc(list);
        }
    }

    private void processToc(@NonNull final Book book,
                            @NonNull final String key,
                            @NonNull final List<TocEntry> list) {
        if (book.contains(key)) {
            final String encodedList = book.getString(key, null);
            book.remove(key);

            if (encodedList != null && !encodedList.isEmpty()) {
                // Weeding out duplicates here is likely overkill but oh well.
                tocCoder.decodeList(encodedList)
                        .stream()
                        .filter(tocEntry -> list.stream().noneMatch(
                                bs -> bs.isSameName(tocEntry)))
                        .forEach(list::add);
            }
        }
    }

    /**
     * Process the list of Bookshelves.
     * <p>
     * Database access is strictly limited to fetching ID's.
     *
     * @param book to process
     */
    private void processBookshelves(@NonNull final Book book) {
        final List<Bookshelf> list = book.getBookshelves();

        processBookshelf(book, DBKey.BOOKSHELF.NAME, list);
        processBookshelf(book, LEGACY_BOOKSHELF_1_1, list);
        processBookshelf(book, LEGACY_BOOKSHELF_TEXT, list);

        if (!list.isEmpty()) {
            book.setBookshelves(list);
        }
    }

    private void processBookshelf(@NonNull final Book book,
                                  @NonNull final String key,
                                  @NonNull final List<Bookshelf> list) {
        if (book.contains(key)) {
            final String encodedList = book.getString(key, null);
            book.remove(key);

            if (encodedList != null && !encodedList.isEmpty()) {
                // Do not add if already there.
                // We need to do this here (before going to the database)
                // so we can keep them in the exact order as they come in.
                // This is particularly important for Goodreads imports
                bookshelfCoder.decodeList(encodedList)
                              .stream()
                              .filter(bookshelf -> list.stream().noneMatch(
                                      bs -> bs.isSameName(bookshelf)))
                              .forEach(list::add);
            }
        }
    }

    private void processCalibreData(@NonNull final Book book) {
        // we need to convert the string id to the row id.
        final String stringId = book.getString(DBKey.CALIBRE.LIBRARY_STRING_ID, null);
        if (stringId != null && !stringId.isEmpty()) {
            if (calibreLibraryStr2IdMap == null) {
                calibreLibraryStr2IdMap = new HashMap<>();

                ServiceLocator.getInstance().getCalibreLibraryDao().getAllLibraries().forEach(
                        library -> calibreLibraryStr2IdMap.put(
                                      library.getLibraryStringId(), library.getId()));
            }
            final Long id = calibreLibraryStr2IdMap.get(stringId);
            if (id != null) {
                book.putLong(DBKey.FK_CALIBRE_LIBRARY, id);
            } else {
                // Don't try to recover; just remove all calibre keys from this book.
                book.setCalibreLibrary(null);
            }

            // and discard the string-id
            book.remove(DBKey.CALIBRE.LIBRARY_STRING_ID);
        }
    }

    private void processRating(@NonNull final Book book) {
        // Read the values as String and parse to verify they are valid.
        // The order of the "if" statements is important!

        if (book.contains(DBKey.RATING)) {
            // If the key DBKey.RATING is present, we ALWAYS use it even if it's 0/empty
            ratingParser.parse(book.getString(DBKey.RATING)).ifPresent(book::setRating);
        }
    }

    private void processPrice(@NonNull final Book book) {
        if (book.contains(DBKey.PRICE_LISTED)) {
            final String s = book.getString(DBKey.PRICE_LISTED);
            if (!s.isBlank()) {
                try {
                    final BigDecimal v = moneyParser.getRealNumberParser().parseBigDecimal(s);
                    book.putBigDecimal(DBKey.PRICE_LISTED, v);
                    return;
                } catch (@NonNull final NumberFormatException ignore) {
                    // ignore, drop the field
                }
            }

            // remove failures to parse and empty values
            book.remove(DBKey.PRICE_LISTED);
            // Future compatibility
            book.remove(DBKey.PRICE_LISTED_CURRENCY);
        }
    }

    private void processGenre(@NonNull final Book book) {
        final String genre = book.getString(LEGACY_GENRE);
        if (!genre.isEmpty()) {
            book.getTags().addAll(GenreMigration.convert(genre));
            book.remove(LEGACY_GENRE);
        }
    }

    /**
     * We have no certainty about the csv columns containing external {@link Identifier} keys.
     * What we CAN do is simply check the keys present against the known identifier keys.
     * <p>
     * It's up to the user to add an unknown {@link Identifier} key <strong>before</strong>
     * doing the csv import.
     *
     * @param book the book
     */
    private void processIdentifiers(@NonNull final Book book) {
        final Set<String> known = identifierDao.getAll(Identifier.EntityType.Book)
                                               .stream()
                                               .map(Identifier::getKey)
                                               .collect(Collectors.toSet());
        // Collect the valid identifiers
        final List<Identifier.Value> ivs =
                book.keySet()
                    .stream()
                    .filter(known::contains)
                    .filter(key -> !book.getString(key).isEmpty()
                                   && !"0".equals(book.getString(key)))
                    .map(key -> new Identifier.Value(key, book.getString(key)))
                    .collect(Collectors.toList());

        if (!ivs.isEmpty()) {
            book.setIdentifiers(ivs);
            // Remove keys which were transformed to identifiers
            ivs.forEach(iv -> book.remove(iv.getKey()));
        }
    }

    /**
     * Require a column to be present. First one found; remainders are not needed.
     *
     * @param context        Current context
     * @param columnsPresent the column names which are present
     * @param names          columns which should be checked for, in order of preference
     *
     * @throws DataReaderException if no suitable column is present
     */
    private void requireColumnOrThrow(@NonNull final Context context,
                                      @NonNull final List<String> columnsPresent,
                                      @NonNull final String... names)
            throws DataReaderException {


        for (final String name : names) {
            if (columnsPresent.contains(name)) {
                return;
            }
        }

        throw new DataReaderException(context.getString(
                R.string.error_import_csv_missing_columns_x, String.join(",", names)));
    }
}
