/** YYYY */
export type YearKey = `${number}`;
/** YYYY-MM */
export type MonthKey = `${number}-${number}`;
/** YYYY-MM-DD */
export type DateKey = `${MonthKey}-${number}`;
/** YYYY-MM--W (0-3, aligned to month) */
export type WeekKey = `${MonthKey}--${number}`;

/**
 * Represents a day in the Gregorian calendar in the form of [year, month, day].
 * Note that `month` is one-based, not zero-based. As opposed to JS Date's `monthIndex`.
 *
 * I prefer working with Day objects instead of Date objects to avoid all kind of problems 😁
 */
export class Day {
    static readonly LOWEST = new Day(0, 0, 0);
    static readonly HIGHEST = new Day(9999, 12, 31);

    public readonly year: number; // YYYY
    public readonly month: number; // 1-12 ! (its month, not month index)
    public readonly day: number; // 1-31

    constructor(year: number, month: number, day: number) {
        this.year = year;
        this.month = month;
        this.day = day;
    }

    /** Creates a Day from a JS Date object */
    static fromDate(date: Date): Day {
        return new Day(date.getFullYear(), date.getMonth() + 1, date.getDate());
    }

    /** Creates a Day from a time key. If month or day is not provided, it will default to the earliest that matches */
    static fromKey(key: DateKey | WeekKey | MonthKey | YearKey): Day {
        const arr = key.split("-").map(Number);
        switch (arr.length) {
            case 1:
                return new Day(arr[0], 1, 1);
            case 2:
                return new Day(arr[0], arr[1], 1);
            case 3:
                return new Day(arr[0], arr[1], arr[2]);
            default:
                // 0 <= arr[3] <= 3
                return new Day(arr[0], arr[1], arr[3] * 7 + 1);
        }
    }

    /** Creates a Day from a binary number generated by `toBinary` */
    static fromBinary(binary: number): Day {
        const year = binary >>> 9;
        const month = (binary >>> 5) & 0b1111;
        const day = binary & 0b11111;
        return new Day(year, month, day);
    }

    /** Converts the day into a JS Date object */
    toDate(): Date {
        return new Date(this.year, this.month - 1, this.day);
    }

    /** Converts the day into a binary number */
    toBinary(): number {
        return (this.year << 9) | (this.month << 5) | this.day;
    }

    /** Returns the UTC timestamp for this day */
    toTimestamp(): number {
        return this.toDate().getTime();
    }

    get yearKey(): YearKey {
        return `${this.year}`;
    }
    get monthKey(): MonthKey {
        return `${this.year}-${this.month}`;
    }
    get weekKey(): WeekKey {
        const week = Math.floor((this.day - 1) / 7);
        return `${this.year}-${this.month}--${week}`;
    }
    get dateKey(): DateKey {
        return `${this.monthKey}-${this.day}`;
    }

    /** Returns a new Day instance for `days` days into the future */
    nextDays(days: number): Day {
        const d = this.toDate();
        d.setDate(d.getDate() + days);
        return Day.fromDate(d);
    }

    /** Returns a new Day instance for the following day */
    nextDay(): Day {
        return this.nextDays(1);
    }

    /** Equal */
    static eq(a: Day, b: Day): boolean {
        return a.year === b.year && a.month === b.month && a.day === b.day;
    }

    /** Less than */
    static lt(a: Day, b: Day): boolean {
        return (
            a.year < b.year ||
            (a.year === b.year && a.month < b.month) ||
            (a.year === b.year && a.month === b.month && a.day < b.day)
        );
    }

    /** Greater than */
    static gt(a: Day, b: Day): boolean {
        return (
            a.year > b.year ||
            (a.year === b.year && a.month > b.month) ||
            (a.year === b.year && a.month === b.month && a.day > b.day)
        );
    }

    /** Min between two days (past) */
    static min(a: Day, b: Day): Day {
        return Day.lt(a, b) ? a : b;
    }

    /** Max between two days (future) */
    static max(a: Day, b: Day): Day {
        return Day.gt(a, b) ? a : b;
    }

    /** Clamp day between two days */
    static clamp(day: Day, a: Day, b: Day): Day {
        return Day.min(Day.max(day, a), b);
    }
}

export interface TimeKeysResult {
    dateKeys: DateKey[];
    weekKeys: WeekKey[];
    monthKeys: MonthKey[];
    yearKeys: YearKey[];
    // correspondance between dateKey and weekKeys/monthKeys/yearKeys
    dateToWeekIndex: number[];
    dateToMonthIndex: number[];
    dateToYearIndex: number[];
}

/**
 * Generates each time key contained in an interval of time [start, end].
 * Also generates indexes to map between date keys and the rest.
 */
export const genTimeKeys = (start: Day, end: Day): TimeKeysResult => {
    // check start <= end
    if (Day.lt(end, start)) throw new Error("genTimeKeys: start must be before end");

    const onePastEnd = end.nextDay();

    const dateKeys: DateKey[] = [];
    const weekKeys: WeekKey[] = [];
    const monthKeys: MonthKey[] = [];
    const yearKeys: YearKey[] = [];
    const dateToWeekIndex: number[] = [];
    const dateToMonthIndex: number[] = [];
    const dateToYearIndex: number[] = [];

    let day = start;
    while (!Day.eq(day, onePastEnd)) {
        const dateKey = day.dateKey;
        const monthKey = day.monthKey;
        const weekKey = day.weekKey;
        const yearKey = day.yearKey;

        if (weekKeys.length === 0 || weekKeys[weekKeys.length - 1] !== weekKey) weekKeys.push(weekKey);
        if (monthKeys.length === 0 || monthKeys[monthKeys.length - 1] !== monthKey) monthKeys.push(monthKey);
        if (yearKeys.length === 0 || yearKeys[yearKeys.length - 1] !== yearKey) yearKeys.push(yearKey);
        dateKeys.push(dateKey);
        dateToWeekIndex.push(weekKeys.length - 1);
        dateToMonthIndex.push(monthKeys.length - 1);
        dateToYearIndex.push(yearKeys.length - 1);

        day = day.nextDay();
    }

    return {
        dateKeys,
        weekKeys,
        monthKeys,
        yearKeys,
        dateToMonthIndex,
        dateToWeekIndex,
        dateToYearIndex,
    };
};

/**
 * Available human-readable formats
 *
 * Examples for day="2020-12-25", seconds=12345:
 * - y: 2020
 * - ym: December 2020
 * - ymd: December 25, 2020
 * - symd: 12/25/2020
 * - ymdh: December 25, 2020, 03 AM
 * - ymdhm: December 25, 2020, 03:25 AM
 * - ymdhms: December 25, 2020, 03:25:45 AM
 */
export type TimeFormat = "y" | "ym" | "ymd" | "symd" | "ymdh" | "ymdhm" | "ymdhms";

const f: Intl.DateTimeFormatOptions = {
    year: "numeric",
    month: "long",
    day: "2-digit",
    hour: "2-digit",
    minute: "2-digit",
    second: "2-digit",
};

const DateTimeFormatters: {
    [key in TimeFormat]: Intl.DateTimeFormat;
} = {
    y: new Intl.DateTimeFormat(undefined, { year: f.year }),
    ym: new Intl.DateTimeFormat(undefined, { year: f.year, month: f.month }),
    ymd: new Intl.DateTimeFormat(undefined, { year: f.year, month: f.month, day: f.day }),
    ymdh: new Intl.DateTimeFormat(undefined, {
        year: f.year,
        month: f.month,
        day: f.day,
        hour: f.hour,
    }),
    symd: new Intl.DateTimeFormat(undefined, {
        year: "numeric",
        month: "numeric",
        day: "numeric",
    }),
    ymdhm: new Intl.DateTimeFormat(undefined, {
        year: f.year,
        month: f.month,
        day: f.day,
        hour: f.hour,
        minute: f.minute,
    }),
    ymdhms: new Intl.DateTimeFormat(undefined, {
        year: f.year,
        month: f.month,
        day: f.day,
        hour: f.hour,
        minute: f.minute,
        second: f.second,
    }),
};

// we cant test this since it depends on the browser locale
// setting it as a parameter will force us to create a new Intl.DateTimeFormat each call
// istanbul ignore next
/** Format a Day (+seconds) into a human-readable string */
export const formatTime = (format: TimeFormat, day: Day, secondsOfDay: number = 0): string => {
    const d = day.toDate();
    d.setSeconds(secondsOfDay);
    return DateTimeFormatters[format].format(d);
};

/** Point in time */
export interface Datetime {
    key: DateKey | WeekKey | MonthKey | YearKey;
    secondOfDay?: number;
}

/** Format a Datetime into a human-readable string. @see formatTime */
export const formatDatetime = (format: TimeFormat, datetime?: Datetime) => {
    if (datetime === undefined) return "-";

    return formatTime(format, Day.fromKey(datetime.key), datetime.secondOfDay);
};

/** Finds the time difference in seconds between two Datetimes */
export const diffDatetime = (a: Datetime, b: Datetime): number => {
    // Probably this can be done more efficient and be reused (also see formatTime)

    const aDate = Day.fromKey(a.key).toDate();
    if (a.secondOfDay !== undefined) aDate.setSeconds(a.secondOfDay);

    const bDate = Day.fromKey(b.key).toDate();
    if (b.secondOfDay !== undefined) bDate.setSeconds(b.secondOfDay);

    return (bDate.getTime() - aDate.getTime()) / 1000;
};
