"""Expandir una RRULE iCalendar a las fechas de inicio de cada ocurrencia.

Implementacion pura y determinista, solo stdlib (datetime, re). NO usa
python-dateutil (no disponible en los venvs del ecosistema). Soporta un
subconjunto pragmatico de RFC 5545 suficiente para una agenda personal:
FREQ (DAILY/WEEKLY/MONTHLY/YEARLY), INTERVAL, COUNT, UNTIL y BYDAY (solo
para FREQ=WEEKLY). Cualquier otro componente se ignora silenciosamente.
"""

import re
from datetime import date, datetime, timedelta

# Tope de seguridad para no colgar cuando faltan COUNT y UNTIL.
_MAX_OCCURRENCES = 1000

# Mapeo de codigos BYDAY iCal -> weekday() de Python (lunes=0 .. domingo=6).
_BYDAY_TO_WEEKDAY = {
    "MO": 0,
    "TU": 1,
    "WE": 2,
    "TH": 3,
    "FR": 4,
    "SA": 5,
    "SU": 6,
}


def _parse_ical_date(value: str) -> date:
    """Extrae la parte YYYYMMDD de un valor iCal y la devuelve como date.

    Acepta YYYYMMDD, YYYYMMDDTHHMMSS y YYYYMMDDTHHMMSSZ.
    """
    digits = value.strip()[:8]
    return date(int(digits[:4]), int(digits[4:6]), int(digits[6:8]))


def _parse_time_suffix(dtstart_ical: str) -> str:
    """Devuelve la parte de hora 'THHMMSS' del dtstart, o '' si es all-day."""
    m = re.search(r"T(\d{6})", dtstart_ical.strip())
    return f"T{m.group(1)}" if m else ""


def _parse_rrule(rrule: str) -> dict:
    """Parsea el cuerpo de una RRULE a un dict de componentes en mayusculas."""
    parts: dict[str, str] = {}
    for token in rrule.strip().split(";"):
        if not token or "=" not in token:
            continue
        key, _, val = token.partition("=")
        parts[key.strip().upper()] = val.strip()
    return parts


def _add_months(d: date, months: int) -> date:
    """Suma `months` meses a una fecha, recortando el dia al ultimo valido."""
    total = (d.year * 12 + (d.month - 1)) + months
    year, month = divmod(total, 12)
    month += 1
    # Recorte de dia: si el dia no existe en el mes destino, usar el ultimo.
    if month == 12:
        next_first = date(year + 1, 1, 1)
    else:
        next_first = date(year, month + 1, 1)
    last_day = (next_first - timedelta(days=1)).day
    return date(year, month, min(d.day, last_day))


def _format_occurrence(d: date, time_suffix: str, all_day: bool) -> str:
    """Formatea una fecha de ocurrencia como string DTSTART iCal."""
    ymd = f"{d.year:04d}{d.month:02d}{d.day:02d}"
    if all_day or not time_suffix:
        return ymd
    return f"{ymd}{time_suffix}"


def _generate_dates(dtstart: date, rule: dict) -> list[date]:
    """Genera las fechas de ocurrencia segun la RRULE (antes de filtrar rango).

    El recorte por rango lo hace el llamador; aqui se respeta COUNT, UNTIL y
    el tope de seguridad _MAX_OCCURRENCES.
    """
    freq = rule.get("FREQ", "").upper()
    interval = max(1, int(rule["INTERVAL"])) if rule.get("INTERVAL", "").isdigit() else 1
    count = int(rule["COUNT"]) if rule.get("COUNT", "").isdigit() else None
    until = _parse_ical_date(rule["UNTIL"]) if rule.get("UNTIL") else None

    results: list[date] = []

    def reached_limit() -> bool:
        if count is not None and len(results) >= count:
            return True
        return len(results) >= _MAX_OCCURRENCES

    def past_until(d: date) -> bool:
        return until is not None and d > until

    if freq == "WEEKLY":
        byday = rule.get("BYDAY", "")
        weekdays = [
            _BYDAY_TO_WEEKDAY[code.strip().upper()]
            for code in byday.split(",")
            if code.strip().upper() in _BYDAY_TO_WEEKDAY
        ]
        if not weekdays:
            weekdays = [dtstart.weekday()]
        weekdays = sorted(set(weekdays))
        # Lunes de la semana del dtstart.
        week_anchor = dtstart - timedelta(days=dtstart.weekday())
        week_index = 0
        while True:
            week_start = week_anchor + timedelta(weeks=week_index * interval)
            for wd in weekdays:
                occ = week_start + timedelta(days=wd)
                if occ < dtstart:
                    continue
                if past_until(occ):
                    return results
                if reached_limit():
                    return results
                results.append(occ)
            if reached_limit():
                return results
            # Corte: si no hay COUNT ni UNTIL, el llamador acota por range_end,
            # pero el tope duro evita el bucle infinito.
            if until is None and count is None and len(results) >= _MAX_OCCURRENCES:
                return results
            week_index += 1
            # Salvaguarda extra si UNTIL/COUNT no recortan a tiempo.
            if week_index > _MAX_OCCURRENCES:
                return results
        return results

    # FREQ por periodos simples (DAILY / MONTHLY / YEARLY).
    step = 0
    while True:
        if freq == "DAILY":
            occ = dtstart + timedelta(days=step * interval)
        elif freq == "MONTHLY":
            occ = _add_months(dtstart, step * interval)
        elif freq == "YEARLY":
            occ = _add_months(dtstart, step * interval * 12)
        else:
            # FREQ desconocido o ausente: solo la primera ocurrencia.
            occ = dtstart
            if not past_until(occ):
                results.append(occ)
            return results

        if past_until(occ):
            return results
        if reached_limit():
            return results
        results.append(occ)
        if reached_limit():
            return results
        if until is None and count is None and len(results) >= _MAX_OCCURRENCES:
            return results
        step += 1
        if step > _MAX_OCCURRENCES:
            return results


def expand_rrule(
    dtstart_ical: str,
    rrule: str,
    range_start: str,
    range_end: str,
    all_day: bool = False,
) -> list[str]:
    """Expande una RRULE iCal a las fechas DTSTART de cada ocurrencia en rango.

    Args:
        dtstart_ical: fecha de inicio del evento maestro en formato iCal crudo.
            Puede ser YYYYMMDD (all-day), YYYYMMDDTHHMMSS o YYYYMMDDTHHMMSSZ.
            Es la primera ocurrencia (la serie incluye dtstart si cae en rango).
        rrule: cuerpo de la RRULE SIN el prefijo 'RRULE:', p.ej.
            'FREQ=WEEKLY;INTERVAL=1;COUNT=10' o 'FREQ=MONTHLY;UNTIL=20261231;BYDAY=MO,WE'.
        range_start: limite inferior del rango como YYYYMMDD (inclusive).
        range_end: limite superior del rango como YYYYMMDD (inclusive).
        all_day: si True las ocurrencias se devuelven como YYYYMMDD; si False
            conservan la parte de hora del dtstart original (misma hora local en
            cada ocurrencia) y devuelven YYYYMMDDTHHMMSS (sin sufijo Z).

    Returns:
        Lista ordenada de strings DTSTART iCal, una por ocurrencia cuya fecha
        (parte YYYYMMDD del DTSTART) cae en [range_start, range_end]. Lista
        vacia si la RRULE no produce ninguna en rango.
    """
    rule = _parse_rrule(rrule)
    dtstart = _parse_ical_date(dtstart_ical)
    time_suffix = _parse_time_suffix(dtstart_ical)
    lo = _parse_ical_date(range_start)
    hi = _parse_ical_date(range_end)

    occurrences = _generate_dates(dtstart, rule)

    out: list[str] = []
    for occ in occurrences:
        if lo <= occ <= hi:
            out.append(_format_occurrence(occ, time_suffix, all_day))
    out.sort()
    return out
