"""Materialise concrete Event rows from a recurring EventSeries.

Occurrences are real Event rows (so attendance attaches directly). The series
holds the RRULE, an anchor ``dtstart``, and a set of ``excluded_dates``
(EXDATEs). Rows are generated up to a rolling horizon; a single occurrence can
be cancelled (adds an EXDATE + removes the row) or detached (edited
independently, so series-wide updates skip it).
"""

from datetime import timedelta

from dateutil.rrule import rrulestr
from django.utils import timezone

from events.models import Event

HORIZON_DAYS = 90


def horizon():
    return timezone.now() + timedelta(days=HORIZON_DAYS)


def occurrence_datetimes(series, until):
    """Expand the series' RRULE from its anchor up to ``until``, minus EXDATEs.

    ``until`` is the generation horizon; the series' own ``until`` (its formal
    end) further caps it, whichever comes first.
    """
    if series.until is not None and series.until < until:
        until = series.until

    rule = rrulestr(series.rrule, dtstart=series.dtstart)
    excluded = set(series.excluded_dates)

    result = []
    for occurrence in rule:
        if occurrence > until:
            break
        if occurrence.isoformat() in excluded:
            continue
        result.append(occurrence)
    return result


def apply_template(series, event):
    """Copy the series template (and audience) onto ``event`` and save it.

    Setting the audience M2M fires the sync signal, so attendance follows.
    """
    event.kind = series.kind
    event.title = series.title
    event.location = series.location
    event.opponent = series.opponent
    event.club_wide = series.club_wide
    event.end = event.start + series.duration if series.duration is not None else None
    event.gathering = event.start - series.gathering_offset if series.gathering_offset is not None else None
    event.deadline = event.start - series.deadline_offset if series.deadline_offset is not None else None
    event.save()

    event.teams.set(series.teams.all())
    event.groups.set(series.groups.all())
    event.invited_members.set(series.invited_members.all())
    event.excluded_members.set(series.excluded_members.all())


def generate_occurrences(series, until=None):
    """Materialise any missing occurrences up to ``until`` (default: horizon)."""
    until = until or horizon()
    existing = set(series.occurrences.values_list("start", flat=True))

    created = []
    for start in occurrence_datetimes(series, until):
        if start in existing:
            continue
        event = Event(club=series.club, series=series, start=start)
        apply_template(series, event)
        created.append(event)

    series.generated_until = until
    series.save(update_fields=["generated_until"])
    return created


def cancel_occurrence(event, *, hard_delete=True):
    """Drop a single occurrence and record an EXDATE so it isn't regenerated."""
    series = event.series
    if series is not None:
        iso = event.start.isoformat()
        if iso not in series.excluded_dates:
            series.excluded_dates = [*series.excluded_dates, iso]
            series.save(update_fields=["excluded_dates"])

    if hard_delete:
        event.delete()
    else:
        event.cancelled = True
        event.save(update_fields=["cancelled"])


def detach_occurrence(event):
    """Mark an occurrence as edited independently of the series."""
    event.detached = True
    event.save(update_fields=["detached"])


def propagate_series(series):
    """Re-apply the series template to its non-detached future occurrences."""
    now = timezone.now()
    updated = []
    for event in series.occurrences.filter(detached=False, start__gte=now):
        apply_template(series, event)
        updated.append(event)
    return updated
