import dataclasses
import os
import typing
import math
import settings
import threading

from BaseClasses import Item, MultiWorld, Tutorial, ItemClassification
from worlds.AutoWorld import WebWorld, World
from worlds.generic.Rules import add_rule, exclusion_rules

from .Client import SMWSNIClient
from .Items import SMWItem, ItemData, item_table, junk_table
from .Levels import full_level_list, generate_level_list, location_id_to_level_id
from .Locations import SMWLocation, all_locations, setup_locations, special_zone_level_names, special_zone_dragon_coin_names, special_zone_hidden_1up_names, special_zone_blocksanity_names
from .Names import ItemName, LocationName
from .Options import SMWOptions, smw_option_groups
from .Presets import smw_options_presets
from .Regions import create_regions, connect_regions
from .Rom import LocalRom, patch_rom, get_base_rom_path, SMWDeltaPatch
from .Rules import set_rules


class SMWSettings(settings.Group):
    class RomFile(settings.SNESRomPath):
        """File name of the SMW US rom"""
        description = "Super Mario World (USA) ROM File"
        copy_to = "Super Mario World (USA).sfc"
        md5s = [SMWDeltaPatch.hash]

    rom_file: RomFile = RomFile(RomFile.copy_to)


class SMWWeb(WebWorld):
    theme = "grass"

    setup_en = Tutorial(
        "Multiworld Setup Guide",
        "A guide to setting up the Super Mario World randomizer connected to an Archipelago Multiworld.",
        "English",
        "setup_en.md",
        "setup/en",
        ["PoryGone"]
    )

    tutorials = [setup_en]

    option_groups = smw_option_groups
    options_presets = smw_options_presets


class SMWWorld(World):
    """
    Super Mario World is an action platforming game.
    The Princess has been kidnapped by Bowser again, but Mario has somehow
    lost all of his abilities. Can he get them back in time to save the Princess?
    """
    game: str = "Super Mario World"

    settings: typing.ClassVar[SMWSettings]

    options_dataclass = SMWOptions
    options: SMWOptions

    topology_present = False
    required_client_version = (0, 4, 5)

    item_name_to_id = {name: data.code for name, data in item_table.items()}
    location_name_to_id = all_locations

    active_level_dict: typing.Dict[int,int]
    web = SMWWeb()
    
    def __init__(self, multiworld: MultiWorld, player: int):
        self.rom_name_available_event = threading.Event()
        super().__init__(multiworld, player)

    @classmethod
    def stage_assert_generate(cls, multiworld: MultiWorld):
        rom_file = get_base_rom_path()
        if not os.path.exists(rom_file):
            raise FileNotFoundError(rom_file)

    def fill_slot_data(self) -> dict:
        slot_data = self.options.as_dict(
            "dragon_coin_checks",
            "moon_checks",
            "hidden_1up_checks",
            "bonus_block_checks",
            "blocksanity",
        )
        slot_data["active_levels"] = self.active_level_dict
        slot_data["trap_weights"] = self.output_trap_weights()

        return slot_data

    def generate_early(self):
        if self.options.early_climb:
            self.multiworld.local_early_items[self.player][ItemName.mario_climb] = 1

    def create_regions(self):
        location_table = setup_locations(self)
        create_regions(self, location_table)

        # Not generate basic
        itempool: typing.List[SMWItem] = []

        self.active_level_dict = dict(zip(generate_level_list(self), full_level_list))
        self.topology_present = self.options.level_shuffle

        connect_regions(self, self.active_level_dict)

        # Add Boss Token amount requirements for Worlds
        add_rule(self.multiworld.get_region(LocationName.donut_plains_1_tile, self.player).entrances[0], lambda state: state.has(ItemName.koopaling, self.player, 1))
        add_rule(self.multiworld.get_region(LocationName.vanilla_dome_1_tile, self.player).entrances[0], lambda state: state.has(ItemName.koopaling, self.player, 2))
        add_rule(self.multiworld.get_region(LocationName.forest_of_illusion_1_tile, self.player).entrances[0], lambda state: state.has(ItemName.koopaling, self.player, 4))
        add_rule(self.multiworld.get_region(LocationName.chocolate_island_1_tile, self.player).entrances[0], lambda state: state.has(ItemName.koopaling, self.player, 5))
        add_rule(self.multiworld.get_region(LocationName.valley_of_bowser_1_tile, self.player).entrances[0], lambda state: state.has(ItemName.koopaling, self.player, 6))

        exclusion_pool = set()
        if self.options.exclude_special_zone:
            exclusion_pool.update(special_zone_level_names)
            if self.options.dragon_coin_checks:
                exclusion_pool.update(special_zone_dragon_coin_names)
            if self.options.hidden_1up_checks:
                exclusion_pool.update(special_zone_hidden_1up_names)
            if self.options.blocksanity:
                exclusion_pool.update(special_zone_blocksanity_names)

            exclusion_rules(self.multiworld, self.player, exclusion_pool)

        total_required_locations = 96
        if self.options.dragon_coin_checks:
            total_required_locations += 49
        if self.options.moon_checks:
            total_required_locations += 7
        if self.options.hidden_1up_checks:
            total_required_locations += 14
        if self.options.bonus_block_checks:
            total_required_locations += 4
        if self.options.blocksanity:
            total_required_locations += 582

        itempool += [self.create_item(ItemName.mario_run)]
        itempool += [self.create_item(ItemName.mario_carry)]
        itempool += [self.create_item(ItemName.mario_swim)]
        itempool += [self.create_item(ItemName.mario_spin_jump)]
        itempool += [self.create_item(ItemName.mario_climb)]
        itempool += [self.create_item(ItemName.yoshi_activate)]
        itempool += [self.create_item(ItemName.p_switch)]
        itempool += [self.create_item(ItemName.p_balloon)]
        itempool += [self.create_item(ItemName.super_star_active)]
        itempool += [self.create_item(ItemName.progressive_powerup) for _ in range(3)]
        itempool += [self.create_item(ItemName.yellow_switch_palace)]
        itempool += [self.create_item(ItemName.green_switch_palace)]
        itempool += [self.create_item(ItemName.red_switch_palace)]
        itempool += [self.create_item(ItemName.blue_switch_palace)]
        itempool += [self.create_item(ItemName.special_world_clear)]
        
        if self.options.goal == "yoshi_egg_hunt":
            raw_egg_count = total_required_locations - len(itempool) - len(exclusion_pool)
            total_egg_count = min(raw_egg_count, self.options.max_yoshi_egg_cap.value)
            self.required_egg_count = max(math.floor(total_egg_count * (self.options.percentage_of_yoshi_eggs.value / 100.0)), 1)
            extra_egg_count = total_egg_count - self.required_egg_count
            removed_egg_count = math.floor(extra_egg_count * (self.options.junk_fill_percentage.value / 100.0))
            self.actual_egg_count = total_egg_count - removed_egg_count

            itempool += [self.create_item(ItemName.yoshi_egg) for _ in range(self.actual_egg_count)]

            self.multiworld.get_location(LocationName.yoshis_house, self.player).place_locked_item(self.create_item(ItemName.victory))
        else:
            self.actual_egg_count = 0
            self.required_egg_count = 0

            self.multiworld.get_location(LocationName.bowser, self.player).place_locked_item(self.create_item(ItemName.victory))

        junk_count = total_required_locations - len(itempool)
        trap_weights = []
        trap_weights += ([ItemName.ice_trap] * self.options.ice_trap_weight.value)
        trap_weights += ([ItemName.stun_trap] * self.options.stun_trap_weight.value)
        trap_weights += ([ItemName.literature_trap] * self.options.literature_trap_weight.value)
        trap_weights += ([ItemName.timer_trap] * self.options.timer_trap_weight.value)
        trap_weights += ([ItemName.reverse_controls_trap] * self.options.reverse_trap_weight.value)
        trap_weights += ([ItemName.thwimp_trap] * self.options.thwimp_trap_weight.value)
        trap_count = 0 if (len(trap_weights) == 0) else math.ceil(junk_count * (self.options.trap_fill_percentage.value / 100.0))
        junk_count -= trap_count

        trap_pool = []
        for i in range(trap_count):
            trap_item = self.random.choice(trap_weights)
            trap_pool.append(self.create_item(trap_item))

        itempool += trap_pool

        junk_weights = []
        junk_weights += ([ItemName.one_coin] * 15)
        junk_weights += ([ItemName.five_coins] * 15)
        junk_weights += ([ItemName.ten_coins] * 25)
        junk_weights += ([ItemName.fifty_coins] * 25)
        junk_weights += ([ItemName.one_up_mushroom] * 20)

        junk_pool = [self.create_item(self.random.choice(junk_weights)) for _ in range(junk_count)]
        
        itempool += junk_pool

        boss_location_names = [LocationName.yoshis_island_koopaling, LocationName.donut_plains_koopaling, LocationName.vanilla_dome_koopaling,
                               LocationName.twin_bridges_koopaling, LocationName.forest_koopaling, LocationName.chocolate_koopaling,
                               LocationName.valley_koopaling, LocationName.vanilla_reznor, LocationName.forest_reznor, LocationName.chocolate_reznor, LocationName.valley_reznor]

        for location_name in boss_location_names:
            self.multiworld.get_location(location_name, self.player).place_locked_item(self.create_item(ItemName.koopaling))

        self.multiworld.itempool += itempool


    def generate_output(self, output_directory: str):
        rompath = ""  # if variable is not declared finally clause may fail
        try:
            multiworld = self.multiworld
            player = self.player

            rom = LocalRom(get_base_rom_path())
            patch_rom(self, rom, self.player, self.active_level_dict)

            rompath = os.path.join(output_directory, f"{self.multiworld.get_out_file_name_base(self.player)}.sfc")
            rom.write_to_file(rompath)
            self.rom_name = rom.name

            patch = SMWDeltaPatch(os.path.splitext(rompath)[0]+SMWDeltaPatch.patch_file_ending, player=player,
                                  player_name=multiworld.player_name[player], patched_path=rompath)
            patch.write()
        except:
            raise
        finally:
            self.rom_name_available_event.set()  # make sure threading continues and errors are collected
            if os.path.exists(rompath):
                os.unlink(rompath)

    def modify_multidata(self, multidata: dict):
        import base64
        # wait for self.rom_name to be available.
        self.rom_name_available_event.wait()
        rom_name = getattr(self, "rom_name", None)
        # we skip in case of error, so that the original error in the output thread is the one that gets raised
        if rom_name:
            new_name = base64.b64encode(bytes(self.rom_name)).decode()
            multidata["connect_names"][new_name] = multidata["connect_names"][self.multiworld.player_name[self.player]]

    def extend_hint_information(self, hint_data: typing.Dict[int, typing.Dict[int, str]]):
        if self.topology_present:
            world_names = [
                LocationName.yoshis_island_region,
                LocationName.donut_plains_region,
                LocationName.vanilla_dome_region,
                LocationName.twin_bridges_region,
                LocationName.forest_of_illusion_region,
                LocationName.chocolate_island_region,
                LocationName.valley_of_bowser_region,
                LocationName.star_road_region,
                LocationName.special_zone_region,
            ]
            world_cutoffs = [
                0x07,
                0x13,
                0x1F,
                0x26,
                0x30,
                0x39,
                0x44,
                0x4F,
                0x59
            ]
            er_hint_data = {}
            for loc_name, level_data in location_id_to_level_id.items():
                level_id = level_data[0]

                if level_id not in self.active_level_dict:
                    continue

                keys_list = list(self.active_level_dict.keys())
                level_index = keys_list.index(level_id)
                for i in range(len(world_cutoffs)):
                    if level_index >= world_cutoffs[i]:
                        continue

                    if not self.options.dragon_coin_checks and "Dragon Coins" in loc_name:
                        continue
                    if not self.options.moon_checks and "3-Up Moon" in loc_name:
                        continue
                    if not self.options.hidden_1up_checks and "Hidden 1-Up" in loc_name:
                        continue
                    if not self.options.bonus_block_checks and "1-Up from Bonus Block" in loc_name:
                        continue
                    if not self.options.blocksanity and "Block #" in loc_name:
                        continue

                    location = self.multiworld.get_location(loc_name, self.player)
                    er_hint_data[location.address] = world_names[i]
                    break

            hint_data[self.player] = er_hint_data

    def create_item(self, name: str, force_non_progression=False) -> Item:
        data = item_table[name]

        if force_non_progression:
            classification = ItemClassification.filler
        elif name == ItemName.yoshi_egg:
            classification = ItemClassification.progression_skip_balancing
        elif data.progression:
            classification = ItemClassification.progression
        elif data.trap:
            classification = ItemClassification.trap
        else:
            classification = ItemClassification.filler

        created_item = SMWItem(name, classification, data.code, self.player)

        return created_item

    def get_filler_item_name(self) -> str:
        return self.random.choice(list(junk_table.keys()))

    def set_rules(self):
        set_rules(self)

    def output_trap_weights(self) -> dict[int, int]:
        trap_data = {}

        trap_data[0xBC0013] = self.options.ice_trap_weight.value
        trap_data[0xBC0014] = self.options.stun_trap_weight.value
        trap_data[0xBC0015] = self.options.literature_trap_weight.value
        trap_data[0xBC0016] = self.options.timer_trap_weight.value
        trap_data[0xBC001C] = self.options.reverse_trap_weight.value
        trap_data[0xBC001D] = self.options.thwimp_trap_weight.value

        return trap_data
