"""A module for rating systems, e.g. ELO."""

import logging
from math import sqrt, log, pi, isnan
from copy import copy
from datetime import datetime
from typing import Optional, Dict
from dataclasses import dataclass

# Constants
BASE_RATING = 1500.0
BASE_RD = 350.0
Q = log(10.0) / 400.0

# Logging setup
logging.info("Starting logger.")
logger = logging.getLogger("elo")
logger.setLevel(logging.INFO)

@dataclass
class Player:
    """Represents a player with a rating and rating deviation."""

    def __init__(self, rating: float = BASE_RATING, rd: float = BASE_RD):
        """
        Initializes a player with default rating and deviation.

        Parameters
        ----------
        rating: Initial rating of the player.
        rd: Initial rating deviation of the player.
        """
        self.rating: float = rating
        self.rd: float = rd
        self.last_fight_date: Optional[datetime] = None
        self.grd: Optional[float] = None  # g(rd)
        self.win_exp: Optional[float] = None  # Expected win probability


class Glicko2:
    """Implements Glicko-2 rating system for players."""

    def __init__(self):
        """Initializes the Glicko2 object with default settings."""
        self.c: float = 0.0
        self.set_c()
        self.players: Dict[str, Player] = {}

    def set_c(self, avg_rd: float = 200, days: int = 2500) -> None:
        """
        Sets the constant c for rating deviation decay over time.

        Parameters
        ----------
        avg_rd: Average rating deviation.
        days: Number of days over which decay is measured.
        """
        self.c = (BASE_RD**2 - avg_rd**2) / days

    @staticmethod
    def g(rd_opp: float) -> float:
        """
        Calculates the weight for an opponent's rating deviation.

        Parameters
        ----------
        rd_opp: Opponent's rating deviation.

        Returns
        -------
        Weight for opponent's deviation.
        """
        return 1.0 / sqrt(1.0 + (3.0 * Q**2) * (rd_opp**2) / (pi**2))

    @staticmethod
    def win_proba(r: float, r_opp: float, rd_opp: float) -> float:
        """
        Calculates expected win probability against an opponent.

        Parameters
        ----------
        r: Player's rating.
        r_opp: Opponent's rating.
        rd_opp: Opponent's rating deviation.

        Returns
        -------
        Expected win probability.
        """
        return 1.0 / (1.0 + pow(10.0, -Glicko2.g(rd_opp) * (r - r_opp) / 400.0))

    @staticmethod
    def d_squared(grd_opp: float, win_exp: float) -> float:
        """
        Calculates the variance of the rating.

        Parameters
        ----------
        grd_opp: g(rd) value of the opponent
        win_exp: Expected win probability.

        Returns
        -------
        Variance of the rating.
        """
        return (Q**2) * (grd_opp**2) * win_exp * (1.0 - win_exp)

    def update(
        self, p1_key: str, p2_key: str, outcome: str, date: datetime
    ) -> None:
        """
        Updates ratings for two players based on a match outcome.

        Parameters
        ----------
        p1_key: Key for player 1.
        p2_key: Key for player 2.
        outcome: Match outcome ('win', 'loss', or 'draw').
        date: Date of the match.
        """
        outcome_value = (
            0.0 if outcome == "loss" else 1.0 if outcome == "win" else 0.5
        )
        p1 = self.get_or_create_player(p1_key, date)
        p2 = self.get_or_create_player(p2_key, date)

        self.calculate_updates(p1, p2, outcome_value, date)

    def get_or_create_player(self, pkey: str, date: datetime) -> Player:
        """
        Gets or creates a player and updates their rating deviation.

        Parameters
        ----------
        pkey: Key for the player.
        date: Date to update the rating deviation.

        Returns
        -------
        The player object.
        """
        if pkey not in self.players:
            self.players[pkey] = Player()
        player = self.players[pkey]
        if player.last_fight_date:
            t = (date - player.last_fight_date).days
            player.rd = min(BASE_RD, sqrt(player.rd**2 + self.c * t))
        player.last_fight_date = date
        return player

    def calculate_updates(
        self, p1: Player, p2: Player, outcome: float, date: datetime
    ) -> None:
        """
        Calculates and applies updates to player ratings.

        Parameters
        ----------
        p1: Player 1 object.
        p2: Player 2 object.
        outcome: Outcome of the match for player 1.
        date: Date of the match.
        """
        p1.grd = Glicko2.g(p1.rd)
        p1.win_exp = Glicko2.win_proba(p1.rating, p2.rating, p2.rd)
        p2.grd = Glicko2.g(p2.rd)
        p2.win_exp = Glicko2.win_proba(p2.rating, p1.rating, p1.rd)

        for player_a, player_b, outcome_a in (
            (p1, p2, outcome),
            (p2, p1, 1.0 - outcome),
        ):
            rd2_d2opp = 1.0 / (player_a.rd**2) + Glicko2.d_squared(
                player_b.grd, player_b.win_exp
            )
            player_a.rating += (
                (Q / rd2_d2opp) * player_b.grd * (outcome_a - player_a.win_exp)
            )
            player_a.rd = sqrt(1.0 / rd2_d2opp)
            player_a.last_fight_date = date

    def get_rating(self, pkey: str) -> float:
        """Returns the rating of a player."""
        return self.players.get(pkey, Player()).rating

    def get_consistent_rating(self, pkey: str) -> float:
        """Returns the conservative rating of a player (rating - rd)."""
        player = self.players.get(pkey, Player())
        return player.rating - player.rd

    def get_player_data(self, pkey: str) -> Player:
        """Returns the player data for a given player key."""
        return self.players.get(pkey, Player())


class KalmanRating:
    """Implements Kalman Filter based rating system."""

    @dataclass
    class DualRating:
        """Container for offensive and defensive ratings."""

        @dataclass
        class Rating:
            """Simple Rating container class."""

            def __init__(self, rtg: float, var_rtg: float):
                self.rtg: float = rtg
                self.var_rtg: float = var_rtg

        def __init__(self, rtg: float, var_rtg: float):
            """
            Initializes DualRating with offensive and defensive ratings.

            :param rtg: Initial rating value.
            :param var_rtg: Initial variance of the rating.
            """
            self.o: KalmanRating.DualRating.Rating = (
                KalmanRating.DualRating.Rating(rtg, var_rtg)
            )
            self.d: KalmanRating.DualRating.Rating = (
                KalmanRating.DualRating.Rating(rtg, var_rtg)
            )

    def __init__(
        self,
        avg_rtg: float,
        avg_var_rtg: float,
        var_proc: float,
        var_meas: float,
        min_threshold: float = 0,
    ):
        """
        Initializes the KalmanRating system with given parameters.

        :param avg_rtg: Average rating.
        :param avg_var_rtg: Average variance of the rating.
        :param var_proc: Process variance.
        :param var_meas: Measurement variance.
        :param min_threshold: Minimum threshold for updates.
        """
        self.avg_rtg: float = avg_rtg
        self.avg_var_rtg: float = avg_var_rtg * avg_rtg
        self.var_proc: float = var_proc * avg_rtg
        self.var_meas: float = var_meas * avg_rtg
        self.min_threshold: float = min_threshold
        self.players: Dict[str, KalmanRating.DualRating] = {}

    def update(
        self,
        p1_key: str,
        p2_key: str,
        p1_offensive_rating: float,
        p2_offensive_rating: float,
    ) -> None:
        """
        Updates ratings for two players based on observed ratings.

        :param p1_key: Key for player 1.
        :param p2_key: Key for player 2.
        :param p1_offensive_rating: Observed offensive rating for player 1.
        :param p2_offensive_rating: Observed offensive rating for player 2.
        """
        p1 = self.players.setdefault(
            p1_key, self.DualRating(self.avg_rtg, self.avg_var_rtg)
        )
        p2 = self.players.setdefault(
            p2_key, self.DualRating(self.avg_rtg, self.avg_var_rtg)
        )

        to_update = []
        if not isnan(p1_offensive_rating):
            to_update += [
                (p1.o, copy(p2.d), p1_offensive_rating),
                (p2.d, copy(p1.o), p1_offensive_rating),
            ]
        if not isnan(p2_offensive_rating):
            to_update += [
                (p2.o, copy(p1.d), p2_offensive_rating),
                (p1.d, copy(p2.o), p2_offensive_rating),
            ]

        for rating1, rating2, observed_rating in to_update:
            self.update_rating(rating1, rating2, observed_rating)

    def update_rating(
        self,
        rating1: DualRating.Rating,
        rating2: DualRating.Rating,
        observed_rating: float,
    ) -> None:
        """
        Updates a player's rating using the Kalman filter equations.

        :param rating1: Rating object to be updated.
        :param rating2: Rating object used for prediction.
        :param observed_rating: Observed rating value.
        """
        predicted_rating = rating1.rtg + rating2.rtg - self.avg_rtg
        predicted_var = rating1.var_rtg + self.var_proc
        kalman_gain = predicted_var / (predicted_var + self.var_meas)

        rating1.rtg += kalman_gain * (observed_rating - predicted_rating)
        rating1.var_rtg *= 1 - kalman_gain

    def get_rating(self, pkey: str) -> DualRating:
        """Returns the DualRating object for a player."""
        return self.players.get(
            pkey, self.DualRating(self.avg_rtg, self.avg_var_rtg)
        )
