# -*- coding: utf-8 -*-
# -----------------------------------------------------------------------------
# Copyright 2016 Continuum Analytics, Inc.
#
# May be copied and distributed freely only as part of an Anaconda or
# Miniconda installation.
# -----------------------------------------------------------------------------
"""API for using the api (anaconda-client, downloads and conda)."""

# yapf: disable

# Standard library imports
from collections import OrderedDict
import copy
import datetime
import json
import os

# Third party imports
from qtpy.QtCore import QObject, Signal
import _license

# Local imports
from anaconda_navigator.api.client_api import ClientAPI
from anaconda_navigator.api.conda_api import CondaAPI
from anaconda_navigator.api.download_api import DownloadAPI
from anaconda_navigator.api.project_api import ProjectAPI
from anaconda_navigator.config import (CONF, LICENSE_NAME_FOR_PACKAGE,
                                       LICENSE_PATH, PACKAGES_WITH_LICENSE,
                                       REMOVED_LICENSE_PATH,
                                       VALID_PRODUCT_LICENSES)
from anaconda_navigator.static import images


# yapf: enable


class _AnacondaAPI(QObject):
    """Anaconda Manager API process worker."""

    sig_repodata_updated = Signal(object)
    sig_repodata_errored = Signal()

    def __init__(self):
        """Anaconda Manager API process worker."""
        super(_AnacondaAPI, self).__init__()

        # API's
        self.config = CONF
        self._conda_api = CondaAPI()
        self._client_api = ClientAPI()
        self._download_api = DownloadAPI()
        self._project_api = ProjectAPI()
        self.ROOT_PREFIX = self._conda_api.ROOT_PREFIX

        # Vars
        self._checking_repos = None
        self._data_directory = None
        self._files_downloaded = None
        self._repodata_files = None
        self._valid_repos = None

        # Expose some methods for convenient access. Methods return a worker
        self.conda_create = self._conda_api.create
        self.conda_create_yaml = self._conda_api.create_from_yaml
        self.conda_clone = self._conda_api.clone_environment
        self.conda_dependencies = self._conda_api.dependencies
        self.conda_get_condarc_channels = self._conda_api.get_condarc_channels
        self.conda_install = self._conda_api.install
        self.conda_remove = self._conda_api.remove
        self.conda_terminate = self._conda_api.terminate_all_processes
        self.conda_config_add = self._conda_api.config_add
        self.conda_config_set = self._conda_api.config_set
        self.conda_config_remove = self._conda_api.config_remove
        self.pip_list = self._conda_api.pip_list
        self.pip_remove = self._conda_api.pip_remove

        # No workers are returned for these methods
        self.conda_clear_lock = self._conda_api.clear_lock
        self.conda_environment_exists = self._conda_api.environment_exists
        self.conda_get_envs = self._conda_api.get_envs
        self.conda_linked = self._conda_api.linked
        self.conda_linked_apps_info = self._conda_api.linked_apps_info
        self.conda_get_prefix_envname = self._conda_api.get_prefix_envname
        self.conda_package_version = self._conda_api.package_version
        self.conda_platform = self._conda_api.get_platform
        self.conda_load_proxy_config = self._conda_api.load_proxy_config

        self.conda_split_canonical_name = self._conda_api.split_canonical_name

        # These download methods return a worker
        _get_api_info = self._download_api.get_api_info
        _is_valid_url = self._download_api.is_valid_api_url
        _get_api_url = self._client_api.get_api_url
        self.download = self._download_api.download
        self.download_is_valid_url = self._download_api.is_valid_url
        self.download_is_valid_api_url = _is_valid_url
        self.download_get_api_info = lambda: _get_api_info(_get_api_url())
        self.download_is_valid_channel = self._download_api.is_valid_channel
        self.download_terminate = self._download_api.terminate

        # These client methods return a worker
        self.client_store_token = self._client_api.store_token
        self.client_remove_token = self._client_api.remove_token
        self.client_login = self._client_api.login
        self.client_logout = self._client_api.logout
        self.client_load_repodata = self._client_api.load_repodata
        self.client_prepare_packages_data = self._client_api.prepare_model_data
        self.client_user = self._client_api.user
        self.client_domain = self._client_api.domain
        self.client_set_domain = self._client_api.set_domain
        self.client_packages = self._client_api.packages
        self.client_multi_packages = self._client_api.multi_packages
        self.client_organizations = self._client_api.organizations
        self.client_load_token = self._client_api.load_token
        self.client_get_api_url = self._client_api.get_api_url
        self.client_set_api_url = self._client_api.set_api_url
        self.client_get_ssl = self._client_api.get_ssl
        self.client_set_ssl = self._client_api.set_ssl
        self.client_get_user_licenses = self._client_api.get_user_licenses

        # Project calls
        self.project_create = self._project_api.create_project
        self.project_upload = self._project_api.upload
        self.project_load = self._project_api.load_project

        # No workers are returned for these methods
        m = self._client_api.get_logged_user_list_channels
        self.client_get_logged_user_list_channels = m

    # --- Helper methods
    # -------------------------------------------------------------------------
    def _set_repo_urls_from_channels(self, channels):
        """
        Convert a channel into a normalized repo name including.

        Channels are assumed in normalized url form.
        """
        repos = []
        sys_platform = self._conda_api.get_platform()

        # Platform specific channels
        for channel in channels:
            url = '{0}/{1}/repodata.json.bz2'.format(channel, sys_platform)
            repos.append(url)

        # Noarch channels
        for channel in channels:
            url = '{0}/noarch/repodata.json.bz2'.format(channel)
            repos.append(url)

        return repos

    def _check_repos(self, repos):
        """Check if repodata urls are valid."""
        self._checking_repos = []
        self._valid_repos = []

        for repo in repos:
            worker = self.download_is_valid_url(repo)
            worker.sig_finished.connect(self._repos_checked)
            worker.repo = repo
            self._checking_repos.append(repo)

    def _repos_checked(self, worker, output, error):
        """Callback for _check_repos."""
        if worker.repo in self._checking_repos:
            self._checking_repos.remove(worker.repo)

        if output:
            self._valid_repos.append(worker.repo)

        if len(self._checking_repos) == 0:
            self._download_repodata(self._valid_repos)

    def _repo_url_to_path(self, repo):
        """Convert a `repo` url to a file path for local storage."""
        repo = repo.replace('http://', '')
        repo = repo.replace('https://', '')
        repo = repo.replace('/', '_')

        return os.sep.join([self._data_directory, repo])

    def _download_repodata(self, checked_repos):
        """Dowload repodata."""
        self._files_downloaded = []
        self._repodata_files = []
        self.__counter = -1

        if checked_repos:
            for repo in checked_repos:
                path = self._repo_url_to_path(repo)
                self._files_downloaded.append(path)
                self._repodata_files.append(path)
                worker = self.download(
                    repo,
                    path,
                    verify=self._client_api.get_ssl(),
                )
                worker.url = repo
                worker.path = path
                worker.sig_finished.connect(self._repodata_downloaded)
        else:
            # Empty, maybe there is no internet connection
            # Load information from conda-meta and save that file
            path = self._get_repodata_from_meta()
            self._repodata_files = [path]
            self._repodata_downloaded()

    def _get_repodata_from_meta(self):
        """Generate repodata from local meta files."""
        path = os.sep.join([self.ROOT_PREFIX, 'conda-meta'])
        packages = os.listdir(path)
        meta_repodata = {}
        for pkg in packages:
            if pkg.endswith('.json'):
                filepath = os.sep.join([path, pkg])
                with open(filepath, 'r') as f:
                    data = json.load(f)

                if 'files' in data:
                    data.pop('files')
                if 'icondata' in data:
                    data.pop('icondata')

                name = pkg.replace('.json', '')
                meta_repodata[name] = data

        meta_repodata_path = os.sep.join(
            [
                self._data_directory,
                'offline.json',
            ]
        )
        repodata = {'info': [], 'packages': meta_repodata}

        with open(meta_repodata_path, 'w') as f:
            json.dump(
                repodata,
                f,
                sort_keys=True,
                indent=4,
                separators=(',', ': '),
            )

        return meta_repodata_path

    def _repodata_downloaded(self, worker=None, output=None, error=None):
        """Callback for _download_repodata."""
        if worker:
            self._files_downloaded.remove(worker.path)

            if worker.path in self._files_downloaded:
                self._files_downloaded.remove(worker.path)

        if len(self._files_downloaded) == 0:
            self.sig_repodata_updated.emit(list(set(self._repodata_files)))

    # --- Public API
    # -------------------------------------------------------------------------
    def set_data_directory(self, data_directory):
        """Set the directory where repodata and metadata are stored."""
        self._data_directory = data_directory

    def repodata_files(self, channels=None):
        """
        Return the repodata paths based on `channels` and the `data_directory`.

        There is no check for validity here.
        """
        if channels is None:
            channels = self.conda_get_condarc_channels()

        repodata_urls = self._set_repo_urls_from_channels(channels)

        repopaths = []

        for repourl in repodata_urls:
            fullpath = os.sep.join([self._repo_url_to_path(repourl)])
            repopaths.append(fullpath)

        return repopaths

    def update_repodata(self, channels=None):
        """Update repodata from channels or use condarc channels if None."""
        norm_channels = self.conda_get_condarc_channels(
            channels=channels,
            normalize=True,
        )
        repodata_urls = self._set_repo_urls_from_channels(norm_channels)
        self._check_repos(repodata_urls)

    def update_metadata(self):
        """
        Update the metadata available for packages in repo.continuum.io.

        Returns a download worker.
        """
        # TODO: there needs to be an uniform way to query the metadata for
        # both repo and anaconda.org
        if self._data_directory is None:
            raise Exception('Need to call `api.set_data_directory` first.')

        metadata_url = 'https://repo.continuum.io/pkgs/metadata.json'
        filepath = os.sep.join([self._data_directory, 'metadata.json'])
        worker = self.download(metadata_url, filepath)
        return worker

    def check_valid_channel(
        self, channel, conda_url='https://conda.anaconda.org'
    ):
        """Check if channel is valid."""
        if channel.startswith('https://') or channel.startswith('http://'):
            url = channel
        else:
            url = "{0}/{1}".format(conda_url, channel)

        if url[-1] == '/':
            url = url[:-1]
        plat = self.conda_platform()
        repodata_url = "{0}/{1}/{2}".format(url, plat, 'repodata.json')
        worker = self.download_is_valid_url(repodata_url)
        worker.url = url
        return worker

    def process_apps(self, apps, prefix=None):
        """Process app information."""
        applications = {}
        if prefix is None:
            prefix = self.ROOT_PREFIX

        # This checks installed apps in the prefix
        linked_apps = self.conda_linked_apps_info(prefix)
        missing_apps = [app for app in linked_apps if app not in apps]
        for app in missing_apps:
            apps[app] = linked_apps[app]

        # Temporal hardcoded images
        image_paths = {
            'glueviz': images.GLUEVIZ_ICON_1024_PATH,
            'spyder-app': images.SPYDER_ICON_1024_PATH,
            'spyder': images.SPYDER_ICON_1024_PATH,
            'ipython-qtconsole': images.IPYTHON_QTCONSOLE_ICON_1024_PATH,
            'qtconsole': images.IPYTHON_QTCONSOLE_ICON_1024_PATH,
            'ipython-notebook': images.IPYTHON_NOTEBOOK_ICON_1024_PATH,
            'notebook': images.NOTEBOOK_ICON_1024_PATH,
            'orange-app': images.ORANGE_ICON_1024_PATH,
            'orange3': images.ORANGE_ICON_1024_PATH,
            'rodeo': images.RODEO_ICON_1024_PATH,
            'veusz': images.VEUSZ_ICON_1024_PATH,
            'rstudio': images.RSTUDIO_ICON_PATH,
        }

        APPS_DESCRIPTIONS = {
            'glueviz': (
                'Multidimensional data visualization across files. '
                'Explore relationships within and among related '
                'datasets.'
            ),
            'notebook': (
                'Web-based, interactive computing notebook '
                'environment. Edit and run human-readable docs while '
                'describing the data analysis.'
            ),
            'orange-app': (
                'Component based data mining framework. Data '
                'visualization and data analysis for novice and '
                'expert. Interactive workflows with a large '
                'toolbox.'
            ),
            'qtconsole': (
                'PyQt GUI that supports inline figures, proper '
                'multiline editing with syntax highlighting, '
                'graphical calltips, and more.'
            ),
            'spyder': (
                'Scientific PYthon Development EnviRonment. Powerful '
                'Python IDE with advanced editing, interactive '
                'testing, debugging and introspection features'
            ),
            'rodeo': (
                'A browser-based IDE for data science with python. '
                'Includes autocomplete, syntax highlighting, IPython '
                'support.'
            ),
            'veusz': (
                'Veusz is a GUI scientific plotting and graphing '
                'package. It is designed to produce publication-ready '
                'Postscript or PDF output.'
            ),
            'rstudio': (
                'A set of integrated tools designed to help you be '
                'more productive with R. Includes R essentials and notebooks.'
            ),
            'anaconda-fusion': (
                'Integration between Excel ® and Anaconda '
                'via Notebooks. Run data science functions, '
                'interact with results and create advanced '
                'visualizations in a code-free app inside '
                'Excel'
            ),
            'anaconda-mosaic': (
                'Interactive exploration of larger than '
                'memory datasets. Create data sources, '
                'perform transformations and combinations.'
            ),
        }

        APPS_DESCRIPTIONS['orange3'] = APPS_DESCRIPTIONS['orange-app']

        invalid_apps = [
            'spyder-app',
            'ipython-qtconsole',
            'ipython-notebook',
            'anacondafusion',
        ]

        for app_name in apps:
            if app_name in invalid_apps:
                continue

            data = apps[app_name]
            versions = data.get('versions')
            description = APPS_DESCRIPTIONS.get(
                app_name, data.get('description', '')
            )
            version = versions[-1]  # Versions are sorted from small to big
            image_path = image_paths.get(
                app_name, images.ANACONDA_ICON_512_PATH
            )
            app_entry = data.get('app_entry').get(version, '')

            # Handle deprecated entrypoints for notebook and qtconsole
            if 'ipython notebook' in app_entry.lower():
                app_entry = app_entry.replace(
                    'ipython notebook', 'jupyter-notebook'
                )
            elif 'ipython qtconsole' in app_entry.lower():
                app_entry = app_entry.replace(
                    'ipython qtconsole', 'jupyter-qtconsole'
                )

            needs_license = app_name.lower() in PACKAGES_WITH_LICENSE
            application = dict(
                name=app_name,
                description=description,
                versions=versions,
                command=app_entry,
                image_path=image_path,
                needs_license=needs_license,
            )
            applications[app_name] = application

        return applications

    # --- New moved API
    # -------------------------------------------------------------------------
    @property
    def channels(self):
        """Convenience property for returning conda rc channels."""
        return list(self.conda_get_condarc_channels())

    @property
    def active_channels(self):
        """Convenience property for returning active channels."""
        active_channels = self.config.get('main', 'conda_active_channels')
        if active_channels is None or not active_channels:
            active_channels = self.channels
        return active_channels

    @property
    def active_normalized_channels(self):
        """Convenience property for returning active normalized channels."""
        return self.conda_get_condarc_channels(
            channels=self.active_channels,
            normalize=True,
        )

    @property
    def user_dynamic_channels(self):
        """
        Return normalized list of logged user channels.

        These are the channels that are located at anaconda server (cloud),
        as opposed to the repo.continuum ones.

        FIXME: This method is flawed as defaults could also include logged
        channels.
        """
        channels = []
        condarc_channels = self._conda_api.get_condarc_channels()

        for ch in condarc_channels:
            if ('repo.continuum' not in ch and ch != 'defaults' and
                    '/t/' not in ch):
                channels.append(ch)

        channels = [ch for ch in channels if ch in self.active_channels]
        return channels

    @property
    def environments(self):
        """
        Return an ordered dictionary of all existing named environments.

        The dictionary includes the root environment as the first entry.
        """
        environments = OrderedDict()
        environments_prefix = sorted(self.conda_get_envs(log=False))
        environments['root'] = self.ROOT_PREFIX

        for prefix in environments_prefix:
            name = os.path.basename(prefix)
            environments[name] = prefix

        return environments

    # --- License management
    # -------------------------------------------------------------------------
    def add_license(self, paths):
        """Add license file callback."""
        valid_licenses = {}
        invalid_licenses = {}
        paths = [p for p in paths if os.path.isfile(p)]
        for path in paths:
            lic = _license.read_licenses(path)
            if lic:
                valid_licenses[path] = lic
            else:
                invalid_licenses[path] = None

        # FIXME: Check  if license name exists in any of the paths
        # And then ask the user a question based on this
        if not os.path.isdir(self.license_location()):
            os.mkdir(self.license_location())

        for path in valid_licenses:
            head, tail = os.path.split(path)
            new_path = os.path.join(self.license_location(), tail)
            with open(new_path, 'w') as f:
                json.dump(valid_licenses[path], f)

        return valid_licenses, invalid_licenses

    @staticmethod
    def remove_license(lic):
        """Remove license callback."""
        path = lic.get(LICENSE_PATH)
        sig = lic.get('sig')

        with open(path) as f:
            licenses = json.load(f)

        for i, lic in enumerate(licenses):
            if lic.get('sig') == sig:
                break

        removed_license = licenses.pop(i)
        with open(path, 'w') as f:
            json.dump(licenses, f)

        head, tail = os.path.split(os.path.abspath(path))
        removed_folder = os.path.join(head, REMOVED_LICENSE_PATH)
        removed_path = os.path.join(removed_folder, tail)

        if not os.path.isdir(removed_folder):
            os.mkdir(removed_folder)

        removed_licenses = [removed_license]
        if os.path.isfile(removed_path):
            # Merge removed files
            with open(removed_path) as f:
                existing_removed_licenses = json.load(f)
                removed_licenses.extend(existing_removed_licenses)

        with open(removed_path, 'w') as f:
            json.dump(removed_licenses, f)

    @classmethod
    def load_licenses(cls, product=None):
        """Load license files."""
        res = []
        # This is used instead of _license.find_licenses to have the path
        # for each file
        for license_path in cls.license_paths():
            licenses = _license.read_licenses(license_path)
            for lic in licenses:
                product_name = lic.get('product')
                product_filter = product == product_name if product else True
                if product_name in VALID_PRODUCT_LICENSES and product_filter:
                    valid = cls.is_valid_license(lic)
                    lic['__valid__'] = valid
                    lic['__status__'] = 'Valid' if valid else 'Invalid'
                    lic['__type__'] = lic.get('type', 'Enterprise').lower()
                    lic[LICENSE_PATH] = license_path
                    res.append(lic)
        return res

    @classmethod
    def get_package_license(cls, package_name):
        """
        Get stored license for a package.

        If several license found only the valid ones with the largest date
        is returned including expired and non expired licenses. Priority is
        given to nontrial over trial licenses, even if a trial has a larger
        date.
        """
        all_licenses = []
        for name in LICENSE_NAME_FOR_PACKAGE.get(package_name, []):
            licenses = cls.load_licenses(product=name)
            valid_licenses = [
                l for l in licenses
                if cls.is_valid_license(
                    l, check_expired=False
                )
            ]
            all_licenses.extend(valid_licenses)

        # Order by trial and non trial. And select the one with the
        # longest remaining days giving priority to non trial.
        trial_valid_licenses = []
        nontrial_valid_licenses = []

        for lic in all_licenses:
            if cls.is_trial_license(lic):
                trial_valid_licenses.append(lic)
            else:
                nontrial_valid_licenses.append(lic)

        trial_valid_licenses = sorted(
            trial_valid_licenses,
            key=lambda i: i.get('end_date'),
        )
        nontrial_valid_licenses = sorted(
            nontrial_valid_licenses,
            key=lambda i: i.get('end_date'),
        )

        if nontrial_valid_licenses:
            lic = nontrial_valid_licenses[-1]  # Larger date
        elif trial_valid_licenses:
            lic = trial_valid_licenses[-1]  # Larger date
        else:
            lic = {}

        return lic

    @classmethod
    def license_paths(cls):
        """Return licenses paths founds on main location."""
        _license.get_license_paths()
        return _license.get_license_paths()

    @classmethod
    def license_location(cls):
        """Return license main location."""
        return _license.get_license_dirs()[0]

    @classmethod
    def is_valid_license(cls, lic, check_expired=True):
        """Return wether a license dictionary is valid."""
        verified = cls.is_verified_license(lic)
        if check_expired:
            expired = cls.is_expired_license(lic)
        else:
            expired = False
        valid_vendor = cls.is_valid_vendor(lic)
        return verified and not expired and valid_vendor

    @classmethod
    def is_verified_license(cls, lic):
        """Check that the license is verified."""
        # Clean license from additional keys
        check_license = copy.deepcopy(lic)

        for key in lic:
            if key.startswith('__') and key.endswith('__'):
                check_license.pop(key)

        return bool(_license.verify_license(check_license))

    @classmethod
    def is_valid_vendor(cls, lic):
        """Check if a license is from a valid vendor."""
        vendor = lic["vendor"]
        return vendor in (
            "Continuum Analytics, Inc.",
            "Continuum Analytics",
            "continuum",
        )

    @classmethod
    def is_expired_license(cls, lic):
        """Check if the license is expired."""
        return cls.get_days_left(lic) == 0

    @classmethod
    def is_trial_license(cls, lic):
        """Check if a license is of trial type."""
        return lic.get('type', '').lower() == 'trial'

    @classmethod
    def is_enterprise_license(cls, lic):
        """Check if a license is of enterprise type."""
        return not cls.is_trial_license(lic)

    @classmethod
    def get_days_left(cls, lic):
        """Get the number of days left for a license."""
        days = 0
        try:
            end_date = _license.date_from_string(lic.get("end_date", ''))
            days = (end_date - datetime.date.today()).days
            if days < 0:
                days = 0
        except (ValueError, TypeError):
            days = 0

        # If license is an empty dict this should not return inf!
        if lic and "end_date" not in lic:
            days = float("inf")

        return days


ANACONDA_API = None


def AnacondaAPI():
    """Manager API threaded worker."""
    global ANACONDA_API

    if ANACONDA_API is None:
        ANACONDA_API = _AnacondaAPI()

    return ANACONDA_API


# --- Local testing
# -----------------------------------------------------------------------------
def finished(worker, output, error):  # pragma: no cover
    """Print information on test finished."""
    print(worker, output, error)


def download_finished(url, path):  # pragma: no cover
    """Print information on downlaod finished."""
    print(url, path)


def repodata_updated(repos):  # pragma: no cover
    """Print information on repodata updated."""
    print(repos)


def test():  # pragma: no cover
    """Main local test."""
    from anaconda_navigator.utils.qthelpers import qapplication

    app = qapplication()
    api = AnacondaAPI()
    # api.sig_repodata_updated.connect(repodata_updated)
    # data_directory = tempfile.mkdtemp()
    # api.set_data_directory(data_directory)
    # worker = api.update_metadata()
    # worker.sig_download_finished.connect(download_finished)
    # api.update_repodata()
    lic = api.get_package_license('anaconda-fusion')
    print(lic)
    app.exec_()


if __name__ == '__main__':  # pragma: no cover
    test()
