/**
 * Copyright 2011-2021 Asakusa Framework Team.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.asakusafw.runtime.value;

import java.util.Calendar;

/**
 * Utilities about date and time.
 * @since 0.1.0
 * @version 0.7.0
 */
public final class DateUtil {

    private static final ThreadLocal<Calendar> CALENDAR_CACHE = ThreadLocal.withInitial(Calendar::getInstance);

    private static final int DAYS_YEAR = 365;

    private static final int DAYS_JANUARY = 31;
    private static final int DAYS_FEBRUARY = DAYS_JANUARY + 28;
    private static final int DAYS_MARCH = DAYS_FEBRUARY + 31;
    private static final int DAYS_APRIL = DAYS_MARCH + 30;
    private static final int DAYS_MAY = DAYS_APRIL + 31;
    private static final int DAYS_JUNE = DAYS_MAY + 30;
    private static final int DAYS_JULY = DAYS_JUNE + 31;
    private static final int DAYS_AUGUST = DAYS_JULY + 31;
    private static final int DAYS_SEPTEMBER = DAYS_AUGUST + 30;
    private static final int DAYS_OCTOBER = DAYS_SEPTEMBER + 31;
    private static final int DAYS_NOVEMBER = DAYS_OCTOBER + 30;

    private static final int[] DAYS_MONTH = {
        0,
        DAYS_JANUARY,
        DAYS_FEBRUARY,
        DAYS_MARCH,
        DAYS_APRIL,
        DAYS_MAY,
        DAYS_JUNE,
        DAYS_JULY,
        DAYS_AUGUST,
        DAYS_SEPTEMBER,
        DAYS_OCTOBER,
        DAYS_NOVEMBER,
    };

    private static final int YEARS_LEAP_CYCLE = 400;

    private static final int DAYS_LEAP_CYCLE =
        DAYS_YEAR * YEARS_LEAP_CYCLE
        + (YEARS_LEAP_CYCLE / 4)
        - (YEARS_LEAP_CYCLE / 100)
        + (YEARS_LEAP_CYCLE / 400);

    private static final int YEARS_CENTURY = 100;

    private static final int DAYS_CENTURY =
        DAYS_YEAR * YEARS_CENTURY
        + (YEARS_CENTURY / 4)
        - (YEARS_CENTURY / 100)
        + (YEARS_CENTURY / 400);

    private static final int YEARS_LEAP = 4;

    private static final int DAYS_LEAP =
        DAYS_YEAR * YEARS_LEAP
        + (YEARS_LEAP / 4)
        - (YEARS_LEAP / 100)
        + (YEARS_LEAP / 400);

    /**
     * Returns a date as the number of elapsed days from {@code 0001/01/01 (YYYY/MM/DD)}.
     * @param year year
     * @param month month (1-12)
     * @param day day (1-31)
     * @return the number of elapsed days (0-origin)
     */
    public static int getDayFromDate(int year, int month, int day) {
        int result = 0;
        result += getDayFromYear(year);
        result += DAYS_MONTH[month - 1];
        result += day - 1;
        if (month >= 3 && isLeap(year)) {
            result += 1;
        }
        return result;
    }

    /**
     * Returns a date as the number of elapsed days from {@code 0001/01/01 (YYYY/MM/DD)}.
     * @param calendar the target calendar object
     * @return the number of elapsed days (0-origin)
     * @since 0.2.2
     */
    public static int getDayFromCalendar(Calendar calendar) {
        int year = calendar.get(Calendar.YEAR);
        int month = calendar.get(Calendar.MONTH) + 1;
        int day = calendar.get(Calendar.DAY_OF_MONTH);
        return getDayFromDate(year, month, day);
    }

    /**
     * Returns a date as the number of elapsed days from {@code 0001/01/01 (YYYY/MM/DD)}.
     * @param date the target date object
     * @return the number of elapsed days (0-origin)
     * @since 0.7.0
     */
    public static int getDayFromDate(java.util.Date date) {
        return getDayFromCalendar(toCachedCalendar(date));
    }

    /**
     * Converts the number of elapsed days from {@code 0001/01/01 (YYYY/MM/DD)} to the corresponded date,
     * and set it to the target calendar object.
     * Note that, the hour, minute, second and millisecond fields will be set to {@code 0}.
     * @param days the number of elapsed days from {@code 0001/01/01 (YYYY/MM/DD)}
     * @param calendar the target calendar object
     * @since 0.2.2
     */
    public static void setDayToCalendar(int days, Calendar calendar) {
        int year = getYearFromDay(days);
        int daysInYear = days - getDayFromYear(year);
        boolean leap = isLeap(year);
        int month = getMonthOfYear(daysInYear, leap);
        int day = getDayOfMonth(daysInYear, leap);
        calendar.set(year, month - 1, day, 0, 0, 0);
        calendar.set(Calendar.MILLISECOND, 0);
    }

    /**
     * Converts the time as the number of elapsed seconds from {@code 00:00:00}.
     * @param hour hour
     * @param minute minute
     * @param second second
     * @return the number of elapsed seconds (0-origin)
     */
    public static int getSecondFromTime(int hour, int minute, int second) {
        int result = 0;
        result += hour * 60 * 60;
        result += minute * 60;
        result += second;
        return result;
    }

    /**
     * Converts the date and time as the number of elapsed seconds from {@code 0001/01/01 (YYYY/MM/DD) 00:00:00}.
     * @param calendar the target calendar object
     * @return the number of elapsed seconds (0-origin)
     * @since 0.2.2
     */
    public static long getSecondFromCalendar(Calendar calendar) {
        int days = getDayFromCalendar(calendar);
        long result = (long) days * 86400;
        result += calendar.get(Calendar.HOUR_OF_DAY) * 60 * 60;
        result += calendar.get(Calendar.MINUTE) * 60;
        result += calendar.get(Calendar.SECOND);
        return result;
    }

    /**
     * Converts the date and time as the number of elapsed seconds from {@code 0001/01/01 (YYYY/MM/DD) 00:00:00}.
     * @param date the target date object
     * @return the number of elapsed seconds (0-origin)
     * @since 0.7.0
     */
    public static long getSecondFromDate(java.util.Date date) {
        return getSecondFromCalendar(toCachedCalendar(date));
    }

    private static Calendar toCachedCalendar(java.util.Date date) {
        Calendar work = CALENDAR_CACHE.get();
        work.setTime(date);
        return work;
    }

    /**
     * Converts the number of elapsed seconds from {@code 0001/01/01 (YYYY/MM/DD) 00:00:00} to the corresponded date,
     * and set it to the target calendar object.
     * Note that, the millisecond fields will be set to {@code 0}.
     * @param seconds the number of elapsed seconds
     * @param calendar the target calendar object
     * @since 0.2.2
     */
    public static void setSecondToCalendar(long seconds, Calendar calendar) {
        int days = getDayFromSeconds(seconds);
        int year = getYearFromDay(days);
        int daysInYear = days - getDayFromYear(year);
        boolean leap = isLeap(year);
        int month = getMonthOfYear(daysInYear, leap);
        int day = getDayOfMonth(daysInYear, leap);

        int rest = getSecondOfDay(seconds);
        int hour = rest / (60 * 60);
        int minute = rest / 60 % 60;
        int second = rest % 60;

        calendar.set(year, month - 1, day, hour, minute, second);
        calendar.set(Calendar.MILLISECOND, 0);
    }

    /**
     * Converts the number of elapsed days from {@code 0001/01/01 (YYYY/MM/DD)} to the corresponding year.
     * @param days the number of elapsed days
     * @return the corresponded year
     */
    public static int getYearFromDay(int days) {

        // the number of leap year cycles (400years)
        int cycles = days / DAYS_LEAP_CYCLE;
        int cycleRest = days % DAYS_LEAP_CYCLE;

        // the century offset in the current leap year cycle (0-3)
        int centInCycle = cycleRest / DAYS_CENTURY;
        int centRest = cycleRest % DAYS_CENTURY;
        centRest += DAYS_CENTURY * (centInCycle / (YEARS_LEAP_CYCLE / YEARS_CENTURY));
        centInCycle -= (centInCycle / (YEARS_LEAP_CYCLE / YEARS_CENTURY));

        // the leap year offset in the current century (0-24)
        int leapInCent = centRest / DAYS_LEAP;
        int leapRest = centRest % DAYS_LEAP;

        // the year offset since the last leap year (0-3)
        int yearInLeap = leapRest / DAYS_YEAR;
        yearInLeap -= (yearInLeap / YEARS_LEAP);

        // compute the year
        int year = YEARS_LEAP_CYCLE * cycles
            + YEARS_CENTURY * centInCycle
            + YEARS_LEAP * leapInCent
            + yearInLeap
            + 1;

        return year;
    }

    /**
     * Whether the target year is leap year or not.
     * @param year the target year
     * @return {@code true} if the target year is leap year, otherwise {@code false}
     */
    public static boolean isLeap(int year) {
        if (year % 4 != 0) {
            return false;
        }
        return (year % 100) != 0 || (year % 400) == 0;
    }

    /**
     * Converts the year to the number of elapsed days from {@code 0001/01/01 (YYYY/MM/DD)} about
     * the first day of the specified year.
     * For example, {@code getDayFromYear(1)} returns just {@code 0}.
     * @param year the target year
     * @return the number of elapsed days about the first day of the specified year
     */
    public static int getDayFromYear(int year) {
        int y = year - 1;
        return DAYS_YEAR * y + (y / 4) - (y / 100) + (y / 400);
    }

    /**
     * Converts the number of elapsed days from beginning of the year to the corresponding month.
     * @param dayOfYear the number of elapsed days from beginning of the year
     * @param leap whether the target year is leap year or not
     * @return the month including the target day (<em>1-origin</em>)
     */
    public static int getMonthOfYear(int dayOfYear, boolean leap) {
        int d = dayOfYear;
        if (d < DAYS_JANUARY) {
            return 1;
        }
        if (leap) {
            d--;
        }
        if (d < DAYS_FEBRUARY) {
            return 2;
        }
        if (d < DAYS_MARCH) {
            return 3;
        }
        if (d < DAYS_APRIL) {
            return 4;
        }
        if (d < DAYS_MAY) {
            return 5;
        }
        if (d < DAYS_JUNE) {
            return 6;
        }
        if (d < DAYS_JULY) {
            return 7;
        }
        if (d < DAYS_AUGUST) {
            return 8;
        }
        if (d < DAYS_SEPTEMBER) {
            return 9;
        }
        if (d < DAYS_OCTOBER) {
            return 10;
        }
        if (d < DAYS_NOVEMBER) {
            return 11;
        }
        return 12;
    }

    /**
     * Converts the number of elapsed days from beginning of the year to the date in the current month.
     * @param dayOfYear the number of elapsed days from beginning of the year
     * @param leap whether the target year is leap year or not
     * @return the date in the current month (1-origin)
     */
    public static int getDayOfMonth(int dayOfYear, boolean leap) {
        int d = dayOfYear;
        if (d < DAYS_JANUARY) {
            return d + 1;
        }
        if (d < DAYS_FEBRUARY) {
            return d - (DAYS_JANUARY - 1);
        }
        if (leap) {
            if (d == DAYS_FEBRUARY) {
                return 29;
            }
            d--;
        }
        if (d < DAYS_MARCH) {
            return d - (DAYS_FEBRUARY - 1);
        }
        if (d < DAYS_APRIL) {
            return d - (DAYS_MARCH - 1);
        }
        if (d < DAYS_MAY) {
            return d - (DAYS_APRIL - 1);
        }
        if (d < DAYS_JUNE) {
            return d - (DAYS_MAY - 1);
        }
        if (d < DAYS_JULY) {
            return d - (DAYS_JUNE - 1);
        }
        if (d < DAYS_AUGUST) {
            return d - (DAYS_JULY - 1);
        }
        if (d < DAYS_SEPTEMBER) {
            return d - (DAYS_AUGUST - 1);
        }
        if (d < DAYS_OCTOBER) {
            return d - (DAYS_SEPTEMBER - 1);
        }
        if (d < DAYS_NOVEMBER) {
            return d - (DAYS_OCTOBER - 1);
        }
        return d - (DAYS_NOVEMBER - 1);
    }

    /**
     * Converts the number of elapsed seconds from {@code 0001/01/01 (YYYY/MM/DD) 00:00:00} to
     * the corresponded date as the number of elapsed days.
     * @param seconds the number of elapsed seconds (0-origin)
     * @return the corresponded number of elapsed days (0-origin)
     * @see #getSecondOfDay(long)
     */
    public static int getDayFromSeconds(long seconds) {
        return (int) (seconds / 86400);
    }

    /**
     * Converts the number of elapsed seconds from {@code 0001/01/01 (YYYY/MM/DD) 00:00:00} to
     * the number of elapsed seconds from beginning of the corresponding date.
     * @param seconds the number of elapsed seconds (0-origin)
     * @return the number of elapsed seconds of the day (0-origin)
     * @see #getDayFromSeconds(long)
     */
    public static int getSecondOfDay(long seconds) {
        return (int) (seconds % 86400);
    }

    private static final int COL_YEAR_BEGIN = 0;

    private static final int COL_YEAR_END = COL_YEAR_BEGIN + 4;

    private static final int COL_MONTH_BEGIN = COL_YEAR_END + 1;

    private static final int COL_MONTH_END = COL_MONTH_BEGIN + 2;

    private static final int COL_DAY_BEGIN = COL_MONTH_END + 1;

    private static final int COL_DAY_END = COL_DAY_BEGIN + 2;

    private static final int COL_HOUR_BEGIN = COL_DAY_END + 1;

    private static final int COL_HOUR_END = COL_HOUR_BEGIN + 2;

    private static final int COL_MINUTE_BEGIN = COL_HOUR_END + 1;

    private static final int COL_MINUTE_END = COL_MINUTE_BEGIN + 2;

    private static final int COL_SECOND_BEGIN = COL_MINUTE_END + 1;

    private static final int COL_SECOND_END = COL_SECOND_BEGIN + 2;

    /**
     * Parses a {@code Date} value.
     * @param value the date value
     * @param dateSegmentSeparator the separator char between each date segment
     * @return the days for {@link Date} object
     * @since 0.7.0
     */
    public static int parseDate(
            CharSequence value,
            char dateSegmentSeparator) {
        int length = value.length();
        if (length == COL_DAY_END
                && is(value, COL_YEAR_END, dateSegmentSeparator)
                && is(value, COL_MONTH_END, dateSegmentSeparator)) {
            int year = parse(value, COL_YEAR_BEGIN, COL_YEAR_END);
            int month = parse(value, COL_MONTH_BEGIN, COL_MONTH_END);
            int day = parse(value, COL_DAY_BEGIN, COL_DAY_END);
            if (year > 0 && month > 0 && day > 0) {
                return DateUtil.getDayFromDate(year, month, day);
            }
        }
        return -1;
    }

    /**
     * Parses a {@code DateTime} value.
     * @param value the date-time value
     * @param dateSegmentSeparator the separator char between each date segment
     * @param dateTimeSeparator the separator char between date and time
     * @param timeSegmentSeparator the separator char between each time segment
     * @return the seconds for {@link DateTime} object
     * @since 0.7.0
     */
    public static long parseDateTime(
            CharSequence value,
            char dateSegmentSeparator,
            char dateTimeSeparator,
            char timeSegmentSeparator) {
        int length = value.length();
        if (length >= COL_SECOND_END
                && is(value, COL_YEAR_END, dateSegmentSeparator)
                && is(value, COL_MONTH_END, dateSegmentSeparator)
                && is(value, COL_DAY_END, dateTimeSeparator)
                && is(value, COL_HOUR_END, timeSegmentSeparator)
                && is(value, COL_MINUTE_END, timeSegmentSeparator)) {
            int year = parse(value, COL_YEAR_BEGIN, COL_YEAR_END);
            int month = parse(value, COL_MONTH_BEGIN, COL_MONTH_END);
            int day = parse(value, COL_DAY_BEGIN, COL_DAY_END);
            int hour = parse(value, COL_HOUR_BEGIN, COL_HOUR_END);
            int minute = parse(value, COL_MINUTE_BEGIN, COL_MINUTE_END);
            int second = parse(value, COL_SECOND_BEGIN, COL_SECOND_END);
            if (year > 0 && month > 0 && day > 0
                    && hour >= 0 && minute >= 0 && second >= 0) {
                long result = DateUtil.getDayFromDate(year, month, day) * 86400L;
                result += DateUtil.getSecondFromTime(hour, minute, second);
                return result;
            }
        }
        return -1;
    }

    private static boolean is(CharSequence string, int column, char value) {
        return string.charAt(column) == value;
    }

    private static int parse(CharSequence string, int begin, int end) {
        int result = 0;
        for (int i = begin; i < end; i++) {
            char c = string.charAt(i);
            if (c < '0' || '9' < c) {
                return -1;
            }
            result = (result * 10) + (c - '0');
        }
        return result;
    }


    /**
     * Appends a string representation of {@link Date}.
     * @param elapsedDays the number of elapsed days from 0001/01/01
     * @param dateSegmentSeparator the separator char between each date segment
     * @param target the target buffer
     * @since 0.7.0
     */
    public static void toDateString(
            int elapsedDays,
            char dateSegmentSeparator,
            StringBuilder target) {
        appendDateString(target, elapsedDays, dateSegmentSeparator);
    }

    /**
     * Appends a string representation of {@link DateTime}.
     * @param elapsedSeconds the number of elapsed seconds from 0001/01/01 00:00:00
     * @param dateSegmentSeparator the separator char between each date segment
     * @param dateTimeSeparator the separator char between date and time
     * @param timeSegmentSeparator the separator char between each time segment
     * @param target the target buffer
     * @since 0.7.0
     */
    public static void toDateTimeString(
            long elapsedSeconds,
            char dateSegmentSeparator,
            char dateTimeSeparator,
            char timeSegmentSeparator,
            StringBuilder target) {
        appendDateString(target, DateUtil.getDayFromSeconds(elapsedSeconds), dateSegmentSeparator);
        target.append(dateTimeSeparator);
        appendTimeString(target, DateUtil.getSecondOfDay(elapsedSeconds), timeSegmentSeparator);
    }

    private static void appendDateString(StringBuilder buf, int days, char separator) {
        int year = DateUtil.getYearFromDay(days);
        boolean leap = DateUtil.isLeap(year);
        int dayInYear = days - DateUtil.getDayFromYear(year);
        int month = DateUtil.getMonthOfYear(dayInYear, leap);
        int day = DateUtil.getDayOfMonth(dayInYear, DateUtil.isLeap(year));

        fill(buf, 4, year);
        buf.append(separator);
        fill(buf, 2, month);
        buf.append(separator);
        fill(buf, 2, day);
    }

    private static void appendTimeString(StringBuilder buf, int seconds, char separator) {
        int hour = seconds / (60 * 60);
        int minute = seconds / 60 % 60;
        int second = seconds % 60;
        fill(buf, 2, hour);
        buf.append(separator);
        fill(buf, 2, minute);
        buf.append(separator);
        fill(buf, 2, second);
    }

    private static void fill(StringBuilder buf, int columns, int value) {
        if (value < 0) {
            buf.append('-');
            if (value == Integer.MIN_VALUE) {
                fill(buf, columns - 2, -(value / 10));
                buf.append('8');
            } else {
                fill(buf, columns - 1, -value);
            }
        } else {
            int required = countColumns(value);
            for (int i = required; i < columns; i++) {
                buf.append('0');
            }
            buf.append(value);
        }
    }

    private static int countColumns(int value) {
        assert value >= 0;
        if (value == 0) {
            return 1;
        }
        double log = Math.log10(value);
        return (int) Math.floor(log) + 1;
    }

    private DateUtil() {
        throw new AssertionError();
    }
}
