from typeguard import typechecked
from typing import Any, Union, List, Callable

from rstt import Player
from rstt.ranking import Standing
from rstt.stypes import Inference, RatingSystem, Observer


def set_equi(func: Callable[..., Any]) -> Callable[..., Any]:
    def wrapper_set(self, *args: Any, **kwargs: Any) -> Any:
        set_action = func(self, *args, **kwargs)
        if self._Ranking__maintain_equivalence:
            self._Ranking__ContainerEquivalence()
        return set_action
    return wrapper_set

def get_equi(func: Callable[..., Any]) -> Callable[..., Any]:
    def wrapper_get(self, *args: Any, **kwars: Any) -> Any:
        if self._Ranking__maintain_equivalence:
            self._Ranking__ContainerEquivalence()
        return func(self, *args, **kwars)
    return wrapper_get
    
def get_disamb(func: Callable[..., Any]) -> Callable[..., Any]:
    def wrapper_get(self, *args: Any, **kwars: Any) -> Any:
        if self._Ranking__maintain_disambiguity:
            self._Ranking__RankDisambiguity()
        return func(self, *args, **kwars)
    return wrapper_get
    
def set_disamb(func: Callable[..., Any]) -> Callable[..., Any]:
    def wrapper_set(self, *args: Any, **kwargs: Any) -> Any:
        set_action = func(self, *args, **kwargs)
        if self._Ranking__maintain_disambiguity:
            self._Ranking__RankDisambiguity()
        return set_action
    return wrapper_set


class Ranking():
    '''   
    NOTE:
        - The Ranking class has two dict[Player, ...], (RatingSystem, Standing) - which induce redundancy and ambiguity.
        The 'Container Equivalence' and 'Rank Disambiguity' (SEE GLOSSARY) are two properties that needs to be enforced
        to guarantee a well defined behaviour for the Ranking class. Hopefully it is also sufficient.
        - The RatingSystem act like a defaultdict. Get operations can induce set operations.
        This is tricky as a player could be inserted in the ranking using 'read' methods of the interface.
        
    GLOSSARY:
        - Container Equivalence (Union == Intersection):
        (key in self.datamodel.ratings) <=> (key in self.standing).
        In the code we refer to 'equivalence'
        - Rank Disambiguity (point '=' rating):
        self.datamodel.ordinal(key) == self.standing.value(key) for all keys.
        In the code we refer to 'disambiguity'

    # !!!
        - self.__disambiguity == True && self._Standing__sorted == False
        This means that the players have the correct points but are not correctly ranked.
        It can happen when 'self.standing._keep_sorted_ == False' which is triggered by the user outside of the Ranking methods.
    
    # ??? 
        - Can this state be reached from the intended Ranking usage ?
        How to detect it and what to do ?
    '''
    @typechecked
    def __init__(self, name: str = '',
                 standing: Standing = Standing(),
                 datamodel: RatingSystem = None, 
                 backend: Inference = None,
                 handler: Observer = None,
                 players: Union[None, List[Player]] = None):
        
        # name/identifier - usefull for plot
        self.name = name
        
        # fundamental notion of the Ranking Class
        self.standing = standing
        self.backend = backend
        self.datamodel = datamodel
        self.handler = handler

        # state control variable
        self.__equivalence = True
        self.__disambiguity = True

        # protocol control variable
        self.__maintain_equivalence = True
        self.__maintain_disambiguity = True
        
        if players:
            self.add(keys=players)
        
    # --- Containers standard methods --- #
    @set_disamb
    @set_equi
    @typechecked
    def add(self, keys: List[Player]):
        # turn maintainance for optimization
        should_maintain = self.__maintain_equivalence
        self.__maintain_equivalence = False
        
        # perform iteratively addition
        for key in keys:
            self.__add(key)
        
        # restaure Ranking status
        self.__maintain_equivalence = should_maintain
             
    def __add(self, key: Player):
        if key in self:
            msg = f'Can not add a key already present in the Ranking, {key}'
            raise ValueError(msg)
        # default dict get operator for missing key
        self.datamodel.get(key)
        
        # self.datamodel do not match self.standing
        self.__equivalence = False
    
    # --- magic methods --- #
    def __getitem__(self, *args, **kwargs) -> Union[Player, List[Player], int, List[int]]:
        ''' get item based on a rank or a key
        
        NOBUG:
            - sorting is handled by the Standing itself
            - so is typechecked
        '''
        return self.standing.__getitem__(*args, **kwargs)
    
    def __delitem__(self, key: Player):
        ''' delete element from the Ranking
        
        REQ:
            - element needs to be remove both from the standing and the RatingSystem
            
        NOBUG:
            - del standing[key] is typechecked. CALL MUST BE BEFORE RatingSystem
        
        # ???:
            - could del succeed on standing but fail on RatingSystem.
            This would otentialy lead to an invalid ranking state because __delitem__ is not decorated.
            A ranking invalid state can exactly be the reason why this scenario happens.
            What is the best approach to this situation ?
        '''
        del self.standing[key]
        del self.datamodel[key]
            
    def __contains__(self, key: Player):
        '''
        NOTE:
            - it does match standing behavior as specified but
            depending on the choices, 'p in self' can be slower than
            'p in self.standing' / 'p in self.datamodel'
        '''
        return key in self.standing
    
    def __len__(self):
        return len(self.standing)
    
    def __iter__(self):
        return self.standing.__iter__()
                
    # --- getter --- #
    def rank(self, player: Player) -> int:
        return self[player]
        
    def ranks(self, players: List[Player]) -> List[int]:
        return [self.rank(player) for player in players]
        
    def rating(self, player: Player) -> Any:
        """Get method for rating

        Rating object is the internal model associated to a key.
        Ratings are used to automaticly compute values for the sorting feature of a Standing.

        Parameters
        ----------
        player : Player
            A key in the Ranking

        Returns
        -------
        _type_
            The associated model to the provided key. The type is defined by Ranking.RatingSystem.rtype
            
        Raises
        ------
        KeyError
        """
        if player in self: # NOBUG RatingSystem is a defaultdict
            return self.datamodel.get(player)
        else:
            msg = f"{player} is not present in {self.standing}"
            raise KeyError

    def ratings(self) -> List[Any]:
        """Get method for all ratings

        Returns
        -------
        list[_type_]
            A list of all rating object present in the Ranking, in order of the Standing.
        """
        return [self.rating(player) for player in self]
    
    def players(self) -> List[Player]:
        """Get method of all keys

        Alias for Ranking.standing.keys()

        Returns
        -------
        List[Player]
            A list of all player in descending order of their associated values.
        """
        return self.standing.keys()

    def point(self, player: Player) -> float:
        """Get the point associated to a key

        Alias for Ranking.standing.value(player)

        Returns
        -------
        float
            the associated value.
        """
        return self.standing.value(player)

    def points(self) -> List[float]:
        """Get method of all values

        Alias for Ranking.standing.values()

        Returns
        -------
        List[float]
            A list of all associated values in descending order.
        """
        return self.standing.values()

    def status(self):
        return  {'equivalence': self.__equivalence,
        'disambiguity': self.__disambiguity,
        'maintain_equivalence': self.__maintain_equivalence,
        'm_disambanbiguity': self.__maintain_disambiguity}
        
    # ??? items() -> List[(rank, player, ratings)]
    # ??? item(key) -> (rank, player, ratings)
    # --- setter --- #
    @set_disamb
    @set_equi
    def set_rating(self, key: Player, rating: Any):
        """A method to assign a rating to a Player

        
        The Ranking delegate this task to a 'RatingSystem' instance stored as attribute 'rankink.datamodel'.
        The RatingSystem define what rating type is accepted and wether a set operation is authorized for the provided key.
        
        Parameters
        ----------
        key : Player
            A Player
        rating : Any
            A rating object associated to the key
        """
        self.datamodel.set(key, rating)
        self.__equivalence = False
        
    # ??? remove()
    # ??? pop()
            
    # --- general purpose methods --- #
    @get_disamb
    @get_equi   
    def plot(self):
        self.standing.plot(standing_name=self.name)
    
    @set_disamb
    @set_equi
    def update(self, *args, **kwargs):
        """Update the Ratings

        # TODO: explain what happen here. GL&HF

        Parameters
        ----------
        observations : _type_
            _description_
        """
        
        self.forward(*args, **kwargs)
      
        # BUG: How do we know if the ranking state changed ?
        # HACK: assume we did change everything
        self.__disambiguity = False
        self.__equivalence = False
    
    def forward(self, *args, **kwargs):
        '''
        BUG:
            - while using BasicElo
        TypeError: GameByGame.handle_observations() got multiple values for argument 'infer'
        This was fixed 'user side' by giving a param 'games' to GameByGame.handle_observations() and explicilty calling
        Ranking.update(games=[])
        But it should not be necessary to make an explicit arg assignement. 
        And the source of the error never identified.
        
        It happens in the wrapper. an explanation could be related to the self parameters
        
        '''
        self.handler.handle_observations(infer=self.backend,
                            datamodel=self.datamodel,
                            *args, **kwargs)

    @get_disamb
    @get_equi
    def rerank(self, permutation: List[int], name: str = None, direct: bool = True):
        # TODO: write doc
        # check permutation validity
        if not (set(permutation) == set(i for i,_ in enumerate(self))):
            # TODO: write good msg error
            msg = ''
            raise ValueError(msg)
        
        # TODO: deal with direct=False
        
        pairs = []
        for current_rank, future_rank in enumerate(permutation):
            player = self[current_rank]
            ratings = self.datamodel.get(self[future_rank])
            pairs.append((player, ratings))
        for p, r in pairs:
            self.datamodel.set(p, r)
    
    @get_disamb
    @get_equi
    def fit(self, players: List[Player], name: str = ''):
        seeding= Standing()
        points = []
        for player in players:
            if player in self:
                points.append(self.point(player))
            else:
                points.append(self.standing._default_)
        seeding.add(players, points)
        return seeding
    
    # --- internal mechanism --- #
    def __ContainerEquivalence(self):
        ''' property checker
        
        '''
        # get keys
        standing_keys = set(self.standing.keys())
        RatingSystem_keys = set(self.datamodel.keys())
        
        if standing_keys == RatingSystem_keys:
            self.__equivalence = True
        elif standing_keys <= RatingSystem_keys: # a <= b means 'a.issubset(b)
            not_ranked_players = list(RatingSystem_keys - standing_keys) # a - b means a.difference(b)
            new_points = []
            for player in not_ranked_players:
                # NOBUG: player is in the RatingSystem. 'get()' is safe to perform.
                new_points.append(self.datamodel.ordinal(self.datamodel.get(player)))
            # NOBUG: no ambiguity is introduce this way
            self.standing.add(keys=not_ranked_players, values=new_points)
            self.__equivalence = True
        else:
            # TODO: write a good error message
            msg = f''
            raise RuntimeError(msg)
        
    def __RankDisambiguity(self):
        for player in self.standing:
            # TODO: performance check (a) assign only if needed (b) assign always
            # ??? (a / b) as user options ?
            rating = self.datamodel.get(player)
            point = self.datamodel.ordinal(rating)
            if self.standing.value(player) != point:
                self.standing[player] = point
        
        self.__disambiguity = True

