
package dev.sonpx.loyalty.mcp.model.valueobject;

import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.Arrays;
import java.util.Map;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;

@Slf4j
@Getter
public class DateTimeValue extends BaseValue<LocalDateTime> {

    private int quarter = 0;

    public static final DateTimeValue NULL = new DateTimeValue(null);

    public static final DateTimeValue NOW = new DateTimeValue(LocalDateTime.now());

    /**
     * Mapping of weekday abbreviations to DayOfWeek enums, used by onWeekdays().
     * Example: "Mon" -> DayOfWeek.MONDAY
     */
    public static final Map<String, DayOfWeek> DAY_OF_WEEK = Map.ofEntries(
            Map.entry("Mon", DayOfWeek.MONDAY),
            Map.entry("Tue", DayOfWeek.TUESDAY),
            Map.entry("Wed", DayOfWeek.WEDNESDAY),
            Map.entry("Thu", DayOfWeek.THURSDAY),
            Map.entry("Fri", DayOfWeek.FRIDAY),
            Map.entry("Sat", DayOfWeek.SATURDAY),
            Map.entry("Sun", DayOfWeek.SUNDAY));

    /**
     * Enum representing units of time for plus() and minus() operations.
     */
    public enum TimeUnit {
        DAY,
        WEEK,
        MONTH,
        QUARTER,
        YEAR,
    }

    public enum TimeOffset {
        AGO,
        AWAY,
    }

    /**
     * Private constructor to enforce use of factory method of().
     * @param value the LocalDateTime to wrap
     */
    private DateTimeValue(LocalDateTime value) {
        super(value);
        setQuarter();
    }

    /**
     * Creates a DateTimeValue from various input types.
     * - LocalDateTime: Converts to start of day
     * - LocalDate: Converts to start of day LocalDateTime
     * - String: Parses ISO ("yyyy-MM-dd") or "dd/MM/yyyy" formats
     * - Null or invalid: Returns NULL_VALUE
     *
     * @param value the input to convert
     * @return a new DateTimeValue instance
     */
    public static DateTimeValue of(Object value) {
        return switch (value) {
            case LocalDateTime dateTime -> toStartOfDay(dateTime);
            case LocalDate localDate -> new DateTimeValue(localDate.atStartOfDay());
            case String datetimePattern -> parseString(datetimePattern);
            case null, default -> NULL;
        };
    }

    private static DateTimeValue parseString(String datetimePattern) {
        if (StringUtils.isBlank(datetimePattern)) {
            return DateTimeValue.NULL;
        }
        LocalDateTime localDateTime = LocalDate.parse(datetimePattern).atStartOfDay();
        return new DateTimeValue(localDateTime);
    }

    public static DateTimeValue toStartOfDay(LocalDateTime localDateTime) {
        return new DateTimeValue(localDateTime.toLocalDate().atStartOfDay());
    }

    /**
     * Checks if this date falls on any of the specified weekdays.
     * Example: onWeekdays("Mon", "Fri") returns true if date is a Monday or Friday.
     *
     * @param days weekday abbreviations (e.g., "Mon", "Tue")
     * @return true if the date matches any specified weekday, false if null or no match
     */
    public boolean onWeekdays(String... days) {
        if (isNull()) {
            return false;
        }
        return Arrays.stream(days)
                .map(DAY_OF_WEEK::get)
                .anyMatch(dayOfWeek -> dayOfWeek != null && dayOfWeek.equals(value.getDayOfWeek()));
    }

    /**
     * Adds a specified quantity of time to this DateTimeValue.
     * Example: plus(3, TimeUnit. MONTH) adds 3 months.
     *
     * @param quantity the amount to add (positive or negative)
     * @param unit the time unit to apply
     * @return a new DateTimeValue with the adjusted time
     * @throws IllegalStateException if this instance is null
     */
    public DateTimeValue plus(long quantity, TimeUnit unit) {
        if (isNull()) {
            throw new IllegalStateException("Cannot modify a null DateTimeValue");
        }
        if (quantity == 0) {
            return this; // No change
        }
        return switch (unit) {
            case DAY -> DateTimeValue.of(value.plusDays(quantity));
            case MONTH -> DateTimeValue.of(value.plusMonths(quantity));
            case QUARTER -> DateTimeValue.of(value.plusMonths(quantity * 3));
            case YEAR -> DateTimeValue.of(value.plusYears(quantity));
            case WEEK -> DateTimeValue.of(value.plusWeeks(quantity));
            default -> NULL;
        };
    }

    /**
     * Subtracts a specified amount of time from this DateTimeValue.
     * Example: minus(2, TimeUnit.DAY) subtracts 2 days.
     *
     * @param amount the amount to subtract (positive; negative values add time)
     * @param unit the time unit to apply
     * @return a new DateTimeValue with the adjusted time
     * @throws IllegalStateException if this instance is null
     */
    public DateTimeValue minus(long amount, TimeUnit unit) {
        if (isNull()) {
            throw new IllegalStateException("Cannot modify a null DateTimeValue");
        }
        if (amount == 0) {
            return this; // No change
        }
        return switch (unit) {
            case DAY -> DateTimeValue.of(value.minusDays(amount));
            case MONTH -> DateTimeValue.of(value.minusMonths(amount));
            case QUARTER -> DateTimeValue.of(value.minusMonths(amount * 3));
            case YEAR -> DateTimeValue.of(value.minusYears(amount));
            case WEEK -> DateTimeValue.of(value.minusWeeks(amount));
            default -> NULL;
        };
    }

    /**
     * Checks if this DateTimeValue is empty (null).
     *
     * @return true if null, false otherwise
     */
    public boolean isEmpty() {
        return isNull();
    }

    /**
     * Checks if this DateTimeValue is not empty (not null).
     *
     * @return true if not null, false otherwise
     */
    public boolean isNotEmpty() {
        return !isNull();
    }

    /**
     * Checks if this DateTimeValue equals another, ignoring time of day.
     * Example: "2025-03-02" equals "2025-03-02".
     *
     * @param compareValue the DateTimeValue to compare with
     * @return true if equal, false if either is null or not equal
     */
    public boolean isEquals(DateTimeValue compareValue) {
        if (isNull()) {
            return false;
        }
        return this.value.isEqual(compareValue.value);
    }

    /**
     * Checks if this DateTimeValue is not equal to another.
     *
     * @param compareValue the DateTimeValue to compare with
     * @return true if not equal, false if either is null or equal
     */
    public boolean isNotEquals(DateTimeValue compareValue) {
        return !isEquals(compareValue);
    }

    /**
     * Checks if this DateTimeValue is on or after another.
     * Example: "2025-03-02" isOnOrAfter "2025-03-01" returns true.
     *
     * @param compareValue the DateTimeValue to compare with
     * @return true if on or after, false if either is null or before
     */
    public boolean isOnOrAfter(DateTimeValue compareValue) {
        return value.isAfter(compareValue.value) || value.isEqual(compareValue.value);
    }

    /**
     * Checks if this DateTimeValue is on or before another.
     * Example: "2025-03-02" isOnOrBefore "2025-03-03" returns true.
     *
     * @param compareValue the DateTimeValue to compare with
     * @return true if on or before, false if either is null or after
     */
    public boolean isOnOrBefore(DateTimeValue compareValue) {
        return value.isBefore(compareValue.value) || value.isEqual(compareValue.value);
    }

    /**
     * Checks if this DateTimeValue falls between two others (inclusive).
     * @param from the start of the range
     * @param to the end of the range
     * @return true if within range, false if null or outside range
     */
    public boolean isBetween(DateTimeValue from, DateTimeValue to) {
        return isOnOrAfter(from) && isOnOrBefore(to);
    }

    public boolean quarterAndYearEquals(DateTimeValue compareValue) {
        if (isNull()) {
            return false;
        }
        return this.quarter == compareValue.quarter && this.value.getYear() == compareValue.value.getYear();
    }

    public boolean quarterAndYearNotEquals(DateTimeValue compareValue) {
        return !quarterAndYearEquals(compareValue);
    }

    private void setQuarter() {
        if (!isNull()) {
            this.quarter = (this.value.getMonthValue() - 1) / 3 + 1;
        }
    }

    /**
     * Checks if a date component falls within a specified range.
     * Example: isInRangeWith(1, 31, TimeUnit.DAY) checks if day is between 1 and 31.
     *
     * @param start the start of the range
     * @param end the end of the range
     * @param timeUnit the unit to check (DAY, MONTH, YEAR)
     * @return true if within range, false if null or outside range
     */
    public boolean isInRangeWith(Integer start, Integer end, TimeUnit timeUnit) {
        return switch (timeUnit) {
            case DAY -> value.getDayOfMonth() >= start && value.getDayOfMonth() <= end;
            case MONTH -> value.getMonthValue() >= start && value.getMonthValue() <= end;
            case YEAR -> value.getYear() >= start && value.getYear() <= end;
            case QUARTER -> quarter >= start && quarter <= end;
            default -> false;
        };
    }

    /**
     * Calculates the difference between this DateTimeValue and another in the specified time unit.
     * Returns the number of days, months, or years between the two dates, with positive values indicating
     * this date is after the compared date, and negative values indicating it is before. Only DAY, MONTH,
     * and YEAR units are supported; other units throw an exception.
     *
     * Usage:
     *   DateTimeValue start = DateTimeValue.of("2025-01-01");
     *   DateTimeValue end = DateTimeValue.of("2025-03-02");
     *   Long days = start.differenceInUnits(end, TimeUnit.DAY);    // Returns 60 (Jan 1 to Mar 2)
     *   Long months = start.differenceInUnits(end, TimeUnit.MONTH); // Returns 2 (Jan to Mar)
     *   Long years = end.differenceInUnits(start, TimeUnit.YEAR);   // Returns 0 (same year)
     *
     * Notes:
     * - Time components are ignored since DateTimeValue normalizes to start of day.
     * - Returns null if this instance is null; assumes compareValue is non-null (may throw NPE if not).
     * - Throws UnsupportedOperationException for WEEK or QUARTER units, as they are not implemented.
     * - The result is signed: positive if this date is after compareValue, negative if before.
     *
     * @param compareValue the DateTimeValue to compare against (non-null expected)
     * @param unit the time unit to measure the difference in (DAY, MONTH, or YEAR)
     * @return the difference as a Long (positive or negative), or null if this instance is null
     * @throws UnsupportedOperationException if unit is not DAY, MONTH, or YEAR
     * @throws NullPointerException if compareValue or its value is null
     */
    public Long differenceInUnits(DateTimeValue compareValue, TimeUnit unit) {
        if (isNull()) {
            return null;
        }
        return switch (unit) {
            case DAY -> ChronoUnit.DAYS.between(value, compareValue.value);
            case MONTH -> ChronoUnit.MONTHS.between(value, compareValue.value);
            case YEAR -> ChronoUnit.YEARS.between(value, compareValue.value);
            case null, default -> throw new UnsupportedOperationException("Not supported");
        };
    }
}
