from math import ceil
from collections import defaultdict, Counter
from itertools import product, groupby, permutations, chain
from random import getrandbits, uniform
from typing import List, Dict, DefaultDict, Set, Tuple, Any, Optional, TYPE_CHECKING
from pydfs_lineup_optimizer.constants import PlayerRank
from pydfs_lineup_optimizer.solvers import Solver, SolverSign
from pydfs_lineup_optimizer.utils import list_intersection, get_positions_for_optimizer, get_remaining_positions, \
    get_players_grouped_by_teams
from pydfs_lineup_optimizer.lineup import Lineup
from pydfs_lineup_optimizer.player import Player


if TYPE_CHECKING:  # pragma: no cover
    from pydfs_lineup_optimizer.lineup_optimizer import LineupOptimizer
    from pydfs_lineup_optimizer.stacks import BaseGroup


__all__ = [
    'OptimizerRule', 'NormalObjective', 'RandomObjective', 'UniqueLineupRule', 'TotalPlayersRule',
    'LineupBudgetRule', 'LockedPlayersRule', 'PositionsRule', 'TeamMatesRule', 'MaxFromOneTeamRule',
    'MinSalaryCapRule', 'RemoveInjuredRule', 'MaxRepeatingPlayersRule',
    'ProjectedOwnershipRule', 'UniquePlayerRule', 'LateSwapRule',
    'RestrictPositionsForOpposingTeam', 'RosterSpacingRule', 'FanduelBaseballRosterRule',
    'TotalTeamsRule', 'FanduelSingleGameMVPRule', 'FanduelSingleGameMaxQBRule',
    'RestrictPositionsForSameTeamRule', 'ForcePositionsForOpposingTeamRule', 'GenericStacksRule',
    'MinStartersRule', 'MinExposureRule', 'FanduelSingleGameProRule', 'FanduelSingleGameStarRule',
]


class OptimizerRule:
    def __init__(self, optimizer: 'LineupOptimizer', all_players: List[Player], params: Dict[str, Any]):
        self.params = params
        self.optimizer = optimizer
        self.all_players = all_players

    def apply(self, solver: Solver, players_dict: Dict[Player, Any]):
        pass

    def apply_for_iteration(self, solver: Solver, players_dict: Dict[Player, Any], result: Optional[Lineup]):
        pass

    def post_optimize(self, solved_variables: List[str]):
        pass


class NormalObjective(OptimizerRule):
    def apply(self, solver, players_dict):
        variables = []
        coefficients = []
        for player, variable in players_dict.items():
            variables.append(variable)
            coefficients.append(player.fppg)
        solver.set_objective(variables, coefficients)


class RandomObjective(OptimizerRule):
    def apply_for_iteration(self, solver, players_dict, result):
        variables = []
        coefficients = []
        optimizer_min_deviation, optimizer_max_deviation = self.optimizer.get_deviation()
        for player, variable in players_dict.items():
            variables.append(variable)
            if player.fppg_floor is not None and player.fppg_ceil is not None:
                random_fppg = uniform(player.fppg_floor, player.fppg_ceil)
            else:
                multiplier = uniform(
                    player.min_deviation if player.min_deviation is not None else optimizer_min_deviation,
                    player.max_deviation if player.max_deviation is not None else optimizer_max_deviation
                )
                random_fppg = player.fppg * (1 + (-1 if bool(getrandbits(1)) else 1) * multiplier)
            coefficients.append(random_fppg)
        solver.set_objective(variables, coefficients)


class UniqueLineupRule(OptimizerRule):
    def __init__(self, optimizer, all_players, params):
        super(UniqueLineupRule, self).__init__(optimizer, all_players, params)
        self.used_combinations = []  # type: List[Any]

    def apply_for_iteration(self, solver, players_dict, result):
        if not result:
            return
        self.used_combinations.append([players_dict[player] for player in result])
        total_players = self.optimizer.total_players
        for variables in self.used_combinations:
            solver.add_constraint(variables, None, SolverSign.LTE, total_players - 1)


class TotalPlayersRule(OptimizerRule):
    def apply(self, solver, players_dict):
        variables = players_dict.values()
        solver.add_constraint(variables, None, SolverSign.EQ, self.optimizer.total_players)


class LineupBudgetRule(OptimizerRule):
    def apply(self, solver, players_dict):
        variables = []
        coefficients = []
        for player, variable in players_dict.items():
            variables.append(variable)
            coefficients.append(player.salary)
        solver.add_constraint(variables, coefficients, SolverSign.LTE, self.optimizer.budget)


class LockedPlayersRule(OptimizerRule):
    def __init__(self, optimizer, all_players, params):
        super(LockedPlayersRule, self).__init__(optimizer, all_players, params)
        self.used_players = defaultdict(int)  # type: DefaultDict[Player, int]
        self.total_lineups = params.get('n')
        self.remaining_iteration = params.get('n') + 1

    def apply_for_iteration(self, solver, players_dict, result):
        force_variables = []
        exclude_variables = []
        self.remaining_iteration -= 1
        if result:
            for player in result.lineup:
                self.used_players[player] += 1
        removed_players = []
        for player, used in self.used_players.items():
            max_exposure = player.max_exposure if player.max_exposure is not None else self.params.get('max_exposure')
            if max_exposure is not None and max_exposure <= used / self.total_lineups:
                removed_players.append(player)
                exclude_variables.append(players_dict[player])
        for player in self.optimizer.locked_players:
            if player not in removed_players:
                force_variables.append(players_dict[player])
        if force_variables:
            solver.add_constraint(force_variables, None, SolverSign.EQ, len(force_variables))
        if exclude_variables:
            solver.add_constraint(exclude_variables, None, SolverSign.EQ, 0)


class PositionsRule(OptimizerRule):
    def apply(self, solver, players_dict):
        optimizer = self.optimizer
        extra_positions = optimizer.players_with_same_position
        positions_combinations = set([tuple(sorted(player.positions)) for player in players_dict.keys()
                                      if len(player.positions) > 1])
        for rank, rank_positions in groupby(optimizer.settings.positions, lambda pos: pos.for_rank):
            positions = get_positions_for_optimizer(list(rank_positions), positions_combinations)
            unique_positions = optimizer.available_positions
            players_by_positions = {
                position: {variable for player, variable in players_dict.items()
                           if player.rank == rank and position in player.positions} for position in unique_positions
            }
            for position, places in positions.items():
                extra = 0
                if len(position) == 1:
                    extra = extra_positions.get(position[0], 0)
                players_with_position = set()
                for pos in position:
                    players_with_position.update(players_by_positions[pos])
                solver.add_constraint(players_with_position, None, SolverSign.GTE, places + extra)


class TeamMatesRule(OptimizerRule):
    def apply(self, solver, players_dict):
        for team, quantity in self.optimizer.players_from_one_team.items():
            players_from_same_team = [variable for player, variable in players_dict.items() if player.team == team]
            solver.add_constraint(players_from_same_team, None, SolverSign.EQ, quantity)


class MaxFromOneTeamRule(OptimizerRule):
    def apply(self, solver, players_dict):
        if not self.optimizer.max_from_one_team:
            return
        for team in self.optimizer.available_teams:
            players_from_team = [variable for player, variable in players_dict.items() if player.team == team]
            solver.add_constraint(players_from_team, None, SolverSign.LTE, self.optimizer.max_from_one_team)


class MinSalaryCapRule(OptimizerRule):
    def apply(self, solver, players_dict):
        variables = []
        coefficients = []
        for player, variable in players_dict.items():
            variables.append(variable)
            coefficients.append(player.salary)
        min_salary_cap = self.optimizer.min_salary_cap
        solver.add_constraint(variables, coefficients, SolverSign.GTE, min_salary_cap)


class RemoveInjuredRule(OptimizerRule):
    def apply(self, solver, players_dict):
        injured_players_variables = [variable for player, variable in players_dict.items() if player.is_injured]
        solver.add_constraint(injured_players_variables, None, SolverSign.EQ, 0)


class MaxRepeatingPlayersRule(OptimizerRule):
    def __init__(self, optimizer, all_players, params):
        super(MaxRepeatingPlayersRule, self).__init__(optimizer, all_players, params)
        self.exclude_combinations = []  # type: List[Any]

    def apply_for_iteration(self, solver, players_dict, result):
        max_repeating_players = self.optimizer.max_repeating_players
        if max_repeating_players is None or not result:
            return
        self.exclude_combinations.append([players_dict[player] for player in result])
        for players_combination in self.exclude_combinations:
            solver.add_constraint(players_combination, None, SolverSign.LTE, max_repeating_players)


class ProjectedOwnershipRule(OptimizerRule):
    def apply(self, solver, players_dict):
        min_variables = []
        min_coefficients = []
        max_variables = []
        max_coefficients = []
        min_projected_ownership = self.optimizer.min_projected_ownership
        max_projected_ownership = self.optimizer.max_projected_ownership
        for player, variable in players_dict.items():
            if player.projected_ownership is None:
                continue
            if min_projected_ownership:
                min_variables.append(variable)
                min_coefficients.append(player.projected_ownership - min_projected_ownership)
            if max_projected_ownership:
                max_variables.append(variable)
                max_coefficients.append(player.projected_ownership - max_projected_ownership)
        if min_variables:
            solver.add_constraint(min_variables, min_coefficients, SolverSign.GTE, 0)
        if max_variables:
            solver.add_constraint(max_variables, max_coefficients, SolverSign.LTE, 0)


class UniquePlayerRule(OptimizerRule):
    @staticmethod
    def sort_players(player_tuple):
        return player_tuple[0].full_name

    def apply(self, solver, players_dict):
        data = sorted(players_dict.items(), key=self.sort_players)
        for player_id, group_iterator in groupby(data, key=self.sort_players):
            group = list(group_iterator)
            if len(group) == 1:
                continue
            variables = [variable for player, variable in group]
            solver.add_constraint(variables, None, SolverSign.LTE, 1)


class LateSwapRule(OptimizerRule):
    def __init__(self, optimizer, all_players, params):
        super(LateSwapRule, self).__init__(optimizer, all_players, params)
        self.current_iteration = 0
        self.lineups = params.get('lineups')  # type: List[Lineup]

    def apply_for_iteration(self, solver, players_dict, result):
        current_lineup = self.lineups[self.current_iteration]
        unswappable_players = current_lineup.get_unswappable_players()
        remaining_positions = get_remaining_positions(self.optimizer.settings.positions, unswappable_players)
        # lock selected players
        for player in unswappable_players:
            solver.add_constraint([players_dict[player]], None, SolverSign.EQ, 1)
        # set remaining positions
        positions_combinations = set([tuple(sorted(player.positions)) for player in players_dict.keys()
                                      if len(player.positions) > 1])
        positions = get_positions_for_optimizer(remaining_positions, positions_combinations)
        players_for_optimization = set()
        for position, places in positions.items():
            players_with_position = [variable for player, variable in players_dict.items()
                                     if list_intersection(position, player.positions) and
                                     player not in unswappable_players]
            players_for_optimization.update(players_with_position)
            solver.add_constraint(players_with_position, None, SolverSign.GTE, places)
        # Set total players for optimization
        solver.add_constraint(players_for_optimization, None, SolverSign.EQ, len(remaining_positions))
        # Exclude players with active games
        for player, variable in players_dict.items():
            if player not in unswappable_players and player.is_game_started:
                solver.add_constraint([players_dict[player]], None, SolverSign.EQ, 0)
        self.current_iteration += 1


class GenericStacksRule(OptimizerRule):
    def __init__(self, optimizer, all_players, params):
        super().__init__(optimizer, all_players, params)
        self.total_lineups = params.get('n')
        self.stacks = list(chain.from_iterable(stack.build_stacks(all_players, optimizer) for stack in optimizer.stacks))
        self.used_groups = defaultdict(int)  # type: Dict[str, int]
        self.with_exposures = any(stack.with_exposures for stack in self.stacks)

    def apply(self, solver, players_dict):
        if not self.with_exposures:
            self._create_constraints(solver, players_dict)

    def apply_for_iteration(self, solver, players_dict, result):
        if self.with_exposures:
            self._create_constraints(solver, players_dict)

    @staticmethod
    def _build_group_name(group: 'BaseGroup'):
        return 'stack_%s' % group.uuid.hex

    def _is_reached_exposure(self, group: 'BaseGroup') -> bool:
        return group.max_exposure is not None and \
               group.max_exposure <= self.used_groups[self._build_group_name(group)] / self.total_lineups

    def _create_constraints(
            self,
            solver: Solver,
            players_dict: Dict[Player, Any],
    ):
        players_in_stack = defaultdict(set)  # type: Dict[Player, Set[Any]]
        for stack in self.stacks:
            combinations_variables = {}
            for group in stack.groups:
                group_name = self._build_group_name(group)
                sub_groups = group.get_all_players_groups()
                if self._is_reached_exposure(group) or (group.parent and self._is_reached_exposure(group.parent)):
                    for group_players, _, max_from_group in sub_groups:
                        if max_from_group is None:
                            continue
                        solver.add_constraint([players_dict[p] for p in group_players], None, SolverSign.EQ, 0)
                    max_group = sorted(sub_groups, key=lambda t: t[1])[0]
                    if max_group[1]:
                        solver.add_constraint([players_dict[p] for p in max_group[0]], None, SolverSign.LTE,
                                              max_group[1] - 1)
                    continue
                solver_variable = None
                if any(sub_group[1] is not None for sub_group in sub_groups):
                    solver_variable = solver.add_variable(group_name)
                    combinations_variables[group_name] = solver_variable
                for group_players, group_min, group_max in sub_groups:
                    variables = [players_dict[p] for p in group_players]
                    if group_min is not None:
                        if not stack.can_intersect:
                            for player in group_players:
                                players_in_stack[player].add(solver_variable)
                        solver.add_constraint(variables, None, SolverSign.GTE, group_min * solver_variable)
                        if group_max is not None:
                            solver.add_constraint(variables, None, SolverSign.LTE, group_max)
                    elif group_max is not None:
                        solver_variable = solver.add_variable(group_name, min_value=0, max_value=group_max)
                        solver.add_constraint(variables, None, SolverSign.EQ, solver_variable)
            if combinations_variables:
                solver.add_constraint(combinations_variables.values(), None, SolverSign.GTE, 1)
        for player, stacks_vars in players_in_stack.items():
            if len(stacks_vars) > 1:
                solver.add_constraint(stacks_vars, None, SolverSign.LTE, 1)

    def post_optimize(self, solved_variables):
        for variable in solved_variables:
            self.used_groups[variable] += 1


class MinExposureRule(OptimizerRule):
    def __init__(self, optimizer, all_players, params):
        super().__init__(optimizer, all_players, params)
        self.total_lineups = params.get('n')
        self.remaining_lineups = self.total_lineups
        self.min_exposure_players = {
            player: round(player.min_exposure * self.total_lineups)
            for player in all_players if player.min_exposure
        }
        self.positions = {}
        if self.min_exposure_players:
            self.positions = get_positions_for_optimizer(optimizer.settings.positions, None)

    def apply_for_iteration(self, solver, players_dict, result):
        if not self.min_exposure_players:
            return
        if result:
            for player in result:
                if player not in self.min_exposure_players:
                    continue
                self.min_exposure_players[player] -= 1
                if self.min_exposure_players[player] == 0:
                    del self.min_exposure_players[player]
            self.remaining_lineups -= 1
        self._create_constraints(solver, players_dict)

    def _create_constraints(self, solver: Solver, players_dict: Dict[Player, Any]) -> None:
        for positions, total_for_positions in self.positions.items():
            min_exposure_players = [p for p in self.min_exposure_players if list_intersection(p.positions, positions)]
            if not min_exposure_players:
                continue
            total_force = sum(self.min_exposure_players[p] for p in min_exposure_players) - \
                          total_for_positions * (self.remaining_lineups - 1)
            if total_force > 0:
                variables = [players_dict[p] for p in min_exposure_players]
                total_force = min(total_force, ceil(total_force / self.remaining_lineups))
                solver.add_constraint(variables, None, SolverSign.GTE, total_force)
        for player, total_lineups in self.min_exposure_players.items():
            if total_lineups >= self.remaining_lineups:
                solver.add_constraint([players_dict[player]], None, SolverSign.EQ, 1)


class RestrictPositionsForOpposingTeam(OptimizerRule):
    MULTIPLIER = 1000

    def apply(self, solver, players_dict):
        if not self.optimizer.opposing_teams_position_restriction:
            return
        for game in self.optimizer.games:
            first_team_players = {player: variable for player, variable in players_dict.items()
                                  if player.team == game.home_team}
            second_team_players = {player: variable for player, variable in players_dict.items()
                                   if player.team == game.away_team}
            for first_team_positions, second_team_positions in \
                    permutations(self.optimizer.opposing_teams_position_restriction, 2):
                first_team_variables = [variable for player, variable in first_team_players.items()
                                        if list_intersection(player.positions, first_team_positions)]
                second_team_variables = [variable for player, variable in second_team_players.items()
                                         if list_intersection(player.positions, second_team_positions)]
                coefficients = [1] * len(second_team_variables)
                for var in first_team_variables:
                    solver.add_constraint([var, *second_team_variables], [self.MULTIPLIER, *coefficients],
                                          SolverSign.LTE, self.MULTIPLIER + self.optimizer.opposing_teams_max_allowed)


class RestrictPositionsForSameTeamRule(OptimizerRule):
    def apply(self, solver, players_dict):
        all_restrict_positions = self.optimizer.same_team_restrict_positions
        if not all_restrict_positions:
            return
        players_by_team = get_players_grouped_by_teams(players_dict.keys())
        for restrict_positions in all_restrict_positions:
            for team_players in players_by_team.values():
                first_position_players = [player for player in team_players
                                          if restrict_positions[0] in player.positions]
                second_position_players = [player for player in team_players
                                           if restrict_positions[1] in player.positions]
                for players_combination in product(first_position_players, second_position_players):
                    if players_combination[0] == players_combination[1]:
                        continue
                    variables = [players_dict[player] for player in players_combination]
                    solver.add_constraint(variables, None, SolverSign.LTE, 1)


class ForcePositionsForOpposingTeamRule(OptimizerRule):
    def apply(self, solver, players_dict):
        raw_all_force_positions = self.optimizer.opposing_team_force_positions
        if not raw_all_force_positions:
            return
        all_force_positions = [tuple(sorted(positions)) for positions in raw_all_force_positions]
        for positions, total_combinations in Counter(all_force_positions).items():
            positions_vars = []
            combinations_count = 0
            for game in self.optimizer.games:
                first_team_players = {player: variable for player, variable in players_dict.items()
                                      if player.team == game.home_team}
                second_team_players = {player: variable for player, variable in players_dict.items()
                                       if player.team == game.away_team}
                for first_team_positions, second_team_positions in permutations(positions, 2):
                    first_team_variables = [variable for player, variable in first_team_players.items()
                                            if first_team_positions in player.positions]
                    second_team_variables = [variable for player, variable in second_team_players.items()
                                             if second_team_positions in player.positions]
                    for variables in product(first_team_variables, second_team_variables):
                        solver_variable = solver.add_variable('force_positions_%s_%d' % (positions, combinations_count))
                        combinations_count += 1
                        positions_vars.append(solver_variable)
                        solver.add_constraint(variables, None, SolverSign.GTE, 2 * solver_variable)
            solver.add_constraint(positions_vars, None, SolverSign.GTE, total_combinations)


class RosterSpacingRule(OptimizerRule):
    def apply(self, solver, players_dict):
        optimizer = self.optimizer
        positions, spacing = optimizer.spacing_positions, optimizer.spacing
        if not spacing or not positions:
            return
        players_by_roster_positions = defaultdict(list)  # type: Dict[int, List[Tuple[Player, Any]]]
        for player, variable in players_dict.items():
            if player.roster_order is None or not list_intersection(player.positions, positions):
                continue
            players_by_roster_positions[player.roster_order].append((player, variable))
        for roster_position, players in players_by_roster_positions.items():
            next_restricted_roster_position = roster_position + spacing
            restricted_players = chain.from_iterable(
                players for players_spacing, players in players_by_roster_positions.items()
                if players_spacing >= next_restricted_roster_position
            )
            for first_player, first_variable in restricted_players:
                for second_player, second_variable in players:
                    if first_player.team != second_player.team:
                        continue
                    solver.add_constraint([first_variable, second_variable], None, SolverSign.LTE, 1)


class FanduelBaseballRosterRule(OptimizerRule):
    HITTERS = ('1B', '2B', '3B', 'SS', 'C', 'OF')
    MAXIMUM_HITTERS_FROM_ONE_TEAM = 4

    def apply(self, solver, players_dict):
        players_dict = {player: variable for player, variable in players_dict.items() if
                        list_intersection(player.positions, self.HITTERS)}
        for team in self.optimizer.available_teams:
            players_from_team = [variable for player, variable in players_dict.items() if player.team == team]
            solver.add_constraint(players_from_team, None, SolverSign.LTE, self.MAXIMUM_HITTERS_FROM_ONE_TEAM)


class TotalTeamsRule(OptimizerRule):
    def apply(self, solver, players_dict):
        total_teams = self.optimizer.total_teams
        settings = self.optimizer.settings
        min_teams = settings.min_teams
        if not min_teams and not total_teams:
            return
        total_players = self.optimizer.settings.get_total_players()
        players_by_teams = get_players_grouped_by_teams(players_dict.keys(), for_positions=[
            position for position in self.optimizer.available_positions
            if position not in settings.total_teams_exclude_positions
        ])
        teams_variables = []
        for team, team_players in players_by_teams.items():
            variable = solver.add_variable('total_teams_%s' % team)
            teams_variables.append(variable)
            variables = [players_dict[player] for player in team_players]
            solver.add_constraint(variables, None, SolverSign.LTE, variable * total_players)
            solver.add_constraint(variables, None, SolverSign.GTE, variable)
        if total_teams:
            solver.add_constraint(teams_variables, None, SolverSign.EQ, total_teams)
        else:
            solver.add_constraint(teams_variables, None, SolverSign.GTE, min_teams)


class FanduelSingleGameMVPRule(OptimizerRule):
    def apply(self, solver, players_dict):
        variables = [var for player, var in players_dict.items() if player.rank == PlayerRank.MVP]
        solver.add_constraint(variables, None, SolverSign.EQ, 1)


class FanduelSingleGameStarRule(OptimizerRule):
    def apply(self, solver, players_dict):
        variables = [var for player, var in players_dict.items() if player.rank == PlayerRank.STAR]
        solver.add_constraint(variables, None, SolverSign.EQ, 1)


class FanduelSingleGameProRule(OptimizerRule):
    def apply(self, solver, players_dict):
        variables = [var for player, var in players_dict.items() if player.rank == PlayerRank.PRO]
        solver.add_constraint(variables, None, SolverSign.EQ, 1)


class FanduelSingleGameMaxQBRule(OptimizerRule):
    def apply(self, solver, players_dict):
        variables = [var for player, var in players_dict.items() if 'QB' in player.positions]
        solver.add_constraint(variables, None, SolverSign.LTE, 2)


class MinStartersRule(OptimizerRule):
    def apply(self, solver, players_dict):
        min_starters = self.optimizer.min_starters
        if not min_starters:
            return
        variables = [variable for player, variable in players_dict.items() if player.is_confirmed_starter]
        solver.add_constraint(variables, None, SolverSign.GTE, min_starters)
