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.ZonedTimeDeserializer;
import net.ranides.assira.time2.json.ZonedTimeSerializer;
import org.jetbrains.annotations.NotNull;

import java.sql.Timestamp;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoField;
import java.time.temporal.ChronoUnit;

@JsonDeserialize(using = ZonedTimeDeserializer.class)
@JsonSerialize(using = ZonedTimeSerializer.class)
public abstract class ZonedTime implements Comparable<ZonedTime> {

    // @todo withHour, withMinute, withSecond, withNano, plusNanos, minusNanos
    // @todo parse ZonedTime from full ISO time
    // @todo json serializer: ZonedTime from full ISO time
    // @todo SQL serializer
    // @todo SQL timestamp test

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

    @ZoneExplicit
    public static @NotNull ZonedTime of(@NotNull ZoneId zone, @NotNull LocalTime time) {
        return new Incomplete(zone, time);
    }

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

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

    @ZoneExplicit
    public static @NotNull ZonedTime ofEpochSecond(@NotNull ZoneId zone, long epoch) {
        LocalTime date = Instant.ofEpochSecond(epoch).atZone(zone).toLocalTime();
        return of(zone, date);
    }

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

    public abstract ZoneId getZone();

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

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

    public final ZonedTime plusHours(int hours) {
        return plus(hours, ChronoUnit.HOURS);
    }

    public final ZonedTime minusHours(int hours) {
        return minus(hours, ChronoUnit.HOURS);
    }

    public final ZonedTime plusMinutes(int minutes) {
        return plus(minutes, ChronoUnit.MINUTES);
    }

    public final ZonedTime minusMinutes(int minutes) {
        return minus(minutes, ChronoUnit.MINUTES);
    }

    public final ZonedTime plusSeconds(int seconds) {
        return plus(seconds, ChronoUnit.SECONDS);
    }

    public final ZonedTime minusSeconds(int seconds) {
        return minus(seconds, ChronoUnit.SECONDS);
    }

    public abstract int getHour();

    public abstract int getMinute();

    public abstract int getSecond();

    public abstract int getNano();

    public abstract long toNanoOfDay();

    @ZoneHazard
    public abstract boolean isAfter(ZonedTime other);

    public final boolean isAfter(ZonedDateTime other) {
        return toNanoOfDay() > other.withZoneSameInstant(getZone()).toLocalTime().toNanoOfDay();
    }

    @ZoneHazard
    public abstract boolean isBefore(ZonedTime other);

    public final boolean isBefore(ZonedDateTime other) {
        return toNanoOfDay() < other.withZoneSameInstant(getZone()).toLocalTime().toNanoOfDay();
    }

    @ZoneHazard
    public abstract boolean isEqual(ZonedTime other);

    public final boolean isEqualTime(ZonedDateTime other) {
        return toNanoOfDay() == other.withZoneSameInstant(getZone()).toLocalTime().toNanoOfDay();
    }

    public final boolean isSameZone(ZonedTime other) {
        return getZone().equals(other.getZone());
    }

    public final boolean isSameZone(ZonedDateTime other) {
        return getZone().equals(other.getZone());
    }

    public final boolean isSameZone(ZoneId other) {
        return getZone().equals(other);
    }

    public abstract boolean isZoneConvertible(ZoneId zone);

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

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

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

    @ZoneHazard
    public abstract ZonedDateTime withDate(ZonedDate time);

    public ZonedDateTime withDate(ZonedDateTime datetime) {
        //noinspection timezone-hazard
        return withDate(ZonedDate.of(datetime));
    }

    public final Duration until(ZonedDateTime end) {
        return Duration.ofNanos(until(end, ChronoUnit.NANOS));
    }

    public final long until(ZonedDateTime end, ChronoUnit unit) {
        long nanos = end.withZoneSameInstant(getZone()).toLocalTime().toNanoOfDay() - toNanoOfDay();
        return nanos / unit.getDuration().toNanos();
    }

    @ZoneHazard
    public final Duration until(ZonedTime end) {
        return Duration.ofNanos(until(end, ChronoUnit.NANOS));
    }

    @ZoneHazard
    public abstract long until(ZonedTime end, ChronoUnit unit);

    @ZoneHazard
    @Override
    public int compareTo(@NotNull ZonedTime other) {
        if(isBefore(other)) {
            return -1;
        }
        if(isAfter(other)) {
            return +1;
        }
        return 0;
    }

    @Override
    public String toString() {
        return DateTimeFormatters.formatTime(withDate(ZonedDateTime.now())) + " [" + 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 ZonedTime {

        private final ZonedDateTime value;

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

        @Override
        public boolean isAfter(ZonedTime other) {
            return withZone(other.getZone()).toNanoOfDay() > other.toNanoOfDay();
        }

        @Override
        public boolean isBefore(ZonedTime other) {
            return withZone(other.getZone()).toNanoOfDay() < other.toNanoOfDay();
        }

        @Override
        public boolean isEqual(ZonedTime other) {
            return withZone(other.getZone()).toNanoOfDay() == other.toNanoOfDay();
        }

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

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

        @Override
        public ZonedDateTime withDate(ZonedDate date) {
            return value
                .withZoneSameInstant(date.getZone())
                .withYear(date.getYear())
                .withMonth(date.getMonth())
                .withDayOfMonth(date.getDay())
                .withZoneSameInstant(getZone());
        }

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

        @Override
        public long until(ZonedTime end, ChronoUnit unit) {
            long nanos = end.toNanoOfDay() - withZone(end.getZone()).toNanoOfDay();
            return nanos / unit.getDuration().toNanos();
        }

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

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

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

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

        @Override
        public long toNanoOfDay() {
            return value.toLocalTime().toNanoOfDay();
        }
    }

    /**
     * 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 ZonedTime {

        private final ZoneId zone;

        private final LocalTime value;

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

        @Override
        public boolean isAfter(ZonedTime other) {
            return toNanoOfDay() > other.withZone(getZone()).toNanoOfDay();
        }

        @Override
        public boolean isBefore(ZonedTime other) {
            return toNanoOfDay() < other.withZone(getZone()).toNanoOfDay();
        }

        @Override
        public boolean isEqual(ZonedTime other) {
            return toNanoOfDay() == other.withZone(getZone()).toNanoOfDay();
        }

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

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

        @Override
        public ZonedDateTime withDate(ZonedDate date) {
            return date
                .withZone(getZone())
                .withZero()
                .with(ChronoField.NANO_OF_DAY, toNanoOfDay());
        }

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

        @Override
        public long until(ZonedTime end, ChronoUnit unit) {
            long nanos = end.withZone(getZone()).toNanoOfDay() - toNanoOfDay();
            return nanos / unit.getDuration().toNanos();
        }

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

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

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

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

        @Override
        public long toNanoOfDay() {
            return value.toNanoOfDay();
        }
    }

}
