Key/Value based editing of a file.

    .. versionadded:: Neon

    This function differs from ``file.replace`` in that it is able to search for
    keys, followed by a customizable separator, and replace the value with the
    given value. Should the value be the same as the one already in the file, no
    changes will be made.

    Either supply both ``key`` and ``value`` parameters, or supply a dictionary
    with key / value pairs. It is an error to supply both.

    name
        Name of the file to search/replace in.

    key
        Key to search for when ensuring a value. Use in combination with a
        ``value`` parameter.

    value
        Value to set for a given key. Use in combination with a ``key``
        parameter.

    key_values
        Dictionary of key / value pairs to search for and ensure values for.
        Used to specify multiple key / values at once.

    separator : "="
        Separator which separates key from value.

    append_if_not_found : False
        Append the key/value to the end of the file if not found. Note that this
        takes precedence over ``prepend_if_not_found``.

    prepend_if_not_found : False
        Prepend the key/value to the beginning of the file if not found. Note
        that ``append_if_not_found`` takes precedence.

    show_changes : True
        Show a diff of the resulting removals and inserts.

    ignore_if_missing : False
        Return with success even if the file is not found (or not readable).

    count : 1
        Number of occurences to allow (and correct), default is 1. Set to -1 to
        replace all, or set to 0 to remove all lines with this key regardsless
        of its value.

    .. note::
        Any additional occurences after ``count`` are removed.
        A count of -1 will only replace all occurences that are currently
        uncommented already. Lines commented out will be left alone.

    uncomment : None
        Disregard and remove supplied leading characters when finding keys. When
        set to None, lines that are commented out are left for what they are.

    .. note::
        The argument to ``uncomment`` is not a prefix string. Rather; it is a
        set of characters, each of which are stripped.

    key_ignore_case : False
        Keys are matched case insensitively. When a value is changed the matched
        key is kept as-is.

    value_ignore_case : False
        Values are checked case insensitively, trying to set e.g. 'Yes' while
        the current value is 'yes', will not result in changes when
        ``value_ignore_case`` is set to True.

    An example of using ``file.keyvalue`` to ensure sshd does not allow
    for root to login with a password and at the same time setting the
    login-gracetime to 1 minute and disabling all forwarding:

    .. code-block:: yaml

        sshd_config_harden:
            file.keyvalue:
              - name: /etc/ssh/sshd_config
              - key_values:
                  permitrootlogin: 'without-password'
                  LoginGraceTime: '1m'
                  DisableForwarding: 'yes'
              - separator: ' '
              - uncomment: '# '
              - key_ignore_case: True
              - append_if_not_found: True

    The same example, except for only ensuring PermitRootLogin is set correctly.
    Thus being able to use the shorthand ``key`` and ``value`` parameters
    instead of ``key_values``.

    .. code-block:: yaml

        sshd_config_harden:
            file.keyvalue:
              - name: /etc/ssh/sshd_config
              - key: PermitRootLogin
              - value: without-password
              - separator: ' '
              - uncomment: '# '
              - key_ignore_case: True
              - append_if_not_found: True

    .. note::
        Notice how the key is not matched case-sensitively, this way it will
        correctly identify both 'PermitRootLogin' as well as 'permitrootlogin'.

def keyvalue(
        name,
        key=None,
        value=None,
        key_values=None,
        separator="=",
        append_if_not_found=False,
        prepend_if_not_found=False,
        search_only=False,
        show_changes=True,
        ignore_if_missing=False,
        count=1,
        uncomment=None,
        key_ignore_case=False,
        value_ignore_case=False):
    '''
    Key/Value based editing of a file.

    .. versionadded:: Neon

    This function differs from ``file.replace`` in that it is able to search for
    keys, followed by a customizable separator, and replace the value with the
    given value. Should the value be the same as the one already in the file, no
    changes will be made.

    Either supply both ``key`` and ``value`` parameters, or supply a dictionary
    with key / value pairs. It is an error to supply both.

    name
        Name of the file to search/replace in.

    key
        Key to search for when ensuring a value. Use in combination with a
        ``value`` parameter.

    value
        Value to set for a given key. Use in combination with a ``key``
        parameter.

    key_values
        Dictionary of key / value pairs to search for and ensure values for.
        Used to specify multiple key / values at once.

    separator : "="
        Separator which separates key from value.

    append_if_not_found : False
        Append the key/value to the end of the file if not found. Note that this
        takes precedence over ``prepend_if_not_found``.

    prepend_if_not_found : False
        Prepend the key/value to the beginning of the file if not found. Note
        that ``append_if_not_found`` takes precedence.

    show_changes : True
        Show a diff of the resulting removals and inserts.

    ignore_if_missing : False
        Return with success even if the file is not found (or not readable).

    count : 1
        Number of occurences to allow (and correct), default is 1. Set to -1 to
        replace all, or set to 0 to remove all lines with this key regardsless
        of its value.

    .. note::
        Any additional occurences after ``count`` are removed.
        A count of -1 will only replace all occurences that are currently
        uncommented already. Lines commented out will be left alone.

    uncomment : None
        Disregard and remove supplied leading characters when finding keys. When
        set to None, lines that are commented out are left for what they are.

    .. note::
        The argument to ``uncomment`` is not a prefix string. Rather; it is a
        set of characters, each of which are stripped.

    key_ignore_case : False
        Keys are matched case insensitively. When a value is changed the matched
        key is kept as-is.

    value_ignore_case : False
        Values are checked case insensitively, trying to set e.g. 'Yes' while
        the current value is 'yes', will not result in changes when
        ``value_ignore_case`` is set to True.

    An example of using ``file.keyvalue`` to ensure sshd does not allow
    for root to login with a password and at the same time setting the
    login-gracetime to 1 minute and disabling all forwarding:

    .. code-block:: yaml

        sshd_config_harden:
            file.keyvalue:
              - name: /etc/ssh/sshd_config
              - key_values:
                  permitrootlogin: 'without-password'
                  LoginGraceTime: '1m'
                  DisableForwarding: 'yes'
              - separator: ' '
              - uncomment: '# '
              - key_ignore_case: True
              - append_if_not_found: True

    The same example, except for only ensuring PermitRootLogin is set correctly.
    Thus being able to use the shorthand ``key`` and ``value`` parameters
    instead of ``key_values``.

    .. code-block:: yaml

        sshd_config_harden:
            file.keyvalue:
              - name: /etc/ssh/sshd_config
              - key: PermitRootLogin
              - value: without-password
              - separator: ' '
              - uncomment: '# '
              - key_ignore_case: True
              - append_if_not_found: True

    .. note::
        Notice how the key is not matched case-sensitively, this way it will
        correctly identify both 'PermitRootLogin' as well as 'permitrootlogin'.

    '''
    name = os.path.expanduser(name)

    # default return values
    ret = {
        'name': name,
        'changes': {},
        'pchanges': {},
        'result': None,
        'comment': '',
        }

    if not name:
        return _error(ret, 'Must provide name to file.keyvalue')
    if key is not None and value is not None:
        if isinstance(key_values, dict):
            return _error(ret,
                    'file.keyvalue can not combine key_values with key and value')
        key_values = {str(key): value}
    elif not isinstance(key_values, dict):
        return _error(ret,
                'file.keyvalue key and value not supplied and key_values empty')

    # try to open the file and only return a comment if ignore_if_missing is
    # enabled, also mark as an error if not
    file_contents = []
    try:
        with salt.utils.files.fopen(name, 'r') as fd:
            file_contents = fd.readlines()
    except (OSError, IOError):
        ret['comment'] = 'unable to open {n}'.format(n=name)
        ret['result'] = True if ignore_if_missing else False
        return ret

    # used to store diff combinations and check if anything has changed
    diff = []
    # store the final content of the file in case it needs to be rewritten
    content = []
    # target format is templated like this
    tmpl = '{key}{sep}{value}'+os.linesep
    # number of lines changed
    changes = 0
    # keep track of number of times a key was updated
    diff_count = {k: count for k in key_values.keys()}

    # read all the lines from the file
    for line in file_contents:
        test_line = line.lstrip(uncomment)
        did_uncomment = True if len(line) > len(test_line) else False

        if key_ignore_case:
            test_line = test_line.lower()

        for key, value in key_values.items():
            test_key = key.lower() if key_ignore_case else key
            # if the line starts with the key
            if test_line.startswith(test_key):
                # if the testline got uncommented then the real line needs to
                # be uncommented too, otherwhise there might be separation on
                # a character which is part of the comment set
                working_line = line.lstrip(uncomment) if did_uncomment else line

                # try to separate the line into its' components
                line_key, line_sep, line_value = working_line.partition(separator)

                # if separation was unsuccessful then line_sep is empty so
                # no need to keep trying. continue instead
                if line_sep != separator:
                    continue

                # start on the premises the key does not match the actual line
                keys_match = False
                if key_ignore_case:
                    if line_key.lower() == test_key:
                        keys_match = True
                else:
                    if line_key == test_key:
                        keys_match = True

                # if the key was found in the line and separation was successful
                if keys_match:
                    # trial and error have shown it's safest to strip whitespace
                    # from values for the sake of matching
                    line_value = line_value.strip()
                    # make sure the value is an actual string at this point
                    test_value = str(value).strip()
                    # convert test_value and line_value to lowercase if need be
                    if value_ignore_case:
                        line_value = line_value.lower()
                        test_value = test_value.lower()

                    # values match if they are equal at this point
                    values_match = True if line_value == test_value else False

                    # in case a line had its comment removed there are some edge
                    # cases that need considderation where changes are needed
                    # regardless of values already matching.
                    needs_changing = False
                    if did_uncomment:
                        # irrespective of a value, if it was commented out and
                        # changes are still to be made, then it needs to be
                        # commented in
                        if diff_count[key] > 0:
                            needs_changing = True
                        # but if values did not match but there are really no
                        # changes expected anymore either then leave this line
                        elif not values_match:
                            values_match = True
                    else:
                        # a line needs to be removed if it has been seen enough
                        # times and was not commented out, regardless of value
                        if diff_count[key] == 0:
                            needs_changing = True

                    # then start checking to see if the value needs replacing
                    if not values_match or needs_changing:
                        # the old line always needs to go, so that will be
                        # reflected in the diff (this is the original line from
                        # the file being read)
                        diff.append('- {0}'.format(line))
                        line = line[:0]

                        # any non-zero value means something needs to go back in
                        # its place. negative values are replacing all lines not
                        # commented out, positive values are having their count
                        # reduced by one every replacement
                        if diff_count[key] != 0:
                            # rebuild the line using the key and separator found
                            # and insert the correct value.
                            line = str(tmpl.format(key=line_key,
                                                   sep=line_sep,
                                                   value=value))

                            # display a comment in case a value got converted
                            # into a string
                            if not isinstance(value, str):
                                diff.append('+ {0} (from {1} type){2}'.format(
                                    line.rstrip(),
                                    type(value).__name__,
                                    os.linesep))
                            else:
                                diff.append('+ {0}'.format(line))
                        changes += 1
                    # subtract one from the count if it was larger than 0, so
                    # next lines are removed. if it is less than 0 then count is
                    # ignored and all lines will be updated.
                    if diff_count[key] > 0:
                        diff_count[key] -= 1
                    # at this point a continue saves going through the rest of
                    # the keys to see if they match since this line already
                    #matched the current key
                    continue
        # with the line having been checked for all keys (or matched before all
        # keys needed searching), the line can be added to the content to be
        # written once the last checks have been performed
        content.append(line)
    # finally, close the file
    fd.close()

    # if append_if_not_found was requested, then append any key/value pairs
    # still having a count left on them
    if append_if_not_found:
        tmpdiff = []
        for key, value in key_values.items():
            if diff_count[key] > 0:
                line = tmpl.format(key=key, sep=separator, value=value)
                tmpdiff.append('+ {0}'.format(line))
                content.append(line)
                changes += 1
        if tmpdiff:
            tmpdiff.insert(0, '- <EOF>'+os.linesep)
            tmpdiff.append('+ <EOF>'+os.linesep)
            diff.extend(tmpdiff)
    # only if append_if_not_found was not set should prepend_if_not_found be
    # considered, benefit of this is that the number of counts left does not
    # mean there might be both a prepend and append happening
    elif prepend_if_not_found:
        did_diff = False
        for key, value in key_values.items():
            if diff_count[key] > 0:
                line = tmpl.format(key=key, sep=separator, value=value)
                if not did_diff:
                    diff.insert(0, '  <SOF>'+os.linesep)
                    did_diff = True
                diff.insert(1, '+ {0}'.format(line))
                content.insert(0, line)
                changes += 1

    # if a diff was made
    if changes > 0:
        # return comment of changes if test
        if __opts__['test']:
            ret['comment'] = 'File {n} is set to be changed ({c} lines)'.format(
                n=name,
                c=changes)
            if show_changes:
                # For some reason, giving an actual diff even in test=True mode
                # will be seen as both a 'changed' and 'unchanged'. this seems to
                # match the other modules behaviour though
                ret['pchanges']['diff'] = ''.join(diff)

                # add changes to comments for now as well because of how
                # stateoutputter seems to handle pchanges etc.
                # See: https://github.com/saltstack/salt/issues/40208
                ret['comment'] += '\nPredicted diff:\n\r\t\t'
                ret['comment'] += '\r\t\t'.join(diff)
                ret['result'] = None

        # otherwise return the actual diff lines
        else:
            ret['comment'] = 'Changed {c} lines'.format(c=changes)
            if show_changes:
                ret['changes']['diff'] = ''.join(diff)
    else:
        ret['result'] = True
        return ret

    # if not test=true, try and write the file
    if not __opts__['test']:
        try:
            with salt.utils.files.fopen(name, 'w') as fd:
                # write all lines to the file which was just truncated
                fd.writelines(content)
                fd.close()
        except (OSError, IOError):
            # return an error if the file was not writable
            ret['comment'] = '{n} not writable'.format(n=name)
            ret['result'] = False
            return ret
        # if all went well, then set result to true
        ret['result'] = True

    return ret