from __future__ import annotations

from decimal import Decimal
from uuid import UUID

from django.db import IntegrityError, transaction
from django.db.models import Sum

from players.models import Player
from players.age import sporting_age, SEASON_AGE_POLICY
from seasons.constants import SeasonStatus
from seasons.models import Season

from .exceptions import PlayerProgressionIntegrityError, PlayerProgressionUnavailableError
from .models import (
    PlayerAttributeRatingChange,
    PlayerDevelopmentProgress,
    PlayerRatingMaterializationRun,
    PlayerRatingMaterializationTrigger,
    PlayerSeasonAgingResult,
    TrainingDevelopmentResult,
)
from .progression import (
    AGING_POLICY_KEY,
    AGING_POLICY_VERSION,
    MATERIALIZATION_POLICY_KEY,
    MATERIALIZATION_POLICY_VERSION,
    PlayerAgingPolicyV1,
    PlayerRatingMaterializationPolicyV1,
    payload_sha256,
)


def _source_uuid(value):
    if value is None:
        return None
    # Season has an integer PK; UUIDField persists integer references as UUID(int=pk).
    # Canonicalize before hashing, not only when the database adapter saves it.
    if isinstance(value, int) or (isinstance(value, str) and value.isdecimal()):
        return UUID(int=int(value))
    return UUID(str(value))


def _same_source(left, right):
    try:
        return _source_uuid(left) == _source_uuid(right)
    except (ValueError, TypeError, AttributeError):
        return False


class PlayerProgressionMaterializationService:
    @classmethod
    @transaction.atomic
    def materialize_player(cls, *, player_id, trigger_type, source_reference=None, observed_on):
        source_reference = _source_uuid(source_reference)
        try:
            player = Player.objects.select_for_update().get(pk=player_id)
        except Player.DoesNotExist as exc:
            raise PlayerProgressionUnavailableError("Player does not exist.") from exc

        if source_reference is not None:
            existing = PlayerRatingMaterializationRun.objects.filter(
                player=player, trigger_type=trigger_type, source_reference=source_reference
            ).prefetch_related("attribute_changes").first()
            if existing is not None:
                cls.verify_run(existing)
                return existing

        if player.career.status != "ACTIVE":
            raise PlayerProgressionUnavailableError("Completed player cards cannot develop further.")

        latest_run = PlayerRatingMaterializationRun.objects.filter(player=player).order_by("-observed_on", "-created_at").first()
        if latest_run is not None and latest_run.observed_on > observed_on:
            raise PlayerProgressionUnavailableError(
                "Player progression must be materialized chronologically; an older observation cannot follow a newer rating run."
            )

        progress_rows = {
            row.attribute_name: row
            for row in PlayerDevelopmentProgress.objects.select_for_update()
            .filter(player=player).order_by("attribute_name")
        }
        eligible = cls._eligible_credits(player_id=player.pk, observed_on=observed_on)
        missing_progress = set(eligible) - set(progress_rows)
        if missing_progress:
            raise PlayerProgressionIntegrityError("Causally earned development credit is missing its progress ledger row.")
        available = {}
        for name, row in progress_rows.items():
            earned = eligible.get(name, Decimal("0.000000"))
            if row.total_points < earned:
                raise PlayerProgressionIntegrityError(f"Development progress total is below earned credit for {name}.")
            if row.consumed_points > earned:
                raise PlayerProgressionIntegrityError(
                    f"Consumed development credit exceeds causally earned credit for {name}."
                )
            available[name] = (earned - row.consumed_points).quantize(Decimal("0.000001"))
        ratings_before = cls._ratings(player)
        result = PlayerRatingMaterializationPolicyV1.materialize(
            position=player.position,
            ratings=ratings_before,
            potential=player.potential,
            date_of_birth=player.date_of_birth,
            observed_on=observed_on,
            available_credits=available,
            age_years=(sporting_age(player) if trigger_type == PlayerRatingMaterializationTrigger.SEASON_CREDIT
                       else sporting_age(player, observed_on)),
        )

        change_payload = []
        for change in result.changes:
            row = progress_rows[change.attribute_name]
            row.consumed_points += change.credit_consumed
            row.save(update_fields=["consumed_points", "updated_at"])
            setattr(player, change.attribute_name, change.rating_after)
            change_payload.append({
                "attribute": change.attribute_name,
                "rating_before": change.rating_before,
                "rating_after": change.rating_after,
                "rating_delta": change.delta,
                "credit_available_before": str(change.credit_available_before),
                "credit_consumed": str(change.credit_consumed),
                "credit_available_after": str(change.credit_available_after),
            })
        player.overall = result.overall_after
        if result.changes:
            update_fields = [c.attribute_name for c in result.changes] + ["overall", "updated_at"]
            player.save(update_fields=update_fields)

        evidence = {
            "schema_version": 1,
            "player_id": str(player.pk),
            "trigger_type": trigger_type,
            "source_reference": str(source_reference) if source_reference else None,
            "observed_on": observed_on.isoformat(),
            "age_years": result.age_years,
            "overall_before": result.overall_before,
            "overall_after": result.overall_after,
            "potential": result.potential,
            "total_credit_consumed": str(result.total_credit_consumed),
            "retirement_eligible": result.age_years >= 40,
            "changes": change_payload,
            "policy_key": MATERIALIZATION_POLICY_KEY,
            "policy_version": MATERIALIZATION_POLICY_VERSION,
        }
        try:
            run = PlayerRatingMaterializationRun.objects.create(
                player=player,
                trigger_type=trigger_type,
                source_reference=source_reference,
                observed_on=observed_on,
                age_years=result.age_years,
                overall_before=result.overall_before,
                overall_after=result.overall_after,
                potential=result.potential,
                total_credit_consumed=result.total_credit_consumed,
                retirement_eligible=result.age_years >= 40,
                evidence_payload=evidence,
                evidence_hash=payload_sha256(evidence),
                policy_key=MATERIALIZATION_POLICY_KEY,
                policy_version=MATERIALIZATION_POLICY_VERSION,
            )
        except IntegrityError as exc:
            raise PlayerProgressionIntegrityError("Rating materialization violates a database invariant.") from exc
        for change in result.changes:
            PlayerAttributeRatingChange.objects.create(
                run=run,
                attribute_name=change.attribute_name,
                rating_before=change.rating_before,
                rating_after=change.rating_after,
                rating_delta=change.delta,
                credit_available_before=change.credit_available_before,
                credit_consumed=change.credit_consumed,
                credit_available_after=change.credit_available_after,
            )
        return run

    @staticmethod
    def _eligible_credits(*, player_id, observed_on):
        from .models import MatchExperienceDevelopmentResult

        totals = {}
        training_rows = (
            TrainingDevelopmentResult.objects.filter(player_id=player_id, training_date__lte=observed_on)
            .values("attribute_name").annotate(total=Sum("final_effect"))
        )
        for row in training_rows:
            totals[row["attribute_name"]] = row["total"] or Decimal("0.000000")
        match_rows = (
            MatchExperienceDevelopmentResult.objects.filter(
                player_id=player_id, dynamic_effect__match__completed_at__date__lte=observed_on
            ).values("attribute_name").annotate(total=Sum("credit"))
        )
        for row in match_rows:
            totals[row["attribute_name"]] = totals.get(row["attribute_name"], Decimal("0")) + (row["total"] or Decimal("0"))
        return {name: Decimal(value).quantize(Decimal("0.000001")) for name, value in totals.items()}

    @staticmethod
    def _ratings(player):
        from training.constants import TRAINABLE_PLAYER_ATTRIBUTES
        return {name: int(getattr(player, name)) for name in TRAINABLE_PLAYER_ATTRIBUTES}

    @classmethod
    def verify_run(cls, run):
        if payload_sha256(run.evidence_payload) != run.evidence_hash:
            raise PlayerProgressionIntegrityError("Rating materialization evidence hash is invalid.")
        payload = run.evidence_payload
        if (run.trigger_type == PlayerRatingMaterializationTrigger.SEASON_END
                and not _same_source(payload.get("season_id"), run.source_reference)):
            raise PlayerProgressionIntegrityError("Season aging evidence does not match its stored season reference.")
        expected = {
            "player_id": str(run.player_id),
            "trigger_type": run.trigger_type,
            "source_reference": str(run.source_reference) if run.source_reference else None,
            "observed_on": run.observed_on.isoformat(),
            "age_years": run.age_years,
            "overall_before": run.overall_before,
            "overall_after": run.overall_after,
            "potential": run.potential,
            "total_credit_consumed": str(run.total_credit_consumed),
            "retirement_eligible": run.retirement_eligible,
        }
        for key, value in expected.items():
            # Historic SEASON_END evidence already contained the canonical
            # season_id but omitted the duplicate source_reference property.
            # Preserve its original hash while validating that its season
            # identity really equals the stored UUID. New rows include both.
            if key == "source_reference":
                candidate = payload.get(key)
                if key not in payload and run.trigger_type == PlayerRatingMaterializationTrigger.SEASON_END:
                    candidate = payload.get("season_id")
                if _same_source(candidate, run.source_reference):
                    continue
            if (key == "total_credit_consumed" and key not in payload
                    and run.trigger_type == PlayerRatingMaterializationTrigger.SEASON_END
                    and run.total_credit_consumed == Decimal("0")):
                # Legacy aging never consumes development credit. Attribute
                # rows are reconciled below; preserve the original evidence hash.
                continue
            if payload.get(key) != value:
                raise PlayerProgressionIntegrityError(f"Rating materialization evidence field {key} does not match persisted data.")
        changes = list(run.attribute_changes.all())
        consumed = sum((row.credit_consumed for row in changes), Decimal("0.000000"))
        if consumed != run.total_credit_consumed:
            raise PlayerProgressionIntegrityError("Rating materialization credit total does not reconcile.")
        for row in changes:
            if row.rating_after - row.rating_before != row.rating_delta:
                raise PlayerProgressionIntegrityError("Rating materialization delta does not reconcile.")
        return run


class PlayerSeasonAgingService:
    @classmethod
    @transaction.atomic
    def apply_player(cls, *, player_id, season_id):
        try:
            season = Season.objects.get(pk=season_id)
        except Season.DoesNotExist as exc:
            raise PlayerProgressionUnavailableError("Season does not exist.") from exc
        if season.status not in {SeasonStatus.COMPLETED, SeasonStatus.ARCHIVED}:
            raise PlayerProgressionUnavailableError("Aging requires a completed or archived season.")

        # Serialize retries before checking the unique player/season result.
        player = Player.objects.select_for_update(of=("self",)).select_related("career").get(pk=player_id)
        existing = PlayerSeasonAgingResult.objects.filter(player_id=player_id, season=season).select_related("materialization_run").first()
        if existing is not None:
            cls.verify_result(existing)
            return existing

        if player.career.status != "ACTIVE":
            raise PlayerProgressionUnavailableError("Retired player cards cannot age or change ratings.")
        if season.end_date < player.age_reference_date:
            raise PlayerProgressionUnavailableError("A season before this player's age baseline cannot age the player.")
        latest = PlayerSeasonAgingResult.objects.filter(player=player).order_by("-season__end_date").first()
        if latest and latest.season.end_date > season.end_date:
            raise PlayerProgressionUnavailableError("Season aging must be processed chronologically.")
        # Late processing still follows the already-completed sporting years;
        # the season's scheduled end date is not the execution wall clock.
        age_before = sporting_age(player)
        training_rows = (
            TrainingDevelopmentResult.objects.filter(
                player=player,
                training_date__gte=season.start_date,
                training_date__lte=season.end_date,
            )
            .values("attribute_name")
            .annotate(total=Sum("final_effect"))
        )
        training_by_attribute = {row["attribute_name"]: row["total"] or Decimal("0") for row in training_rows}
        ratings_before = PlayerProgressionMaterializationService._ratings(player)
        result = PlayerAgingPolicyV1.apply(
            position=player.position,
            ratings=ratings_before,
            date_of_birth=player.date_of_birth,
            observed_on=season.end_date,
            season_training_by_attribute=training_by_attribute,
            player_seed=f"{player.pk}|{season.pk}|{season.end_date.isoformat()}",
            age_years=age_before + 1,
        )
        changes_payload = []
        for attribute, delta in sorted(result.attribute_deltas.items()):
            before = ratings_before[attribute]
            after = result.ratings_after[attribute]
            setattr(player, attribute, after)
            changes_payload.append({"attribute": attribute, "rating_before": before, "rating_after": after, "rating_delta": delta})
        player.overall = result.overall_after
        if changes_payload:
            player.save(update_fields=[r["attribute"] for r in changes_payload] + ["overall", "updated_at"])

        run_payload = {
            "schema_version": 1,
            "season_age_policy": SEASON_AGE_POLICY,
            "age_before_season_end": age_before,
            "player_id": str(player.pk),
            "season_id": str(season.pk),
            "source_reference": str(_source_uuid(season.pk)),
            "trigger_type": PlayerRatingMaterializationTrigger.SEASON_END,
            "observed_on": season.end_date.isoformat(),
            "age_years": result.age_years,
            "overall_before": result.overall_before,
            "overall_after": result.overall_after,
            "potential": player.potential,
            "training_protection": str(result.training_protection),
            "total_credit_consumed": "0.000000",
            "base_decline_points": result.base_decline_points,
            "applied_decline_points": result.applied_decline_points,
            "retirement_eligible": result.retirement_eligible,
            "changes": changes_payload,
            "policy_key": AGING_POLICY_KEY,
            "policy_version": AGING_POLICY_VERSION,
        }
        run = PlayerRatingMaterializationRun.objects.create(
            player=player,
            trigger_type=PlayerRatingMaterializationTrigger.SEASON_END,
            source_reference=_source_uuid(season.pk),
            observed_on=season.end_date,
            age_years=result.age_years,
            overall_before=result.overall_before,
            overall_after=result.overall_after,
            potential=player.potential,
            total_credit_consumed=Decimal("0.000000"),
            retirement_eligible=result.retirement_eligible,
            evidence_payload=run_payload,
            evidence_hash=payload_sha256(run_payload),
            policy_key=AGING_POLICY_KEY,
            policy_version=AGING_POLICY_VERSION,
        )
        for row in changes_payload:
            PlayerAttributeRatingChange.objects.create(
                run=run,
                attribute_name=row["attribute"],
                rating_before=row["rating_before"],
                rating_after=row["rating_after"],
                rating_delta=row["rating_delta"],
                credit_available_before=Decimal("0.000000"),
                credit_consumed=Decimal("0.000000"),
                credit_available_after=Decimal("0.000000"),
            )
        aging_payload = dict(run_payload)
        aging_payload["season_training_by_attribute"] = {k: str(v) for k, v in sorted(training_by_attribute.items())}
        aging = PlayerSeasonAgingResult.objects.create(
            player=player,
            season=season,
            materialization_run=run,
            age_years=result.age_years,
            base_decline_points=result.base_decline_points,
            applied_decline_points=result.applied_decline_points,
            training_protection=result.training_protection,
            retirement_eligible=result.retirement_eligible,
            evidence_payload=aging_payload,
            evidence_hash=payload_sha256(aging_payload),
            policy_key=AGING_POLICY_KEY,
            policy_version=AGING_POLICY_VERSION,
        )
        return aging

    @classmethod
    def process_season(cls, *, season_id):
        season = Season.objects.get(pk=season_id)
        results = []
        for player_id in Player.objects.filter(career__status="ACTIVE", age_reference_date__lte=season.end_date).order_by("pk").values_list("pk", flat=True).iterator():
            PlayerProgressionMaterializationService.materialize_player(
                player_id=player_id,
                trigger_type=PlayerRatingMaterializationTrigger.SEASON_CREDIT,
                source_reference=season.pk,
                observed_on=season.end_date,
            )
            results.append(cls.apply_player(player_id=player_id, season_id=season.pk))
        return results

    @staticmethod
    def verify_result(result):
        if payload_sha256(result.evidence_payload) != result.evidence_hash:
            raise PlayerProgressionIntegrityError("Season aging evidence hash is invalid.")
        PlayerProgressionMaterializationService.verify_run(result.materialization_run)
        if result.applied_decline_points > result.base_decline_points:
            raise PlayerProgressionIntegrityError("Applied aging decline exceeds base decline.")
        return result
