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

# Copyright: (c) 2012, Jan-Piet Mens <jpmens () gmail.com>
# Copyright: (c) 2015, Ales Nosek <anosek.nosek () gmail.com>
# Copyright: (c) 2017, Ansible Project
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)

from __future__ import absolute_import, division, print_function
__metaclass__ = type


DOCUMENTATION = r'''
---
module: ini_file
short_description: Tweak settings in INI files
extends_documentation_fragment: files
description:
     - Manage (add, remove, change) individual settings in an INI-style file without having
       to manage the file as a whole with, say, M(ansible.builtin.template) or M(ansible.builtin.assemble).
     - Adds missing sections if they don't exist.
     - Before Ansible 2.0, comments are discarded when the source file is read, and therefore will not show up in the destination file.
     - Since Ansible 2.3, this module adds missing ending newlines to files to keep in line with the POSIX standard, even when
       no other modifications need to be applied.
options:
  path:
    description:
      - Path to the INI-style file; this file is created if required.
      - Before Ansible 2.3 this option was only usable as I(dest).
    type: path
    required: true
    aliases: [ dest ]
  section:
    description:
      - Section name in INI file. This is added if C(state=present) automatically when
        a single value is being set.
      - If left empty or set to C(null), the I(option) will be placed before the first I(section).
      - Using C(null) is also required if the config format does not support sections.
    type: str
    required: true
  option:
    description:
      - If set (required for changing a I(value)), this is the name of the option.
      - May be omitted if adding/removing a whole I(section).
    type: str
  value:
    description:
      - The string value to be associated with an I(option).
      - May be omitted when removing an I(option).
    type: str
  backup:
    description:
      - Create a backup file including the timestamp information so you can get
        the original file back if you somehow clobbered it incorrectly.
    type: bool
    default: no
  state:
    description:
      - If set to C(absent) the option or section will be removed if present instead of created.
    type: str
    choices: [ absent, present ]
    default: present
  no_extra_spaces:
    description:
      - Do not insert spaces before and after '=' symbol.
    type: bool
    default: no
  create:
    description:
      - If set to C(no), the module will fail if the file does not already exist.
      - By default it will create the file if it is missing.
    type: bool
    default: yes
  allow_no_value:
    description:
      - Allow option without value and without '=' symbol.
    type: bool
    default: no
notes:
   - While it is possible to add an I(option) without specifying a I(value), this makes no sense.
   - As of Ansible 2.3, the I(dest) option has been changed to I(path) as default, but I(dest) still works as well.
   - As of community.general 3.2.0, UTF-8 BOM markers are discarded when reading files.
author:
    - Jan-Piet Mens (@jpmens)
    - Ales Nosek (@noseka1)
'''

EXAMPLES = r'''
# Before Ansible 2.3, option 'dest' was used instead of 'path'
- name: Ensure "fav=lemonade is in section "[drinks]" in specified file
  community.general.ini_file:
    path: /etc/conf
    section: drinks
    option: fav
    value: lemonade
    mode: '0600'
    backup: yes

- name: Ensure "temperature=cold is in section "[drinks]" in specified file
  community.general.ini_file:
    path: /etc/anotherconf
    section: drinks
    option: temperature
    value: cold
    backup: yes
'''

import io
import os
import re
import tempfile
import traceback

from ansible.module_utils.basic import AnsibleModule


def match_opt(option, line):
    option = re.escape(option)
    return re.match('[#;]?( |\t)*%s( |\t)*(=|$)' % option, line)


def match_active_opt(option, line):
    option = re.escape(option)
    return re.match('( |\t)*%s( |\t)*(=|$)' % option, line)


def do_ini(module, filename, section=None, option=None, value=None,
           state='present', backup=False, no_extra_spaces=False, create=True,
           allow_no_value=False):

    diff = dict(
        before='',
        after='',
        before_header='%s (content)' % filename,
        after_header='%s (content)' % filename,
    )

    if not os.path.exists(filename):
        if not create:
            module.fail_json(rc=257, msg='Destination %s does not exist !' % filename)
        destpath = os.path.dirname(filename)
        if not os.path.exists(destpath) and not module.check_mode:
            os.makedirs(destpath)
        ini_lines = []
    else:
        with io.open(filename, 'r', encoding="utf-8-sig") as ini_file:
            ini_lines = ini_file.readlines()

    if module._diff:
        diff['before'] = ''.join(ini_lines)

    changed = False

    # ini file could be empty
    if not ini_lines:
        ini_lines.append('\n')

    # last line of file may not contain a trailing newline
    if ini_lines[-1] == "" or ini_lines[-1][-1] != '\n':
        ini_lines[-1] += '\n'
        changed = True

    # append fake section lines to simplify the logic
    # At top:
    # Fake random section to do not match any other in the file
    # Using commit hash as fake section name
    fake_section_name = "ad01e11446efb704fcdbdb21f2c43757423d91c5"

    # Insert it at the beginning
    ini_lines.insert(0, '[%s]' % fake_section_name)

    # At bottom:
    ini_lines.append('[')

    # If no section is defined, fake section is used
    if not section:
        section = fake_section_name

    within_section = not section
    section_start = 0
    msg = 'OK'
    if no_extra_spaces:
        assignment_format = '%s=%s\n'
    else:
        assignment_format = '%s = %s\n'

    for index, line in enumerate(ini_lines):
        if line.startswith('[%s]' % section):
            within_section = True
            section_start = index
        elif line.startswith('['):
            if within_section:
                if state == 'present':
                    # insert missing option line at the end of the section
                    for i in range(index, 0, -1):
                        # search backwards for previous non-blank or non-comment line
                        if not re.match(r'^[ \t]*([#;].*)?$', ini_lines[i - 1]):
                            if option and value:
                                ini_lines.insert(i, assignment_format % (option, value))
                                msg = 'option added'
                                changed = True
                            elif option and not value and allow_no_value:
                                ini_lines.insert(i, '%s\n' % option)
                                msg = 'option added'
                                changed = True
                            break
                elif state == 'absent' and not option:
                    # remove the entire section
                    del ini_lines[section_start:index]
                    msg = 'section removed'
                    changed = True
                break
        else:
            if within_section and option:
                if state == 'present':
                    # change the existing option line
                    if match_opt(option, line):
                        if not value and allow_no_value:
                            newline = '%s\n' % option
                        else:
                            newline = assignment_format % (option, value)
                        option_changed = ini_lines[index] != newline
                        changed = changed or option_changed
                        if option_changed:
                            msg = 'option changed'
                        ini_lines[index] = newline
                        if option_changed:
                            # remove all possible option occurrences from the rest of the section
                            index = index + 1
                            while index < len(ini_lines):
                                line = ini_lines[index]
                                if line.startswith('['):
                                    break
                                if match_active_opt(option, line):
                                    del ini_lines[index]
                                else:
                                    index = index + 1
                        break
                elif state == 'absent':
                    # delete the existing line
                    if match_active_opt(option, line):
                        del ini_lines[index]
                        changed = True
                        msg = 'option changed'
                        break

    # remove the fake section line
    del ini_lines[0]
    del ini_lines[-1:]

    if not within_section and state == 'present':
        ini_lines.append('[%s]\n' % section)
        msg = 'section and option added'
        if option and value is not None:
            ini_lines.append(assignment_format % (option, value))
        elif option and value is None and allow_no_value:
            ini_lines.append('%s\n' % option)
        else:
            msg = 'only section added'
        changed = True

    if module._diff:
        diff['after'] = ''.join(ini_lines)

    backup_file = None
    if changed and not module.check_mode:
        if backup:
            backup_file = module.backup_local(filename)

        try:
            tmpfd, tmpfile = tempfile.mkstemp(dir=module.tmpdir)
            f = os.fdopen(tmpfd, 'w')
            f.writelines(ini_lines)
            f.close()
        except IOError:
            module.fail_json(msg="Unable to create temporary file %s", traceback=traceback.format_exc())

        try:
            module.atomic_move(tmpfile, filename)
        except IOError:
            module.ansible.fail_json(msg='Unable to move temporary \
                                   file %s to %s, IOError' % (tmpfile, filename), traceback=traceback.format_exc())

    return (changed, backup_file, diff, msg)


def main():

    module = AnsibleModule(
        argument_spec=dict(
            path=dict(type='path', required=True, aliases=['dest']),
            section=dict(type='str', required=True),
            option=dict(type='str'),
            value=dict(type='str'),
            backup=dict(type='bool', default=False),
            state=dict(type='str', default='present', choices=['absent', 'present']),
            no_extra_spaces=dict(type='bool', default=False),
            allow_no_value=dict(type='bool', default=False),
            create=dict(type='bool', default=True)
        ),
        add_file_common_args=True,
        supports_check_mode=True,
    )

    path = module.params['path']
    section = module.params['section']
    option = module.params['option']
    value = module.params['value']
    state = module.params['state']
    backup = module.params['backup']
    no_extra_spaces = module.params['no_extra_spaces']
    allow_no_value = module.params['allow_no_value']
    create = module.params['create']

    if state == 'present' and not allow_no_value and value is None:
        module.fail_json("Parameter 'value' must not be empty if state=present and allow_no_value=False")

    (changed, backup_file, diff, msg) = do_ini(module, path, section, option, value, state, backup, no_extra_spaces, create, allow_no_value)

    if not module.check_mode and os.path.exists(path):
        file_args = module.load_file_common_arguments(module.params)
        changed = module.set_fs_attributes_if_different(file_args, changed)

    results = dict(
        changed=changed,
        diff=diff,
        msg=msg,
        path=path,
    )
    if backup_file is not None:
        results['backup_file'] = backup_file

    # Mission complete
    module.exit_json(**results)


if __name__ == '__main__':
    main()