# -*- coding: utf-8 -*-
"""
Contains helper data, functions, and classes for Monster Builder UI creation & management.
"""
import os
import traceback
import types
# noinspection PyUnresolvedReferences
from typing import *
# noinspection PyUnresolvedReferences
import wx

import file_utils
import monster_builder
import ui_helpers
from ui_helpers import *
# Used to resolve circular reference in type hints.
import encounter_builder

# ----------
# Control Creation Helper Functions
# ----------


class DummyMonsterBuilderPanel(wx.Panel):
    """
    Dummy panel to use in place of a MonsterBuilderPanel when no monster is available.
    """

    def __init__(self, frame: ui_helpers.BuilderFrame, parent: wx.Window):
        """
        Constructor for the dummy monster window. Sets up and initializes UI elements.

        :param frame: MonsterBuilderFrame or EncounterBuilderFrame object that will hold this panel. Used to reference frame controls like the status bar.
        :param parent: The owner of this panel.
        """
        super(DummyMonsterBuilderPanel, self).__init__(parent=parent, id=wx.ID_ANY)
        self.frame = frame

        # Partial UI of a MonsterBuilderFrame to masquerade as one for certain functions and operations.
        self.labels = types.SimpleNamespace()
        self.textboxes = types.SimpleNamespace()
        self.sizer = wx.BoxSizer(orient=wx.HORIZONTAL)

        self.labels.blank_monster = basic_label(parent=self, label="Use New Monster or Load Monster to add a monster to the encounter.\nSelect a monster in the list to view or edit it.", sizer=self.sizer, style=wx.ALIGN_CENTRE_HORIZONTAL, proportion=1, flag=wx.ALL | wx.EXPAND)
        # Used if something asks a monster's name when only the dummy is selected. Pretends to be a "new monster" with blank data.
        self.textboxes.name = basic_textbox(parent=self, value="New Monster", sizer=self.sizer, minsize=ui_helpers.TEXT_LARGE_DLG_SIZE)
        self.textboxes.name.Hide()

        self.SetSizer(self.sizer)


class MonsterListItemControls(wx.BoxSizer):
    """
    Specialized BoxSizer containing the controls needed to display a monster in the monster list.
    """

    def __init__(self, manage_panel: 'MonsterManagementPanel', parent: wx.Window, monster_id: int, monster_name: str, quantity: int = 1, orient: int = wx.VERTICAL):
        """
        Creates a new set of monster list item controls in a sizer.
        All controls get the wx naming convention "[Control]:[Index]", where [Control] is what the control represents,
        and [Index] is the numeric index of the monster in the list.

        :param manage_panel: MonsterManagementPanel that will manage these controls. Used for callbacks.
        :param parent: The owner of the new controls.
        :param monster_id: The numeric ID of the monster in the encounter list.
        :param monster_name: The monster's initial name.
        :param quantity: The initial number of monsters of this type in the encounter, default 1.
        :param orient: The orientation of the sizer, default HORIZONTAL.
        """
        super().__init__(orient=orient)
        self.manage_panel = manage_panel
        self.parent = parent
        self.monster_id = monster_id
        self.monster_name = monster_name
        self.quantity = quantity

        self.Add(window=wx.StaticLine(parent=self.parent, id=wx.ID_ANY, style=wx.LI_HORIZONTAL), flag=wx.EXPAND)

        row_sizer = wx.BoxSizer(orient=wx.HORIZONTAL)
        # Button to move the monster up in the list.
        self.up_button = wx.Button(parent=self.parent, id=wx.ID_ANY, label="^", size=ui_helpers.UP_DOWN_BUTTON_SIZE)
        self.up_button.SetName("MonsterUp:{0:d}".format(self.monster_id))
        self.up_button.SetFont(ui_helpers.CTRL_FONT)
        self.up_button.Bind(wx.EVT_BUTTON, self._cb_move_up)
        row_sizer.Add(window=self.up_button, proportion=0, flag=wx.ALL | wx.ALIGN_LEFT, border=0)
        # Radio button to set monster to current view/edit.
        self.radio = wx.RadioButton(parent=self.parent, id=wx.ID_ANY, label=self.monster_name, style=wx.RB_SINGLE, name="Active:{0:d}".format(self.monster_id))
        row_sizer.Add(window=self.radio, proportion=1, flag=wx.ALL | wx.EXPAND, border=ui_helpers.CTRL_BORDER)
        self.radio.Bind(event=wx.EVT_RADIOBUTTON, handler=self._cb_name_clicked)
        # Button to remove the monster from the encounter.
        self.remove_button = wx.Button(parent=self.parent, id=wx.ID_ANY, label="X", size=ui_helpers.X_BUTTON_SIZE)
        self.remove_button.SetName("Remove:{0:d}".format(self.monster_id))
        self.remove_button.SetFont(ui_helpers.CTRL_FONT)
        self.remove_button.Bind(wx.EVT_BUTTON, self._cb_remove_monster)
        row_sizer.Add(window=self.remove_button, proportion=0, flag=wx.ALL, border=0)

        self.Add(sizer=row_sizer, flag=wx.EXPAND)

        row_sizer = wx.BoxSizer(orient=wx.HORIZONTAL)
        # Button to move the monster down in the list.
        self.down_button = wx.Button(parent=self.parent, id=wx.ID_ANY, label="v", size=ui_helpers.UP_DOWN_BUTTON_SIZE)
        self.down_button.SetName("MonsterDown:{0:d}".format(self.monster_id))
        self.down_button.SetFont(ui_helpers.CTRL_FONT)
        self.down_button.Bind(wx.EVT_BUTTON, self._cb_move_down)
        row_sizer.Add(window=self.down_button, proportion=0, flag=wx.ALL, border=0)
        # Checkbox to include/exclude the monster.
        self.checkbox = basic_checkbox(parent=self.parent, label="Include in exports", sizer=row_sizer)
        self.checkbox.SetValue(True)
        self.checkbox.SetName("Include:{0:d}".format(self.monster_id))
        self.checkbox.Bind(event=wx.EVT_CHECKBOX, handler=self._cb_include_clicked)
        # Number of copies of this monster in the encounter.
        self.quantity_label = ui_helpers.basic_label(parent=self.parent, label="Quantity:", sizer=row_sizer, style=wx.ALIGN_RIGHT)
        self.quantity_text = wx.SpinCtrl(parent=self.parent, id=wx.ID_ANY, value=str(self.quantity), style=wx.SP_ARROW_KEYS, min=0, max=100, initial=0)
        self.quantity_text.SetMinSize(ui_helpers.SMALL_TEXTBOX_SIZE)
        self.quantity_text.SetName("Quantity:{0:d}".format(self.monster_id))
        row_sizer.Add(window=self.quantity_text, proportion=0, flag=wx.ALL, border=0)
        self.quantity_text.Bind(event=wx.EVT_SPINCTRL, handler=self._cb_quantity_changed)
        self.quantity_text.Bind(event=wx.EVT_TEXT, handler=self._cb_quantity_changed)

        self.Add(sizer=row_sizer, flag=wx.EXPAND)

    # ----------
    # Callbacks
    # ----------

    # noinspection PyUnusedLocal
    def _cb_remove_monster(self, event: wx.CommandEvent):
        """
        Callback handling removing a monster from the monster list.

        :param event: CommandEvent object containing details about the event.
        """
        self.manage_panel.remove_monster(self.monster_id, prompt=True)

    # noinspection PyUnusedLocal
    def _cb_move_down(self, event: wx.CommandEvent):
        """
        Callback handling moving a monster down in the order.

        :param event: CommandEvent object containing details about the event.
        """
        self.manage_panel.move_monster_down(self.monster_id)

    # noinspection PyUnusedLocal
    def _cb_move_up(self, event: wx.CommandEvent):
        """
        Callback handling moving a monster up in the order.

        :param event: CommandEvent object containing details about the event.
        """
        self.manage_panel.move_monster_up(self.monster_id)

    # noinspection PyUnusedLocal
    def _cb_include_clicked(self, event: wx.CommandEvent):
        """
        Event handler when the monster is (un)checked to be excluded/included in the encounter.

        :param event: CommandEvent containing details about the event.
        """
        # (Un)including a monster effectively changes the quantity of monsters in the encounter, so re-use that method.
        self.monster_quantity_changed()

    def cb_monster_name_changed(self, event: wx.CommandEvent):
        """
        Event handler when the monster's name gets changed.
        Public because it gets connected directly to the monster's name field.

        :param event: CommandEvent containing details about the event.
        """
        self.change_name(event.GetString())

    # noinspection PyUnusedLocal
    def _cb_name_clicked(self, event: wx.CommandEvent):
        """
        Event handler when the monster is selected to be the current monster to view/edit.

        :param event: CommandEvent containing details about the event.
        """
        self.manage_panel.select_monster(self.monster_id)

    # noinspection PyUnusedLocal
    def _cb_quantity_changed(self, event: wx.SpinEvent):
        """
        Event handler when the monster's quantity is changed.

        :param event: SpinEvent containing details about the event.
        """
        self.monster_quantity_changed()

    # ----------
    # Public action and helper methods.
    # ----------

    def change_name(self, new_name: str):
        """
        Changes the name of the monster in the list to the new value.

        :param new_name: New name for the monster.
        """
        self.monster_name = new_name
        self.radio.SetLabel(new_name)
        self.repaint()
        self.manage_panel.encounter_panel.resize()

    def destroy(self):
        """
        Destroys this collection of controls and then itself.
        """
        self.up_button.Destroy()
        self.down_button.Destroy()
        self.checkbox.Destroy()
        self.radio.Destroy()
        self.quantity_text.Destroy()
        self.remove_button.Destroy()
        self.Destroy()

    def is_included(self) -> bool:
        """
        Returns True if the monster is checked (included in the encounter), False if not.
        """
        return self.checkbox.IsChecked()

    def is_selected(self):
        """
        Returns True if the monster is selected as the active viewing/editing monster, False if not.
        """
        return self.radio.GetValue()

    def get_quantity(self) -> str:
        """
        Returns the string contents of the quantity textbox.
        """
        return self.quantity_text.GetValue()

    def get_quantity_int(self) -> int:
        """
        Returns the integer-cast contents of the quantity textbox, or 0 if its contents aren't an integer.
        """
        return ui_helpers.get_textbox_int(self.quantity_text)

    def monster_quantity_changed(self):
        """
        Handles recalculating values & and adjusting UI when the quantity of monsters in an encounter changes.
        """
        self.repaint()
        self.manage_panel.recalculate()
        self.manage_panel.encounter_panel.update_ui()

    def repaint(self):
        """
        Adjusts the display of the child controls based on what's selected.
        """
        # Enable or disable the up/down buttons based on if this monster is already at the top/bottom of the list.
        self.up_button.Enable(self.monster_id > min(self.manage_panel.monster_ctrls.keys()))
        self.down_button.Enable(self.monster_id < max(self.manage_panel.monster_ctrls.keys()))
        if self.checkbox.IsChecked():
            self.quantity_text.Enable()
        else:
            self.quantity_text.Disable()
        self.Layout()

    def reset_values(self):
        """
        Reverts to the default of selecting the monster and having its quantity be 1.
        """
        self.checkbox.SetValue(True)
        self.quantity_text.SetValue("1")
        self.repaint()

    def set_monster_id(self, new_monster_id: int):
        """
        Sets the monster ID to the new ID, adjusting control names accordingly.

        :param new_monster_id: Integer ID to assign to this monster.
        """
        self.monster_id = new_monster_id
        self.up_button.SetName("MonsterUp:{0:d}".format(self.monster_id))
        self.down_button.SetName("MonsterDown:{0:d}".format(self.monster_id))
        self.checkbox.SetName("Include:{0:d}".format(self.monster_id))
        self.radio.SetName("Active:{0:d}".format(self.monster_id))
        self.quantity_text.SetName("Quantity:{0:d}".format(self.monster_id))
        self.remove_button.SetName("Remove:{0:d}".format(self.monster_id))

    def set_selected(self, selected: bool=True):
        """
        Helper method to (de)select this monster's radio button.

        :param selected: Selects the radio button if True (the default), deselects if False.
        """
        self.radio.SetValue(selected)

    def update_ui(self, recalculate=True):
        """
        Checks if the monster's checkbox is checked or not. Changes whether or not the text box is editable,
        and has the management panel recalculate its used XP budget accordingly.

        :param recalculate: Will recalculate encounter stats if True, will skip it if False.
        """
        self.repaint()
        if recalculate:
            self.manage_panel.recalculate()


class MonsterManagementPanel(wx.Panel):
    """
    Specialized Panel containing & managing controls to display the list of encounter monsters.

    Access controls for each monster by doing something like this.monster_ctrl[MONSTER_ID].
    """
    # ID assigned to the next monster that gets added to the list. Auto-incremented during monster creation/load.
    next_monster_id = 0

    def __init__(self, encounter_panel: 'encounter_builder.EncounterBuilderPanel', parent: wx.Window, label: str = "Encounter Monsters"):
        """
        Creates the controls to display a list of monsters in the encounter.

        :param encounter_panel: EncounterBuilderPanel object that will hold this panel. Used to reference callbacks and other controls.
        :param parent: The owner of this panel.
        :param label: The name to show for the panel's box.
        """
        super().__init__(parent=parent, id=wx.ID_ANY)
        self.encounter_panel = encounter_panel
        self.parent = parent
        self.box_label = label
        self.sizer = wx.StaticBoxSizer(orient=wx.VERTICAL, parent=self, label=self.box_label)
        self.static_box = self.sizer.GetStaticBox()
        # Key = monster ID, Value = MonsterListItemControls object.
        self.monster_ctrls = {}

        # Blank list to hold monsters.
        self.monster_list = wx.Window(parent=self.static_box, id=wx.ID_ANY)
        self.list_sizer = wx.BoxSizer(orient=wx.VERTICAL)

        self.monster_list.SetSizer(self.list_sizer)
        self.sizer.Add(window=self.monster_list, flag=wx.EXPAND)

        self.sizer.Add(window=wx.StaticLine(parent=self.static_box, id=wx.ID_ANY, style=wx.LI_HORIZONTAL), flag=wx.EXPAND)

        monster_buttons_sizer = wx.BoxSizer(orient=wx.HORIZONTAL)
        self.new_monster_button = ui_helpers.basic_button(parent=self.static_box, label="Add New", sizer=monster_buttons_sizer, border=0)
        self.new_monster_button.Bind(event=wx.EVT_BUTTON, handler=self._cb_new_monster)
        self.clone_monster_button = ui_helpers.basic_button(parent=self.static_box, label="Clone", sizer=monster_buttons_sizer, border=0)
        self.clone_monster_button.Bind(event=wx.EVT_BUTTON, handler=self._cb_clone_monster)
        self.load_monster_button = ui_helpers.basic_button(parent=self.static_box, label="Import", sizer=monster_buttons_sizer, border=0)
        self.load_monster_button.Bind(event=wx.EVT_BUTTON, handler=self._cb_load_monster)
        self.save_monster_button = ui_helpers.basic_button(parent=self.static_box, label="Export", sizer=monster_buttons_sizer, border=0)
        self.save_monster_button.Bind(event=wx.EVT_BUTTON, handler=self._cb_save_monster)
        self.sizer.Add(sizer=monster_buttons_sizer, flag=wx.EXPAND)

        self.SetSizer(self.sizer)

    # ----------
    # Callbacks
    # ----------

    # noinspection PyUnusedLocal
    def _cb_clone_monster(self, event):
        """
        Callback handling when the user wants to create a copy of the currently selected monster.

        :param event: CommandEvent object containing details about the event.
        """
        self.clone_monster()

    # noinspection PyUnusedLocal
    def _cb_load_monster(self, event):
        """
        Callback handling when the user wants to load a monster from file & add it to the list.

        :param event: CommandEvent object containing details about the event.
        """
        self.load_monster()

    # noinspection PyUnusedLocal
    def _cb_new_monster(self, event):
        """
        Callback handling when the user wants to add a new monster to the list.

        :param event: CommandEvent object containing details about the event.
        """
        self.new_monster()

    # noinspection PyUnusedLocal
    def _cb_save_monster(self, event):
        """
        Callback handling when the user wants to save a monster to a file.

        :param event: CommandEvent object containing details about the event.
        """
        self.save_monster()

    # ----------
    # Private helper methods
    # ----------

    def _add_monster(self, monster_panel: 'monster_builder.MonsterBuilderPanel', update_ui: bool = True) -> MonsterListItemControls:
        """
        Takes a (presumably newly created/loaded) MonsterBuilderPanel, adds it to the encounter monster data structures,
        and adds controls to manage it to the bottom of the monster list.
        Indended to be a helper method to new_monster() and load_monster().

        :param update_ui: If True, will update the monster list appearance. Will skip doing so if False.
        :return: Tuple of the monster's ID and the MonsterListItemControls object created as part of making the new monster.
        """
        monster_id = self.next_monster_id
        monster_name = monster_panel.textboxes.name.GetValue()
        self.encounter_panel.monsters[monster_id] = types.SimpleNamespace()
        self.encounter_panel.monsters[monster_id].monster_id = monster_id
        self.encounter_panel.monsters[monster_id].file_name = ''
        self.encounter_panel.monsters[monster_id].panel = monster_panel
        monster_panel.Hide()
        # Create new MonsterListItemControls, add them to our UI.
        self.monster_ctrls[monster_id] = MonsterListItemControls(manage_panel=self, parent=self.monster_list, monster_id=monster_id, monster_name=monster_name)
        # Subscribe to changes to the monster's name so it can also be updated in the encounter list.
        monster_panel.textboxes.name.Bind(event=wx.EVT_TEXT, handler=self.monster_ctrls[monster_id].cb_monster_name_changed)
        # Subscribe our containing encounter panel to changes to the monster's XP so it can update encounter XP totals.
        monster_panel.textboxes.xp.Bind(event=wx.EVT_TEXT, handler=self.encounter_panel.cb_monster_xp_changed)
        self.list_sizer.Add(sizer=self.monster_ctrls[monster_id], flag=wx.EXPAND)
        self._organize_monster_ctrls(update_ui)

        self.next_monster_id += 1

        return self.monster_ctrls[monster_id]
    
    def _get_next_monster_id(self, monster_id):
        """
        Finds the next monster ID after the given one.
        
        :param monster_id: ID of the "current" monster. 
        :return: The next highest monster ID in the list, or -1 if there is no ID after the given one.
        """
        max_id = max(self.monster_ctrls.keys())
        if monster_id == max_id:
            return -1
        # Find the lower list neighbor of the "current" monster.
        next_id = monster_id + 1
        while next_id not in self.monster_ctrls.keys() and next_id <= max_id:
            next_id += 1
        if next_id > max_id:
            return -1
        return next_id
    
    def _get_previous_monster_id(self, monster_id):
        """
        Finds the previous monster ID before the given one.

        :param monster_id: ID of the "current" monster. 
        :return: The next lowest monster ID in the list, or -1 if there is no ID before the given one.
        """
        min_id = min(self.monster_ctrls.keys())
        if monster_id == min_id:
            return
        # Find the upper list neighbor of the "current" monster.
        prev_id = monster_id - 1
        while prev_id not in self.monster_ctrls.keys() and prev_id >= min_id:
            prev_id -= 1
        if prev_id < min_id:
            return
        return prev_id

    def _organize_monster_ctrls(self, update_ui: bool=True):
        """
        Ensures that the list of monsters are displayed in order.

        :param update_ui: Will update the monster list display if True, will skip doing so if False.
        """
        while not self.list_sizer.IsEmpty():
            self.list_sizer.Detach(index=0)  # Just pops them out of the sizer w/o destroying them.
        for monster_id in sorted(self.monster_ctrls.keys()):
            self.list_sizer.Add(self.monster_ctrls[monster_id], flag=wx.EXPAND)
        if update_ui:
            self.update_ui()

    # ----------
    # Public action and helper methods
    # ----------

    def clone_monster(self, select_new_monster=True) -> Optional[MonsterListItemControls]:
        """
        Takes the currently selected monster and creates a copy of it, making a new monster panel for the copy and adding
        controls to manage it to the bottom of the monster list.
        Creates a monster builder panel from file or from a pre-supplied monster dictionary (as from file_utils._monster_to_dict())
        and adds controls to manage it to the bottom of the monster list.

        :param select_new_monster: If True (the default), will auto-select the loaded monster as the "active" monster.
        :return: The MonsterListItemControls object created as part of making the new monster.
        """
        monster_ctrl = None
        success = False
        active_id = self.get_active_id_int()
        if active_id < 0:
            self.encounter_panel.frame.set_status("Error: No monster selected, can't clone!")
        else:
            self.encounter_panel.frame.set_status('Cloning monster...')
            # Get a dictionary version of the currently selected monster, create a new MonsterBuilderPanel,
            # load monster data into it from the dictionary, add it to the encounter panel's UI.
            source_monster_dict = file_utils.monster_to_dict(self.encounter_panel.monsters[active_id].panel)
            monster_panel = monster_builder.MonsterBuilderPanel(frame=self.encounter_panel.frame, parent=self.encounter_panel.static_boxes.monster_builder)
            if file_utils.dict_to_monster(monster_dict=source_monster_dict, monster=monster_panel, filename=None):
                # Give the cloned monster a different name to help identify it.
                monster_panel.textboxes.name.SetValue(monster_panel.textboxes.name.GetValue().strip() + " [Clone]")
                self.encounter_panel.frame.set_status('Monster "' + self.encounter_panel.monsters[active_id].panel.textboxes.name.GetValue().strip() + '" cloned.')
                monster_ctrl = self._add_monster(monster_panel=monster_panel, update_ui=False)
                success = True
            else:
                log_error('Error: Could not clone monster.')
                self.encounter_panel.frame.set_status('Error: Could not clone monster.')

        if success:
            # Select monster if desired.
            if select_new_monster:
                self.select_monster(monster_ctrl.monster_id)
        else:
            monster_ctrl = None

        return monster_ctrl

    def get_active_id(self) -> str:
        """
        Retrieves the current ID of the active monster.

        :return: String ID of the active monster, or "-1" if none are selected.
        """
        return str(self.encounter_panel.active_monster.monster_id)

    def get_active_id_int(self) -> int:
        """
        Retrieves the current integer ID of the active monster.

        :return: Integer ID of the active monster, or -1 if none are selected.
        """
        return self.encounter_panel.active_monster.monster_id

    def load_monster(self, monster_dict: Optional[dict]=None, file_name: str="", start_dir: Optional[str]=None, select_new_monster=True) -> Optional[MonsterListItemControls]:
        """
        Creates a monster builder panel from file or from a pre-supplied monster dictionary (as from file_utils._monster_to_dict())
        and adds controls to manage it to the bottom of the monster list.

        :param monster_dict: Dictionary (as file_utils._monster_to_dict()) to load monster from. Will load from file if None.
        :param file_name: Path & file name to load monster from, will open an Open File dialog if blank.
        :param start_dir: The path to use as the "base path" when trying to load the file given in file_name. Defaults to current directory if not supplied.
        :param select_new_monster: If True (the default), will auto-select the loaded monster as the "active" monster.
        :return: The MonsterListItemControls object created as part of making the new monster.
        """
        monster_ctrl = None
        success = False
        # Try opening from file if we weren't given a dictionary to use.
        if monster_dict is None:
            if len(file_name) == 0:
                open_file = wx.FileSelector('Choose monster file to open', default_filename=self.encounter_panel.active_monster.panel.textboxes.name.GetValue(), default_extension='.mon', wildcard='Monster Files (*.mon)|*.mon|All Files (*.*)|*.*', parent=self.encounter_panel.frame).strip()

                # Cancel open if the user cancels out of open file dialog.
                if len(open_file) == 0:
                    return monster_ctrl
            else:
                if start_dir is None:
                    start_path = file_utils.abs_path(os.getcwd())
                else:
                    start_path = file_utils.abs_path(start_dir)
                open_file = file_utils.abs_path(os.path.join(start_path, file_name))
            self.encounter_panel.frame.set_status('Opening monster file "' + open_file + '"...')
            try:
                # Create a new MonsterBuilderPanel, load monster data into it from file, add it to the encounter panel's UI.
                monster_panel = monster_builder.MonsterBuilderPanel(frame=self.encounter_panel.frame, parent=self.encounter_panel.static_boxes.monster_builder)
                if file_utils.load_monster(open_file, monster_panel):
                    self.encounter_panel.frame.set_status('Monster opened from file "' + open_file + '".')
                    # TODO: Check if that monster is already open, remove the old panel & replace with the new panel if so.
                    # TODO: If both file names aren't blank, check them (strip path first). If equal, treat as the same.
                    # TODO: If not, check monster names. If equal, treat as same, otherwise treat as different.
                    monster_ctrl = self._add_monster(monster_panel=monster_panel, update_ui=False)
                    monster_id = monster_ctrl.monster_id
                    # Save the monster file's absolute path so we can find it when we save the encounter.
                    self.encounter_panel.monsters[monster_id].file_name = file_utils.abs_path(open_file)
                    success = True
                else:
                    log_error('Error: Could not open monster from file "' + open_file + '".')
                    self.encounter_panel.frame.set_status('Error: Could not open monster from file "' + open_file + '".')
            except Exception as ex:
                log_error('Error: Failed to open monster: {0}\n{1}', ex, traceback.format_exc())
                self.encounter_panel.frame.set_status('Error: Could not open monster: {0}'.format(ex))
        else:
            # Create new MonsterBuilderPanel, load monster data into it from the dictionary, add it to the encounter panel's UI.
            monster_panel = monster_builder.MonsterBuilderPanel(frame=self.encounter_panel.frame, parent=self.encounter_panel.static_boxes.monster_builder)
            if file_utils.dict_to_monster(monster_dict=monster_dict, monster=monster_panel, filename=None):
                self.encounter_panel.frame.set_status('Monster "' + monster_panel.textboxes.name.GetValue() + '" opened.')
                monster_ctrl = self._add_monster(monster_panel=monster_panel, update_ui=False)
                success = True
            else:
                log_error('Error: Could not open monster.')
                self.encounter_panel.frame.set_status('Error: Could not open monster.')

        if success:
            # Select monster if desired.
            if select_new_monster:
                self.select_monster(monster_ctrl.monster_id)
        else:
            monster_ctrl = None

        return monster_ctrl

    def move_monster_down(self, monster_id: int):
        """
        Moves the monster with the given ID down one slot on the list of monsters.

        :param monster_id: Integer ID of the monster to move.
        """
        swap_id = self._get_next_monster_id(monster_id)
        if swap_id < 0:
            return
        # If the active monster is one that's moving, make sure the active ID updates too.
        if self.encounter_panel.active_monster.monster_id == monster_id:
            self.encounter_panel.active_monster.monster_id = swap_id
            self.encounter_panel.active_monster.panel = self.encounter_panel.monsters[swap_id].panel
        elif self.encounter_panel.active_monster.monster_id == swap_id:
            self.encounter_panel.active_monster.monster_id = monster_id
            self.encounter_panel.active_monster.panel = self.encounter_panel.monsters[monster_id].panel
        # Exchange IDs and positions for both the monster list & the monster panels.
        self.monster_ctrls[monster_id].set_monster_id(swap_id)
        self.monster_ctrls[swap_id].set_monster_id(monster_id)
        self.monster_ctrls[-1] = self.monster_ctrls[monster_id]
        self.monster_ctrls[monster_id] = self.monster_ctrls[swap_id]
        self.monster_ctrls[swap_id] = self.monster_ctrls[-1]
        del self.monster_ctrls[-1]
        self.encounter_panel.monsters[-1] = self.encounter_panel.monsters[monster_id]
        self.encounter_panel.monsters[monster_id] = self.encounter_panel.monsters[swap_id]
        self.encounter_panel.monsters[swap_id] = self.encounter_panel.monsters[-1]
        del self.encounter_panel.monsters[-1]
        self._organize_monster_ctrls()
        self.encounter_panel.resize()

    def move_monster_up(self, monster_id: int):
        """
        Moves the monster with the given ID up one slot on the list of monsters.

        :param monster_id: Integer ID of the monster to move.
        """
        swap_id = self._get_previous_monster_id(monster_id)
        if swap_id < 0:
            return
        # If the active monster is one that's moving, make sure the active ID updates too.
        if self.encounter_panel.active_monster.monster_id == monster_id:
            self.encounter_panel.active_monster.monster_id = swap_id
            self.encounter_panel.active_monster.panel = self.encounter_panel.monsters[swap_id].panel
        elif self.encounter_panel.active_monster.monster_id == swap_id:
            self.encounter_panel.active_monster.monster_id = monster_id
            self.encounter_panel.active_monster.panel = self.encounter_panel.monsters[monster_id].panel
        # Exchange IDs and positions for both the monster list & the monster panels.
        self.monster_ctrls[monster_id].set_monster_id(swap_id)
        self.monster_ctrls[swap_id].set_monster_id(monster_id)
        self.monster_ctrls[-1] = self.monster_ctrls[monster_id]
        self.monster_ctrls[monster_id] = self.monster_ctrls[swap_id]
        self.monster_ctrls[swap_id] = self.monster_ctrls[-1]
        self.encounter_panel.monsters[-1] = self.encounter_panel.monsters[monster_id]
        self.encounter_panel.monsters[monster_id] = self.encounter_panel.monsters[swap_id]
        self.encounter_panel.monsters[swap_id] = self.encounter_panel.monsters[-1]
        del self.encounter_panel.monsters[-1]
        del self.monster_ctrls[-1]
        self._organize_monster_ctrls()
        self.encounter_panel.resize()

    def new_monster(self, select_new_monster=True) -> MonsterListItemControls:
        """
        Creates a new monster builder panel and adds controls to manage it to the bottom of the monster list.

        :param select_new_monster: If True (the default), will auto-select the new monster as the "active" monster.
        :return: The MonsterListItemControls object created as part of making the new monster.
        """
        # Create new MonsterBuilderPanel, add it to the encounter panel's UI.
        monster_panel = monster_builder.MonsterBuilderPanel(frame=self.encounter_panel.frame, parent=self.encounter_panel.static_boxes.monster_builder)
        monster_panel.textboxes.name.SetValue("New Monster")
        monster_ctrls = self._add_monster(monster_panel=monster_panel)
        # Select monster if desired.
        if select_new_monster:
            self.select_monster(monster_ctrls.monster_id)

        self.encounter_panel.update_ui()

        return monster_ctrls

    def recalculate(self):
        """
        Calculates encounter XP based on monster XP & quantities.
        """
        stat_stuff.CALC_VALS.xp_used = 0
        for monster_id, monster in self.encounter_panel.monsters.items():
            if self.monster_ctrls[monster_id].checkbox.IsChecked():
                monster_xp = ui_helpers.get_textbox_int(monster.panel.textboxes.xp)
                stat_stuff.CALC_VALS.xp_used += monster_xp * self.monster_ctrls[monster_id].get_quantity_int()

    def remove_monster(self, monster_id: int, prompt: bool=False):
        """
        Removes a monster from the monster list.

        :param monster_id: The ID of the monster to remove.
        :param prompt: If True, will show a message box asking the user to confirm removing the monster. Default False.
        """
        if prompt:
            response = wx.MessageBox(message='Are you sure you want to remove "{0:s}" from the Encounter?'.format(self.monster_ctrls[monster_id].monster_name),
                                     caption="Confirm Remove Monster",
                                     style=wx.YES_NO | wx.CANCEL | wx.CENTRE,
                                     parent=self.parent)
            if response != wx.YES:
                return

        if self.encounter_panel.active_monster.monster_id >= 0 and self.encounter_panel.active_monster.monster_id == monster_id:
            self.select_replacement_monster()
        # Destroy the monster panel & the encounter panel's references to it.
        self.encounter_panel.monsters[monster_id].panel.destroy()
        del self.encounter_panel.monsters[monster_id]
        # Destroy the monster list controls and rebuild the list.
        while not self.list_sizer.IsEmpty():
            self.list_sizer.Detach(0)
        self.monster_ctrls[monster_id].destroy()
        del self.monster_ctrls[monster_id]
        self._organize_monster_ctrls()
        # Recalculate stats since removing a monster likely changed them.
        self.recalculate()
        self.encounter_panel.set_label_vals()
        # Redraw everything.
        self.repaint()
        self.encounter_panel.resize()

    def repaint(self):
        """
        Makes all the monster list controls repaint themselves.
        """
        for monster_ctrl in self.monster_ctrls.values():
            monster_ctrl.repaint()
        self.list_sizer.Layout()
        self.sizer.Layout()
        self.monster_list.Layout()

    def reset(self):
        """
        Removes all monster controls from the list of monsters and resets monster management options.
        """
        for monster_id in list(self.monster_ctrls.keys()):
            self.remove_monster(monster_id)
        self.recalculate()

    def reset_values(self):
        """
        Resets the monster controls to default, making them all included in the encounter w/ quantity 1,
        and selecting the first in the list.
        """
        for monster_ctrl in self.monster_ctrls.values():
            monster_ctrl.reset_values()
        if len(self.monster_ctrls) > 0:
            min_monster_id = min(self.monster_ctrls.keys())
            self.select_monster(min_monster_id)
        self.update_ui()

    def save_monster(self) -> int:
        """
        Saves the currently selected monster to its own monster file for later loading/importing.

        :return: 1 if the export was successful, 0 if it was cancelled by the user, -1 if it failed.
        """
        result = False
        active_id = self.get_active_id_int()
        if active_id != -1:
            self.encounter_panel.frame.set_status('Exporting currently selected monster to file...')
            # If the save operation failed or was canceled, abort saving the encounter.
            monster_save_result = self.encounter_panel.monsters[active_id].panel.save()
            if monster_save_result < 0:
                self.encounter_panel.frame.set_status('Error: Could not export monster to file.')
                return -1
            elif monster_save_result == 0:
                self.encounter_panel.frame.set_status('Monster export cancelled.')
                return 0
            else:
                # On success, remember the monster's file path & name.
                self.encounter_panel.monsters[active_id].file_name = file_utils.abs_path(self.encounter_panel.monsters[active_id].panel.last_save_file)
                self.encounter_panel.frame.set_status('Monster exported to file "' + self.encounter_panel.monsters[active_id].file_name + '".')

        return result

    def select_monster(self, monster_id: int):
        """
        Sets the active viewed/edited monster to the one with the given ID, deselects the others in the list.

        :param monster_id: Integer monster ID to select.
        """
        # If the "new" monster is the same as the "current" active monster, skip doing anything.
        if monster_id == self.get_active_id_int():
            return
        # Detach everything from the monster builder area.
        while not self.encounter_panel.sizers.monster_builder.IsEmpty():
            self.encounter_panel.sizers.monster_builder.Detach(index=0)  # Just pops them out of the sizer w/o destroying them.
        # Select & show the desired monster.
        if monster_id in self.encounter_panel.monsters.keys() and monster_id in self.monster_ctrls.keys():
            monster_panel = self.encounter_panel.monsters[monster_id].panel
            self.encounter_panel.sizers.monster_builder.Add(window=monster_panel, flag=wx.EXPAND)
            self.encounter_panel.active_monster.monster_id = monster_id
            self.encounter_panel.active_monster.panel = monster_panel
            self.monster_ctrls[monster_id].set_selected(True)
            # Make sure the current monster stats reflect the newly selected one.
            monster_panel.recalculate()
            monster_panel.Show()
        # Find the one that was selected and show it, hide the others.
        for monster_id in self.monster_ctrls.keys():
            if monster_id != self.encounter_panel.active_monster.monster_id:
                self.monster_ctrls[monster_id].set_selected(False)
                self.encounter_panel.monsters[monster_id].panel.Hide()
        # If nothing filled the monster builder area, assume bad monster ID or no monsters available so just show the blank monster panel.
        if self.encounter_panel.sizers.monster_builder.IsEmpty():
            self.encounter_panel.sizers.monster_builder.Add(window=self.encounter_panel.panels.blank_monster, flag=wx.EXPAND)
            self.encounter_panel.active_monster.monster_id = -1
            self.encounter_panel.active_monster.panel = self.encounter_panel.panels.blank_monster
            self.encounter_panel.panels.blank_monster.Show()
        else:
            self.encounter_panel.panels.blank_monster.Hide()

        self.encounter_panel.resize()
    
    def select_replacement_monster(self):
        """
        Selects the next monster down in the list, or the previous if there is no next monster, or the blank monster
        if there aren't any others to select.
        
        Assumed this is called during a remove_monster() or equivalent call.
        """
        monster_id = self.get_active_id_int()
        swap_id = self._get_next_monster_id(monster_id)
        if swap_id < 0:
            swap_id = self._get_previous_monster_id(monster_id)
        # If this ends up -1, select_monster() will automatically handle "selecting" the blank monster panel.
        self.select_monster(swap_id)

    # noinspection PyMethodMayBeStatic
    def should_monsters_save_to_files(self) -> bool:
        """
        Convenience method to check which monster saving method the user has selected.
        Always returns False, monsters now always just save into the encounter unless the user actively exports them.

        :return: True if monsters should save to their own files, False if they should only be saved in the encounter file.
        """
        return False

    def update_ui(self):
        """
        Updates the display and calculations of all monster controls,
        presumably after something like a monster being loaded from file or the monster order changing externally.
        """
        for monster_ctrl in self.monster_ctrls.values():
            monster_ctrl.update_ui(recalculate=False)  # Save doing recalculation a bunch of unnecessary times.
        self.recalculate()
        self.repaint()
