import typing

from BaseClasses import Item, Tutorial, ItemClassification, Region, MultiWorld
from worlds.AutoWorld import WebWorld, World
from Options import OptionError
from .Items import OSRSItem, starting_area_dict, chunksanity_starting_chunks, QP_Items, ItemRow, \
    chunksanity_special_region_names
from .Locations import OSRSLocation, LocationRow, task_types
from .Rules import *
from .Options import OSRSOptions, StartingArea
from .Names import LocationNames, ItemNames, RegionNames

from .LogicCSV.LogicCSVToPython import data_csv_tag
from .LogicCSV.items_generated import item_rows
from .LogicCSV.locations_generated import location_rows
from .LogicCSV.regions_generated import region_rows
from .LogicCSV.resources_generated import resource_rows
from .Regions import RegionRow, ResourceRow


class OSRSWeb(WebWorld):
    theme = "stone"

    setup_en = Tutorial(
        "Multiworld Setup Guide",
        "A guide to setting up the Old School Runescape Randomizer connected to an Archipelago Multiworld",
        "English",
        "setup_en.md",
        "setup/en",
        ["digiholic"]
    )
    tutorials = [setup_en]


class OSRSWorld(World):
    """
    The best retro fantasy MMORPG on the planet. Old School is RuneScape but… older! This is the open world you know and love, but as it was in 2007.
    The Randomizer takes the form of a Chunk-Restricted f2p Ironman that takes a brand new account up through defeating
    the Green Dragon of Crandor and earning a spot in the fabled Champion's Guild!
    """

    game = "Old School Runescape"
    options_dataclass = OSRSOptions
    options: OSRSOptions
    topology_present = True
    web = OSRSWeb()
    base_id = 0x070000
    data_version = 1
    explicit_indirect_conditions = False
    ut_can_gen_without_yaml = True

    item_name_to_id = {item_rows[i].name: 0x070000 + i for i in range(len(item_rows))}
    location_name_to_id = {location_rows[i].name: 0x070000 + i for i in range(len(location_rows))}

    region_name_to_data: typing.Dict[str, Region]
    location_name_to_data: typing.Dict[str, OSRSLocation]

    location_rows_by_name: typing.Dict[str, LocationRow]
    region_rows_by_name: typing.Dict[str, RegionRow]
    resource_rows_by_name: typing.Dict[str, ResourceRow]
    item_rows_by_name: typing.Dict[str, ItemRow]

    starting_area_item: str

    locations_by_category: typing.Dict[str, typing.List[LocationRow]]
    available_QP_locations: typing.List[str]

    def __init__(self, multiworld: MultiWorld, player: int):
        super().__init__(multiworld, player)
        self.region_name_to_data = {}
        self.location_name_to_data = {}

        self.location_rows_by_name = {}
        self.region_rows_by_name = {}
        self.resource_rows_by_name = {}
        self.item_rows_by_name = {}

        self.starting_area_item = ""

        self.locations_by_category = {}
        self.available_QP_locations = []

    def generate_early(self) -> None:
        location_categories = [location_row.category for location_row in location_rows]
        self.locations_by_category = {category:
                                          [location_row for location_row in location_rows if
                                           location_row.category == category]
                                      for category in location_categories}

        self.location_rows_by_name = {loc_row.name: loc_row for loc_row in location_rows}
        self.region_rows_by_name = {reg_row.name: reg_row for reg_row in region_rows}
        self.resource_rows_by_name = {rec_row.name: rec_row for rec_row in resource_rows}
        self.item_rows_by_name = {it_row.name: it_row for it_row in item_rows}

        rnd = self.random
        starting_area = self.options.starting_area

        #UT specific override, if we are in normal gen, resolve starting area, we will get it from slot_data in UT
        if not hasattr(self.multiworld, "generation_is_fake"):
            if starting_area.value == StartingArea.option_any_bank:
                self.starting_area_item = rnd.choice(starting_area_dict)
            elif starting_area.value < StartingArea.option_chunksanity:
                self.starting_area_item = starting_area_dict[starting_area.value]
            else:
                self.starting_area_item = rnd.choice(chunksanity_starting_chunks)

            # Set Starting Chunk
            self.multiworld.push_precollected(self.create_item(self.starting_area_item))
        elif hasattr(self.multiworld,"re_gen_passthrough") and self.game in self.multiworld.re_gen_passthrough:
            re_gen_passthrough = self.multiworld.re_gen_passthrough[self.game] # UT passthrough
            if "starting_area" in re_gen_passthrough:
                self.starting_area_item = re_gen_passthrough["starting_area"]
            for task_type in task_types:
                if f"max_{task_type}_level" in re_gen_passthrough:
                    getattr(self.options,f"max_{task_type}_level").value = re_gen_passthrough[f"max_{task_type}_level"]
                max_count = getattr(self.options,f"max_{task_type}_tasks")
                max_count.value = max_count.range_end
            self.options.brutal_grinds.value = re_gen_passthrough["brutal_grinds"]



    """
    This function pulls from LogicCSVToPython so that it sends the correct tag of the repository to the client.
    _Make sure to update that value whenever the CSVs change!_
    """

    def fill_slot_data(self):
        data = self.options.as_dict("brutal_grinds")
        data["data_csv_tag"] = data_csv_tag
        data["starting_area"] = str(self.starting_area_item) #these aren't actually strings, they just play them on tv
        for task_type in task_types:
            data[f"max_{task_type}_level"] = getattr(self.options,f"max_{task_type}_level").value
        return data

    @staticmethod
    def interpret_slot_data(slot_data: typing.Dict[str, typing.Any]) -> typing.Dict[str, typing.Any]:
        return slot_data

    def create_regions(self) -> None:
        """
        called to place player's regions into the MultiWorld's regions list. If it's hard to separate, this can be done
        during generate_early or basic as well.
        """

        # First, create the "Menu" region to start
        menu_region = self.create_region("Menu")

        for region_row in region_rows:
            self.create_region(region_row.name)

        for resource_row in resource_rows:
            self.create_region(resource_row.name)

        # Removes the word "Area: " from the item name to get the region it applies to.
        # I figured tacking "Area: " at the beginning would make it _easier_ to tell apart. Turns out it made it worse
        # if area hasn't been set, then we shouldn't connect it
        if self.starting_area_item != "":
            if self.starting_area_item in chunksanity_special_region_names:
                starting_area_region = chunksanity_special_region_names[self.starting_area_item]
            else:
                starting_area_region = self.starting_area_item[6:]  # len("Area: ")
            starting_entrance = menu_region.create_exit(f"Start->{starting_area_region}")
            starting_entrance.access_rule = lambda state: state.has(self.starting_area_item, self.player)
            starting_entrance.connect(self.region_name_to_data[starting_area_region])

        # Create entrances between regions
        for region_row in region_rows:
            region = self.region_name_to_data[region_row.name]

            for outbound_region_name in region_row.connections:
                parsed_outbound = outbound_region_name.replace('*', '')
                entrance = region.create_exit(f"{region_row.name}->{parsed_outbound}")
                entrance.connect(self.region_name_to_data[parsed_outbound])

                item_name = self.region_rows_by_name[parsed_outbound].itemReq
                entrance.access_rule = lambda state, item_name=item_name.replace("*",""): state.has(item_name, self.player)
                generate_special_rules_for(entrance, region_row, outbound_region_name, self.player, self.options, self)

            for resource_region in region_row.resources:
                if not resource_region:
                    continue

                entrance = region.create_exit(f"{region_row.name}->{resource_region.replace('*', '')}")
                if "*" not in resource_region:
                    entrance.connect(self.region_name_to_data[resource_region])
                else:
                    entrance.connect(self.region_name_to_data[resource_region.replace('*', '')])
                generate_special_rules_for(entrance, region_row, resource_region, self.player, self.options, self)

        self.roll_locations()

    def task_within_skill_levels(self, skills_required):
        # Loop through each required skill. If any of its requirements are out of the defined limit, return false
        for skill in skills_required:
            max_level_for_skill = getattr(self.options, f"max_{skill.skill.lower()}_level")
            if skill.level > max_level_for_skill:
                return False
        return True

    def roll_locations(self):
        generation_is_fake = hasattr(self.multiworld, "generation_is_fake")  # UT specific override
        locations_required = 0
        for item_row in item_rows:
            if item_row.name == self.starting_area_item:
                continue #skip starting area
            # If it's a filler item, set it aside for later
            if item_row.progression == ItemClassification.filler:
                continue

            # If it starts with "Care Pack", only add it if Care Packs are enabled
            if item_row.name.startswith("Care Pack"):
                if not self.options.enable_carepacks:
                    continue
            locations_required += item_row.amount
        if self.options.enable_duds: locations_required += self.options.dud_count

        locations_added = 0  # Keep track of the number of locations we add so we don't add more the number of items we're going to make
        # Quests are always added first, before anything else is rolled
        for i, location_row in enumerate(location_rows):
            if location_row.category in {"quest"}:
                if self.task_within_skill_levels(location_row.skills):
                    self.create_and_add_location(i)
                    locations_added += 1
            elif location_row.category in {"goal"}:
                if not self.task_within_skill_levels(location_row.skills):
                    raise OptionError(f"Goal location for {self.player_name} not allowed in skill levels") #it doesn't actually have any, but just in case for future
                self.create_and_add_location(i)


        # Build up the weighted Task Pool
        rnd = self.random

        # Start with the minimum general tasks
        general_tasks = [task for task in self.locations_by_category["general"]]
        if not self.options.progressive_tasks:
            rnd.shuffle(general_tasks)
        else:
            general_tasks.reverse()
        general_tasks_added = 0
        while general_tasks_added<self.options.minimum_general_tasks and general_tasks:
            task = general_tasks.pop()
            if self.task_within_skill_levels(task.skills):
                self.add_location(task)
                locations_added += 1
                general_tasks_added += 1
        while generation_is_fake and len(general_tasks)>0:
            task = general_tasks.pop()
            if self.task_within_skill_levels(task.skills):
                self.add_location(task)
                locations_added += 1
                general_tasks_added += 1
        if general_tasks_added < self.options.minimum_general_tasks:
            raise OptionError(f"{self.plyaer_name} doesn't have enough general tasks to create required minimum count"+
                              f", raise maximum skill levels or lower minimum general tasks")

        general_weight = self.options.general_task_weight.value if len(general_tasks) > 0 else 0

        tasks_per_task_type: typing.Dict[str, typing.List[LocationRow]] = {}
        weights_per_task_type: typing.Dict[str, int] = {}
        
        for task_type in task_types:
            max_amount_for_task_type = getattr(self.options, f"max_{task_type}_tasks")
            tasks_for_this_type = [task for task in self.locations_by_category[task_type]
                                   if self.task_within_skill_levels(task.skills)]
            max_amount_for_task_type = min(max_amount_for_task_type, len(tasks_for_this_type))
            if not self.options.progressive_tasks:
                rnd.shuffle(tasks_for_this_type)
            else:
                tasks_for_this_type.reverse()

            tasks_for_this_type = tasks_for_this_type[:max_amount_for_task_type]
            weight_for_this_type = getattr(self.options,
                                                       f"{task_type}_task_weight")
            if weight_for_this_type > 0 and tasks_for_this_type:
                tasks_per_task_type[task_type] = tasks_for_this_type
                weights_per_task_type[task_type] = weight_for_this_type

        # Build a list of collections and weights in a matching order for rnd.choices later
        all_tasks = []
        all_weights = []
        for task_type in task_types:
            if task_type in tasks_per_task_type:
                all_tasks.append(tasks_per_task_type[task_type])
                all_weights.append(weights_per_task_type[task_type])

        # Even after the initial forced generals, they can still be rolled randomly
        if general_weight > 0 and len(general_tasks)>0:
            all_tasks.append(general_tasks)
            all_weights.append(general_weight)

        if not generation_is_fake and locations_added > locations_required: #due to minimum general tasks we already have more than needed
            raise OptionError(f"Too many locations created for {self.player_name}, lower the minimum general tasks")

        while locations_added < locations_required or (generation_is_fake and len(all_tasks) > 0):
            if all_tasks:
                chosen_task = rnd.choices(all_tasks, all_weights)[0]
                if chosen_task:
                    task = chosen_task.pop()
                    self.add_location(task)
                    locations_added += 1

                # This isn't an else because chosen_task can become empty in the process of resolving the above block
                # We still want to clear this list out while we're doing that
                if not chosen_task:
                    index = all_tasks.index(chosen_task)
                    del all_tasks[index]
                    del all_weights[index]

            else: # We can ignore general tasks in UT because they will have been cleared already
                if len(general_tasks) == 0:
                    raise OptionError(f"There are not enough available tasks to fill the remaining pool for OSRS " +
                                    f"Please adjust {self.player_name}'s settings to be less restrictive of tasks.")
                task = general_tasks.pop()
                self.add_location(task)
                locations_added += 1


    def add_location(self, location):
        index = [i for i in range(len(location_rows)) if location_rows[i].name == location.name][0]
        self.create_and_add_location(index)

    def create_items(self) -> None:
        filler_items:list[ItemRow] = []
        for item_row in item_rows:
            if item_row.name != self.starting_area_item:
                # If it's a filler item, set it aside for later
                if item_row.progression == ItemClassification.filler:
                    filler_items.append(item_row)
                    continue

                # If it starts with "Care Pack", only add it if Care Packs are enabled
                if item_row.name.startswith("Care Pack"):
                    if not self.options.enable_carepacks:
                        continue

                for c in range(item_row.amount):
                    item = self.create_item(item_row.name)
                    self.multiworld.itempool.append(item)
        if self.options.enable_duds:
            self.random.shuffle(filler_items)
            filler_items = filler_items[0:self.options.dud_count]
            for item_row in filler_items:
                item = self.create_item(item_row.name)
                self.multiworld.itempool.append(item)

    def get_filler_item_name(self) -> str:
        if self.options.enable_duds:
            return self.random.choice([item.name for item in item_rows if item.progression == ItemClassification.filler])
        else:
            return self.random.choice([ItemNames.Progressive_Weapons, ItemNames.Progressive_Magic,
                                       ItemNames.Progressive_Range_Weapon, ItemNames.Progressive_Armor,
                                       ItemNames.Progressive_Range_Armor, ItemNames.Progressive_Tools])

    def create_and_add_location(self, row_index) -> None:
        location_row = location_rows[row_index]

        # Quest Points are handled differently now, but in case this gets fed an older version of the data sheet,
        # the points might still be listed in a different row
        if location_row.category == "points":
            return

        # Create Location
        location_id = self.base_id + row_index
        if location_row.category == "goal":
            location_id = None
        location = OSRSLocation(self.player, location_row.name, location_id)
        self.location_name_to_data[location_row.name] = location

        # Add the location to its first region, or if it doesn't belong to one, to Menu
        region = self.region_name_to_data["Menu"]
        if location_row.regions:
            region = self.region_name_to_data[location_row.regions[0]]
        location.parent_region = region
        region.locations.append(location)

        # If it's a quest, generate a "Points" location we'll add an event to
        if location_row.category == "quest":
            points_name = location_row.name.replace("Quest:", "Points:")
            points_location = OSRSLocation(self.player, points_name)
            self.location_name_to_data[points_name] = points_location
            points_location.parent_region = region
            region.locations.append(points_location)

    def set_rules(self) -> None:
        """
        called to set access and item rules on locations and entrances.
        """
        quest_attr_names = ["Cooks_Assistant", "Demon_Slayer", "Restless_Ghost", "Romeo_Juliet",
                            "Sheep_Shearer", "Shield_of_Arrav", "Ernest_the_Chicken", "Vampyre_Slayer",
                            "Imp_Catcher", "Prince_Ali_Rescue", "Dorics_Quest", "Black_Knights_Fortress",
                            "Witchs_Potion", "Knights_Sword", "Goblin_Diplomacy", "Pirates_Treasure",
                            "Rune_Mysteries", "Misthalin_Mystery", "Corsair_Curse", "X_Marks_the_Spot",
                            "Below_Ice_Mountain"]

        for quest_attr_name in quest_attr_names:
            qp_loc_name = getattr(LocationNames, f"QP_{quest_attr_name}")
            qp_loc = self.location_name_to_data.get(qp_loc_name)

            q_loc_name = getattr(LocationNames, f"Q_{quest_attr_name}")
            q_loc = self.location_name_to_data.get(q_loc_name)

            # Checks to make sure the task is actually in the list before trying to create its rules
            if qp_loc and q_loc:
                # Create the QP Event Item
                item_name = getattr(ItemNames, f"QP_{quest_attr_name}")
                qp_loc.place_locked_item(self.create_event(item_name))

                # If a quest is excluded, don't actually consider it for quest point progression
                if q_loc_name not in self.options.exclude_locations:
                    self.available_QP_locations.append(item_name)

                # Set the access rule for the QP Location
                add_rule(qp_loc, lambda state, loc=q_loc: (loc.can_reach(state)))

        qp = 0
        for qp_event in self.available_QP_locations:
            qp += int(qp_event[0])
        if qp < self.location_rows_by_name[LocationNames.Q_Dragon_Slayer].qp:
            raise OptionError(f"{self.player_name} doesn't have enough quests for reach goal, increase maximum skill levels")

        # place "Victory" at "Dragon Slayer" and set collection as win condition
        self.multiworld.get_location(LocationNames.Q_Dragon_Slayer, self.player) \
            .place_locked_item(self.create_event("Victory"))
        self.multiworld.completion_condition[self.player] = lambda state: (state.has("Victory", self.player))

        for location_name, location in self.location_name_to_data.items():
            location_row = self.location_rows_by_name[location_name]
            # Set up requirements for region
            for region_required_name in location_row.regions:
                region_required = self.region_name_to_data[region_required_name]
                add_rule(location,
                         lambda state, region_required=region_required: state.can_reach(region_required, "Region",
                                                                                        self.player))
            for skill_req in location_row.skills:
                add_rule(location, get_skill_rule(skill_req.skill, skill_req.level, self.player, self.options))
            for item_req in location_row.items:
                add_rule(location, lambda state, item_req=item_req: state.has(item_req, self.player))
            if location_row.qp:
                add_rule(location, lambda state, location_row=location_row: self.quest_points(state) > location_row.qp)

    def create_region(self, name: str) -> "Region":
        region = Region(name, self.player, self.multiworld)
        self.region_name_to_data[name] = region
        self.multiworld.regions.append(region)
        return region

    def create_item(self, item_name: str) -> "Item":
        items = [item for item in item_rows if item.name == item_name]
        assert len(items) > 0, f"No matching item found for name {item_name} for player {self.player_name}"
        item = items[0]
        index = item_rows.index(item)
        return OSRSItem(item.name, item.progression, self.base_id + index, self.player)

    def create_event(self, event: str):
        # while we are at it, we can also add a helper to create events
        return OSRSItem(event, ItemClassification.progression, None, self.player)

    def quest_points(self, state):
        qp = 0
        for qp_event in self.available_QP_locations:
            if state.has(qp_event, self.player):
                qp += int(qp_event[0])
        return qp

