# -*- coding: utf-8 -*-
# -----------------------------------------------------------------------------
# Copyright (c) 2016, Anaconda, Inc. All rights reserved.
#
# Licensed under the terms of the BSD 3-Clause License.
# The full license is in the file LICENSE.txt, distributed with this software.
# -----------------------------------------------------------------------------
"""Abstract high-level interface to Conda."""
from __future__ import absolute_import

import codecs
import glob
import os
import shutil
import subprocess

from anaconda_project.conda_manager import (CondaManager, CondaEnvironmentDeviations, CondaLockSet, CondaManagerError)
import anaconda_project.internal.conda_api as conda_api
import anaconda_project.internal.pip_api as pip_api
import anaconda_project.internal.makedirs as makedirs

from anaconda_project import __version__ as version


def _refactor_common_packages(existing_sets, include_predicate, factored_name):
    # For items in existing_sets included by include_predicate,
    # try to factor out common items into factored_name.
    # include_predicate takes keys from existing_sets as param.
    factorable_names = [name for name in existing_sets.keys() if include_predicate(name)]
    unfactorable_names = [name for name in existing_sets.keys() if not include_predicate(name)]

    if len(factorable_names) < 2:
        # need at least two things to have a DRY problem; we want to
        # keep things specific unless there's a reason to refactor.
        return existing_sets

    specs = [existing_sets[name] for name in factorable_names]
    factored = set.intersection(*specs)

    if len(factored) == 0:
        # nothing in common amongst relevant things
        return existing_sets

    result = dict()
    for name in factorable_names:
        remaining = existing_sets[name] - factored
        if len(remaining) > 0:
            result[name] = remaining

    for name in unfactorable_names:
        result[name] = existing_sets[name]

    result[factored_name] = factored

    return result


def _extract_common(by_platform):
    # convert from dict of platform-to-lists to platform-to-sets
    result = {name: set(values) for (name, values) in by_platform.items()}

    # linux-64,linux-32 => linux, etc.
    platform_names = set([conda_api.parse_platform(platform)[0] for platform in by_platform.keys()])
    for name in platform_names:
        result = _refactor_common_packages(result, lambda p: p.startswith("%s-" % name), name)

    # is it a candidate for the "unix" grouping
    def is_unix(platform_or_platform_name):
        for unix_name in conda_api.unix_platform_names:
            if platform_or_platform_name.startswith(unix_name):
                return True
        return False

    # if we have "linux","linux-64","osx-64", we should consider "linux","osx-64"
    # and leave out "linux-64".
    def is_most_general(platform_or_platform_name):
        if '-' in platform_or_platform_name:
            platform_name = conda_api.parse_platform(platform_or_platform_name)[0]
            if platform_name in result:
                return False

        if 'unix' in result and is_unix(platform_or_platform_name) and platform_or_platform_name != 'unix':
            return False

        # we don't call this after adding 'all'
        assert platform_or_platform_name != 'all'

        return True

    # linux*, osx* => unix
    result = _refactor_common_packages(result, lambda p: is_unix(p) and is_most_general(p), "unix")

    # everything => all
    result = _refactor_common_packages(result, lambda p: is_most_general(p), "all")

    return {name: sorted(list(value)) for (name, value) in result.items()}


class DefaultCondaManager(CondaManager):
    def __init__(self, frontend):
        self._frontend = frontend

    def _log_info(self, line):
        if self._frontend is not None:
            self._frontend.info(line)

    # uncomment this if you want to use it, not used for now.
    # def _log_error(self, line):
    #     if self._frontend is not None:
    #         self._frontend.error(line)

    def _on_stdout(self, data):
        if self._frontend is not None:
            self._frontend.partial_info(data)

    def _on_stderr(self, data):
        if self._frontend is not None:
            self._frontend.partial_error(data)

    def _cache_directory(self, prefix):
        return os.path.join(prefix, "var", "cache", "anaconda-project")

    def _test_writable_file(self, prefix):
        return os.path.join(self._cache_directory(prefix), "status")

    def _force_readonly_file(self, prefix, parent=False):
        if parent:
            prefix = os.path.dirname(prefix)
        return os.path.join(prefix, '.readonly')

    def _timestamp_file(self, prefix, spec):
        return os.path.join(self._cache_directory(prefix), "env-specs", spec.locked_hash)

    def _timestamp_comparison_directories(self, prefix):
        # this is a little bit heuristic; we are trying to detect
        # if any packages are installed or removed. This may need
        # to become more comprehensive.  We don't want to check
        # directories that would change at runtime like /var/run,
        # and we need this to be reasonably fast (so we can't do a
        # full directory walk or something). Remember that on
        # Linux at least a new mtime on a directory means
        # _immediate_ child directory entries were added or
        # removed, changing the files themselves or the files in
        # subdirs will not affect mtime. Windows may be a bit
        # different.

        # Linux
        dirs = list(glob.iglob(os.path.join(prefix, "lib", "python*", "site-packages")))
        dirs.append(os.path.join(prefix, "bin"))
        dirs.append(os.path.join(prefix, "lib"))
        # Windows
        dirs.append(os.path.join(prefix, "Lib", "site-packages"))
        dirs.append(os.path.join(prefix, "Library", "bin"))
        dirs.append(os.path.join(prefix, "Scripts"))
        # conda-meta
        dirs.append(os.path.join(prefix, "conda-meta"))

        return dirs

    def _timestamp_file_up_to_date(self, prefix, spec):
        # The goal here is to return False if 1) the env spec
        # has changed (different hash) or 2) the environment has
        # been modified (e.g. by pip or conda).

        filename = self._timestamp_file(prefix, spec)
        try:
            stamp_mtime = os.path.getmtime(filename)
        except OSError:
            return False

        dirs = self._timestamp_comparison_directories(prefix)

        for d in dirs:
            try:
                d_mtime = os.path.getmtime(d)
            except OSError:
                d_mtime = 0
            # When we write the timestamp, we put it 1s in the
            # future, so we want >= here (if the d_mtime has gone
            # into the future from when we wrote the timestamp,
            # the directory has changed).
            if d_mtime >= stamp_mtime:
                return False

        return True

    def _write_a_file(self, filename):
        try:
            makedirs.makedirs_ok_if_exists(os.path.dirname(filename))
            with codecs.open(filename, 'w', encoding='utf-8') as f:
                # we don't read the contents of the file for now, but
                # recording the version in it in case in the future
                # that is useful. We need to write something to the
                # file to bump its mtime if it already exists...
                f.write('{"anaconda_project_version": "%s"}\n' % version)
            return True
        except (IOError, OSError):
            # ignore errors because this is just an optimization, if we
            # fail we will survive
            return False

    def _is_environment_writable(self, prefix):
        if (os.path.exists(self._force_readonly_file(prefix))
                or os.path.exists(self._force_readonly_file(prefix, parent=True))):
            return False
        filename = self._test_writable_file(prefix)
        return self._write_a_file(filename)

    def _write_timestamp_file(self, prefix, spec):
        filename = self._timestamp_file(prefix, spec)
        if self._write_a_file(filename):
            # set the timestamp 1s in the future, which guarantees
            # it doesn't have the same mtime as any files in the
            # environment changed by us; if another process
            # changes some files during the current second, then
            # we would not notice those changes. The alternative
            # is that we falsely believe we changed things
            # ourselves. Ultimately clock resolution keeps us from
            # perfection here without some sort of cross-process
            # locking.
            actual_time = os.path.getmtime(filename)
            next_tick_time = actual_time + 1
            os.utime(filename, (next_tick_time, next_tick_time))

    def resolve_dependencies(self, package_specs, channels, platforms):
        by_platform = {}

        current = conda_api.current_platform()
        resolve_for_platforms = list(platforms)
        # always resolve "current" first because it's confusing if
        # an error says resolution failed on another platform when
        # the real issue is that resolution will fail on all platforms.
        if current in resolve_for_platforms:
            resolve_for_platforms.remove(current)
            resolve_for_platforms = [current] + resolve_for_platforms
        for conda_platform in resolve_for_platforms:
            try:
                self._log_info("Resolving conda packages for %s" % conda_platform)
                deps = conda_api.resolve_dependencies(pkgs=package_specs, platform=conda_platform, channels=channels)
            except conda_api.CondaError as e:
                raise CondaManagerError("Error resolving for {}: {}".format(conda_platform, str(e)))
            locked_specs = ["%s=%s=%s" % dep for dep in deps]
            by_platform[conda_platform] = sorted(locked_specs)

        by_platform = _extract_common(by_platform)

        lock_set = CondaLockSet(package_specs_by_platform=by_platform, platforms=resolve_for_platforms)
        return lock_set

    def _find_conda_deviations(self, prefix, env_spec):
        try:
            installed = conda_api.installed(prefix)
        except conda_api.CondaError as e:
            raise CondaManagerError("Conda failed while listing installed packages in %s: %s" % (prefix, str(e)))

        missing = set()
        wrong_version = set()

        for spec_string in env_spec.conda_packages_for_create:
            spec = conda_api.parse_spec(spec_string)
            name = spec.name

            if name not in installed:
                missing.add(name)
            else:

                def version_match(wanted, installed):
                    if wanted == installed:
                        return True
                    else:
                        return installed.startswith(wanted + ".")

                # The only constraint we are smart enough to understand is
                # the one we put in the lock file, which is plain =.
                # We can't do version comparisons, which is a bug.
                # We won't notice if non-= constraints are unmet.
                (_, installed_version, installed_build) = installed[name]
                if spec.exact_version is not None and not version_match(spec.exact_version, installed_version):
                    wrong_version.add(name)
                elif spec.exact_build_string is not None and not version_match(spec.exact_build_string,
                                                                               installed_build):
                    wrong_version.add(name)

        return (sorted(list(missing)), sorted(list(wrong_version)))

    def _find_pip_missing(self, prefix, spec):
        # this is an important optimization to avoid a slow "pip
        # list" operation if the project has no pip packages
        if len(spec.pip_package_names_set) == 0:
            return []

        try:
            installed = pip_api.installed(prefix)
        except pip_api.PipError as e:
            raise CondaManagerError("pip failed while listing installed packages in %s: %s" % (prefix, str(e)))

        # TODO: we don't verify that the environment contains the right versions
        # https://github.com/Anaconda-Server/anaconda-project/issues/77

        missing = set()

        for name in spec.pip_package_names_set:
            if name not in installed:
                missing.add(name)

        return sorted(list(missing))

    def _broken_lock_set_error(self, spec):
        error = None
        if spec.lock_set is not None and spec.lock_set.enabled:
            # We have to check this first, because getting our package list
            # is not valid if we don't have platform support.
            current_platform = conda_api.current_platform()
            if current_platform not in spec.platforms:
                error = "Env spec '%s' does not support current platform %s (it supports: %s)" % \
                        (spec.name, current_platform, ", ".join(spec.platforms))
            elif not spec.lock_set.supports_current_platform:
                error = "Env spec '%s' does not have the current platform %s in the lock file" % \
                        (spec.name, current_platform)
        return error

    def find_environment_deviations(self, prefix, spec):
        broken_lock_set = self._broken_lock_set_error(spec)
        if broken_lock_set is not None:
            return CondaEnvironmentDeviations(summary=broken_lock_set,
                                              missing_packages=(),
                                              wrong_version_packages=(),
                                              missing_pip_packages=(),
                                              wrong_version_pip_packages=(),
                                              broken=True,
                                              unfixable=True)

        if not os.path.isdir(os.path.join(prefix, 'conda-meta')):
            return CondaEnvironmentDeviations(summary="'%s' doesn't look like it contains a Conda environment yet." %
                                              (prefix),
                                              missing_packages=tuple(spec.conda_package_names_for_create_set),
                                              wrong_version_packages=(),
                                              missing_pip_packages=tuple(spec.pip_package_names_for_create_set),
                                              wrong_version_pip_packages=(),
                                              broken=True)

        broken = unfixable = False
        if self._timestamp_file_up_to_date(prefix, spec):
            conda_missing = []
            conda_wrong_version = []
            pip_missing = []
        else:
            (conda_missing, conda_wrong_version) = self._find_conda_deviations(prefix, spec)
            pip_missing = self._find_pip_missing(prefix, spec)
            broken = self._is_environment_writable(prefix)
            # For readonly environments, do not enforce the writing of the timestamp.
            # But mark other deviations as unfixable
            unfixable = not broken and (conda_missing or conda_wrong_version or pip_missing)

        all_missing_string = ", ".join(conda_missing + pip_missing)
        all_wrong_version_string = ", ".join(conda_wrong_version)

        if all_missing_string != "" and all_wrong_version_string != "":
            summary = "Conda environment is missing packages: %s and has wrong versions of: %s" % (
                all_missing_string, all_wrong_version_string)
        elif all_missing_string != "":
            summary = "Conda environment is missing packages: %s" % all_missing_string
        elif all_wrong_version_string != "":
            summary = "Conda environment has wrong versions of: %s" % all_wrong_version_string
        elif broken:
            summary = "Conda environment needs to be marked as up-to-date"
        else:
            summary = "OK"
        if unfixable:
            summary += " and the environment is read-only"
        return CondaEnvironmentDeviations(summary=summary,
                                          missing_packages=conda_missing,
                                          wrong_version_packages=conda_wrong_version,
                                          missing_pip_packages=pip_missing,
                                          wrong_version_pip_packages=(),
                                          broken=broken,
                                          unfixable=unfixable)

    def fix_environment_deviations(self, prefix, spec, deviations=None, create=True):
        if deviations is None:
            deviations = self.find_environment_deviations(prefix, spec)

        if deviations.unfixable:
            raise CondaManagerError("Unable to update environment at %s" % prefix)

        conda_meta = os.path.join(prefix, 'conda-meta')
        packed = os.path.join(conda_meta, '.packed')
        install_pip = True

        if os.path.isdir(conda_meta) and os.path.exists(packed):
            with open(packed) as f:
                packed_arch = f.read().strip()

            matched = packed_arch == conda_api.current_platform()
            if matched:
                if 'win' in conda_api.current_platform():
                    unpack_script = ['python', os.path.join(prefix, 'Scripts', 'conda-unpack-script.py')]

                else:
                    unpack_script = os.path.join(prefix, 'bin', 'conda-unpack')

                try:
                    subprocess.check_call(unpack_script)
                    os.remove(packed)
                    install_pip = False
                except (subprocess.CalledProcessError, OSError) as e:
                    self._log_info('Warning: conda-unpack could not be run: \n{}\n'
                                   'The environment will be recreated.'.format(str(e)))
                    create = True
                    shutil.rmtree(prefix)

            else:
                self._log_info('Warning: The unpacked env does not match the current architecture. '
                               'It will be recreated.')
                create = True
                shutil.rmtree(prefix)

        if os.path.isdir(conda_meta):
            to_update = list(set(deviations.missing_packages + deviations.wrong_version_packages))
            if len(to_update) > 0:
                specs = spec.specs_for_conda_package_names(to_update)
                assert len(specs) == len(to_update)
                spec.apply_pins(prefix, specs)
                try:
                    conda_api.install(prefix=prefix,
                                      pkgs=specs,
                                      channels=spec.channels,
                                      stdout_callback=self._on_stdout,
                                      stderr_callback=self._on_stderr)
                except conda_api.CondaError as e:
                    raise CondaManagerError("Failed to install packages: {}: {}".format(", ".join(specs), str(e)))
                finally:
                    spec.remove_pins(prefix)
        elif create:
            # Create environment from scratch

            command_line_packages = set(spec.conda_packages_for_create)

            try:
                conda_api.create(prefix=prefix,
                                 pkgs=list(command_line_packages),
                                 channels=spec.channels,
                                 stdout_callback=self._on_stdout,
                                 stderr_callback=self._on_stderr)
            except conda_api.CondaError as e:
                raise CondaManagerError("Failed to create environment at %s: %s" % (prefix, str(e)))
        else:
            raise CondaManagerError("Conda environment at %s does not exist" % (prefix))

        # now add pip if needed
        missing = list(deviations.missing_pip_packages)
        if (len(missing) > 0) and install_pip:
            specs = spec.specs_for_pip_package_names(missing)
            assert len(specs) == len(missing)
            try:
                pip_api.install(prefix=prefix,
                                pkgs=specs,
                                stdout_callback=self._on_stdout,
                                stderr_callback=self._on_stderr)
            except pip_api.PipError as e:
                raise CondaManagerError("Failed to install missing pip packages: {}: {}".format(
                    ", ".join(missing), str(e)))

        # write a file to tell us we can short-circuit next time
        self._write_timestamp_file(prefix, spec)

    def remove_packages(self, prefix, packages, pip=False):
        if pip:
            try:
                pip_api.remove(prefix, packages, stdout_callback=self._on_stdout, stderr_callback=self._on_stderr)
            except pip_api.PipError as e:
                raise CondaManagerError('Failed to remove pip packages from {}: {}'.format(prefix, str(e)))
        else:
            try:
                conda_api.remove(prefix, packages, stdout_callback=self._on_stdout, stderr_callback=self._on_stderr)
            except conda_api.CondaError as e:
                raise CondaManagerError("Failed to remove packages from %s: %s" % (prefix, str(e)))
