package net.ranides.assira.time2;

import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import lombok.RequiredArgsConstructor;
import net.ranides.assira.time2.json.ZonedDateDeserializer;
import net.ranides.assira.time2.json.ZonedDateSerializer;
import org.jetbrains.annotations.NotNull;

import java.sql.Timestamp;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoField;
import java.time.temporal.ChronoUnit;
import java.util.Objects;


@JsonDeserialize(using = ZonedDateDeserializer.class)
@JsonSerialize(using = ZonedDateSerializer.class)
public abstract class ZonedDate implements Comparable<ZonedDate> {

    // @todo withMonth, withDay, withYear, plusMonths, plusYears, minusMonths, minusYears

    // @todo configure spring
    //      https://stackoverflow.com/questions/7854030/configuring-objectmapper-in-spring
    // @todo parse ZonedDate from full ISO time
    // @todo json serializer: ZonedDate from full ISO time

    public static @NotNull ZonedDate of(@NotNull ZonedDateTime input) {
        return new Complete(input);
    }

    @ZoneExplicit
    public static @NotNull ZonedDate of(@NotNull ZoneId zone, @NotNull LocalDate date) {
        return new Incomplete(zone, date);
    }

    public static ZonedDate of(@NotNull Timestamp source) {
        return of(ZoneId.systemDefault(), source);
    }

    @ZoneExplicit
    @Deprecated
    public static ZonedDate of(@NotNull ZoneId zone, @NotNull Timestamp source) {
        return new Incomplete(zone, source.toLocalDateTime().atZone(zone).toLocalDate());
    }

    @ZoneExplicit
    public static @NotNull ZonedDate ofEpochSecond(@NotNull ZoneId zone, long epoch) {
        LocalDate date = Instant.ofEpochSecond(epoch).atZone(zone).toLocalDate();
        return of(zone, date);
    }

    public static @NotNull ZonedDate now() {
        return new Complete(ZonedDateTime.now());
    }

    public abstract ZoneId getZone();

    public final boolean isAfter(ZonedDate other) {
        return withMin().isAfter(other.withMin());
    }

    public final boolean isAfter(ZonedDateTime other) {
        return withMin().isAfter(other);
    }

    public final boolean isBefore(ZonedDate other) {
        return withMax().isBefore(other.withMax());
    }

    public final boolean isBefore(ZonedDateTime other) {
        return withMax().isBefore(other);
    }

    public final boolean isEqual(ZonedDate other) {
        return withMin().isEqual(other.withMin());
    }

    public final boolean isEqualDate(ZonedDateTime other) {
        return withMin().isEqual(other.with(LocalTime.MIN));
    }

    public final boolean isSameZone(ZonedDate other) {
        return Objects.nonNull(other) && isSameZone(other.getZone());
    }

    public final boolean isSameZone(ZonedDateTime other) {
        return Objects.nonNull(other) && isSameZone(other.getZone());
    }

    public final boolean isSameZone(@NotNull ZoneId zone) {
        return getZone().equals(zone);
    }

    public abstract boolean isZoneConvertible(ZoneId zone);

    @ZoneExplicit
    @ZoneHazard
    public abstract ZonedDate withZone(ZoneId zone);

    @ZoneExplicit
    @ZoneHazard
    public final ZonedDate withZoneUtc() {
        return withZone(DateTimeUtils.ZONE_UTC);
    }

    @ZoneExplicit
    @ZoneHazard
    public final ZonedDate withZonePolish() {
        return withZone(DateTimeUtils.ZONE_PL);
    }

    @ZoneHazard
    public abstract ZonedDateTime withTime(ZonedTime time);

    public ZonedDateTime withTime(ZonedDateTime time) {
        return withTime(ZonedTime.of(time).withZone(getZone()));
    }

    public abstract ZonedDateTime withMin();

    public final ZonedDateTime withZero() {
        return withMin();
    }

    public abstract ZonedDateTime withMax();

    public final ZonedDateTime withMidnight() {
        return withZero();
    }

    public final ZonedDateTime atStartOfDay() {
        return withZero();
    }

    public final ZonedDateTime atEndOfDay() {
        return withMax();
    }

    public final Period until(ZonedDateTime end) {
        return Period.ofDays(until(end, ChronoUnit.DAYS));
    }

    public final int until(ZonedDateTime end, ChronoUnit unit) {
        return (int)withZero().until(end, unit);
    }

    public final Period until(ZonedDate end) {
        return Period.ofDays(until(end, ChronoUnit.DAYS));
    }

    public final int until(ZonedDate end, ChronoUnit unit) {
        return (int)withZero().until(end.withZero(), unit);
    }

    public abstract ZonedDate plus(long amount, ChronoUnit unit);

    public final ZonedDate minus(long amount, ChronoUnit unit) {
        return plus(-amount, unit);
    }

    public final ZonedDate plusDays(int days) {
        return plus(days, ChronoUnit.DAYS);
    }

    public final ZonedDate minusDays(int days) {
        return minus(days, ChronoUnit.DAYS);
    }

    public abstract int getYear();

    public abstract int getMonth();

    public abstract int getDay();

    @Override
    public int compareTo(ZonedDate other) {
        if(this.isBefore(other)) {
            return -1;
        }
        if(this.isAfter(other)) {
            return +1;
        }
        return 0;
    }

    @Override
    public final boolean equals(Object other) {
        return other instanceof ZonedDate && isEqual((ZonedDate)other);
    }

    @Override
    public final int hashCode() {
        return Objects.hash(getYear(), getMonth(), getDay());
    }

    public String format(DateTimeFormatter format) {
        return DateTimeFormatters.format(format, withZero());
    }

    @Override
    public String toString() {
        return DateTimeFormatters.formatDate(withZero()) + " [" + getZone() + "]";
    }

    /**
     * This wrapper allows all operations even if timezones do not match.
     * Because we have full datetime, we can convert between timezones safely.
     */
    @RequiredArgsConstructor
    private static class Complete extends ZonedDate {

        private final ZonedDateTime value;

        @Override
        public ZoneId getZone() {
            return value.getZone();
        }

        @Override
        public boolean isZoneConvertible(ZoneId zone) {
            return true;
        }

        @Override
        public ZonedDate withZone(ZoneId zone) {
            return new Complete(value.withZoneSameInstant(zone));
        }

        @Override
        public ZonedDateTime withTime(ZonedTime time) {
            return value.withZoneSameInstant(time.getZone()).with(ChronoField.NANO_OF_DAY, time.toNanoOfDay());
        }

        @Override
        public ZonedDateTime withMin() {
            return value.with(LocalTime.MIN);
        }

        @Override
        public ZonedDateTime withMax() {
            return value.with(LocalTime.MAX);
        }

        @Override
        public ZonedDate plus(long amount, ChronoUnit unit) {
            return new Complete(value.plus(amount, unit));
        }

        @Override
        public int getYear() {
            return value.getYear();
        }

        @Override
        public int getMonth() {
            return value.getMonthValue();
        }

        @Override
        public int getDay() {
            return value.getDayOfMonth();
        }
    }

    /**
     * This wrapper throws exception when timezones do not match.
     * It is impossible to convert between timezones without full datetime.
     */
    @RequiredArgsConstructor
    private static class Incomplete extends ZonedDate {

        private final ZoneId zone;

        private final LocalDate value;

        @Override
        public ZoneId getZone() {
            return zone;
        }

        @Override
        public boolean isZoneConvertible(ZoneId zone) {
            return false;
        }

        @Override
        public ZonedDate withZone(ZoneId zone) {
            if(!isSameZone(zone)) {
                throw new UnsupportedOperationException("This date " + this + " can't be converted to " + zone);
            } else {
                return this;
            }
        }

        @Override
        public ZonedDateTime withTime(ZonedTime time) {
            LocalTime adjusted = LocalTime.ofNanoOfDay(time.withZone(getZone()).toNanoOfDay());
            return ZonedDateTime.of(value, adjusted, getZone());
        }

        @Override
        public ZonedDateTime withMin() {
            return ZonedDateTime.of(value, LocalTime.MIN, getZone());
        }

        @Override
        public ZonedDateTime withMax() {
            return ZonedDateTime.of(value, LocalTime.MAX, getZone());
        }

        @Override
        public ZonedDate plus(long amount, ChronoUnit unit) {
            return new Incomplete(zone, value.plus(amount, unit));
        }

        @Override
        public int getYear() {
            return value.getYear();
        }

        @Override
        public int getMonth() {
            return value.getMonthValue();
        }

        @Override
        public int getDay() {
            return value.getDayOfMonth();
        }
    }

}
