from enum import IntEnum
from typing import Any, List, Dict, Set, Callable, Optional, TextIO

from BaseClasses import ItemClassification, CollectionState, Region, Entrance, Location, Tutorial, LocationProgressType
from worlds.AutoWorld import World, WebWorld

from .Overcooked2Levels import Overcooked2Dlc, Overcooked2Level, Overcooked2GenericLevel
from .Locations import Overcooked2Location, oc2_location_name_to_id, oc2_location_id_to_name
from .Options import OC2Options, OC2OnToggle, LocationBalancingMode, DeathLinkMode
from .Items import item_table, Overcooked2Item, item_name_to_id, item_id_to_name, item_to_unlock_event, item_frequencies, dlc_exclusives
from .Logic import has_requirements_for_level_star, has_requirements_for_level_access, level_shuffle_factory, is_item_progression, is_useful


class Overcooked2Web(WebWorld):
    theme = "partyTime"

    bug_report_page = "https://github.com/toasterparty/oc2-modding/issues"
    setup_en = Tutorial(
        "Multiworld Setup Guide",
        "A guide to setting up the Overcooked! 2 randomizer on your computer.",
        "English",
        "setup_en.md",
        "setup/en",
        ["toasterparty"]
    )

    tutorials = [setup_en]


class PrepLevelMode(IntEnum):
    original = 0
    excluded = 1
    ayce = 2


class Overcooked2World(World):
    """
    Overcooked! 2 is a frantically paced arcade cooking game where
    players race against the clock to complete orders for points. Bring
    peace to the Onion Kingdom once again by recovering lost items and abilities,
    earning stars to unlock levels, and defeating the unbread horde. Levels are
    randomized to increase gameplay variety. Play with up to 4 friends.
    """

    # Autoworld API

    game = "Overcooked! 2"
    web = Overcooked2Web()
    required_client_version = (0, 3, 8)
    topology_present: bool = False

    item_name_to_id = item_name_to_id
    item_id_to_name = item_id_to_name

    location_id_to_name = oc2_location_id_to_name
    location_name_to_id = oc2_location_name_to_id

    options_dataclass = OC2Options
    options: OC2Options
    itempool: List[Overcooked2Item]

    # Helper Functions

    def is_level_horde(self, level_id: int) -> bool:
        return self.options.include_horde_levels and \
            (self.level_mapping is not None) and \
            level_id in self.level_mapping.keys() and \
            self.level_mapping[level_id].is_horde

    def create_item(self, item: str, classification: ItemClassification = ItemClassification.progression) -> Overcooked2Item:
        return Overcooked2Item(item, classification, self.item_name_to_id[item], self.player)

    def create_event(self, event: str, classification: ItemClassification) -> Overcooked2Item:
        return Overcooked2Item(event, classification, None, self.player)

    def place_event(self, location_name: str, item_name: str,
                    classification: ItemClassification = ItemClassification.progression_skip_balancing):
        location: Location = self.multiworld.get_location(location_name, self.player)
        location.place_locked_item(self.create_event(item_name, classification))

    def add_region(self, region_name: str):
        region = Region(
            region_name,
            self.player,
            self.multiworld,
        )
        self.multiworld.regions.append(region)

    def connect_regions(self, source: str, target: str, rule: Optional[Callable[[CollectionState], bool]] = None):
        sourceRegion = self.multiworld.get_region(source, self.player)
        targetRegion = self.multiworld.get_region(target, self.player)
        sourceRegion.connect(targetRegion, rule=rule)

    def add_level_location(
        self,
        region_name: str,
        location_name: str,
        level_id: int,
        stars: int,
        is_event: bool = False,
        priority=False,
    ) -> None:

        if is_event:
            location_id = None
        else:
            location_id = level_id

        region = self.multiworld.get_region(region_name, self.player)
        location = Overcooked2Location(
            self.player,
            location_name,
            location_id,
            region,
        )

        if priority:
            location.progress_type = LocationProgressType.PRIORITY
        else:
            location.progress_type = LocationProgressType.DEFAULT

        # if level_id is none, then it's the 6-6 edge case
        if level_id is None:
            level_id = 36
        if self.level_mapping is not None and level_id in self.level_mapping:
            level = self.level_mapping[level_id]
        else:
            level = Overcooked2GenericLevel(level_id)

        completion_condition: Callable[[CollectionState], bool] = \
            lambda state, level=level, stars=stars: \
            has_requirements_for_level_star(state, level, stars, self.player)
        location.access_rule = completion_condition

        region.locations.append(
            location
        )

    def get_n_random_locations(self, n: int) -> List[int]:
        """Return a list of n random non-repeating level locations"""
        levels = list()

        if n == 0:
            return levels

        for level in Overcooked2Level():
            if level.level_id == 36:
                continue
            elif not self.options.kevin_levels and level.level_id > 36:
                break

            levels.append(level.level_id)

        self.multiworld.random.shuffle(levels)
        return levels[:n]

    def get_priority_locations(self) -> List[int]:
        """Randomly generate list of priority locations, thus insulating this game
        from the negative effects of being shuffled with games that contain large
        ammounts of filler"""

        if self.multiworld.players == 1:
            # random priority locations have no desirable effect on solo seeds
            return list()

        balancing_mode = self.options.location_balancing

        if balancing_mode == LocationBalancingMode.disabled:
            # Location balancing is disabled, progression density is purely determined by filler
            return list()

        # Count how many progression items are required for this overcooked instance
        game_item_count = len(self.itempool)
        game_progression_count = 0
        for item in self.itempool:
            if item.advancement:
                game_progression_count += 1
        game_progression_density = game_progression_count/game_item_count

        if balancing_mode == LocationBalancingMode.full:
            # Location balancing will be employed in an attempt to keep the number of
            # progression locations and proression items as close to equal as possible
            return self.get_n_random_locations(game_progression_count)

        assert balancing_mode == LocationBalancingMode.compromise

        # Count how many progression items are shuffled between all games
        total_item_count = len(self.multiworld.itempool)
        total_progression_count = 0

        for item in self.multiworld.itempool:
            if item.advancement:
                total_progression_count += 1
        total_progression_density = total_progression_count/total_item_count

        if total_progression_density >= game_progression_density:
            # This game has more filler than the average of all other games.
            # It is not in need of location balancing
            return list()

        # Calculate the midpoint between the two ratios
        target_progression_ratio = (game_progression_density - total_progression_density) / 2.0 + total_progression_density
        target_progression_count = int((target_progression_ratio * game_item_count) + 0.5) # I'm sorry I round like an old person

        # Location balancing will be employed in an attempt to find a compromise at
        # the half-way point between natural probability and full artifical balancing
        return self.get_n_random_locations(target_progression_count)

    # Helper Data

    player_name: str
    level_unlock_counts: Dict[int, int]  # level_id, stars to purchase
    level_mapping: Dict[int, Overcooked2GenericLevel]  # level_id, level
    enabled_dlc: Set[Overcooked2Dlc]

    # Autoworld Hooks

    def generate_early(self):
        # 0.0 to 1.0 where 1.0 is World Record
        self.star_threshold_scale = self.options.star_threshold_scale / 100.0

        # Parse DLCOptionSet back into enums
        self.enabled_dlc = {Overcooked2Dlc(x) for x in self.options.include_dlcs.value}

        # Generate level unlock requirements such that the levels get harder to unlock
        # the further the game has progressed, and levels progress radially rather than linearly
        self.level_unlock_counts = level_unlock_requirement_factory(self.options.stars_to_win.value)

        # Assign new kitchens to each spot on the overworld using pure random chance and nothing else
        if self.options.shuffle_level_order:
            self.level_mapping = \
                level_shuffle_factory(
                    self.multiworld.random,
                    self.options.prep_levels != PrepLevelMode.excluded,
                    self.options.include_horde_levels.result,
                    self.options.kevin_levels.result,
                    self.enabled_dlc,
                    self.player_name,
                )
        else:
            self.level_mapping = None
            if Overcooked2Dlc.STORY not in self.enabled_dlc:
                raise Exception(f"Invalid OC2 settings({self.player_name}) Need either Level Shuffle disabled or 'Story' DLC enabled")

            self.enabled_dlc = {Overcooked2Dlc.STORY}

    def set_location_priority(self) -> None:
        priority_locations = self.get_priority_locations()
        for level in Overcooked2Level():
            if level.level_id in priority_locations:
                location: Location = self.multiworld.get_location(level.location_name_item, self.player)
                location.progress_type = LocationProgressType.PRIORITY


    def create_regions(self) -> None:
        # Menu -> Overworld
        self.add_region("Menu")
        self.add_region("Overworld")
        self.connect_regions("Menu", "Overworld")

        # Create and populate "regions" (a.k.a. levels)
        for level in Overcooked2Level():
            if not self.options.kevin_levels and level.level_id > 36:
                break

            # Create Region (e.g. "1-1")
            self.add_region(level.level_name)

            # Add Location to house progression item (1-star)
            if level.level_id == 36:
                # 6-6 doesn't have progression, but it does have victory condition which is placed later
                self.add_level_location(
                    level.level_name,
                    level.location_name_item,
                    None,
                    1,
                )
            else:
                # Location to house progression item
                self.add_level_location(
                    level.level_name,
                    level.location_name_item,
                    level.level_id,
                    1,
                )

                # Location to house level completed event
                self.add_level_location(
                    level.level_name,
                    level.location_name_level_complete,
                    level.level_id,
                    1,
                    is_event=True,
                )

            # Add Locations to house star aquisition events
            if self.is_level_horde(level.level_id):
                # in randomizer, horde levels grant a single star
                star_counts = [1]
            else:
                star_counts = [1, 2, 3]

            for n in star_counts:
                self.add_level_location(
                    level.level_name,
                    level.location_name_star_event(n),
                    level.level_id,
                    n,
                    is_event=True,
                )

            # Overworld -> Level
            required_star_count: int = self.level_unlock_counts[level.level_id]
            if level.level_id % 6 != 1 and level.level_id <= 36:
                previous_level_completed_event_name: str = Overcooked2GenericLevel(
                    level.level_id - 1).shortname.split(" ")[1] + " Level Complete"
            else:
                previous_level_completed_event_name = None

            level_access_rule: Callable[[CollectionState], bool] = \
                lambda state, level_name=level.level_name, previous_level_completed_event_name=previous_level_completed_event_name, required_star_count=required_star_count: \
                has_requirements_for_level_access(state, level_name, previous_level_completed_event_name, required_star_count, self.options.ramp_tricks.result, self.player)
            self.connect_regions("Overworld", level.level_name, level_access_rule)

            # Level --> Overworld
            self.connect_regions(level.level_name, "Overworld")

        completion_condition: Callable[[CollectionState], bool] = lambda state: \
            state.has("Victory", self.player)
        self.multiworld.completion_condition[self.player] = completion_condition


    def create_items(self):
        self.itempool = []

        # Make Items
        # useful = list()
        # filler = list()
        # progression = list()
        for item_name in item_table:            
            if item_name in item_frequencies:
                freq = item_frequencies[item_name]
            else:
                freq = 1
            
            if freq <= 0:
                # not used
                continue

            if item_name in dlc_exclusives:
                if not any(x in dlc_exclusives[item_name] for x in self.enabled_dlc):
                    # Item is always useless with these settings
                    continue

            if not self.options.include_horde_levels and item_name in ["Calmer Unbread", "Coin Purse"]:
                # skip horde-specific items if no horde levels
                continue

            if not self.options.kevin_levels:
                if item_name.startswith("Kevin"):
                    # skip kevin items if no kevin levels
                    continue

                if item_name == "Dark Green Ramp":
                    # skip dark green ramp if there's no Kevin-1 to reveal it
                    continue

            if is_item_progression(item_name, self.level_mapping, self.options.kevin_levels):
                # progression.append(item_name)
                classification = ItemClassification.progression
            else:
                if (is_useful(item_name)):
                    # useful.append(item_name)
                    classification = ItemClassification.useful
                else:
                    # filler.append(item_name)
                    classification = ItemClassification.filler

            while freq > 0:
                self.itempool.append(self.create_item(item_name, classification))
                classification = ItemClassification.useful  # only the first progressive item can be progression
                freq -= 1

        # print(f"progression: {progression}")
        # print(f"useful: {useful}")
        # print(f"filler: {filler}")

        # Fill any free space with filler
        pool_count = len(oc2_location_name_to_id)
        if not self.options.kevin_levels:
            pool_count -= 8

        while len(self.itempool) < pool_count:
            self.itempool.append(self.create_item("Bonus Star", ItemClassification.useful))

        self.multiworld.itempool += self.itempool


    def place_events(self):
        # Add Events (Star Acquisition)
        for level in Overcooked2Level():
            if not self.options.kevin_levels and level.level_id > 36:
                break

            if level.level_id != 36:
                self.place_event(level.location_name_level_complete, level.event_name_level_complete)

            if self.is_level_horde(level.level_id):
                # in randomizer, horde levels grant a single star
                star_counts = [1]
            else:
                star_counts = [1, 2, 3]

            for n in star_counts:
                self.place_event(level.location_name_star_event(n), "Star")

        # Add Victory Condition
        self.place_event("6-6 Completed", "Victory")

    def set_rules(self):
        pass

    def generate_basic(self) -> None:
        self.place_events()
        self.set_location_priority()

    # Items get distributed to locations

    def fill_json_data(self) -> Dict[str, Any]:
        mod_name = f"AP-{self.multiworld.seed_name}-P{self.player}-{self.player_name}"

        # Serialize Level Order
        story_level_order = dict()

        if self.options.shuffle_level_order:
            for level_id in self.level_mapping:
                level: Overcooked2GenericLevel = self.level_mapping[level_id]
                story_level_order[str(level_id)] = {
                    "DLC": level.dlc.value,
                    "LevelID": level.level_id,
                }

        custom_level_order = dict()
        custom_level_order["Story"] = story_level_order

        # Serialize Unlock Requirements
        level_purchase_requirements = dict()
        for level_id in self.level_unlock_counts:
            level_purchase_requirements[str(level_id)] = self.level_unlock_counts[level_id]

        # Override Vanilla Unlock Chain Behavior
        # (all worlds accessible from the start and progressible in any order)
        level_unlock_requirements = dict()
        level_force_reveal = [
            1,   # 1-1
            7,   # 2-1
            13,  # 3-1
            19,  # 4-1
            25,  # 5-1
            31,  # 6-1
        ]
        for level_id in range(1, 37):
            if (level_id not in level_force_reveal):
                level_unlock_requirements[str(level_id)] = level_id - 1

        # Set Kevin Unlock Requirements
        if self.options.kevin_levels:
            def kevin_level_to_keyholder_level_id(level_id: int) -> Optional[int]:
                location = self.multiworld.find_item(f"Kevin-{level_id-36}", self.player)
                if location.player != self.player:
                    return None  # This kevin level will be unlocked by the server at runtime
                level_id = oc2_location_name_to_id[location.name]
                return level_id

            for level_id in range(37, 45):
                keyholder_level_id = kevin_level_to_keyholder_level_id(level_id)
                if keyholder_level_id is not None:
                    level_unlock_requirements[str(level_id)] = keyholder_level_id

        # Place Items at Level Completion Screens (local only)
        on_level_completed: Dict[str, list[Dict[str, str]]] = dict()
        locations = self.multiworld.get_filled_locations(self.player)
        for location in locations:
            if location.item.code is None:
                continue  # it's an event
            if location.item.player != self.player:
                continue  # not for us
            level_id = str(oc2_location_name_to_id[location.name])
            on_level_completed[level_id] = [item_to_unlock_event(location.item.name)]

        # Put it all together
        star_threshold_scale = self.options.star_threshold_scale / 100

        base_data = {
            # Changes Inherent to rando
            "DisableAllMods": False,
            "UnlockAllChefs": True,
            "UnlockAllDLC": True,
            "DisplayFPS": True,
            "SkipTutorial": True,
            "SkipAllOnionKing": True,
            "SkipTutorialPopups": True,
            "RevealAllLevels": False,
            "PurchaseAllLevels": False,
            "CheatsEnabled": False,
            "ImpossibleTutorial": True,
            "ForbidDLC": True,
            "ForceSingleSaveSlot": True,
            "DisableNGP": True,
            "LevelForceReveal": level_force_reveal,
            "SaveFolderName": mod_name,
            "CustomOrderTimeoutPenalty": 10,
            "LevelForceHide": [37, 38, 39, 40, 41, 42, 43, 44],
            "LocalDeathLink": self.options.deathlink != DeathLinkMode.disabled,
            "BurnTriggersDeath": self.options.deathlink == DeathLinkMode.death_and_overcook,

            # Game Modifications
            "LevelPurchaseRequirements": level_purchase_requirements,
            "Custom66TimerScale": max(0.4, 0.25 + (1.0 - star_threshold_scale)*0.6),
            "ShortHordeLevels": self.options.short_horde_levels.result,
            "CustomLevelOrder": custom_level_order,

            # Items (Starting Inventory)
            "CustomOrderLifetime": 70.0,  # 100 is original
            "DisableWood": True,
            "DisableCoal": True,
            "DisableOnePlate": True,
            "DisableFireExtinguisher": True,
            "DisableBellows": True,
            "PlatesStartDirty": True,
            "MaxTipCombo": 2,
            "DisableDash": True,
            "WeakDash": True,
            "DisableThrow": True,
            "DisableCatch": True,
            "DisableControlStick": True,
            "DisableWokDrag": True,
            # "DisableRampButton": True, # Unused
            "DisableGreenRampButton" : True,
            "DisableYellowRampButton" : True,
            "DisableBlueRampButton" : True,
            "DisablePinkRampButton" : True,
            "DisableGreyRampButton" : True,
            "DisableRedRampButton" : True,
            "DisablePurpleRampButton" : True,
            "WashTimeMultiplier": 1.4,
            "BurnSpeedMultiplier": 1.43,
            "MaxOrdersOnScreenOffset": -2,
            "ChoppingTimeScale": 1.4,
            "BackpackMovementScale": 0.75,
            "RespawnTime": 10.0,
            "CarnivalDispenserRefactoryTime": 4.0,
            "LevelUnlockRequirements": level_unlock_requirements,
            "LockedEmotes": [0, 1, 2, 3, 4, 5],
            "StarOffset": 0,
            "AggressiveHorde": True,
            "DisableEarnHordeMoney": True,

            # Item Unlocking
            "OnLevelCompleted": on_level_completed,
        }

        # Set remaining data in the options dict
        bugs = ["FixDoubleServing", "FixSinkBug", "FixControlStickThrowBug", "FixEmptyBurnerThrow"]
        for bug in bugs:
            base_data[bug] = self.options.fix_bugs.result
        base_data["PreserveCookingProgress"] = self.options.always_preserve_cooking_progress.result
        base_data["TimerAlwaysStarts"] = self.options.prep_levels == PrepLevelMode.ayce
        base_data["LevelTimerScale"] = 0.666 if self.options.shorter_level_duration else 1.0
        base_data["LeaderboardScoreScale"] = {
            "FourStars": 1.0,
            "ThreeStars": star_threshold_scale,
            "TwoStars": star_threshold_scale * 0.75,
            "OneStar": star_threshold_scale * 0.35,
        }
        base_data["AlwaysServeOldestOrder"] = self.options.always_serve_oldest_order.result

        return base_data

    def fill_slot_data(self) -> Dict[str, Any]:
        return self.fill_json_data()

    def write_spoiler(self, spoiler_handle: TextIO) -> None:
        if not self.options.shuffle_level_order:
            return

        world: Overcooked2World = self
        spoiler_handle.write(f"\n\n{self.player_name}'s Level Order:\n\n")
        for overworld_id in world.level_mapping:
            overworld_name = Overcooked2GenericLevel(overworld_id).shortname.split("Story ")[1]
            kitchen_name = world.level_mapping[overworld_id].shortname
            spoiler_handle.write(f'{overworld_name} | {kitchen_name}\n')


def level_unlock_requirement_factory(stars_to_win: int) -> Dict[int, int]:
    level_unlock_counts = dict()
    level = 1
    sublevel = 1
    for n in range(1, 37):
        progress: float = float(n)/36.0
        progress *= progress  # x^2 curve

        star_count = int(progress*float(stars_to_win))
        min = (n-1)*3
        if (star_count > min):
            star_count = min

        level_id = (level-1)*6 + sublevel

        # print("%d-%d (%d) = %d" % (level, sublevel, level_id, star_count))

        level_unlock_counts[level_id] = star_count

        level += 1
        if level > 6:
            level = 1
            sublevel += 1

    # force sphere 1 to 0 stars to help keep our promises to the item fill algo
    level_unlock_counts[1] = 0  # 1-1
    level_unlock_counts[7] = 0  # 2-1
    level_unlock_counts[19] = 0  # 4-1

    # Force 5-1 into sphere 1 to help things out
    level_unlock_counts[25] = 1  # 5-1

    for n in range(37, 46):
        level_unlock_counts[n] = 0

    return level_unlock_counts
