/*
 * @Copyright 2018-2025 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.core.parsers;

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

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.time.format.DateTimeParseException;
import java.time.temporal.ChronoField;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Optional;

/**
 * Parser for dates comes from the internet and/or the user (either as direct input, or by import).
 * <p>
 * This object is thread-safe, but user Locale specific at creation time.
 * <p>
 * The result is always a {@link LocalDateTime}.
 * <p>
 * Typical use is Date or DateTime timestamps with an unknown format.
 * Parsing is done in 3 steps; first result is returned.
 * <ol>
 *     <li>{@link ISODateParser}</li>
 *     <li>Numerical formats</li>
 *     <li>Text formats</li>
 * </ol>
 * TODO: performance: create all parsers, then parse (and reuse the parsers)...
 *  or create the parsers each time, but stop at first result.
 */
public class FullDateParser
        implements DateParser<LocalDateTime> {

    /** All numerical (i.e. Locale independent) patterns. */
    private static final String[] NUMERICAL_PATTERNS = {
            // US format first
            "MM-dd-yyyy HH:mm:ss",
            "MM-dd-yyyy HH:mm",

            // International next
            "dd-MM-yyyy HH:mm:ss",
            "dd-MM-yyyy HH:mm",

            // same without time
            "MM-dd-yyyy",
            "dd-MM-yyyy",

            // Goodreads csv (and yes, it IS mm/dd)
            "yyyy/MM/dd"
    };

    /** Patterns with Locale dependent text. */
    private static final String[] TEXT_PATTERNS = {
            // Abbreviated month
            "dd-MMM-yyyy",
            "dd MMM yyyy",
            "dd MMM. yyyy",

            // Just a dot after the day...
            "d. MMM yyyy",
            "dd. MMM yyyy",

            // Month in full
            "dd-MMMM-yyyy",
            "dd MMMM yyyy",

            // Dates of the form: 'Fri Apr 5 17:23:11 -0800 2012'
            "EEE MMM dd HH:mm:ss ZZZZ yyyy",

            // Used by OpenLibrary, maybe others
            "MMM d, yyyy",
            "MMMM d, yyyy",
            "MMM yyyy",
            "MMMM yyyy",

            // Occasionally we have seen the year in old style 2 digits
            "dd-MMM-yy",
            "dd-MMMM-yy",
    };

    /** Immutable. */
    @NonNull
    private final List<Locale> locales;
    @NonNull
    private final DateParser<LocalDateTime> isoDateParser;
    /** List of patterns we'll use to parse dates. */
    @Nullable
    private Collection<DateTimeFormatter> textParsers;
    @NonNull
    private final Collection<DateTimeFormatter> numericalParsers = new ArrayList<>();

    /**
     * Constructor.
     *
     * @param isoDateParser to use for ISO date parsing
     * @param locales       the locales to creates further parsers as needed.
     */
    public FullDateParser(@NonNull final DateParser<LocalDateTime> isoDateParser,
                          @NonNull final List<Locale> locales) {
        this.isoDateParser = isoDateParser;
        this.locales = locales;

        addPatterns(numericalParsers, NUMERICAL_PATTERNS, List.of(Locale.ROOT));
    }

    /**
     * Attempt to parse a date string.
     *
     * @param dateStr String to parse
     *
     * @return Resulting date if parsed, otherwise {@code Optional.empty()}
     */
    @NonNull
    @Override
    public Optional<LocalDateTime> parse(@Nullable final CharSequence dateStr) {
        return parse(dateStr, null);
    }

    /**
     * Attempt to parse a date string using the passed locale.
     * This method is meant to be used by site-specific code where the site Locale is known.
     *
     * @param dateStr     String to parse
     * @param firstLocale to try first; i.e. before the pre-defined list.
     *
     * @return Resulting date if parsed, otherwise {@code Optional.empty()}
     */
    @NonNull
    @Override
    public Optional<LocalDateTime> parse(@Nullable final CharSequence dateStr,
                                         @Nullable final Locale firstLocale) {
        if (dateStr == null || dateStr.length() == 0) {
            return Optional.empty();
        }

        // Try ISO first,
        return isoDateParser.parse(dateStr).or(() -> {
            // then numerical,
            return parse(numericalParsers, dateStr, firstLocale).or(() -> {
                // or lastly the extensive text based.
                if (textParsers == null) {
                    textParsers = new ArrayList<>();
                    addPatterns(textParsers, TEXT_PATTERNS, locales);
                    //Create an English variant of a parser
                    // if English is not already in the list of locales.
                    if (locales.stream().noneMatch(Locale.ENGLISH::equals)) {
                        addPatterns(textParsers, TEXT_PATTERNS, List.of(Locale.ENGLISH));
                    }
                }
                return parse(textParsers, dateStr, firstLocale);
            });
        });
    }

    /**
     * Attempt to parse a date string.
     * Any missing parts of the pattern will get set to default: 1-Jan, 00:00:00
     * If the year is missing, {@code null} is returned.
     *
     * @param parsers     to use
     * @param dateStr     String to parse
     * @param firstLocale (optional) Locale to apply/try before the default list.
     *
     * @return Resulting date if parsed, otherwise {@code Optional.empty()}
     */
    @NonNull
    private Optional<LocalDateTime> parse(@NonNull final Iterable<DateTimeFormatter> parsers,
                                          @NonNull final CharSequence dateStr,
                                          @Nullable final Locale firstLocale) {

        // Try the specified Locale first
        if (firstLocale != null) {
            for (final DateTimeFormatter dtf : parsers) {
                try {
                    return Optional.of(LocalDateTime.parse(dateStr, dtf.withLocale(firstLocale)));
                } catch (@NonNull final DateTimeParseException ignore) {
                    // ignore and try the next one
                }
            }
        }

        // Parse with the default locales, using the default ResolverStyle
        for (final DateTimeFormatter dtf : parsers) {
            try {
                return Optional.of(LocalDateTime.parse(dateStr, dtf));
            } catch (@NonNull final DateTimeParseException ignore) {
                // ignore and try the next one
            }
        }

        return Optional.empty();
    }

    /**
     * <strong>Add</strong> patterns to the given group.
     *
     * @param group    collection to add to
     * @param patterns list of patterns to add
     * @param locales  to use for number parsing
     */
    private void addPatterns(@NonNull final Collection<DateTimeFormatter> group,
                             @NonNull final String[] patterns,
                             @NonNull final List<Locale> locales) {
        // prevent duplicate locales
        final Collection<Locale> added = new HashSet<>();
        locales.stream()
               .filter(locale -> !added.contains(locale))
               .forEach(locale -> {
                   added.add(locale);
                   Arrays.stream(patterns)
                         .map(pattern -> new DateTimeFormatterBuilder()
                                 .parseCaseInsensitive()
                                 .appendPattern(pattern)
                                 // Allow the day of the month to be missing and use '1'
                                 .parseDefaulting(ChronoField.DAY_OF_MONTH, 1)
                                 // Allow the time to be completely missing.
                                 .parseDefaulting(ChronoField.HOUR_OF_DAY, 0)
                                 .parseDefaulting(ChronoField.MINUTE_OF_HOUR, 0)
                                 .parseDefaulting(ChronoField.SECOND_OF_MINUTE, 0))
                         .map(builder -> builder.toFormatter(locale))
                         .forEach(group::add);
               });
    }
}
