import ast
import time
from threading import Timer

import pandas as pd
from ortools.sat.python import cp_model

from sbc.models import SBCSolvationModel, RareFlag
from utils.realy_public_methods import new_print

LOG_RUNTIME = True


def runtime(func):
    '''Wrapper function to log the execution time'''
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        seconds = round(time.time() - start, 2)
        print(f"Processing time {func.__name__}: {seconds} seconds")
        return result
    return wrapper if LOG_RUNTIME else func


class ObjectiveEarlyStopping(cp_model.CpSolverSolutionCallback):
    '''Stop the search if the objective remains the same for X seconds'''
    def __init__(self, timer_limit: int):
        super().__init__()
        self._timer_limit = timer_limit
        self._timer = None

    def on_solution_callback(self):
        '''This is called everytime a solution with better objective is found.'''
        self._reset_timer()

    def _reset_timer(self):
        if self._timer:
            self._timer.cancel()
        self._timer = Timer(self._timer_limit, self.StopSearch)
        self._timer.start()

    def StopSearch(self):
        print(f"{self._timer_limit} seconds without improvement in objective. ")
        super().StopSearch()


# https://github.com/Regista6/EA-FC-24-Automated-SBC-Solving
class SolvationCore:
    def __init__(self):
        self.formation_dict = {
            "3-1-4-2": ["GK", "CB", "CB", "CB", "LM", "CM", "CDM", "CM", "RM", "ST", "ST"],
            "3-4-1-2": ["GK", "CB", "CB", "CB", "LM", "CM", "CM", "RM", "CAM", "ST", "ST"],
            "3-4-2-1": ["GK", "CB", "CB", "CB", "LM", "CM", "CM", "RM", "CAM", "ST", "CAM"],
            # "3-4-3": ["GK", "CB", "CB", "CB", "LM", "CM", "CM", "RM", "CAM", "ST", "ST"],
            "3-5-2": ["GK", "CB", "CB", "CB", "CDM", "CDM", "LM", "CAM", "RM", "ST", "ST"],
            "3-4-3": ["GK", "CB", "CB", "CB", "LM", "CM", "CM", "RM", "LW", "ST", "RW"],
            "4-1-2-1-2": ["GK", "LB", "CB", "CB", "RB", "CDM", "LM", "CAM", "RM", "ST", "ST"],
            "4-1-2-1-2[2]": ["GK", "LB", "CB", "CB", "RB", "CDM", "CM", "CAM", "CM", "ST", "ST"],
            "4-1-3-2": ["GK", "LB", "CB", "CB", "RB", "CDM", "LM", "CM", "RM", "ST", "ST"],
            "4-1-4-1": ["GK", "LB", "CB", "CB", "RB", "CDM", "LM", "CM", "CM", "RM", "ST"],
            "4-2-1-3": ["GK", "LB", "CB", "CB", "RB", "CDM", "CDM", "CAM", "LW", "ST", "RW"],
            "4-2-3-1": ["GK", "LB", "CB", "CB", "RB", "CDM", "CDM", "CAM", "CAM", "CAM", "ST"],
            "4-2-3-1[2]": ["GK", "LB", "CB", "CB", "RB", "CDM", "CDM", "CAM", "LM", "ST", "RM"],
            "4-2-2-2": ["GK", "LB", "CB", "CB", "RB", "CDM", "CDM", "CAM", "CAM", "ST", "ST"],
            "4-2-4": ["GK", "LB", "CB", "CB", "RB", "CM", "CM", "LW", "ST", "ST", "RW"],
            "4-3-1-2": ["GK", "CB", "CB", "LB", "RB", "CM", "CM", "CM", "CAM", "ST", "ST"],
            "4-3-2-1": ["GK", "LB", "CB", "CB", "RB", "CM", "CM", "CM", "CAM", "ST", "CAM"],
            "4-3-3": ["GK", "LB", "CB", "CB", "RB", "CM", "CM", "CM", "LW", "ST", "RW"],
            "4-3-3[2]": ["GK", "LB", "CB", "CB", "RB", "CM", "CDM", "CM", "LW", "ST", "RW"],
            "4-3-3[3]": ["GK", "LB", "CB", "CB", "RB", "CDM", "CDM", "CM", "LW", "ST", "RW"],
            "4-3-3[4]": ["GK", "LB", "CB", "CB", "RB", "CM", "CM", "CAM", "LW", "ST", "RW"],
            # "4-3-3[5]": ["GK", "LB", "CB", "CB", "RB", "CDM", "CM", "CM", "LW", "CAM", "RW"],
            # "4-4-1-1": ["GK", "LB", "CB", "CB", "RB", "CM", "CM", "LM", "CAM", "RM", "ST"],
            "4-4-1-1[2]": ["GK", "LB", "CB", "CB", "RB", "CM", "CM", "LM", "CAM", "RM", "ST"],
            "4-4-2": ["GK", "LB", "CB", "CB", "RB", "LM", "CM", "CM", "RM", "ST", "ST"],
            "4-4-2[2]": ["GK", "LB", "CB", "CB", "RB", "LM", "CDM", "CDM", "RM", "ST", "ST"],
            "4-5-1": ["GK", "CB", "CB", "LB", "RB", "CM", "LM", "CAM", "CAM", "RM", "ST"],
            "4-5-1[2]": ["GK", "CB", "CB", "LB", "RB", "CM", "LM", "CM", "CM", "RM", "ST"],
            "5-2-1-2": ["GK", "LB", "CB", "CB", "CB", "RB", "CM", "CM", "CAM", "ST", "ST"],
            "5-2-2-1": ["GK", "LB", "CB", "CB", "CB", "RB", "CM", "CM", "LW", "ST", "RW"],
            "5-2-3": ["GK", "LB", "CB", "CB", "CB", "RB", "CM", "CM", "LW", "ST", "RW"],
            "5-3-2": ["GK", "LB", "CB", "CB", "CB", "RB", "CM", "CDM", "CM", "ST", "ST"],
            "5-4-1": ["GK", "LB", "CB", "CB", "CB", "RB", "CM", "CM", "LM", "RM", "ST"]
        }

        self.formation_dict_2 = {
            "3-1-4-2": ["GK", "CB", "CB", "CB", "CDM", "RM", "CM", "CM", "LM", "ST", "ST"],
            "3-4-1-2": ["GK", "CB", "CB", "CB", "RM", "CM", "CM", "LM", "CAM", "ST", "ST"],
            "3-4-2-1": ["GK", "CB", "CB", "CB", "RM", "CM", "CM", "LM", "CAM", "CAM", "ST"],
            "3-4-3": ["GK", "CB", "CB", "CB", "RM", "CM", "CM", "LM", "RW", "ST", "LW"],
            "3-5-2": ["GK", "CB", "CB", "CB", "CDM", "CDM", "RM", "LM", "CAM", "ST", "ST"],
            "4-1-2-1-2": ["GK", "RB", "CB", "CB", "LB", "CDM", "RM", "LM", "CAM", "ST", "ST"],
            "4-1-2-1-2[2]": ["GK", "RB", "CB", "CB", "LB", "CDM", "CM", "CM", "CAM", "ST", "ST"],
            "4-1-3-2": ["GK", "RB", "CB", "CB", "LB", "CDM", "RM", "CM", "LM", "ST", "ST"],
            "4-1-4-1": ["GK", "RB", "CB", "CB", "LB", "CDM", "RM", "CM", "CM", "LM", "ST"],
            "4-2-1-3": ["GK", "RB", "CB", "CB", "LB", "CDM", "CDM", "CAM", "RW", "ST", "LW"],
            "4-2-2-2": ["GK", "RB", "CB", "CB", "LB", "CDM", "CDM", "CAM", "CAM", "ST", "ST"],
            "4-2-3-1": ["GK", "RB", "CB", "CB", "LB", "CDM", "CDM", "CAM", "CAM", "CAM", "ST"],
            "4-2-3-1[2]": ["GK", "RB", "CB", "CB", "LB", "CDM", "CDM", "RM", "LM", "CAM", "ST"],
            "4-2-4": ["GK", "RB", "CB", "CB", "LB", "CM", "CM", "RW", "ST", "ST", "LW"],
            "4-3-1-2": ["GK", "RB", "CB", "CB", "LB", "CM", "CM", "CM", "CAM", "ST", "ST"],
            "4-3-2-1": ["GK", "RB", "CB", "CB", "LB", "CM", "CM", "CM", "CAM", "CAM", "ST"],
            "4-3-3": ["GK", "RB", "CB", "CB", "LB", "CM", "CM", "CM", "RW", "ST", "LW"],
            "4-3-3[2]": ["GK", "RB", "CB", "CB", "LB", "CDM", "CM", "CM", "RW", "ST", "LW"],
            "4-3-3[3]": ["GK", "RB", "CB", "CB", "LB", "CDM", "CDM", "CM", "RW", "ST", "LW"],
            "4-3-3[4]": ["GK", "RB", "CB", "CB", "LB", "CM", "CM", "CAM", "RW", "ST", "LW"],
            # "4-3-3[5]": ["GK", "RB", "CB", "CB", "LB", "CDM", "CM", "CM", "CAM", "RW", "LW"],
            # "4-4-1-1": ["GK", "RB", "CB", "CB", "LB", "RM", "CM", "CM", "LM", "CAM", "ST"],
            "4-4-1-1[2]": ["GK", "RB", "CB", "CB", "LB", "RM", "CM", "CM", "LM", "CAM", "ST"],
            "4-4-2": ["GK", "RB", "CB", "CB", "LB", "RM", "CM", "CM", "LM", "ST", "ST"],
            "4-4-2[2]": ["GK", "RB", "CB", "CB", "LB", "CDM", "CDM", "RM", "LM", "ST", "ST"],
            "4-5-1": ["GK", "RB", "CB", "CB", "LB", "RM", "CM", "LM", "CAM", "CAM", "ST"],
            "4-5-1[2]": ["GK", "RB", "CB", "CB", "LB", "RM", "CM", "CM", "CM", "LM", "ST"],
            "5-2-1-2": ["GK", "RB", "CB", "CB", "CB", "LB", "CM", "CM", "CAM", "ST", "ST"],
            "5-2-2-1": ["GK", "RB", "CB", "CB", "CB", "LB", "CM", "CM", "RW", "ST", "LW"],
            "5-2-3": ["GK", "RB", "CB", "CB", "CB", "LB", "CM", "CM", "RW", "ST", "LW"],
            "5-3-2": ["GK", "RB", "CB", "CB", "CB", "LB", "CDM", "CM", "CM", "ST", "ST"],
            "5-4-1": ["GK", "RB", "CB", "CB", "CB", "LB", "RM", "CM", "CM", "LM", "ST"]
        }

        self.status_dict = {
            0: "UNKNOWN: The status of the model is still unknown. A search limit has been reached before any of the statuses below could be determined.(more time)",
            1: "MODEL_INVALID: The given CpModelProto didn't pass the validation step.(error)",
            2: "FEASIBLE: A feasible solution has been found. But the search was stopped before we could prove optimality.(solved not best)",
            3: "INFEASIBLE: The problem has been proven infeasible.(cant solve)",
            4: "OPTIMAL: An optimal feasible solution has been found.(solved best)"
        }
        self.status_short_dict = {
            0: "UNKNOWN: (more time)",
            1: "MODEL_INVALID: (error)",
            2: "FEASIBLE: (solved not best)",
            3: "INFEASIBLE: (cant solve)",
            4: "OPTIMAL: (solved best)"
        }

    def set_conditions(self, solvation_model: SBCSolvationModel):

        self.FORMATION = solvation_model.formation

        self.NUM_PLAYERS = solvation_model.num_players
        if self.NUM_PLAYERS < 11:
            self.FORMATION = 'special'
            formation = self.formation_dict_2.get(solvation_model.formation).copy()
            if solvation_model.required_positions:
                correct_pos = [int(iie) for iie in solvation_model.required_positions.split(',')]
            else:
                # todo : remove solvation_model instance from database . just use solvation_model instance created from dict
                correct_pos = list(solvation_model.sbc_type.sbctarget_set.values_list('position', flat=True))
            new_formation = []
            for ind, ee in enumerate(formation):
                if ind in correct_pos:
                    new_formation.append(formation[ind])
            self.formation_dict.update({'special': new_formation})
            self.formation_dict_2.update({'special': new_formation})

        self.PLAYERS_IN_POSITION = solvation_model.players_in_position  # PLAYERS_IN_POSITION = True => No player will be out of position and False implies otherwise.

        # This can be used to fix specific players and optimize the rest.
        # Find the Row_ID (starts from 2) of each player to be fixed
        # from the club dataset and plug that in.
        if solvation_model.fix_players:
            self.FIX_PLAYERS = ast.literal_eval(solvation_model.fix_players)
        else:
            self.FIX_PLAYERS = []

        # Filter out specific players using Row_ID.
        if solvation_model.remove_players:
            self.REMOVE_PLAYERS = ast.literal_eval(solvation_model.remove_players)
        else:
            self.REMOVE_PLAYERS = []

        # Change the nature of the objective.
        # By default, the solver tries to minimize the overall cost.
        # Set only one of the below to True to change the objective type.
        self.MINIMIZE_MAX_COST = solvation_model.minimize_max_cost  # This minimizes the max cost within a solution. This is worth a try but not that effective.
        self.MAXIMIZE_TOTAL_COST = solvation_model.maximize_total_cost  # Could be used for building a good team.

        # Set only one of the below to True and the other to False. Both can't be False.
        self.USE_PREFERRED_POSITION = solvation_model.use_preferred_position
        # self.USE_PREFERRED_POSITION = True
        self.USE_ALTERNATE_POSITIONS = solvation_model.use_alternate_positions
        # self.USE_ALTERNATE_POSITIONS = False

        # Set only one of the below to True and the others to False if duplicates are to be prioritized.
        self.USE_ALL_DUPLICATES = solvation_model.use_all_duplicates
        self.USE_AT_LEAST_HALF_DUPLICATES = solvation_model.use_at_least_half_duplicates
        self.USE_AT_LEAST_ONE_DUPLICATE = solvation_model.use_at_least_one_duplicate

        # Which cards should be considered Rare or Common?
        # Source: https://www.fut.gg/rarities/
        # Source: https://www.ea.com/en-gb/games/fifa/fifa-23/news/explaining-rarity-in-fifa-ultimate-team
        # Source: https://www.reddit.com/r/EASportsFC/comments/pajy29/how_do_ea_determine_wether_a_card_is_rare_or_none/
        # Source: https://www.reddit.com/r/EASportsFC/comments/16qfz75/psa_libertadores_cards_no_longer_count_as_rares/
        # Note: Apparently, EA randomly assigns a card as Rare. I kind of forgot to factor in this fact.
        # Note: In v1.1.0.3 of the extension, the actual rarity of each card is now displayed in the club dataset.
        # Note: Everything else is considered as a Common card. Keep modifying this as it is incomplete and could also be wrong!
        # CONSIDER_AS_RARE = ["Rare", "TOTW", "Icon", "UT Heroes", "Nike", "UCL Road to the Knockouts",
        #                     "UEL Road to the Knockouts", "UWCL Road to the Knockouts", "UECL Road to the Knockouts"]
        self.CONSIDER_AS_RARE = list(RareFlag.objects.filter(considered_rare=True).values_list('rare_id', flat=True))

        # CLUB = [["Real Madrid", "Arsenal"], ["FC Bayern"]]
        # NUM_CLUB = [3, 2]  # Total players from i^th list >= NUM_CLUB[i]
        self.CLUB = []
        self.NUM_CLUB = []
        if solvation_model.club:
            self.CLUB = ast.literal_eval(solvation_model.club)
            self.NUM_CLUB = ast.literal_eval(solvation_model.num_club)
        # MAX_NUM_CLUB = 2  # Same Club Count: Max X / Max X Players from the Same Club
        # MIN_NUM_CLUB = 2  # Same Club Count: Min X / Min X Players from the Same Club
        # NUM_UNIQUE_CLUB = [5, "Max"]  # Clubs: Max / Min / Exactly X
        self.MAX_NUM_CLUB = solvation_model.max_num_club
        self.MIN_NUM_CLUB = solvation_model.min_num_club
        self.NUM_UNIQUE_CLUB = []
        if solvation_model.num_unique_club:
            self.NUM_UNIQUE_CLUB = ast.literal_eval(solvation_model.num_unique_club)

        # LEAGUE = [["Premier League", "LaLiga Santander"]]
        # NUM_LEAGUE = [11]  # Total players from i^th list >= NUM_LEAGUE[i]
        self.LEAGUE = []
        self.NUM_LEAGUE = []
        if solvation_model.league:
            self.LEAGUE = ast.literal_eval(solvation_model.league)
            self.NUM_LEAGUE = ast.literal_eval(solvation_model.num_league)  # Total players from i^th list >= NUM_LEAGUE[i]

        # MAX_NUM_LEAGUE = 4  # Same League Count: Max X / Max X Players from the Same League
        # MIN_NUM_LEAGUE = 5  # Same League Count: Min X / Min X Players from the Same League
        # NUM_UNIQUE_LEAGUE = [4, "Exactly"]  # Leagues: Max / Min / Exactly X
        self.MAX_NUM_LEAGUE = solvation_model.max_num_league  # Same League Count: Max X / Max X Players from the Same League
        self.MIN_NUM_LEAGUE = solvation_model.min_num_league  # Same League Count: Min X / Min X Players from the Same League
        self.NUM_UNIQUE_LEAGUE = []
        if solvation_model.num_unique_league:
            self.NUM_UNIQUE_LEAGUE = ast.literal_eval(solvation_model.num_unique_league)  # Leagues: Max / Min / Exactly X

        # COUNTRY = [["England", "Spain"], ["Germany"]]
        # NUM_COUNTRY = [2, 1] # Total players from i^th list >= NUM_COUNTRY[i]
        self.COUNTRY = []
        self.NUM_COUNTRY = []
        if solvation_model.country:
            self.COUNTRY = ast.literal_eval(solvation_model.country)
            self.NUM_COUNTRY = ast.literal_eval(solvation_model.num_country)

        # MAX_NUM_COUNTRY = 3  # Same Nation Count: Max X / Max X Players from the Same Nation
        # MIN_NUM_COUNTRY = 5  # Same Nation Count: Min X / Min X Players from the Same Nation
        # NUM_UNIQUE_COUNTRY = [5, "Exactly"]  # Nations: Max / Min / Exactly X
        self.MAX_NUM_COUNTRY = solvation_model.max_num_country
        self.MIN_NUM_COUNTRY = solvation_model.min_num_country
        self.NUM_UNIQUE_COUNTRY = []
        if solvation_model.num_unique_country:
            self.NUM_UNIQUE_COUNTRY = ast.literal_eval(solvation_model.num_unique_country)

        # RARITY_1 = [['Gold', 'TOTW']]
        # NUM_RARITY_1 = [1 ,2]  # This is for cases like "Gold TOTW: Min X (0/X)"
        self.RARITY_1 = []
        self.NUM_RARITY_1 = []
        if solvation_model.rarity_1:
            self.RARITY_1 = ast.literal_eval(solvation_model.rarity_1)
            self.NUM_RARITY_1 = ast.literal_eval(solvation_model.num_rarity_1)

        # [Rare, Common, TOTW, Gold, Silver, Bronze ... etc]
        # Note: Unfortunately several cards like 'TOTW' are listed as 'Special'
        # Note: This is fixed in v1.1.0.3 of the extension to download club datset!
        # Note: Actual Rarity of each card is now shown.
        # RARITY_2 = [["Rare", "Min"], ["3", "Exactly"]]
        # NUM_RARITY_2 = [3]  # Total players from i^th Rarity / Color >= NUM_RARITY_2[i]
        self.RARITY_2 = []
        self.NUM_RARITY_2 = []
        if solvation_model.rarity_2:
            self.RARITY_2 = ast.literal_eval(solvation_model.rarity_2)
            self.NUM_RARITY_2 = ast.literal_eval(solvation_model.num_rarity_2)  # Total players from i^th Rarity / Color >= NUM_RARITY_2[i]

        self.GROUP_RARITY = []
        self.NUM_GROUP_RARITY = []
        if solvation_model.group_rarity:
            self.GROUP_RARITY = ast.literal_eval(solvation_model.group_rarity)
            self.NUM_GROUP_RARITY = ast.literal_eval(
                solvation_model.num_group_rarity)  # Total players from i^th Rarity / Color >= NUM_RARITY_2[i]

        # SQUAD_RATING = 80 # Squad Rating: Min XX
        self.SQUAD_RATING = solvation_model.squad_rating # Squad Rating: Min XX

        # MIN_OVERALL = [55, 66]
        # NUM_MIN_OVERALL = [5, 6]  # Minimum OVR of XX : Min X
        self.MIN_OVERALL = []
        self.NUM_MIN_OVERALL = []
        if solvation_model.min_overall:
            self.MIN_OVERALL = ast.literal_eval(solvation_model.min_overall)
            self.NUM_MIN_OVERALL = ast.literal_eval(solvation_model.num_min_overall)  # Minimum OVR of XX : Min X

        # CHEMISTRY = 14  # Squad Total Chemistry Points: Min X
        # If there is no constraint on total chemistry, then set this to 0.
        self.CHEMISTRY = solvation_model.chemistry  # Squad Total Chemistry Points: Min X

        self.CHEM_PER_PLAYER = solvation_model.chem_per_player  # Chemistry Points Per Player: Min X

        self.MINIMIZE_OBJECTIVE = 'rating'  # set it to rating or cost

        # Can be used for constraints like Player Quality: Only Gold. ['Gold', 0] ,{0 'min' , 1 'max' , 2 'exactly - only'}
        self.UNIQUE_QUALITY = []
        if solvation_model.uniq_quality:
            self.UNIQUE_QUALITY = ast.literal_eval(solvation_model.uniq_quality)

        self.MIN_RATING_PLAYERS = solvation_model.min_rating_players


        '''INPUTS'''



        self.fifa_account = None

        self.max_time_in_seconds = 600
        self.solve_status_text = None
        self.solve_status_short_text = None

    def calc_squad_rating(self, rating):
        '''https://www.reddit.com/r/EASportsFC/comments/5osq7k/new_overall_rating_figured_out'''
        rat_sum = sum(rating)
        avg_rat = rat_sum / self.NUM_PLAYERS
        excess = sum(max(rat - avg_rat, 0) for rat in rating)
        return round(rat_sum + excess) // self.NUM_PLAYERS


    @runtime
    def create_var(self, model, df, map_idx, num_cnts):
        '''Create the relevant variables'''
        num_players, num_clubs, num_league, num_country = num_cnts[0], num_cnts[1], num_cnts[2], num_cnts[3]

        player = [] # player[i] = 1 => i^th player is considered and 0 otherwise
        chem = []  # chem[i] = chemistry of i^th player

        # Preprocessing things to speed-up model creation time.
        # Thanks Gregory Wullimann !!
        players_grouped = {
            "Club": {}, "League": {}, "Country": {}, "Position": {},
            "Rating": {}, "Color": {}, "Rarity": {}, "Name": {}
        }
        for i in range(num_players):
            player.append(model.NewBoolVar(f"player{i}"))
            chem.append(model.NewIntVar(0, 3, f"chem{i}"))
            players_grouped["Club"][map_idx["Club"][df.at[i, "Club"]]] = players_grouped["Club"].get(map_idx["Club"][df.at[i, "Club"]], []) + [player[i]]
            players_grouped["League"][map_idx["League"][df.at[i, "League"]]] = players_grouped["League"].get(map_idx["League"][df.at[i,"League"]], []) + [player[i]]
            players_grouped["Country"][map_idx["Country"][df.at[i, "Country"]]] = players_grouped["Country"].get(map_idx["Country"][df.at[i, "Country"]], []) + [player[i]]
            players_grouped["Position"][map_idx["Position"][df.at[i, "Position"]]] = players_grouped["Position"].get(map_idx["Position"][df.at[i, "Position"]], []) + [player[i]]
            players_grouped["Rating"][map_idx["Rating"][df.at[i, "Rating"]]] = players_grouped["Rating"].get(map_idx["Rating"][df.at[i, "Rating"]], []) + [player[i]]
            players_grouped["Color"][map_idx["Color"][df.at[i, "Color"]]] = players_grouped["Color"].get(map_idx["Color"][df.at[i, "Color"]], []) + [player[i]]
            players_grouped["Rarity"][map_idx["Rarity"][df.at[i, "Rarity"]]] = players_grouped["Rarity"].get(map_idx["Rarity"][df.at[i, "Rarity"]], []) + [player[i]]
            players_grouped["Name"][map_idx["Name"][df.at[i, "Name"]]] = players_grouped["Name"].get(map_idx["Name"][df.at[i, "Name"]], []) + [player[i]]

        # These variables are basically chemistry of each club, league and nation
        z_club = [model.NewIntVar(0, 3, f"z_club{i}") for i in range(num_clubs)]
        z_league = [model.NewIntVar(0, 3, f"z_league{i}") for i in range(num_league)]
        z_nation = [model.NewIntVar(0, 3, f"z_nation{i}") for i in range(num_country)]

        # Needed for chemistry constraint
        b_c = [[model.NewBoolVar(f"b_c{j}{i}") for i in range(4)]for j in range(num_clubs)]
        b_l = [[model.NewBoolVar(f"b_l{j}{i}") for i in range(4)]for j in range(num_league)]
        b_n = [[model.NewBoolVar(f"b_n{j}{i}") for i in range(4)]for j in range(num_country)]

        # These variables represent whether a particular club, league or nation is
        # considered in the final solution or not
        club = [model.NewBoolVar(f"club_{i}") for i in range(num_clubs)]
        country = [model.NewBoolVar(f"country_{i}") for i in range(num_country)]
        league = [model.NewBoolVar(f"league_{i}") for i in range(num_league)]
        return model, player, chem, z_club, z_league, z_nation, b_c, b_l, b_n, club, country, league, players_grouped

    @runtime
    def create_basic_constraints(self, df, model, player, map_idx, players_grouped, num_cnts):
        '''Create some essential constraints'''
        # Max players in squad
        model.Add(cp_model.LinearExpr.Sum(player) == self.NUM_PLAYERS)

        # Unique players constraint. Currently different players of same name not present in dataset.
        # Same player with multiple card versions present.
        for idx, expr in players_grouped["Name"].items():
            model.Add(cp_model.LinearExpr.Sum(expr) <= 1)

        # Formation constraint
        if self.PLAYERS_IN_POSITION == True:
            formation_list = self.formation_dict_2[self.FORMATION]
            cnt = {}
            for pos in formation_list:
                cnt[pos] = formation_list.count(pos)
            for pos, num in cnt.items():
                if map_idx["Position"].get(pos) is None:
                    raise KeyError(f'position not found {pos}')
                expr = players_grouped["Position"].get(map_idx["Position"][pos], [])
                model.Add(cp_model.LinearExpr.Sum(expr) == num)
        return model

    @runtime
    def create_country_constraint(self, df, model, player, map_idx, players_grouped, num_cnts):
        '''Create country constraint (>=)'''
        for i, nation_list in enumerate(self.COUNTRY):
            expr = []
            for nation in nation_list:
                try:
                    expr += players_grouped["Country"].get(map_idx["Country"][nation], [])
                except KeyError:
                    print(f'country not found {nation}')
            if expr == []:
                raise Exception(f'countries not found {nation_list}')
            model.Add(cp_model.LinearExpr.Sum(expr) >= self.NUM_COUNTRY[i])
        return model

    @runtime
    def create_league_constraint(self, df, model, player, map_idx, players_grouped, num_cnts):
        '''Create league constraint (>=)'''
        for i, league_list in enumerate(self.LEAGUE):
            expr = []
            for league in league_list:
                try:
                    expr += players_grouped["League"].get(map_idx["League"][league], [])
                except KeyError:
                    print(f'league not found {league}')
            if expr == []:
                raise KeyError(f'leagues not found {league_list}')
            model.Add(cp_model.LinearExpr.Sum(expr) >= self.NUM_LEAGUE[i])
        return model

    @runtime
    def create_club_constraint(self, df, model, player, map_idx, players_grouped, num_cnts):
        '''Create club constraint (>=)'''
        for i, club_list in enumerate(self.CLUB):
            expr = []
            for club in club_list:
                try:
                    expr += players_grouped["Club"].get(map_idx["Club"][club], [])
                except KeyError:
                    print(f'club not found {club}')
            if expr == []:
                raise KeyError(f'clubs not found {club_list}')
            model.Add(cp_model.LinearExpr.Sum(expr) >= self.NUM_CLUB[i])
        return model

    @runtime
    def create_rarity_1_constraint(self, df, model, player, map_idx, players_grouped, num_cnts):
        '''Create constraint for gold TOTW, gold Rare, gold Non Rare,
           silver TOTW, etc (>=).
        '''
        for i, rarity in enumerate(self.RARITY_1):
            idxes = list(df[(df["Color"] == rarity[0]) & (df["Rarity"] == rarity[1])].index)
            expr = [player[j] for j in idxes]
            model.Add(cp_model.LinearExpr.Sum(expr) >= self.NUM_RARITY_1[i])
        return model

    @runtime
    def create_rarity_2_constraint(self, df, model, player, map_idx, players_grouped, num_cnts):
        '''[Rare, Common, TOTW, Gold, Silver, Bronze ... etc] , (>=) ,  (<=) , (==).'''
        for i, rarity_type in enumerate(self.RARITY_2):
            expr = []
            if rarity_type[0] in ["Gold", "Silver", "Bronze"]:
                expr = players_grouped["Color"].get(map_idx["Color"].get(rarity_type[0], -1), [])
            elif rarity_type[0] == "Rare":
                # Consider the following cards as Rare.
                for rarity in self.CONSIDER_AS_RARE:
                    expr += players_grouped["Rarity"].get(map_idx["Rarity"].get(rarity, -1), [])
            elif rarity_type[0] == "Common":
                # Consider everthing other than the above as Common.
                common_rarities = list(set(df["Rarity"].unique().tolist()) - set(self.CONSIDER_AS_RARE))
                for rarity in common_rarities:
                    expr += players_grouped["Rarity"].get(map_idx["Rarity"].get(rarity, -1), [])
            else:
                expr = players_grouped["Rarity"].get(map_idx["Rarity"].get(rarity_type[0], -1), [])
            if rarity_type[1] == 'Min':
                model.Add(cp_model.LinearExpr.Sum(expr) >= self.NUM_RARITY_2[i])
            elif rarity_type[1] == 'Max':
                model.Add(cp_model.LinearExpr.Sum(expr) <= self.NUM_RARITY_2[i])
            elif rarity_type[1] == 'Exactly':
                model.Add(cp_model.LinearExpr.Sum(expr) == self.NUM_RARITY_2[i])
            else:
                raise KeyError(f'Min or Max or Exactly not defined for rarity {rarity_type[1]}')
        return model

    @runtime
    def create_group_rarity_constraint(self, df, model, player, map_idx, players_grouped, num_cnts):
        '''[Rare, Common, TOTW, Gold, Silver, Bronze ... etc] , (>=) ,  (<=) , (==).'''
        for i, rarity_type in enumerate(self.GROUP_RARITY):
            expr = players_grouped["Rarity"].get(map_idx["Rarity"].get(rarity_type[0], -1), [])
            if rarity_type[1] == 'Min':
                model.Add(cp_model.LinearExpr.Sum(expr) >= self.NUM_GROUP_RARITY[i])
            elif rarity_type[1] == 'Max':
                model.Add(cp_model.LinearExpr.Sum(expr) <= self.NUM_GROUP_RARITY[i])
            elif rarity_type[1] == 'Exactly':
                model.Add(cp_model.LinearExpr.Sum(expr) == self.NUM_GROUP_RARITY[i])
            else:
                raise KeyError(f'Min or Max or Exactly not defined for rarity {rarity_type[1]}')
        return model

    @runtime
    def create_squad_rating_constraint_1(self, df, model, player, map_idx, players_grouped, num_cnts):
        '''Squad Rating: Min XX (>=) based on average rating.'''
        rating = df["Rating"].tolist()
        model.Add(cp_model.LinearExpr.WeightedSum(player, rating) >= (self.SQUAD_RATING) * (self.NUM_PLAYERS))
        return model

    @runtime
    def create_squad_rating_constraint_2(self, df, model, player, map_idx, players_grouped, num_cnts):
        '''Squad Rating: Min XX (>=) based on
        https://www.reddit.com/r/EASportsFC/comments/5osq7k/new_overall_rating_figured_out.
        Probably more accurate.
        '''
        num_players = num_cnts[0]
        rating = df["Rating"].tolist()
        avg_rat = cp_model.LinearExpr.WeightedSum(player, rating) # Assuming that the original ratings have been scaled by 11 (self.NUM_PLAYERS).
        # This represents the max non-negative gap between player rating and squad avg_rating.
        # Should be set to a reasonable amount to avoid overwhelming the solver.
        # Good solutions likely don't have large gap anyways.
        max_gap_bw_rating = min(150, (df["Rating"].max() - df["Rating"].min()) * (self.NUM_PLAYERS - 1)) # max_rat * 11 - (min_rat * 10 + max_rat) (seems alright).
        excess = [model.NewIntVar(0, max_gap_bw_rating, f"excess{i}") for i in range(num_players)]
        [model.AddMaxEquality(excess[i], [(player[i] * rat * self.NUM_PLAYERS - avg_rat), 0])  for i, rat in enumerate(rating)]
        sum_excess = cp_model.LinearExpr.Sum(excess)
        model.Add((avg_rat * self.NUM_PLAYERS + sum_excess) >= (self.SQUAD_RATING) * (self.NUM_PLAYERS) * (self.NUM_PLAYERS))
        return model

    @runtime
    def create_squad_rating_constraint_3(self, df, model, player, map_idx, players_grouped, num_cnts):
        '''Squad Rating: Min XX (>=).
        Another way to model 'create_squad_rating_constraint_2'.
        This significantly speeds up the model creation time and for some reason
        the solver converges noticeably faster to a good solution, even without a rating filter
        when tested on a single constraint like Squad Rating: Min XX.
        '''
        rat_list = df["Rating"].unique().tolist()
        R = {} # This variable represents how many players have a particular rating in the final solution.
        rat_expr = []
        for rat in (rat_list):
            rat_idx = map_idx["Rating"][rat]
            expr = players_grouped["Rating"].get(rat_idx, [])
            R[rat_idx] = model.NewIntVar(0, self.NUM_PLAYERS, f"R{rat_idx}")
            rat_expr.append(R[rat_idx] * rat)
            model.Add(R[rat_idx] == cp_model.LinearExpr.Sum(expr))
        avg_rat = cp_model.LinearExpr.Sum(rat_expr)
        # This is similar in concept to the excess variable in create_squad_rating_constraint_2.
        excess = [model.NewIntVar(0, 1500, f"excess{i}") for i in range(len(rat_list))]
        for rat in (rat_list):
            rat_idx = map_idx["Rating"][rat]
            lhs = rat * self.NUM_PLAYERS * R[rat_idx]
            rat_expr_1 = []
            for rat_1 in (rat_list):
                rat_idx_1 = map_idx["Rating"][rat_1]
                temp = model.NewIntVar(0, 15000, f"temp{rat_idx_1}")
                model.AddMultiplicationEquality(temp, R[rat_idx], R[rat_idx_1] * rat_1)
                rat_expr_1.append(temp)
            rhs = cp_model.LinearExpr.Sum(rat_expr_1)
            model.AddMaxEquality(excess[rat_idx], [lhs - rhs, 0])
        sum_excess = cp_model.LinearExpr.Sum(excess)
        model.Add((avg_rat * self.NUM_PLAYERS + sum_excess) >= (self.SQUAD_RATING) * (self.NUM_PLAYERS) * (self.NUM_PLAYERS))
        return model

    @runtime
    def create_min_overall_constraint(self, df, model, player, map_idx, players_grouped, num_cnts):
        '''Minimum OVR of XX : Min X (>=)'''
        MAX_RATING = df["Rating"].max()
        for i, rating in enumerate(self.MIN_OVERALL):
            expr = []
            for rat in range(rating, MAX_RATING + 1):
                if rat not in map_idx["Rating"]:
                    continue
                expr += players_grouped["Rating"].get(map_idx["Rating"][rat], [])
            model.Add(cp_model.LinearExpr.Sum(expr) >= self.NUM_MIN_OVERALL[i])
        return model

    @runtime
    def create_chemistry_constraint(self, df, model, chem, z_club, z_league, z_nation, player, players_grouped, num_cnts, map_idx, b_c, b_l, b_n):
        '''Optimize Chemistry (>=)
        (https://www.rockpapershotgun.com/fifa-23-chemistry)
        '''
        num_players, num_clubs, num_league, num_country = num_cnts[0], num_cnts[1], num_cnts[2], num_cnts[3]

        club_dict, league_dict, country_dict, pos_dict = map_idx["Club"], map_idx["League"], map_idx["Country"], map_idx["Position"]

        formation_list = self.formation_dict[self.FORMATION]

        pos = [] # pos[i] = 1 => player[i] should be placed in their position.
        m_pos, m_idx = {}, {}
        chem_expr = []

        for i in range(num_players):
            p_club, p_league, p_nation, p_pos = df.at[i, "Club"], df.at[i, "League"], df.at[i, "Country"], df.at[i, "Position"]
            pos.append(model.NewBoolVar(f"_pos{i}"))
            m_pos[player[i]] = pos[i]
            m_idx[player[i]] = i
            if p_pos in formation_list:
                if self.PLAYERS_IN_POSITION == True:
                    model.Add(pos[i] == 1)
                if df.at[i, "Rarity"] in ["Icon"]:
                    model.Add(chem[i] == 3)
                # if df.at[i, "Rarity"] in ["Icon", "UT Heroes"]:
                #     model.Add(chem[i] == 3)
                # elif df.at[i, "Rarity"] in ["Radioactive"]:
                #     model.Add(chem[i] == 2)
                else:
                    sum_expr = z_club[club_dict[p_club]] + z_league[league_dict[p_league]] + z_nation[country_dict[p_nation]]
                    b = model.NewBoolVar(f"b{i}")
                    model.Add(sum_expr <= 3).OnlyEnforceIf(b)
                    model.Add(sum_expr > 3).OnlyEnforceIf(b.Not())
                    model.Add(chem[i] == sum_expr).OnlyEnforceIf(b)
                    model.Add(chem[i] == 3).OnlyEnforceIf(b.Not())
            else:
                model.Add(chem[i] == 0)
                model.Add(pos[i] == 0)

            model.Add(chem[i] >= self.CHEM_PER_PLAYER).OnlyEnforceIf(player[i])
            play_pos = model.NewBoolVar(f"play_pos{i}")
            model.AddMultiplicationEquality(play_pos, player[i], pos[i])
            player_chem_expr = model.NewIntVar(0, 3, f"chem_expr{i}")
            model.AddMultiplicationEquality(player_chem_expr, play_pos, chem[i])
            chem_expr.append(player_chem_expr)

        pos_expr = [] # Players whose position is there in the input_conf formation.

        '''
            For example say if the solver selects 3 CMs in the final
            solution but we only need at-most 2 of them to be in position for a 3-4-3
            formation and be considered for chemistry calcuation.
        '''
        for Pos in set(formation_list):
            if Pos not in pos_dict:
                    continue
            t_expr = players_grouped["Position"].get(pos_dict[Pos], [])
            pos_expr += t_expr
            if self.PLAYERS_IN_POSITION == False:
                play_pos = [model.NewBoolVar(f"play_pos{Pos}{i}") for i in range(len(t_expr))]
                [model.AddMultiplicationEquality(play_pos[i], p, m_pos[p]) for i, p in enumerate(t_expr)]
                model.Add(cp_model.LinearExpr.Sum(play_pos) <= formation_list.count(Pos))

        club_bucket = [[0, 1], [2, 3], [4, 6], [7, self.NUM_PLAYERS]]

        for j in range(num_clubs):
            t_expr = players_grouped["Club"].get(j, [])
            # We need players from j^th club whose position is there in the input_conf formation.
            # Since only such players would contribute towards chemistry.
            t_expr_1 = list(set(t_expr) & set(pos_expr))
            expr = []
            for i, p in enumerate(t_expr_1):
                if df.at[m_idx[p], "Rarity"] in ["Icon", "UT Heroes"]: # Heroes or Icons don't contribute to club chem.
                    continue
                t_var = model.NewBoolVar(f"t_var_c{i}")
                model.AddMultiplicationEquality(t_var, p, m_pos[p])
                if df.at[m_idx[p], "Rarity"] == "Radioactive":  # Radioactive cards contribute 2x to club chem.
                    expr.append(2 * t_var)
                else:
                    expr.append(t_var)
            sum_expr = cp_model.LinearExpr.Sum(expr)
            for idx in range(4):
                lb, ub = club_bucket[idx][0], club_bucket[idx][1]
                model.AddLinearConstraint(sum_expr, lb, ub).OnlyEnforceIf(b_c[j][idx])
                model.Add(z_club[j] == idx).OnlyEnforceIf(b_c[j][idx])
            model.AddExactlyOne(b_c[j])

        league_bucket = [[0, 2], [3, 4], [5, 7], [8, self.NUM_PLAYERS]]

        icons_expr = players_grouped["Rarity"].get(map_idx["Rarity"].get("Icon", -1), [])

        for j in range(num_league):
            t_expr = players_grouped["League"].get(j, [])
            t_expr += icons_expr # In EA FC 24, Icons add 1 chem to every league in the squad.
            # We need players from j^th league whose position is there in the input_conf formation.
            # Since only such players would contribute towards chemistry.
            t_expr_1 = list(set(t_expr) & set(pos_expr))
            expr = []
            for i, p in enumerate(t_expr_1):
                t_var = model.NewBoolVar(f"t_var_l{i}")
                model.AddMultiplicationEquality(t_var, p, m_pos[p])
                if df.at[m_idx[p], "Rarity"] in ["UT Heroes", "Radioactive"]:  # Heroes / Radioactive cards contribute 2x to league chem.
                    expr.append(2 * t_var)
                else:
                    expr.append(t_var)
            sum_expr = cp_model.LinearExpr.Sum(expr)
            for idx in range(4):
                lb, ub = league_bucket[idx][0], league_bucket[idx][1]
                model.AddLinearConstraint(sum_expr, lb, ub).OnlyEnforceIf(b_l[j][idx])
                model.Add(z_league[j] == idx).OnlyEnforceIf(b_l[j][idx])
            model.AddExactlyOne(b_l[j])

        country_bucket = [[0, 1], [2, 4], [5, 7], [8, self.NUM_PLAYERS]]

        for j in range(num_country):
            t_expr = players_grouped["Country"].get(j, [])
            # We need players from j^th country whose position is there in the input_conf formation.
            # Since only such players would contribute towards chemistry.
            t_expr_1 = list(set(t_expr) & set(pos_expr))
            expr = []
            for i, p in enumerate(t_expr_1):
                t_var = model.NewBoolVar(f"t_var_n{i}")
                model.AddMultiplicationEquality(t_var, p, m_pos[p])
                if df.at[m_idx[p], "Rarity"] in ["Icon", "Radioactive"]:  # Icons / Radioactive cards contribute 2x to country chem.
                    expr.append(2 * t_var)
                else:
                    expr.append(t_var)
            sum_expr = cp_model.LinearExpr.Sum(expr)
            for idx in range(4):
                lb, ub = country_bucket[idx][0], country_bucket[idx][1]
                model.AddLinearConstraint(sum_expr, lb, ub).OnlyEnforceIf(b_n[j][idx])
                model.Add(z_nation[j] == idx).OnlyEnforceIf(b_n[j][idx])
            model.AddExactlyOne(b_n[j])

        model.Add(cp_model.LinearExpr.Sum(chem_expr) >= self.CHEMISTRY)
        return model, pos, chem_expr

    @runtime
    def create_max_club_constraint(self, df, model, player, map_idx, players_grouped, num_cnts):
        '''Same Club Count: Max X / Max X Players from the Same Club (<=)'''
        num_clubs = num_cnts[1]
        for i in range(num_clubs):
            expr = players_grouped["Club"].get(i, [])
            model.Add(cp_model.LinearExpr.Sum(expr) <= self.MAX_NUM_CLUB)
        return model

    @runtime
    def create_max_league_constraint(self, df, model, player, map_idx, players_grouped, num_cnts):
        '''Same League Count: Max X / Max X Players from the Same League (<=)'''
        num_league = num_cnts[2]
        for i in range(num_league):
            expr = players_grouped["League"].get(i, [])
            model.Add(cp_model.LinearExpr.Sum(expr) <= self.MAX_NUM_LEAGUE)
        return model

    @runtime
    def create_max_country_constraint(self, df, model, player, map_idx, players_grouped, num_cnts):
        '''Same Nation Count: Max X / Max X Players from the Same Nation (<=)'''
        num_country = num_cnts[3]
        for i in range(num_country):
            expr = players_grouped["Country"].get(i, [])
            model.Add(cp_model.LinearExpr.Sum(expr) <= self.MAX_NUM_COUNTRY)
        return model

    @runtime
    def create_min_club_constraint(self, df, model, player, map_idx, players_grouped, num_cnts):
        '''Same Club Count: Min X / Min X Players from the Same Club (>=)'''
        num_clubs = num_cnts[1]
        B_C = [model.NewBoolVar(f"B_C{i}") for i in range(num_clubs)]
        for i in range(num_clubs):
            expr = players_grouped["Club"].get(i, [])
            model.Add(cp_model.LinearExpr.Sum(expr) >= self.MIN_NUM_CLUB).OnlyEnforceIf(B_C[i])
            model.Add(cp_model.LinearExpr.Sum(expr) < self.MIN_NUM_CLUB).OnlyEnforceIf(B_C[i].Not())
        model.AddAtLeastOne(B_C)
        return model

    @runtime
    def create_min_league_constraint(self, df, model, player, map_idx, players_grouped, num_cnts):
        '''Same League Count: Min X / Min X Players from the Same League (>=)'''
        num_league = num_cnts[2]
        B_L = [model.NewBoolVar(f"B_L{i}") for i in range(num_league)]
        for i in range(num_league):
            expr = players_grouped["League"].get(i, [])
            model.Add(cp_model.LinearExpr.Sum(expr) >= self.MIN_NUM_LEAGUE).OnlyEnforceIf(B_L[i])
            model.Add(cp_model.LinearExpr.Sum(expr) < self.MIN_NUM_LEAGUE).OnlyEnforceIf(B_L[i].Not())
        model.AddAtLeastOne(B_L)
        return model

    @runtime
    def create_min_country_constraint(self, df, model, player, map_idx, players_grouped, num_cnts):
        '''Same Nation Count: Min X / Min X Players from the Same Nation (>=)'''
        num_country = num_cnts[3]
        B_N = [model.NewBoolVar(f"B_N{i}") for i in range(num_country)]
        for i in range(num_country):
            expr = players_grouped["Country"].get(i, [])
            model.Add(cp_model.LinearExpr.Sum(expr) >= self.MIN_NUM_COUNTRY).OnlyEnforceIf(B_N[i])
            model.Add(cp_model.LinearExpr.Sum(expr) < self.MIN_NUM_COUNTRY).OnlyEnforceIf(B_N[i].Not())
        model.AddAtLeastOne(B_N)
        return model

    @runtime
    def create_unique_club_constraint(self, df, model, player, club, map_idx, players_grouped, num_cnts):
        '''Clubs: Max / Min / Exactly X'''
        num_clubs = num_cnts[1]
        for i in range(num_clubs):
            expr = players_grouped["Club"].get(i, [])
            model.Add(cp_model.LinearExpr.Sum(expr) >= 1).OnlyEnforceIf(club[i])
            model.Add(cp_model.LinearExpr.Sum(expr) == 0).OnlyEnforceIf(club[i].Not())
        if self.NUM_UNIQUE_CLUB[1] == "Min":
            model.Add(cp_model.LinearExpr.Sum(club) >= self.NUM_UNIQUE_CLUB[0])
        elif self.NUM_UNIQUE_CLUB[1] == "Max":
            model.Add(cp_model.LinearExpr.Sum(club) <= self.NUM_UNIQUE_CLUB[0])
        elif self.NUM_UNIQUE_CLUB[1] == "Exactly":
            model.Add(cp_model.LinearExpr.Sum(club) == self.NUM_UNIQUE_CLUB[0])
        else:
            print("**Couldn't create unique_club_constraint!**")
        return model

    @runtime
    def create_unique_league_constraint(self, df, model, player, league, map_idx, players_grouped, num_cnts):
        '''Leagues: Max / Min / Exactly X'''
        num_league = num_cnts[2]
        for i in range(num_league):
            expr = players_grouped["League"].get(i, [])
            model.Add(cp_model.LinearExpr.Sum(expr) >= 1).OnlyEnforceIf(league[i])
            model.Add(cp_model.LinearExpr.Sum(expr) == 0).OnlyEnforceIf(league[i].Not())
        if self.NUM_UNIQUE_LEAGUE[1] == "Min":
            model.Add(cp_model.LinearExpr.Sum(league) >= self.NUM_UNIQUE_LEAGUE[0])
        elif self.NUM_UNIQUE_LEAGUE[1] == "Max":
            model.Add(cp_model.LinearExpr.Sum(league) <= self.NUM_UNIQUE_LEAGUE[0])
        elif self.NUM_UNIQUE_LEAGUE[1] == "Exactly":
            model.Add(cp_model.LinearExpr.Sum(league) == self.NUM_UNIQUE_LEAGUE[0])
        else:
            print("**Couldn't create unique_league_constraint!**")
        return model

    @runtime
    def create_unique_country_constraint(self, df, model, player, country, map_idx, players_grouped, num_cnts):
        '''Nations: Max / Min / Exactly X'''
        num_country = num_cnts[3]
        for i in range(num_country):
            expr = players_grouped["Country"].get(i, [])
            model.Add(cp_model.LinearExpr.Sum(expr) >= 1).OnlyEnforceIf(country[i])
            model.Add(cp_model.LinearExpr.Sum(expr) == 0).OnlyEnforceIf(country[i].Not())
        if self.NUM_UNIQUE_COUNTRY[1] == "Min":
            model.Add(cp_model.LinearExpr.Sum(country) >= self.NUM_UNIQUE_COUNTRY[0])
        elif self.NUM_UNIQUE_COUNTRY[1] == "Max":
            model.Add(cp_model.LinearExpr.Sum(country) <= self.NUM_UNIQUE_COUNTRY[0])
        elif self.NUM_UNIQUE_COUNTRY[1] == "Exactly":
            model.Add(cp_model.LinearExpr.Sum(country) == self.NUM_UNIQUE_COUNTRY[0])
        else:
            print("**Couldn't create unique_country_constraint!**")
        return model

    @runtime
    def prioritize_duplicates(self, df, model, player):
        dup_idxes = list(df[(df["IsDuplicate"] == True)].index)
        if not dup_idxes:
            print("**No Duplicates Found!**")
            return model
        duplicates = [player[j] for j in dup_idxes]
        dup_expr = cp_model.LinearExpr.Sum(duplicates)
        if self.USE_ALL_DUPLICATES:
            model.Add(dup_expr == min(self.NUM_PLAYERS, len(dup_idxes)))
        elif self.USE_AT_LEAST_HALF_DUPLICATES:
            model.Add(2 * dup_expr >= min(self.NUM_PLAYERS, len(dup_idxes)))
        elif self.USE_AT_LEAST_ONE_DUPLICATE:
            model.Add(dup_expr >= 1)
        return model

    @runtime
    def fix_players(self, df, model, player):
        '''Fix specific players and optimize the rest'''
        if not self.FIX_PLAYERS:
            return model
        missing_players = []
        for idx in self.FIX_PLAYERS:
            idxes = list(df[(df["Original_Idx"] == (idx - 2))].index)
            if not idxes:
                missing_players.append(idx)
                continue
            players_to_fix = [player[j] for j in idxes]
            # Note: A selected player may play in multiple positions.
            # Any one such version must be fixed.
            model.Add(cp_model.LinearExpr.Sum(players_to_fix) == 1)
        if missing_players:
            print(f"**Couldn't fix the following players with Row_ID: {missing_players}**")
            print(f"**They may have already been filtered out**")
        return model

    @runtime
    def set_objective(self, df, model, player):
        '''Set objective based on player cost.
        The default behaviour of the solver is to minimize the overall cost.
        '''
        cost = df["Cost"].tolist()
        rating = df["Rating"].tolist()
        if self.MINIMIZE_MAX_COST:
            print("**MINIMIZE_MAX_COST**")
            max_cost = model.NewIntVar(0, df["Cost"].max(), "max_cost")
            play_cost = [player[i] * cost[i] for i in range(len(cost))]
            model.AddMaxEquality(max_cost, play_cost)
            model.Minimize(max_cost)
        elif self.MAXIMIZE_TOTAL_COST:
            print("**MAXIMIZE_TOTAL_COST**")
            model.Maximize(cp_model.LinearExpr.WeightedSum(player, cost))
        else:
            if self.MINIMIZE_OBJECTIVE == 'cost':
                print("**MINIMIZE_TOTAL_COST**")
                model.Minimize(cp_model.LinearExpr.WeightedSum(player, cost))
            elif self.MINIMIZE_OBJECTIVE == 'rating':
                print("**MINIMIZE_TOTAL_RATE**")
                model.Minimize(cp_model.LinearExpr.WeightedSum(player, rating))
            else:
                raise Exception('Set MINIMIZE_OBJECTIVE')
        return model

    def get_dict(self, df, col):
        '''Map fields to a unique index'''
        d = {}
        unique_col = df[col].unique()
        for i, val in enumerate(unique_col):
            d[val] = i
        return d

    @runtime
    def solver(self, df):
        '''Optimize SBC using Constraint Integer Programming'''
        num_cnts = [df.shape[0], df.Club.nunique(), df.League.nunique(), df.Country.nunique()] # Count of important fields

        map_idx = {}  # Map fields to a unique index
        fields = ["Club", "League", "Country", "Position", "Rating", "Color", "Rarity", "Name"]
        for field in fields:
            map_idx[field] = self.get_dict(df, field)

        '''Create the CP-SAT Model'''
        model = cp_model.CpModel()

        '''Create essential variables and do some pre-processing'''
        model, player, chem, z_club, z_league, z_nation, b_c, b_l, b_n, club, country, league, players_grouped = self.create_var(model, df, map_idx, num_cnts)

        '''Essential constraints'''
        model = self.create_basic_constraints(df, model, player, map_idx, players_grouped, num_cnts)

        '''Comment out the constraints not required'''

        '''Club'''
        if self.CLUB:
            model = self.create_club_constraint(df, model, player, map_idx, players_grouped, num_cnts)
        if self.MAX_NUM_CLUB:
            model = self.create_max_club_constraint(df, model, player, map_idx, players_grouped, num_cnts)
        if self.MIN_NUM_CLUB:
            model = self.create_min_club_constraint(df, model, player, map_idx, players_grouped, num_cnts)
        if self.NUM_UNIQUE_CLUB:
            model = self.create_unique_club_constraint(df, model, player, club, map_idx, players_grouped, num_cnts)
        '''Club'''

        '''League'''
        if self.LEAGUE:
            model = self.create_league_constraint(df, model, player, map_idx, players_grouped, num_cnts)
        if self.MAX_NUM_LEAGUE:
            model = self.create_max_league_constraint(df, model, player, map_idx, players_grouped, num_cnts)
        if self.MIN_NUM_LEAGUE:
            model = self.create_min_league_constraint(df, model, player, map_idx, players_grouped, num_cnts)
        if self.NUM_UNIQUE_LEAGUE:
            model = self.create_unique_league_constraint(df, model, player, league, map_idx, players_grouped, num_cnts)
        '''League'''

        '''Country'''
        if self.COUNTRY:
            model = self.create_country_constraint(df, model, player, map_idx, players_grouped, num_cnts)
        if self.MAX_NUM_COUNTRY:
            model = self.create_max_country_constraint(df, model, player, map_idx, players_grouped, num_cnts)
        if self.MIN_NUM_COUNTRY:
            model = self.create_min_country_constraint(df, model, player, map_idx, players_grouped, num_cnts)
        if self.NUM_UNIQUE_COUNTRY:
            model = self.create_unique_country_constraint(df, model, player, country, map_idx, players_grouped, num_cnts)
        '''Country'''

        '''Rarity'''
        if self.RARITY_1:
            model = self.create_rarity_1_constraint(df, model, player, map_idx, players_grouped, num_cnts)
        if self.RARITY_2:
            model = self.create_rarity_2_constraint(df, model, player, map_idx, players_grouped, num_cnts)
        if self.GROUP_RARITY:
            model = self.create_group_rarity_constraint(df, model, player, map_idx, players_grouped, num_cnts)
        '''Rarity'''

        '''Squad Rating'''
        if self.SQUAD_RATING:
            model = self.create_squad_rating_constraint_3(df, model, player, map_idx, players_grouped, num_cnts)
        '''Squad Rating'''

        '''Min Overall'''
        if self.MIN_OVERALL:
            model = self.create_min_overall_constraint(df, model, player, map_idx, players_grouped, num_cnts)
        '''Min Overall'''

        '''Duplicates'''
        if self.USE_ALL_DUPLICATES or self.USE_AT_LEAST_HALF_DUPLICATES or self.USE_AT_LEAST_ONE_DUPLICATE:
            model = self.prioritize_duplicates(df, model, player)

        '''Comment out the constraints not required'''

        '''If there is no constraint on total chemistry, simply set self.CHEMISTRY = 0'''
        model, pos, chem_expr = self.create_chemistry_constraint(df, model, chem, z_club, z_league, z_nation, player, players_grouped, num_cnts, map_idx, b_c, b_l, b_n)

        '''Fix specific players and optimize the rest'''
        if self.FIX_PLAYERS:
            model = self.fix_players(df, model, player)

        '''Set objective based on player cost'''
        model = self.set_objective(df, model, player)

        '''Export Model to file'''
        # model.ExportToFile('model.txt')

        max_possible_rating = df["Rating"].nlargest(self.NUM_PLAYERS).sum() / self.NUM_PLAYERS
        pre_status = 0
        if max_possible_rating < self.SQUAD_RATING:
            error_text = f"Problem is infeasible: max possible rating is {max_possible_rating}, but required is {self.SQUAD_RATING}"
            if self.fifa_account:
                new_print(self.fifa_account, error_text)
            else:
                print(error_text)
            pre_status = 3

        total_minimum_rating = sum(df["Rating"].nlargest(self.NUM_PLAYERS))
        if total_minimum_rating < self.SQUAD_RATING * self.NUM_PLAYERS:
            if self.fifa_account:
                new_print(self.fifa_account, "Impossible to meet SQUAD_RATING with given players.")
            else:
                print("Impossible to meet SQUAD_RATING with given players.")
            pre_status = 3

        if pre_status:
            self.solve_status_text = self.status_dict[pre_status]
            self.solve_status_short_text = self.status_short_dict[pre_status]
            return []

        '''Solve'''
        print("Solve Started")
        solver = cp_model.CpSolver()

        '''Solver Parameters'''
        solver.parameters.random_seed = 42
        solver.parameters.max_time_in_seconds = self.max_time_in_seconds
        # Whether the solver should log the search progress.
        solver.parameters.log_search_progress = True
        # Specify the number of parallel workers (i.e. threads) to use during search.
        # This should usually be lower than your number of available cpus + hyperthread in your machine.
        # Setting this to 16 or 24 can help if the solver is slow in improving the bound.
        solver.parameters.num_search_workers = 8
        # Stop the search when the gap between the best feasible objective (O) and
        # our best objective bound (B) is smaller than a limit.
        # Relative: abs(O - B) / max(1, abs(O)).
        # Note that if the gap is reached, the search status will be OPTIMAL. But
        # one can check the best objective bound to see the actual gap.
        # solver.parameters.relative_gap_limit = 0.05
        # solver.parameters.cp_model_presolve = False
        # solver.parameters.stop_after_first_solution = True
        # solver.log_callback = self.log_new_print
        '''Solver Parameters'''

        status = solver.Solve(model, ObjectiveEarlyStopping(timer_limit=60))
        self.solve_status_text = self.status_dict[status]
        self.solve_status_short_text = self.status_short_dict[status]

        if self.fifa_account:
            new_print(self.fifa_account, 'sbc solver status : ', status, ' - ', self.status_dict[status])
        else:
            print('status : ', status)
            print(self.status_dict[status])
            print('\n')

        final_players = []
        if status == 2 or status == 4: # Feasible or Optimal
            df['Chemistry'] = 0
            df['Is_Pos'] = 0 # Is_Pos = 1 => Player should be placed in their respective position.
            for i in range(num_cnts[0]):
                if solver.Value(player[i]) == 1:
                    final_players.append(i)
                    df.loc[i, "Chemistry"] = solver.Value(chem_expr[i])
                    df.loc[i, "Is_Pos"] = solver.Value(pos[i])
        return final_players

    # Preprocess the club dataset obtained from ea by custom
    def preprocess_data_3(self, df: pd.DataFrame):
        # df = df.drop(['Last Sale Price', 'Discard Value', 'DefinitionId'], axis = 1)
        df = df.rename(columns={'Nation': 'Country', 'Team': 'Club', 'ExternalPrice': 'Cost'})
        df["Color"] = df["Rating"].apply(lambda x: 'Bronze' if x < 65 else ('Silver' if 65 <= x <= 74 else 'Gold'))
        df.insert(2, 'Color', df.pop('Color'))
        if self.UNIQUE_QUALITY:
            if self.UNIQUE_QUALITY[0] == 'Bronze':
                if self.UNIQUE_QUALITY[1] in ['Max', 'Exactly']:
                    df = df[df["Color"] == 'Bronze']
            elif self.UNIQUE_QUALITY[0] == 'Silver':
                if self.UNIQUE_QUALITY[1] in ['Min']:
                    df = df[(df["Color"] == 'Silver') | (df["Color"] == 'Gold')]
                elif self.UNIQUE_QUALITY[1] in ['Max']:
                    df = df[(df["Color"] == 'Bronze') | (df['Color'] == 'Silver')]
                elif self.UNIQUE_QUALITY[1] in ['Exactly']:
                    df = df[df["Color"] == 'Silver']
            elif self.UNIQUE_QUALITY[0] == 'Gold':
                if self.UNIQUE_QUALITY[1] in ['Min', 'Exactly']:
                    df = df[df["Color"] == 'Gold']
            # df = df[df["Color"] == "Gold"] # Can be used for constraints like Player Quality: Only Gold.
            # df = df[df["Color"] != "Gold"] # Can be used for constraints like Player Quality: Max Silver.
            # df = df[df["Color"] != "Bronze"] # Can be used for constraints like Player Quality: Min Silver.
        # df = df[df["Untradeable"] == True]
        # df = df[df["IsInActive11"] != True]
        # df = df[df["Loans"] == False]
        # df = df[df["Cost"] != '-- NA --']
        # df = df[df["Cost"] != '0']
        # df = df[df["Cost"] != 0]
        # df['Rarity'] = df['Rarity'].replace('Team of the Week', 'TOTW')
        # Note: The filter on rating is especially useful when there is only a single constraint like Squad Rating: Min XX.
        # Otherwise, the search space is too large and this overwhelms the solver (very slow in improving the bound).
        # df = df[(df["Rating"] >= self.SQUAD_RATING - 3) & (df["Rating"] <= self.SQUAD_RATING + 3)]
        if self.SQUAD_RATING and not self.MIN_RATING_PLAYERS:
            rating_condition = (df["Rating"] >= self.SQUAD_RATING - 4)
            # شرط برای بررسی حضور بازیکن در COUNTRY, LEAGUE یا CLUB
            country_condition = df["Country"].isin(
                [item for sublist in self.COUNTRY for item in sublist]) if self.COUNTRY else False
            league_condition = df["League"].isin(
                [item for sublist in self.LEAGUE for item in sublist]) if self.LEAGUE else False
            club_condition = df["Club"].isin(
                [item for sublist in self.CLUB for item in sublist]) if self.CLUB else False
            # ترکیب شرایط
            special_condition = country_condition | league_condition | club_condition
            # اعمال فیلتر
            df = df[rating_condition | special_condition]
            # # شناسایی بازیکنان خاص که باید حفظ شوند
            # special_players = df[special_condition]
            # # مرتب‌سازی بازیکنان خاص بر اساس نزدیکی به SQUAD_RATING
            # special_players["Rating_Proximity"] = special_players["Rating"].apply(lambda x: abs(self.SQUAD_RATING - x))
            # special_players = special_players.sort_values(by="Rating_Proximity")
            # # حذف ستون موقت
            # special_players = special_players.drop(columns=["Rating_Proximity"])
            # # بازیکنان فیلتر شده نهایی
            # df = pd.concat([df[rating_condition], special_players]).drop_duplicates()

        if self.REMOVE_PLAYERS:
            self.REMOVE_PLAYERS = [(idx - 2) for idx in self.REMOVE_PLAYERS if (idx - 2) in df.index]
            df.drop(self.REMOVE_PLAYERS, inplace=True)
        if self.USE_PREFERRED_POSITION:
            df = df.rename(columns={'PreferredPosition': 'Position'})
            df.insert(4, 'Position', df.pop('Position'))
        elif self.USE_ALTERNATE_POSITIONS:
            df = df.drop(['PreferredPosition'], axis=1)
            df = df.rename(columns={'AlternatePositions': 'Position'})
            df.insert(4, 'Position', df.pop('Position'))
            df['Position'] = df['Position'].str.split(',')
            df = df.explode('Position')  # Creating separate entries of a particular player for each alternate position.
        elif self.GROUP_RARITY:
            df['RarityGroup'] = df['RarityGroup'].str.split(',')
            df = df.explode('RarityGroup')
        df['Original_Idx'] = df.index
        # df = df.reset_index(drop=True).astype({'Rating': 'int32', 'Cost': 'int32'})
        df = df.reset_index(drop=True).astype({'Rating': 'int32', 'Cost': 'int32', 'Rarity': 'str'})
        return df

    def log_new_print(self, log_callback, *args, **kwargs):
        if self.fifa_account:
            new_print(self.fifa_account, log_callback)
        else:
            print(log_callback)