import json
import os
import sys
import tempfile
from pathlib import Path

import yaml

from album.core.api.model.configuration import IConfiguration
from album.core.api.model.environment import IEnvironment
from album.core.api.model.link import Link
from album.core.model.default_values import DefaultValues
from album.core.utils import subcommand
from album.core.utils.operations.file_operations import construct_cache_link_target, remove_link
from album.core.utils.subcommand import SubProcessError
from album.runner import album_logging
from album.runner.album_logging import debug_settings

module_logger = album_logging.get_active_logger


class CondaManager:
    """Class for handling conda environments.

    The conda class manages the environments a solution is supposed to run in. It provides all features necessary for
    environment creation, deletion, dependency installation, etc.

    Notes:
        An installed \"conda\" program must be available and callable via commandline or powershell.

    """

    def __init__(self, configuration: IConfiguration):
        self._configuration = configuration
        self._conda_executable = self._configuration.conda_executable()

    def _get_install_environment_executable(self):
        return self._conda_executable

    def get_environment_list(self):
        """Returns the available album conda environments."""
        if Path(self._get_base_environment_target_path()).exists():
            return sorted(self._get_immediate_subdirectories(self._get_base_environment_target_path()))
        else:
            return []

    @staticmethod
    def _get_immediate_subdirectories(a_dir: Path):
        return [a_dir.joinpath(name).resolve() for name in os.listdir(a_dir)
                if os.path.isdir(os.path.join(a_dir, name))]

    def _get_base_environment_target_path(self):
        """Gets the first of the paths the conda installation uses to manage its environments."""
        return Path(self._configuration.lnk_path()).joinpath(DefaultValues.lnk_env_prefix.value)

    def environment_exists(self, environment_name):
        """Checks whether an environment already exists or not.

        Args:
            environment_name:
                The name of the environment in which a solution will run.

        Returns:
            True when environment exists else false.
        """
        environment_list = self.get_environment_list()
        path_expected = self._environment_name_to_path(environment_name, create=False)

        return True if (path_expected and path_expected.resolve() in environment_list and os.listdir(path_expected)) else False

    def _environment_name_to_path(self, environment_name, create=True):
        path = os.path.normpath(self._configuration.cache_path_envs().joinpath(environment_name))
        target = construct_cache_link_target(self._configuration.lnk_path(), link=path,
                                             target=DefaultValues.lnk_env_prefix.value, create=create)
        if target:
            return Link(target).set_link(path)
        else:
            return None

    def get_environment_path(self, environment_name: str, create: bool = True) -> Link:
        """Gets the environment path for a given environment

        Args:
            environment_name:
                The environment to get the path for.

        Returns:
            None or the path
        """
        environment_list = self.get_environment_list()
        path_expected = self._environment_name_to_path(environment_name, create)

        if path_expected.resolve() in environment_list:
            return path_expected
        raise LookupError('Could not find environment %s.' % environment_name)

    def get_active_environment_name(self):
        """Returns the environment from the active album."""
        conda_list = self.get_info()
        return conda_list["active_prefix_name"]

    def get_active_environment_path(self):
        """Returns the environment form the active album."""
        conda_list = self.get_info()
        path = conda_list["active_prefix"]
        link = construct_cache_link_target(self._configuration.lnk_path(), link=path,
                                             target=DefaultValues.lnk_env_prefix.value, create=False)
        if link:
            return link
        else:
            return Link(path)

    def remove_environment(self, environment_name) -> bool:
        """Removes an environment given its name. Does nothing when environment does not exist.

        Args:
            environment_name:
                The name of the environment to remove

        Returns:
            True, when removal succeeded, else False

        """
        if self.get_active_environment_name() == environment_name:
            module_logger().warning("Cannot remove active environment! Skipping...")
            return False

        if not self.environment_exists(environment_name):
            module_logger().warning("Environment does not exist! Skipping...")
            return False

        path = self.get_environment_path(environment_name, create=False)

        try:
            subprocess_args = [
                self._get_install_environment_executable(), 'env', 'remove', '-y', '-q', '-p', os.path.normpath(path)
            ]

            subcommand.run(subprocess_args, log_output=False)
        except SubProcessError:
            module_logger().debug("Can't delete environment via command line call, deleting the folder next...")
        # try to remove file content if any but don't fail:
        remove_link(path)
        return True

    def get_info(self):
        """Get the info of the conda installation on the corresponding system.

        Returns:
            dictionary corresponding to conda info.
        """
        subprocess_args = [
            self._get_install_environment_executable(), 'info', '--json'
        ]
        output = subcommand.check_output(subprocess_args)
        return json.loads(output)

    def list_environment(self, environment_path):
        """Lists all available conda installation in the given environment.

        Args:
            environment_path:
                The prefix of the environment to list.

        Returns:
            dictionary containing the available packages in the given conda environment.
        """
        subprocess_args = [self._get_install_environment_executable(), 'list', '--json', '--prefix', str(environment_path)]
        output = subcommand.check_output(subprocess_args)
        return json.loads(output)

    def create_environment_from_file(self, yaml_path, environment_name, album_api_version=None):
        """Creates a conda environment given a path to a yaml file and its name.

        Args:
            yaml_path:
                The path to the file.
            environment_name:
                The name of the environment. Must be the same as specified in the yml file.

        Raises:
            NameError:
                When the file has the wrong format according to its extension.
            ValueError:
                When the file is unreadable or empty.
            RuntimeError:
                When the environment could not be created due to whatever reasons.

        """
        if self.environment_exists(environment_name):
            self.remove_environment(environment_name)

        if not (str(yaml_path).endswith(".yml") or str(yaml_path).endswith(".yaml")):
            raise NameError("File needs to be a yml or yaml file!")

        yaml_path = Path(yaml_path)

        if not (yaml_path.is_file() and yaml_path.stat().st_size > 0):
            raise ValueError("File not a valid yml file!")

        if not album_api_version:
            album_api_version = DefaultValues.runner_api_packet_version.value
        if not DefaultValues.runner_api_packet_version.value:
            album_api_version = None

        with open(yaml_path, 'r') as f:
            content = yaml.safe_load(f)

        self._append_framework_to_yml(content, album_api_version)

        with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.yml') as env_file:
            env_file.write(yaml.safe_dump(content))

        env_prefix = os.path.normpath(self._environment_name_to_path(environment_name))

        # TODO in debug mode, use -v to display the installation process
        subprocess_args = [self._get_install_environment_executable(), 'env', 'create', '-q', '--force', '-f',
                           os.path.normpath(env_file.name), '-p', env_prefix]

        try:
            subcommand.run(subprocess_args, log_output=True)
        except SubProcessError as e:
            # cleanup after failed installation
            if self.environment_exists(environment_name):
                module_logger().debug('Cleanup failed installation...')
                self.remove_environment(environment_name)
            raise RuntimeError("Command failed due to reasons above!") from e
        # finally:
        #     os.remove(env_file.name)

    @staticmethod
    def _append_framework_to_yml(content, album_api_version):
        framework = '%s==%s' % (DefaultValues.runner_api_packet_name.value, album_api_version)
        if not 'dependencies' in content or not content['dependencies']:
            content['dependencies'] = []
        pip_dict = None
        found_pip_dep = False
        for dep in content['dependencies']:
            if isinstance(dep, dict):
                if 'pip' in dep:
                    pip_dict = dep
            if dep == 'pip' or str(dep).startswith('pip='):
                found_pip_dep = True
        if not found_pip_dep:
            content['dependencies'].append(DefaultValues.runner_pip_version.value)
        if not pip_dict:
            pip_dict = {'pip': []}
            content['dependencies'].append(pip_dict)
        pip_dict['pip'].append(framework)
        return content

    def create_environment(self, environment_name, album_api_version=None, force=False):
        """Creates a conda environment with python (latest version) installed.

        Args:
            environment_name:
                The desired environment name.
            force:
                If True, force creates the environment by deleting the old one.
        Raises:
            RuntimeError:
                When the environment could not be created due to whatever reasons.

        """
        env_exists = self.environment_exists(environment_name)
        if force and env_exists:
            self.remove_environment(environment_name)
        else:
            if env_exists:
                raise EnvironmentError("Environment with name %s already exists!" % environment_name)

        env_prefix = os.path.normpath(self._environment_name_to_path(environment_name))

        if not album_api_version:
            album_api_version = DefaultValues.runner_api_packet_version.value
        if not DefaultValues.runner_api_packet_version.value:
            album_api_version = None

        with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.yml') as env_file:

            env_file.write(
                "channels:\n" +
                "  - defaults\n" +
                "dependencies:\n" +
                "  - python=3.8\n" +
                "  - pip\n" +
                "  - pip:\n" +
                "    - %s==%s\n" % (DefaultValues.runner_api_packet_name.value, album_api_version)
            )

        subprocess_args = [self._get_install_environment_executable(), 'env', 'create', '--force', '-q',
                           '--file', env_file.name, '-p', env_prefix]

        try:
            subcommand.run(subprocess_args, log_output=True)
        except RuntimeError as e:
            # cleanup after failed installation
            if self.environment_exists(environment_name):
                module_logger().debug('Cleanup failed environment creation...')
                self.remove_environment(environment_name)
            raise RuntimeError("Command failed due to reasons above!") from e
        finally:
            os.remove(env_file.name)

    def run_script(self, environment_path, script_full_path, pipe_output=True):
        """Runs a script in the given environment.

        Args:
            environment_path:
                The prefix path of the environment to install the package to.
            script_full_path:
                The full path to the script to run.
            pipe_output:
                Indicates whether to pipe the output of the subprocess or just return it as is.

        """
        if sys.platform == 'win32' or sys.platform == 'cygwin':
            # NOTE: WHEN USING 'CONDA RUN' THE CORRECT ENVIRONMENT GETS TEMPORARY ACTIVATED,
            # BUT THE PATH POINTS TO THE WRONG PYTHON (conda base folder python) BECAUSE THE CONDA BASE PATH
            # COMES FIRST IN ENVIRONMENT VARIABLE "%PATH%". THUS, FULL PATH IS NECESSARY TO CALL
            # THE CORRECT PYTHON OR PIP! ToDo: keep track of this!
            subprocess_args = [
                self._conda_executable, 'run', '--no-capture-output', '--prefix',
                os.path.normpath(environment_path),
                os.path.normpath(Path(environment_path).joinpath('python')),
                os.path.normpath(script_full_path)
            ]
        else:
            subprocess_args = [
                self._conda_executable, 'run', '--no-capture-output', '--prefix',
                os.path.normpath(environment_path), 'python',
                os.path.normpath(script_full_path)
            ]
        subcommand.run(subprocess_args, pipe_output=pipe_output)

    def set_environment_path(self, environment: IEnvironment):
        path = self.get_environment_path(environment.name())
        module_logger().debug('Set environment path to %s...' % path)
        environment.set_path(path)

    def is_installed(self, environment_path: str, package_name, min_package_version=None):
        """Checks if package is installed in a certain version."""
        conda_list = self.list_environment(environment_path)

        for package in conda_list:
            if package["name"] == package_name:
                if min_package_version:
                    if package["version"] == min_package_version:
                        module_logger().debug('Package %s:%s is installed...' % (package_name, min_package_version))
                        return True
                    if package["version"] < min_package_version:
                        module_logger().debug('Package %s:%s is installed. Requirements not set! Reinstalling...'
                                              % (package_name, package["version"]))
                        return False
                    if package["version"] > min_package_version:
                        module_logger().debug('Package %s:%s is installed. Version should be compatible...'
                                              % (package_name, package["version"]))
                        return True
                else:
                    module_logger().debug('Package %s:%s is installed...' % (package_name, package["version"]))
                    return True

        return False

    def run_scripts(self, environment: IEnvironment, scripts, pipe_output=True):
        """Runs the solution in the target environment

        Args:
            scripts:
                List of he scripts calling the solution(s)
            environment:
                The virtual environment used to run the scripts
            pipe_output:
                Indicates whether to pipe the output of the subprocess or just return it as is.
        """
        if not environment.path():
            raise EnvironmentError('Could not find environment %s. Is the solution installed?' % environment.name())

        module_logger().debug('run_in_environment: %s...' % str(environment.path()))

        # first write scripts to disc, create meta-script to execute them in the order of the list
        if len(scripts) > 1:
            script = ""
            for s in scripts:
                fp_step = tempfile.NamedTemporaryFile(mode='w+', delete=False)
                fp_step.write(s)
                fp_step.flush()
                os.fsync(fp_step)
                script += "\nexec(open(r\'%s\').read())\n" % fp_step.name
                fp_step.close()
        else:
            script = scripts[0]

        self.write_and_run_script(environment, script, pipe_output=pipe_output)

    def write_and_run_script(self, environment, script, pipe_output):
        """Use an environment path and a temporary file to store the script
        Args:

            environment:
                The virtual environment used to run the scripts
            script:
                The virtual environment used to run the scripts
            pipe_output:
                Indicates whether to pipe the output of the subprocess or just return it as is.
        """
        if debug_settings():
            fp = open(str(DefaultValues.app_cache_dir.value.joinpath('album_test.py')), 'w')
            module_logger().debug(
                "Executable file in: %s..." % str(DefaultValues.app_cache_dir.value.joinpath('album_test.py')))
        else:
            fp = tempfile.NamedTemporaryFile(mode='w+', delete=False)
            module_logger().debug('Executable file in: %s...' % fp.name)
        fp.write(script)
        fp.flush()
        os.fsync(fp)
        fp.close()
        self.run_script(environment.path(), fp.name, pipe_output=pipe_output)
        Path(fp.name).unlink()

    def create_or_update_env(self, environment: IEnvironment, album_api_version: str = None):
        """Creates or updates the environment"""
        if self.environment_exists(environment.name()):
            self.update(environment)
        else:
            self.create(environment, album_api_version)

    def update(self, environment: IEnvironment):
        """Updates the environment"""
        module_logger().debug('Skip installing environment %s...' % environment.name())
        pass  # ToDo: implement and change log message

    def create(self, environment: IEnvironment, album_api_version=None):
        """Creates environment a solution runs in."""
        if environment.yaml_file():
            self.create_environment_from_file(environment.yaml_file(), environment.name(), album_api_version)
        else:
            module_logger().warning("No yaml file specified. Creating Environment without dependencies!")
            self.create_environment(environment.name(), album_api_version)

    def install(self, environment: IEnvironment, album_api_version=None):
        """Creates or updates an an environment and installs album in the target environment."""
        self.create_or_update_env(environment, album_api_version)
        self.set_environment_path(environment)
