/**
 * Date helpers for the lesson calendar.
 *
 * Two kinds of value travel through this module:
 * - an **ISO day** (`'2026-10-06'`), a civil date with no time and no zone;
 * - an **instant** (`'2026-10-06T09:30:00+02:00'`), a precise moment sent by the API.
 *
 * Civil days are the unit the calendar navigates by, so all day arithmetic runs on
 * UTC midnight tokens: adding a day is always 86 400 000 ms and never trips on the
 * Italian DST switch. Instants are read in Europe/Rome, the timezone the app works in,
 * so a lesson lands on the same day for a user abroad as it does for the office.
 */

const TIME_ZONE = 'Europe/Rome';

const DAY_MS = 86_400_000;

const isoDayPattern = /^\d{4}-\d{2}-\d{2}$/;

const romeDayFormatter = new Intl.DateTimeFormat('it-IT', {
    timeZone: TIME_ZONE,
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
});

const romeTimeFormatter = new Intl.DateTimeFormat('it-IT', {
    timeZone: TIME_ZONE,
    hour: '2-digit',
    minute: '2-digit',
    hourCycle: 'h23',
});

const monthYearFormatter = new Intl.DateTimeFormat('it-IT', {
    timeZone: 'UTC',
    month: 'long',
    year: 'numeric',
});

const dayLabelFormatter = new Intl.DateTimeFormat('it-IT', {
    timeZone: 'UTC',
    weekday: 'long',
    day: 'numeric',
    month: 'long',
});

const shortDayFormatter = new Intl.DateTimeFormat('it-IT', {
    timeZone: 'UTC',
    day: 'numeric',
    month: 'short',
});

const weekdayLongFormatter = new Intl.DateTimeFormat('it-IT', {
    timeZone: 'UTC',
    weekday: 'long',
});

const weekdayShortFormatter = new Intl.DateTimeFormat('it-IT', {
    timeZone: 'UTC',
    weekday: 'short',
});

/** A civil date with no time part, e.g. `'2026-10-06'`. */
export type IsoDay = string;

function partsOf(
    formatter: Intl.DateTimeFormat,
    date: Date,
): Record<string, string> {
    const parts: Record<string, string> = {};

    for (const part of formatter.formatToParts(date)) {
        parts[part.type] = part.value;
    }

    return parts;
}

/** True when the string is a bare civil date rather than a full instant. */
export function isIsoDay(value: string): boolean {
    return isoDayPattern.test(value);
}

/** Turns an ISO day into the UTC-midnight token used for day arithmetic. */
export function parseIsoDay(day: IsoDay): Date {
    const [year, month, date] = day.split('-').map(Number);

    return new Date(Date.UTC(year, month - 1, date));
}

/** Serialises a UTC-midnight token back to `'YYYY-MM-DD'`. */
export function formatIsoDay(token: Date): IsoDay {
    const year = String(token.getUTCFullYear()).padStart(4, '0');
    const month = String(token.getUTCMonth() + 1).padStart(2, '0');
    const date = String(token.getUTCDate()).padStart(2, '0');

    return `${year}-${month}-${date}`;
}

/** The civil day an instant falls on in Rome, e.g. `'2026-10-06'`. */
export function dayOfInstant(instant: string): IsoDay {
    if (isIsoDay(instant)) {
        return instant;
    }

    const parts = partsOf(romeDayFormatter, new Date(instant));

    return `${parts.year}-${parts.month}-${parts.day}`;
}

/** Today's civil day in Rome. */
export function todayIso(): IsoDay {
    return dayOfInstant(new Date().toISOString());
}

/** Shifts a civil day by whole days; negative counts move backwards. */
export function addDays(day: IsoDay, count: number): IsoDay {
    return formatIsoDay(new Date(parseIsoDay(day).getTime() + count * DAY_MS));
}

/** Shifts a civil day by whole months, clamping to the last day of a short month. */
export function addMonths(day: IsoDay, count: number): IsoDay {
    const token = parseIsoDay(day);
    const year = token.getUTCFullYear();
    const month = token.getUTCMonth() + count;
    const date = token.getUTCDate();
    const lastDayOfTarget = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();

    return formatIsoDay(
        new Date(Date.UTC(year, month, Math.min(date, lastDayOfTarget))),
    );
}

/** The Monday of the week containing the given civil day. */
export function startOfWeek(day: IsoDay): IsoDay {
    const token = parseIsoDay(day);
    const weekday = token.getUTCDay();
    const offset = weekday === 0 ? -6 : 1 - weekday;

    return addDays(day, offset);
}

/** The Sunday of the week containing the given civil day. */
export function endOfWeek(day: IsoDay): IsoDay {
    return addDays(startOfWeek(day), 6);
}

/** The first civil day of the month containing the given day. */
export function startOfMonth(day: IsoDay): IsoDay {
    return `${day.slice(0, 7)}-01`;
}

/** The last civil day of the month containing the given day. */
export function endOfMonth(day: IsoDay): IsoDay {
    const token = parseIsoDay(day);

    return formatIsoDay(
        new Date(Date.UTC(token.getUTCFullYear(), token.getUTCMonth() + 1, 0)),
    );
}

/** The full weeks covering a month, from the leading Monday to the trailing Sunday. */
export function monthGridRange(day: IsoDay): { from: IsoDay; to: IsoDay } {
    return {
        from: startOfWeek(startOfMonth(day)),
        to: endOfWeek(endOfMonth(day)),
    };
}

/** Every civil day from `from` to `to`, both ends included. */
export function eachDay(from: IsoDay, to: IsoDay): IsoDay[] {
    const days: IsoDay[] = [];
    const last = parseIsoDay(to).getTime();

    for (
        let cursor = parseIsoDay(from).getTime();
        cursor <= last;
        cursor += DAY_MS
    ) {
        days.push(formatIsoDay(new Date(cursor)));
    }

    return days;
}

/**
 * True when two values fall on the same civil day in Rome. Either side may be a
 * civil day or a full instant, so a lesson can be compared against a grid cell.
 */
export function sameDay(left: string, right: string): boolean {
    return dayOfInstant(left) === dayOfInstant(right);
}

/** True when both civil days belong to the same calendar month. */
export function sameMonth(left: IsoDay, right: IsoDay): boolean {
    return left.slice(0, 7) === right.slice(0, 7);
}

/** True for Saturday and Sunday. */
export function isWeekend(day: IsoDay): boolean {
    const weekday = parseIsoDay(day).getUTCDay();

    return weekday === 0 || weekday === 6;
}

/** The day number on its own, e.g. `6`. */
export function dayNumber(day: IsoDay): number {
    return parseIsoDay(day).getUTCDate();
}

/** A full day label, e.g. `'lunedì 6 ottobre'`. */
export function formatDay(day: IsoDay): string {
    return dayLabelFormatter.format(parseIsoDay(day));
}

/** A compact day label, e.g. `'6 ott'`. */
export function formatDayShort(day: IsoDay): string {
    return shortDayFormatter.format(parseIsoDay(day));
}

/** The weekday name, e.g. `'lunedì'` or `'lun'`. */
export function formatWeekday(day: IsoDay, style: 'long' | 'short'): string {
    return style === 'long'
        ? weekdayLongFormatter.format(parseIsoDay(day))
        : weekdayShortFormatter.format(parseIsoDay(day));
}

/** The month and year of a civil day, e.g. `'ottobre 2026'`. */
export function formatMonthYear(day: IsoDay): string {
    return monthYearFormatter.format(parseIsoDay(day));
}

/** The Rome wall-clock time of an instant, e.g. `'09:30'`. */
export function formatTime(instant: string): string {
    return romeTimeFormatter.format(new Date(instant));
}

/** Minutes elapsed since Rome midnight, used to position a block in the week grid. */
export function minutesOfDay(instant: string): number {
    const parts = partsOf(romeTimeFormatter, new Date(instant));

    return Number(parts.hour) * 60 + Number(parts.minute);
}

/** A range label for the period title, e.g. `'5 – 11 ottobre 2026'`. */
export function formatDayRange(from: IsoDay, to: IsoDay): string {
    if (sameMonth(from, to)) {
        return `${dayNumber(from)} – ${dayNumber(to)} ${formatMonthYear(from)}`;
    }

    return `${formatDayShort(from)} – ${formatDayShort(to)} ${parseIsoDay(to).getUTCFullYear()}`;
}
