import typing
from dataclasses import fields

from typing import List, Set, Iterable, Sequence, Dict, Callable, Union
from math import floor, ceil
from BaseClasses import Item, MultiWorld, Location, Tutorial, ItemClassification
from worlds.AutoWorld import WebWorld, World
from . import ItemNames
from .Items import StarcraftItem, filler_items, get_item_table, get_full_item_list, \
    get_basic_units, ItemData, upgrade_included_names, progressive_if_nco, kerrigan_actives, kerrigan_passives, \
    kerrigan_only_passives, progressive_if_ext, not_balanced_starting_units, spear_of_adun_calldowns, \
    spear_of_adun_castable_passives, nova_equipment
from .ItemGroups import item_name_groups
from .Locations import get_locations, LocationType, get_location_types, get_plando_locations
from .Regions import create_regions
from .Options import get_option_value, LocationInclusion, KerriganLevelItemDistribution, \
    KerriganPresence, KerriganPrimalStatus, RequiredTactics, kerrigan_unit_available, StarterUnit, SpearOfAdunPresence, \
    get_enabled_campaigns, SpearOfAdunAutonomouslyCastAbilityPresence, Starcraft2Options
from .PoolFilter import filter_items, get_item_upgrades, UPGRADABLE_ITEMS, missions_in_mission_table, get_used_races
from .MissionTables import MissionInfo, SC2Campaign, lookup_name_to_mission, SC2Mission, \
    SC2Race


class Starcraft2WebWorld(WebWorld):
    setup_en = Tutorial(
        "Multiworld Setup Guide",
        "A guide to setting up the Starcraft 2 randomizer connected to an Archipelago Multiworld",
        "English",
        "setup_en.md",
        "setup/en",
        ["TheCondor", "Phaneros"]
    )

    setup_fr = Tutorial(
        setup_en.tutorial_name,
        setup_en.description,
        "Français",
        "setup_fr.md",
        "setup/fr",
        ["Neocerber"]
    )

    tutorials = [setup_en, setup_fr]


class SC2World(World):
    """
    StarCraft II is a science fiction real-time strategy video game developed and published by Blizzard Entertainment.
    Play as one of three factions across four campaigns in a battle for supremacy of the Koprulu Sector.
    """

    game = "Starcraft 2"
    web = Starcraft2WebWorld()

    item_name_to_id = {name: data.code for name, data in get_full_item_list().items()}
    location_name_to_id = {location.name: location.code for location in get_locations(None)}
    options_dataclass = Starcraft2Options
    options: Starcraft2Options

    item_name_groups = item_name_groups
    locked_locations: typing.List[str]
    location_cache: typing.List[Location]
    mission_req_table: Dict[SC2Campaign, Dict[str, MissionInfo]] = {}
    final_mission_id: int
    victory_item: str
    required_client_version = 0, 4, 5

    def __init__(self, multiworld: MultiWorld, player: int):
        super(SC2World, self).__init__(multiworld, player)
        self.location_cache = []
        self.locked_locations = []

    def create_item(self, name: str) -> Item:
        data = get_full_item_list()[name]
        return StarcraftItem(name, data.classification, data.code, self.player)

    def create_regions(self):
        self.mission_req_table, self.final_mission_id, self.victory_item = create_regions(
            self, get_locations(self), self.location_cache
        )

    def create_items(self):
        setup_events(self.player, self.locked_locations, self.location_cache)

        excluded_items = get_excluded_items(self)

        starter_items = assign_starter_items(self, excluded_items, self.locked_locations, self.location_cache)

        fill_resource_locations(self, self.locked_locations, self.location_cache)

        pool = get_item_pool(self, self.mission_req_table, starter_items, excluded_items, self.location_cache)

        fill_item_pool_with_dummy_items(self, self.locked_locations, self.location_cache, pool)

        self.multiworld.itempool += pool

    def set_rules(self):
        self.multiworld.completion_condition[self.player] = lambda state: state.has(self.victory_item, self.player)

    def get_filler_item_name(self) -> str:
        return self.random.choice(filler_items)

    def fill_slot_data(self):
        slot_data = {}
        for option_name in [field.name for field in fields(Starcraft2Options)]:
            option = get_option_value(self, option_name)
            if type(option) in {str, int}:
                slot_data[option_name] = int(option)
        slot_req_table = {}

        # Serialize data
        for campaign in self.mission_req_table:
            slot_req_table[campaign.id] = {}
            for mission in self.mission_req_table[campaign]:
                slot_req_table[campaign.id][mission] = self.mission_req_table[campaign][mission]._asdict()
                # Replace mission objects with mission IDs
                slot_req_table[campaign.id][mission]["mission"] = slot_req_table[campaign.id][mission]["mission"].id

                for index in range(len(slot_req_table[campaign.id][mission]["required_world"])):
                    # TODO this is a band-aid, sometimes the mission_req_table already contains dicts
                    # as far as I can tell it's related to having multiple vanilla mission orders
                    if not isinstance(slot_req_table[campaign.id][mission]["required_world"][index], dict):
                        slot_req_table[campaign.id][mission]["required_world"][index] = slot_req_table[campaign.id][mission]["required_world"][index]._asdict()

        enabled_campaigns = get_enabled_campaigns(self)
        slot_data["plando_locations"] = get_plando_locations(self)
        slot_data["nova_covert_ops_only"] = (enabled_campaigns == {SC2Campaign.NCO})
        slot_data["mission_req"] = slot_req_table
        slot_data["final_mission"] = self.final_mission_id
        slot_data["version"] = 3

        if SC2Campaign.HOTS not in enabled_campaigns:
            slot_data["kerrigan_presence"] = KerriganPresence.option_not_present
        return slot_data


def setup_events(player: int, locked_locations: typing.List[str], location_cache: typing.List[Location]):
    for location in location_cache:
        if location.address is None:
            item = Item(location.name, ItemClassification.progression, None, player)

            locked_locations.append(location.name)

            location.place_locked_item(item)


def get_excluded_items(world: World) -> Set[str]:
    excluded_items: Set[str] = set(get_option_value(world, 'excluded_items'))
    for item in world.multiworld.precollected_items[world.player]:
        excluded_items.add(item.name)
    locked_items: Set[str] = set(get_option_value(world, 'locked_items'))
    # Starter items are also excluded items
    starter_items: Set[str] = set(get_option_value(world, 'start_inventory'))
    item_table = get_full_item_list()
    soa_presence = get_option_value(world, "spear_of_adun_presence")
    soa_autocast_presence = get_option_value(world, "spear_of_adun_autonomously_cast_ability_presence")
    enabled_campaigns = get_enabled_campaigns(world)

    # Ensure no item is both guaranteed and excluded
    invalid_items = excluded_items.intersection(locked_items)
    invalid_count = len(invalid_items)
    # Don't count starter items that can appear multiple times
    invalid_count -= len([item for item in starter_items.intersection(locked_items) if item_table[item].quantity != 1])
    if invalid_count > 0:
        raise Exception(f"{invalid_count} item{'s are' if invalid_count > 1 else ' is'} both locked and excluded from generation.  Please adjust your excluded items and locked items.")

    def smart_exclude(item_choices: Set[str], choices_to_keep: int):
        expected_choices = len(item_choices)
        if expected_choices == 0:
            return
        item_choices = set(item_choices)
        starter_choices = item_choices.intersection(starter_items)
        excluded_choices = item_choices.intersection(excluded_items)
        item_choices.difference_update(excluded_choices)
        item_choices.difference_update(locked_items)
        candidates = sorted(item_choices)
        exclude_amount = min(expected_choices - choices_to_keep - len(excluded_choices) + len(starter_choices), len(candidates))
        if exclude_amount > 0:
            excluded_items.update(world.random.sample(candidates, exclude_amount))

    # Nova gear exclusion if NCO not in campaigns
    if SC2Campaign.NCO not in enabled_campaigns:
        excluded_items = excluded_items.union(nova_equipment)

    kerrigan_presence = get_option_value(world, "kerrigan_presence")
    # Exclude Primal Form item if option is not set or Kerrigan is unavailable
    if get_option_value(world, "kerrigan_primal_status") != KerriganPrimalStatus.option_item or \
        (kerrigan_presence in {KerriganPresence.option_not_present, KerriganPresence.option_not_present_and_no_passives}):
        excluded_items.add(ItemNames.KERRIGAN_PRIMAL_FORM)

    # no Kerrigan & remove all passives => remove all abilities
    if kerrigan_presence == KerriganPresence.option_not_present_and_no_passives:
        for tier in range(7):
            smart_exclude(kerrigan_actives[tier].union(kerrigan_passives[tier]), 0)
    else:
        # no Kerrigan, but keep non-Kerrigan passives
        if kerrigan_presence == KerriganPresence.option_not_present:
            smart_exclude(kerrigan_only_passives, 0)
            for tier in range(7):
                smart_exclude(kerrigan_actives[tier], 0)

    # SOA exclusion, other cases are handled by generic race logic
    if (soa_presence == SpearOfAdunPresence.option_lotv_protoss and SC2Campaign.LOTV not in enabled_campaigns) \
            or soa_presence == SpearOfAdunPresence.option_not_present:
        excluded_items.update(spear_of_adun_calldowns)
    if (soa_autocast_presence == SpearOfAdunAutonomouslyCastAbilityPresence.option_lotv_protoss \
            and SC2Campaign.LOTV not in enabled_campaigns) \
            or soa_autocast_presence == SpearOfAdunAutonomouslyCastAbilityPresence.option_not_present:
        excluded_items.update(spear_of_adun_castable_passives)

    return excluded_items


def assign_starter_items(world: World, excluded_items: Set[str], locked_locations: List[str], location_cache: typing.List[Location]) -> List[Item]:
    starter_items: List[Item] = []
    non_local_items = get_option_value(world, "non_local_items")
    starter_unit = get_option_value(world, "starter_unit")
    enabled_campaigns = get_enabled_campaigns(world)
    first_mission = get_first_mission(world.mission_req_table)
    # Ensuring that first mission is completable
    if starter_unit == StarterUnit.option_off:
        starter_mission_locations = [location.name for location in location_cache
                                     if location.parent_region.name == first_mission
                                     and location.access_rule == Location.access_rule]
        if not starter_mission_locations:
            # Force early unit if first mission is impossible without one
            starter_unit = StarterUnit.option_any_starter_unit

    if starter_unit != StarterUnit.option_off:
        first_race = lookup_name_to_mission[first_mission].race

        if first_race == SC2Race.ANY:
            # If the first mission is a logic-less no-build
            mission_req_table: Dict[SC2Campaign, Dict[str, MissionInfo]] = world.mission_req_table
            races = get_used_races(mission_req_table, world)
            races.remove(SC2Race.ANY)
            if lookup_name_to_mission[first_mission].race in races:
                # The campaign's race is in (At least one mission that's not logic-less no-build exists)
                first_race = lookup_name_to_mission[first_mission].campaign.race
            elif len(races) > 0:
                # The campaign only has logic-less no-build missions. Find any other valid race
                first_race = world.random.choice(list(races))

        if first_race != SC2Race.ANY:
            # The race of the early unit has been chosen
            basic_units = get_basic_units(world, first_race)
            if starter_unit == StarterUnit.option_balanced:
                basic_units = basic_units.difference(not_balanced_starting_units)
            if first_mission == SC2Mission.DARK_WHISPERS.mission_name:
                # Special case - you don't have a logicless location but need an AA
                basic_units = basic_units.difference(
                    {ItemNames.ZEALOT, ItemNames.CENTURION, ItemNames.SENTINEL, ItemNames.BLOOD_HUNTER,
                     ItemNames.AVENGER, ItemNames.IMMORTAL, ItemNames.ANNIHILATOR, ItemNames.VANGUARD})
            if first_mission == SC2Mission.SUDDEN_STRIKE.mission_name:
                # Special case - cliffjumpers
                basic_units = {ItemNames.REAPER, ItemNames.GOLIATH, ItemNames.SIEGE_TANK, ItemNames.VIKING, ItemNames.BANSHEE}
            local_basic_unit = sorted(item for item in basic_units if item not in non_local_items and item not in excluded_items)
            if not local_basic_unit:
                # Drop non_local_items constraint
                local_basic_unit = sorted(item for item in basic_units if item not in excluded_items)
                if not local_basic_unit:
                    raise Exception("Early Unit: At least one basic unit must be included")

            unit: Item = add_starter_item(world, excluded_items, local_basic_unit)
            starter_items.append(unit)

            # NCO-only specific rules
            if first_mission == SC2Mission.SUDDEN_STRIKE.mission_name:
                support_item: Union[str, None] = None
                if unit.name == ItemNames.REAPER:
                    support_item = ItemNames.REAPER_SPIDER_MINES
                elif unit.name == ItemNames.GOLIATH:
                    support_item = ItemNames.GOLIATH_JUMP_JETS
                elif unit.name == ItemNames.SIEGE_TANK:
                    support_item = ItemNames.SIEGE_TANK_JUMP_JETS
                elif unit.name == ItemNames.VIKING:
                    support_item = ItemNames.VIKING_SMART_SERVOS
                if support_item is not None:
                    starter_items.append(add_starter_item(world, excluded_items, [support_item]))
                starter_items.append(add_starter_item(world, excluded_items, [ItemNames.NOVA_JUMP_SUIT_MODULE]))
                starter_items.append(
                    add_starter_item(world, excluded_items,
                                     [
                                         ItemNames.NOVA_HELLFIRE_SHOTGUN,
                                         ItemNames.NOVA_PLASMA_RIFLE,
                                         ItemNames.NOVA_PULSE_GRENADES
                                     ]))
            if enabled_campaigns == {SC2Campaign.NCO}:
                starter_items.append(add_starter_item(world, excluded_items, [ItemNames.LIBERATOR_RAID_ARTILLERY]))
    
    starter_abilities = get_option_value(world, 'start_primary_abilities')
    assert isinstance(starter_abilities, int)
    if starter_abilities:
        ability_count = starter_abilities
        ability_tiers = [0, 1, 3]
        world.random.shuffle(ability_tiers)
        if ability_count > 3:
            ability_tiers.append(6)
        for tier in ability_tiers:
            abilities = kerrigan_actives[tier].union(kerrigan_passives[tier]).difference(excluded_items, non_local_items)
            if not abilities:
                abilities = kerrigan_actives[tier].union(kerrigan_passives[tier]).difference(excluded_items)
            if abilities:
                ability_count -= 1
                starter_items.append(add_starter_item(world, excluded_items, list(abilities)))
                if ability_count == 0:
                    break

    return starter_items


def get_first_mission(mission_req_table: Dict[SC2Campaign, Dict[str, MissionInfo]]) -> str:
    # The first world should also be the starting world
    campaigns = mission_req_table.keys()
    lowest_id = min([campaign.id for campaign in campaigns])
    first_campaign = [campaign for campaign in campaigns if campaign.id == lowest_id][0]
    first_mission = list(mission_req_table[first_campaign])[0]
    return first_mission


def add_starter_item(world: World, excluded_items: Set[str], item_list: Sequence[str]) -> Item:

    item_name = world.random.choice(sorted(item_list))

    excluded_items.add(item_name)

    item = create_item_with_correct_settings(world.player, item_name)

    world.multiworld.push_precollected(item)

    return item


def get_item_pool(world: World, mission_req_table: Dict[SC2Campaign, Dict[str, MissionInfo]],
                  starter_items: List[Item], excluded_items: Set[str], location_cache: List[Location]) -> List[Item]:
    pool: List[Item] = []

    # For the future: goal items like Artifact Shards go here
    locked_items = []

    # YAML items
    yaml_locked_items = get_option_value(world, 'locked_items')
    assert not isinstance(yaml_locked_items, int)

    # Adjust generic upgrade availability based on options
    include_upgrades = get_option_value(world, 'generic_upgrade_missions') == 0
    upgrade_items = get_option_value(world, 'generic_upgrade_items')
    assert isinstance(upgrade_items, int)

    # Include items from outside main campaigns
    item_sets = {'wol', 'hots', 'lotv'}
    if get_option_value(world, 'nco_items') \
            or SC2Campaign.NCO in get_enabled_campaigns(world):
        item_sets.add('nco')
    if get_option_value(world, 'bw_items'):
        item_sets.add('bw')
    if get_option_value(world, 'ext_items'):
        item_sets.add('ext')

    def allowed_quantity(name: str, data: ItemData) -> int:
        if name in excluded_items \
                or data.type == "Upgrade" and (not include_upgrades or name not in upgrade_included_names[upgrade_items]) \
                or not data.origin.intersection(item_sets):
            return 0
        elif name in progressive_if_nco and 'nco' not in item_sets:
            return 1
        elif name in progressive_if_ext and 'ext' not in item_sets:
            return 1
        else:
            return data.quantity

    for name, data in get_item_table().items():
        for _ in range(allowed_quantity(name, data)):
            item = create_item_with_correct_settings(world.player, name)
            if name in yaml_locked_items:
                locked_items.append(item)
            else:
                pool.append(item)

    existing_items = starter_items + [item for item in world.multiworld.precollected_items[world.player] if item not in starter_items]
    existing_names = [item.name for item in existing_items]

    # Check the parent item integrity, exclude items
    pool[:] = [item for item in pool if pool_contains_parent(item, pool + locked_items + existing_items)]

    # Removing upgrades for excluded items
    for item_name in excluded_items:
        if item_name in existing_names:
            continue
        invalid_upgrades = get_item_upgrades(pool, item_name)
        for invalid_upgrade in invalid_upgrades:
            pool.remove(invalid_upgrade)

    fill_pool_with_kerrigan_levels(world, pool)
    filtered_pool = filter_items(world, mission_req_table, location_cache, pool, existing_items, locked_items)
    return filtered_pool


def fill_item_pool_with_dummy_items(self: SC2World, locked_locations: List[str],
                                    location_cache: List[Location], pool: List[Item]):
    for _ in range(len(location_cache) - len(locked_locations) - len(pool)):
        item = create_item_with_correct_settings(self.player, self.get_filler_item_name())
        pool.append(item)


def create_item_with_correct_settings(player: int, name: str) -> Item:
    data = get_full_item_list()[name]

    item = Item(name, data.classification, data.code, player)

    return item


def pool_contains_parent(item: Item, pool: Iterable[Item]):
    item_data = get_full_item_list().get(item.name)
    if item_data.parent_item is None:
        # The item has not associated parent, the item is valid
        return True
    parent_item = item_data.parent_item
    # Check if the pool contains the parent item
    return parent_item in [pool_item.name for pool_item in pool]


def fill_resource_locations(world: World, locked_locations: List[str], location_cache: List[Location]):
    """
    Filters the locations in the world using a trash or Nothing item
    :param multiworld:
    :param player:
    :param locked_locations:
    :param location_cache:
    :return:
    """
    open_locations = [location for location in location_cache if location.item is None]
    plando_locations = get_plando_locations(world)
    resource_location_types = get_location_types(world, LocationInclusion.option_resources)
    location_data = {sc2_location.name: sc2_location for sc2_location in get_locations(world)}
    for location in open_locations:
        # Go through the locations that aren't locked yet (early unit, etc)
        if location.name not in plando_locations:
            # The location is not plando'd
            sc2_location = location_data[location.name]
            if sc2_location.type in resource_location_types:
                item_name = world.random.choice(filler_items)
                item = create_item_with_correct_settings(world.player, item_name)
                location.place_locked_item(item)
                locked_locations.append(location.name)


def place_exclusion_item(item_name, location, locked_locations, player):
    item = create_item_with_correct_settings(player, item_name)
    location.place_locked_item(item)
    locked_locations.append(location.name)


def fill_pool_with_kerrigan_levels(world: World, item_pool: List[Item]):
    total_levels = get_option_value(world, "kerrigan_level_item_sum")
    if get_option_value(world, "kerrigan_presence") not in kerrigan_unit_available \
            or total_levels == 0 \
            or SC2Campaign.HOTS not in get_enabled_campaigns(world):
        return
    
    def add_kerrigan_level_items(level_amount: int, item_amount: int):
        name = f"{level_amount} Kerrigan Level"
        if level_amount > 1:
            name += "s"
        for _ in range(item_amount):
            item_pool.append(create_item_with_correct_settings(world.player, name))

    sizes = [70, 35, 14, 10, 7, 5, 2, 1]
    option = get_option_value(world, "kerrigan_level_item_distribution")

    assert isinstance(option, int)
    assert isinstance(total_levels, int)

    if option in (KerriganLevelItemDistribution.option_vanilla, KerriganLevelItemDistribution.option_smooth):
        distribution = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
        if option == KerriganLevelItemDistribution.option_vanilla:
            distribution = [32, 0, 0, 1, 3, 0, 0, 0, 1, 1]
        else: # Smooth
            distribution = [0, 0, 0, 1, 1, 2, 2, 2, 1, 1]
        for tier in range(len(distribution)):
            add_kerrigan_level_items(tier + 1, distribution[tier])
    else:
        size = sizes[option - 2]
        round_func: Callable[[float], int] = round
        if total_levels > 70:
            round_func = floor
        else:
            round_func = ceil
        add_kerrigan_level_items(size, round_func(float(total_levels) / size))