import re
import time
from datetime import datetime
from threading import Thread
from typing import List, Set, Type

from bauh.api.abstract.controller import SoftwareManager, SearchResult, ApplicationContext, UpgradeRequirements, \
    TransactionResult
from bauh.api.abstract.disk import DiskCacheLoader
from bauh.api.abstract.handler import ProcessWatcher, TaskManager
from bauh.api.abstract.model import SoftwarePackage, PackageHistory, PackageUpdate, PackageSuggestion, \
    SuggestionPriority, CustomSoftwareAction
from bauh.api.abstract.view import SingleSelectComponent, SelectViewType, InputOption
from bauh.commons import resource, internet
from bauh.commons.category import CategoriesDownloader
from bauh.commons.html import bold
from bauh.commons.system import SystemProcess, ProcessHandler, new_root_subprocess
from bauh.gems.snap import snap, URL_CATEGORIES_FILE, SNAP_CACHE_PATH, CATEGORIES_FILE_PATH, SUGGESTIONS_FILE, \
    get_icon_path
from bauh.gems.snap.constants import SNAP_API_URL
from bauh.gems.snap.model import SnapApplication
from bauh.gems.snap.worker import SnapAsyncDataLoader

RE_AVAILABLE_CHANNELS = re.compile(re.compile(r'(\w+)\s+(snap install.+)'))


class SnapManager(SoftwareManager):

    def __init__(self, context: ApplicationContext):
        super(SnapManager, self).__init__(context=context)
        self.i18n = context.i18n
        self.api_cache = context.cache_factory.new()
        context.disk_loader_factory.map(SnapApplication, self.api_cache)
        self.enabled = True
        self.http_client = context.http_client
        self.logger = context.logger
        self.ubuntu_distro = context.distro == 'ubuntu'
        self.categories = {}
        self.suggestions_cache = context.cache_factory.new()
        self.info_path = None
        self.custom_actions = [
            CustomSoftwareAction(i18n_status_key='snap.action.refresh.status',
                                 i18_label_key='snap.action.refresh.label',
                                 icon_path=resource.get_path('img/refresh.svg', context.get_view_path()),
                                 manager_method='refresh',
                                 requires_root=True)
        ]

    def get_info_path(self) -> str:
        if self.info_path is None:
            self.info_path = snap.get_app_info_path()

        return self.info_path

    def map_json(self, app_json: dict, installed: bool,  disk_loader: DiskCacheLoader, internet: bool = True) -> SnapApplication:
        app = SnapApplication(publisher=app_json.get('publisher'),
                              rev=app_json.get('rev'),
                              notes=app_json.get('notes'),
                              has_apps_field=app_json.get('apps_field', False),
                              id=app_json.get('name'),
                              name=app_json.get('name'),
                              version=app_json.get('version'),
                              latest_version=app_json.get('version'),
                              description=app_json.get('description', app_json.get('summary')),
                              verified_publisher=app_json.get('developer_validation', '') == 'verified',
                              extra_actions=self.custom_actions)

        if app.publisher and app.publisher.endswith('*'):
            app.verified_publisher = True
            app.publisher = app.publisher.replace('*', '')

        categories = self.categories.get(app.name.lower())

        if categories:
            app.categories = categories

        app.installed = installed

        if not app.is_application():
            categories = app.categories

            if categories is None:
                categories = []
                app.categories = categories

            if 'runtime' not in categories:
                categories.append('runtime')

        api_data = self.api_cache.get(app_json['name'])
        expired_data = api_data and api_data.get('expires_at') and api_data['expires_at'] <= datetime.utcnow()

        if (not api_data or expired_data) and app.is_application():
            if disk_loader and app.installed:
                disk_loader.fill(app)

            if internet:
                SnapAsyncDataLoader(app=app, api_cache=self.api_cache, manager=self, context=self.context).start()
        else:
            app.fill_cached_data(api_data)

        return app

    def search(self, words: str, disk_loader: DiskCacheLoader, limit: int = -1, is_url: bool = False) -> SearchResult:
        if is_url:
            return SearchResult([], [], 0)

        if snap.is_snapd_running():
            installed = self.read_installed(disk_loader).installed

            res = SearchResult([], [], 0)

            for app_json in snap.search(words):

                already_installed = None

                if installed:
                    already_installed = [i for i in installed if i.id == app_json.get('name')]
                    already_installed = already_installed[0] if already_installed else None

                if already_installed:
                    res.installed.append(already_installed)
                else:
                    res.new.append(self.map_json(app_json, installed=False, disk_loader=disk_loader))

            res.total = len(res.installed) + len(res.new)
            return res
        else:
            return SearchResult([], [], 0)

    def read_installed(self, disk_loader: DiskCacheLoader, limit: int = -1, only_apps: bool = False, pkg_types: Set[Type[SoftwarePackage]] = None, internet_available: bool = None) -> SearchResult:
        info_path = self.get_info_path()

        if snap.is_snapd_running() and info_path:
            installed = [self.map_json(app_json, installed=True, disk_loader=disk_loader, internet=internet_available) for app_json in snap.read_installed(info_path)]
            return SearchResult(installed, None, len(installed))
        else:
            return SearchResult([], None, 0)

    def downgrade(self, pkg: SnapApplication, root_password: str, watcher: ProcessWatcher) -> bool:
        return ProcessHandler(watcher).handle(SystemProcess(subproc=snap.downgrade_and_stream(pkg.name, root_password), wrong_error_phrase=None))

    def upgrade(self, requirements: UpgradeRequirements, root_password: str, watcher: ProcessWatcher) -> SystemProcess:
        raise Exception("'upgrade' is not supported by {}".format(SnapManager.__class__.__name__))

    def uninstall(self, pkg: SnapApplication, root_password: str, watcher: ProcessWatcher, disk_loader: DiskCacheLoader) -> TransactionResult:
        uninstalled = ProcessHandler(watcher).handle(SystemProcess(subproc=snap.uninstall_and_stream(pkg.name, root_password)))

        if uninstalled:
            if self.suggestions_cache:
                self.suggestions_cache.delete(pkg.name)

            return TransactionResult(success=True, installed=None, removed=[pkg])

        return TransactionResult.fail()

    def get_managed_types(self) -> Set[Type[SoftwarePackage]]:
        return {SnapApplication}

    def clean_cache_for(self, pkg: SnapApplication):
        super(SnapManager, self).clean_cache_for(pkg)
        self.api_cache.delete(pkg.id)

    def get_info(self, pkg: SnapApplication) -> dict:
        info = snap.get_info(pkg.name, attrs=('license', 'contact', 'commands', 'snap-id', 'tracking', 'installed'))
        info['description'] = pkg.description
        info['publisher'] = pkg.publisher
        info['revision'] = pkg.rev
        info['name'] = pkg.name

        if info.get('commands'):
            info['commands'] = ' '.join(info['commands'])

        if info.get('license') and info['license'] == 'unset':
            del info['license']

        return info

    def get_history(self, pkg: SnapApplication) -> PackageHistory:
        raise Exception("'get_history' is not supported by {}".format(pkg.__class__.__name__))

    def install(self, pkg: SnapApplication, root_password: str, disk_loader: DiskCacheLoader, watcher: ProcessWatcher) -> TransactionResult:
        info_path = self.get_info_path()

        if not info_path:
            self.logger.warning('Information directory was not found. It will not be possible to determine if the installed application can be launched')

        # retrieving all installed so it will be possible to know the additional installed runtimes after the operation succeeds
        installed_names = snap.list_installed_names()

        res, output = ProcessHandler(watcher).handle_simple(snap.install_and_stream(pkg.name, pkg.confinement, root_password))

        if 'error:' in output:
            res = False
            if 'not available on stable' in output:
                channels = RE_AVAILABLE_CHANNELS.findall(output)

                if channels:
                    opts = [InputOption(label=c[0], value=c[1]) for c in channels]
                    channel_select = SingleSelectComponent(type_=SelectViewType.RADIO, label='', options=opts, default_option=opts[0])
                    body = '<p>{}.</p>'.format(self.i18n['snap.install.available_channels.message'].format(bold(self.i18n['stable']), bold(pkg.name)))
                    body += '<p>{}:</p>'.format(self.i18n['snap.install.available_channels.help'])

                    if watcher.request_confirmation(title=self.i18n['snap.install.available_channels.title'],
                                                    body=body,
                                                    components=[channel_select],
                                                    confirmation_label=self.i18n['continue'],
                                                    deny_label=self.i18n['cancel']):
                        self.logger.info("Installing '{}' with the custom command '{}'".format(pkg.name, channel_select.value))
                        res = ProcessHandler(watcher).handle(SystemProcess(new_root_subprocess(channel_select.value.value.split(' '), root_password=root_password)))

                        if res and info_path:
                            pkg.has_apps_field = snap.has_apps_field(pkg.name, info_path)

                        return self._gen_installation_response(success=res, pkg=pkg,
                                                               installed=installed_names, disk_loader=disk_loader)
                else:
                    self.logger.error("Could not find available channels in the installation output: {}".format(output))
        else:
            if info_path:
                pkg.has_apps_field = snap.has_apps_field(pkg.name, info_path)

        return self._gen_installation_response(success=res, pkg=pkg, installed=installed_names, disk_loader=disk_loader)

    def _gen_installation_response(self, success: bool, pkg: SnapApplication, installed: Set[str], disk_loader: DiskCacheLoader):
        if success:
            new_installed = [pkg]

            if installed:
                try:
                    current_installed = self.read_installed(disk_loader=disk_loader, internet_available=internet.is_available()).installed
                except:
                    current_installed = None

                if current_installed and (not installed or len(current_installed) > len(installed) + 1):
                    for p in current_installed:
                        if p.name != pkg.name and (not installed or p.name not in installed):
                            new_installed.append(p)

            return TransactionResult(success=success, installed=new_installed, removed=[])
        else:
            return TransactionResult.fail()

    def is_enabled(self) -> bool:
        return self.enabled

    def set_enabled(self, enabled: bool):
        self.enabled = enabled

    def can_work(self) -> bool:
        return snap.is_installed()

    def requires_root(self, action: str, pkg: SnapApplication):
        return action not in ('search', 'prepare')

    def refresh(self, pkg: SnapApplication, root_password: str, watcher: ProcessWatcher) -> bool:
        return ProcessHandler(watcher).handle(SystemProcess(subproc=snap.refresh_and_stream(pkg.name, root_password)))

    def _start_category_task(self, task_man: TaskManager):
        if task_man:
            task_man.register_task('snap_cats', self.i18n['task.download_categories'].format('Snap'), get_icon_path())
            task_man.update_progress('snap_cats', 50, None)

    def _finish_category_task(self, task_man: TaskManager):
        if task_man:
            task_man.update_progress('snap_cats', 100, None)
            task_man.finish_task('snap_cats')

    def prepare(self, task_manager: TaskManager, root_password: str, internet_available: bool):
        CategoriesDownloader(id_='snap', manager=self, http_client=self.http_client, logger=self.logger,
                             url_categories_file=URL_CATEGORIES_FILE, disk_cache_dir=SNAP_CACHE_PATH,
                             categories_path=CATEGORIES_FILE_PATH,
                             before=lambda: self._start_category_task(task_manager),
                             after=lambda: self._finish_category_task(task_manager)).start()

    def list_updates(self, internet_available: bool) -> List[PackageUpdate]:
        pass

    def list_warnings(self, internet_available: bool) -> List[str]:
        if snap.is_installed():
            if not snap.is_snapd_running():
                snap_bold = bold('Snap')
                return [self.i18n['snap.notification.snapd_unavailable'].format(bold('snapd'), snap_bold),
                        self.i18n['snap.notification.snap.disable'].format(snap_bold, bold('{} > {}'.format(self.i18n['settings'].capitalize(),
                                                                                                            self.i18n['core.config.tab.types'])))]

            elif internet_available:
                available, output = snap.is_api_available()

                if not available:
                    self.logger.warning('It seems Snap API is not available. Search output: {}'.format(output))
                    return [self.i18n['snap.notifications.api.unavailable'].format(bold('Snaps'), bold('Snap'))]

    def _fill_suggestion(self, pkg_name: str, priority: SuggestionPriority, out: List[PackageSuggestion]):
        res = self.http_client.get_json(SNAP_API_URL + '/search?q=package_name:{}'.format(pkg_name))

        if res and res['_embedded']['clickindex:package']:
            pkg = res['_embedded']['clickindex:package'][0]
            pkg['rev'] = pkg['revision']
            pkg['name'] = pkg_name

            sug = PackageSuggestion(self.map_json(pkg, installed=False, disk_loader=None), priority)
            self.suggestions_cache.add(pkg_name, sug)
            out.append(sug)
        else:
            self.logger.warning("Could not retrieve suggestion '{}'".format(pkg_name))

    def list_suggestions(self, limit: int, filter_installed: bool) -> List[PackageSuggestion]:
        res = []

        if snap.is_snapd_running():
            self.logger.info('Downloading suggestions file {}'.format(SUGGESTIONS_FILE))
            file = self.http_client.get(SUGGESTIONS_FILE)

            if not file or not file.text:
                self.logger.warning("No suggestion found in {}".format(SUGGESTIONS_FILE))
                return res
            else:
                self.logger.info('Mapping suggestions')

                suggestions, threads = [], []
                installed = {i.name.lower() for i in self.read_installed(disk_loader=None).installed} if filter_installed else None

                for l in file.text.split('\n'):
                    if l:
                        if limit <= 0 or len(suggestions) < limit:
                            sug = l.strip().split('=')
                            name = sug[1]

                            if not installed or name not in installed:
                                cached_sug = self.suggestions_cache.get(name)

                                if cached_sug:
                                    res.append(cached_sug)
                                else:
                                    t = Thread(target=self._fill_suggestion, args=(name, SuggestionPriority(int(sug[0])), res))
                                    t.start()
                                    threads.append(t)
                                    time.sleep(0.001)  # to avoid being blocked
                        else:
                            break

                for t in threads:
                    t.join()

                res.sort(key=lambda s: s.priority.value, reverse=True)
        return res

    def is_default_enabled(self) -> bool:
        return True

    def launch(self, pkg: SnapApplication):
        snap.run(pkg, self.context.logger)

    def get_screenshots(self, pkg: SoftwarePackage) -> List[str]:
        res = self.http_client.get_json('{}/search?q={}'.format(SNAP_API_URL, pkg.name))

        if res:
            if res.get('_embedded') and res['_embedded'].get('clickindex:package'):
                snap_data = res['_embedded']['clickindex:package'][0]

                if snap_data.get('screenshot_urls'):
                    return snap_data['screenshot_urls']
                else:
                    self.logger.warning("No 'screenshots_urls' defined for {}".format(pkg))
            else:
                self.logger.error('It seems the API is returning a different response: {}'.format(res))
        else:
            self.logger.warning('Could not retrieve data for {}'.format(pkg))

        return []
