# -*- coding: utf-8 -*-

# Copyright: (c) 2012, Stephen Fromm <sfromm@gmail.com>
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)

from __future__ import annotations


DOCUMENTATION = '''
---
module: group
version_added: "0.0.2"
short_description: Add or remove groups
requirements:
- groupadd
- groupdel
- groupmod
description:
    - Manage presence of groups on a host.
    - For Windows targets, use the M(ansible.windows.win_group) module instead.
options:
    name:
        description:
            - Name of the group to manage.
        type: str
        required: true
    gid:
        description:
            - Optional I(GID) to set for the group.
        type: int
    state:
        description:
            - Whether the group should be present or not on the remote host.
        type: str
        choices: [ absent, present ]
        default: present
    force:
        description:
            - Whether to delete a group even if it is the primary group of a user.
            - Only applicable on platforms which implement a C(--force) flag on the group deletion command.
        type: bool
        default: false
        version_added: "2.15"
    system:
        description:
            - If V(yes), indicates that the group created is a system group.
        type: bool
        default: no
    local:
        description:
            - Forces the use of "local" command alternatives on platforms that implement it.
            - This is useful in environments that use centralized authentication when you want to manipulate the local groups.
              (for example, it uses C(lgroupadd) instead of C(groupadd)).
            - This requires that these commands exist on the targeted host, otherwise it will be a fatal error.
        type: bool
        default: no
        version_added: "2.6"
    non_unique:
        description:
            - This option allows to change the group ID to a non-unique value. Requires O(gid).
            - Not supported on macOS or BusyBox distributions.
        type: bool
        default: no
        version_added: "2.8"
    gid_min:
        description:
            - Sets the GID_MIN value for group creation.
            - Overwrites /etc/login.defs default value.
            - Currently supported on Linux. Does nothing when used with other platforms.
            - Requires O(local) is omitted or V(False).
        type: int
        version_added: "2.18"
    gid_max:
        description:
            - Sets the GID_MAX value for group creation.
            - Overwrites /etc/login.defs default value.
            - Currently supported on Linux. Does nothing when used with other platforms.
            - Requires O(local) is omitted or V(False).
        type: int
        version_added: "2.18"
extends_documentation_fragment: action_common_attributes
attributes:
    check_mode:
        support: full
    diff_mode:
        support: none
    platform:
        platforms: posix
seealso:
- module: ansible.builtin.user
- module: ansible.windows.win_group
author:
- Stephen Fromm (@sfromm)
'''

EXAMPLES = '''
- name: Ensure group "somegroup" exists
  ansible.builtin.group:
    name: somegroup
    state: present

- name: Ensure group "docker" exists with correct gid
  ansible.builtin.group:
    name: docker
    state: present
    gid: 1750
'''

RETURN = r'''
gid:
  description: Group ID of the group.
  returned: When O(state) is C(present)
  type: int
  sample: 1001
name:
  description: Group name.
  returned: always
  type: str
  sample: users
state:
  description: Whether the group is present or not.
  returned: always
  type: str
  sample: 'absent'
system:
  description: Whether the group is a system group or not.
  returned: When O(state) is C(present)
  type: bool
  sample: False
'''

import grp
import os

from ansible.module_utils.common.text.converters import to_bytes
from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils.common.sys_info import get_platform_subclass


class Group(object):
    """
    This is a generic Group manipulation class that is subclassed
    based on platform.

    A subclass may wish to override the following action methods:-
      - group_del()
      - group_add()
      - group_mod()

    All subclasses MUST define platform and distribution (which may be None).
    """

    platform = 'Generic'
    distribution = None  # type: str | None
    GROUPFILE = '/etc/group'

    def __new__(cls, *args, **kwargs):
        new_cls = get_platform_subclass(Group)
        return super(cls, new_cls).__new__(new_cls)

    def __init__(self, module):
        self.module = module
        self.state = module.params['state']
        self.name = module.params['name']
        self.force = module.params['force']
        self.gid = module.params['gid']
        self.system = module.params['system']
        self.local = module.params['local']
        self.non_unique = module.params['non_unique']
        self.gid_min = module.params['gid_min']
        self.gid_max = module.params['gid_max']

        if self.local:
            if self.gid_min is not None:
                module.fail_json(msg="'gid_min' can not be used with 'local'")
            if self.gid_max is not None:
                module.fail_json(msg="'gid_max' can not be used with 'local'")

    def execute_command(self, cmd):
        return self.module.run_command(cmd)

    def group_del(self):
        if self.local:
            command_name = 'lgroupdel'
        else:
            command_name = 'groupdel'
        cmd = [self.module.get_bin_path(command_name, True), self.name]
        return self.execute_command(cmd)

    def _local_check_gid_exists(self):
        if self.gid:
            for gr in grp.getgrall():
                if self.gid == gr.gr_gid and self.name != gr.gr_name:
                    self.module.fail_json(msg="GID '{0}' already exists with group '{1}'".format(self.gid, gr.gr_name))

    def group_add(self, **kwargs):
        if self.local:
            command_name = 'lgroupadd'
            self._local_check_gid_exists()
        else:
            command_name = 'groupadd'
        cmd = [self.module.get_bin_path(command_name, True)]
        for key in kwargs:
            if key == 'gid' and kwargs[key] is not None:
                cmd.append('-g')
                cmd.append(str(kwargs[key]))
                if self.non_unique:
                    cmd.append('-o')
            elif key == 'system' and kwargs[key] is True:
                cmd.append('-r')
        if self.gid_min is not None:
            cmd.append('-K')
            cmd.append('GID_MIN=' + str(self.gid_min))
        if self.gid_max is not None:
            cmd.append('-K')
            cmd.append('GID_MAX=' + str(self.gid_max))
        cmd.append(self.name)
        return self.execute_command(cmd)

    def group_mod(self, **kwargs):
        if self.local:
            command_name = 'lgroupmod'
            self._local_check_gid_exists()
        else:
            command_name = 'groupmod'
        cmd = [self.module.get_bin_path(command_name, True)]
        info = self.group_info()
        for key in kwargs:
            if key == 'gid':
                if kwargs[key] is not None and info[2] != int(kwargs[key]):
                    cmd.append('-g')
                    cmd.append(str(kwargs[key]))
                    if self.non_unique:
                        cmd.append('-o')
        if len(cmd) == 1:
            return (None, '', '')
        if self.module.check_mode:
            return (0, '', '')
        cmd.append(self.name)
        return self.execute_command(cmd)

    def group_exists(self):
        # The grp module does not distinguish between local and directory accounts.
        # It's output cannot be used to determine whether or not a group exists locally.
        # It returns True if the group exists locally or in the directory, so instead
        # look in the local GROUP file for an existing account.
        if self.local:
            if not os.path.exists(self.GROUPFILE):
                self.module.fail_json(msg="'local: true' specified but unable to find local group file {0} to parse.".format(self.GROUPFILE))

            exists = False
            name_test = '{0}:'.format(self.name)
            with open(self.GROUPFILE, 'rb') as f:
                reversed_lines = f.readlines()[::-1]
                for line in reversed_lines:
                    if line.startswith(to_bytes(name_test)):
                        exists = True
                        break
            return exists
        else:
            try:
                if grp.getgrnam(self.name):
                    return True
            except KeyError:
                return False

    def group_info(self):
        if not self.group_exists():
            return False
        try:
            info = list(grp.getgrnam(self.name))
        except KeyError:
            return False
        return info


# ===========================================

class Linux(Group):
    """
    This is a Linux Group manipulation class. This is to apply the '-f' parameter to the groupdel command

    This overrides the following methods from the generic class:-
        - group_del()
    """

    platform = 'Linux'
    distribution = None

    def group_del(self):
        if self.local:
            command_name = 'lgroupdel'
        else:
            command_name = 'groupdel'
        cmd = [self.module.get_bin_path(command_name, True)]
        if self.force:
            cmd.append('-f')
        cmd.append(self.name)
        return self.execute_command(cmd)


# ===========================================

class SunOS(Group):
    """
    This is a SunOS Group manipulation class. Solaris doesn't have
    the 'system' group concept.

    This overrides the following methods from the generic class:-
        - group_add()
    """

    platform = 'SunOS'
    distribution = None
    GROUPFILE = '/etc/group'

    def group_add(self, **kwargs):
        cmd = [self.module.get_bin_path('groupadd', True)]
        for key in kwargs:
            if key == 'gid' and kwargs[key] is not None:
                cmd.append('-g')
                cmd.append(str(kwargs[key]))
                if self.non_unique:
                    cmd.append('-o')
        if self.gid_min is not None:
            cmd.append('-K')
            cmd.append('GID_MIN=' + str(self.gid_min))
        if self.gid_max is not None:
            cmd.append('-K')
            cmd.append('GID_MAX=' + str(self.gid_max))
        cmd.append(self.name)
        return self.execute_command(cmd)


# ===========================================

class AIX(Group):
    """
    This is a AIX Group manipulation class.

    This overrides the following methods from the generic class:-
      - group_del()
      - group_add()
      - group_mod()
    """

    platform = 'AIX'
    distribution = None
    GROUPFILE = '/etc/group'

    def group_del(self):
        cmd = [self.module.get_bin_path('rmgroup', True), self.name]
        return self.execute_command(cmd)

    def group_add(self, **kwargs):
        cmd = [self.module.get_bin_path('mkgroup', True)]
        for key in kwargs:
            if key == 'gid' and kwargs[key] is not None:
                cmd.append('id=' + str(kwargs[key]))
            elif key == 'system' and kwargs[key] is True:
                cmd.append('-a')
        if self.gid_min is not None:
            cmd.append('-K')
            cmd.append('GID_MIN=' + str(self.gid_min))
        if self.gid_max is not None:
            cmd.append('-K')
            cmd.append('GID_MAX=' + str(self.gid_max))
        cmd.append(self.name)
        return self.execute_command(cmd)

    def group_mod(self, **kwargs):
        cmd = [self.module.get_bin_path('chgroup', True)]
        info = self.group_info()
        for key in kwargs:
            if key == 'gid':
                if kwargs[key] is not None and info[2] != int(kwargs[key]):
                    cmd.append('id=' + str(kwargs[key]))
        if len(cmd) == 1:
            return (None, '', '')
        if self.module.check_mode:
            return (0, '', '')
        cmd.append(self.name)
        return self.execute_command(cmd)


# ===========================================

class FreeBsdGroup(Group):
    """
    This is a FreeBSD Group manipulation class.

    This overrides the following methods from the generic class:-
      - group_del()
      - group_add()
      - group_mod()
    """

    platform = 'FreeBSD'
    distribution = None
    GROUPFILE = '/etc/group'

    def group_del(self):
        cmd = [self.module.get_bin_path('pw', True), 'groupdel', self.name]
        return self.execute_command(cmd)

    def group_add(self, **kwargs):
        cmd = [self.module.get_bin_path('pw', True), 'groupadd', self.name]
        if self.gid is not None:
            cmd.append('-g')
            cmd.append(str(self.gid))
            if self.non_unique:
                cmd.append('-o')
        if self.gid_min is not None:
            cmd.append('-K')
            cmd.append('GID_MIN=' + str(self.gid_min))
        if self.gid_max is not None:
            cmd.append('-K')
            cmd.append('GID_MAX=' + str(self.gid_max))
        return self.execute_command(cmd)

    def group_mod(self, **kwargs):
        cmd = [self.module.get_bin_path('pw', True), 'groupmod', self.name]
        info = self.group_info()
        cmd_len = len(cmd)
        if self.gid is not None and int(self.gid) != info[2]:
            cmd.append('-g')
            cmd.append(str(self.gid))
            if self.non_unique:
                cmd.append('-o')
        # modify the group if cmd will do anything
        if cmd_len != len(cmd):
            if self.module.check_mode:
                return (0, '', '')
            return self.execute_command(cmd)
        return (None, '', '')


class DragonFlyBsdGroup(FreeBsdGroup):
    """
    This is a DragonFlyBSD Group manipulation class.
    It inherits all behaviors from FreeBsdGroup class.
    """

    platform = 'DragonFly'


# ===========================================

class DarwinGroup(Group):
    """
    This is a Mac macOS Darwin Group manipulation class.

    This overrides the following methods from the generic class:-
      - group_del()
      - group_add()
      - group_mod()

    group manipulation are done using dseditgroup(1).
    """

    platform = 'Darwin'
    distribution = None

    def group_add(self, **kwargs):
        cmd = [self.module.get_bin_path('dseditgroup', True)]
        cmd += ['-o', 'create']
        if self.gid is not None:
            cmd += ['-i', str(self.gid)]
        elif 'system' in kwargs and kwargs['system'] is True:
            gid = self.get_lowest_available_system_gid()
            if gid is not False:
                self.gid = str(gid)
                cmd += ['-i', str(self.gid)]
        cmd += ['-L', self.name]
        (rc, out, err) = self.execute_command(cmd)
        return (rc, out, err)

    def group_del(self):
        cmd = [self.module.get_bin_path('dseditgroup', True)]
        cmd += ['-o', 'delete']
        cmd += ['-L', self.name]
        (rc, out, err) = self.execute_command(cmd)
        return (rc, out, err)

    def group_mod(self, gid=None):
        info = self.group_info()
        if self.gid is not None and int(self.gid) != info[2]:
            cmd = [self.module.get_bin_path('dseditgroup', True)]
            cmd += ['-o', 'edit']
            if gid is not None:
                cmd += ['-i', str(gid)]
            cmd += ['-L', self.name]
            (rc, out, err) = self.execute_command(cmd)
            return (rc, out, err)
        return (None, '', '')

    def get_lowest_available_system_gid(self):
        # check for lowest available system gid (< 500)
        try:
            cmd = [self.module.get_bin_path('dscl', True)]
            cmd += ['/Local/Default', '-list', '/Groups', 'PrimaryGroupID']
            (rc, out, err) = self.execute_command(cmd)
            lines = out.splitlines()
            highest = 0
            for group_info in lines:
                parts = group_info.split(' ')
                if len(parts) > 1:
                    gid = int(parts[-1])
                    if gid > highest and gid < 500:
                        highest = gid
            if highest == 0 or highest == 499:
                return False
            return (highest + 1)
        except Exception:
            return False


class OpenBsdGroup(Group):
    """
    This is a OpenBSD Group manipulation class.

    This overrides the following methods from the generic class:-
      - group_del()
      - group_add()
      - group_mod()
    """

    platform = 'OpenBSD'
    distribution = None
    GROUPFILE = '/etc/group'

    def group_del(self):
        cmd = [self.module.get_bin_path('groupdel', True), self.name]
        return self.execute_command(cmd)

    def group_add(self, **kwargs):
        cmd = [self.module.get_bin_path('groupadd', True)]
        if self.gid is not None:
            cmd.append('-g')
            cmd.append(str(self.gid))
            if self.non_unique:
                cmd.append('-o')
        if self.gid_min is not None:
            cmd.append('-K')
            cmd.append('GID_MIN=' + str(self.gid_min))
        if self.gid_max is not None:
            cmd.append('-K')
            cmd.append('GID_MAX=' + str(self.gid_max))
        cmd.append(self.name)
        return self.execute_command(cmd)

    def group_mod(self, **kwargs):
        cmd = [self.module.get_bin_path('groupmod', True)]
        info = self.group_info()
        if self.gid is not None and int(self.gid) != info[2]:
            cmd.append('-g')
            cmd.append(str(self.gid))
            if self.non_unique:
                cmd.append('-o')
        if len(cmd) == 1:
            return (None, '', '')
        if self.module.check_mode:
            return (0, '', '')
        cmd.append(self.name)
        return self.execute_command(cmd)


# ===========================================

class NetBsdGroup(Group):
    """
    This is a NetBSD Group manipulation class.

    This overrides the following methods from the generic class:-
      - group_del()
      - group_add()
      - group_mod()
    """

    platform = 'NetBSD'
    distribution = None
    GROUPFILE = '/etc/group'

    def group_del(self):
        cmd = [self.module.get_bin_path('groupdel', True), self.name]
        return self.execute_command(cmd)

    def group_add(self, **kwargs):
        cmd = [self.module.get_bin_path('groupadd', True)]
        if self.gid is not None:
            cmd.append('-g')
            cmd.append(str(self.gid))
            if self.non_unique:
                cmd.append('-o')
        if self.gid_min is not None:
            cmd.append('-K')
            cmd.append('GID_MIN=' + str(self.gid_min))
        if self.gid_max is not None:
            cmd.append('-K')
            cmd.append('GID_MAX=' + str(self.gid_max))
        cmd.append(self.name)
        return self.execute_command(cmd)

    def group_mod(self, **kwargs):
        cmd = [self.module.get_bin_path('groupmod', True)]
        info = self.group_info()
        if self.gid is not None and int(self.gid) != info[2]:
            cmd.append('-g')
            cmd.append(str(self.gid))
            if self.non_unique:
                cmd.append('-o')
        if len(cmd) == 1:
            return (None, '', '')
        if self.module.check_mode:
            return (0, '', '')
        cmd.append(self.name)
        return self.execute_command(cmd)


# ===========================================


class BusyBoxGroup(Group):
    """
    BusyBox group manipulation class for systems that have addgroup and delgroup.

    It overrides the following methods:
        - group_add()
        - group_del()
        - group_mod()
    """

    def group_add(self, **kwargs):
        cmd = [self.module.get_bin_path('addgroup', True)]
        if self.gid is not None:
            cmd.extend(['-g', str(self.gid)])

        if self.system:
            cmd.append('-S')

        if self.gid_min is not None:
            cmd.append('-K')
            cmd.append('GID_MIN=' + str(self.gid_min))

        if self.gid_max is not None:
            cmd.append('-K')
            cmd.append('GID_MAX=' + str(self.gid_max))

        cmd.append(self.name)

        return self.execute_command(cmd)

    def group_del(self):
        cmd = [self.module.get_bin_path('delgroup', True), self.name]
        return self.execute_command(cmd)

    def group_mod(self, **kwargs):
        # Since there is no groupmod command, modify /etc/group directly
        info = self.group_info()
        if self.gid is not None and self.gid != info[2]:
            with open('/etc/group', 'rb') as f:
                b_groups = f.read()

            b_name = to_bytes(self.name)
            b_current_group_string = b'%s:x:%d:' % (b_name, info[2])
            b_new_group_string = b'%s:x:%d:' % (b_name, self.gid)

            if b':%d:' % self.gid in b_groups:
                self.module.fail_json(msg="gid '{gid}' in use".format(gid=self.gid))

            if self.module.check_mode:
                return 0, '', ''
            b_new_groups = b_groups.replace(b_current_group_string, b_new_group_string)
            with open('/etc/group', 'wb') as f:
                f.write(b_new_groups)
            return 0, '', ''

        return None, '', ''


class AlpineGroup(BusyBoxGroup):

    platform = 'Linux'
    distribution = 'Alpine'


def main():
    module = AnsibleModule(
        argument_spec=dict(
            state=dict(type='str', default='present', choices=['absent', 'present']),
            name=dict(type='str', required=True),
            force=dict(type='bool', default=False),
            gid=dict(type='int'),
            system=dict(type='bool', default=False),
            local=dict(type='bool', default=False),
            non_unique=dict(type='bool', default=False),
            gid_min=dict(type='int'),
            gid_max=dict(type='int'),
        ),
        supports_check_mode=True,
        required_if=[
            ['non_unique', True, ['gid']],
        ],
    )

    if module.params['force'] and module.params['local']:
        module.fail_json(msg='force is not a valid option for local, force=True and local=True are mutually exclusive')

    group = Group(module)

    module.debug('Group instantiated - platform %s' % group.platform)
    if group.distribution:
        module.debug('Group instantiated - distribution %s' % group.distribution)

    rc = None
    out = ''
    err = ''
    result = {}
    result['name'] = group.name
    result['state'] = group.state

    if group.state == 'absent':

        if group.group_exists():
            if module.check_mode:
                module.exit_json(changed=True)
            (rc, out, err) = group.group_del()
            if rc != 0:
                module.fail_json(name=group.name, msg=err)

    elif group.state == 'present':

        if not group.group_exists():
            if module.check_mode:
                module.exit_json(changed=True)
            (rc, out, err) = group.group_add(gid=group.gid, system=group.system)
        else:
            (rc, out, err) = group.group_mod(gid=group.gid)

        if rc is not None and rc != 0:
            module.fail_json(name=group.name, msg=err)

    if rc is None:
        result['changed'] = False
    else:
        result['changed'] = True
    if out:
        result['stdout'] = out
    if err:
        result['stderr'] = err

    if group.group_exists():
        info = group.group_info()
        result['system'] = group.system
        result['gid'] = info[2]

    module.exit_json(**result)


if __name__ == '__main__':
    main()
