import threading
import os
import functools

import sublime

from .show_error import show_error
from .console_write import console_write
from .unicode import unicode_from_os
from .clear_directory import clear_directory, unlink_or_delete_directory, clean_old_files
from .automatic_upgrader import AutomaticUpgrader
from .package_manager import PackageManager
from .open_compat import open_compat
from .package_io import package_file_exists
from .settings import preferences_filename, pc_settings_filename, load_list_setting, save_list_setting
from . import loader, text, __version__
from .providers.release_selector import is_compatible_version


class PackageCleanup(threading.Thread):

    """
    Cleans up folders for packages that were removed, but that still have files
    in use.
    """

    def __init__(self):
        self.manager = PackageManager()

        settings = sublime.load_settings(pc_settings_filename())

        # We no longer use the installed_dependencies setting because it is not
        # necessary and created issues with settings shared across operating systems
        if settings.get('installed_dependencies'):
            settings.erase('installed_dependencies')
            sublime.save_settings(pc_settings_filename())

        self.original_installed_packages = load_list_setting(settings, 'installed_packages')
        self.remove_orphaned = settings.get('remove_orphaned', True)

        threading.Thread.__init__(self)

    def run(self):
        # This song and dance is necessary so Package Control doesn't try to clean
        # itself up, but also get properly marked as installed in the settings
        installed_packages_at_start = list(self.original_installed_packages)

        # Ensure we record the installation of Package Control itself
        if 'Package Control' not in installed_packages_at_start:
            params = {
                'package': 'Package Control',
                'operation': 'install',
                'version': __version__
            }
            self.manager.record_usage(params)
            installed_packages_at_start.append('Package Control')

        found_packages = []
        installed_packages = list(installed_packages_at_start)

        found_dependencies = []
        installed_dependencies = self.manager.list_dependencies()

        # We scan the Installed Packages folder in ST3 before we check for
        # dependencies since some dependencies might be specified by a
        # .sublime-package-new that has not yet finished being installed.
        if int(sublime.version()) >= 3000:
            installed_path = sublime.installed_packages_path()

            for file in os.listdir(installed_path):
                # If there is a package file ending in .sublime-package-new, it
                # means that the .sublime-package file was locked when we tried
                # to upgrade, so the package was left in ignored_packages and
                # the user was prompted to restart Sublime Text. Now that the
                # package is not loaded, we can replace the old version with the
                # new one.
                if file[-20:] == '.sublime-package-new' and file != loader.loader_package_name + '.sublime-package-new':
                    package_name = file.replace('.sublime-package-new', '')
                    package_file = os.path.join(installed_path, package_name + '.sublime-package')
                    if os.path.exists(package_file):
                        os.remove(package_file)
                    os.rename(os.path.join(installed_path, file), package_file)
                    console_write(
                        u'''
                        Finished replacing %s.sublime-package
                        ''',
                        package_name
                    )
                    continue

                if file[-16:] != '.sublime-package':
                    continue

                package_name = file.replace('.sublime-package', '')

                if package_name == loader.loader_package_name:
                    found_dependencies.append(package_name)
                    continue

                # Cleanup packages that were installed via Package Control, but
                # we removed from the "installed_packages" list - usually by
                # removing them from another computer and the settings file
                # being synced.
                if self.remove_orphaned and package_name not in installed_packages_at_start \
                        and package_file_exists(package_name, 'package-metadata.json'):
                    # Since Windows locks the .sublime-package files, we must
                    # do a dance where we disable the package first, which has
                    # to be done in the main Sublime Text thread.
                    package_filename = os.path.join(installed_path, file)

                    # We use a functools.partial to generate the on-complete callback in
                    # order to bind the current value of the parameters, unlike lambdas.
                    sublime.set_timeout(functools.partial(self.remove_package_file, package_name, package_filename), 10)

                else:
                    found_packages.append(package_name)

        required_dependencies = set(self.manager.find_required_dependencies())
        extra_dependencies = list(set(installed_dependencies) - required_dependencies)

        # Clean up unneeded dependencies so that found_dependencies will only
        # end up having required dependencies added to it
        for dependency in extra_dependencies:
            dependency_dir = os.path.join(sublime.packages_path(), dependency)
            if unlink_or_delete_directory(dependency_dir):
                console_write(
                    u'''
                    Removed directory for unneeded dependency %s
                    ''',
                    dependency
                )
            else:
                cleanup_file = os.path.join(dependency_dir, 'package-control.cleanup')
                if not os.path.exists(cleanup_file):
                    open_compat(cleanup_file, 'w').close()
                console_write(
                    u'''
                    Unable to remove directory for unneeded dependency %s -
                    deferring until next start
                    ''',
                    dependency
                )
            # Make sure when cleaning up the dependency files that we remove the loader for it also
            loader.remove(dependency)

        for package_name in os.listdir(sublime.packages_path()):
            found = True

            package_dir = os.path.join(sublime.packages_path(), package_name)
            if not os.path.isdir(package_dir):
                continue

            clean_old_files(package_dir)

            # Cleanup packages/dependencies that could not be removed due to in-use files
            cleanup_file = os.path.join(package_dir, 'package-control.cleanup')
            if os.path.exists(cleanup_file):
                if unlink_or_delete_directory(package_dir):
                    console_write(
                        u'''
                        Removed old directory %s
                        ''',
                        package_name
                    )
                    found = False
                else:
                    if not os.path.exists(cleanup_file):
                        open_compat(cleanup_file, 'w').close()
                    console_write(
                        u'''
                        Unable to remove old directory %s - deferring until next
                        start
                        ''',
                        package_name
                    )

            # Finish reinstalling packages that could not be upgraded due to
            # in-use files
            reinstall = os.path.join(package_dir, 'package-control.reinstall')
            if os.path.exists(reinstall):
                metadata_path = os.path.join(package_dir, 'package-metadata.json')
                # No need to handle symlinks here as that was already handled in earlier step
                # that has attempted to re-install the package initially.
                if not clear_directory(package_dir, [metadata_path]):
                    if not os.path.exists(reinstall):
                        open_compat(reinstall, 'w').close()

                    def show_still_locked(package_name):
                        show_error(
                            u'''
                            An error occurred while trying to finish the upgrade of
                            %s. You will most likely need to restart your computer
                            to complete the upgrade.
                            ''',
                            package_name
                        )
                    # We use a functools.partial to generate the on-complete callback in
                    # order to bind the current value of the parameters, unlike lambdas.
                    sublime.set_timeout(functools.partial(show_still_locked, package_name), 10)
                else:
                    self.manager.install_package(package_name)

            if package_file_exists(package_name, 'package-metadata.json'):
                # This adds previously installed packages from old versions of
                # PC. As of PC 3.0, this should basically never actually be used
                # since installed_packages was added in late 2011.
                if not installed_packages_at_start:
                    installed_packages.append(package_name)
                    params = {
                        'package': package_name,
                        'operation': 'install',
                        'version':
                            self.manager.get_metadata(package_name).get('version')
                    }
                    self.manager.record_usage(params)

                # Cleanup packages that were installed via Package Control, but
                # we removed from the "installed_packages" list - usually by
                # removing them from another computer and the settings file
                # being synced.
                elif self.remove_orphaned and package_name not in installed_packages_at_start:
                    self.manager.backup_package_dir(package_name)
                    if unlink_or_delete_directory(package_dir):
                        console_write(
                            u'''
                            Removed directory for orphaned package %s
                            ''',
                            package_name
                        )
                        found = False
                    else:
                        if not os.path.exists(cleanup_file):
                            open_compat(cleanup_file, 'w').close()
                        console_write(
                            u'''
                            Unable to remove directory for orphaned package %s -
                            deferring until next start
                            ''',
                            package_name
                        )

            if package_name[-20:] == '.package-control-old':
                console_write(
                    u'''
                    Removed old directory %s
                    ''',
                    package_name
                )
                unlink_or_delete_directory(package_dir)

            # Skip over dependencies since we handle them separately
            if (package_file_exists(package_name, 'dependency-metadata.json')
                    or package_file_exists(package_name, '.sublime-dependency')) \
                    and (package_name == loader.loader_package_name or loader.exists(package_name)):
                found_dependencies.append(package_name)
                continue

            if found:
                found_packages.append(package_name)

        invalid_packages = []
        invalid_dependencies = []

        # Check metadata to verify packages were not improperly installed
        for package in found_packages:
            if package == 'User':
                continue

            metadata = self.manager.get_metadata(package)
            if metadata and not self.is_compatible(metadata):
                invalid_packages.append(package)

        # Make sure installed dependencies are not improperly installed
        for dependency in found_dependencies:
            metadata = self.manager.get_metadata(dependency, is_dependency=True)
            if metadata and not self.is_compatible(metadata):
                invalid_dependencies.append(dependency)

        if invalid_packages or invalid_dependencies:
            def show_sync_error():
                message = u''
                if invalid_packages:
                    package_s = 's were' if len(invalid_packages) != 1 else ' was'
                    message += text.format(
                        u'''
                        The following incompatible package%s found installed:

                        %s

                        ''',
                        (package_s, '\n'.join(invalid_packages))
                    )
                if invalid_dependencies:
                    dependency_s = 'ies were' if len(invalid_dependencies) != 1 else 'y was'
                    message += text.format(
                        u'''
                        The following incompatible dependenc%s found installed:

                        %s

                        ''',
                        (dependency_s, '\n'.join(invalid_dependencies))
                    )
                message += text.format(
                    u'''
                    This is usually due to syncing packages across different
                    machines in a way that does not check package metadata for
                    compatibility.

                    Please visit https://packagecontrol.io/docs/syncing for
                    information about how to properly sync configuration and
                    packages across machines.

                    To restore package functionality, please remove each listed
                    package and reinstall it.
                    '''
                )
                show_error(message)
            sublime.set_timeout(show_sync_error, 100)

        sublime.set_timeout(lambda: self.finish(installed_packages, found_packages, found_dependencies), 10)

    def remove_package_file(self, name, filename):
        """
        On Windows, .sublime-package files are locked when imported, so we must
        disable the package, delete it and then re-enable the package.

        :param name:
            The name of the package

        :param filename:
            The filename of the package
        """

        def do_remove():
            try:
                os.remove(filename)
                console_write(
                    u'''
                    Removed orphaned package %s
                    ''',
                    name
                )

            except (OSError) as e:
                console_write(
                    u'''
                    Unable to remove orphaned package %s - deferring until
                    next start: %s
                    ''',
                    (name, unicode_from_os(e))
                )

            finally:
                # Always re-enable the package so it doesn't get stuck
                pref_filename = preferences_filename()
                settings = sublime.load_settings(pref_filename)
                ignored = load_list_setting(settings, 'ignored_packages')
                new_ignored = list(ignored)
                try:
                    new_ignored.remove(name)
                except (ValueError):
                    pass
                save_list_setting(settings, pref_filename, 'ignored_packages', new_ignored, ignored)

        # Disable the package so any filesystem locks are released
        pref_filename = preferences_filename()
        settings = sublime.load_settings(pref_filename)
        ignored = load_list_setting(settings, 'ignored_packages')
        new_ignored = list(ignored)
        new_ignored.append(name)
        save_list_setting(settings, pref_filename, 'ignored_packages', new_ignored, ignored)

        sublime.set_timeout(do_remove, 700)

    def is_compatible(self, metadata):
        """
        Detects if a package is compatible with the current Sublime Text install

        :param metadata:
            A dict from a metadata file

        :return:
            If the package is compatible
        """

        sublime_text = metadata.get('sublime_text')
        platforms = metadata.get('platforms', [])

        # This indicates the metadata is old, so we assume a match
        if not sublime_text and not platforms:
            return True

        if not is_compatible_version(sublime_text):
            return False

        if not isinstance(platforms, list):
            platforms = [platforms]

        platform_selectors = [
            sublime.platform() + '-' + sublime.arch(),
            sublime.platform(),
            '*'
        ]

        for selector in platform_selectors:
            if selector in platforms:
                return True

        return False

    def finish(self, installed_packages, found_packages, found_dependencies):
        """
        A callback that can be run the main UI thread to perform saving of the
        Package Control.sublime-settings file. Also fires off the
        :class:`AutomaticUpgrader`.

        :param installed_packages:
            A list of the string package names of all "installed" packages,
            even ones that do not appear to be in the filesystem.

        :param found_packages:
            A list of the string package names of all packages that are
            currently installed on the filesystem.

        :param found_dependencies:
            A list of the string package names of all dependencies that are
            currently installed on the filesystem.
        """

        # Make sure we didn't accidentally ignore packages because something
        # was interrupted before it completed.
        pc_filename = pc_settings_filename()
        pc_settings = sublime.load_settings(pc_filename)

        in_process = load_list_setting(pc_settings, 'in_process_packages')
        if in_process:
            filename = preferences_filename()
            settings = sublime.load_settings(filename)

            ignored = load_list_setting(settings, 'ignored_packages')
            new_ignored = list(ignored)
            for package in in_process:
                if package in new_ignored:
                    # This prevents removing unused dependencies from being messed up by
                    # the functionality to re-enable packages that were left disabled
                    # by an error.
                    if loader.loader_package_name == package and loader.is_swapping():
                        continue
                    console_write(
                        u'''
                        The package %s is being re-enabled after a Package
                        Control operation was interrupted
                        ''',
                        package
                    )
                    new_ignored.remove(package)

            save_list_setting(settings, filename, 'ignored_packages', new_ignored, ignored)
            save_list_setting(pc_settings, pc_filename, 'in_process_packages', [])

        save_list_setting(
            pc_settings,
            pc_filename,
            'installed_packages',
            installed_packages,
            self.original_installed_packages
        )
        AutomaticUpgrader(found_packages, found_dependencies).start()
