"""Backend logic for matchmaking."""

import json
import logging
from typing import List

from django.conf import settings
from django.db.models import F, Max, Q, Window
from django.db.models.functions import Rank
from django.utils import timezone

from promptgame.gameui.cooldown import accounts_with_cooldown_info
from promptgame.gameui.models import Account
from promptgame.gameui.tiers import tier_bounds

log = logging.getLogger(__name__)


def generate_new_targets(attacking_account: Account) -> List[int]:
    """Rank accounts by balance and select MATCHMAKING_MAX_ACCOUNTS 'similar
    accounts' based on rank. Also filters for cooldown, locking, tiers, etc."""
    # We annotate accounts with a flag indicating whether the attacker is in a
    # 'cooldown' that would prevent them from attacking each of the accounts. We
    # then filter for accounts that are not globally locked and not in cooldown
    # wrt this specific attacker.
    valid_accounts = accounts_with_cooldown_info(attacker=attacking_account).filter(
        Q(unlock_at__lt=timezone.now()) | Q(id=attacking_account.id),
        attacker_in_cooldown=False,
    )

    # This queryset ranks accounts by balance and adds a rank for each one
    ranked_accounts = valid_accounts.annotate(
        rank=Window(expression=Rank(), order_by=F("balance").desc()),
    )

    # get the highest rank available (tells us how many possible ranks there
    # are; this is slightly different than the total # of available accounts
    # because it allows ties)
    (max_rank,) = ranked_accounts.aggregate(Max("rank")).values()

    # Get rank of the current user
    my_account_rank = (
        valid_accounts.filter(balance__gt=attacking_account.balance).count() + 1
    )

    # Delta rank for the rank range
    rank_delta = max(
        settings.MATCHMAKING_RANK_FRACTION * max_rank, settings.MATCHMAKING_MAX_ACCOUNTS
    )

    # hard-limit us to accounts in a permissible tier
    lower_balance_bound, upper_balance_bound = tier_bounds(attacking_account.balance)
    # replace lower bound with INT_MIN if it's -infty, INT_MAX if it's +infty
    if lower_balance_bound == float("-inf"):
        lower_balance_bound = -2147483648
    if upper_balance_bound == float("inf"):
        upper_balance_bound = 2147483647

    # Filter the accounts which are within the desired range
    similar_accounts = (
        ranked_accounts.filter(
            # Minor bug: if the current user's balance changes, or the balance
            # of accounts of similar rank changes, then this query might return
            # an empty or slightly inaccurate set. I don't think this is likely
            # enough to be worth fixing.
            # We do 3 * rank_delta because we want to oversample from low-ranked
            # accounts to make attacking easier, especially for new players.
            rank__gte=my_account_rank - 3 * rank_delta,
            rank__lte=my_account_rank + rank_delta,
            balance__gte=lower_balance_bound,
            balance__lte=upper_balance_bound,
        )
        .exclude(id=attacking_account.id)
        .order_by("?")[: settings.MATCHMAKING_MAX_ACCOUNTS]  # Random ordering
    )

    account_ids = list(similar_accounts.values_list("id", flat=True))

    return account_ids


def get_or_update_targets(
    attacking_account: Account, force_refresh: bool = False
) -> List[Account]:
    """Get the target accounts for this accounts (update the list if it doesn't exist or if force_refresh is True."""
    if attacking_account.cached_target_accounts is None or force_refresh:
        attacking_account.cached_target_accounts = json.dumps(
            {"account_ids": generate_new_targets(attacking_account)}
        )
        attacking_account.save()

    # create Account objects for rendering
    account_ids = json.loads(attacking_account.cached_target_accounts)["account_ids"]
    target_accounts = list(
        accounts_with_cooldown_info(attacker=attacking_account)
        .filter(id__in=account_ids)
        .order_by("balance")
    )

    if len(target_accounts) != len(account_ids):
        # probable error
        log.warning(
            f"Matchmaking returned {len(target_accounts)=} accounts, but we "
            f"expected {len(account_ids)=} to match the number returned by "
            f"the raw SQL query ({len(set(account_ids))=})"
        )
    return target_accounts
