import base64
import copy
import itertools
import math
import os
import settings
import typing
from enum import IntFlag
from typing import Any, ClassVar, Dict, List, Optional, Set, Tuple

from BaseClasses import Entrance, Item, ItemClassification, MultiWorld, Region, Tutorial, \
    LocationProgressType
from Utils import __version__
from Options import AssembleOptions
from worlds.AutoWorld import WebWorld, World
from Fill import fill_restrictive
from worlds.generic.Rules import add_rule, set_rule
from .Options import adventure_option_definitions, DragonRandoType, DifficultySwitchA, DifficultySwitchB
from .Rom import get_base_rom_bytes, get_base_rom_path, AdventureDeltaPatch, apply_basepatch, \
    AdventureAutoCollectLocation
from .Items import item_table, ItemData, nothing_item_id, event_table, AdventureItem, standard_item_max
from .Locations import location_table, base_location_id, LocationData, get_random_room_in_regions
from .Offsets import static_item_data_location, items_ram_start, static_item_element_size, item_position_table, \
    static_first_dragon_index, connector_port_offset, yorgle_speed_data_location, grundle_speed_data_location, \
    rhindle_speed_data_location, item_ram_addresses, start_castle_values, start_castle_offset
from .Regions import create_regions
from .Rules import set_rules


from worlds.LauncherComponents import Component, components, SuffixIdentifier

# Adventure
components.append(Component('Adventure Client', 'AdventureClient', file_identifier=SuffixIdentifier('.apadvn')))


class AdventureSettings(settings.Group):
    class RomFile(settings.UserFilePath):
        """
        File name of the standard NTSC Adventure rom.
        The licensed "The 80 Classic Games" CD-ROM contains this.
        It may also have a .a26 extension
        """
        copy_to = "ADVNTURE.BIN"
        description = "Adventure ROM File"
        md5s = [AdventureDeltaPatch.hash]

    class RomStart(str):
        """
        Set this to false to never autostart a rom (such as after patching)
        True for operating system default program for '.a26'
        Alternatively, a path to a program to open the .a26 file with (generally EmuHawk for multiworld)
        """

    class RomArgs(str):
        """
        Optional, additional args passed into rom_start before the .bin file
        For example, this can be used to autoload the connector script in BizHawk
        (see BizHawk --lua= option)
        Windows example:
        rom_args: "--lua=C:/ProgramData/Archipelago/data/lua/connector_adventure.lua"
        """

    class DisplayMsgs(settings.Bool):
        """Set this to true to display item received messages in EmuHawk"""

    rom_file: RomFile = RomFile(RomFile.copy_to)
    rom_start: typing.Union[RomStart, bool] = True
    rom_args: Optional[RomArgs] = " "
    display_msgs: typing.Union[DisplayMsgs, bool] = True


class AdventureWeb(WebWorld):
    theme = "dirt"

    setup = Tutorial(
        "Multiworld Setup Guide",
        "A guide to setting up Adventure for MultiWorld.",
        "English",
        "setup_en.md",
        "setup/en",
        ["JusticePS"]
    )

    setup_fr = Tutorial(
        "Guide de configuration Multimonde",
        "Un guide pour configurer Adventure MultiWorld",
        "Français",
        "setup_fr.md",
        "setup/fr",
        ["TheLynk"]
    )

    tutorials = [setup, setup_fr]


def get_item_position_data_start(table_index: int):
    item_ram_address = item_ram_addresses[table_index]
    return item_position_table + item_ram_address - items_ram_start


class AdventureWorld(World):
    """
    Adventure for the Atari 2600 is an early graphical adventure game.
    Find the enchanted chalice and return it to the yellow castle,
    using magic items to enter hidden rooms, retrieve out of
    reach items, or defeat the three dragons.  Beware the bat
    who likes to steal your equipment!
    """
    game: ClassVar[str] = "Adventure"
    web: ClassVar[WebWorld] = AdventureWeb()

    option_definitions: ClassVar[Dict[str, AssembleOptions]] = adventure_option_definitions
    settings: ClassVar[AdventureSettings]
    item_name_to_id: ClassVar[Dict[str, int]] = {name: data.id for name, data in item_table.items()}
    location_name_to_id: ClassVar[Dict[str, int]] = {name: data.location_id for name, data in location_table.items()}
    data_version: ClassVar[int] = 1
    required_client_version: Tuple[int, int, int] = (0, 3, 9)

    def __init__(self, world: MultiWorld, player: int):
        super().__init__(world, player)
        self.rom_name: Optional[bytearray] = bytearray("", "utf8" )
        self.dragon_rooms: [int] = [0x14, 0x19, 0x4]
        self.dragon_slay_check: Optional[int] = 0
        self.connector_multi_slot: Optional[int] = 0
        self.dragon_rando_type: Optional[int] = 0
        self.yorgle_speed: Optional[int] = 2
        self.yorgle_min_speed: Optional[int] = 2
        self.grundle_speed: Optional[int] = 2
        self.grundle_min_speed: Optional[int] = 2
        self.rhindle_speed: Optional[int] = 3
        self.rhindle_min_speed: Optional[int] = 3
        self.difficulty_switch_a: Optional[int] = 0
        self.difficulty_switch_b: Optional[int] = 0
        self.start_castle: Optional[int] = 0
        # dict of item names -> list of speed deltas
        self.dragon_speed_reducer_info: {} = {}
        self.created_items: int = 0

    @classmethod
    def stage_assert_generate(cls, _multiworld: MultiWorld) -> None:
        # don't need rom anymore
        pass

    def place_random_dragon(self, dragon_index: int):
        region_list = ["Overworld", "YellowCastle", "BlackCastle", "WhiteCastle"]
        self.dragon_rooms[dragon_index] = get_random_room_in_regions(region_list, self.multiworld.random)

    def generate_early(self) -> None:
        self.rom_name = \
            bytearray(f"ADVENTURE{__version__.replace('.', '')[:3]}_{self.player}_{self.multiworld.seed}", "utf8")[:21]
        self.rom_name.extend([0] * (21 - len(self.rom_name)))

        self.dragon_rando_type = self.multiworld.dragon_rando_type[self.player].value
        self.dragon_slay_check = self.multiworld.dragon_slay_check[self.player].value
        self.connector_multi_slot = self.multiworld.connector_multi_slot[self.player].value
        self.yorgle_speed = self.multiworld.yorgle_speed[self.player].value
        self.yorgle_min_speed = self.multiworld.yorgle_min_speed[self.player].value
        self.grundle_speed = self.multiworld.grundle_speed[self.player].value
        self.grundle_min_speed = self.multiworld.grundle_min_speed[self.player].value
        self.rhindle_speed = self.multiworld.rhindle_speed[self.player].value
        self.rhindle_min_speed = self.multiworld.rhindle_min_speed[self.player].value
        self.difficulty_switch_a = self.multiworld.difficulty_switch_a[self.player].value
        self.difficulty_switch_b = self.multiworld.difficulty_switch_b[self.player].value
        self.start_castle = self.multiworld.start_castle[self.player].value
        self.created_items = 0

        if self.dragon_slay_check == 0:
            item_table["Sword"].classification = ItemClassification.useful
        else:
            item_table["Sword"].classification = ItemClassification.progression
            if self.difficulty_switch_b == DifficultySwitchB.option_hard_with_unlock_item:
                item_table["Right Difficulty Switch"].classification = ItemClassification.progression

        if self.dragon_rando_type == DragonRandoType.option_shuffle:
            self.multiworld.random.shuffle(self.dragon_rooms)
        elif self.dragon_rando_type == DragonRandoType.option_overworldplus:
            dragon_indices = [0, 1, 2]
            overworld_forced_index = self.multiworld.random.choice(dragon_indices)
            dragon_indices.remove(overworld_forced_index)
            region_list = ["Overworld"]
            self.dragon_rooms[overworld_forced_index] = get_random_room_in_regions(region_list, self.multiworld.random)
            self.place_random_dragon(dragon_indices[0])
            self.place_random_dragon(dragon_indices[1])
        elif self.dragon_rando_type == DragonRandoType.option_randomized:
            self.place_random_dragon(0)
            self.place_random_dragon(1)
            self.place_random_dragon(2)

    def create_items(self) -> None:
        for event in map(self.create_item, event_table):
            self.multiworld.itempool.append(event)
        exclude = [item for item in self.multiworld.precollected_items[self.player]]
        self.created_items = 0
        for item in map(self.create_item, item_table):
            if item.code == nothing_item_id:
                continue
            if item in exclude and item.code <= standard_item_max:
                exclude.remove(item)  # this is destructive. create unique list above
            else:
                if item.code <= standard_item_max:
                    self.multiworld.itempool.append(item)
                    self.created_items += 1
        num_locations = len(location_table) - 1  # subtract out the chalice location
        if self.dragon_slay_check == 0:
            num_locations -= 3

        if self.difficulty_switch_a == DifficultySwitchA.option_hard_with_unlock_item:
            self.multiworld.itempool.append(self.create_item("Left Difficulty Switch"))
            self.created_items += 1
        if self.difficulty_switch_b == DifficultySwitchB.option_hard_with_unlock_item:
            self.multiworld.itempool.append(self.create_item("Right Difficulty Switch"))
            self.created_items += 1

        extra_filler_count = num_locations - self.created_items
        self.dragon_speed_reducer_info = {}
        # make sure yorgle doesn't take 2 if there's not enough for the others to get at least one
        if extra_filler_count <= 4:
            extra_filler_count = 1
        self.create_dragon_slow_items(self.yorgle_min_speed, self.yorgle_speed, "Slow Yorgle", extra_filler_count)
        extra_filler_count = num_locations - self.created_items

        if extra_filler_count <= 3:
            extra_filler_count = 1
        self.create_dragon_slow_items(self.grundle_min_speed, self.grundle_speed, "Slow Grundle", extra_filler_count)
        extra_filler_count = num_locations - self.created_items

        self.create_dragon_slow_items(self.rhindle_min_speed, self.rhindle_speed, "Slow Rhindle", extra_filler_count)
        extra_filler_count = num_locations - self.created_items

        # traps would probably go here, if enabled
        freeincarnate_max = self.multiworld.freeincarnate_max[self.player].value
        actual_freeincarnates = min(extra_filler_count, freeincarnate_max)
        self.multiworld.itempool += [self.create_item("Freeincarnate") for _ in range(actual_freeincarnates)]
        self.created_items += actual_freeincarnates

    def create_dragon_slow_items(self, min_speed: int, speed: int, item_name: str, maximum_items: int):
        if min_speed < speed:
            delta = speed - min_speed
            if delta > 2 and maximum_items >= 2:
                self.multiworld.itempool.append(self.create_item(item_name))
                self.multiworld.itempool.append(self.create_item(item_name))
                speed_with_one = speed - math.floor(delta / 2)
                self.dragon_speed_reducer_info[item_table[item_name].id] = [speed_with_one, min_speed]
                self.created_items += 2
            elif maximum_items >= 1:
                self.multiworld.itempool.append(self.create_item(item_name))
                self.dragon_speed_reducer_info[item_table[item_name].id] = [min_speed]
                self.created_items += 1

    def create_regions(self) -> None:
        create_regions(self.multiworld, self.player, self.dragon_rooms)

    set_rules = set_rules

    def generate_basic(self) -> None:
        self.multiworld.get_location("Chalice Home", self.player).place_locked_item(
            self.create_event("Victory", ItemClassification.progression))
        self.multiworld.completion_condition[self.player] = lambda state: state.has("Victory", self.player)

    def pre_fill(self):
        # Place empty items in filler locations here, to limit
        # the number of exported empty items and the density of stuff in overworld.
        max_location_count = len(location_table) - 1
        if self.dragon_slay_check == 0:
            max_location_count -= 3

        force_empty_item_count = (max_location_count - self.created_items)
        if force_empty_item_count <= 0:
            return
        overworld = self.multiworld.get_region("Overworld", self.player)
        overworld_locations_copy = overworld.locations.copy()
        all_locations = self.multiworld.get_locations(self.player)

        locations_copy = list(all_locations)
        for loc in all_locations:
            if loc.item is not None or loc.progress_type != LocationProgressType.DEFAULT:
                locations_copy.remove(loc)
                if loc in overworld_locations_copy:
                    overworld_locations_copy.remove(loc)

        # guarantee at least one overworld location, so we can for sure get a key somewhere
        # if too much stuff is plando'd though, just let it go
        if len(overworld_locations_copy) >= 3:
            saved_overworld_loc = self.multiworld.random.choice(overworld_locations_copy)
            locations_copy.remove(saved_overworld_loc)
            overworld_locations_copy.remove(saved_overworld_loc)

            # if we have few items, enforce another overworld slot, fill a hard slot, and ensure we have
            # at least one hard slot available
            if self.created_items < 15:
                hard_locations = []
                for loc in locations_copy:
                    if "Vault" in loc.name or "Credits" in loc.name:
                        hard_locations.append(loc)
                force_empty_item_count -= 1
                loc = self.multiworld.random.choice(hard_locations)
                loc.place_locked_item(self.create_item('nothing'))
                hard_locations.remove(loc)
                locations_copy.remove(loc)

                loc = self.multiworld.random.choice(hard_locations)
                locations_copy.remove(loc)
                hard_locations.remove(loc)

                saved_overworld_loc = self.multiworld.random.choice(overworld_locations_copy)
                locations_copy.remove(saved_overworld_loc)
                overworld_locations_copy.remove(saved_overworld_loc)

            # if we have very few items, fill another two difficult slots
            if self.created_items < 10:
                for i in range(2):
                    force_empty_item_count -= 1
                    loc = self.multiworld.random.choice(hard_locations)
                    loc.place_locked_item(self.create_item('nothing'))
                    hard_locations.remove(loc)
                    locations_copy.remove(loc)

            # for the absolute minimum number of items, enforce a third overworld slot
            if self.created_items <= 7:
                saved_overworld_loc = self.multiworld.random.choice(overworld_locations_copy)
                locations_copy.remove(saved_overworld_loc)
                overworld_locations_copy.remove(saved_overworld_loc)

        # finally, place nothing items
        while force_empty_item_count > 0 and locations_copy:
            force_empty_item_count -= 1
            # prefer somewhat to thin out the overworld.
            if len(overworld_locations_copy) > 0 and self.multiworld.random.randint(0, 10) < 4:
                loc = self.multiworld.random.choice(overworld_locations_copy)
            else:
                loc = self.multiworld.random.choice(locations_copy)
            loc.place_locked_item(self.create_item('nothing'))
            locations_copy.remove(loc)
            if loc in overworld_locations_copy:
                overworld_locations_copy.remove(loc)

    def place_dragons(self, rom_deltas: {int, int}):
        for i in range(3):
            table_index = static_first_dragon_index + i
            item_position_data_start = get_item_position_data_start(table_index)
            rom_deltas[item_position_data_start] = self.dragon_rooms[i]

    def set_dragon_speeds(self, rom_deltas: {int, int}):
        rom_deltas[yorgle_speed_data_location] = self.yorgle_speed
        rom_deltas[grundle_speed_data_location] = self.grundle_speed
        rom_deltas[rhindle_speed_data_location] = self.rhindle_speed

    def set_start_castle(self, rom_deltas):
        start_castle_value = start_castle_values[self.start_castle]
        rom_deltas[start_castle_offset] = start_castle_value

    def generate_output(self, output_directory: str) -> None:
        rom_path: str = os.path.join(output_directory, f"{self.multiworld.get_out_file_name_base(self.player)}.bin")
        foreign_item_locations: [LocationData] = []
        auto_collect_locations: [AdventureAutoCollectLocation] = []
        local_item_to_location: {int, int} = {}
        bat_no_touch_locs: [LocationData] = []
        bat_logic: int = self.multiworld.bat_logic[self.player].value
        try:
            rom_deltas: { int, int } = {}
            self.place_dragons(rom_deltas)
            self.set_dragon_speeds(rom_deltas)
            self.set_start_castle(rom_deltas)
            # start and stop indices are offsets in the ROM file, not Adventure ROM addresses (which start at f000)

            # This places the local items (I still need to make it easy to inject the offset data)
            unplaced_local_items = dict(filter(lambda x: nothing_item_id < x[1].id <= standard_item_max,
                                               item_table.items()))
            for location in self.multiworld.get_locations(self.player):
                # 'nothing' items, which are autocollected when the room is entered
                if location.item.player == self.player and \
                        location.item.name == "nothing":
                    location_data = location_table[location.name]
                    auto_collect_locations.append(AdventureAutoCollectLocation(location_data.short_location_id,
                                                                               location_data.room_id))
                # standard Adventure items, which are placed in the rom
                elif location.item.player == self.player and \
                        location.item.name != "nothing" and \
                        location.item.code is not None and \
                        location.item.code <= standard_item_max:
                    # I need many of the intermediate values here.
                    item_table_offset = item_table[location.item.name].table_index * static_item_element_size
                    item_ram_address = item_ram_addresses[item_table[location.item.name].table_index]
                    item_position_data_start = item_position_table + item_ram_address - items_ram_start
                    location_data = location_table[location.name]
                    room_x, room_y = location_data.get_position(self.multiworld.per_slot_randoms[self.player])
                    if location_data.needs_bat_logic and bat_logic == 0x0:
                        copied_location = copy.copy(location_data)
                        copied_location.local_item = item_ram_address
                        bat_no_touch_locs.append(copied_location)
                    del unplaced_local_items[location.item.name]

                    rom_deltas[item_position_data_start] = location_data.room_id
                    rom_deltas[item_position_data_start + 1] = room_x
                    rom_deltas[item_position_data_start + 2] = room_y
                    local_item_to_location[item_table_offset] = self.location_name_to_id[location.name] \
                                                              - base_location_id
                # items from other worlds, and non-standard Adventure items handled by script, like difficulty switches
                elif location.item.code is not None:
                    if location.item.code != nothing_item_id:
                        location_data = location_table[location.name]
                        foreign_item_locations.append(location_data)
                        if location_data.needs_bat_logic and bat_logic == 0x0:
                            bat_no_touch_locs.append(location_data)
                    else:
                        location_data = location_table[location.name]
                        auto_collect_locations.append(AdventureAutoCollectLocation(location_data.short_location_id,
                                                                                   location_data.room_id))
            # Adventure items that are in another world get put in an invalid room until needed
            for unplaced_item_name, unplaced_item in unplaced_local_items.items():
                item_position_data_start = get_item_position_data_start(unplaced_item.table_index)
                rom_deltas[item_position_data_start] = 0xff

            if self.multiworld.connector_multi_slot[self.player].value:
                rom_deltas[connector_port_offset] = (self.player & 0xff)
            else:
                rom_deltas[connector_port_offset] = 0
        except Exception as e:
            raise e
        else:
            patch = AdventureDeltaPatch(os.path.splitext(rom_path)[0] + AdventureDeltaPatch.patch_file_ending,
                                        player=self.player, player_name=self.multiworld.player_name[self.player],
                                        locations=foreign_item_locations,
                                        autocollect=auto_collect_locations, local_item_locations=local_item_to_location,
                                        dragon_speed_reducer_info=self.dragon_speed_reducer_info,
                                        diff_a_mode=self.difficulty_switch_a, diff_b_mode=self.difficulty_switch_b,
                                        bat_logic=bat_logic, bat_no_touch_locations=bat_no_touch_locs,
                                        rom_deltas=rom_deltas,
                                        seed_name=bytes(self.multiworld.seed_name, encoding="ascii"))
            patch.write()
        finally:
            if os.path.exists(rom_path):
                os.unlink(rom_path)

    # end of ordered Main.py calls

    def create_item(self, name: str) -> Item:
        item_data: ItemData = item_table.get(name)
        return AdventureItem(name, item_data.classification, item_data.id, self.player)

    def create_event(self, name: str, classification: ItemClassification) -> Item:
        return AdventureItem(name, classification, None, self.player)