import json

from ttfrog.db import schema
from ttfrog.db.schema.constants import Conditions, DamageType, Defenses


def test_manage_character(db, bootstrap):
    with db.transaction():
        darkvision = db.AncestryTrait.filter_by(name="Darkvision").one()
        human = db.Ancestry.filter_by(name="human").one()

        # create a human character (the default)
        char = schema.Character(name="Test Character", ancestry=human)
        db.add_or_update(char)
        assert char.id
        assert char.name == "Test Character"
        assert char.ancestry.name == "human"
        assert char.armor_class == 10
        assert char.hit_points == 10
        assert char.strength == 10
        assert char.dexterity == 10
        assert char.constitution == 10
        assert char.intelligence == 10
        assert char.wisdom == 10
        assert char.charisma == 10

        assert darkvision not in char.traits
        assert char.vision_in_darkness == 0

        # verify basic skills were added at creation time
        for skill in db.Skill.filter(
            schema.Skill.name.in_(("strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"))
        ):
            assert char.check_modifier(skill) == 0

        # switch ancestry to tiefling
        tiefling = db.Ancestry.filter_by(name="tiefling").one()
        char.ancestry = tiefling
        db.add_or_update(char)
        char = db.session.get(schema.Character, char.id)
        assert char.ancestry_id == tiefling.id
        assert char.ancestry.name == "tiefling"
        assert darkvision in char.traits
        assert char.vision_in_darkness == 120

        # tiefling ancestry adds INT and CHA modifiers
        assert char.intelligence == 11
        assert char.intelligence.base == 10
        assert char.charisma == 12
        assert char.charisma.base == 10

        # switch ancestry to dragonborn and assert darkvision persists
        char.ancestry = db.Ancestry.filter_by(name="dragonborn").one()
        db.add_or_update(char)
        assert darkvision in char.traits

        # verify tiefling modifiers were removed
        assert char.intelligence == 10
        assert char.charisma == 10

        # switch ancestry to human and assert darkvision is removed
        char.ancestry = human
        db.add_or_update(char)
        assert darkvision not in char.traits

        fighter = db.CharacterClass.filter_by(name="fighter").one()
        rogue = db.CharacterClass.filter_by(name="rogue").one()

        # assign a class and level
        assert char.add_class(fighter)
        db.add_or_update(char)
        assert char.levels == {"fighter": 1}
        assert char.level == 1
        assert char.class_features == {}

        # 'fighting style' is available, but not at this level
        fighting_style = fighter.feature("Fighting Style")
        assert char.has_class_feature(fighting_style) is False
        assert char.add_class_feature(fighter, fighting_style, fighting_style.options[0]) is False
        db.add_or_update(char)
        assert char.class_features == {}

        # level up
        char.level_up(fighter, num_levels=6)
        db.add_or_update(char)
        assert char.levels == {"fighter": 7}
        assert char.level == 7

        # Assert the fighting style is added automatically and idempotent...ly?
        assert char.has_class_feature(fighting_style)
        assert char.class_features[fighting_style.name] == fighting_style.options[0]
        assert char.add_class_feature(fighter, fighting_style, fighting_style.options[0]) is False
        assert char.has_class_feature(fighting_style)
        db.add_or_update(char)

        athletics = db.Skill.filter_by(name="athletics").one()
        acrobatics = db.Skill.filter_by(name="acrobatics").one()
        assert athletics in char.skills
        assert acrobatics in char.skills
        assert char.check_modifier(athletics) == char.proficiency_bonus + char.strength.bonus == 3
        assert char.check_modifier(acrobatics) == char.proficiency_bonus + char.dexterity.bonus == 3

        # assert dexterity bonus apply to initiative
        char._dexterity = 17
        assert char.dexterity.bonus == 3
        assert char.initiative == char.dexterity.bonus == 3
        char.add_modifier(schema.Modifier("+1 initiative", target="initiative", relative_value=1))
        assert char.initiative == 4
        char._dexterity = 10

        # multiclass
        char.add_class(rogue)
        db.add_or_update(char)
        assert char.level == 8
        assert char.levels == {"fighter": 7, "rogue": 1}
        assert list(char.classes.keys()) == ["fighter", "rogue"]
        assert sum([len(dice) for dice in char.hit_dice.values()]) == char.level == 8

        # use a hit die
        char.spend_hit_die(char.hit_dice["rogue"][0])
        assert len([die for die in char.hit_dice_available if die.character_class.name == "rogue"]) == 0

        # remove a class
        char.remove_class(rogue)
        db.add_or_update(char)
        assert char.levels == {"fighter": 7}
        assert char.level == 7
        assert sum([len(dice) for dice in char.hit_dice.values()]) == char.level == 7

        # verify hit dice are added and removed correctly
        assert len(char.hit_dice["fighter"]) == char.level_in_class(fighter).level == 7
        assert char.hit_dice["fighter"][0].name == fighter.hit_die_name == "1d10"
        assert char.hit_dice["fighter"][0].stat == fighter.hit_die_stat_name == "_constitution"

        char.remove_class(fighter)
        db.add_or_update(char)
        assert char.levels == {}
        assert char.class_features == {}

        # verify the proficiencies etc. added by the classes have been removed
        assert athletics not in char.skills
        assert acrobatics not in char.skills
        assert char.check_modifier(athletics) == 0
        assert char.check_modifier(acrobatics) == 0
        assert char.hit_dice == {}

        # ensure we're not persisting any orphan records in the map tables
        dump = json.loads(db.dump())
        assert not [m for m in dump["character_class_feature_map"] if m["character_id"] == char.id]
        assert not [m for m in dump["class_map"] if m["character_id"] == char.id]


def test_ancestries(db, bootstrap):
    with db.transaction():
        # create the Pygmy Orc ancestry
        porc = schema.Ancestry(
            name="Pygmy Orc",
            size="Small",
            speed=25,
        )
        assert porc.name == "Pygmy Orc"
        assert porc.creature_type == "humanoid"
        assert porc.size == "Small"
        assert porc.speed == 25

        # create the Relentless Endurance trait and add it to the Orc
        endurance = schema.AncestryTrait(name="Relentless Endurance")
        db.add_or_update(endurance)
        porc.add_trait(endurance, level=1)
        db.add_or_update(porc)
        assert endurance in porc.traits

        # add it again and assert nothing changes
        porc.add_trait(endurance, level=1)
        assert endurance in porc.traits

        # add a +3 STR modifier
        str_bonus = schema.Modifier(
            name="STR+3 (Pygmy Orc)",
            target="strength",
            stacks=True,
            relative_value=3,
            description="Your Strength score is increased by 3.",
        )
        assert porc.add_modifier(str_bonus) is True
        assert porc.add_modifier(str_bonus) is False  # test idempotency
        assert str_bonus in porc.modifiers["strength"]

        # now create an orc character and assert it gets traits and modifiers
        grognak = schema.Character(name="Grognak the Mighty", ancestry=porc)
        db.add_or_update(grognak)

        assert endurance in grognak.traits

        # verify the strength bonus is applied
        assert grognak.strength.base == 10
        assert grognak.strength == 13
        assert grognak.strength.bonus == 1
        assert str_bonus in grognak.modifiers["strength"]

        # make sure bonuses are applied to checks and saves
        strength = db.Skill.filter_by(name="strength").one()
        assert grognak.check_modifier(strength) == 1
        assert grognak.check_modifier(strength, save=True) == 1


def test_modifiers(db, bootstrap, carl, tiefling, human, wizard):
    with db.transaction():
        # no modifiers; speed is ancestry speed
        marx = schema.Character(name="Marx", ancestry=human)
        db.add_or_update([carl, marx])
        assert carl.speed == carl.ancestry.speed == 30

        cold = schema.Modifier(target="speed", stacks=True, relative_value=-10, name="Cold")
        hasted = schema.Modifier(target="speed", multiply_value=2.0, name="Hasted")
        slowed = schema.Modifier(target="speed", multiply_value=0.5, name="Slowed")
        restrained = schema.Modifier(target="speed", absolute_value=0, name="Restrained")
        reduced = schema.Modifier(target="size", new_value="Tiny", name="Reduced")
        fly = schema.Modifier(target="fly_speed", absolute_value=30, name="Fly Spell")

        # test sets of modifiers from conditions are applied when a condition is active
        incapacitated = schema.Condition(name="incapacitated")
        incapacitated.add_modifier(schema.Modifier(target="actions_per_turn", absolute_value=0, name="Incapacitated"))
        incapacitated.add_modifier(
            schema.Modifier(target="bonus_actions_per_turn", absolute_value=0, name="Incapacitated")
        )
        incapacitated.add_modifier(schema.Modifier(target="reactions_per_turn", absolute_value=0, name="Incapacitated"))
        db.add_or_update(incapacitated)
        assert carl.actions_per_turn == 1
        assert carl.bonus_actions_per_turn == 1
        assert carl.reactions_per_turn == 1
        assert carl.add_condition(incapacitated)
        db.add_or_update(carl)
        assert carl.has_condition(incapacitated)
        assert carl.actions_per_turn == 0
        assert carl.bonus_actions_per_turn == 0
        assert carl.reactions_per_turn == 0
        assert carl.remove_condition(incapacitated)
        db.add_or_update(carl)
        assert carl.actions_per_turn == 1
        assert carl.bonus_actions_per_turn == 1
        assert carl.reactions_per_turn == 1

        # reduce speed by 10
        assert carl.add_modifier(cold)
        assert carl.speed == 20
        assert carl.climb_speed == 10
        assert carl.swim_speed == 10
        assert carl.fly_speed is None

        # cast fly
        carl.add_modifier(fly)
        assert carl.fly_speed == 20

        # make sure modifiers only apply to carl. Carl is having a bad day.
        assert marx.speed == 30

        # speed is doubled
        assert carl.remove_modifier(cold)
        assert carl.speed == 30
        assert carl.fly_speed == 30
        assert carl.add_modifier(hasted)
        assert carl.speed == 60

        # speed is halved, overriding hasted because it was applied after
        assert carl.add_modifier(slowed)
        assert carl.speed == 15

        # speed is 0
        assert carl.add_modifier(restrained)
        assert carl.speed == 0

        # no longer restrained, but still slowed
        assert carl.remove_modifier(restrained)
        assert carl.speed == 15

        # back to normal
        assert carl.remove_modifier(slowed)
        assert carl.remove_modifier(hasted)
        assert carl.speed == carl.ancestry.speed

        # modifiers can modify string values too
        assert carl.add_modifier(reduced)
        assert carl.size == "Tiny"

        # cannot remove a modifier that isn't applied
        assert not carl.remove_modifier(cold)

        # modifiers can be applied to skills, even if the character doesn't have a skill associated.
        athletics = db.Skill.filter_by(name="athletics").one()
        assert athletics not in carl.skills
        assert carl.check_modifier(athletics) == 0
        temp_proficiency = schema.Modifier(
            "Expertise in Athletics",
            target="athletics_check",
            stacks=True,
            relative_attribute="expertise_bonus",
        )
        assert carl.add_modifier(temp_proficiency)
        assert carl.check_modifier(athletics) == carl.expertise_bonus + carl.strength.bonus == 2
        assert carl.remove_modifier(temp_proficiency)
        assert carl.check_modifier(athletics) == 0

        # fighters get proficiency in athletics by default
        fighter = db.CharacterClass.filter_by(name="fighter").one()
        carl.add_class(fighter)
        db.add_or_update(carl)
        assert carl.check_modifier(athletics) == carl.proficiency_bonus

        # add the skill directly, which will grant proficiency but will not stack with proficiency from the class
        carl.add_skill(athletics, proficient=True)
        db.add_or_update(carl)
        assert len([s for s in carl.skills if s == athletics]) == 2
        assert carl.check_modifier(athletics) == 2

        # manually override proficiency with expertise
        carl.add_skill(athletics, expert=True)
        assert carl.check_modifier(athletics) == 2 * carl.proficiency_bonus
        assert len([s for s in carl.skills if s == athletics]) == 2

        # remove expertise
        carl.add_skill(athletics, proficient=True, expert=False)
        assert carl.check_modifier(athletics) == carl.proficiency_bonus

        # remove the extra skill entirely, but the fighter proficiency remains
        carl.remove_skill(athletics, proficient=True, expert=False, character_class=None)
        assert len([s for s in carl.skills if s == athletics]) == 1
        assert carl.check_modifier(athletics) == carl.proficiency_bonus

        # ensure you can't remove a skill already removed
        assert not carl.remove_skill(athletics, proficient=True, expert=False, character_class=None)


def test_defenses(db, bootstrap, tiefling, carl):
    with db.transaction():
        db.add_or_update(carl)
        assert carl.resistant(DamageType.fire)
        carl.apply_damage(5, DamageType.fire)
        assert carl.hit_points == 8  # half damage

        # add temp HP
        carl.temp_hit_points = 3
        assert carl.hit_points == 8
        carl.apply_damage(1, DamageType.bludgeoning)
        assert carl.hit_points == 8
        assert carl.temp_hit_points == 2
        carl.apply_damage(3, DamageType.bludgeoning)
        assert carl.temp_hit_points == 0
        assert carl.hit_points == 7

        immunity = [
            schema.Modifier("Fire Immunity", target=DamageType.fire, new_value=Defenses.immune),
        ]
        for i in immunity:
            carl.add_modifier(i)
        assert carl.immune(DamageType.fire)
        carl.apply_damage(5, DamageType.fire)
        assert carl.hit_points == 7  # no damage

        vulnerability = [
            schema.Modifier("Fire Vulnerability", target=DamageType.fire, new_value=Defenses.vulnerable),
        ]
        for i in vulnerability:
            carl.add_modifier(i)
        assert carl.vulnerable(DamageType.fire)
        assert not carl.immune(DamageType.fire)
        carl.apply_damage(2, DamageType.fire)
        assert carl.hit_points == 3  # double damage

        absorbs = [schema.Modifier("Absorbs Non-Magical Fire", target=DamageType.fire, new_value=Defenses.absorbs)]
        carl.add_modifier(absorbs[0])
        carl.apply_damage(20, DamageType.fire)
        assert carl.hit_points == carl._max_hit_points == 10

        for i in immunity + vulnerability + absorbs:
            carl.remove_modifier(i)
        carl.apply_damage(5, DamageType.fire)
        assert carl.resistant(DamageType.fire)
        assert not carl.immune(DamageType.fire)
        assert not carl.vulnerable(DamageType.fire)
        assert not carl.absorbs(DamageType.fire)
        assert carl.hit_points == 8  # half damage


def test_condition_immunity(db, bootstrap, carl, tiefling):
    """
    Test immunities prevent conditions from being applied
    """
    with db.transaction():
        db.add_or_update(carl)
        poisoned = schema.Condition(name=DamageType.poison)
        poison_immunity = schema.Modifier("Poison Immunity", target=DamageType.poison, new_value=Defenses.immune)
        db.add_or_update([carl, poisoned, poison_immunity])

        # poison carl
        assert carl.add_condition(poisoned)
        db.add_or_update(carl)
        assert carl.has_condition(poisoned)

        # grant carl immunity, which must remove the poisoned condition
        assert carl.add_modifier(poison_immunity)
        db.add_or_update(carl)
        assert not carl.has_condition(poisoned)

        # ensure that carl cannot be poisoned while immune to poison
        assert not carl.add_condition(poisoned)

        # remove the immunity and ensure the previous poison doesn't come back
        assert carl.remove_modifier(poison_immunity)
        db.add_or_update(carl)
        assert not carl.immune(str(poisoned))
        assert not carl.has_condition(poisoned)

        # carl can be poisoned again
        assert carl.add_condition(poisoned)
        db.add_or_update(carl)
        assert carl.has_condition(poisoned)


def test_partial_immunities(db, bootstrap, carl, tiefling):
    """
    Test that individual modifiers applied by a condition can be negated even if not immune to the condition.
    """
    with db.transaction():
        db.add_or_update(carl)

        # Create some modifiers and conditions for this test
        fly = schema.Modifier(target="fly_speed", absolute_value=30, name="Fly Spell")
        cannot_move = schema.Modifier(name="Cannot Move (Petrified", target="speed", absolute_value=0)
        poisoned = schema.Condition(name=DamageType.poison)
        poison_immunity = schema.Modifier("Poison Immunity", target=DamageType.poison, new_value=Defenses.immune)

        # incapacitated applies several modifiers
        incapacitated = schema.Condition(name=Conditions.incapacitated)
        incapacitated.add_modifier(schema.Modifier(target="actions_per_turn", absolute_value=0, name="Incapacitated"))
        incapacitated.add_modifier(
            schema.Modifier(target="bonus_actions_per_turn", absolute_value=0, name="Incapacitated")
        )
        incapacitated.add_modifier(schema.Modifier(target="reactions_per_turn", absolute_value=0, name="Incapacitated"))

        # petrified applies several modifiers but also incapacitates you!
        petrified = schema.Condition(name=Conditions.petrified)
        assert petrified.add_modifier(poison_immunity)
        assert petrified.add_modifier(cannot_move)
        assert petrified.add_condition(incapacitated)
        db.add_or_update([fly, cannot_move, poisoned, poison_immunity, incapacitated, petrified])

        # carl casts fly!
        assert carl.fly_speed is None
        assert carl.add_modifier(fly)
        assert carl.fly_speed == carl.speed == carl.ancestry.speed
        db.add_or_update(carl)

        # poison carl
        assert not carl.immune(DamageType.poison)
        assert carl.add_condition(poisoned)
        db.add_or_update(carl)

        # petrify carl
        assert not carl.immune(Conditions.petrified)
        assert carl.add_condition(petrified)
        db.add_or_update(carl)

        # while petrified, carl is immune to poison
        assert carl.immune(DamageType.poison)

        # but carl still can't move
        assert carl.actions_per_turn == 0
        assert carl.speed == 0
        assert carl.fly_speed == 0

        # greater restoration ftw!
        assert carl.remove_condition(petrified)
        db.add_or_update(carl)

        # ...but he's still poisoned. Sorry carl :(
        assert carl.has_condition(poisoned)
        assert not carl.immune(DamageType.poison)
