#!/usr/bin/env python3
""" Handling of Faceswap GUI Projects, Tasks and Last Session """

import logging
import os
import tkinter as tk
from tkinter import messagebox

from lib.serializer import get_serializer

logger = logging.getLogger(__name__)  # pylint: disable=invalid-name


class _GuiSession():  # pylint:disable=too-few-public-methods
    """ Parent class for GUI Session Handlers.

    Parameters
    ----------
    config: :class:`lib.gui.utils.Config`
        The master GUI config
    file_handler: :class:`lib.gui.utils.FileHandler`
        A file handler object

    """
    def __init__(self, config, file_handler=None):
        # NB file_handler has to be passed in to avoid circular imports
        logger.debug("Initializing: %s: (config: %s, file_handler: %s)",
                     self.__class__.__name__, config, file_handler)
        self._serializer = get_serializer("json")
        self._config = config

        self._options = None
        self._file_handler = file_handler
        self._filename = None
        self._saved_tasks = None
        self._modified = False
        logger.debug("Initialized: %s", self.__class__.__name__)

    @property
    def _active_tab(self):
        """ str: The name of the currently selected :class:`lib.gui.command.CommandNotebook`
        tab. """
        notebook = self._config.command_notebook
        toolsbook = self._config.tools_notebook
        command = notebook.tab(notebook.select(), "text").lower()
        if command == "tools":
            command = toolsbook.tab(toolsbook.select(), "text").lower()
        logger.debug("Active tab: %s", command)
        return command

    @property
    def _modified_vars(self):
        """ dict: The tkinter Boolean vars indicating the modified state for each tab. """
        return self._config.modified_vars

    @property
    def _file_exists(self):
        """ bool: ``True`` if :attr:`_filename` exists otherwise ``False``. """
        return self._filename is not None and os.path.isfile(self._filename)

    @property
    def _cli_options(self):
        """ dict: the raw cli options from :attr:`_options` with project fields removed. """
        return {key: val for key, val in self._options.items() if isinstance(val, dict)}

    @property
    def _default_options(self):
        """ dict: The default options for all tabs """
        return self._config.default_options

    @property
    def _dirname(self):
        """ str: The folder name that :attr:`_filename` resides in. Returns ``None`` if
        filename is ``None``. """
        return os.path.dirname(self._filename) if self._filename is not None else None

    @property
    def _basename(self):
        """ str: The base name of :attr:`_filename`. Returns ``None`` if filename is ``None``. """
        return os.path.basename(self._filename) if self._filename is not None else None

    @property
    def _stored_tab_name(self):
        """str: The tab_name stored in :attr:`_options` or ``None`` if it does not exist """
        if self._options is None:
            return None
        return self._options.get("tab_name", None)

    @property
    def _selected_to_choices(self):
        """ dict: The selected value and valid choices for multi-option, radio or combo options.
        """
        valid_choices = {cmd: {opt: dict(choices=val["cpanel_option"].choices,
                                         is_multi=val["cpanel_option"].is_multi_option)
                               for opt, val in data.items()
                               if isinstance(val, dict) and "cpanel_option" in val
                               and val["cpanel_option"].choices is not None}
                         for cmd, data in self._config.cli_opts.opts.items()}
        logger.trace("valid_choices: %s", valid_choices)
        retval = {command: {option: {"value": value,
                                     "is_multi": valid_choices[command][option]["is_multi"],
                                     "choices":  valid_choices[command][option]["choices"]}
                            for option, value in options.items()
                            if value and command in valid_choices
                            and option in valid_choices[command]}
                  for command, options in self._options.items()
                  if isinstance(options, dict)}
        logger.trace("returning: %s", retval)
        return retval

    def _current_gui_state(self, command=None):
        """ The current state of the GUI.

        Parameters
        ----------
        command: str, optional
        If provided, returns the state of just the given tab command. If ``None`` returns options
        for all tabs. Default ``None``

        Returns
        -------
        dict: The options currently set in the GUI
        """
        return self._config.cli_opts.get_option_values(command)

    def _set_filename(self, filename=None, sess_type="project"):
        """ Set the :attr:`_filename` attribute.

        :attr:`_filename` is set either from a given filename or the result from
        a :attr:`_file_handler`.

        Parameters
        ----------
        filename: str, optional
            An optional filename. If given then this filename will be used otherwise it will be
            collected by a :attr:`_file_handler`

        sess_type: {all, project, task}, optional
            The session type that the filename is being set for. Dictates the type of file handler
            that is opened.

        Returns
        -------
        bool: `True` if filename has been successfully set otherwise ``False``
        """
        logger.debug("filename: '%s', sess_type: '%s'", filename, sess_type)
        handler = f"config_{sess_type}"

        if filename is None:
            logger.debug("Popping file handler")
            cfgfile = self._file_handler("open", handler).return_file
            if not cfgfile:
                logger.debug("No filename given")
                return False
            filename = cfgfile.name
            cfgfile.close()

        if not os.path.isfile(filename):
            msg = f"File does not exist: '{filename}'"
            logger.error(msg)
            return False
        ext = os.path.splitext(filename)[1]
        if (sess_type == "project" and ext != ".fsw") or (sess_type == "task" and ext != ".fst"):
            logger.debug("Invalid file extension for session type: (sess_type: '%s', "
                         "extension: '%s')", sess_type, ext)
            return False
        logger.debug("Setting filename: '%s'", filename)
        self._filename = filename
        return True

    # GUI STATE SETTING
    def _set_options(self, command=None):
        """ Set the GUI options based on the currently stored properties of :attr:`_options`
        and sets the active tab.

        Parameters
        ----------
        command: str, optional
            The tab to set the options for. If None then sets options for all tabs.
            Default: ``None``
        """
        opts = self._get_options_for_command(command) if command else self._cli_options
        logger.debug("command: %s, opts: %s", command, opts)
        if opts is None:
            logger.debug("No options found. Returning")
            return
        for cmd, opt in opts.items():
            self._set_gui_state_for_command(cmd, opt)
        tab_name = self._options.get("tab_name", None) if command is None else command
        tab_name = tab_name if tab_name is not None else "extract"
        logger.debug("tab_name: %s", tab_name)
        self._config.set_active_tab_by_name(tab_name)

    def _get_options_for_command(self, command):
        """ Return a single command's options from :attr:`_options` formatted consistently with
        an all options dict.

        Parameters
        ----------
        command: str
            The command to return the options for

        Returns
        -------
        dict: The options for a single command in the format {command: options}. If the command
        is not found then returns ``None``
        """
        logger.debug(command)
        opts = self._options.get(command, None)
        retval = {command: opts}
        if not opts:
            self._config.tk_vars.console_clear.set(True)
            logger.info("No  %s section found in file", command)
            retval = None
        logger.debug(retval)
        return retval

    def _set_gui_state_for_command(self, command, options):
        """ Set the GUI state for the given command.

        Parameters
        ----------
        command: str
            The tab to set the options for
        options: dict
            The option values to set the GUI to
        """
        logger.debug("command: %s: options: %s", command, options)
        if not options:
            logger.debug("No options provided, not updating GUI")
            return
        for srcopt, srcval in options.items():
            optvar = self._config.cli_opts.get_one_option_variable(command, srcopt)
            if not optvar:
                continue
            logger.trace("setting option: (srcopt: %s, optvar: %s, srcval: %s)",
                         srcopt, optvar, srcval)
            optvar.set(srcval)

    def _reset_modified_var(self, command=None):
        """ Reset :attr:`_modified_vars` variables back to unmodified (`False`) for all
        commands or for the given command.

        Parameters
        ----------
        command: str, optional
            The command to reset the modified tkinter variable for. If ``None`` then all tkinter
            modified variables are reset to `False`. Default: ``None``
        """
        for key, tk_var in self._modified_vars.items():
            if (command is None or command == key) and tk_var.get():
                logger.debug("Reset modified state for: (command: %s key: %s)", command, key)
                tk_var.set(False)

    # RECENT FILE HANDLING
    def _add_to_recent(self, command=None):
        """ Add the file for this session to the recent files list.

        Parameters
        ----------
        command: str, optional
            The command that this session relates to. If `None` then the whole project is added.
            Default: ``None``
        """
        logger.debug(command)
        if self._filename is None:
            logger.debug("No filename for selected file. Not adding to recent.")
            return
        recent_filename = os.path.join(self._config.pathcache, ".recent.json")
        logger.debug("Adding to recent files '%s': (%s, %s)",
                     recent_filename, self._filename, command)
        if not os.path.exists(recent_filename) or os.path.getsize(recent_filename) == 0:
            logger.debug("Starting with empty recent_files list")
            recent_files = []
        else:
            logger.debug("loading recent_files list: %s", recent_filename)
            recent_files = self._serializer.load(recent_filename)
        logger.debug("Initial recent files: %s", recent_files)
        recent_files = self._del_from_recent(self._filename, recent_files)
        ftype = "project" if command is None else command
        recent_files.insert(0, (self._filename, ftype))
        recent_files = recent_files[:20]
        logger.debug("Final recent files: %s", recent_files)
        self._serializer.save(recent_filename, recent_files)

    def _del_from_recent(self, filename, recent_files=None, save=False):
        """ Remove an item from the recent files list.

        Parameters
        ----------
        filename: str
            The filename to be removed from the recent files list
        recent_files: list, optional
            If the recent files list has already been loaded, it can be passed in to avoid
            loading again. If ``None`` then load the recent files list from disk. Default: ``None``
        save: bool, optional
            Whether the recent files list should be saved after removing the file. ``True`` saves
            the file, ``False`` does not. Default: ``False``
        """
        recent_filename = os.path.join(self._config.pathcache, ".recent.json")
        if recent_files is None:
            logger.debug("Loading file list from disk: %s", recent_filename)
            if not os.path.exists(recent_filename) or os.path.getsize(recent_filename) == 0:
                logger.debug("No recent file list")
                return None
            recent_files = self._serializer.load(recent_filename)
        filenames = [recent[0] for recent in recent_files]
        if filename in filenames:
            idx = filenames.index(filename)
            logger.debug("Removing from recent file list: %s", filename)
            del recent_files[idx]
            if save:
                logger.debug("Saving recent files list: %s", recent_filename)
                self._serializer.save(recent_filename, recent_files)
        else:
            logger.debug("Filename '%s' does not appear in recent file list", filename)
        return recent_files

    def _get_lone_task(self):
        """ Get the sole command name from :attr:`_options`.

        Returns
        -------
        str: The only existing command name in the current :attr:`_options` dict or ``None`` if
        there are multiple commands stored.
        """
        command = None
        if len(self._cli_options) == 1:
            command = list(self._cli_options.keys())[0]
        logger.debug(command)
        return command

    # DISK IO
    def _load(self):
        """ Load GUI options from :attr:`_filename` location and set to :attr:`_options`.

        Returns
        -------
        bool: ``True`` if successfully loaded otherwise ``False``
        """
        if self._file_exists:
            logger.debug("Loading config")
            self._options = self._serializer.load(self._filename)
            self._check_valid_choices()
            retval = True
        else:
            logger.debug("File doesn't exist. Aborting")
            retval = False
        return retval

    def _check_valid_choices(self):
        """ Check whether the loaded file has any selected combo/radio/multi-option values that are
        no longer valid and remove them so that they are not passed into faceswap. """
        for command, options in self._selected_to_choices.items():
            for option, data in options.items():
                if ((data["is_multi"] and all(v in data["choices"] for v in data["value"].split()))
                        or not data["is_multi"] and data["value"] in data["choices"]):
                    continue
                if data["is_multi"]:
                    val = " ".join([v for v in data["value"].split() if v in data["choices"]])
                else:
                    val = ""
                val = self._default_options[command][option] if not val else val
                logger.debug("Updating invalid value to default: (command: '%s', option: '%s', "
                             "original value: '%s', new value: '%s')", command, option,
                             self._options[command][option], val)
                self._options[command][option] = val

    def _save_as_to_filename(self, session_type):
        """ Set :attr:`_filename` from a save as dialog.

        Parameters
        ----------
        session_type: ['all', 'task', 'project']
            The type of session to pop the save as dialog for. Limits the allowed filetypes

        Returns
        -------
        bool:
            True if :attr:`filename` successfully set otherwise ``False``
        """
        logger.debug("Popping save as file handler. session_type: '%s'", session_type)
        title = f"Save {f'{session_type.title()} ' if session_type != 'all' else ''}As..."
        cfgfile = self._file_handler("save",
                                     f"config_{session_type}",
                                     title=title,
                                     initial_folder=self._dirname).return_file
        if not cfgfile:
            logger.debug("No filename provided. session_type: '%s'", session_type)
            return False
        self._filename = cfgfile.name
        logger.debug("Set filename: (session_type: '%s', filename: '%s'",
                     session_type, self._filename)
        cfgfile.close()
        return True

    def _save(self, command=None):
        """ Collect the options in the current GUI state and save.

        Obtains the current options set in the GUI with the selected tab and applies them to
        :attr:`_options`. Saves :attr:`_options` to :attr:`_filename`. Resets :attr:_modified_vars
        for either the given command or all commands,

        Parameters
        ----------
        command: str, optional
            The tab to collect the current state for. If ``None`` then collects the current
            state for all tabs. Default: ``None``
        """
        self._options = self._current_gui_state(command)
        self._options["tab_name"] = self._active_tab
        logger.debug("Saving options: (filename: %s, options: %s", self._filename, self._options)
        self._serializer.save(self._filename, self._options)
        self._reset_modified_var(command)
        self._add_to_recent(command)


class Tasks(_GuiSession):
    """ Faceswap ``.fst`` Task File handling.

    Faceswap tasks handle the management of each individual task tab in the GUI. Unlike
    :class:`Projects`, Tasks contains all the active tasks currently running, rather than an
    individual task.

    Parameters
    ----------
    config: :class:`lib.gui.utils.Config`
        The master GUI config
    file_handler: :class:`lib.gui.utils.FileHandler`
        A file handler object
    """
    def __init__(self, config, file_handler):
        super().__init__(config, file_handler)
        self._tasks = {}

    @property
    def _is_project(self):
        """ str: ``True`` if all tasks are from an overarching session project else ``False``."""
        retval = False if not self._tasks else all(v.get("is_project", False)
                                                   for v in self._tasks.values())
        return retval

    @property
    def _project_filename(self):
        """ str: The overarching session project filename."""
        fname = None
        if not self._is_project:
            return fname

        for val in self._tasks.values():
            fname = val["filename"]
            break
        return fname

    def load(self, *args,  # pylint:disable=unused-argument
             filename=None, current_tab=True):
        """ Load a task into this :class:`Tasks` class.

        Tasks can be loaded from project ``.fsw`` files or task ``.fst`` files, depending on where
        this function is being called from.

        Parameters
        ----------
        *args: tuple
            Unused, but needs to be present for arguments passed by tkinter event handling
        filename: str, optional
            If a filename is passed in, This will be used, otherwise a file handler will be
            launched to select the relevant file.
        current_tab: bool, optional
            ``True`` if the task to be loaded must be for the currently selected tab. ``False``
            if loading a task into any tab. If current_tab is `True` then tasks can be loaded from
            ``.fsw`` and ``.fst`` files, otherwise they can only be loaded from ``.fst`` files.
            Default: ``True``
        """
        logger.debug("Loading task config: (filename: '%s', current_tab: '%s')",
                     filename, current_tab)

        # Option to load specific task from project files:
        sess_type = "all" if current_tab else "task"

        is_legacy = (not self._is_project and
                     filename is not None and sess_type == "task" and
                     os.path.splitext(filename)[1] == ".fsw")
        if is_legacy:
            logger.debug("Legacy task found: '%s'", filename)
            filename = self._update_legacy_task(filename)

        filename_set = self._set_filename(filename, sess_type=sess_type)
        if not filename_set:
            return
        loaded = self._load()
        if not loaded:
            return

        command = self._active_tab if current_tab else self._stored_tab_name
        command = self._get_lone_task() if command is None else command
        if command is None:
            logger.error("Unable to determine task from the given file: '%s'", filename)
            return
        if command not in self._options:
            logger.error("No '%s' task in '%s'", command, self._filename)
            return

        self._set_options(command)
        self._add_to_recent(command)

        if self._is_project:
            self._filename = self._project_filename
        elif self._filename.endswith(".fsw"):
            self._filename = None

        self._add_task(command)
        if is_legacy:
            self.save()

        logger.debug("Loaded task config: (command: '%s', filename: '%s')", command, filename)

    def _update_legacy_task(self, filename):
        """ Update legacy ``.fsw`` tasks to ``.fst`` tasks.

        Tasks loaded from the recent files menu may be passed in with a ``.fsw`` extension.
        This renames the file and removes it from the recent file list.

        Parameters
        ----------
        filename: str
            The filename of the `.fsw` file that needs converting

        Returns
        -------
        str:
            The new filename of the updated tasks file
        """
        # TODO remove this code after a period of time. Implemented November 2019
        logger.debug("original filename: '%s'", filename)
        fname, ext = os.path.splitext(filename)
        if ext != ".fsw":
            logger.debug("Not a .fsw file: '%s'", filename)
            return filename

        new_filename = f"{fname}.fst"
        logger.debug("Renaming '%s' to '%s'", filename, new_filename)
        os.rename(filename, new_filename)
        self._del_from_recent(filename, save=True)
        logger.debug("new filename: '%s'", new_filename)
        return new_filename

    def save(self, save_as=False):
        """ Save the current GUI state for the active tab to a ``.fst`` faceswap task file.

        Parameters
        ----------
        save_as: bool, optional
            Whether to save to the stored filename, or pop open a file handler to ask for a
            location. If there is no stored filename, then a file handler will automatically be
            popped.
        """
        logger.debug("Saving config...")
        self._set_active_task()
        save_as = save_as or self._is_project or self._filename is None

        if save_as and not self._save_as_to_filename("task"):
            return

        command = self._active_tab
        self._save(command=command)
        self._add_task(command)
        if not save_as:
            logger.info("Saved project to: '%s'", self._filename)
        else:
            logger.debug("Saved project to: '%s'", self._filename)

    def clear(self):
        """ Reset all GUI options to their default values for the active tab. """
        self._config.cli_opts.reset(self._active_tab)

    def reload(self):
        """ Reset currently selected tab GUI options to their last saved state. """
        self._set_active_task()

        if self._options is None:
            logger.info("No active task to reload")
            return
        logger.debug("Reloading task")
        self.load(filename=self._filename, current_tab=True)
        if self._is_project:
            self._reset_modified_var(self._active_tab)

    def _add_task(self, command):
        """ Add the currently active task to the internal :attr:`_tasks` dict.

        If the currently stored task is from an overarching session project, then
        only the options are updated. When resetting a tab to saved a project will always
        be preferred to a task loaded into the project, so the original reference file name
        stays with the project.

        Parameters
        ----------
        command: str
            The tab that pertains to the currently active task

        """
        self._tasks[command] = dict(filename=self._filename,
                                    options=self._options,
                                    is_project=self._is_project)

    def clear_tasks(self):
        """ Clears all of the stored tasks.

        This is required when loading a task stored in a legacy project file, and is only to be
        called by :class:`Project` when a project has been loaded which is in fact a task.
        """
        logger.debug("Clearing stored tasks")
        self._tasks = {}

    def add_project_task(self, filename, command, options):
        """ Add an individual task from a loaded :class:`Project` to the internal :attr:`_tasks`
        dict.

        Project tasks take priority over any other tasks, so the individual tasks from a new
        project must be placed in the _tasks dict.

        Parameters
        ----------
        filename: str
            The filename of the session project file
        command: str
            The tab that this task's options belong to
        options: dict
            The options for this task loaded from the project
        """
        self._tasks[command] = dict(filename=filename, options=options, is_project=True)

    def _set_active_task(self, command=None):
        """ Set the active :attr:`_filename` and :attr:`_options` to currently selected tab's
        options.

        Parameters
        ----------
        command: str, optional
            If a command is passed in then set the given tab to active, If this is none set the tab
            which currently has focus to active. Default: ``None``
        """
        logger.debug(command)
        command = self._active_tab if command is None else command
        task = self._tasks.get(command, None)
        if task is None:
            self._filename, self._options = (None, None)
        else:
            self._filename, self._options = (task.get("filename", None), task.get("options", None))
        logger.debug("tab: %s, filename: %s, options: %s",
                     self._active_tab, self._filename, self._options)


class Project(_GuiSession):
    """ Faceswap ``.fsw`` Project File handling.

    Faceswap projects handle the management of all task tabs in the GUI and updates
    the main Faceswap title bar with the project name and modified state.

    Parameters
    ----------
    config: :class:`lib.gui.utils.Config`
        The master GUI config
    file_handler: :class:`lib.gui.utils.FileHandler`
        A file handler object
    """

    def __init__(self, config, file_handler):
        super().__init__(config, file_handler)
        self._update_root_title()

    @property
    def filename(self):
        """ str: The currently active project filename. """
        return self._filename

    @property
    def cli_options(self):
        """ dict: the raw cli options from :attr:`_options` with project fields removed. """
        return self._cli_options

    @property
    def _project_modified(self):
        """bool: ``True`` if the project has been modified otherwise ``False``. """
        return any(var.get() for var in self._modified_vars.values())

    @property
    def _tasks(self):
        """ :class:`Tasks`: The current session's :class:``Tasks``. """
        return self._config.tasks

    def set_default_options(self):
        """ Set the default options. The Default GUI options are stored on Faceswap startup.

        Exposed as the :attr:`_default_options` for a project cannot be set until after the main
        Command Tabs have been loaded.
        """
        logger.debug("Setting options to default")
        self._options = self._default_options

    # MODIFIED STATE CALLBACK
    def set_modified_callback(self):
        """ Adds a callback to each of the :attr:`_modified_vars` tkinter variables
        When one of these variables is changed, triggers :func:`_modified_callback`
        with the command that was changed.

        This is exposed as the callback can only be added after the main Command Tabs have
        been drawn, and their options' initial values have been set.

        """
        for key, tkvar in self._modified_vars.items():
            logger.debug("Adding callback for tab: %s", key)
            tkvar.trace("w", self._modified_callback)

    def _modified_callback(self, *args):  # pylint:disable=unused-argument
        """ Update the project modified state on a GUI modification change and
        update the Faceswap title bar. """
        if self._project_modified and self._current_gui_state() == self._cli_options:
            logger.debug("Project is same as stored. Setting modified to False")
            self._reset_modified_var()

        if self._modified != self._project_modified:
            logger.debug("Updating project state from variable: (modified: %s)",
                         self._project_modified)
            self._modified = self._project_modified
            self._update_root_title()

    def load(self, *args,  # pylint:disable=unused-argument
             filename=None,  last_session=False):
        """ Load a project from a saved ``.fsw`` project file.

        Parameters
        ----------
        *args: tuple
            Unused, but needs to be present for arguments passed by tkinter event handling
        filename: str, optional
            If a filename is passed in, This will be used, otherwise a file handler will be
            launched to select the relevant file.
        last_session: bool, optional
            ``True`` if the project is being loaded from the last opened session ``False`` if the
            project is being loaded directly from disk. Default: ``False``
        """
        logger.debug("Loading project config: (filename: '%s', last_session: %s)",
                     filename, last_session)
        filename_set = self._set_filename(filename, sess_type="project")

        if not filename_set:
            logger.debug("No filename set")
            return
        loaded = self._load()
        if not loaded:
            logger.debug("Options not loaded")
            return

        # Legacy .fsw files could store projects or tasks. Check if this is a legacy file
        # and hand off file to Tasks if necessary
        command = self._get_lone_task()
        legacy = command is not None
        if legacy:
            self._handoff_legacy_task()
            return

        if not last_session:
            self._set_options()  # Options will be set by last session. Don't set now
        self._update_tasks()
        self._add_to_recent()
        self._reset_modified_var()
        self._update_root_title()
        logger.debug("Loaded project config: (command: '%s', filename: '%s')", command, filename)

    def _handoff_legacy_task(self):
        """ Update legacy tasks saved with the old file extension ``.fsw`` to tasks ``.fst``.

        Hands off file handling to :class:`Tasks` and resets project to default.
        """
        logger.debug("Updating legacy task '%s", self._filename)
        filename = self._filename
        self._filename = None
        self.set_default_options()
        self._tasks.clear_tasks()
        self._tasks.load(filename=filename, current_tab=False)
        logger.debug("Updated legacy task and reset project")

    def _update_tasks(self):
        """ Add the tasks from the loaded project to the :class:`Tasks` class. """
        for key, val in self._cli_options.items():
            opts = {key: val}
            opts["tab_name"] = key
            self._tasks.add_project_task(self._filename, key, opts)

    def reload(self, *args):  # pylint:disable=unused-argument
        """ Reset all GUI's option tabs to their last saved state.

        Parameters
        ----------
        *args: tuple
            Unused, but needs to be present for arguments passed by tkinter event handling
        """
        if self._options is None:
            logger.info("No active project to reload")
            return
        logger.debug("Reloading project")
        self._set_options()
        self._update_tasks()
        self._reset_modified_var()
        self._update_root_title()

    def _update_root_title(self):
        """ Update the root Window title with the project name. Add a asterisk
        if the file is modified. """
        text = "<untitled project>" if self._basename is None else self._basename
        text += "*" if self._modified else ""
        self._config.set_root_title(text=text)

    def save(self, *args, save_as=False):  # pylint:disable=unused-argument
        """ Save the current GUI state to a ``.fsw`` project file.

        Parameters
        ----------
        *args: tuple
            Unused, but needs to be present for arguments passed by tkinter event handling
        save_as: bool, optional
            Whether to save to the stored filename, or pop open a file handler to ask for a
            location. If there is no stored filename, then a file handler will automatically be
            popped.
        """
        logger.debug("Saving config as...")

        save_as = save_as or self._filename is None
        if save_as and not self._save_as_to_filename("project"):
            return
        self._save()
        self._update_tasks()
        self._update_root_title()
        if not save_as:
            logger.info("Saved project to: '%s'", self._filename)
        else:
            logger.debug("Saved project to: '%s'", self._filename)

    def new(self, *args):  # pylint:disable=unused-argument
        """ Create a new project with default options.

        Pops a file handler to select location.

        Parameters
        ----------
        *args: tuple
            Unused, but needs to be present for arguments passed by tkinter event handling
        """
        logger.debug("Creating new project")
        if not self.confirm_close():
            logger.debug("Creating new project cancelled")
            return

        cfgfile = self._file_handler("save",
                                     "config_project",
                                     title="New Project...",
                                     initial_folder=self._basename).return_file
        if not cfgfile:
            logger.debug("No filename selected")
            return
        self._filename = cfgfile.name
        cfgfile.close()

        self.set_default_options()
        self._config.cli_opts.reset()
        self._save()
        self._update_root_title()

    def close(self, *args):  # pylint:disable=unused-argument
        """ Clear the current project and set all options to default.

        Parameters
        ----------
        *args: tuple
            Unused, but needs to be present for arguments passed by tkinter event handling
        """
        logger.debug("Close requested")
        if not self.confirm_close():
            logger.debug("Close cancelled")
            return
        self._config.cli_opts.reset()
        self._filename = None
        self.set_default_options()
        self._reset_modified_var()
        self._update_root_title()
        self._config.set_active_tab_by_name(self._config.user_config_dict["tab"])

    def confirm_close(self):
        """ Pop a message box to get confirmation that an unsaved project should be closed

        Returns
        -------
        bool: ``True`` if user confirms close, ``False`` if user cancels close
        """
        if not self._modified:
            logger.debug("Project is not modified")
            return True
        confirmtxt = "You have unsaved changes.\n\nAre you sure you want to close the project?"
        if messagebox.askokcancel("Close", confirmtxt, default="cancel", icon="warning"):
            logger.debug("Close Cancelled")
            return True
        logger.debug("Close confirmed")
        return False


class LastSession(_GuiSession):
    """ Faceswap Last Session handling.

    Faceswap :class:`LastSession` handles saving the state of the Faceswap GUI at close and
    reloading the state  at launch.

    Last Session behavior can be configured in :file:`config.gui.ini`.

    Parameters
    ----------
    config: :class:`lib.gui.utils.Config`
        The master GUI config
    """

    def __init__(self, config):
        super().__init__(config)
        self._filename = os.path.join(self._config.pathcache, ".last_session.json")
        if not self._enabled:
            return

        if self._save_option == "prompt":
            self.ask_load()
        elif self._save_option == "always":
            self.load()

    @property
    def _save_option(self):
        """ str: The user config autosave option. """
        return self._config.user_config_dict.get("autosave_last_session", "never")

    @property
    def _enabled(self):
        """ bool: ``True`` if autosave is enabled otherwise ``False``. """
        return self._save_option != "never"

    def from_dict(self, options):
        """ Set the :attr:`_options` property based on the given options dictionary
        and update the GUI to use these values.

        This function is required for reloading the GUI state when the GUI has been force
        refreshed on a config change.

        Parameters
        ----------
        options: dict
            The options to set. Should be the output of :func:`to_dict`
        """
        logger.debug("Setting options from dict: %s", options)
        self._options = options
        self._set_options()

    def to_dict(self):
        """ Collect the current GUI options and place them in a dict for retrieval or storage.

        This function is required for reloading the GUI state when the GUI has been force
        refreshed on a config change.

        Returns
        -------
        dict: The current cli options ready for saving or retrieval by :func:`from_dict`
        """
        opts = self._current_gui_state()
        logger.debug("Collected opts: %s", opts)
        if not opts or opts == self._default_options:
            logger.debug("Default session, or no opts found. Not saving last session.")
            return None
        opts["tab_name"] = self._active_tab
        opts["project"] = self._config.project.filename
        logger.debug("Added project items: %s", {k: v for k, v in opts.items()
                                                 if k in ("tab_name", "project")})
        return opts

    def ask_load(self):
        """ Pop a message box to ask the user if they wish to load their last session.  """
        if not self._file_exists:
            logger.debug("No last session file found")
        elif tk.messagebox.askyesno("Last Session", "Load last session?"):
            logger.debug("Loading last session at user request")
            self.load()
        else:
            logger.debug("Not loading last session at user request")
            logger.debug("Deleting LastSession file")
            os.remove(self._filename)

    def load(self):
        """ Load the last session.

        Loads the last saved session options. Checks if a previous project was loaded
        and whether there have been changes since the last saved version of the project.
        Sets the display and :class:`Project` and :class:`Task` objects accordingly.
        """
        loaded = self._load()
        if not loaded:
            return
        self._set_project()
        self._set_options()

    def _set_project(self):
        """ Set the :class:`Project` if session is resuming from one. """
        if self._options.get("project", None) is None:
            logger.debug("No project stored")
        else:
            logger.debug("Loading stored project")
            self._config.project.load(filename=self._options["project"], last_session=True)

    def save(self):
        """ Save a snapshot of currently set GUI config options.

        Called on Faceswap shutdown.
        """
        if not self._enabled:
            logger.debug("LastSession not enabled")
            if os.path.exists(self._filename):
                logger.debug("Deleting existing LastSession file")
                os.remove(self._filename)
            return

        opts = self.to_dict()
        if opts is None and os.path.exists(self._filename):
            logger.debug("Last session default or blank. Clearing saved last session.")
            os.remove(self._filename)
        if opts is not None:
            self._serializer.save(self._filename, opts)
            logger.debug("Saved last session. (filename: '%s', opts: %s", self._filename, opts)
