/**
 * Pure date helpers for the season bulk-edit feature (template projection,
 * overlap detection, gap computation for the base price).
 *
 * Conventions:
 * - Every date is an ISO `YYYY-MM-DD` string; comparisons are lexicographic
 *   (valid because the segments are zero-padded and ordered big-endian).
 * - All ranges are INCLUSIVE on both bounds (RU putPrices semantics: a season
 *   ending 06-30 and one starting 07-01 are adjacent, NOT overlapping).
 * - No function reads the clock: `today` is always a parameter. Callers build
 *   it from the browser clock with `toLocalISODate(new Date())`.
 */

export type SeasonIntensity = 'low' | 'mid' | 'high' | 'peak' | 'event';

export type SeasonTemplate = {
    name: string;
    /** Recurring start day, `MM-DD` format. */
    start: string;
    /** Recurring end day, `MM-DD` format. May be before `start` (year-crossing). */
    end: string;
    intensity: SeasonIntensity;
};

export type DateRange = {
    start_date: string;
    end_date: string;
};

// The template list itself lives in `config/season-templates.php` (server-side
// source of truth, passed to the pages as the `seasonTemplates` Inertia prop).
// It is intentionally NOT mirrored here — tests carry their own fixture.

// ---------------------------------------------------------------------------
// Low-level date helpers (UTC arithmetic so DST can never shift a day)
// ---------------------------------------------------------------------------

/** Formats a Date using its LOCAL calendar day (never `toISOString`, which is UTC). */
export function toLocalISODate(date: Date): string {
    const m = String(date.getMonth() + 1).padStart(2, '0');
    const d = String(date.getDate()).padStart(2, '0');
    return `${date.getFullYear()}-${m}-${d}`;
}

/** `YYYY-MM-DD` + n days (n may be negative). Internally UTC, so always exact. */
export function addDays(iso: string, n: number): string {
    const [y, m, d] = iso.split('-').map(Number);
    const date = new Date(Date.UTC(y, m - 1, d + n));
    const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
    const dd = String(date.getUTCDate()).padStart(2, '0');
    return `${date.getUTCFullYear()}-${mm}-${dd}`;
}

/** Inclusive day count of a range (start 06-01 / end 06-03 → 3 days). */
export function daysInRange(range: DateRange): number {
    const toUtcMs = (iso: string): number => {
        const [y, m, d] = iso.split('-').map(Number);
        return Date.UTC(y, m - 1, d);
    };
    return Math.round((toUtcMs(range.end_date) - toUtcMs(range.start_date)) / 86_400_000) + 1;
}

/** `today` + 1 year. Feb 29 maps to Feb 28 of the next (non-leap) year. */
export function addOneYear(today: string): string {
    const [y, m, d] = today.split('-').map(Number);
    const day = m === 2 && d === 29 ? 28 : d;
    return `${y + 1}-${String(m).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}

/**
 * Builds `YYYY-MM-DD` from a year and an `MM-DD` anchor. `02-29` on a non-leap
 * year normalizes to `03-01` (Date.UTC rollover) instead of producing an
 * invalid date.
 */
function anchorToDate(year: number, monthDay: string): string {
    const [m, d] = monthDay.split('-').map(Number);
    return addDays(`${year}-${String(m).padStart(2, '0')}-01`, d - 1);
}

// ---------------------------------------------------------------------------
// Template projection (spec rule B)
// ---------------------------------------------------------------------------

/**
 * Projects a recurring `MM-DD` template onto concrete dates:
 * - the end year is start year + 1 when the template crosses the year
 *   boundary (end anchor < start anchor);
 * - the retained occurrence is the closest one whose END is >= `today`
 *   (a season already over this year projects onto next year's occurrence);
 * - if that occurrence already started (start < today <= end), its start is
 *   clamped to `today` so we never push prices into the past.
 */
export function projectTemplate(tpl: SeasonTemplate, today: string): DateRange {
    const crossesYear = tpl.end < tpl.start; // lexicographic on MM-DD
    const todayYear = Number(today.slice(0, 4));

    // Start scanning one year back: a year-crossing occurrence started in
    // December of LAST year can still be the active one in early January.
    for (let year = todayYear - 1; ; year++) {
        const start = anchorToDate(year, tpl.start);
        const end = anchorToDate(year + (crossesYear ? 1 : 0), tpl.end);
        if (end >= today) {
            return { start_date: start < today ? today : start, end_date: end };
        }
    }
}

// ---------------------------------------------------------------------------
// Overlap & gaps (spec rules C / E)
// ---------------------------------------------------------------------------

/** True when the two INCLUSIVE ranges share at least one day. */
export function rangesOverlap(a: DateRange, b: DateRange): boolean {
    return a.start_date <= b.end_date && b.start_date <= a.end_date;
}

/**
 * True as soon as any two ranges in the list overlap (INCLUSIVE bounds, so
 * adjacent ranges 06-30 / 07-01 are fine but a shared 06-30 is an overlap).
 * Single source of truth for the season-overlap guard across step6 and the
 * bulk editor — replaces the looser `start < otherEnd && end > otherStart`
 * inline checks that missed shared-endpoint collisions.
 */
export function hasOverlap(ranges: DateRange[]): boolean {
    for (let i = 0; i < ranges.length; i++) {
        for (let j = i + 1; j < ranges.length; j++) {
            if (rangesOverlap(ranges[i], ranges[j])) {
                return true;
            }
        }
    }
    return false;
}

// ---------------------------------------------------------------------------
// Overlap RESOLUTION (trim / split instead of blocking)
// ---------------------------------------------------------------------------

/** A season carrying a price — the unit the resolver re-groups. */
export type PricedSeason = {
    start_date: string;
    end_date: string;
    daily_price: number | string;
    extra_price?: number | string;
    name?: string;
};

/** Which side keeps the contested days of a single conflict. */
export type OverlapWinner = 'new' | 'existing';

/** Stable key for a season's range (existing seasons never overlap → unique). */
export function seasonRangeKey(s: DateRange): string {
    return `${s.start_date}|${s.end_date}`;
}

/** The complete (coherent) seasons of `list` that overlap `target`. */
export function findOverlappingSeasons<T extends DateRange>(target: DateRange, list: T[]): T[] {
    return list.filter((s) => s.start_date && s.end_date && s.start_date <= s.end_date && rangesOverlap(s, target));
}

/** Intersection of two inclusive ranges, or null when they don't overlap. */
export function intersectRange(a: DateRange, b: DateRange): DateRange | null {
    if (!rangesOverlap(a, b)) return null;
    return {
        start_date: a.start_date > b.start_date ? a.start_date : b.start_date,
        end_date: a.end_date < b.end_date ? a.end_date : b.end_date,
    };
}

/** `a` minus `b`: the 0, 1 or 2 fragments of `a` that `b` does not cover. */
export function subtractRange<T extends DateRange>(a: T, b: DateRange): T[] {
    if (!rangesOverlap(a, b)) return [a];
    const out: T[] = [];
    if (a.start_date < b.start_date) {
        out.push({ ...a, start_date: a.start_date, end_date: addDays(b.start_date, -1) });
    }
    if (a.end_date > b.end_date) {
        out.push({ ...a, start_date: addDays(b.end_date, 1), end_date: a.end_date });
    }
    return out;
}

/**
 * Commit `newSeason` into `list`, resolving every overlap by DAY-LEVEL ownership
 * (RU semantics, applied locally): existing seasons keep every day the new one
 * doesn't claim; the new season claims an overlapping day only where it wins.
 * `winners` maps an existing season's `seasonRangeKey` → who keeps the contested
 * days (default 'new'). Untouched seasons pass through; the affected union is
 * re-grouped into contiguous same-(price, extra, name) ranges — so an existing
 * season is TRIMMED, or SPLIT in two when the new one lands in its middle, never
 * deleted wholesale.
 */
export function resolveSeasonConflicts<T extends PricedSeason>(
    newSeason: T,
    list: T[],
    winners: Record<string, OverlapWinner>,
): T[] {
    const conflicting = findOverlappingSeasons(newSeason, list);
    if (conflicting.length === 0) {
        return [...list, newSeason];
    }
    const conflictingSet = new Set(conflicting);
    const untouched = list.filter((s) => !conflictingSet.has(s));

    const owner = new Map<string, T>();
    for (const e of conflicting) {
        for (let d = e.start_date; d <= e.end_date; d = addDays(d, 1)) {
            owner.set(d, e);
        }
    }
    for (let d = newSeason.start_date; d <= newSeason.end_date; d = addDays(d, 1)) {
        const existing = owner.get(d);
        if (!existing) {
            owner.set(d, newSeason);
            continue;
        }
        if ((winners[seasonRangeKey(existing)] ?? 'new') === 'new') {
            owner.set(d, newSeason);
        }
    }

    const sameSeason = (a: PricedSeason, b: PricedSeason) =>
        String(a.daily_price) === String(b.daily_price)
        && String(a.extra_price ?? '') === String(b.extra_price ?? '')
        && (a.name ?? '') === (b.name ?? '');

    const days = [...owner.keys()].sort();
    const grouped: T[] = [];
    let cur: T | null = null;
    let curStart = '';
    let curEnd = '';
    for (const d of days) {
        const o = owner.get(d)!;
        if (cur && sameSeason(cur, o) && addDays(curEnd, 1) === d) {
            curEnd = d;
        } else {
            if (cur) grouped.push({ ...cur, start_date: curStart, end_date: curEnd });
            cur = o;
            curStart = d;
            curEnd = d;
        }
    }
    if (cur) grouped.push({ ...cur, start_date: curStart, end_date: curEnd });

    return [...untouched, ...grouped];
}

/**
 * Complement of the union of `ranges` inside the inclusive window
 * [`today`, `horizon`]: the uncovered stretches, sorted, adjacent/overlapping
 * input ranges merged, never an empty range. With no input ranges the whole
 * window is one gap.
 */
export function computeGaps(today: string, horizon: string, ranges: DateRange[]): DateRange[] {
    const sorted = [...ranges]
        .filter((r) => r.start_date <= r.end_date && rangesOverlap(r, { start_date: today, end_date: horizon }))
        .sort((a, b) => (a.start_date < b.start_date ? -1 : a.start_date > b.start_date ? 1 : 0));

    const gaps: DateRange[] = [];
    // First day not yet known to be covered.
    let cursor = today;

    for (const range of sorted) {
        const start = range.start_date < today ? today : range.start_date;
        const end = range.end_date > horizon ? horizon : range.end_date;

        if (end < cursor) {
            continue; // fully inside an already-covered stretch
        }
        if (start > cursor) {
            // Uncovered stretch right before this range (inclusive bounds:
            // the gap stops the day BEFORE the range starts).
            gaps.push({ start_date: cursor, end_date: addDays(start, -1) });
        }
        cursor = addDays(end, 1); // first day after this range
        if (cursor > horizon) {
            return gaps;
        }
    }

    if (cursor <= horizon) {
        gaps.push({ start_date: cursor, end_date: horizon });
    }
    return gaps;
}
