"""Keep an event's attendance rows in sync with its effective audience.

The audience of an event is the union of the current rosters of its ``teams``
(for the event's season) and the current members of its ``groups``, plus any
individually ``invited_members``, minus any ``excluded_members`` -- or, for a
``club_wide`` event, every member with an ACTIVE ClubMembership for the
event's season instead of teams/groups (the two are mutually exclusive, see
EventForm/EventSeriesForm). Attendance rows are reconciled against that set,
but only for events that are still in the future — history is never rewritten.

A member provisionally rostered by management.views.SignupPlaceInTeamView
(on a team, but still PENDING -- their sign-up isn't fully processed yet) is
still subtracted back out here if an open onboarding requirement blocks this
event's kind -- see club.services.onboarding.blocked_member_ids_for_event.
Explicitly ``invited_members`` bypasses that: a named, individual invite is a
deliberate staff decision that should win regardless.
"""

from django.db.models import Count, Q
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.utils.translation import ngettext

from club.models import ClubMembership, Season
from club.services.access import current_season
from club.services.onboarding import blocked_member_ids_for_event, open_requirements_blocking
from events.models import Attendance, Event
from members.models import Member
from notifications.services import notify_members
from teams.models import TeamMembership


def resolve_season(event):
    """The season whose rosters define the event's audience."""
    if event.season_id is not None:
        return event.season
    return Season.covering(event.club, event.start.date())


def effective_members(event):
    """Return the ``Member`` queryset invited to ``event``."""
    season = resolve_season(event)

    if event.club_wide:
        member_ids = set()
        if season is not None:
            member_ids.update(ClubMembership.objects.filter(club=event.club, season=season, status=ClubMembership.StatusChoices.ACTIVE).values_list("member_id", flat=True))
    else:
        member_ids = set()
        if season is not None:
            team_ids = list(event.teams.values_list("id", flat=True))
            if team_ids:
                member_ids.update(TeamMembership.objects.filter(team_id__in=team_ids, season=season).values_list("member_id", flat=True))

        group_ids = list(event.groups.values_list("id", flat=True))
        if group_ids:
            member_ids.update(Member.objects.filter(group_memberships__group_id__in=group_ids).values_list("id", flat=True))

    invited_ids = set(event.invited_members.values_list("id", flat=True))

    if season is not None:
        member_ids.difference_update(blocked_member_ids_for_event(event.club, season, event.kind) - invited_ids)

    member_ids.update(invited_ids)
    member_ids.difference_update(event.excluded_members.values_list("id", flat=True))

    return Member.objects.filter(id__in=member_ids)


def sync_event_attendances(event):
    """Reconcile ``event``'s attendance rows with its effective audience.

    No-op for events that have already started. New members get a
    ``NO_RESPONSE`` row; members no longer invited have their row removed
    outright (hard reconcile).
    """
    if event.start < timezone.now():
        return

    desired_ids = set(effective_members(event).values_list("id", flat=True))
    existing_ids = set(event.attendances.values_list("member_id", flat=True))

    to_add = desired_ids - existing_ids
    if to_add:
        Attendance.objects.bulk_create([Attendance(event=event, member_id=member_id) for member_id in to_add])

    to_remove = existing_ids - desired_ids
    if to_remove:
        event.attendances.filter(member_id__in=to_remove).delete()


def blocked_upcoming_events_for_member(member, club):
    """Upcoming events ``member`` would normally see (via a team roster or a
    group they're in) but has no ``Attendance`` row for, because an open
    onboarding requirement blocks that event's kind -- the read side of
    ``effective_members()``'s own exclusion, which otherwise makes the event
    vanish for them with no explanation at all. Built for the member-facing
    "why can't I sign up" card (mobile/views.py) rather than the event simply
    never appearing.

    Returns ``[(event, [blocking OnboardingRequirement, ...]), ...]``, soonest
    first. A member explicitly ``invited_members`` on an event never appears
    here -- that bypasses blocking entirely (see ``effective_members``'s own
    docstring), so they already have a normal Attendance row for it."""
    season = current_season(club)
    if season is None:
        return []

    team_ids = list(TeamMembership.objects.filter(member=member, season=season).values_list("team_id", flat=True))
    group_ids = list(member.group_memberships.values_list("group_id", flat=True))
    if not team_ids and not group_ids:
        return []

    candidates = list(
        Event.objects.filter(club=club, cancelled=False, start__gte=timezone.now())
        .filter(Q(teams__id__in=team_ids) | Q(groups__id__in=group_ids))
        .exclude(excluded_members=member)
        .distinct()
        .order_by("start")
    )
    if not candidates:
        return []

    existing_ids = set(Attendance.objects.filter(member=member, event__in=candidates).values_list("event_id", flat=True))

    results = []
    for event in candidates:
        if event.pk in existing_ids:
            continue
        requirements = open_requirements_blocking(member, club, season, event.kind)
        if requirements:
            results.append((event, requirements))
    return results


def notify_newly_invited(member, *, club, events):
    """One notification (push+email), not one per event -- used when a
    roster/group change (events/signals.py's sync_on_roster_change/
    sync_on_group_membership_change) adds ``member`` to a team/group that
    already has upcoming events on the calendar. A flood of one-per-event
    pushes for, say, joining a team mid-season with a whole recurring
    practice series already scheduled would be worse than useless -- same
    reasoning as news.tasks._dedupe_by_recipients's single-summary
    preference over a flood, just per-recipient instead of cross-family."""
    if not events:
        return
    title = _("New events on your calendar")
    body = ngettext("%(count)d new event has been added to your calendar.", "%(count)d new events have been added to your calendar.", len(events)) % {"count": len(events)}
    notify_members([member], club=club, title=title, body=body)


def record_check_in(attendance, *, showed_up):
    """Record whether ``attendance``'s member actually showed up, separate from
    their RSVP status -- the hook a future check-in UI (the coaches app) writes
    through. Nothing in this codebase calls this yet; it exists so a no-show can
    be recorded the moment something does."""
    attendance.showed_up = showed_up
    attendance.save(update_fields=["showed_up"])


def team_attendance_rate(team, season):
    """Turnout for ``team`` in ``season``: present / (present + absent) among
    past events -- same definition as
    controlpanel.services.statistics.attendance_rates, just scoped to one team
    instead of the whole club."""
    counts = Attendance.objects.filter(event__teams=team, event__season=season, event__start__lt=timezone.now()).aggregate(
        present=Count("id", filter=Q(status=Attendance.AttendanceStatus.PRESENT)),
        absent=Count("id", filter=Q(status=Attendance.AttendanceStatus.ABSENT)),
    )
    answered = counts["present"] + counts["absent"]
    return round(100 * counts["present"] / answered) if answered else None


def member_attendance_sparkline(member, season, *, limit=12):
    """This member's most recent ``limit`` attendance rows in ``season``,
    oldest first, each bucketed into the three states the member detail
    page's sparkline draws: 'present'/'absent' for a past event (mirrors
    team_attendance_rate's own present-vs-everything-else-that-isn't-present
    definition -- excused/no_response/etc. all read as a miss here, same as
    there) or 'upcoming' for one that hasn't happened yet."""
    now = timezone.now()
    rows = list(Attendance.objects.filter(member=member, event__season=season).select_related("event").order_by("-event__start")[:limit])
    rows.reverse()

    bars = []
    for row in rows:
        if row.event.start > now:
            state = "upcoming"
        elif row.status == Attendance.AttendanceStatus.PRESENT:
            state = "present"
        else:
            state = "absent"
        bars.append({"event": row.event, "state": state})
    return bars


def member_attendance_counts(member, season):
    """Present/Absent/No-reply totals for the season, alongside the
    sparkline -- counts every past attendance row, not just the (possibly
    truncated) ones the sparkline itself displays."""
    return Attendance.objects.filter(member=member, event__season=season, event__start__lt=timezone.now()).aggregate(
        present=Count("id", filter=Q(status=Attendance.AttendanceStatus.PRESENT)),
        absent=Count("id", filter=Q(status=Attendance.AttendanceStatus.ABSENT)),
        no_reply=Count("id", filter=Q(status=Attendance.AttendanceStatus.NO_RESPONSE)),
    )


def player_attendance_rankings(team, season, *, minimum_responses=3):
    """Each player's turnout this season, best first:
    ``[{"member": Member, "rate": int, "responses": int}, ...]``. Excludes
    anyone with fewer than ``minimum_responses`` present/absent replies -- one
    absence out of one invite would otherwise read as "0%, worst on the team."
    """
    rows = (
        Attendance.objects.filter(
            event__teams=team,
            event__season=season,
            event__start__lt=timezone.now(),
            status__in=[Attendance.AttendanceStatus.PRESENT, Attendance.AttendanceStatus.ABSENT],
        )
        .values("member")
        .annotate(present=Count("id", filter=Q(status=Attendance.AttendanceStatus.PRESENT)), responses=Count("id"))
        .filter(responses__gte=minimum_responses)
    )

    members_by_id = Member.objects.in_bulk([row["member"] for row in rows])
    rankings = [{"member": members_by_id[row["member"]], "rate": round(100 * row["present"] / row["responses"]), "responses": row["responses"]} for row in rows]
    rankings.sort(key=lambda entry: entry["rate"], reverse=True)
    return rankings


def players_who_missed_recent_practices(team, season, *, count=2):
    """Members absent or silent (not excused) on *every one* of the team's
    ``count`` most recent past training-kind events this season. Empty if the
    team has fewer than ``count`` past practices logged yet -- not enough
    history to call anyone out."""
    practices = list(Event.objects.filter(teams=team, season=season, kind=Event.EventKind.TRAINING, start__lt=timezone.now()).order_by("-start")[:count])
    if len(practices) < count:
        return Member.objects.none()

    flagged = (Attendance.AttendanceStatus.ABSENT, Attendance.AttendanceStatus.NO_RESPONSE)
    missed_by_member = None
    for practice in practices:
        missed_here = set(Attendance.objects.filter(event=practice, status__in=flagged).values_list("member_id", flat=True))
        missed_by_member = missed_here if missed_by_member is None else missed_by_member & missed_here

    return Member.objects.filter(pk__in=missed_by_member).order_by("last_name", "first_name")


def team_no_shows(team, season):
    """Attendance rows where the member RSVPed present/selected but was
    checked in as showed_up=False -- most recent first. Each entry carries
    both the member and the event: a no-show is about a specific missed
    occasion, not a season-long rate. Never inferred from a missing check-in --
    with nothing checking anyone in yet, that would flag every "present" RSVP."""
    return (
        Attendance.objects.filter(
            event__teams=team,
            event__season=season,
            status__in=[Attendance.AttendanceStatus.PRESENT, Attendance.AttendanceStatus.SELECTED],
            showed_up=False,
        )
        .select_related("member", "event")
        .order_by("-event__start")
    )
