"""Explicit season clock, readiness and resumable automatic lifecycle."""

from datetime import timedelta
from itertools import pairwise

from django.db import transaction
from django.utils import timezone

from .exceptions import SeasonActivationGateError, SeasonError
from .models import Season, SeasonAutomationPolicy, SeasonOrchestrationRun
from .orchestration_policy import SeasonCalendarPolicyV1


def league_windows(policy):
    return SeasonCalendarPolicyV1.league_windows(
        start_date=policy.season.start_date,
        end_date=policy.season.end_date,
        round_gap_days=policy.round_gap_days,
        break_start=timezone.localtime(policy.midseason_opens_at).date(),
        break_end=timezone.localtime(policy.midseason_closes_at).date(),
    )


def can_configure(actor):
    from api_core.permissions import is_platform_admin
    from federations.access import can_control_federation
    from federations.models import Federation

    return bool(
        actor
        and (
            is_platform_admin(actor)
            or any(
                can_control_federation(actor, mother)
                for mother in Federation.objects.filter(
                    federation_type="MOTHER", status="ACTIVE"
                )
            )
        )
    )


def _notice(policy, *, key, title, body, event_type="season.calendar.notice"):
    from clubs.models import Club
    from core_runtime.services import OutboxService

    recipients = {str(policy.operator_id)}
    for club in Club.objects.filter(
        league_memberships__season=policy.season,
        league_memberships__status="REGISTERED",
    ).select_related("coach_career"):
        if club.owner_id:
            recipients.add(str(club.owner_id))
        if club.coach_career_id:
            recipients.add(str(club.coach_career.user_id))
    return OutboxService.enqueue(
        topic="seasons",
        event_type=event_type,
        aggregate_type="Season",
        aggregate_id=policy.season_id,
        deduplication_key=f"season-calendar:{policy.season_id}:{key}",
        payload={"season_id": policy.season_id},
        headers={
            "notification": {
                "recipient_user_ids": sorted(recipients),
                "category": "TRANSFER",
                "title": title,
                "body": body,
                "thread_key": f"season:{policy.season_id}",
                "action_url": f"/seasons/{policy.season_id}",
            },
        },
    )


@transaction.atomic
def configure(*, season_id, actor, **values):
    if not can_configure(actor):
        raise SeasonActivationGateError(
            "Platform administration or mother federation governance required."
        )
    season = Season.objects.select_for_update().get(pk=season_id)
    if season.status not in {"DRAFT", "UPCOMING"}:
        raise SeasonActivationGateError(
            "Season calendar is sealed once the season starts."
        )
    policy = (
        SeasonAutomationPolicy.objects.select_for_update().filter(season=season).first()
    )
    adopting = values.pop("adopt_legacy_calendar", False)
    new_end = values.pop("end_date", None)
    if adopting:
        from django.db.models import Q

        from matches.models import Match

        from .models import SeasonCompetitionCalendarSlot

        started = (
            Match.objects.filter(
                Q(fixture__competition__season=season)
                | Q(fixture__federation_competition__season=season)
            )
            .exclude(status__in=["PENDING", "SCHEDULED"])
            .exists()
        )
        if (
            policy
            or SeasonCompetitionCalendarSlot.objects.filter(season=season).exists()
            or started
        ):
            raise SeasonActivationGateError(
                "Only an unstarted legacy season without published calendar evidence may adopt its initial calendar."
            )
        season.start_date = timezone.localtime(values["starts_at"]).date()
        if new_end is not None:
            season.end_date = new_end
        from .validators import validate_season_dates

        validate_season_dates(start_date=season.start_date, end_date=season.end_date)
        season.save(update_fields=["start_date", "end_date", "updated_at"])
    elif new_end is not None and new_end != season.end_date:
        raise SeasonActivationGateError(
            "Changing season dates requires explicit initial calendar adoption."
        )
    policy = policy or SeasonAutomationPolicy(season=season)
    for key, value in values.items():
        setattr(policy, key, value)
    policy.operator = actor
    points = [
        policy.registration_opens_at,
        policy.starts_at,
        policy.midseason_opens_at,
        policy.midseason_closes_at,
    ]
    if any(value is None or timezone.is_naive(value) for value in points) or not all(
        a < b for a, b in pairwise(points)
    ):
        raise SeasonActivationGateError(
            "Season and transfer window times must be aware and strictly ordered."
        )
    if timezone.localtime(policy.starts_at).date() != season.start_date:
        raise SeasonActivationGateError(
            "Season kickoff date must match the season start date."
        )
    if timezone.localtime(policy.midseason_closes_at).date() >= season.end_date:
        raise SeasonActivationGateError(
            "Midseason window must end before the season ends."
        )
    if policy.kickoff_time < timezone.localtime(policy.starts_at).time().replace(
        tzinfo=None
    ):
        raise SeasonActivationGateError(
            "League kickoff time cannot precede the season starting time."
        )
    try:
        league_windows(policy)
    except ValueError as exc:
        raise SeasonActivationGateError(str(exc)) from exc
    # Calendar edits cannot silently invalidate already published kickoffs.
    from matches.models import Match

    if (
        not adopting
        and Match.objects.filter(
            fixture__competition__season=season, scheduled_at__isnull=False
        ).exists()
    ):
        raise SeasonActivationGateError(
            "Reschedule or clear the existing published calendar through calendar governance before configuring automation."
        )
    policy.save()
    try:
        sync_windows(policy)
        if adopting:
            prepare_leagues(policy, include_legacy=True)
    except AUTOMATION_DOMAIN_ERRORS as exc:
        raise SeasonActivationGateError(str(exc)) from exc
    _notice(
        policy,
        key=f"configured:{policy.updated_at.isoformat()}",
        title="تقویم فصل ثبت شد",
        body=f"شروع فصل {season.name}: {policy.starts_at.isoformat()}. پنجره پیش‌فصل در همین زمان بسته می‌شود.",
    )
    return policy


def sync_windows(policy, now=None):
    from transfers.models import TransferWindow

    now = now or timezone.now()
    for label, kind, start, end in (
        ("Preseason", "SUMMER", policy.registration_opens_at, policy.starts_at),
        ("Midseason", "WINTER", policy.midseason_opens_at, policy.midseason_closes_at),
    ):
        name = f"Season {policy.season_id} {label}"
        window = (
            TransferWindow.objects.select_for_update()
            .filter(season=policy.season, name=name)
            .first()
        )
        if window is None:
            from transfers.services import TransferWindowService

            window = TransferWindowService.create_window(
                season=policy.season,
                name=name,
                window_type=kind,
                start_at=start,
                end_at=end,
            )
        elif (window.start_at, window.end_at) != (start, end):
            raise SeasonActivationGateError(
                "Existing transfer window dates differ; configure a consistent season calendar."
            )
        if window.status == "CANCELLED":
            raise SeasonActivationGateError(
                "An automatic season transfer window was cancelled."
            )
        # No late worker can reopen an elapsed window. Clock gates also protect requests between ticks.
        previous_status = window.status
        if now >= end and window.status != "CLOSED":
            window.status = "CLOSED"
            window.opened_at = window.opened_at or start
            window.closed_at = now
            window.cancelled_at = None
            window.save()
        elif start <= now < end and window.status == "SCHEDULED":
            from transfers.services import TransferWindowService

            TransferWindowService.open_window(window_id=window.pk)
            window.refresh_from_db()
        if previous_status != window.status:
            _notice(
                policy,
                key=f"window:{window.pk}:{window.status}",
                event_type="season.window.changed",
                title="وضعیت پنجره نقل‌وانتقالات تغییر کرد",
                body=f"{label}: {window.status}. پایان پنجره: {end.isoformat()}",
            )
        if end - timedelta(hours=24) <= now < end:
            _notice(
                policy,
                key=f"window:{window.pk}:deadline",
                title="مهلت نقل‌وانتقالات رو به پایان است",
                body=f"کمتر از ۲۴ ساعت تا پایان پنجره {label} باقی مانده است. زمان پایان: {end.isoformat()}",
            )


def readiness(season, now=None):
    from django.db.models import Exists, OuterRef

    from leagues.models import LeagueCompetition, LeagueMembership
    from matches.models import Match, MatchScheduleRevision

    now = now or timezone.now()
    policy = SeasonAutomationPolicy.objects.filter(season=season).first()
    reasons = []
    if policy is None:
        return {
            "ready": False,
            "reasons": ["Season automation calendar is not configured."],
            "ready_leagues": 0,
        }
    if now < policy.starts_at:
        reasons.append("Scheduled season starting time has not arrived.")
    if timezone.localdate(now) > season.end_date:
        reasons.append("Season end date has passed.")
    competitions = list(
        LeagueCompetition.objects.filter(
            season=season, status__in=["SCHEDULED", "ACTIVE"]
        )
    )
    if not competitions:
        reasons.append("No fully scheduled league is ready.")
    for competition in competitions:
        if (
            LeagueMembership.objects.filter(
                league=competition.league, season=season, status="REGISTERED"
            ).count()
            != 20
        ):
            reasons.append(
                f"League {competition.league_id}: requires 20 registered teams."
            )
        qs = Match.objects.filter(fixture__competition=competition)
        if qs.count() != 380 or qs.filter(scheduled_at__isnull=True).exists():
            reasons.append(
                f"League {competition.league_id}: complete kickoff calendar is required."
            )
        bound = MatchScheduleRevision.objects.filter(match_id=OuterRef("pk"), calendar_slot__isnull=False)
        if qs.annotate(calendar_bound=Exists(bound)).filter(calendar_bound=False).exists():
            reasons.append(f"League {competition.league.name}: calendar publication has not completed for every match.")
        if (
            qs.filter(scheduled_at__lt=policy.starts_at).exists()
            or qs.filter(
                scheduled_at__gte=policy.midseason_opens_at,
                scheduled_at__lt=policy.midseason_closes_at,
            ).exists()
        ):
            reasons.append(
                f"League {competition.league.name}: kickoff calendar conflicts with season or midseason boundaries."
            )
        if (
            season.status == "UPCOMING"
            and qs.filter(scheduled_at__lt=now - timedelta(minutes=5)).exists()
        ):
            reasons.append(
                f"League {competition.league_id}: missed kickoffs require calendar review."
            )
        if competition.league.competition_type == "ADULT":
            from clubs.operation_models import ClubLeagueEntryRequest

            registered = LeagueMembership.objects.filter(
                league=competition.league, season=season, status="REGISTERED"
            )
            approved = (
                ClubLeagueEntryRequest.objects.filter(
                    league=competition.league,
                    season=season,
                    club_id__in=registered.values("club_id"),
                    status="APPROVED",
                    payment__isnull=False,
                )
                .values("club_id")
                .distinct()
                .count()
            )
            if approved != 20:
                reasons.append(
                    f"League {competition.league_id}: entry payments and federation approvals are incomplete."
                )
            from clubs.operations import stadium_for

            if any(
                stadium_for(row.club) is None
                for row in registered.select_related("club")
            ):
                reasons.append(
                    f"League {competition.league_id}: all teams require an active owned or leased stadium."
                )
    return {
        "ready": not reasons,
        "reasons": reasons,
        "ready_leagues": len(competitions),
    }


@transaction.atomic
def prepare_leagues(policy, include_legacy=False):
    from django.conf import settings
    from django.db.models import Exists, OuterRef, Q

    from leagues.models import LeagueMembership
    from matches.kickoff_scheduling import CompetitionKickoffSchedulingService
    from matches.models import Match, MatchScheduleRevision
    from matches.services import LeagueFixtureGenerationService

    from .orchestration import SeasonCompetitionOrchestrationService as Workflow

    season = policy.season
    if season.status not in {"DRAFT", "UPCOMING"}:
        return
    windows = league_windows(policy)
    remaining = max(1, int(getattr(settings, "SEASON_KICKOFF_BATCH_SIZE", 50)))
    league_ids = list(
        LeagueMembership.objects.filter(
            season=season, status="REGISTERED", league__status="ACTIVE"
        )
        .values_list("league_id", flat=True)
        .distinct()
    )
    for league_id in league_ids:
        if (
            LeagueMembership.objects.filter(
                league_id=league_id, season=season, status="REGISTERED"
            ).count()
            != 20
        ):
            continue
        competition = LeagueFixtureGenerationService.generate(
            league_id=league_id, season_id=season.pk
        )
        run = SeasonOrchestrationRun.objects.filter(
            target_season=season, status="SUCCEEDED"
        ).first()
        for window in windows:
            Workflow._persist_calendar_slot(
                run=run,
                season=season,
                source_type="LEAGUE",
                league_competition=competition,
                federation_competition=None,
                window=window,
            )
        bound = MatchScheduleRevision.objects.filter(
            match_id=OuterRef("pk"), calendar_slot__isnull=False
        )
        matches = Match.objects.filter(fixture__competition=competition).annotate(
            calendar_bound=Exists(bound)
        )
        if not include_legacy:
            matches = matches.filter(
                Q(scheduled_at__isnull=True) | Q(calendar_bound=False)
            )
        matches = matches.order_by("fixture__round_number", "id")[:remaining]
        for match in matches.select_related("fixture"):
            CompetitionKickoffSchedulingService.schedule_match_from_calendar(
                match_id=match.pk, performed_by=policy.operator
            )
            remaining -= 1
        if remaining <= 0:
            return
    # Orchestrated group/knockout fixtures have their own immutable date windows.
    for match_id in (
        Match.objects.filter(
            fixture__federation_competition__season=season, scheduled_at__isnull=True
        )
        .order_by("fixture__round_number", "id")
        .values_list("pk", flat=True)[:remaining]
    ):
        CompetitionKickoffSchedulingService.schedule_match_from_calendar(
            match_id=match_id, performed_by=policy.operator
        )


def _successor(policy):
    from .services import SeasonService

    source = policy.season
    # Row-locked caller and unique season year prevent duplicate successors.
    target = Season.objects.filter(year=source.year + 1).first()
    if target is None:
        delta = timedelta(days=(source.end_date - source.start_date).days + 31)
        start_date = source.start_date + delta
        target = SeasonService.create_season(
            name=f"FTG Season {source.year + 1}",
            year=source.year + 1,
            start_date=start_date,
            end_date=source.end_date + delta,
        )
    delta = target.start_date - source.start_date
    SeasonAutomationPolicy.objects.get_or_create(
        season=target,
        defaults={
            "operator": policy.operator,
            "registration_opens_at": policy.registration_opens_at + delta,
            "starts_at": policy.starts_at + delta,
            "midseason_opens_at": policy.midseason_opens_at + delta,
            "midseason_closes_at": policy.midseason_closes_at + delta,
            "round_gap_days": policy.round_gap_days,
            "kickoff_time": policy.kickoff_time,
            "enabled": True,
            "auto_successor": policy.auto_successor,
            "continental_enabled": policy.continental_enabled,
        },
    )
    return target


def tick_one(policy_id, now=None):
    from .orchestration import SeasonCompetitionOrchestrationService as Workflow
    from .services import SeasonService

    now = now or timezone.now()
    run_id = None
    try:
        with transaction.atomic():
            policy = (
                SeasonAutomationPolicy.objects.select_for_update(skip_locked=True)
                .select_related("season", "operator")
                .filter(pk=policy_id, enabled=True)
                .first()
            )
            if policy is None:
                return {"state": "SKIPPED"}
            if not can_configure(policy.operator):
                raise SeasonActivationGateError(
                    "Season automation operator no longer has governance authority."
                )
            season = Season.objects.select_for_update().get(pk=policy.season_id)
            sync_windows(policy, now)
            if season.status in {"DRAFT", "UPCOMING"}:
                incoming = SeasonOrchestrationRun.objects.filter(
                    target_season=season
                ).first()
                earlier = Season.objects.filter(
                    start_date__lt=season.start_date, status__in=["ACTIVE", "UPCOMING"]
                ).exists()
                if earlier or (incoming and incoming.status != "SUCCEEDED"):
                    result = {
                        "state": "WAITING",
                        "reasons": ["Predecessor preparation has not completed."],
                    }
                else:
                    prepare_leagues(policy)
                    if season.status == "DRAFT":
                        season = SeasonService.mark_upcoming(season_id=season.pk)
                    result = readiness(season, now)
                    result["state"] = "WAITING"
                    if result["ready"]:
                        if incoming:
                            Workflow.activate_target(run_id=incoming.pk)
                        else:
                            SeasonService.activate(season_id=season.pk)
                        result["state"] = "ACTIVE"
            elif season.status == "ACTIVE":
                if policy.auto_successor:
                    _successor(
                        policy
                    )  # Give mother governance a season of notice to publish next year's quotas.
                from leagues.models import LeagueCompetition
                from matches.models import Match
                from matches.services import LeagueCompetitionLifecycleService

                for competition in LeagueCompetition.objects.filter(
                    season=season
                ).exclude(status="COMPLETED"):
                    if (
                        Match.objects.filter(fixture__competition=competition).count()
                        == 380
                        and not Match.objects.filter(fixture__competition=competition)
                        .exclude(status="COMPLETED")
                        .exists()
                    ):
                        LeagueCompetitionLifecycleService.complete(
                            competition_id=competition.pk
                        )
                try:
                    SeasonService._assert_competitions_terminal(season)
                except SeasonError as exc:
                    result = {"state": "WAITING", "reasons": [str(exc)]}
                else:
                    if policy.auto_successor:
                        target = _successor(policy)
                        run = Workflow.prepare(
                            source_season_id=season.pk, target_season_id=target.pk
                        )
                        run_id = run.pk
                        result = {"state": "PREPARING", "target_season_id": target.pk}
                    else:
                        SeasonService.complete(season_id=season.pk)
                        result = {"state": "COMPLETED"}
            elif season.status == "COMPLETED":
                pending = (
                    SeasonOrchestrationRun.objects.filter(source_season=season)
                    .exclude(status="SUCCEEDED")
                    .first()
                )
                run_id = pending.pk if pending else None
                result = {"state": "COMPLETED"}
            else:
                result = {"state": season.status}
        # The existing workflow owns its independent durable step transactions.
        # Never wrap it in the season clock's transaction or lose recovery receipts.
        if run_id:
            from .authority import season_execution_authority

            with season_execution_authority(policy):
                run = Workflow.execute(run_id=run_id, performed_by=policy.operator)
            result = {
                "state": "SUCCESSOR_PREPARED",
                "target_season_id": run.target_season_id,
            }
    except (SeasonError, *AUTOMATION_DOMAIN_ERRORS) as exc:
        result = {"state": "WAITING", "reasons": [str(exc)]}
    with transaction.atomic():
        policy = SeasonAutomationPolicy.objects.select_for_update().get(pk=policy_id)
        if result != policy.last_result:
            from .orchestration_policy import payload_sha256

            _notice(
                policy,
                key=f"state:{payload_sha256(result)}",
                title="وضعیت چرخه فصل تغییر کرد",
                body=f"{result.get('state')}: {'; '.join(result.get('reasons', []))}",
                event_type="season.automation.changed",
            )
        policy.last_checked_at, policy.last_result = now, result
        policy.save(update_fields=["last_checked_at", "last_result", "updated_at"])
    return {"season_id": policy.season_id, **result}


def process_due_seasons():
    return [
        tick_one(pk)
        for pk in SeasonAutomationPolicy.objects.filter(enabled=True)
        .exclude(
            season__status__in=["ARCHIVED", "CANCELLED"],
        )
        .order_by("season__start_date")
        .values_list("pk", flat=True)
    ]


from federations.exceptions import FederationError
from leagues.exceptions import LeagueError
from matches.exceptions import MatchDomainError
from transfers.exceptions import TransferWindowError

AUTOMATION_DOMAIN_ERRORS = (
    FederationError,
    LeagueError,
    MatchDomainError,
    TransferWindowError,
)
