package teammates.common.util;

import java.lang.reflect.Field;
import java.time.DateTimeException;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
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.zone.ZoneRulesProvider;
import java.util.Locale;

import teammates.common.exception.TeammatesException;
import teammates.common.util.Const.SystemParams;

/**
 * A helper class to hold time-related functions (e.g., converting dates to strings etc.).
 *
 * <p>Time zone is assumed as UTC unless specifically mentioned.
 */
public final class TimeHelper {

    private static final Logger log = Logger.getLogger();

    private TimeHelper() {
        // utility class
    }

    /**
     * Registers the zone rules loaded from resources via {@link TzdbResourceZoneRulesProvider}.
     * Some manipulation of the system class loader is required to enable loading of a custom
     * {@link ZoneRulesProvider} in GAE.
     */
    public static void registerResourceZoneRules() {
        try {
            ClassLoader originalScl = ClassLoader.getSystemClassLoader();

            // ZoneRulesProvider uses the system class loader for loading a custom provider as the default provider.
            // However, GAE's system class loader includes only the Java runtime and not the application. Hence, we
            // use reflection to temporarily replace the system class loader with the class loader for the current context.
            Field scl = ClassLoader.class.getDeclaredField("scl");
            scl.setAccessible(true);
            scl.set(null, Thread.currentThread().getContextClassLoader());

            // ZoneRulesProvider reads this system property to determine which provider to use as the default.
            System.setProperty("java.time.zone.DefaultZoneRulesProvider",
                    TzdbResourceZoneRulesProvider.class.getCanonicalName());

            // This first reference to ZoneRulesProvider executes the class's static initialization block,
            // performing the actual registration of our custom provider named in the system property above.
            // The system class loader is used to load the class from the name.
            // If any exceptions occur, an Error is thrown.
            log.info("Registered zone rules version " + ZoneRulesProvider.getVersions("UTC").firstKey());

            // Restore the original system class loader.
            scl.set(null, originalScl);

        } catch (ReflectiveOperationException | Error e) {
            log.severe("Failed to register zone rules: " + TeammatesException.toStringWithStackTrace(e));
        }
    }

    /**
     * Returns an Instant that is offset by a number of days from now.
     *
     * @param offsetInDays integer number of days to offset by
     * @return an Instant offset by {@code offsetInDays} days
     */
    public static Instant getInstantDaysOffsetFromNow(long offsetInDays) {
        return Instant.now().plus(Duration.ofDays(offsetInDays));
    }

    /**
     * Converts the {@code localDateTime} to {@code Instant} using the {@code timeZone}.
     */
    public static Instant convertLocalDateTimeToInstant(LocalDateTime localDateTime, ZoneId timeZone) {
        return localDateTime == null ? null : localDateTime.atZone(timeZone).toInstant();
    }

    /**
     * Converts the {@code Instant} at the specified {@code timeZone} to {@code localDateTime}.
     */
    public static LocalDateTime convertInstantToLocalDateTime(Instant instant, ZoneId timeZoneId) {
        return instant == null ? null : instant.atZone(timeZoneId).toLocalDateTime();
    }

    /**
     * Formats a datetime stamp from a {@code LocalDateTime} using a formatting pattern.
     *
     * <p>Note: a formatting pattern containing 'a' (for the period; AM/PM) is treated differently at noon/midday.
     * Using that pattern with a datetime whose time falls on "12:00 PM" will cause it to be formatted as "12:00 NOON".</p>
     *
     * @param localDateTime the LocalDateTime to be formatted
     * @param pattern       formatting pattern, see Oracle docs for DateTimeFormatter for pattern table
     * @return the formatted datetime stamp string
     */
    private static String formatLocalDateTime(LocalDateTime localDateTime, String pattern) {
        if (localDateTime == null || pattern == null) {
            return "";
        }
        String processedPattern = pattern;
        if (localDateTime.getHour() == 12 && localDateTime.getMinute() == 0) {
            processedPattern = pattern.replace("a", "'NOON'");
        }
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern(processedPattern);
        return localDateTime.format(formatter);
    }

    /**
     * Formats a datetime stamp from a {@code localDateTime}.
     * Example: Sun, 01 Apr 2018, 12:01 PM
     *
     * <p>Note: a datetime with time "12:00 PM" is specially formatted to "12:00 NOON"
     * Example: Sun, 01 Apr 2018, 12:00 NOON</p>
     *
     * @param localDateTime the LocalDateTime to be formatted
     * @return the formatted datetime stamp string
     */
    public static String formatDateTimeForDisplay(LocalDateTime localDateTime) {
        return formatLocalDateTime(localDateTime, "EEE, dd MMM yyyy, hh:mm a");
    }

    /**
     * Formats a datetime stamp from an {@code instant} including time zone name.
     * Example: Sun, 01 Apr 2018, 11:21 PM SGT
     *
     * <p>Note: a datetime with time "12:00 PM" is specially formatted to "12:00 NOON"
     * Example: Sun, 01 Apr 2018, 12:00 NOON SGT</p>
     *
     * @param instant         the instant to be formatted
     * @param sessionTimeZone the time zone to compute local datetime
     * @return the formatted datetime stamp string
     */
    public static String formatDateTimeForDisplay(Instant instant, ZoneId sessionTimeZone) {
        return formatInstant(instant, sessionTimeZone, "EEE, dd MMM yyyy, hh:mm a z");
    }

    /**
     * Formats a date stamp from a {@code localDateTime} for populating the sessions form.
     * Example: Sun, 01 Apr, 2018
     *
     * <p>This method discards the time stored in the {@code localDateTime}.</p>
     *
     * @param localDateTime the LocalDateTime to be formatted
     * @return the formatted date stamp string
     */
    public static String formatDateForSessionsForm(LocalDateTime localDateTime) {
        return formatLocalDateTime(localDateTime, "EEE, dd MMM, yyyy");
    }

    /**
     * Formats a short datetime stamp from a {@code localDateTime} for the instructor's home page.
     * Example: 5 Apr 12:01 PM
     *
     * <p>Note: a datetime with time "12:00 PM" is specially formatted to "12:00 NOON"
     * Example: 5 Apr 12:01 NOON</p>
     *
     * @param localDateTime the LocalDateTime to be formatted
     * @return the formatted datetime stamp string
     */
    public static String formatDateTimeForInstructorHomePage(LocalDateTime localDateTime) {
        return formatLocalDateTime(localDateTime, "d MMM h:mm a");
    }

    /**
     * Convenience method to perform {@link #adjustLocalDateTimeForSessionsFormInputs} followed by
     * {@link #formatDateForSessionsForm} on a {@link LocalDateTime}.
     * @see #adjustAndFormatDateForSessionsFormInputs
     * @see #formatDateForSessionsForm
     */
    public static String adjustAndFormatDateForSessionsFormInputs(LocalDateTime localDateTime) {
        return formatDateForSessionsForm(adjustLocalDateTimeForSessionsFormInputs(localDateTime));
    }

    /**
     * Returns a copy of the {@link LocalDateTime} adjusted to be compatible with the format output by
     * {@link #parseDateTimeFromSessionsForm}, i.e. either the time is 23:59, or the minute is 0 and the hour is not 0.
     * The date time is first rounded to the nearest hour, then the special case 00:00 is handled.
     * @param ldt The {@link LocalDateTime} to be adjusted for compatibility.
     * @return a copy of {@code ldt} adjusted for compatibility, or null if {@code ldt} is null.
     * @see #parseDateTimeFromSessionsForm
     */
    public static LocalDateTime adjustLocalDateTimeForSessionsFormInputs(LocalDateTime ldt) {
        if (ldt == null) {
            return null;
        }
        if (ldt.getMinute() == 0 && ldt.getHour() != 0 || ldt.getMinute() == 59 && ldt.getHour() == 23) {
            return ldt;
        }

        // Round to the nearest hour
        LocalDateTime rounded;
        LocalDateTime floor = ldt.truncatedTo(ChronoUnit.HOURS);
        LocalDateTime ceiling = floor.plusHours(1);
        Duration distanceToCeiling = Duration.between(ldt, ceiling);
        if (distanceToCeiling.compareTo(Duration.ofMinutes(30)) <= 0) {
            rounded = ceiling;
        } else {
            rounded = floor;
        }

        // Adjust 00:00 -> 23:59
        if (rounded.getHour() == 0) {
            return rounded.minusMinutes(1);
        }
        return rounded;
    }

    /**
     * Formats a datetime stamp from an {@code instant} using a formatting pattern.
     *
     * <p>Note: a formatting pattern containing 'a' (for the period; AM/PM) is treated differently at noon/midday.
     * Using that pattern with a datetime whose time falls on "12:00 PM" will cause it to be formatted as "12:00 NOON".</p>
     *
     * @param instant  the instant to be formatted
     * @param timeZone the time zone to compute local datetime
     * @param pattern  formatting pattern, see Oracle docs for DateTimeFormatter for pattern table
     * @return the formatted datetime stamp string
     */
    private static String formatInstant(Instant instant, ZoneId timeZone, String pattern) {
        if (instant == null || timeZone == null || pattern == null) {
            return "";
        }
        ZonedDateTime zonedDateTime = instant.atZone(timeZone);
        String processedPattern = pattern;
        if (zonedDateTime.getHour() == 12 && zonedDateTime.getMinute() == 0) {
            processedPattern = pattern.replace("a", "'NOON'");
        }
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern(processedPattern);
        return zonedDateTime.format(formatter);
    }

    /**
     * Formats a datetime stamp from an {@code instant} including time zone name and offset.
     * Example: Sun, 01 Apr 2018, 11:23 PM SGT (UTC+0800)
     *
     * <p>Note: a datetime with time "12:00 PM" is specially formatted to "12:00 NOON"
     * Example: Sun, 01 Apr 2018, 12:00 NOON SGT (UTC+0800)</p>
     *
     * @param instant the interpreted instant to be formatted
     * @param zone    the time zone to compute local datetime
     * @return the formatted datetime stamp string
     */
    public static String formatDateTimeForDisplayFull(Instant instant, ZoneId zone) {
        return formatInstant(instant, zone, "EEE, dd MMM yyyy, hh:mm a z ('UTC'Z)");
    }

    /**
     * Formats a date stamp from an {@code instant} for the instructor's pages.
     * Example: 5 May 2017
     *
     * @param instant the instant to be formatted
     * @param zoneId  the time zone to calculate local date
     * @return the formatted date stamp string
     */
    public static String formatDateForInstructorPages(Instant instant, ZoneId zoneId) {
        return formatInstant(instant, zoneId, "d MMM yyyy");
    }

    /**
     * Formats {@code instant} using the ISO8601 format in UTC.
     * Example: 2011-12-03T10:15:30Z
     *
     * <p>Used to inject a standardized date into date elements in Teammates for sortable tables.
     * Should not be used for anything user-facing.</p>
     *
     * @param instant the instant to be formatted
     * @return the formatted datetime ISO8601 stamp in UTC
     */
    public static String formatDateTimeToIso8601Utc(Instant instant) {
        return instant == null ? null : DateTimeFormatter.ISO_INSTANT.format(instant);
    }

    /**
     * Returns whether the given {@code instant} is being used as a special representation, signifying its face value
     * should not be used without proper processing.
     *
     * <p>A {@code null} instant is not a special time.</p>
     *
     * @param instant the instant to test
     * @return {@code true} if the given instant is used as a special representation, {@code false} otherwise
     */
    public static boolean isSpecialTime(Instant instant) {
        if (instant == null) {
            return false;
        }

        return instant.equals(Const.TIME_REPRESENTS_FOLLOW_OPENING)
                || instant.equals(Const.TIME_REPRESENTS_FOLLOW_VISIBLE)
                || instant.equals(Const.TIME_REPRESENTS_LATER)
                || instant.equals(Const.TIME_REPRESENTS_NOW);
    }

    /**
     * Parses an {@code Instant} object from a datetime string in the {@link SystemParams#DEFAULT_DATE_TIME_FORMAT}.
     *
     * @param dateTimeString should be in the format {@link SystemParams#DEFAULT_DATE_TIME_FORMAT}
     * @return the parsed {@code Instant} object
     * @throws AssertionError if there is a parsing error
     */
    public static Instant parseInstant(String dateTimeString) {
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern(SystemParams.DEFAULT_DATE_TIME_FORMAT, Locale.ROOT);
        try {
            return ZonedDateTime.parse(dateTimeString, formatter).toInstant();
        } catch (DateTimeParseException e) {
            Assumption.fail("Date in String is in wrong format.");
            return null;
        }
    }

    /**
     * Parses a {@code LocalDate} object from a date string and parsing pattern.
     *
     * @param dateString the string containing the date
     * @param pattern    the parsing pattern of the datetime string
     * @return the parsed {@code LocalDate} object, or {@code null} if there are errors
     */
    public static LocalDate parseLocalDate(String dateString, String pattern) {
        if (dateString == null || pattern == null) {
            return null;
        }

        DateTimeFormatter formatter = DateTimeFormatter.ofPattern(pattern);
        try {
            return LocalDate.parse(dateString, formatter);
        } catch (DateTimeParseException e) {
            return null;
        }
    }

    /**
     * Parses a {@code LocalDate} object from a date string.
     * Example: date "Tue, 01 Apr, 2014"
     *
     * @param date date in format "EEE, dd MMM, yyyy"
     * @return the parsed {@code LocalDate} object, or {@code null} if there are errors
     */
    public static LocalDate parseDateFromSessionsForm(String date) {
        return parseLocalDate(date, "EEE, dd MMM, yyyy");
    }

    /**
     * Parses a {@code LocalDateTime} object from separated date, hour and minute strings.
     * Example: date "Tue, 01 Apr, 2014", hour "23", min "59"
     *
     * @param date date in format "EEE, dd MMM, yyyy"
     * @param hour hour-of-day (0-23)
     * @param min  minute-of-hour (0-59)
     * @return the parsed {@code LocalDateTime} object, or {@code null} if there are errors
     */
    public static LocalDateTime parseDateTimeFromSessionsForm(String date, String hour, String min) {
        LocalDate localDate = parseDateFromSessionsForm(date);
        if (localDate == null) {
            return null;
        }
        if (hour == null || min == null) {
            return null;
        }
        try {
            return localDate.atTime(Integer.parseInt(hour), Integer.parseInt(min));
        } catch (DateTimeException | NumberFormatException e) {
            return null;
        }
    }

    /**
     * Parses a date string and a time string of only the hour into a LocalDateTime object.
     * If the {@code inputTimeHours} is "24", it is converted to "23:59".
     *
     * @param inputDate      date in format "EEE, dd MMM, yyyy"
     * @param inputTimeHours hour-of-day (0-24)
     * @return the parsed {@code LocalDateTime} at the specified date and hour, or null for invalid parameters
     */
    public static LocalDateTime parseDateTimeFromSessionsForm(String inputDate, String inputTimeHours) {
        if ("24".equals(inputTimeHours)) {
            return parseDateTimeFromSessionsForm(inputDate, "23", "59");
        }
        return parseDateTimeFromSessionsForm(inputDate, inputTimeHours, "0");
    }

}