from zlib import crc32
import numpy as np

#from line_profiler_pycharm import profile

from custom_ga import CustomGA as ga
from multiprocessing import Pool, cpu_count
from OppOpPopInit.oppositor import OppositionOperators

STATS_CATEGORIZED = {
    'PRIMARY': [
        'ap',
        'mp',
        'range',
        'summons',
        '% critical',
        'power'
    ],
    'STATS': [
        'vitality',
        "wisdom",
        "chance",
        "agility",
        "intelligence",
        "strength",
    ],
    'NONCOMBAT': [
        'prospecting',
        'pod',
    ],
    'SECONDARY': [
        'initiative',
        'mp reduction',
        'ap reduction',
        'mp parry',
        'ap parry',
        'dodge',
        'lock',
        "heal",
    ],
    'DAMAGE': [
        "neutral damage",
        "air damage",
        "water damage",
        "fire damage",
        "earth damage",
        "damage",
        'critical damage',
        'pushback damage',
        '% ranged damage',
        '% melee damage',
        '% spell damage',
        '% weapon damage',
    ],
    'RESISTS': [
        "% neutral resistance",
        "% fire resistance",
        "% water resistance",
        "% earth resistance",
        "% air resistance",
        '% ranged resistance',
        '% melee resistance',
    ],
    'UNIT_RESISTS': [
        "neutral resistance",
        "fire resistance",
        "water resistance",
        "earth resistance",
        "air resistance",
        'critical resistance',
        'pushback resistance',
        'reflected damage',
    ]
}

STATS = [i for s in STATS_CATEGORIZED.values() for i in s]


class Builder:
    def __init__(self, data, primary_score, secondary_score, resists_score, params):
        self.primary_score = primary_score
        self.params = params
        self.secondary_score = secondary_score
        self.resists_score = resists_score
        self.slots = data.slots
        all_items = data.filter_irrelevant_items(data.items, params, key_items=params['key_items'])
        all_items = [i for i in all_items if i.level >= params['level_bounds'][0]
                     or i.slot.name == 'Familier'
                     or i.slot.name == 'Base stats'
                     or i.type == 'Default'
                     or (i.slot.name == 'Dofus' and not (
                i.type == 'Trophy' and i.level < (params['level_bounds'][0] - 50)))
                     or i.name in params['key_items']
                     # or i.name in ('Casque Dragoeuf',)
                     ]
        all_items = [i for i in all_items if i.level <= params['level_bounds'][1]
                     or i.name in params['key_items']]
        all_items = [i for i in all_items if i.name not in params['excluded_items']]
        if params['exclude_mounts']:
            all_items = [i for i in all_items if i.type not in ["Dragoturkey", "Seemyool", "Rhineetle"]]

        # exclude/include ap/mp items completely
        # all_items = [i for i in all_items if (i not in data.mp_items() or params['non_boot_mp']) and (
        #        i not in data.ap_items() or params['non_amulet_ap']) or i.name in params['key_items']]

        # penalize these items early on
        ap_mp_items = list(
            {i for i in data.mp_items() + data.ap_items() if i in all_items and i.name not in params['key_items']})

        for slot in self.slots:
            slot.eligible_items = [i for i in all_items if slot == i.slot]
            slot.key_items = [slot.eligible_items.index(i) for i in slot.eligible_items if
                              i.name in params['key_items']]
            slot.early_penalized_indices = [slot.eligible_items.index(i) for i in slot.eligible_items if
                                            i in ap_mp_items]
            slot.ensure_fillable()
        self.early_penalty = 0

        for i in all_items:
            for s in i.stats:
                if s not in STATS:
                    STATS.append(s)
                    print(f'added missing stat {s} for item {i.name}')

        # additional damage lines from equipped weapon
        self.weapon_lines = [l for l in self.params['lines'] if l['element'] == 'weapon']

    def prepare_results(self, generation_number, report_list, last_population, last_scores):
        Builder.equip(last_population[0], self.slots)
        return {'build': [[i for i in s.get_equipped()] for s in self.slots],
                'score': -last_scores[0],
                'stats': Builder.get_stat(self.slots, self.params)}

    def maximize(self, algorithm_param, callbacks=[]):
        varbound = np.array(
            [(0, len(s.eligible_items) - 1) for s in self.slots for q in range(s.quantity) if s.eligible_items]).astype(
            int)

        def increment_iter(generation_number, report_list, last_population, last_scores):
            # zero_penalty_iter = algorithm_param['max_iteration_without_improv']
            # self.early_penalty = int(zero_penalty_iter >= generation_number)  # min(generation_number/zero_penalty_iter, 1)

            # if no improvements for some time, remove penalty on AP/MP items
            wait_iter = algorithm_param['max_iteration_without_improv'] // 8
            if (len(report_list) > wait_iter and 1 == len(set(list(report_list[-wait_iter:])))) or len(
                    report_list) > algorithm_param['max_num_iteration'] / 4:
                self.early_penalty = 1

        model = ga(dimension=sum([s.quantity for s in self.slots if s.eligible_items]),
                   variable_type='int',
                   variable_boundaries=varbound,
                   algorithm_parameters=algorithm_param)
        res = model.run(function=self.f, progress_bar_stream=None,
                        no_plot=True, disable_printing=True, callbacks=callbacks + [increment_iter],
                        remove_duplicates_generation_step=1,
                        duplicates_oppositor=OppositionOperators.Discrete.integers_by_order(minimums=varbound[:, 0],
                                                                                            maximums=varbound[:, 1])
                        )
        return self.prepare_results(None, None,
                                    [res.variable], [res.score])

    @staticmethod
    #@profile
    def equip(item_indices, slots):
        # equip input
        i = 0
        for slot in slots:
            slot.equipped_indices = slot.key_items[:slot.quantity]
            i += len(slot.equipped_indices)
            if slot.eligible_items:
                while len(slot.equipped_indices) < slot.quantity and slot.eligible_items:
                    slot.equipped_indices.append(int(item_indices[i]))
                    i += 1

            if not slot.get_set_legal():
                return False
        return True

    @staticmethod
    #@profile
    def get_stat(slots, params):
        stats = {c: 0 for c in STATS}
        stats.update({line['base_stat']: line['base_stat_value'] for line in params['base_stats']})
        stats['ap'] = 6 + int(params['level_bounds'][1] >= 100) + int(params['exo_AP'])  # base+lvl100
        stats['mp'] = 3 + int(params['exo_MP'])
        stats['range'] = int(params['exo_Range'])
        stats['summons'] = 1
        stats['set bonus'] = 0

        sets = {}
        for slot in slots:
            # get stats and count set items
            for item in [slot.eligible_items[i] for i in slot.equipped_indices]:
                for s, v in item.stats.items():
                    if slot.name == 'Base stats':
                        stats[s] += v * params['level_bounds'][1] / 200
                    else:
                        stats[s] += v

                if item.set:
                    if item.set not in sets:
                        sets[item.set] = 1
                    else:
                        sets[item.set] += 1
                        stats['set bonus'] += 1

        # set bonuses
        for ss, count in sets.items():
            if count in ss.bonuses:
                stats.update({k: stats.get(k, 0) + ss.bonuses[count].get(k, 0) for k in set(ss.bonuses[count])})

        for s in ('% neutral resistance', '% air resistance', '% fire resistance',
                  '% earth resistance', '% water resistance'):
            stats[s] = min(stats[s], 50)  # cap at 50% res

        # stats boosting other stats
        stats['mp parry'] += stats['wisdom'] / 10
        stats['mp reduction'] += stats['wisdom'] / 10
        stats['ap parry'] += stats['wisdom'] / 10
        stats['ap reduction'] += stats['wisdom'] / 10

        stats['lock'] += stats['agility'] / 10
        stats['dodge'] += stats['agility'] / 10

        stats['prospecting'] += stats['chance'] / 10

        stats['initiative'] += stats['strength'] + stats['intelligence'] + stats['agility'] + stats['chance']

        # zero cap stats
        stats['range'] = max(0, stats['range'])
        stats['summons'] = max(1, stats['summons'])
        stats['mp parry'] = max(0, stats['mp parry'])
        stats['ap parry'] = max(0, stats['ap parry'])
        stats['dodge'] = max(0, stats['dodge'])
        stats['lock'] = max(0, stats['lock'])

        return stats

    def get_weapon_damage_lines(self):
        additional_lines = []
        # include weapon damage lines
        if self.weapon_lines:
            # get equipped weapon
            for s in [s for s in self.slots if s.name == 'Arme']:
                weapon = s.get_equipped()
                break
            if weapon:
                weapon = weapon[0]
                # add damage lines
                for weapon_line in self.weapon_lines:
                    for effect, value in weapon.effects:
                        damage_line = {}
                        damage_line['element'] = effect.split(' ')[0].split('-')[0]
                        if damage_line['element'] not in ('neutral', 'water', 'air', 'fire', 'earth', 'best'):
                            continue
                        damage_line['damage_base'] = value
                        damage_line['damage_crit'] = value + weapon.critical_hit_bonus
                        damage_line['is_weapon'] = True
                        damage_line['is_ranged'] = weapon.range_min > 1
                        damage_line['is_heal'] = effect.endswith(' heals')
                        damage_line['can_crit'] = weapon.critical_hit_probability > 0
                        damage_line['base_critical'] = weapon.critical_hit_probability
                        damage_line['weight'] = 1
                        if weapon_line['is_heal'] != damage_line['is_heal']:  # heal mode override
                            damage_line['weight'] *= -1
                        if weapon_line['is_ranged'] and effect.endswith(' steal'):  # lifesteal override
                            damage_line['weight'] += 0.5
                        if not weapon_line['is_weapon']:  # by AP override
                            damage_line['weight'] *= 5 / weapon.ap_cost
                        damage_line['weight'] *= weapon.max_cast_per_turn

                        additional_lines.append(damage_line)

        return additional_lines

    #@profile
    def f(self, item_indices):
        valid = Builder.equip(item_indices, self.slots)
        if not valid:
            return 1e10
        stats = Builder.get_stat(self.slots, self.params)

        penalty = 1

        set_requirements = {
            # 1018: 'Amulette'  # gargandyas set : requires a gargandyas amulet -> removed from the game
        }
        unmet_set_requirements = set()
        for slot in self.slots:
            if not slot.get_set_legal():
                penalty *= 0.8

            if self.early_penalty < 1:
                if [i for i in slot.equipped_indices if i in slot.early_penalized_indices]:
                    penalty *= self.early_penalty

            for item in slot.get_equipped():
                unmet_conditions = False
                if item.conditions is not None:
                    if 'stat' in item.conditions and item.conditions['stat'] in stats:
                        if not eval(
                                f"stats[item.conditions['stat']] {item.conditions['operator']} item.conditions['value']"):
                            unmet_conditions = True
                    elif 'and' in item.conditions:
                        for cond in item.conditions['and']:
                            if 'stat' not in cond or cond['stat'] not in stats:
                                continue
                            if not eval(f"stats[cond['stat']] {cond['operator']} cond['value']"):
                                unmet_conditions = True
                    elif 'or' in item.conditions:
                        unmet_conditions = True
                        for cond in item.conditions['or']:
                            if 'stat' not in cond or cond['stat'] not in stats:
                                continue
                            if eval(f"stats[cond['stat']] {cond['operator']} cond['value']"):
                                unmet_conditions = False

                if unmet_conditions:
                    penalty *= 0.8

                if item.set is not None and item.set.id in set_requirements:
                    if item.slot.name == set_requirements[item.set.id]:
                        set_requirements.pop(item.set.id)
                        unmet_set_requirements.discard(item.set.id)
                    else:
                        unmet_set_requirements.add(item.set.id)

        if unmet_set_requirements:
            penalty *= 0.5

        # primary score
        score = self.primary_score(stats, self.params, self.get_weapon_damage_lines())

        # other weighed stats
        score += self.secondary_score(stats, self.params) * score / 50

        score *= self.resists_score(stats, self.params)

        # hard constraint penalty
        for k, v in self.params['min_stats'].items():
            if stats[k] < v:
                if v != 0:
                    diff = max(0, (v - stats[k]) / v)
                    penalty *= (1 - min(diff * 2, 1))
                else:
                    penalty *= 0

        build_hash = float(crc32(
            ':'.join(['.'.join([str(i) for i in sorted(s.equipped_indices)]) for s in self.slots]).encode('utf-8')
        ) & 0xffffffff) / 2 ** 32

        return -score * penalty - build_hash * score / 2 ** 10
