/*
 * @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.database.dao.impl;

import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;

import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;

import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;

import com.hardbacknutter.nevertoomanybooks.BuildConfig;
import com.hardbacknutter.nevertoomanybooks.DEBUG_SWITCHES;
import com.hardbacknutter.nevertoomanybooks.ServiceLocator;
import com.hardbacknutter.nevertoomanybooks.bookreadstatus.ReadingProgress;
import com.hardbacknutter.nevertoomanybooks.core.database.ColumnInfo;
import com.hardbacknutter.nevertoomanybooks.core.database.Domain;
import com.hardbacknutter.nevertoomanybooks.core.database.SqLiteDataType;
import com.hardbacknutter.nevertoomanybooks.core.database.SqlEncode;
import com.hardbacknutter.nevertoomanybooks.core.database.TableInfo;
import com.hardbacknutter.nevertoomanybooks.core.parsers.MoneyParser;
import com.hardbacknutter.nevertoomanybooks.core.parsers.NumberParser;
import com.hardbacknutter.nevertoomanybooks.core.parsers.RealNumberParser;
import com.hardbacknutter.nevertoomanybooks.core.storage.StorageException;
import com.hardbacknutter.nevertoomanybooks.core.tasks.ASyncExecutor;
import com.hardbacknutter.nevertoomanybooks.core.utils.Money;
import com.hardbacknutter.nevertoomanybooks.core.utils.textnormaliser.TextNormaliser;
import com.hardbacknutter.nevertoomanybooks.covers.CoverStorage;
import com.hardbacknutter.nevertoomanybooks.database.DBDefinitions;
import com.hardbacknutter.nevertoomanybooks.database.DBKey;
import com.hardbacknutter.nevertoomanybooks.database.dao.IdentifierDao;
import com.hardbacknutter.nevertoomanybooks.entities.Book;
import com.hardbacknutter.nevertoomanybooks.entities.Identifier;
import com.hardbacknutter.nevertoomanybooks.utils.ReorderHelper;
import com.hardbacknutter.util.logger.LoggerFactory;

/**
 * Preprocess a Book for storage. This class does not access to database.
 * <p>
 * Normal flow:
 * <ol>
 *     <li>Create this object</li>
 *     <li>Call {@link #process(Context, Book, boolean)}</li>
 *     <li>insert or update the book to the database</li>
 *     <li>Call {@link #persistCovers(Book)}</li>
 * </ol>
 * <p>
 * Processing and filtering is done in two methods to facilitate testing.
 */
class BookDaoHelper {

    private static final String TAG = "BookDaoHelper";

    @NonNull
    private final TextNormaliser textNormaliser;
    @NonNull
    private final List<Locale> userLocales;
    @NonNull
    private final TableInfo tableInfo;
    @NonNull
    private final List<String> dateDomainNames;
    @NonNull
    private final List<String> dateTimeDomainNames;
    private final List<Domain> tableDomains;

    /**
     * Constructor.
     *
     * @param tableInfo   of the {@link DBDefinitions#TBL_BOOKS} table
     * @param userLocales Current Locales
     */
    BookDaoHelper(@NonNull final TableInfo tableInfo,
                  @NonNull final List<Locale> userLocales) {
        this.tableInfo = tableInfo;
        this.userLocales = userLocales;

        this.textNormaliser = new TextNormaliser();

        tableDomains = DBDefinitions.TBL_BOOKS.getDomains();
        dateDomainNames = tableDomains
                .stream()
                .filter(domain -> domain.getSqLiteDataType() == SqLiteDataType.Date)
                .map(Domain::getName)
                .collect(Collectors.toList());
        dateTimeDomainNames = tableDomains
                .stream()
                .filter(domain -> domain.getSqLiteDataType() == SqLiteDataType.DateTime)
                .map(Domain::getName)
                .collect(Collectors.toList());
    }

    /**
     * Examine the values and make any changes necessary before writing the data.
     * Called during {@link BookDaoImpl#insert(Context, Book, Set)}
     * and {@link BookDaoImpl#update(Context, Book, Set)}.
     *
     * @param context Current context
     * @param book    to process
     * @param isNew   flag; whether the book is entirely 'new' or it's an update
     *
     * @return New and filtered ContentValues
     */
    @NonNull
    ContentValues process(@NonNull final Context context,
                          @NonNull final Book book,
                          final boolean isNew) {

        final Locale userLocale = userLocales.get(0);

        // Handle Language field FIRST, we need it for _OB fields.
        // If the book has no valid language set, we use the user-locale.
        final Locale bookLocale = book.getLocaleAndUpdateLanguage(userLocale, true)
                                      .orElse(userLocale);

        // NEW copy, with the book-locale as the first
        final List<Locale> locales = new ArrayList<>(userLocales);
        if (!bookLocale.equals(userLocale)) {
            locales.add(0, bookLocale);
        }

        final RealNumberParser realNumberParser = new RealNumberParser(locales);
        final MoneyParser moneyParser = new MoneyParser(userLocale, locales);

        // Handle TITLE
        if (book.contains(DBKey.TITLE)) {
            final String title = book.getTitle();

            final String obTitle = new ReorderHelper(locales)
                    .reorderForSorting(context, title, bookLocale);

            book.putString(DBKey.TITLE_OB, textNormaliser.strict(obTitle, bookLocale));
        }

        // normalise/store only valid bits
        if (book.contains(DBKey.CONTENT_TYPE)) {
            book.setContentType(book.getContentType());
        }

        // normalise/store only valid bits
        if (book.contains(DBKey.EDITION_FLAGS)) {
            book.setEditionFlags(book.getEditionFlags());
        }

        // clean-up/build all price related fields
        processPrices(book, moneyParser);

        // Try to cross-pollinate the ReadingProgress and page-count fields.
        processReadProgress(book);

        // replace 'T' by ' ' and truncate pure date fields if needed
        processDates(book);

        // make sure there are only valid external id's present
        if (book.contains(Identifier.Value.BKEY_LIST)) {
            processExternalIds(book);
        }

        // lastly, clean-up null and blank fields as needed.
        processNullsAndBlanks(book, isNew);

        return filterValues(book, realNumberParser);
    }

    /**
     * If the {@link DBKey#PAGES} field is empty, and we have
     * a total-pages value from the {@link DBKey#READ_PROGRESS} field,
     * copy the value across.
     * We do NOT overwrite existing {@link DBKey#PAGES} values!
     *
     * @param book to process
     *
     * @see BookDaoImpl#setReadingProgress(Book, ReadingProgress)
     */
    @VisibleForTesting
    void processReadProgress(@NonNull final Book book) {
        final ReadingProgress readingProgress = book.getReadingProgress();
        // KEEP THIS LOGIC IN SYNC with {@link BookDaoImpl#setReadingProgress()} !
        if (!readingProgress.asPercentage() && book.getPages().isEmpty()) {
            book.setPages(readingProgress.getTotalPages());
        }
    }


    /**
     * Handle all price fields.
     *
     * @param book        to process
     * @param moneyParser to use
     */
    @VisibleForTesting
    void processPrices(@NonNull final Book book,
                       @NonNull final MoneyParser moneyParser) {
        DBKey.getMoneyKeys().forEach(key -> processPrice(book, key, moneyParser));
    }

    /**
     * Handle the given price field.
     *
     * @param book        to process
     * @param key         key for the money (value) field
     * @param moneyParser to use
     */
    private void processPrice(@NonNull final Book book,
                              @NonNull final String key,
                              @NonNull final MoneyParser moneyParser) {
        try {
            final String currencyKey = key + DBKey.CURRENCY_SUFFIX;
            if (book.contains(key) && !book.contains(currencyKey)) {
                // Verify a price without a currency.
                final Optional<Money> money = moneyParser.parse(book.getString(key));
                // If the currency could be decoded, store the Money back into the book
                if (money.isPresent()) {
                    book.putMoney(key, money.get());
                    return;
                }
                // else just leave the original text in the book
            }

            // Make sure any currency strings present are uppercase
            if (book.contains(currencyKey)) {
                book.putString(currencyKey, book.getString(currencyKey)
                                                .toUpperCase(userLocales.get(0)));
            }
        } catch (@NonNull final NumberFormatException e) {
            // just leave the original text in the book
        }
    }

    /**
     * Truncate Date fields to being pure dates without a time segment.
     * Replaces 'T' with ' ' to please SqLite/SQL datetime standards.
     * <p>
     * <strong>Note 1:</strong>: We do not fully parse each date string,
     * to verify/correct as an SQLite datetime string.
     * It is assumed that during normal logic flow this is already done.
     * The 'T' is the exception as that is easier to handle here for all fields.
     * <p>
     * <strong>Note 2:</strong>: such a full parse should be done during import operations.
     * <p>
     * <strong>VERY IMPORTANT:</strong> SQLite date functions:
     * <a href="https://sqlite.org/lang_datefunc.html">https://sqlite.org/lang_datefunc.html</a>
     * <pre>
     *      Function        Equivalent strftime()
     *      date(...)       strftime('%Y-%m-%d', ...)
     *      time(...)       strftime('%H:%M:%S', ...)
     *      datetime(...)   strftime('%Y-%m-%d %H:%M:%S', ...)
     * </pre>
     * <p>
     * ==> the default 'datetime' function <strong>does NOT include the 'T' character</strong>
     * as used in ISO standards.
     * <p>
     * ==> the standard 'current_timestamp' <strong>does NOT include the 'T' character</strong>
     * * as used in ISO standards.
     * <p>
     * Which means that when using {@link java.time.format.DateTimeFormatter}
     * versus SQLite 'datetime/current_timestamp' conversions between 'T' and ' '
     * will need to be done.
     * Reading/writing itself is not an issue (parsers in sqlite and in our app take care of that),
     * but <strong>string COMPARE as in 'where' clauses</strong> will cause faulty results.
     * <p>
     * Affected columns are those of type
     * {@link  com.hardbacknutter.nevertoomanybooks.core.database.SqLiteDataType#DateTime}.
     * Status on 2020-09-26:
     * {@link com.hardbacknutter.nevertoomanybooks.database.DBKey#DATE_ADDED__UTC}
     * {@link com.hardbacknutter.nevertoomanybooks.database.DBKey#DATE_LAST_UPDATED__UTC}
     * and
     * {@link com.hardbacknutter.nevertoomanybooks.database.CacheDbHelper}#IMAGE_LAST_UPDATED__UTC
     * <p>
     * Columns of type
     * {@link  com.hardbacknutter.nevertoomanybooks.core.database.SqLiteDataType#Date}
     * Status on 2020-09-26:
     * {@link com.hardbacknutter.nevertoomanybooks.database.DBKey#READ_START__DATE}
     * {@link com.hardbacknutter.nevertoomanybooks.database.DBKey#READ_END__DATE}
     * {@link com.hardbacknutter.nevertoomanybooks.database.DBKey#DATE_ACQUIRED}
     * {@link com.hardbacknutter.nevertoomanybooks.database.DBKey#PUBLICATION_DATE}
     * {@link com.hardbacknutter.nevertoomanybooks.database.DBKey#FIRST_PUBLICATION_DATE}
     *
     * @param book to process
     */
    @VisibleForTesting
    void processDates(@NonNull final Book book) {
        // Partial/Full Date strings
        for (final String dateDomainName : dateDomainNames) {
            if (book.contains(dateDomainName)) {
                final String date = book.getString(dateDomainName);
                // This is very crude... we simply truncate to 10 characters maximum
                // i.e. 'YYYY-MM-DD', but do not verify if it's a valid date.
                if (date.length() > 10) {
                    book.putString(dateDomainName, date.substring(0, 10));
                }
            }
        }

        // Full UTC based DateTime strings
        for (final String key : dateTimeDomainNames) {
            if (book.contains(key)) {
                final String date = book.getString(key);
                // Again, very crude logic... we simply check for the 11th char being a 'T'
                // and if so, replace it with a space
                if (date.length() > 10 && date.charAt(10) == 'T') {
                    book.putString(key, SqlEncode.dateTime(date));
                }
            }
        }
    }

    /**
     * Processes the external id keys.
     * <p>
     * Removes any keys with zero values, empty strings, null values
     * or any invalid values for the type. Further processing is done in
     * {@link #processNullsAndBlanks(Book, boolean)}.
     *
     * @param book to process
     */
    @VisibleForTesting
    public void processExternalIds(@NonNull final Book book) {
        final IdentifierDao identifierDao = ServiceLocator.getInstance().getIdentifierDao();
        // We need to recollect these here, as they can be updated
        // by a previous book processing.
        final Map<String, Identifier.Type> map = identifierDao
                .getAll(Identifier.EntityType.Book)
                .stream()
                .collect(Collectors.toMap(Identifier::getKey, Identifier::getType));

        final List<Identifier.Value> ivsOut = new ArrayList<>();
        final List<Identifier.Value> ivsIn = book.getIdentifiers();

        // Weed out duplicates, nulls, empties and "0"s.
        identifierDao.pruneList(ivsIn);

        ivsIn.stream()
             // Identifier.Type.Number identifiers MUST exist
             .filter(iv -> map.get(iv.getKey()) == Identifier.Type.Number)
             .filter(this::isPositiveNumber)
             .forEach(ivsOut::add);

        ivsIn.stream()
             // Identifier.Type.Text identifiers are optional
             .filter(iv -> {
                 final Identifier.Type type = map.get(iv.getKey());
                 return type == null || type == Identifier.Type.Text;
             })
             .forEach(ivsOut::add);

        // always write, even when empty. The latter would be the result
        // of the above code and means we want to delete all identifiers.
        book.setIdentifiers(ivsOut);
    }

    private boolean isPositiveNumber(@NonNull final Identifier.Value iv) {
        try {
            return Long.parseLong(iv.getSid()) > 0;
        } catch (@NonNull final NumberFormatException e) {
            if (BuildConfig.DEBUG) {
                LoggerFactory.getLogger().d(TAG, "preprocessExternalIds",
                                            "NumberFormatException|iv=" + iv);
            }
            return false;
        }
    }

    /**
     * Fields in this Book, which have a default in the database and
     * <ul>
     *      <li>which are null but not allowed to be null</li>
     *      <li>which are null/empty (i.e. blank) but not allowed to be blank</li>
     * </ul>
     * <p>
     * For new books, REMOVE those keys.
     * Existing books, REPLACE those keys with the default value for the column.
     *  @param book  to process
     *
     * @param isNew flag; whether the book is entirely 'new' or it's an update
     */
    @VisibleForTesting
    public void processNullsAndBlanks(@NonNull final Book book,
                                      final boolean isNew) {
        for (final Domain domain : tableDomains) {
            if (book.contains(domain.getName()) && domain.hasDefault()) {
                // We don't care about Money here. Value/Currency are treated as Number/String
                final Object o = book.get(domain.getName());
                if (
                    // Fields which are null but not allowed to be null
                        o == null && domain.isNotNull()
                        ||
                        // Fields which are null/empty (i.e. blank) but not allowed to be blank
                        (o == null || o.toString().isEmpty()) && domain.isNotBlank()
                ) {
                    if (isNew) {
                        book.remove(domain.getName());
                    } else {
                        // restore the column to its default value.
                        //noinspection DataFlowIssue
                        book.putString(domain.getName(), domain.getDefault());
                    }
                }
            }
        }
    }

    /**
     * Creates a ContentValues collection containing only those values from 'source'
     * that match columns in 'dest'.
     * <ul>
     *      <li>Exclude the primary key from the list of columns.</li>
     *      <li>data will be transformed based on the column definition.<br>
     *          e.g. if a columns says it's Integer, an incoming boolean will
     *          be transformed to 0/1</li>
     * </ul>
     *
     * @param book             to process
     * @param realNumberParser to use
     *
     * @return New and filtered ContentValues
     *
     * @throws IllegalArgumentException if a {@code null} is set for a not-nullable column
     */
    @NonNull
    private ContentValues filterValues(@NonNull final Book book,
                                       @NonNull final RealNumberParser realNumberParser) {
        final ContentValues cv = new ContentValues();

        for (final String key : book.keySet()) {
            // We've seen empty keys in old BC imports - this is likely due to a csv column
            // not being properly escaped, i.e. the data itself containing a comma.
            // Not much we can do about that, so skip if encountered.
            if (key.isEmpty()) {
                continue;
            }

            final ColumnInfo columnInfo = tableInfo.getColumn(key);
            // Check if we actually have a matching column, and never update a PK.
            if (columnInfo != null && !columnInfo.isPrimaryKey()) {
                // We don't care about Money here. Value/Currency are treated as Number/String
                final Object entry = book.get(key);
                if (entry == null) {
                    if (columnInfo.isNullable()) {
                        cv.putNull(key);
                    } else {
                        throw new IllegalArgumentException(
                                "NULL on a non-nullable column|key=" + key);
                    }
                } else {
                    final String columnName = columnInfo.getName();
                    switch (columnInfo.getCursorFieldType()) {
                        case Cursor.FIELD_TYPE_STRING: {
                            if (entry instanceof String) {
                                cv.put(columnName, (String) entry);
                            } else {
                                cv.put(columnName, entry.toString());
                            }
                            break;
                        }
                        case Cursor.FIELD_TYPE_INTEGER: {
                            if (entry instanceof Boolean) {
                                cv.put(columnName, (Boolean) entry ? 1 : 0);
                            } else {
                                try {
                                    cv.put(columnName, NumberParser.toInt(entry));
                                } catch (@NonNull final NumberFormatException e) {
                                    // We do NOT want to fail at this point.
                                    // Log, but skip this field.
                                    LoggerFactory.getLogger()
                                                 .w(TAG, e.getMessage(),
                                                    "columnName(int)=" + columnName,
                                                    "entry=" + entry,
                                                    "book=" + book);
                                }
                            }
                            break;
                        }
                        case Cursor.FIELD_TYPE_FLOAT: {
                            try {
                                cv.put(columnName, realNumberParser.toDouble(entry));
                            } catch (@NonNull final NumberFormatException e) {
                                // We do NOT want to fail at this point.
                                // Log, but skip this field.
                                LoggerFactory.getLogger()
                                             .w(TAG, e.getMessage(),
                                                "columnName(float)=" + columnName,
                                                "entry=" + entry,
                                                "book=" + book);
                            }
                            break;
                        }
                        case Cursor.FIELD_TYPE_BLOB: {
                            if (entry instanceof byte[]) {
                                cv.put(columnName, (byte[]) entry);
                            } else {
                                throw new IllegalArgumentException(
                                        "non-null Blob but not a byte[] ?"
                                        + "|columnName(blob)=" + columnName
                                        + "|key=" + key);
                            }
                            break;
                        }
                        case Cursor.FIELD_TYPE_NULL:
                        default:
                            // ignore
                            break;
                    }
                }
            }
        }
        return cv;
    }

    /**
     * Called during {@link BookDaoImpl#insert(Context, Book, Set)}
     * and {@link BookDaoImpl#update(Context, Book, Set)}.
     *
     * @param book to process
     *
     * @throws StorageException The covers directory is not available
     * @throws IOException      on generic/other IO failures
     */
    @SuppressWarnings("OverlyBroadThrowsClause")
    void persistCovers(@NonNull final Book book)
            throws StorageException, IOException {

        final String uuid = book.getUuid();
        final CoverStorage coverStorage = ServiceLocator.getInstance().getCoverStorage();

        for (int cIdx = 0; cIdx < DBKey.NR_OF_BOOK_COVERS; cIdx++) {
            if (book.contains(Book.BKEY_TMP_FILE_SPEC[cIdx])) {
                final String fileSpec = book.getString(Book.BKEY_TMP_FILE_SPEC[cIdx]);

                if (BuildConfig.DEBUG && DEBUG_SWITCHES.IMAGES) {
                    LoggerFactory.getLogger()
                                 .d(TAG, "persistCovers",
                                    "BKEY_TMP_FILE_SPEC[" + cIdx + "]=`" + fileSpec + '`');
                }

                if (fileSpec.isEmpty()) {
                    // A *present* but empty fileSpec indicates we need to delete the cover
                    final int finalCIdx = cIdx;
                    ASyncExecutor.STORAGE_WRITES.execute(
                            () -> coverStorage.delete(uuid, finalCIdx));
                } else {
                    // Rename the temp file to the uuid permanent file name
                    coverStorage.persist(new File(fileSpec), uuid, cIdx);
                }

                book.remove(Book.BKEY_TMP_FILE_SPEC[cIdx]);
            }
        }
    }
}
