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

# Copyright (c) 2016, William L Thomson Jr
# Copyright (c) 2013, Yap Sok Ann
# Written by Yap Sok Ann <sokann@gmail.com>
# Modified by William L. Thomson Jr. <wlt@o-sinc.com>
# Based on apt module written by Matthew Williams <matthew@flowroute.com>
#
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later

from __future__ import absolute_import, division, print_function
__metaclass__ = type


DOCUMENTATION = '''
---
module: portage
short_description: Package manager for Gentoo
description:
  - Manages Gentoo packages

extends_documentation_fragment:
  - community.general.attributes

attributes:
  check_mode:
    support: full
  diff_mode:
    support: none

options:
  package:
    description:
      - Package atom or set, for example V(sys-apps/foo) or V(>foo-2.13) or V(@world)
    aliases: [name]
    type: list
    elements: str

  state:
    description:
      - State of the package atom
    default: "present"
    choices: [ "present", "installed", "emerged", "absent", "removed", "unmerged", "latest" ]
    type: str

  update:
    description:
      - Update packages to the best version available (--update)
    type: bool
    default: false

  backtrack:
    description:
      - Set backtrack value (C(--backtrack)).
    type: int
    version_added: 5.8.0

  deep:
    description:
      - Consider the entire dependency tree of packages (--deep)
    type: bool
    default: false

  newuse:
    description:
      - Include installed packages where USE flags have changed (--newuse)
    type: bool
    default: false

  changed_use:
    description:
      - Include installed packages where USE flags have changed, except when
      - flags that the user has not enabled are added or removed
      - (--changed-use)
    type: bool
    default: false

  oneshot:
    description:
      - Do not add the packages to the world file (--oneshot)
    type: bool
    default: false

  noreplace:
    description:
      - Do not re-emerge installed packages (--noreplace)
    type: bool
    default: true

  nodeps:
    description:
      - Only merge packages but not their dependencies (--nodeps)
    type: bool
    default: false

  onlydeps:
    description:
      - Only merge packages' dependencies but not the packages (--onlydeps)
    type: bool
    default: false

  depclean:
    description:
      - Remove packages not needed by explicitly merged packages (--depclean)
      - If no package is specified, clean up the world's dependencies
      - Otherwise, --depclean serves as a dependency aware version of --unmerge
    type: bool
    default: false

  quiet:
    description:
      - Run emerge in quiet mode (--quiet)
    type: bool
    default: false

  verbose:
    description:
      - Run emerge in verbose mode (--verbose)
    type: bool
    default: false

  select:
    description:
      - If set to V(true), explicitely add the package to the world file.
      - Please note that this option is not used for idempotency, it is only used
        when actually installing a package.
    type: bool
    version_added: 8.6.0

  sync:
    description:
      - Sync package repositories first
      - If V(yes), perform "emerge --sync"
      - If V(web), perform "emerge-webrsync"
    choices: [ "web", "yes", "no" ]
    type: str

  getbinpkgonly:
    description:
      - Merge only packages specified at C(PORTAGE_BINHOST) in C(make.conf).
    type: bool
    default: false
    version_added: 1.3.0

  getbinpkg:
    description:
      - Prefer packages specified at C(PORTAGE_BINHOST) in C(make.conf).
    type: bool
    default: false

  usepkgonly:
    description:
      - Merge only binaries (no compiling).
    type: bool
    default: false

  usepkg:
    description:
      - Tries to use the binary package(s) in the locally available packages directory.
    type: bool
    default: false

  keepgoing:
    description:
      - Continue as much as possible after an error.
    type: bool
    default: false

  jobs:
    description:
      - Specifies the number of packages to build simultaneously.
      - "Since version 2.6: Value of 0 or False resets any previously added"
      - --jobs setting values
    type: int

  loadavg:
    description:
      - Specifies that no new builds should be started if there are
      - other builds running and the load average is at least LOAD
      - "Since version 2.6: Value of 0 or False resets any previously added"
      - --load-average setting values
    type: float

  withbdeps:
    description:
      - Specifies that build time dependencies should be installed.
    type: bool
    version_added: 5.8.0

  quietbuild:
    description:
      - Redirect all build output to logs alone, and do not display it
      - on stdout (--quiet-build)
    type: bool
    default: false

  quietfail:
    description:
      - Suppresses display of the build log on stdout (--quiet-fail)
      - Only the die message and the path of the build log will be
      - displayed on stdout.
    type: bool
    default: false

author:
    - "William L Thomson Jr (@wltjr)"
    - "Yap Sok Ann (@sayap)"
    - "Andrew Udvare (@Tatsh)"
'''

EXAMPLES = '''
- name: Make sure package foo is installed
  community.general.portage:
    package: foo
    state: present

- name: Make sure package foo is not installed
  community.general.portage:
    package: foo
    state: absent

- name: Update package foo to the latest version (os specific alternative to latest)
  community.general.portage:
    package: foo
    update: true

- name: Install package foo using PORTAGE_BINHOST setup
  community.general.portage:
    package: foo
    getbinpkg: true

- name: Re-install world from binary packages only and do not allow any compiling
  community.general.portage:
    package: '@world'
    usepkgonly: true

- name: Sync repositories and update world
  community.general.portage:
    package: '@world'
    update: true
    deep: true
    sync: true

- name: Remove unneeded packages
  community.general.portage:
    depclean: true

- name: Remove package foo if it is not explicitly needed
  community.general.portage:
    package: foo
    state: absent
    depclean: true
'''

import os
import re
import sys
import traceback

from ansible.module_utils.basic import AnsibleModule, missing_required_lib
from ansible.module_utils.common.respawn import has_respawned, respawn_module
from ansible.module_utils.common.text.converters import to_native


try:
    from portage.dbapi import vartree
    from portage.exception import InvalidAtom
    HAS_PORTAGE = True
    PORTAGE_IMPORT_ERROR = None
except ImportError:
    HAS_PORTAGE = False
    PORTAGE_IMPORT_ERROR = traceback.format_exc()


def query_package(module, package, action):
    if package.startswith('@'):
        return query_set(module, package, action)
    return query_atom(module, package, action)


def query_atom(module, atom, action):
    vdb = vartree.vardbapi()
    try:
        exists = vdb.match(atom)
    except InvalidAtom:
        return False
    return bool(exists)


def query_set(module, set, action):
    system_sets = [
        '@live-rebuild',
        '@module-rebuild',
        '@preserved-rebuild',
        '@security',
        '@selected',
        '@system',
        '@world',
        '@x11-module-rebuild',
    ]

    if set in system_sets:
        if action == 'unmerge':
            module.fail_json(msg='set %s cannot be removed' % set)
        return False

    world_sets_path = '/var/lib/portage/world_sets'
    if not os.path.exists(world_sets_path):
        return False

    cmd = 'grep %s %s' % (set, world_sets_path)

    rc, out, err = module.run_command(cmd)
    return rc == 0


def sync_repositories(module, webrsync=False):
    if module.check_mode:
        module.exit_json(msg='check mode not supported by sync')

    if webrsync:
        webrsync_path = module.get_bin_path('emerge-webrsync', required=True)
        cmd = '%s --quiet' % webrsync_path
    else:
        cmd = '%s --sync --quiet --ask=n' % module.emerge_path

    rc, out, err = module.run_command(cmd)
    if rc != 0:
        module.fail_json(msg='could not sync package repositories')


# Note: In the 3 functions below, package querying is done one-by-one,
# but emerge is done in one go. If that is not desirable, split the
# packages into multiple tasks instead of joining them together with
# comma.


def emerge_packages(module, packages):
    """Run emerge command against given list of atoms."""
    p = module.params

    if p['noreplace'] and not p['changed_use'] and not p['newuse'] and not (p['update'] or p['state'] == 'latest'):
        for package in packages:
            if p['noreplace'] and not p['changed_use'] and not p['newuse'] and not query_package(module, package, 'emerge'):
                break
        else:
            module.exit_json(changed=False, msg='Packages already present.')
        if module.check_mode:
            module.exit_json(changed=True, msg='Packages would be installed.')

    args = []
    emerge_flags = {
        'update': '--update',
        'deep': '--deep',
        'newuse': '--newuse',
        'changed_use': '--changed-use',
        'oneshot': '--oneshot',
        'noreplace': '--noreplace',
        'nodeps': '--nodeps',
        'onlydeps': '--onlydeps',
        'quiet': '--quiet',
        'verbose': '--verbose',
        'getbinpkgonly': '--getbinpkgonly',
        'getbinpkg': '--getbinpkg',
        'usepkgonly': '--usepkgonly',
        'usepkg': '--usepkg',
        'keepgoing': '--keep-going',
        'quietbuild': '--quiet-build',
        'quietfail': '--quiet-fail',
    }
    for flag, arg in emerge_flags.items():
        if p[flag]:
            args.append(arg)

    if p['state'] and p['state'] == 'latest':
        args.append("--update")

    emerge_flags = {
        'jobs': '--jobs',
        'loadavg': '--load-average',
        'backtrack': '--backtrack',
        'withbdeps': '--with-bdeps',
        'select': '--select',
    }

    for flag, arg in emerge_flags.items():
        flag_val = p[flag]

        if flag_val is None:
            """Fallback to default: don't use this argument at all."""
            continue

        """Add the --flag=value pair."""
        if isinstance(flag_val, bool):
            args.extend((arg, to_native('y' if flag_val else 'n')))
        elif not flag_val:
            """If the value is 0 or 0.0: add the flag, but not the value."""
            args.append(arg)
        else:
            args.extend((arg, to_native(flag_val)))

    cmd, (rc, out, err) = run_emerge(module, packages, *args)
    if rc != 0:
        module.fail_json(
            cmd=cmd, rc=rc, stdout=out, stderr=err,
            msg='Packages not installed.',
        )

    # Check for SSH error with PORTAGE_BINHOST, since rc is still 0 despite
    #   this error
    if (p['usepkgonly'] or p['getbinpkg'] or p['getbinpkgonly']) \
            and 'Permission denied (publickey).' in err:
        module.fail_json(
            cmd=cmd, rc=rc, stdout=out, stderr=err,
            msg='Please check your PORTAGE_BINHOST configuration in make.conf '
                'and your SSH authorized_keys file',
        )

    changed = True
    for line in out.splitlines():
        if re.match(r'(?:>+) Emerging (?:binary )?\(1 of', line):
            msg = 'Packages installed.'
            break
        elif module.check_mode and re.match(r'\[(binary|ebuild)', line):
            msg = 'Packages would be installed.'
            break
    else:
        changed = False
        msg = 'No packages installed.'

    module.exit_json(
        changed=changed, cmd=cmd, rc=rc, stdout=out, stderr=err,
        msg=msg,
    )


def unmerge_packages(module, packages):
    p = module.params

    for package in packages:
        if query_package(module, package, 'unmerge'):
            break
    else:
        module.exit_json(changed=False, msg='Packages already absent.')

    args = ['--unmerge']

    for flag in ['quiet', 'verbose']:
        if p[flag]:
            args.append('--%s' % flag)

    cmd, (rc, out, err) = run_emerge(module, packages, *args)

    if rc != 0:
        module.fail_json(
            cmd=cmd, rc=rc, stdout=out, stderr=err,
            msg='Packages not removed.',
        )

    module.exit_json(
        changed=True, cmd=cmd, rc=rc, stdout=out, stderr=err,
        msg='Packages removed.',
    )


def cleanup_packages(module, packages):
    p = module.params

    if packages:
        for package in packages:
            if query_package(module, package, 'unmerge'):
                break
        else:
            module.exit_json(changed=False, msg='Packages already absent.')

    args = ['--depclean']

    for flag in ['quiet', 'verbose']:
        if p[flag]:
            args.append('--%s' % flag)

    cmd, (rc, out, err) = run_emerge(module, packages, *args)
    if rc != 0:
        module.fail_json(cmd=cmd, rc=rc, stdout=out, stderr=err)

    removed = 0
    for line in out.splitlines():
        if not line.startswith('Number removed:'):
            continue
        parts = line.split(':')
        removed = int(parts[1].strip())
    changed = removed > 0

    module.exit_json(
        changed=changed, cmd=cmd, rc=rc, stdout=out, stderr=err,
        msg='Depclean completed.',
    )


def run_emerge(module, packages, *args):
    args = list(args)

    args.append('--ask=n')
    if module.check_mode:
        args.append('--pretend')

    cmd = [module.emerge_path] + args + packages
    return cmd, module.run_command(cmd)


portage_present_states = ['present', 'emerged', 'installed', 'latest']
portage_absent_states = ['absent', 'unmerged', 'removed']


def main():
    module = AnsibleModule(
        argument_spec=dict(
            package=dict(type='list', elements='str', default=None, aliases=['name']),
            state=dict(
                default=portage_present_states[0],
                choices=portage_present_states + portage_absent_states,
            ),
            update=dict(default=False, type='bool'),
            backtrack=dict(default=None, type='int'),
            deep=dict(default=False, type='bool'),
            newuse=dict(default=False, type='bool'),
            changed_use=dict(default=False, type='bool'),
            oneshot=dict(default=False, type='bool'),
            noreplace=dict(default=True, type='bool'),
            nodeps=dict(default=False, type='bool'),
            onlydeps=dict(default=False, type='bool'),
            depclean=dict(default=False, type='bool'),
            select=dict(default=None, type='bool'),
            quiet=dict(default=False, type='bool'),
            verbose=dict(default=False, type='bool'),
            sync=dict(default=None, choices=['yes', 'web', 'no']),
            getbinpkgonly=dict(default=False, type='bool'),
            getbinpkg=dict(default=False, type='bool'),
            usepkgonly=dict(default=False, type='bool'),
            usepkg=dict(default=False, type='bool'),
            keepgoing=dict(default=False, type='bool'),
            jobs=dict(default=None, type='int'),
            loadavg=dict(default=None, type='float'),
            withbdeps=dict(default=None, type='bool'),
            quietbuild=dict(default=False, type='bool'),
            quietfail=dict(default=False, type='bool'),
        ),
        required_one_of=[['package', 'sync', 'depclean']],
        mutually_exclusive=[
            ['nodeps', 'onlydeps'],
            ['quiet', 'verbose'],
            ['quietbuild', 'verbose'],
            ['quietfail', 'verbose'],
            ['oneshot', 'select'],
        ],
        supports_check_mode=True,
    )

    if not HAS_PORTAGE:
        if sys.executable != '/usr/bin/python' and not has_respawned():
            respawn_module('/usr/bin/python')
        else:
            module.fail_json(msg=missing_required_lib('portage'),
                             exception=PORTAGE_IMPORT_ERROR)

    module.emerge_path = module.get_bin_path('emerge', required=True)

    p = module.params

    if p['sync'] and p['sync'].strip() != 'no':
        sync_repositories(module, webrsync=(p['sync'] == 'web'))
        if not p['package']:
            module.exit_json(msg='Sync successfully finished.')

    packages = []
    if p['package']:
        packages.extend(p['package'])

    if p['depclean']:
        if packages and p['state'] not in portage_absent_states:
            module.fail_json(
                msg='Depclean can only be used with package when the state is '
                    'one of: %s' % portage_absent_states,
            )

        cleanup_packages(module, packages)

    elif p['state'] in portage_present_states:
        emerge_packages(module, packages)

    elif p['state'] in portage_absent_states:
        unmerge_packages(module, packages)


if __name__ == '__main__':
    main()
