#!/usr/bin/python
# -*- coding: utf-8 -*-

DOCUMENTATION = """
---
module: conda
short_description: Manage Python libraries via conda
description:
  >
    Manage Python libraries via conda.
    Can install, update, and remove packages.
author: 
  - Synthicity
  - Colin Nolan (@colin-nolan)
notes:
  >
    Requires conda to already be installed.
options:
  name:
    description: The name of a Python package to install.
    required: true
  version:
    description: The specific version of a package to install.
    required: false
  state:
    description: State in which to leave the Python package. "present" will install a package of the specified version 
                 if it is not installed (will not upgrade to latest if version is unspecified - will only install  
                 latest); "latest" will both install and subsequently upgrade a package to the latest version on each 
                 run; "absent" will uninstall the package if installed.
    required: false
    default: present
    choices: [ "present", "absent", "latest" ]
  channels:
    description: Extra channels to use when installing packages.
    required: false
  executable:
    description: Full path to the conda executable.
    required: false
  extra_args:
    description: Extra arguments passed to conda.
    required: false
"""

EXAMPLES = """
- name: install numpy via conda
  conda: 
    name: numpy
    state: latest

- name: install scipy 0.14 via conda
  conda: 
    name: scipy 
    version: "0.14"

- name: remove matplotlib from conda
  conda: 
    name: matplotlib 
    state: absent
"""

RETURN = """
output:
    description: JSON output from Conda
    returned: `changed == True`
    type: dict
stderr:
    description: stderr content written by Conda
    returned: `changed == True`
    type: str
"""


from distutils.spawn import find_executable
import os.path
import json
from ansible.module_utils.basic import AnsibleModule


def run_package_operation(conda, name, version, state, dry_run, command_runner, on_failure, on_success):
    """
    Runs Conda package operation.

    This method is intentionally decoupled from `AnsibleModule` to allow it to be easily tested in isolation.
    :param conda: location of the Conda executable
    :param name: name of the package of interest
    :param version: version of the package (`None` for latest)
    :param state: state the package should be in
    :param dry_run: will "pretend" to make changes only if `True`
    :param command_runner: method that executes a given Conda command (given as list of string arguments), which returns
    JSON and returns a tuple where the first argument is the outputted JSON and the second is anything written to stderr
    :param on_failure: method that takes any kwargs to be called on failure
    :param on_success: method that takes any kwargs to be called on success
    """
    correct_version_installed = check_package_installed(command_runner, conda, name, version)

    # TODO: State should be an "enum" (or whatever the Py2.7 equivalent is)
    if not correct_version_installed and state != 'absent':
        try:
            output, stderr = install_package(command_runner, conda, name, version, dry_run=dry_run)
            on_success(changed=True, output=output, stderr=stderr)
        except CondaPackageNotFoundError:
            on_failure(msg='Conda package "%s" not found' % (get_install_target(name, version, )))

    elif state == 'absent':
        try:
            output, stderr = uninstall_package(command_runner, conda, name, dry_run=dry_run)
            on_success(changed=True, output=output, stderr=stderr)
        except CondaPackageNotFoundError:
            on_success(changed=False)

    else:
        on_success(changed=False)


def check_package_installed(command_runner, conda, name, version):
    """
    Check whether a package with the given name and version is installed.
    :param command_runner: method that executes a given Conda command (given as list of string arguments), which returns
    JSON and returns a tuple where the first argument is the outputted JSON and the second is anything written to stderr
    :param name: the name of the package to check if installed
    :param version: the version of the package to check if installed (`None` if check for latest)
    :return: `True` if a package with the given name and version is installed
    :raises CondaUnexpectedOutputError: if the JSON returned by Conda was unexpected
    """
    output, stderr = run_conda_package_command(
        command_runner, name, version, [conda, 'install', '--json', '--dry-run', get_install_target(name, version)])

    if 'message' in output and output['message'] == 'All requested packages already installed.':
        return True
    elif 'actions' in output and len(output['actions']) > 0:
        return False
    else:
        raise CondaUnexpectedOutputError(output, stderr)


def install_package(command_runner, conda, name, version=None, dry_run=False):
    """
    Install a package with the given name and version. Version will default to latest if `None`.
    """
    command = [conda, 'install', '--yes', '--json', get_install_target(name, version)]
    if dry_run:
        command.insert(-1, '--dry-run')

    return run_conda_package_command(command_runner, name, version, command)


def uninstall_package(command_runner, conda, name, dry_run=False):
    """
    Use Conda to remove a package with the given name.
    """
    command = [conda, 'remove', '--yes', '--json', name]
    if dry_run:
        command.insert(-1, '--dry-run')

    return run_conda_package_command(command_runner, name, None, command)


def find_conda(executable):
    """
    If `executable` is not None, checks whether it points to a valid file
    and returns it if this is the case. Otherwise tries to find the `conda`
    executable in the path. Calls `fail_json` if either of these fail.
    """
    if not executable:
        conda = find_executable('conda')
        if conda:
            return conda
    else:
        if os.path.isfile(executable):
            return executable

    raise CondaExecutableNotFoundError()


def add_channels_to_command(command, channels):
    """
    Add extra channels to a conda command by splitting the channels
    and putting "--channel" before each one.
    """
    if channels:
        channels = channels.strip().split()
        dashc = []
        for channel in channels:
            dashc.append('--channel')
            dashc.append(channel)

        return command[:2] + dashc + command[2:]
    else:
        return command


def add_extras_to_command(command, extras):
    """
    Add extra arguments to a conda command by splitting the arguments
    on white space and inserting them after the second item in the command.
    """
    if extras:
        extras = extras.strip().split()
        return command[:2] + extras + command[2:]
    else:
        return command


def parse_conda_stdout(stdout):
    """
    Parses the given output from Conda.
    :param stdout: the output from stdout
    :return: standard out as parsed JSON else `None` if non-JSON format
    """
    # Conda spews loading progress reports onto stdout(!?), which need ignoring. Bug observed in Conda version 4.3.25.
    split_lines = stdout.strip().split("\n")
    while len(split_lines) > 0:
        line = split_lines.pop(0).strip('\x00')
        try:
            line_content = json.loads(line)
            if "progress" not in line_content and "maxval" not in line_content:
                # Looks like this was the output, not a progress update
                return line_content
        except ValueError:
            split_lines.insert(0, line)
            break

    try:
        return json.loads("".join(split_lines))
    except ValueError:
        return None


def run_conda_package_command(command_runner, name, version, command):
    """
    Runs a Conda command related to a particular package.
    :param command_runner: runner of Conda commands
    :param name: the name of the package the command refers to
    :param version: the version of the package that the command is referring to
    :param command: the Conda command
    :raises CondaPackageNotFoundError: if the package referred to by this command is not found
    """
    try:
        return command_runner(command)
    except CondaCommandJsonDescribedError as e:
        if 'exception_name' in e.output and e.output['exception_name'] in ('PackageNotFoundError', 'PackagesNotFoundError'):
            raise CondaPackageNotFoundError(name, version)
        else:
            raise


def get_install_target(name, version):
    """
    Gets install target string for a package with the given name and version.
    :param name: the package name
    :param version: the package version (`None` if latest)
    :return: the target string that Conda can refer to the given package as
    """
    install_target = name
    if version is not None:
        install_target = '%s=%s' % (name, version)
    return install_target


class CondaCommandError(Exception):
    """
    Error raised when a Conda command fails.
    """
    def __init__(self, command, stdout, stderr):
        self.command = command
        self.stdout = stdout
        self.stderr = stderr

        stdout = ' stdout: %s.' % self.stdout if self.stdout.strip() != '' else ''
        stderr = ' stderr: %s.' % self.stderr if self.stderr.strip() != '' else ''

        super(CondaCommandError, self).__init__(
            'Error running command: %s.%s%s' % (self.command, stdout, stderr))


class CondaCommandJsonDescribedError(CondaCommandError):
    """
    Error raised when a Conda command does not output JSON.
    """
    def __init__(self, command, output, stderr):
        self.output = output
        super(CondaCommandJsonDescribedError, self).__init__(command, json.dumps(output), stderr)


class CondaPackageNotFoundError(Exception):
    """
    Error raised when a Conda package has not been found in the package repositories that were searched.
    """
    def __int__(self, name, version):
        self.name = name
        self.version = version
        super(CondaPackageNotFoundError, self).__init__(
            'Conda package "%s" not found' % (get_install_target(self.name, self.version), ))


class CondaUnexpectedOutputError(Exception):
    """
    Error raised when the running of a Conda command has resulted in an unexpected output.
    """
    def __int__(self, output, stderr):
        self.output = output
        self.stderr = stderr

        stderr = 'stderr: %s' % self.stderr if self.stderr.strip() != '' else ''
        super(CondaUnexpectedOutputError, self).__init__(
            'Unexpected output from Conda (may be due to a change in Conda\'s output format): "%output".%s'
            % (self.output, stderr))


class CondaExecutableNotFoundError(Exception):
    """
    Error raised when the Conda executable was not found.
    """
    def __init__(self):
        super(CondaExecutableNotFoundError, self).__init__('Conda executable not found.')


def _run_conda_command(module, command):
    """
    Runs the given Conda command.
    :param module: Ansible module
    :param command: the Conda command to run, which must return JSON
    """
    command = add_channels_to_command(command, module.params['channels'])
    command = add_extras_to_command(command, module.params['extra_args'])

    rc, stdout, stderr = module.run_command(command)
    output = parse_conda_stdout(stdout)

    if output is None:
        raise CondaCommandError(command, stdout, stderr)
    if rc != 0:
        raise CondaCommandJsonDescribedError(command, output, stderr)

    return output, stderr


def _main():
    """
    Entrypoint.
    """
    module = AnsibleModule(
        argument_spec={
            'name': {'required': True, 'type': 'str'},
            'version': {'default': None, 'required': False, 'type': 'str'},
            'state': {
                'default': 'present',
                'required': False,
                'choices': ['present', 'absent', 'latest']
            },
            'channels': {'default': None, 'required': False},
            'executable': {'default': None, 'required': False},
            'extra_args': {'default': None, 'required': False, 'type': 'str'}
        },
        supports_check_mode=True)

    conda = find_conda(module.params['executable'])
    name = module.params['name']
    state = module.params['state']
    version = module.params['version']

    if state == 'latest' and version is not None:
        module.fail_json(msg='`version` must not be set if `state == "latest"` (`latest` upgrades to newest version)')

    def command_runner(command):
        return _run_conda_command(module, command)

    run_package_operation(
        conda, name, version, state, module.check_mode, command_runner, module.fail_json, module.exit_json)


if __name__ == '__main__':
    _main()