package com.atlassian.webdriver.bitbucket.util;

import org.hamcrest.Description;
import org.hamcrest.Matcher;
import org.hamcrest.TypeSafeDiagnosingMatcher;

import javax.annotation.Nonnull;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.time.temporal.ChronoUnit;
import java.time.temporal.Temporal;
import java.util.Optional;

public class DateUtils {

    /**
     * The timestamp pattern used on the server.
     */
    private static final DateTimeFormatter SERVER_TIMESTAMP_PATTERN = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssZ");

    /**
     * The timestamp pattern used in the test code.
     */
    private static final DateTimeFormatter TEST_TIMESTAMP_PATTERN = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssZ");

    public static final FormatType DATE_TIME = new DateTimeFormatType("dd MMM - hh:mm a");
    public static final FormatType FULL = new DateTimeFormatType("dd MMMM yyyy hh:mm a");
    public static final FormatType LONG = new DateTimeFormatType("dd MMMM yyyy");
    public static final FormatType SHORT = new DateTimeFormatType("dd MMM yyyy");
    public static final FormatType TIME_ONLY = new DateTimeFormatType("hh:mm a");
    public static final FormatType TIME_STAMP = new InstantFormatType();

    /**
     * Parse the given server timestamp into a {@link ZonedDateTime}.
     *
     * @param source the timestamp to parse.
     * @return the server timestamp.
     */
    @Nonnull
    public static ZonedDateTime parseServerTimestamp(@Nonnull String source) {
        return SERVER_TIMESTAMP_PATTERN.parse(source, ZonedDateTime::from);
    }

    /**
     * Parse the given test timestamp into a {@link ZonedDateTime}.
     *
     * @param source the timestamp to parse.
     * @return the test timestamp.
     */
    @Nonnull
    public static ZonedDateTime parseTestTimestamp(@Nonnull String source) {
        return TEST_TIMESTAMP_PATTERN.parse(source, ZonedDateTime::from);
    }

    /**
     * Represents a format of a {@link Temporal} as given by the server. The server has a number of formats
     * it uses for {@code Temporal} rendering. They are defined by the
     * {@link com.atlassian.bitbucket.util.DateFormatter.FormatType} class. This class is the equivalent class used to
     * {@link #matchesTimestamp(String) assert} formatted dates and times in browser tests.
     */
    public interface FormatType {

        /**
         * Format the passed timestamp as the user sees it.
         *
         * @param timestamp the test timestamp to format.
         * @return the timestamp as the user will see it.
         */
        String formatTimestamp(@Nonnull String timestamp);

        /**
         * Return a matcher for a string that ensures it matches the passed (test) timestamp as rendered on the server.
         *
         * @param timestamp the test timestamp to match.
         * @return the matcher for the passed timestamp.
         */
        @Nonnull
        Matcher<String> matchesTimestamp(@Nonnull String timestamp);

        /**
         * Return a matcher for a string that ensures it matches the passed timestamp.
         *
         * @param timestamp the timestamp to match.
         * @return the matcher for the passed timestamp.
         */
        @Nonnull
        Matcher<String> matchesTimestamp(@Nonnull ZonedDateTime timestamp);

        /**
         * Return a new FormatType that expects times to be generated in the passed timezone.
         *
         * @param id the timezone id.
         * @return a new FormatType for the passed timezone.
         */
        @Nonnull
        FormatType withTimeZone(@Nonnull ZoneId id);

        /**
         * Return a new FormatType that expects times to be generated in the passed timezone.
         *
         * @param id the timezone id. {@link Optional#empty()} may be passed to use the current JVM timezone.
         * @return a new FormatType for the passed timezone.
         */
        @Nonnull
        FormatType withTimeZone(@Nonnull Optional<? extends ZoneId> id);
    }

    private static class InstantFormatType implements FormatType {

        @Override
        public String formatTimestamp(@Nonnull String timestamp) {
            return SERVER_TIMESTAMP_PATTERN.format(parseServerTimestamp(timestamp));
        }

        @Nonnull
        @Override
        public Matcher<String> matchesTimestamp(@Nonnull String timestamp) {
            return matchesTimestamp(parseTestTimestamp(timestamp));
        }

        @Nonnull
        @Override
        public Matcher<String> matchesTimestamp(@Nonnull ZonedDateTime expectedTime) {
            return new TypeSafeDiagnosingMatcher<String>() {
                @Override
                protected boolean matchesSafely(String item, Description mismatchDescription) {
                    try {
                        ZonedDateTime actualItem = parse(item);

                        //For a timestamp we only really care that the Instant is correct.
                        if (expectedTime.toInstant().equals(actualItem.toInstant())) {
                            return true;
                        } else {
                            mismatchDescription.appendValue(toString(actualItem));
                            return false;
                        }
                    } catch (DateTimeParseException e) {
                        mismatchDescription.appendValue(item);
                        return false;
                    }
                }

                @Override
                public void describeTo(Description description) {
                    description.appendValue(toString(expectedTime));
                }

                private String toString(ZonedDateTime time) {
                    return String.format("%s [%d]", format(time), time.toInstant().toEpochMilli());
                }
            };
        }

        @Nonnull
        @Override
        public FormatType withTimeZone(@Nonnull ZoneId id) {
            //Ignored as this is an instant based equality.
            return this;
        }

        @Nonnull
        @Override
        public FormatType withTimeZone(@Nonnull Optional<? extends ZoneId> id) {
            //Ignored as this is an instant based equality.
            return this;
        }

        private static String format(ZonedDateTime data) {
            return SERVER_TIMESTAMP_PATTERN.format(data);
        }

        private static ZonedDateTime parse(String data) {
            return SERVER_TIMESTAMP_PATTERN.parse(data, ZonedDateTime::from);
        }
    }

    private static class DateTimeFormatType implements FormatType {

        private final DateTimeFormatter formatter;
        private final ZoneId timeZone;

        private DateTimeFormatType(String pattern) {
            this(DateTimeFormatter.ofPattern(pattern), ZoneId.systemDefault());
        }

        private DateTimeFormatType(DateTimeFormatter pattern, ZoneId timeZone) {
            this.formatter = pattern;
            this.timeZone = timeZone;
        }

        @Override
        public String formatTimestamp(@Nonnull String timestamp) {
            return formatter.format(parseTestTimestamp(timestamp).withZoneSameInstant(timeZone));
        }

        @Nonnull
        @Override
        public Matcher<String> matchesTimestamp(@Nonnull String timestamp) {
            return matchesTimestamp(parseTestTimestamp(timestamp));
        }

        @Nonnull
        @Override
        public Matcher<String> matchesTimestamp(@Nonnull ZonedDateTime zonedDateTime) {
            //Move date into the correct TZ. All the formats only output up until minutes so
            //we truncate to that value.
            String expectedTime = formatter.format(zonedDateTime.withZoneSameInstant(timeZone).truncatedTo(ChronoUnit.MINUTES));

            return new TypeSafeDiagnosingMatcher<String>() {
                @Override
                protected boolean matchesSafely(String item, Description mismatchDescription) {
                    //We compare against the formatted string so that we can do this case insensitive compare.
                    //The CSS attributes on some of the dates can mean the date will come to us altered in some
                    //way (e.g. MARCH instead of March). This means it will fail to parse directly using our
                    //pattern. To get around this we compare the formatted strings instead.
                    if (item.equalsIgnoreCase(expectedTime)) {
                        return true;
                    } else {
                        mismatchDescription.appendValue(item);
                        return false;
                    }
                }

                @Override
                public void describeTo(Description description) {
                    description.appendValue(expectedTime);
                }
            };
        }

        @Nonnull
        @Override
        public FormatType withTimeZone(@Nonnull ZoneId id) {
            if (timeZone.equals(id)) {
                return this;
            } else {
                return new DateTimeFormatType(formatter, id);
            }
        }

        @Nonnull
        @Override
        public FormatType withTimeZone(@Nonnull Optional<? extends ZoneId> id) {
            if (id.isPresent()) {
                return withTimeZone(id.get());
            } else {
                return withTimeZone(ZoneId.systemDefault());
            }
        }
    }
}
