# -*- coding: utf-8 -*-
"""
Functions to load/save monsters from/to files.
"""
import json
# noinspection PyUnresolvedReferences
import stat_stuff
# noinspection PyUnresolvedReferences
from stat_stuff import LAND_SPEED_KEY
# noinspection PyUnresolvedReferences
import ui_helpers
# Used to resolve circular references in type hints.
from monster_builder import *
from encounter_builder import *

MONSTER_DATA_VERSION = 3
ENCOUNTER_DATA_VERSION = 1
# If a monster's file name is set to this in the encounter data structure, it means
# that there's no external file for the monster and that the internal backup data should be used.
MONSTER_IN_ENCOUNTER_FLAG = ":NO_FILE:"

# ----------
# Helper functions.
# ----------


def abs_path(path: str, *paths: Sequence[str]) -> str:
    """
    Converts the given path & path components (joined via os.path.join()) to an absolute path,
    assuming minimal shenanigans.
    """
    return os.path.abspath(os.path.join(path, *paths))


def path_rel_to_program(path: str, *paths: Sequence[str]) -> str:
    """
    Given a set of os.path.join()-style args, creates a path from them starting from this file's
    current location.

    Used to point to files & resources relative to the program's installation directory.
    """
    return abs_path(os.path.dirname(os.path.abspath(__file__)), path, *paths)


def _v_i_dict_from_list(in_list: list) -> dict:
    """
    Returns a dictionary whose keys are the elements of in_list and
    whose values are the indices of each respective element.
    """
    return {x.lower(): i for (i, x) in enumerate(in_list)}

# ----------
# Monster packing & unpacking, loading & saving functions.
# ----------


def _monster_to_dict(monster: 'MonsterBuilderPanel') -> dict:
    """
    Takes a monster defined in a MonsterBuilderPanel and returns a dictionary containing its data.
    """
    # noinspection PyDictCreation
    monster_dict = {}
    monster_dict['monster_version'] = MONSTER_DATA_VERSION
    # Name, level, role, XP.
    monster_dict['name'] = monster.textboxes.name.GetValue().strip()
    monster_dict['level'] = int(ui_helpers.get_dropdown_str(monster.dropdowns.level))
    monster_dict['role_mod'] = ui_helpers.get_dropdown_str(monster.dropdowns.role_mod).lower()
    monster_dict['role'] = ui_helpers.get_dropdown_str(monster.dropdowns.role).lower()
    monster_dict['xp'] = ui_helpers.get_textbox_int(monster.textboxes.xp)
    monster_dict['custom_xp'] = monster.checkboxes.custom_xp.IsChecked()
    # Size, origin, type.
    monster_dict['size'] = ui_helpers.get_dropdown_str(monster.dropdowns.size).lower()
    monster_dict['origin'] = ui_helpers.get_dropdown_str(monster.dropdowns.origin).lower()
    monster_dict['type'] = ui_helpers.get_dropdown_str(monster.dropdowns.type).lower()
    monster_dict['is_swarm'] = monster.checkboxes.swarm.IsChecked()
    monster_dict['swarm_vuln'] = monster.textboxes.swarm_vuln.GetValue().strip()  # NOT get_textbox_int(), value could be anything.
    # Initiative.
    monster_dict['init'] = ui_helpers.get_textbox_int(monster.textboxes.init)
    monster_dict['custom_init'] = monster.checkboxes.custom_init.IsChecked()
    # HP, defenses.
    monster_dict['hp'] = ui_helpers.get_textbox_int(monster.textboxes.hp)
    monster_dict['ac'] = ui_helpers.get_textbox_int(monster.textboxes.ac)
    monster_dict['fort'] = ui_helpers.get_textbox_int(monster.textboxes.fort)
    monster_dict['ref'] = ui_helpers.get_textbox_int(monster.textboxes.ref)
    monster_dict['will'] = ui_helpers.get_textbox_int(monster.textboxes.will)
    # Core attributes.
    monster_dict['attribute_role'] = ui_helpers.get_dropdown_str(monster.dropdowns.attribute_role)
    monster_dict['custom_attributes'] = monster.checkboxes.custom_attributes.IsChecked()
    monster_dict['str'] = ui_helpers.get_textbox_attribute(monster.textboxes.str)
    monster_dict['con'] = ui_helpers.get_textbox_attribute(monster.textboxes.con)
    monster_dict['dex'] = ui_helpers.get_textbox_attribute(monster.textboxes.dex)
    monster_dict['int'] = ui_helpers.get_textbox_attribute(monster.textboxes.int)
    monster_dict['wis'] = ui_helpers.get_textbox_attribute(monster.textboxes.wis)
    monster_dict['cha'] = ui_helpers.get_textbox_attribute(monster.textboxes.cha)
    # Alignment, languages, and equipment.
    monster_dict['alignment'] = monster.dropdowns.alignment.GetValue().strip()
    monster_dict['languages'] = monster.textboxes.languages.GetValue().strip()
    monster_dict['equipment'] = monster.textboxes.equipment.GetValue().strip()
    # Skills. Dictionary keyed by lower-case skill name, value is an array: [ Listed T/F, Trained T/F, Custom T/F, value ].
    monster_dict['skills'] = {}
    for skill_name, skill in monster.skills.items():
        skill_name = skill_name.lower()
        monster_dict['skills'][skill_name] = [skill.is_included(), skill.is_trained(), skill.is_custom(), skill.get_int_value()]
    # Special senses. Dictionary keyed by lower-case sense name, value is an array: [ True/False if (un)checked, int sense range ].
    monster_dict['senses'] = {}
    for sense_name, sense in monster.senses.items():
        sense_name = sense_name.lower()
        if sense.checkbox.IsChecked():
            if sense.has_value:
                monster_dict['senses'][sense_name] = [True, ui_helpers.get_textbox_int(sense.textbox)]
            else:
                monster_dict['senses'][sense_name] = [True, 0]
        else:
            monster_dict['senses'][sense_name] = [False, 0]
    # Speeds. Dictionary whose keys are the lower-case speed names. Values are an array: [ is it checked, value, [ True/False per speed mod ] ].
    monster_dict['speeds'] = {}
    # Land speed always gets included.
    monster_dict['speeds']['speed'] = [True, ui_helpers.get_textbox_int(monster.speeds[LAND_SPEED_KEY].textbox), []]
    # Special speeds.
    for i in range(0, monster.checklists.speeds.GetCount()):
        lower_name = monster.speeds[i].label.GetLabelText().lower()
        monster_dict['speeds'][lower_name] = []
        monster_dict['speeds'][lower_name].append(monster.checklists.speeds.IsChecked(i))
        monster_dict['speeds'][lower_name].append(ui_helpers.get_textbox_int(monster.speeds[i].textbox))
        monster_dict['speeds'][lower_name].append([])
        for mod in monster.speeds[i].mods:
            if mod.IsChecked():
                monster_dict['speeds'][lower_name][-1].append(True)
            else:
                monster_dict['speeds'][lower_name][-1].append(False)
    # Save phasing property like it's a speed for consistency.
    monster_dict['speeds']['phasing'] = [monster.checkboxes.phasing.IsChecked(), 0, []]
    # Resistances, vulnerabilities, and immunities.
    # Dictionary whose keys are the r/v/i name. Values are an array: [ T/F if checked, "r"/"v"/"i" for which kind, value ]
    monster_dict['rvi'] = {}
    for rvi in stat_stuff.RESIST_VULN_IMM:
        lower_name = rvi.lower()
        if monster.resist_vuln_opts[rvi].immune_radio.GetValue() or not monster.resist_vuln_opts[rvi].has_rv:
            rvi_type = "i"
        elif monster.resist_vuln_opts[rvi].vuln_radio.GetValue():
            rvi_type = "v"
        else:
            rvi_type = "r"
        monster_dict['rvi'][lower_name] = [monster.resist_vuln_opts[rvi].checkbox.IsChecked(), rvi_type, ui_helpers.get_textbox_int(monster.resist_vuln_opts[rvi].value_text)]
    # Traits.
    # Array, one trait per item.
    monster_dict['traits'] = []
    for i in sorted(monster.traits.keys()):
        trait = monster.traits[i]
        # Traits are dictionaries with the following key/value pairs:
        # type="aura"/"property", name=Name of trait, range=Contents of range textbox, text=Contents of main text box,
        # damage_keywords=Array of damage keywords, source_keywords=Array of source keywords, type_keywords=Array of type keywords.
        monster_dict['traits'].append(dict())
        monster_dict['traits'][-1]['type'] = trait.type_box.GetStringSelection().lower()
        monster_dict['traits'][-1]['name'] = trait.name_text.GetValue()
        monster_dict['traits'][-1]['range'] = trait.range.GetValue()
        monster_dict['traits'][-1]['text'] = trait.text_text.GetValue()
        monster_dict['traits'][-1]['damage_keywords'] = [s.lower() for s in trait.damage_checklist.GetCheckedStrings()]
        monster_dict['traits'][-1]['source_keywords'] = [s.lower() for s in trait.source_checklist.GetCheckedStrings()]
        monster_dict['traits'][-1]['type_keywords'] = [s.lower() for s in trait.type_checklist.GetCheckedStrings()]
    # Actions.
    # Array, one action per item.
    monster_dict['actions'] = []
    for i in sorted(monster.actions.keys()):
        action = monster.actions[i]
        # Traits are dictionaries with the following key/value pairs:
        # type="aura"/"property", name=Name of action, range=Contents of range textbox, text=Contents of main text box,
        # damage_keywords=Array of damage keywords, source_keywords=Array of source keywords, type_keywords=Array of type keywords.
        monster_dict['actions'].append(dict())
        monster_dict['actions'][-1]['type'] = action.type_box.GetStringSelection().lower()
        monster_dict['actions'][-1]['range_type'] = action.range_type_box.GetStringSelection().lower()
        monster_dict['actions'][-1]['name'] = action.name_text.GetValue()
        monster_dict['actions'][-1]['frequency_type'] = action.frequency_box.GetStringSelection().lower()
        monster_dict['actions'][-1]['frequency_text'] = action.frequency_text.GetValue()
        monster_dict['actions'][-1]['text'] = action.text_text.GetValue()
        monster_dict['actions'][-1]['damage_keywords'] = [s.lower() for s in action.damage_checklist.GetCheckedStrings()]
        monster_dict['actions'][-1]['source_keywords'] = [s.lower() for s in action.source_checklist.GetCheckedStrings()]
        monster_dict['actions'][-1]['type_keywords'] = [s.lower() for s in action.type_checklist.GetCheckedStrings()]

    return monster_dict


def monster_to_dict(monster: 'MonsterBuilderPanel') -> dict:
    """
    Takes a MonsterBuilderPanel and creates a dictionary representation of it.

    Publicly accessible version of _monster_to_dict().

    :param monster: MonsterBuilderPanel that should be populated with the unpacked contents of monster_dict.
    :return: The dictionary created from the monster panel.
    """
    return _monster_to_dict(monster=monster)


def _dict_to_monster(monster_dict: dict, monster: 'MonsterBuilderPanel', filename: Optional[str] = None) -> bool:
    """
    Takes a dictionary equivalent to one generated by _monster_to_dict()
    and applies its data to the given MonsterBuilderPanel.

    :param monster_dict: Dictionary representation of a monster as generated by _monster_to_dict().
    :param monster: MonsterBuilderPanel that should be populated with the unpacked contents of monster_dict.
    :param filename: The (optional) filename of the monster file that the dictionary was loaded from.
    :return: True if the dictionary was unpacked successfully, False if not.
    """
    # Ensure we're using the right version of monster data.
    if monster_dict['monster_version'] > MONSTER_DATA_VERSION:
        return False

    # Remember the file the monster was opened from to so no prompt is necessary for future Save actions.
    if filename is not None:
        monster.last_save_file = abs_path(filename)

    # Suppress UI updates while loading everything.
    monster.laying_out = True

    # Name, level, role, XP.
    # Set the text value dropdowns below based on which string value of the respective list was selected.
    monster.textboxes.name.SetValue(monster_dict['name'])
    monster.dropdowns.level.SetSelection(int(monster_dict['level']) - 1)
    monster.dropdowns.role_mod.SetSelection(_v_i_dict_from_list(stat_stuff.ROLE_MODS)[monster_dict['role_mod'].lower()])
    monster.dropdowns.role.SetSelection(_v_i_dict_from_list(stat_stuff.ROLES)[monster_dict['role'].lower()])
    # Custom XP was added in monster data version 3. Assume using custom XP if coming from previous versions.
    if 'custom_xp' in monster_dict.keys():
        monster.checkboxes.custom_xp.SetValue(monster_dict['custom_xp'])
    else:
        monster.checkboxes.custom_xp.SetValue(True)
    monster.textboxes.xp.SetValue(str(monster_dict['xp']))
    # Size, origin, type.
    monster.dropdowns.size.SetSelection(_v_i_dict_from_list(stat_stuff.SIZES)[monster_dict['size'].lower()])
    monster.dropdowns.origin.SetSelection(_v_i_dict_from_list(stat_stuff.ORIGINS)[monster_dict['origin'].lower()])
    monster.dropdowns.type.SetSelection(_v_i_dict_from_list(stat_stuff.TYPES)[monster_dict['type'].lower()])
    # Swarm was added in monster data version 2.
    if 'is_swarm' in monster_dict.keys() and 'swarm_vuln' in monster_dict.keys():
        monster.checkboxes.swarm.SetValue(monster_dict['is_swarm'])
        monster.textboxes.swarm_vuln.SetValue(monster_dict['swarm_vuln'])
    # Initiative.
    # Custom Init was added in monster data version 3. Assume using custom Init if coming from previous versions.
    if 'custom_init' in monster_dict.keys():
        monster.checkboxes.custom_init.SetValue(monster_dict['custom_init'])
    else:
        monster.checkboxes.custom_init.SetValue(True)
    monster.textboxes.init.SetValue(str(monster_dict['init']))
    # HP, defenses.
    monster.textboxes.hp.SetValue(str(monster_dict['hp']))
    monster.textboxes.ac.SetValue(str(monster_dict['ac']))
    monster.textboxes.fort.SetValue(str(monster_dict['fort']))
    monster.textboxes.ref.SetValue(str(monster_dict['ref']))
    monster.textboxes.will.SetValue(str(monster_dict['will']))
    # Core attributes.
    monster.dropdowns.attribute_role.SetSelection(_v_i_dict_from_list(stat_stuff.DEFAULT_ROLE_STATS.keys())[monster_dict['attribute_role'].lower()])
    monster.checkboxes.custom_attributes.SetValue(bool(monster_dict['custom_attributes']))
    monster.textboxes.str.SetValue(str(monster_dict['str']))
    monster.textboxes.con.SetValue(str(monster_dict['con']))
    monster.textboxes.dex.SetValue(str(monster_dict['dex']))
    monster.textboxes.int.SetValue(str(monster_dict['int']))
    monster.textboxes.wis.SetValue(str(monster_dict['wis']))
    monster.textboxes.cha.SetValue(str(monster_dict['cha']))
    # Alignment, languages, and equipment.
    if 'alignment' in monster_dict.keys():
        # Alignment added in monster data version 2.
        monster.dropdowns.alignment.SetValue(monster_dict['alignment'])
    monster.textboxes.languages.SetValue(monster_dict['languages'])
    monster.textboxes.equipment.SetValue(monster_dict['equipment'])
    # Skills.
    for skill_name in stat_stuff.SKILLS.keys():
        lower_name = skill_name.lower()
        # True if included, False if not.
        monster.skills[skill_name].included_checkbox.SetValue(bool(monster_dict['skills'][lower_name][0]))
        # True if trained, False if not.
        monster.skills[skill_name].trained_checkbox.SetValue(bool(monster_dict['skills'][lower_name][1]))
        # True if custom, False if not.
        monster.skills[skill_name].custom_checkbox.SetValue(bool(monster_dict['skills'][lower_name][2]))
        # Value of the skill.
        monster.skills[skill_name].value_text.SetValue(str(monster_dict['skills'][lower_name][3]))
    # Special senses.
    for sense_name in stat_stuff.SENSES.keys():
        lower_name = sense_name.lower()
        # True if checked, False if not.
        # Telepathy added in monster data version 2.
        if lower_name in monster_dict['senses'] and monster_dict['senses'][lower_name][0]:
            monster.senses[sense_name].checkbox.SetValue(True)
            if monster.senses[sense_name].has_value:
                monster.senses[sense_name].textbox.SetValue(str(monster_dict['senses'][lower_name][1]))
        else:
            monster.senses[sense_name].checkbox.SetValue(False)
            if monster.senses[sense_name].has_value:
                monster.senses[sense_name].textbox.SetValue('')
    # Just grab the land speed value directly.
    monster.speeds[LAND_SPEED_KEY].textbox.SetValue(str(monster_dict['speeds']['speed'][1]))
    # Special speeds.
    for i in range(0, monster.checklists.speeds.GetCount()):
        lower_name = monster.speeds[i].label.GetLabelText().lower()
        monster.checklists.speeds.Check(i, bool(monster_dict['speeds'][lower_name][0]))
        monster.speeds[i].textbox.SetValue(str(monster_dict['speeds'][lower_name][1]))
        for j in range(0, len(monster.speeds[i].mods)):
            monster.speeds[i].mods[j].SetValue(bool(monster_dict['speeds'][lower_name][2][j]))
    # Phasing. Check/uncheck directly.
    monster.checkboxes.phasing.SetValue(monster_dict['speeds']['phasing'][0])
    # Resistances, vulnerabilities, and immunities.
    for rvi in stat_stuff.RESIST_VULN_IMM:
        lower_name = rvi.lower()
        # Guard against unrecognized elements or newer ones older monsters didn't have.
        if rvi in monster.resist_vuln_opts and lower_name in monster_dict['rvi']:
            # Whether it's checked or not.
            monster.resist_vuln_opts[rvi].checkbox.SetValue(bool(monster_dict['rvi'][lower_name][0]))
            # If it's a resistance, vulnerability, or immunity.
            rvi_type = monster_dict['rvi'][lower_name][1].lower()
            if rvi_type == "i" or not monster.resist_vuln_opts[rvi].has_rv:
                monster.resist_vuln_opts[rvi].immune_radio.SetValue(True)
            elif rvi_type == "v":
                monster.resist_vuln_opts[rvi].vuln_radio.SetValue(True)
            else:
                monster.resist_vuln_opts[rvi].resist_radio.SetValue(True)
            # Value that goes in the resist/vuln text box. It's always present even for immunities, it might just be hidden.
            monster.resist_vuln_opts[rvi].value_text.SetValue(str(monster_dict['rvi'][lower_name][2]))
    # Traits.
    # Array, one trait per item.
    for trait_dict in monster_dict['traits']:
        trait = monster.add_trait()
        # Traits were stored as dictionaries with the following key/value pairs:
        # type="aura"/"property", name=Name of trait, range=Contents of range textbox, text=Contents of main text box,
        # damage_keywords=Array of damage keywords, source_keywords=Array of source keywords, type_keywords=Array of type keywords.
        trait.type_box.SetStringSelection(trait_dict['type'])
        trait.name_text.SetValue(trait_dict['name'])
        trait.range.SetValue(trait_dict['range'])
        trait.text_text.SetValue(trait_dict['text'])
        for keyword in trait_dict['damage_keywords']:
            trait.damage_checklist.Check(trait.damage_checklist.FindString(keyword), True)
        for keyword in trait_dict['source_keywords']:
            trait.source_checklist.Check(trait.source_checklist.FindString(keyword), True)
        for keyword in trait_dict['type_keywords']:
            trait.type_checklist.Check(trait.type_checklist.FindString(keyword), True)
    # Actions.
    # Array, one action per item.
    for action_dict in monster_dict['actions']:
        action = monster.add_action()
        # Actions were stored as dictionaries with the following key/value pairs:
        # type="standard"/"move"/"minor"/"triggered"/"free"/"other", range_type=""/"melee"/"melee basic"/"ranged"/"ranged basic"/"burst"/"blast",
        # name=Name of action, frequency_type=""/"at-will"/"encounter"/"recharge", frequency_text=Contents of frequency notes textbox,
        # text=Contents of main text box,
        # damage_keywords=Array of damage keywords, source_keywords=Array of source keywords, type_keywords=Array of type keywords.
        action.type_box.SetStringSelection(action_dict['type'])
        action.range_type_box.SetStringSelection(action_dict['range_type'])
        action.name_text.SetValue(action_dict['name'])
        action.frequency_box.SetStringSelection(action_dict['frequency_type'])
        action.frequency_text.SetValue(action_dict['frequency_text'])
        action.text_text.SetValue(action_dict['text'])
        for keyword in action_dict['damage_keywords']:
            action.damage_checklist.Check(action.damage_checklist.FindString(keyword), True)
        for keyword in action_dict['source_keywords']:
            action.source_checklist.Check(action.source_checklist.FindString(keyword), True)
        for keyword in action_dict['type_keywords']:
            action.type_checklist.Check(action.type_checklist.FindString(keyword), True)

    # Ensure all calculations are current and the UI is updated now that loading is done.
    monster.laying_out = False
    monster.recalculate()
    monster.update_ui()

    return True


def dict_to_monster(monster_dict: dict, monster: 'MonsterBuilderPanel', filename: Optional[str] = None) -> bool:
    """
    Takes a dictionary equivalent to one generated by _monster_to_dict()
    and applies its data to the given MonsterBuilderPanel.

    Publicly accessible version of _dict_to_monster().

    :param monster_dict: Dictionary representation of a monster as generated by _monster_to_dict().
    :param monster: MonsterBuilderPanel that should be populated with the unpacked contents of monster_dict.
    :param filename: The (optional) filename of the monster file that the dictionary was loaded from.
    :return: True if the dictionary was unpacked successfully, False if not.
    """
    return _dict_to_monster(monster_dict=monster_dict, monster=monster, filename=filename)


def save_monster(filename: str, monster: 'MonsterBuilderPanel') -> bool:
    """
    Saves the monster defined in the given frame to the specified file.

    :param filename: Filename (possibly with path) to save to. Will be overwritten if it already exists.
    :param monster: A MonsterBuilderPanel object whose data should be saved.
    :type monster: MonsterBuilderPanel
    :return: True if the file could be opened and written, False otherwise.
    """
    try:
        monster_dict = _monster_to_dict(monster)
        with open(filename, 'w') as outfile:
            json.dump(monster_dict, outfile, separators=(',', ':'))
    except IOError:
        return False
    return True


def load_monster(filename: str, monster: 'MonsterBuilderPanel') -> bool:
    """
    Loads the monster defined in the specified file to the given frame.

    :param filename: Filename (possibly with path) to load from.
    :param monster: A MonsterBuilderPanel object whose data should be overridden by the contents of the file.
    :type monster: MonsterBuilderPanel
    :return: True if the file could be opened and loaded, False otherwise.
    """
    try:
        with open(filename, 'r') as infile:
            monster_dict = json.load(infile)
    except IOError:
        return False
    return _dict_to_monster(monster_dict, monster, filename)

# ----------
# Encounter packing & unpacking, loading & saving functions.
# ----------


def _encounter_to_dict(encounter: 'EncounterBuilderPanel', filename: Optional[str] = None) -> dict:
    """
    Takes an encounter defined in an EncounterBuilderPanel and returns a dictionary containing its data.

    :param encounter: EncounterBuilderPanel that should be the source of data for the encounter dictionary.
    :param filename: The (optional) filename of the encounter file that the dictionary will be saved to. Used for path resolution when setting monster file names.
    """
    # noinspection PyDictCreation
    encounter_dict = {}
    encounter_dict['encounter_version'] = ENCOUNTER_DATA_VERSION
    # Name, description, level, player count, XP budget.
    encounter_dict['name'] = encounter.textboxes.name.GetValue()
    # Array: [T/F if "include in exports" checkbox is checked, contents of description textbox]
    encounter_dict['description'] = [bool(encounter.checkboxes.description.IsChecked()), encounter.textboxes.description.GetValue()]
    encounter_dict['level'] = int(ui_helpers.get_dropdown_str(encounter.dropdowns.level))
    encounter_dict['player_count'] = ui_helpers.get_textbox_int(encounter.textboxes.player_count)
    # Array: [T/F if custom checkbox is checked, integer value in textbox]
    encounter_dict['xp'] = [bool(encounter.checkboxes.xp_used.IsChecked()), ui_helpers.get_textbox_int(encounter.textboxes.xp_used)]
    # Array: [T/F if custom checkbox is checked, integer value in textbox]
    encounter_dict['xp_budget'] = [bool(encounter.checkboxes.xp_budget.IsChecked()), ui_helpers.get_textbox_int(encounter.textboxes.xp_budget)]
    # Whether monsters were saved externally or just internally in the encounter data.
    encounter_dict['save_monster_files'] = bool(encounter.panels.manage.should_monsters_save_to_files())
    # Array of dictionaries, one per monster:
    #   {'included': T/F if monster is included in encounter calculations,
    #   'active': T/F if actively selected for editing,
    #   'quantity': integer quantity,
    #   'file_name': file name for monster, or MONSTER_IN_ENCOUNTER_FLAG if monsters are only being saved in encounter data,
    #   'backup_data': dictionary data for the monster as given by _monster_to_dict()}
    encounter_dict['monsters'] = []
    for monster_id, monster_ctrl in encounter.panels.manage.monster_ctrls.items():
        encounter_dict['monsters'].append(dict())
        encounter_dict['monsters'][-1]['included'] = bool(monster_ctrl.is_included())
        encounter_dict['monsters'][-1]['active'] = bool(monster_ctrl.is_selected())
        encounter_dict['monsters'][-1]['quantity'] = monster_ctrl.get_quantity_int()
        if encounter.panels.manage.should_monsters_save_to_files():
            encounter_dict['monsters'][-1]['file_name'] = ""
            # If we have a potential save file, adjust the monster file names to be relative to that if possible.
            if filename:
                try:
                    encounter_path = os.path.dirname(file_utils.abs_path(filename))
                    encounter_dict['monsters'][-1]['file_name'] = os.path.relpath(encounter.monsters[monster_id].file_name, start=encounter_path)
                except ValueError:
                    # If that failed, there's probably no relative path between the encounter file & the monster file.
                    # Let this fall through to the case below and get filled in there.
                    pass
            # If there's no known encounter save file or no relative path between them, just use their absolute paths.
            if encounter_dict['monsters'][-1]['file_name'] == "":
                encounter_dict['monsters'][-1]['file_name'] = abs_path(encounter.monsters[monster_id].file_name)
        else:
            encounter_dict['monsters'][-1]['file_name'] = MONSTER_IN_ENCOUNTER_FLAG
        encounter_dict['monsters'][-1]['backup_data'] = _monster_to_dict(encounter.monsters[monster_id].panel)

    return encounter_dict


def _dict_to_encounter(encounter_dict: dict, encounter: 'EncounterBuilderPanel', filename: Optional[str] = None) -> Tuple[bool, int, int]:
    """
    Takes a dictionary equivalent to one generated by _encounter_to_dict()
    and applies its data to the given EncounterBuilderPanel.

    :param encounter_dict: Dictionary representation of an encounter as generated by _encounter_to_dict().
    :param encounter: EncounterBuilderPanel that should be populated with the unpacked contents of encounter_dict.
    :param filename: The (optional) filename of the encounter file that the dictionary was loaded from. Used for path resolution when loading monster files.
    :return: Tuple: (T/F if the dictionary was unpacked successfully, # of monsters unpacked, # of monsters (if any) that had to be unpacked from backup data).
    """
    success = True
    num_unpacked = 0
    num_needed_backup = 0
    # Ensure we're using the right version of encounter data.
    if encounter_dict['encounter_version'] > 1:
        return False, num_unpacked, num_needed_backup

    # Use either the path to the encounter file or the current directory as the place to start looking for monster files.
    if filename is not None:
        encounter_dir = os.path.dirname(abs_path(filename))
        # Also remember the file name of the encounter so no prompt is necessary for future Save actions.
        encounter.last_save_file = abs_path(filename)
    else:
        encounter_dir = os.getcwd()
    # Forget any prior export files.
    encounter.last_rtf_file = ''

    # Suppress UI updates while loading everything.
    encounter.laying_out = True

    # Name, level, player count, XP budget.
    encounter.textboxes.name.SetValue(encounter_dict['name'])
    encounter.checkboxes.description.SetValue(bool(encounter_dict['description'][0]))
    encounter.textboxes.description.SetValue(str(encounter_dict['description'][1]))
    encounter.dropdowns.level.SetSelection(int(encounter_dict['level']) - 1)
    encounter.textboxes.player_count.SetValue(str(encounter_dict['player_count']))
    encounter.checkboxes.xp_used.SetValue(bool(encounter_dict['xp'][0]))
    encounter.textboxes.xp_used.SetValue(str(encounter_dict['xp'][1]))
    encounter.checkboxes.xp_budget.SetValue(bool(encounter_dict['xp_budget'][0]))
    encounter.textboxes.xp_budget.SetValue(str(encounter_dict['xp_budget'][1]))

    # Whether the user wanted monsters saved externally or just internally in the encounter data.
    # No longer an encounter setting, just do nothing regardless of the setting.
    if bool(encounter_dict['save_monster_files']):
        pass
    else:
        pass

    # Monsters.
    # Array, one monster per item.
    active_id = -1
    for enc_monster_dict in encounter_dict['monsters']:
        # Monsters are stored as dictionaries with the following key/value pairs:
        # active=true/false if the monster was the "selected"/"active" one being edited,
        # included=true/false if the monster was included in encounter XP calculations,
        # file_name=Path & file name of monster file,
        # quantity=Integer quantity/copies of this monster included in encounter XP calculations,
        # backup_data=Monster dictionary as given by _monster_to_dict(), stored in the file in case the external monster file can't load.
        monster_ctrls = None
        # Check that file_name isn't blank & isn't the "no monster file" flag. Load from file if so.
        if len(enc_monster_dict['file_name']) > 0 and enc_monster_dict['file_name'] != MONSTER_IN_ENCOUNTER_FLAG:
            monster_ctrls = encounter.panels.manage.load_monster(file_name=enc_monster_dict['file_name'], start_dir=encounter_dir, select_new_monster=False)
        # Load from backup_data if file_name was blank, if the file load failed, or if it was only saved in the encounter data.
        if monster_ctrls is None:
            monster_ctrls = encounter.panels.manage.load_monster(monster_dict=enc_monster_dict['backup_data'], select_new_monster=False)
            # If the load from backup wasn't intentional, make note of that to let the user know.
            if enc_monster_dict['file_name'] != MONSTER_IN_ENCOUNTER_FLAG:
                num_needed_backup += 1
        # If the monster still didn't load, note the failure and skip it.
        if monster_ctrls is None:
            success = False
            continue
        # If it did load, update its state.
        if bool(enc_monster_dict['active']):
            active_id = monster_ctrls.monster_id
        monster_ctrls.checkbox.SetValue(bool(enc_monster_dict['included']))
        monster_ctrls.quantity_text.SetValue(str(enc_monster_dict['quantity']))
        monster_ctrls.repaint()
        num_unpacked += 1

    # Update the UI after loading everything.
    encounter.laying_out = False
    encounter.panels.manage.select_monster(active_id)
    encounter.recalculate()
    encounter.update_ui()

    return success, num_unpacked, num_needed_backup


def save_encounter(filename: str, encounter: 'EncounterBuilderPanel') -> bool:
    """
    Saves the encounter defined in the given frame to the specified file.

    :param filename: Filename (possibly with path) to save to. Will be overwritten if it already exists.
    :param encounter: An EncounterBuilderPanel object whose data should be saved.
    :type encounter: EncounterBuilderPanel
    :return: True if the file could be opened and written, False otherwise.
    """
    try:
        encounter_dict = _encounter_to_dict(encounter, filename)
        with open(filename, 'w') as outfile:
            json.dump(encounter_dict, outfile, separators=(',', ':'))
    except IOError:
        return False
    return True


def load_encounter(filename: str, encounter: 'EncounterBuilderPanel') -> Tuple[bool, int, int]:
    """
    Loads the encounter defined in the specified file to the given frame.

    :param filename: Filename (possibly with path) to load from.
    :param encounter: An EncounterBuilderPanel object whose data should be overridden by the contents of the file.
    :type encounter: EncounterBuilderPanel
    :return: Tuple: (T/F if the dictionary was unpacked successfully, # of monsters unpacked, # of monsters (if any) that had to be unpacked from backup data).
    """
    try:
        with open(filename, 'r') as infile:
            encounter_dict = json.load(infile)
    except IOError:
        return False, 0, 0
    return _dict_to_encounter(encounter_dict, encounter, filename)
