# -*- coding: utf-8 -*-
#
# Copyright © Spyder Project Contributors
# Licensed under the terms of the MIT License
# (see spyder/__init__.py for details)

"""
Autosave components for the Editor plugin and the EditorStack widget

The autosave system regularly checks the contents of all opened files and saves
a copy in the autosave directory if the contents are different from the
autosave file (if it exists) or original file (if there is no autosave file).

The mapping between original files and autosave files is stored in the
variable `name_mapping` and saved in the file `pidNNN.txt` in the autosave
directory, where `NNN` stands for the pid. This filename is chosen so that
multiple instances of Spyder can run simultaneously.

File contents are compared using their hash. The variable `file_hashes`
contains the hash of all files currently open in the editor and all autosave
files.

On startup, the contents of the autosave directory is checked and if autosave
files are found, the user is asked whether to recover them;
see `spyder/plugins/editor/widgets/recover.py`.
"""

# Standard library imports
import ast
import logging
import os
import os.path as osp
import re

# Third party imports
from qtpy.QtCore import QTimer

# Local imports
from spyder.config.base import _, get_conf_path, running_under_pytest
from spyder.plugins.editor.widgets.autosaveerror import AutosaveErrorDialog
from spyder.plugins.editor.widgets.recover import RecoveryDialog
from spyder.utils.programs import is_spyder_process


logger = logging.getLogger(__name__)


class AutosaveForPlugin(object):
    """
    Component of editor plugin implementing autosave functionality.

    Attributes:
        name_mapping (dict): map between names of opened and autosave files.
        file_hashes (dict): map between file names and hash of their contents.
            This is used for both files opened in the editor and their
            corresponding autosave files.
    """

    # Interval (in ms) between two autosaves
    DEFAULT_AUTOSAVE_INTERVAL = 60 * 1000

    def __init__(self, editor):
        """
        Constructor.

        Autosave is disabled after construction and needs to be enabled
        explicitly if required.

        Args:
            editor (Editor): editor plugin.
        """
        self.editor = editor
        self.name_mapping = {}
        self.file_hashes = {}
        self.recover_files_to_open = []

        self.timer = QTimer(self.editor)
        self.timer.setSingleShot(True)
        self.timer.timeout.connect(self.do_autosave)

        self._enabled = False  # Can't use setter here
        self._interval = self.DEFAULT_AUTOSAVE_INTERVAL

    @property
    def enabled(self):
        """
        Get or set whether autosave component is enabled.

        The setter will start or stop the autosave component if appropriate.
        """
        return self._enabled

    @enabled.setter
    def enabled(self, new_enabled):
        if new_enabled == self.enabled:
            return
        self.stop_autosave_timer()
        self._enabled = new_enabled
        self.start_autosave_timer()

    @property
    def interval(self):
        """
        Interval between two autosaves, in milliseconds.

        The setter will perform an autosave if the interval is changed and
        autosave is enabled.
        """
        return self._interval

    @interval.setter
    def interval(self, new_interval):
        if new_interval == self.interval:
            return
        self.stop_autosave_timer()
        self._interval = new_interval
        if self.enabled:
            self.do_autosave()

    def start_autosave_timer(self):
        """
        Start a timer which calls do_autosave() after `self.interval`.

        The autosave timer is only started if autosave is enabled.
        """
        if self.enabled:
            self.timer.start(self.interval)

    def stop_autosave_timer(self):
        """Stop the autosave timer."""
        self.timer.stop()

    def do_autosave(self):
        """Instruct current editorstack to autosave files where necessary."""
        logger.debug('Autosave triggered')
        stack = self.editor.get_current_editorstack()
        stack.autosave.autosave_all()
        self.start_autosave_timer()

    def get_files_to_recover(self):
        """
        Get list of files to recover from pid files in autosave dir.

        This returns a tuple `(files_to_recover, pid_files)`. In this tuple,
        `files_to_recover` is a list of tuples containing the original file
        names and the corresponding autosave file names, as recorded in the
        pid files in the autosave directory. Any files in the autosave
        directory which are not listed in a pid file, are also included, with
        the original file name set to `None`. The second entry, `pid_files`,
        is a list with the names of the pid files.
        """
        autosave_dir = get_conf_path('autosave')
        if not os.access(autosave_dir, os.R_OK):
            return [], []

        files_to_recover = []
        files_mentioned = []
        pid_files = []
        non_pid_files = []

        # In Python 3, easier to use os.scandir()
        for name in os.listdir(autosave_dir):
            full_name = osp.join(autosave_dir, name)
            match = re.match(r'pid([0-9]*)\.txt\Z', name)
            if match:
                pid_files.append(full_name)
                logger.debug('Reading pid file: {}'.format(full_name))
                with open(full_name) as pidfile:
                    txt = pidfile.read()
                    try:
                        txt_as_dict = ast.literal_eval(txt)
                    except (SyntaxError, ValueError):
                        # Pid file got corrupted, see spyder-ide/spyder#11375
                        logger.error('Error parsing pid file {}'
                                     .format(full_name))
                        logger.error('Contents: {}'.format(repr(txt)))
                        txt_as_dict = {}
                    files_mentioned += [autosave for (orig, autosave)
                                        in txt_as_dict.items()]
                pid = int(match.group(1))
                if is_spyder_process(pid):
                    logger.debug('Ignoring files in {}'.format(full_name))
                else:
                    files_to_recover += list(txt_as_dict.items())
            else:
                non_pid_files.append(full_name)

        # Add all files not mentioned in any pid file. This can only happen if
        # the pid file somehow got corrupted.
        for filename in set(non_pid_files) - set(files_mentioned):
            files_to_recover.append((None, filename))
            logger.debug('Added unmentioned file: {}'.format(filename))

        return files_to_recover, pid_files

    def try_recover_from_autosave(self):
        """
        Offer to recover files from autosave.

        Read pid files to get a list of files that can possibly be recovered,
        then ask the user what to do with these files, and finally remove
        the pid files.
        """
        files_to_recover, pidfiles = self.get_files_to_recover()
        parent = (
            self.editor if running_under_pytest() else
            self.editor._get_mainwindow()
        )
        dialog = RecoveryDialog(files_to_recover, parent=parent)
        dialog.exec_if_nonempty()
        self.recover_files_to_open = dialog.files_to_open[:]
        for pidfile in pidfiles:
            try:
                os.remove(pidfile)
            except (IOError, OSError):
                pass

    def register_autosave_for_stack(self, autosave_for_stack):
        """
        Register an AutosaveForStack object.

        This replaces the `name_mapping` and `file_hashes` attributes
        in `autosave_for_stack` with references to the corresponding
        attributes of `self`, so that all AutosaveForStack objects
        share the same data.
        """
        autosave_for_stack.name_mapping = self.name_mapping
        autosave_for_stack.file_hashes = self.file_hashes


class AutosaveForStack(object):
    """
    Component of EditorStack implementing autosave functionality.

    In Spyder, the `name_mapping` and `file_hashes` are set to references to
    the corresponding variables in `AutosaveForPlugin`.

    Attributes:
        stack (EditorStack): editor stack this component belongs to.
        name_mapping (dict): map between names of opened and autosave files.
        file_hashes (dict): map between file names and hash of their contents.
            This is used for both files opened in the editor and their
            corresponding autosave files.
    """

    def __init__(self, editorstack):
        """
        Constructor.

        Args:
            editorstack (EditorStack): editor stack this component belongs to.
        """
        self.stack = editorstack
        self.name_mapping = {}
        self.file_hashes = {}

    def create_unique_autosave_filename(self, filename, autosave_dir):
        """
        Create unique autosave file name for specified file name.

        The created autosave file name does not yet exist either in
        `self.name_mapping` or on disk.

        Args:
            filename (str): original file name
            autosave_dir (str): directory in which autosave files are stored
        """
        basename = osp.basename(filename)
        autosave_filename = osp.join(autosave_dir, basename)
        if (autosave_filename in self.name_mapping.values()
                or osp.exists(autosave_filename)):
            counter = 0
            root, ext = osp.splitext(basename)
            while (autosave_filename in self.name_mapping.values()
                   or osp.exists(autosave_filename)):
                counter += 1
                autosave_basename = '{}-{}{}'.format(root, counter, ext)
                autosave_filename = osp.join(autosave_dir, autosave_basename)
        return autosave_filename

    def save_autosave_mapping(self):
        """
        Writes current autosave mapping to a pidNNN.txt file.

        This function should be called after updating `self.autosave_mapping`.
        The NNN in the file name is the pid of the Spyder process. If the
        current autosave mapping is empty, then delete the file if it exists.
        """
        autosave_dir = get_conf_path('autosave')
        my_pid = os.getpid()
        pidfile_name = osp.join(autosave_dir, 'pid{}.txt'.format(my_pid))
        if self.name_mapping:
            with open(pidfile_name, 'w') as pidfile:
                pidfile.write(ascii(self.name_mapping))
        else:
            try:
                os.remove(pidfile_name)
            except (IOError, OSError):
                pass

    def remove_autosave_file(self, filename):
        """
        Remove autosave file for specified file.

        This function also updates `self.name_mapping` and `self.file_hashes`.
        If there is no autosave file, then the function returns without doing
        anything.
        """
        if filename not in self.name_mapping:
            return
        autosave_filename = self.name_mapping[filename]
        try:
            os.remove(autosave_filename)
        except (FileNotFoundError, OSError) as error:
            action = (_('Error while removing autosave file {}')
                      .format(autosave_filename))
            msgbox = AutosaveErrorDialog(action, error)
            msgbox.exec_if_enabled()
        del self.name_mapping[filename]

        # This is necessary to catch an error when a file is changed externally
        # but it's left unsaved in Spyder.
        # Fixes spyder-ide/spyder#19283
        try:
            del self.file_hashes[autosave_filename]
        except KeyError:
            pass

        self.save_autosave_mapping()
        logger.debug('Removing autosave file %s', autosave_filename)

    def get_autosave_filename(self, filename):
        """
        Get name of autosave file for specified file name.

        This function uses the dict in `self.name_mapping`. If `filename` is
        in the mapping, then return the corresponding autosave file name.
        Otherwise, construct a unique file name and update the mapping.

        Args:
            filename (str): original file name
        """
        try:
            autosave_filename = self.name_mapping[filename]
        except KeyError:
            autosave_dir = get_conf_path('autosave')
            if not osp.isdir(autosave_dir):
                try:
                    os.mkdir(autosave_dir)
                except (PermissionError, OSError) as error:
                    action = _('Error while creating autosave directory')
                    msgbox = AutosaveErrorDialog(action, error)
                    msgbox.exec_if_enabled()
            autosave_filename = self.create_unique_autosave_filename(
                    filename, autosave_dir)
            self.name_mapping[filename] = autosave_filename
            self.save_autosave_mapping()
            logger.debug('New autosave file name')
        return autosave_filename

    def maybe_autosave(self, index):
        """
        Autosave a file if necessary.

        If the file is newly created (and thus not named by the user), do
        nothing.  If the current contents are the same as the autosave file
        (if it exists) or the original file (if no autosave filee exists),
        then do nothing. If the current contents are the same as the file on
        disc, but the autosave file is different, then remove the autosave
        file. In all other cases, autosave the file.

        Args:
            index (int): index into self.stack.data
        """
        finfo = self.stack.data[index]
        if finfo.newly_created:
            return

        orig_filename = finfo.filename
        try:
            orig_hash = self.file_hashes[orig_filename]
        except KeyError:
            # This should not happen, but it does: spyder-ide/spyder#11468
            # In this case, use an impossible value for the hash, so that
            # contents of buffer are considered different from contents of
            # original file.
            logger.debug('KeyError when retrieving hash of %s', orig_filename)
            orig_hash = None

        new_hash = self.stack.compute_hash(finfo)
        if orig_filename in self.name_mapping:
            autosave_filename = self.name_mapping[orig_filename]
            autosave_hash = self.file_hashes[autosave_filename]
            if new_hash != autosave_hash:
                if new_hash == orig_hash:
                    self.remove_autosave_file(orig_filename)
                else:
                    self.autosave(finfo)
        else:
            if new_hash != orig_hash:
                self.autosave(finfo)

    def autosave(self, finfo):
        """
        Autosave a file.

        Save a copy in a file with name `self.get_autosave_filename()` and
        update the cached hash of the autosave file. An error dialog notifies
        the user of any errors raised when saving.

        Args:
            fileinfo (FileInfo): file that is to be autosaved.
        """
        autosave_filename = self.get_autosave_filename(finfo.filename)
        logger.debug('Autosaving %s to %s', finfo.filename, autosave_filename)
        try:
            self.stack._write_to_file(finfo, autosave_filename)
            autosave_hash = self.stack.compute_hash(finfo)
            self.file_hashes[autosave_filename] = autosave_hash
        except (PermissionError, OSError) as error:
            action = (_('Error while autosaving {} to {}')
                      .format(finfo.filename, autosave_filename))
            msgbox = AutosaveErrorDialog(action, error)
            msgbox.exec_if_enabled()

    def autosave_all(self):
        """Autosave all opened files where necessary."""
        for index in range(self.stack.get_stack_count()):
            self.maybe_autosave(index)

    def file_renamed(self, old_name, new_name):
        """
        Update autosave files after a file is renamed.

        Args:
            old_name (str): name of file before it is renamed
            new_name (str): name of file after it is renamed
        """
        try:
            old_hash = self.file_hashes[old_name]
        except KeyError:
            # This should not happen, but it does: spyder-ide/spyder#12396
            logger.debug('KeyError when handling rename %s -> %s',
                         old_name, new_name)
            old_hash = None
        self.remove_autosave_file(old_name)
        if old_hash is not None:
            del self.file_hashes[old_name]
            self.file_hashes[new_name] = old_hash
        index = self.stack.has_filename(new_name)
        self.maybe_autosave(index)
