/**
 * Calendrier des saisons du yield, côté écran — COPIE de
 * App\Services\Yield\SeasonCalendar (PHP), le moteur des prix. Logique PURE
 * (aucun DOM, aucune horloge : `today` est toujours un paramètre), testée par
 * season-calendar.test.ts.
 *
 * Pourquoi une copie : la frise, la galerie de gabarits et la ligne d'un Aïd
 * doivent montrer EXACTEMENT les nuits que le moteur tarifera. Un écart ne se
 * verrait nulle part — l'écran mentirait. Le fichier de cas
 * tests/Fixtures/yield/season-calendar-cases.json est parcouru par les deux
 * suites (PHPUnit et Vitest) : une règle changée d'un seul côté fait tomber
 * l'autre.
 *
 * Rappel du contrat (détaillé dans SeasonCalendar.php) :
 *  - ponctuelle : [start_date, end_date], bornes incluses, chaînes ISO ;
 *  - annuelle (`repeat: 'yearly'`) : sur le seul MM-JJ, chaque année ; une
 *    année de fin > année de début = à cheval sur le 31/12 ; le 29/02 n'existe
 *    que les années bissextiles ;
 *  - fête mobile (`holiday`) : [fête + offset_start ; fête + offset_end]
 *    autour de chaque date de la table ; année absente = aucun effet ;
 *  - priorité : événement (`layer: 'event'`), puis saison de fond, puis
 *    socle ; à niveau égal, la première de la liste gagne.
 * Toute entrée mal formée ne correspond à aucune nuit et ne lève jamais.
 */
import { addDays, addOneYear, projectTemplate, rangesOverlap, type DateRange, type SeasonTemplate } from '../../logements/season-range-utils';
import type { MovableHolidayKey, MovableHolidays, YieldSeason } from '../types';

/** Fêtes auxquelles une saison peut se rattacher — SeasonCalendar::MOVABLE_HOLIDAYS. */
export const MOVABLE_HOLIDAY_KEYS: readonly MovableHolidayKey[] = ['eid_al_fitr', 'eid_al_adha', 'easter'];

/** Garde-fou du moteur : au-delà d'un an, un décalage est illisible (SeasonCalendar::MAX_ENGINE_OFFSET_DAYS). */
const MAX_ENGINE_OFFSET_DAYS = 366;

/**
 * Une saison telle qu'elle peut arriver : toutes les clés facultatives et de
 * type inconnu (valeur stockée, saisie en cours, cas de test mal formé). Une
 * `YieldSeason` y est assignable.
 */
export type SeasonInput = Partial<
    Record<'name' | 'start_date' | 'end_date' | 'pct' | 'repeat' | 'layer' | 'holiday' | 'offset_start' | 'offset_end', unknown>
>;

/** Événement prioritaire ? Tout le reste est une saison de fond. */
export function isEventSeason(season: SeasonInput): boolean {
    return season.layer === 'event';
}

/** Une fête est « portée » dès que la clé a une valeur ; illisible, elle ne s'applique nulle part. */
export function hasHoliday(season: SeasonInput): boolean {
    return season.holiday !== undefined && season.holiday !== null && season.holiday !== '';
}

function isYearly(season: SeasonInput): boolean {
    return season.repeat === 'yearly';
}

/**
 * Chaîne `YYYY-MM-DD` qui désigne un vrai jour du calendrier — même règle que
 * checkdate() de PHP (année ≥ 1), calculée à la main : Date.UTC lirait une
 * année à deux chiffres comme 19xx.
 */
export function isIsoDate(value: unknown): value is string {
    if (typeof value !== 'string') return false;
    const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
    if (!match) return false;
    const [y, m, d] = [Number(match[1]), Number(match[2]), Number(match[3])];
    if (y < 1 || m < 1 || m > 12 || d < 1) return false;
    const leap = (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0;
    const daysInMonth = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][m - 1];
    return d <= daysInMonth;
}

/** Entier (nombre entier, ou chaîne « -2 »), sinon null — comme SeasonCalendar::integerOrNull. */
function integerOrNull(value: unknown): number | null {
    if (typeof value === 'number') {
        return Number.isInteger(value) ? value : null;
    }
    if (typeof value === 'string' && /^-?\d{1,4}$/.test(value)) {
        return Number.parseInt(value, 10);
    }
    return null;
}

/** Décalages [début, fin] d'une fête, entiers et dans l'ordre, sinon null (aucun effet). */
export function holidayOffsets(season: SeasonInput): [number, number] | null {
    const start = integerOrNull(season.offset_start);
    const end = integerOrNull(season.offset_end);
    if (start === null || end === null || end < start) return null;
    if (Math.abs(start) > MAX_ENGINE_OFFSET_DAYS || Math.abs(end) > MAX_ENGINE_OFFSET_DAYS) return null;
    return [start, end];
}

/** Dates connues d'une fête dans la table (chaînes ISO valides seulement), triées. */
export function holidayDates(holidays: MovableHolidays | undefined, key: string): string[] {
    if (!holidays || !Object.prototype.hasOwnProperty.call(holidays, key)) return [];
    const entry = holidays[key] as { dates?: unknown } | undefined;
    const dates = entry && Array.isArray(entry.dates) ? entry.dates : [];
    return dates.filter(isIsoDate).sort();
}

/** Fenêtres [fête + début ; fête + fin], une par date connue. */
function holidayWindows(season: SeasonInput, holidays: MovableHolidays | undefined): DateRange[] {
    const offsets = holidayOffsets(season);
    if (typeof season.holiday !== 'string' || offsets === null) return [];
    return holidayDates(holidays, season.holiday).map((date) => ({
        start_date: addDays(date, offsets[0]),
        end_date: addDays(date, offsets[1]),
    }));
}

/**
 * Règle annuelle sur le seul MM-JJ : à cheval sur le 31/12 → md ≥ début OU
 * md ≤ fin ; sinon début ≤ md ≤ fin. Le 29/02 tombe à sa place dans l'ordre
 * lexicographique (28/02 < 29/02 < 01/03).
 */
function matchesYearly(start: string, end: string, date: string): boolean {
    if (!isIsoDate(start) || !isIsoDate(end) || !isIsoDate(date)) return false;
    const startMd = start.slice(5);
    const endMd = end.slice(5);
    const md = date.slice(5);
    return Number(end.slice(0, 4)) > Number(start.slice(0, 4)) ? md >= startMd || md <= endMd : startMd <= md && md <= endMd;
}

/** La saison s'applique-t-elle à la nuit `date` ? */
export function seasonMatches(season: SeasonInput, date: string, holidays: MovableHolidays | undefined): boolean {
    if (hasHoliday(season)) {
        return holidayWindows(season, holidays).some((w) => w.start_date <= date && date <= w.end_date);
    }
    const { start_date: start, end_date: end } = season;
    if (typeof start !== 'string' || typeof end !== 'string') return false;
    if (isYearly(season)) return matchesYearly(start, end, date);
    return start <= date && date <= end;
}

/**
 * Jour concret d'un ancrage MM-JJ : le 29/02 d'une année non bissextile
 * devient le 01/03 pour un DÉBUT et le 28/02 pour une FIN (les nuits que
 * matchesYearly() retient). Le calendrier s'arrête au 31/12/9999, comme
 * SeasonCalendar::anchor() : « 10000-01-06 » se classerait avant « 9999-12-21 ».
 */
function anchor(year: number, monthDay: string, isStart: boolean): string | null {
    const match = /^(\d{2})-(\d{2})$/.exec(monthDay);
    if (!match) return null;
    if (year > 9999) return isStart ? null : '9999-12-31';
    const iso = `${String(year).padStart(4, '0')}-${monthDay}`;
    if (isIsoDate(iso)) return iso;
    if (match[1] === '02' && match[2] === '29') {
        return isStart ? `${year}-03-01` : `${year}-02-28`;
    }
    return null;
}

/**
 * Occurrences de la saison qui touchent [from, to], triées, NON rognées. Les
 * nuits qu'elles couvrent sont exactement celles où seasonMatches() répond vrai.
 */
export function seasonOccurrences(season: SeasonInput, from: string, to: string, holidays: MovableHolidays | undefined): DateRange[] {
    let ranges: DateRange[] = [];

    if (hasHoliday(season)) {
        ranges = holidayWindows(season, holidays);
    } else if (isYearly(season)) {
        const { start_date: start, end_date: end } = season;
        if (!isIsoDate(start) || !isIsoDate(end) || !isIsoDate(from) || !isIsoDate(to)) return [];
        const startMd = start.slice(5);
        const endMd = end.slice(5);
        const crosses = Number(end.slice(0, 4)) > Number(start.slice(0, 4));
        for (let year = Number(from.slice(0, 4)) - 1; year <= Number(to.slice(0, 4)); year++) {
            const occurrenceStart = anchor(year, startMd, true);
            const occurrenceEnd = anchor(year + (crosses ? 1 : 0), endMd, false);
            if (occurrenceStart !== null && occurrenceEnd !== null && occurrenceStart <= occurrenceEnd) {
                ranges.push({ start_date: occurrenceStart, end_date: occurrenceEnd });
            }
        }
    } else {
        const { start_date: start, end_date: end } = season;
        if (typeof start === 'string' && typeof end === 'string' && start <= end) {
            ranges.push({ start_date: start, end_date: end });
        }
    }

    return ranges
        .filter((r) => r.start_date <= to && from <= r.end_date)
        .sort((a, b) => (a.start_date < b.start_date ? -1 : a.start_date > b.start_date ? 1 : 0));
}

/** La prochaine occurrence (celle qui contient `today`, ou la suivante), ou null. */
export function nextOccurrence(season: SeasonInput, today: string, holidays: MovableHolidays | undefined): DateRange | null {
    if (!hasHoliday(season) && !isYearly(season)) {
        const { start_date: start, end_date: end } = season;
        return typeof start === 'string' && typeof end === 'string' && start <= end && end >= today ? { start_date: start, end_date: end } : null;
    }
    return seasonOccurrences(season, today, addDays(today, 2 * 366), holidays).find((r) => r.end_date >= today) ?? null;
}

/**
 * Index de la saison qui l'emporte sur la nuit `date` : un premier passage sur
 * les événements, un second sur les saisons de fond, chacun dans l'ordre de la
 * liste ; null = le socle s'applique.
 */
export function winningSeasonIndex(seasons: readonly unknown[], date: string, holidays: MovableHolidays | undefined): number | null {
    for (const eventPass of [true, false]) {
        for (let i = 0; i < seasons.length; i++) {
            const season = seasons[i];
            if (season === null || typeof season !== 'object' || Array.isArray(season)) continue;
            const input = season as SeasonInput;
            if (isEventSeason(input) !== eventPass) continue;
            if (seasonMatches(input, date, holidays)) return i;
        }
    }
    return null;
}

/** `(float)` de PHP, pour les valeurs que ce code peut rencontrer. */
function phpFloat(value: unknown): number {
    if (typeof value === 'number') return Number.isFinite(value) ? value : 0;
    if (typeof value === 'boolean') return value ? 1 : 0;
    if (typeof value === 'string') {
        const parsed = Number.parseFloat(value);
        return Number.isFinite(parsed) ? parsed : 0;
    }
    return 0;
}

/**
 * Multiplicateur de saison d'une nuit et nom de la saison retenue — même
 * résultat que SeasonCalendar::resolve() (forme par dates ET ancienne forme
 * par mois).
 */
export function resolveSeason(seasonality: unknown, date: string, holidays: MovableHolidays | undefined): { multiplier: number; name: unknown } {
    if (seasonality === null || typeof seasonality !== 'object') {
        return { multiplier: 1, name: null };
    }
    const value = seasonality as Record<string, unknown>;

    if (Object.prototype.hasOwnProperty.call(value, 'seasons')) {
        const raw = value.seasons;
        const seasons: unknown[] = Array.isArray(raw) ? raw : raw !== null && typeof raw === 'object' ? Object.values(raw) : [];
        const index = winningSeasonIndex(seasons, date, holidays);
        if (index !== null) {
            const season = seasons[index] as SeasonInput;
            return { multiplier: 1 + phpFloat(season.pct ?? 0) / 100, name: season.name ?? null };
        }
        return { multiplier: 1 + phpFloat(value.base_pct ?? 0) / 100, name: null };
    }

    // Ancienne forme par mois : { "1": {label, multiplier}, … }.
    const month = value[String(Number(date.slice(5, 7)))];
    if (month !== null && typeof month === 'object') {
        const { multiplier, label } = month as { multiplier?: unknown; label?: unknown };
        if (multiplier !== undefined && multiplier !== null) {
            return { multiplier: phpFloat(multiplier), name: label ?? null };
        }
    }
    return { multiplier: 1, name: null };
}

/**
 * Ligne assez complète pour être placée à l'écran : une fête mobile (ses
 * dates ne sont qu'un cache), ou deux dates ISO valides dans l'ordre. Une
 * ligne en cours de saisie n'en est pas une.
 */
export function isCompleteSeason(season: SeasonInput): boolean {
    if (hasHoliday(season)) return true;
    return isIsoDate(season.start_date) && isIsoDate(season.end_date) && season.start_date <= season.end_date;
}

/** Un segment de la frise : des nuits contiguës gagnées par la même saison (`index` null = socle). */
export type SeasonSegment = DateRange & { index: number | null; season: YieldSeason | null };

/**
 * Découpe [from, to] en segments DISJOINTS, jour par jour, selon la saison qui
 * l'emporte (événement, puis saison de fond, puis socle) — la frise peut
 * additionner leurs largeurs même quand un événement est posé dans une saison.
 */
export function flattenSeasonSegments(
    seasons: readonly YieldSeason[],
    from: string,
    to: string,
    holidays: MovableHolidays | undefined,
): SeasonSegment[] {
    const segments: SeasonSegment[] = [];
    if (!isIsoDate(from) || !isIsoDate(to) || from > to) return segments;

    let current: SeasonSegment | null = null;
    for (let day = from; day <= to; day = addDays(day, 1)) {
        const index = winningSeasonIndex(seasons, day, holidays);
        if (current !== null && current.index === index) {
            current.end_date = day;
            continue;
        }
        current = { start_date: day, end_date: day, index, season: index === null ? null : seasons[index] };
        segments.push(current);
    }
    return segments;
}

/**
 * Plages déjà occupées, pour la galerie de gabarits : les OCCURRENCES des
 * saisons de FOND sur deux ans (une puce projette un gabarit sur son
 * occurrence en cours ou à venir, soit au plus un an et une saison plus loin).
 *
 * Pourquoi pas les dates brutes : une saison annuelle garde les dates de sa
 * première occurrence (« Haute sept » 2026-09-01 → 09-15). Comparée telle
 * quelle, la puce « Haute sept » projetée sur 2027 redevenait cliquable, et le
 * clic ajoutait une seconde saison au même niveau — refusée (422) à l'aperçu
 * comme à l'enregistrement. Les événements n'en font pas partie : une puce de
 * gabarit ordinaire ajoute une saison de fond, qui a le droit de les contenir
 * (les puces « à fête », qui ajoutent un événement, sont jugées à part par
 * templateChipResolver).
 */
export function occupiedRanges(
    seasons: readonly YieldSeason[],
    today: string,
    holidays: MovableHolidays | undefined,
): Array<DateRange & { name?: string }> {
    const until = addDays(addOneYear(today), 366);
    return seasons
        .filter((season) => !isEventSeason(season))
        .flatMap((season) => seasonOccurrences(season, today, until, holidays).map((range) => ({ ...range, name: season.name || undefined })));
}

/**
 * Les saisons à retirer avant d'ajouter une saison de fond sur `range` : celles
 * de FOND dont une occurrence le touche. Les événements restent — et les
 * lignes en cours de saisie aussi : une ligne sans date de début (nom et %
 * déjà tapés) se lirait ['', fin], qui « touche » toute plage finissant avant
 * sa fin ; la galerie ne la compte pas (elle n'est pas complète), la puce reste
 * donc cliquable, et le clic l'effaçait sans rien dire.
 */
export function withoutBackgroundOverlapping<T extends YieldSeason>(
    seasons: readonly T[],
    range: DateRange,
    holidays: MovableHolidays | undefined,
): T[] {
    return seasons.filter(
        (season) =>
            isEventSeason(season) || !isCompleteSeason(season) || seasonOccurrences(season, range.start_date, range.end_date, holidays).length === 0,
    );
}

/** Clé de fête mobile connue, sinon null : une clé inconnue ne donne pas d'événement (le serveur la refuserait). */
function movableHolidayKey(value: unknown): MovableHolidayKey | null {
    return typeof value === 'string' && (MOVABLE_HOLIDAY_KEYS as readonly string[]).includes(value) ? (value as MovableHolidayKey) : null;
}

/**
 * L'événement à date MOBILE qu'ajoute la puce d'un gabarit « à fête » (Aïd
 * Adha, Aïd Fitr) — la forme même de YieldConfig::defaultSeasonality() :
 * niveau événement, fête et décalages du gabarit, dates = cache de la
 * prochaine occurrence d'après la table ; table épuisée, l'occurrence du
 * gabarit, début NON ramené à aujourd'hui (projectTemplate(…, false), comme
 * côté serveur). null pour un gabarit sans fête connue.
 */
export function templateEvent(tpl: SeasonTemplate, pct: number, today: string, holidays: MovableHolidays | undefined): YieldSeason | null {
    const holiday = movableHolidayKey(tpl.holiday);
    if (holiday === null) return null;

    const [offsetStart, offsetEnd] = tpl.offsets ?? [0, 0];
    const fallback = projectTemplate(tpl, today, false);
    const event: YieldSeason = {
        name: tpl.name,
        start_date: fallback.start_date,
        end_date: fallback.end_date,
        pct,
        layer: 'event',
        holiday,
        offset_start: offsetStart,
        offset_end: offsetEnd,
    };
    const next = nextOccurrence(event, today, holidays);
    return next ? { ...event, start_date: next.start_date, end_date: next.end_date } : event;
}

/** Une puce de la galerie : la plage qu'elle affiche, et la saison qui la grise (ou undefined). */
export type TemplateChip = { range: DateRange; conflict?: DateRange & { name?: string } };

/**
 * Projection et blocage des puces de la galerie, sur les OCCURRENCES — pour
 * que la galerie ne propose jamais un ajout que le serveur refuserait (422)
 * ou qui doublerait une saison déjà là :
 *  - gabarit de FOND : projeté comme dans les écrans logements, grisé par une
 *    saison de fond dont une occurrence le touche (occupiedRanges) ;
 *  - gabarit À FÊTE : la puce ajoute l'événement mobile (templateEvent) et
 *    affiche sa prochaine occurrence d'après la table. Grisée dès qu'une
 *    saison porte déjà cette fête, ou qu'un autre ÉVÉNEMENT occupe l'une de
 *    ses fenêtres à venir (toutes, comme SeasonCalendar::firstConflict :
 *    l'Aïd recule de onze jours par an). Les saisons de fond ne la grisent
 *    pas : un événement a le droit d'y être posé.
 * Avant : la puce « Aïd Adha », projetée sur ses MM-JJ fixes et comparée aux
 * seules saisons de fond, restait cliquable à côté de l'Aïd mobile déjà
 * présent — le clic l'ajoutait en double, en saison de fond à dates fixes.
 *
 * `seasons` : les lignes COMPLÈTES (isCompleteSeason).
 */
export function templateChipResolver(
    seasons: readonly YieldSeason[],
    today: string,
    holidays: MovableHolidays | undefined,
): (tpl: SeasonTemplate) => TemplateChip {
    const background = occupiedRanges(seasons, today, holidays);
    const events = seasons.filter(isEventSeason);

    return (tpl) => {
        const event = templateEvent(tpl, 0, today, holidays);
        if (event === null) {
            const range = projectTemplate(tpl, today);
            return { range, conflict: background.find((existing) => rangesOverlap(range, existing)) };
        }

        const range = { start_date: event.start_date, end_date: event.end_date };
        const same = seasons.find((season) => season.holiday === event.holiday);
        if (same) {
            const occupied = nextOccurrence(same, today, holidays) ?? { start_date: same.start_date, end_date: same.end_date };
            return { range, conflict: { ...occupied, name: same.name || undefined } };
        }

        const windows = holidayWindows(event, holidays).filter((window) => window.end_date >= today);
        for (const window of windows) {
            for (const other of events) {
                const hit = seasonOccurrences(other, window.start_date, window.end_date, holidays)[0];
                if (hit) return { range, conflict: { ...hit, name: other.name || undefined } };
            }
        }
        return { range };
    };
}

/**
 * Ce qu'une ligne de saison dit d'elle-même à l'écran (YieldSeasonRow), sans
 * rien inventer : ce que le moteur fera de la ligne.
 *  - `next` : la prochaine occurrence (annuelle, fête), ou null ;
 *  - `estimated` : pour une fête, la date qui porte cette occurrence n'est pas
 *    dans la liste `confirmed` de la table (supposée à ±1 jour) ;
 *  - `missing` : pour une fête, la table n'a aucune date dans les 12 mois à
 *    venir — la ligne ne modifie alors aucun prix sur cette période.
 */
export function seasonStatus(
    season: SeasonInput,
    today: string,
    holidays: MovableHolidays | undefined,
): { next: DateRange | null; estimated: boolean; missing: boolean } {
    const next = nextOccurrence(season, today, holidays);
    if (!hasHoliday(season)) {
        return { next, estimated: false, missing: false };
    }

    const offsets = holidayOffsets(season);
    const missing = next === null || next.start_date > addOneYear(today);
    let estimated = false;
    if (next !== null && offsets !== null && typeof season.holiday === 'string' && holidays) {
        const holidayDate = addDays(next.start_date, -offsets[0]);
        const entry = Object.prototype.hasOwnProperty.call(holidays, season.holiday) ? holidays[season.holiday] : undefined;
        const confirmed = entry && Array.isArray(entry.confirmed) ? entry.confirmed : [];
        estimated = !confirmed.includes(holidayDate);
    }
    return { next: missing ? null : next, estimated, missing };
}

/** « J-2 → J+1 » (`day` = « J » ou « D » selon la langue ; « J » seul pour le jour même). */
export function offsetWindowLabel(offsets: readonly [number, number], day: string): string {
    const one = (n: number): string => (n === 0 ? day : `${day}${n > 0 ? '+' : '-'}${Math.abs(n)}`);
    return `${one(offsets[0])} → ${one(offsets[1])}`;
}

/** JJ/MM/AAAA, comme les messages du serveur. */
export function formatDayMonthYear(iso: string): string {
    const [year, month, day] = iso.split('-');
    return `${day}/${month}/${year}`;
}
