"""Tools for describing a player character."""
import os
import re
import warnings
import math
from types import ModuleType
from typing import Sequence, Union, MutableMapping

import jinja2

from dungeonsheets import (
    armor,
    background,
    classes,
    exceptions,
    features,
    infusions,
    magic_items,
    monsters,
    race,
    spells,
    weapons,
)
from dungeonsheets.content_registry import find_content
from dungeonsheets.weapons import Weapon
from dungeonsheets.content import Creature
from dungeonsheets.dice import combine_dice


dice_re = re.compile(r"(\d+)d(\d+)")

__all__ = (
    "Artificer",
    "Barbarian",
    "Bard",
    "Cleric",
    "Character",
    "Druid",
    "Fighter",
    "Monk",
    "Paladin",
    "Ranger",
    "Rogue",
    "Sorceror",
    "Warlock",
    "Wizard",
)

multiclass_spellslots_by_level = {
    # char_lvl: (cantrips, 1st, 2nd, 3rd, ...)
    1: (0, 2, 0, 0, 0, 0, 0, 0, 0, 0),
    2: (0, 3, 0, 0, 0, 0, 0, 0, 0, 0),
    3: (0, 4, 2, 0, 0, 0, 0, 0, 0, 0),
    4: (0, 4, 3, 0, 0, 0, 0, 0, 0, 0),
    5: (0, 4, 3, 2, 0, 0, 0, 0, 0, 0),
    6: (0, 4, 3, 3, 0, 0, 0, 0, 0, 0),
    7: (0, 4, 3, 3, 1, 0, 0, 0, 0, 0),
    8: (0, 4, 3, 3, 2, 0, 0, 0, 0, 0),
    9: (0, 4, 3, 3, 3, 1, 0, 0, 0, 0),
    10: (0, 4, 3, 3, 3, 2, 0, 0, 0, 0),
    11: (0, 4, 3, 3, 3, 2, 1, 0, 0, 0),
    12: (0, 4, 3, 3, 3, 2, 1, 0, 0, 0),
    13: (0, 4, 3, 3, 3, 2, 1, 1, 0, 0),
    14: (0, 4, 3, 3, 3, 2, 1, 1, 0, 0),
    15: (0, 4, 3, 3, 3, 2, 1, 1, 1, 0),
    16: (0, 4, 3, 3, 3, 2, 1, 1, 1, 0),
    17: (0, 4, 3, 3, 3, 2, 1, 1, 1, 1),
    18: (0, 4, 3, 3, 3, 3, 1, 1, 1, 1),
    19: (0, 4, 3, 3, 3, 3, 2, 1, 1, 1),
    20: (0, 4, 3, 3, 3, 3, 2, 2, 1, 1),
}


class Character(Creature):
    """A generic player character."""
    # Character-specific
    name = "Unknown Hero"
    player_name = ""
    xp = 0
    # Extra hit points info, for characters only
    hp_current = None
    hp_temp = 0
    hit_dice_current = ""
    # Base stats (ability scores)
    inspiration = False
    attacks_and_spellcasting = ""
    class_list = list()
    _background = None

    # Characteristics
    personality_traits = (
        "TODO: Describe how your character behaves, interacts with others"
    )
    ideals = "TODO: Describe what values your character believes in."
    bonds = "TODO: Describe your character's commitments or ongoing quests."
    flaws = "TODO: Describe your character's interesting flaws."
    features_and_traits = "Describe any other features and abilities."
    chosen_tools = ""

    _proficiencies_text = list()

    # Appearance
    portrait = False
    age = 0
    height = ""
    weight = ""
    eyes = ""
    skin = ""
    hair = ""
    # Background
    allies = ""
    faction_name = ""
    # faction_symbol = placeholder not sure how to implement
    backstory = ""
    additional_description = ""
    other_feats_traits = ""
    treasure = ""
    org_name = ""

    def __init__(
        self,
        classes: Sequence = [],
        levels: Sequence[int] = [],
        subclasses: Sequence = [],
        **attrs,
    ):
        """Create a new character from attributes *attrs*.

        **Multiclassing** can be accomplished by a list of class names
        *classes*, and a list of class levels *levels*.

        Parameters
        ==========
        classes
          Strings with class names, or character class definitions
          representing the characters various D&D classes.
        levels
          The class levels for each corresponding class entry in
          *classes*.
        subclasses
          Subclasses that apply for this character.
        **attrs
          Additional keyword parameters to set as attributes for this
          character.

        """
        super(Character, self).__init__()
        self.clear()
        # make sure class, race, background are set first
        my_classes = classes
        my_levels = levels
        my_subclasses = subclasses
        # backwards compatability
        if len(my_classes) == 0:
            if "class" in attrs:
                my_classes = [attrs.pop("class")]
                my_levels = [attrs.pop("level", 1)]
                my_subclasses = [attrs.pop("subclass", None)]
            else:  # if no classes or levels given, default to Lvl 1 Fighter
                my_classes = ["Fighter"]
                my_levels = [1]
                my_subclasses = [None]
        # Generate the list of class objects
        self.add_classes(
            my_classes,
            my_levels,
            my_subclasses,
            feature_choices=attrs.get("feature_choices", []),
        )
        # parse race and background
        self.race = attrs.pop("race", None)
        self.background = attrs.pop("background", None)
        # parse all other attributes
        self.set_attrs(**attrs)
        self.__set_max_hp(attrs.get("hp_max", None))

    def clear(self):
        # reset class-defined items
        self.class_list = list()
        self._weapons = list()
        self.magic_items = list()
        self._saving_throw_proficiencies = tuple()
        self.other_weapon_proficiencies = tuple()
        self.skill_proficiencies = list()
        self.skill_expertise = list()
        self._proficiencies_text = list()
        self._spells = list()
        self._spells_prepared = list()
        self.infusions = list()
        self.custom_features = list()
        self.feature_choices = list()

    def __str__(self):
        return self.name

    def __repr__(self):
        return f"<{self.class_name}: {self.name}>"

    def add_class(
        self,
        cls: (classes.CharClass, type, str),
        level: (int, str),
        subclass=None,
        feature_choices: Sequence = [],
    ):
        """Add a class, level, and subclass the character has attained."""
        if isinstance(cls, str):
            cls = cls.strip().title().replace(" ", "")
            try:
                cls = getattr(classes, cls)
            except AttributeError:
                raise AttributeError(
                    "class was not recognized from classes.py: {:s}".format(cls)
                )
        if isinstance(level, str):
            level = int(level)
        self.class_list.append(
            cls(level, owner=self, subclass=subclass, feature_choices=feature_choices)
        )

    def add_classes(
        self,
        classes_list: Sequence[Union[str, classes.CharClass]] = [],
        levels: Sequence[Union[int, float, str]] = [],
        subclasses: Sequence = [],
        feature_choices: Sequence = [],
    ):
        """Add several classes, levels, etc.

        The lists can also be single values for a single class
        character.

        """
        if isinstance(classes_list, str):
            classes_list = [classes_list]
        if (
            isinstance(levels, int)
            or isinstance(levels, float)
            or isinstance(levels, str)
        ):
            levels = [levels]
        if len(levels) == 0:
            levels = [1] * len(classes_list)
        if isinstance(subclasses, str):
            subclasses = [subclasses]
        if len(subclasses) == 0:
            subclasses = [None] * len(classes_list)
        assert len(classes_list) == len(
            levels
        ), "the length of classes {:d} does not match length of levels {:d}".format(
            len(classes), len(levels)
        )
        assert len(classes_list) == len(
            subclasses
        ), "the length of classes {:d} does not match length of subclasses {:d}".format(
            len(classes_list), len(subclasses)
        )
        for cls, lvl, sub in zip(classes_list, levels, subclasses):
            params = {}
            params["feature_choices"] = feature_choices
            self.add_class(cls=cls, level=lvl, subclass=sub, **params)

    @property
    def race(self):
        return self._race

    @race.setter
    def race(self, newrace):
        if isinstance(newrace, race.Race):
            self._race = newrace
            self._race.owner = self
        elif isinstance(newrace, type) and issubclass(newrace, race.Race):
            self._race = newrace(owner=self)
        elif isinstance(newrace, str):
            try:
                self._race = find_content(newrace, valid_classes=[race.Race])(
                    owner=self
                )
            except exceptions.ContentNotFound:
                msg = f'Race "{newrace}" not defined. Please add it to ``race.py``'
                self._race = race.Race(owner=self)
                warnings.warn(msg)
        elif newrace is None:
            self._race = race.Race(owner=self)

    @property
    def background(self):
        return self._background

    @background.setter
    def background(self, bg):
        if isinstance(bg, background.Background):
            self._background = bg
            self._background.owner = self
        elif isinstance(bg, type) and issubclass(bg, background.Background):
            self._background = bg(owner=self)
        elif isinstance(bg, str):
            try:
                self._background = find_content(
                    bg, valid_classes=[background.Background]
                )(owner=self)
            except exceptions.ContentNotFound:
                msg = (
                    f'Background "{bg}" not defined. Please add it to ``background.py``'
                )
                self._background = background.Background(owner=self)
                warnings.warn(msg)

    @property
    def class_name(self):
        if self.num_classes >= 1:
            return self.primary_class.name
        else:
            return ""

    @property
    def classes_and_levels(self):
        return " / ".join([f"{c.name} {c.level}" for c in self.class_list])

    @property
    def class_names(self):
        return [c.name for c in self.class_list]

    @property
    def levels(self):
        return [c.level for c in self.class_list]

    @property
    def subclasses(self):
        return [c.subclass for c in self.class_list if c.subclass is not None]

    @property
    def level(self):
        return sum(c.level for c in self.class_list)

    @level.setter
    def level(self, new_level):
        self.primary_class.level = new_level
        if self.num_classes > 1:
            warnings.warn(
                "Unable to tell which level to set. Updating "
                "level of primary class {:s}".format(self.primary_class.name)
            )

    @property
    def num_classes(self):
        return len(self.class_list)

    @property
    def has_class(self):
        return self.num_classes > 0

    @property
    def primary_class(self):
        # for now, assume first class given must be primary class
        if self.has_class:
            return self.class_list[0]
        else:
            return None

    def __set_max_hp(self, hp_max):
        """
        Set maximum HP based on value in charlist py or calc from classes
        """
        if hp_max:
            assert isinstance(hp_max, int), hp_max.__class__
            self.hp_max = hp_max
        else:
            const_mod = self.constitution.modifier
            level_one_hp = self.primary_class.hit_dice_faces + const_mod
            self.hp_max = level_one_hp
            for char_cls in self.class_list:
                hp_per_lvl = char_cls.hit_dice_faces / 2 + 1 + const_mod
                levels = char_cls.level
                if char_cls == self.primary_class:
                    levels -= 1
                assert levels >= 0
                self.hp_max += int(hp_per_lvl * levels)

    @property
    def weapon_proficiencies(self):
        wp = set(self.other_weapon_proficiencies)
        if self.num_classes > 0:
            wp |= set(self.primary_class.weapon_proficiencies)
        if self.num_classes > 1:
            for c in self.class_list[1:]:
                wp |= set(c.multiclass_weapon_proficiencies)
        if self.race is not None:
            wp |= set(getattr(self.race, "weapon_proficiencies", ()))
        if self.background is not None:
            wp |= set(getattr(self.background, "weapon_proficiencies", ()))
        return tuple(wp)

    @weapon_proficiencies.setter
    def weapon_proficiencies(self, new_weapons):
        self.other_weapon_proficiencies = tuple(new_weapons)

    @property
    def other_weapon_proficiencies_text(self):
        return tuple(w.name for w in self.other_weapon_proficiencies)

    @property
    def features(self):
        fts = set(self.custom_features)
        fighting_style_defined = False
        set_of_fighting_styles = {
            "Fighting Style (Archery)",
            "Fighting Style (Defense)",
            "Fighting Style (Dueling)",
            "Fighting Style (Great Weapon Fighting)",
            "Fighting Style (Protection)",
            "Fighting Style (Two-Weapon Fighting)",
        }
        for temp_feature in fts:
            fighting_style_defined = temp_feature.name in set_of_fighting_styles
            if fighting_style_defined:
                break

        if not self.has_class:
            return fts
        for c in self.class_list:
            fts |= set(c.features)
            for feature in fts:
                if (
                    fighting_style_defined
                    and feature.name == "Fighting Style (Select One)"
                ):
                    temp_feature = feature
                    fts.remove(temp_feature)
                    break
        if self.race is not None:
            fts |= set(getattr(self.race, "features", ()))
            # some races have level-based features (Ex: Aasimar)
            if hasattr(self.race, "features_by_level"):
                for lvl in range(1, self.level + 1):
                    fts |= set(self.race.features_by_level[lvl])
        if self.background is not None:
            fts |= set(getattr(self.background, "features", ()))

        return sorted(tuple(fts), key=(lambda x: x.name))

    @property
    def custom_features_text(self):
        return tuple([f.name for f in self.custom_features])

    def has_feature(self, feat):
        return any([isinstance(f, feat) for f in self.features])

    @property
    def saving_throw_proficiencies(self):
        if self.primary_class is None:
            return self._saving_throw_proficiencies
        else:
            return (
                self._saving_throw_proficiencies
                or self.primary_class.saving_throw_proficiencies
            )

    @saving_throw_proficiencies.setter
    def saving_throw_proficiencies(self, vals):
        self._saving_throw_proficiencies = vals

    @property
    def spellcasting_classes(self):
        return [c for c in self.class_list if c.is_spellcaster]

    @property
    def spellcasting_classes_excluding_warlock(self):
        return [c for c in self.spellcasting_classes if not type(c) == classes.Warlock]

    @property
    def is_spellcaster(self):
        return len(self.spellcasting_classes) > 0

    def spell_slots(self, spell_level):
        warlock_slots = 0
        for c in self.spellcasting_classes:
            if type(c) is classes.Warlock:
                warlock_slots = c.spell_slots(spell_level)
        if len(self.spellcasting_classes_excluding_warlock) == 0:
            return warlock_slots
        if len(self.spellcasting_classes_excluding_warlock) == 1:
            return (
                self.spellcasting_classes_excluding_warlock[0].spell_slots(spell_level)
                + warlock_slots
            )
        else:
            if spell_level == 0:
                return sum([c.spell_slots(0) for c in self.spellcasting_classes])
            else:
                # compute effective level from PHB pg 164
                eff_level = 0
                for c in self.spellcasting_classes_excluding_warlock:
                    if type(c) in [
                        classes.Bard,
                        classes.Cleric,
                        classes.Druid,
                        classes.Sorceror,
                        classes.Wizard,
                    ]:
                        eff_level += c.level
                    elif type(c) in [classes.Paladin, classes.Ranger]:
                        eff_level += c.level // 2
                    elif type(c) in [classes.Fighter, classes.Rogue]:
                        eff_level += c.level // 3
                    elif type(c) is classes.Artificer:
                        eff_level += math.ceil(c.level / 2)
                if eff_level == 0:
                    return warlock_slots
                else:
                    return (
                        multiclass_spellslots_by_level[eff_level][spell_level]
                        + warlock_slots
                    )

    @property
    def spells(self):
        spells = set(self._spells) | set(self._spells_prepared)
        for f in self.features:
            spells |= set(f.spells_known) | set(f.spells_prepared)
        for c in self.spellcasting_classes:
            spells |= set(c.spells_known) | set(c.spells_prepared)
        if self.race is not None:
            spells |= set(self.race.spells_known) | set(self.race.spells_prepared)
        return sorted(tuple(spells), key=(lambda x: x.name))

    @property
    def spells_prepared(self):
        spells = set(self._spells_prepared)
        for f in self.features:
            spells |= set(f.spells_prepared)
        for c in self.spellcasting_classes:
            spells |= set(c.spells_prepared)
        if self.race is not None:
            spells |= set(self.race.spells_prepared)
        return sorted(tuple(spells), key=(lambda x: x.name))

    def set_attrs(self, **attrs):
        """
        Bulk setting of attributes
        Useful for loading a character from a dictionary
        """
        for attr, val in attrs.items():
            if attr == "dungeonsheets_version":
                pass  # Maybe we'll verify this later?
            elif attr == "weapons":
                if isinstance(val, str):
                    val = [val]
                # Treat weapons specially
                for weap in val:
                    self.wield_weapon(weap)
            elif attr == "magic_items":
                if isinstance(val, str):
                    val = [val]
                for mitem in val:
                    msg = (
                        f'Magic Item "{mitem}" not defined. '
                        "Please add it to ``magic_items.py``"
                    )
                    ThisMagicItem = self._resolve_mechanic(
                        mechanic=mitem,
                        SuperClass=magic_items.MagicItem,
                        warning_message=msg,
                    )
                    self.magic_items.append(ThisMagicItem(owner=self))
            elif attr == "weapon_proficiencies":
                self.other_weapon_proficiencies = ()
                msg = 'Magic Item "{}" not defined. Please add it to ``weapons.py``'
                wps = set(
                    [
                        self._resolve_mechanic(
                            w, SuperClass=weapons.Weapon, warning_message=msg
                        )
                        for w in val
                    ]
                )
                wps -= set(self.weapon_proficiencies)
                self.other_weapon_proficiencies = list(wps)
            elif attr == "armor":
                self.wear_armor(val)
            elif attr == "shield":
                self.wield_shield(val)
            elif attr == "circle":
                if hasattr(self, "Druid"):
                    self.Druid.circle = val
            elif attr == "features":
                if isinstance(val, str):
                    val = [val]
                _features = []
                for f in val:
                    msg = 'Feature "{}" not defined. Please add it to ``features.py``'
                    ThisFeature = self._resolve_mechanic(
                        mechanic=f,
                        SuperClass=features.Feature,
                        warning_message=msg,
                    )
                    _features.append(ThisFeature)
                self.custom_features += tuple(F(owner=self) for F in _features)
            elif (attr == "spells") or (attr == "spells_prepared"):
                # Create a list of actual spell objects
                _spells = []
                for spell_name in val:
                    msg = 'Spell "{}" not defined. Please add it to ``spells.py``'
                    ThisSpell = self._resolve_mechanic(
                        mechanic=spell_name,
                        SuperClass=spells.Spell,
                        warning_message=msg,
                    )
                    _spells.append(ThisSpell)
                # Sort by name
                _spells.sort(key=lambda spell: spell.name)
                # Save list of spells to character atribute
                if attr == "spells":
                    # Instantiate them all for the spells list
                    self._spells = tuple(S() for S in _spells)
                else:
                    # Instantiate them all for the spells list
                    self._spells_prepared = tuple(S() for S in _spells)
            elif attr == "infusions":
                if hasattr(self, "Artificer"):
                    _infusions = []
                    for infusion_name in val:
                        msg = (
                            "Infusion '{}' not defined. Please add it to"
                            " ``infusions.py``"
                        )
                        ThisInfusion = self._resolve_mechanic(
                            mechanic=infusion_name,
                            SuperClass=infusions.Infusion,
                            warning_message=msg,
                        )
                        _infusions.append(ThisInfusion)
                    _infusions.sort(key=lambda infusion: infusion.name)
                    self.infusions = tuple(i() for i in _infusions)
            elif type(val) not in (type, ModuleType):
                # Some other generic attribute
                is_unknown = not hasattr(self, attr) and not attr.startswith("_")
                if is_unknown:
                    warnings.warn(
                        f"Setting unknown character attribute {attr}", RuntimeWarning
                    )
                # Lookup general attributes
                setattr(self, attr, val)

    def spell_save_dc(self, class_type):
        ability_mod = getattr(self, class_type.spellcasting_ability).modifier
        return 8 + self.proficiency_bonus + ability_mod

    def spell_attack_bonus(self, class_type):
        ability_mod = getattr(self, class_type.spellcasting_ability).modifier
        return self.proficiency_bonus + ability_mod

    def is_proficient(self, weapon: Weapon):
        """Is the character proficient with this item?

        Considers class proficiencies and race proficiencies.

        Parameters
        ----------
        weapon
          The weapon to be tested for proficiency.

        Returns
        -------
        Boolean: is this character proficient with this weapon?

        """
        all_proficiencies = self.weapon_proficiencies
        is_proficient = any((isinstance(weapon, W) for W in all_proficiencies))
        return is_proficient

    @property
    def proficiencies_text(self):
        final_text = ""
        all_proficiencies = tuple(self._proficiencies_text)
        if self.has_class:
            all_proficiencies += tuple(self.primary_class._proficiencies_text)
        if self.num_classes > 1:
            for c in self.class_list[1:]:
                all_proficiencies += tuple(c._multiclass_proficiencies_text)
        if self.race is not None:
            all_proficiencies += tuple(self.race.proficiencies_text)
        if self.background is not None:
            all_proficiencies += tuple(self.background.proficiencies_text)
        # Create a single string out of all the proficiencies
        for txt in all_proficiencies:
            if not final_text:
                # Capitalize the first entry
                txt = txt.capitalize()
            else:
                # Put a comma first
                txt = ", " + txt
                # Add this item to the list text
            final_text += txt
        # Add a period at the end
        final_text += "."
        return final_text

    @proficiencies_text.setter
    def proficiencies_text(self, val):
        try:
            profs = val.split(",")
        except AttributeError:
            profs = val
        self._proficiencies_text = profs

    @property
    def features_text(self):
        s = "\n\n--".join(
            [f.name + ("**" if f.needs_implementation else "") for f in self.features]
        )
        if s != "":
            s = "(See Features Page)\n\n--" + s
            s += "\n\n=================\n\n"
        return s
    
    @property
    def features_summary(self):
        # save space for informed features and traits
        if hasattr(self, "features_and_traits"):
            info_list = ["**Other Features**"]
            info_list += [text.strip() for text in self.features_and_traits.split("\n")
                     if not(text.isspace())]
            N = len(info_list)
            for text in info_list:
                if len(text) > 26: # 26 is just a guess for expected size of lines
                    N += 1
            if N > 30:
                return "\n".join(info_list[:30]) + "\n(...)"
            N = 30 - N
        else:
            info_list = []
            N = 30
        if len(self.class_list) > 1:
            featS = ["**Multiclass**:"]
            for cl in self.class_list:
                description = cl.name
                if cl.subclass:
                    description += f"/{cl.subclass}"
                description += f" {cl.level}"
                featS.append(description)
        else:
            featS = []
        featS += ["**Features**"]
        featS += [f.name for f in self.features]
        if len(featS) > N:
            featS = featS[:N] + ["(...)"]
        featS += info_list
        return "\n\n".join(featS)
    
    @property
    def equipment_text(self):
        eq_list = []
        if hasattr(self, "magic_items"):
            eq_list += ["**Magic Items**"]
            eq_list += [item.name for item in self.magic_items]
        if hasattr(self, "equipment"):
            eq_list += ["**Other Equipment**"]
            eq_list += [text.strip() for text in self.equipment.split("\n")
                     if not(text.isspace())]
        return "\n\n".join(eq_list)
    
    @property
    def proficiencies_by_type(self):
        prof_dict = {}
        w_pro = set(self.weapon_proficiencies)
        if weapons.MartialWeapon in w_pro:
            prof_dict["Weapons"] = ["All weapons"]
        elif weapons.SimpleWeapon in w_pro:
            prof_dict["Weapons"] = ["Simple weapons"]
            for w in w_pro:
                if not(issubclass(w, weapons.SimpleWeapon)):
                    prof_dict["Weapons"] += [w.name]
        else:
            prof_dict["Weapons"] = [w.name for w in w_pro]
        if "Weapons" in prof_dict.keys():
            prof_dict["Weapons"] = ", ".join(prof_dict["Weapons"]) + "."
        armor_types = ["all armor", "light armor", "medium armor", 
                       "heavy armor"]
        prof_set = set([prof.lower().strip().strip('.') 
                     for prof in self.proficiencies_text.split(',')])
        prof_dict["Armor"] = [ar for ar in armor_types if ar in prof_set]
        if len(prof_dict["Armor"]) > 2 or 'all armor' in prof_set:
            prof_dict["Armor"] = ["All armor"]
        if 'shields' in prof_set:
            prof_dict["Armor"] += ["shields"]
        prof_dict["Armor"] = ", ".join(prof_dict["Armor"]) + "."
        if hasattr(self, 'chosen_tools'):
            prof_dict["Other"] = self.chosen_tools
        return prof_dict
    
    @property
    def spell_casting_info(self):
        """Returns a ready-to-use dictionary for spellsheets."""
        level_names = ["Cantrip", 
                       'FirstLevelSpell',
                       'SecondLevelSpell',
                       'ThirdLevelSpell',
                       'FourthLevelSpell',
                       'FifthLevelSpell',
                       'SixthLevelSpell',
                       'SeventhLevelSpell',
                       'EighthLevelSpell',
                       'NinthLevelSpell']
        spell_info = {'head':{
        "classes_and_levels": " / ".join(
        [c.name + " " + str(c.level) for c in self.spellcasting_classes]
        ),
        "abilities": " / ".join(
            [c.spellcasting_ability.upper()[:3] 
             for c in self.spellcasting_classes]
            ),
        "DCs": " / ".join(
            [str(self.spell_save_dc(c)) 
             for c in self.spellcasting_classes]
            ),
        "bonuses": " / ".join(
            ["{:+d}".format(self.spell_attack_bonus(c)) 
             for c in self.spellcasting_classes]
            ),
        }}
        slots = {level_names[k]:self.spell_slots(k) for k in range(1, 10)
                 if self.spell_slots(k) > 0}
        spell_info["slots"] = slots
        spell_list = {}
        for s in self.spells:
            prepared = s in self.spells_prepared
            level_info = level_names[s.level]
            info_there = spell_list.get(level_info, [])
            spell_list[level_info] = info_there + [(s.name, prepared)]
        spell_info["list"] = spell_list
        return spell_info

    @property
    def magic_items_text(self):
        s = ", ".join(
            [f.name for f in sorted(self.magic_items, key=(lambda x: x.name))]
        )
        if s:
            s += ", "
        return s

    def wear_armor(self, new_armor):
        """Accepts a string or Armor class and replaces the current armor.

        If a string is given, then a subclass of
        :py:class:`~dungeonsheets.armor.Armor` is retrived from the
        ``armor.py`` file. Otherwise, an subclass of
        :py:class:`~dungeonsheets.armor.Armor` can be provided
        directly.

        """
        if new_armor not in ("", "None", None):
            if isinstance(new_armor, armor.Armor):
                new_armor = new_armor
            else:
                msg = 'Unnown armor "{}". Please add it to ``armor.py``.'
                NewArmor = self._resolve_mechanic(
                    mechanic=new_armor,
                    SuperClass=armor.Armor,
                    warning_message=msg,
                )
                new_armor = NewArmor()
            self.armor = new_armor

    def wield_shield(self, shield):
        """Accepts a string or Shield class and replaces the current armor.

        If a string is given, then a subclass of
        :py:class:`~dungeonsheets.armor.Shield` is retrived from the
        ``armor.py`` file. Otherwise, an subclass of
        :py:class:`~dungeonsheets.armor.Shield` can be provided
        directly.

        """
        if shield not in ("", "None", None):
            msg = 'Unknown shield "{}". Please ad it to ``shields.py``.'
            NewShield = self._resolve_mechanic(shield, SuperClass=armor.Shield, warning_message=msg)
            self.shield = NewShield()

    def wield_weapon(self, weapon):
        """Accepts a string and adds it to the list of wielded weapons.

        Parameters
        ----------
        weapon : str
          Case-insensitive string with a name of the weapon.

        """
        # Retrieve the weapon class from the weapons module
        msg = 'Unknown weapon "{}". Please add it to ``weapons.py``.'
        ThisWeapon = self._resolve_mechanic(
            mechanic=weapon,
            SuperClass=weapons.Weapon,
            warning_message=msg,
        )
        # Save it to the array
        self._weapons.append(ThisWeapon(wielder=self))

    @property
    def weapons(self):
        my_weapons = self._weapons.copy()
        # Account for unarmed strike
        if len(my_weapons) == 0 or hasattr(self, "Monk"):
            my_weapons.append(weapons.Unarmed(wielder=self))
        return my_weapons
    
    @property
    def hit_dice(self):
        """What type and how many dice to use for re-gaining hit points.
        
        To change, set hit_dice_num and hit_dice_faces."""
        dice_s = " + ".join([f"{c.level}d{c.hit_dice_faces}" for c in self.class_list])
        dice_s = combine_dice(dice_s)
        return dice_s

    @property
    def hit_dice_faces(self):
        # Not a valid function if multiclass
        if self.num_classes > 1:
            warnings.warn("hit_dice_faces is not valid for multiclass characters")
        return self.primary_class.hit_dice_faces

    @hit_dice_faces.setter
    def hit_dice_faces(self, faces):
        self.primary_class.hit_dice_faces = faces

    @property
    def proficiency_bonus(self):
        if self.level < 5:
            prof = 2
        elif 5 <= self.level < 9:
            prof = 3
        elif 9 <= self.level < 13:
            prof = 4
        elif 13 <= self.level < 17:
            prof = 5
        elif 17 <= self.level:
            prof = 6
        return prof

    def can_assume_shape(self, shape: monsters.Monster):
        return hasattr(self, "Druid") and self.Druid.can_assume_shape(shape)

    @property
    def all_wild_shapes(self):
        if hasattr(self, "Druid"):
            return self.Druid.all_wild_shapes
        else:
            return ()

    @property
    def wild_shapes(self):
        if hasattr(self, "Druid"):
            return self.Druid.wild_shapes
        else:
            return ()

    @wild_shapes.setter
    def wild_shapes(self, new_shapes):
        if hasattr(self, "Druid"):
            self.Druid.wild_shapes = new_shapes

    @property
    def infusions_text(self):
        if hasattr(self, "Artificer"):
            return tuple([i.name for i in self.infusions])
        else:
            return ()

    @classmethod
    def load(Cls, char_props: MutableMapping):
        """Factory Creates a character from the character definition.

        Parameters
        ==========
        char_props
          Keys and values holding all the attributes of the
          character. E.g. ``char_props['strength'] = 16``

        Returns
        =======
        char
          The initialized ``Character`` object with associated
          parameters.

        """
        # Parse the sheet type
        char_props.pop("sheet_type", "")
        # Load classes
        classes = char_props.get("classes", [])
        # backwards compatability
        if (len(classes) == 0) and ("character_class" in char_props):
            char_props["classes"] = [
                char_props.pop("character_class").lower().capitalize()
            ]
            char_props["levels"] = [str(char_props.pop("level"))]
        # Create the character with loaded properties
        char = Cls(**char_props)
        return char

    def save(self, filename, template_file="character_template.txt"):
        # Create the template context
        context = dict(
            char=self,
        )
        # Render the template
        src_path = os.path.join(os.path.dirname(__file__), "forms/")
        text = (
            jinja2.Environment(loader=jinja2.FileSystemLoader(src_path or "./"))
            .get_template(template_file)
            .render(context)
        )
        # Save the file
        with open(filename, mode="w") as f:
            f.write(text)

    def to_pdf(self, filename, **kwargs):
        from dungeonsheets.make_sheets import make_sheet

        if filename.endswith(".pdf"):
            filename = filename.replace("pdf", "py")
        make_sheet(filename, character=self, flatten=kwargs.get("flatten", True))


# Add backwards compatibility for tests
class Artificer(Character):
    def __init__(self, level=1, **attrs):
        attrs["classes"] = ["Artificer"]
        attrs["levels"] = [level]
        super().__init__(**attrs)


class Barbarian(Character):
    def __init__(self, level=1, **attrs):
        attrs["classes"] = ["Barbarian"]
        attrs["levels"] = [level]
        super().__init__(**attrs)


class Bard(Character):
    def __init__(self, level=1, **attrs):
        attrs["classes"] = ["Bard"]
        attrs["levels"] = [level]
        super().__init__(**attrs)


class Cleric(Character):
    def __init__(self, level=1, **attrs):
        attrs["classes"] = ["Cleric"]
        attrs["levels"] = [level]
        super().__init__(**attrs)


class Druid(Character):
    def __init__(self, level=1, **attrs):
        attrs["classes"] = ["Druid"]
        attrs["levels"] = [level]
        super().__init__(**attrs)


class Fighter(Character):
    def __init__(self, level=1, **attrs):
        attrs["classes"] = ["Fighter"]
        attrs["levels"] = [level]
        super().__init__(**attrs)


class Monk(Character):
    def __init__(self, level=1, **attrs):
        attrs["classes"] = ["Monk"]
        attrs["levels"] = [level]
        super().__init__(**attrs)


class Paladin(Character):
    def __init__(self, level=1, **attrs):
        attrs["classes"] = ["Paladin"]
        attrs["levels"] = [level]
        super().__init__(**attrs)


class Ranger(Character):
    def __init__(self, level=1, **attrs):
        attrs["classes"] = ["Ranger"]
        attrs["levels"] = [level]
        super().__init__(**attrs)


class Rogue(Character):
    def __init__(self, level=1, **attrs):
        attrs["classes"] = ["Rogue"]
        attrs["levels"] = [level]
        super().__init__(**attrs)


class Sorceror(Character):
    def __init__(self, level=1, **attrs):
        attrs["classes"] = ["Sorceror"]
        attrs["levels"] = [level]
        super().__init__(**attrs)


class Warlock(Character):
    def __init__(self, level=1, **attrs):
        attrs["classes"] = ["Warlock"]
        attrs["levels"] = [level]
        super().__init__(**attrs)


class Wizard(Character):
    def __init__(self, level=1, **attrs):
        attrs["classes"] = ["Wizard"]
        attrs["levels"] = [level]
        super().__init__(**attrs)
