.. versionadded:: 2014.1.0

    Replace content of a text block in a file, delimited by line markers

    A block of content delimited by comments can help you manage several lines
    entries without worrying about old entries removal.

    .. note::

        This function will store two copies of the file in-memory (the original
        version and the edited version) in order to detect changes and only
        edit the targeted file if necessary.

    path
        Filesystem path to the file to be edited

    marker_start
        The line content identifying a line as the start of the content block.
        Note that the whole line containing this marker will be considered, so
        whitespace or extra content before or after the marker is included in
        final output

    marker_end
        The line content identifying the end of the content block. As of
        versions 2017.7.5 and 2018.3.1, everything up to the text matching the
        marker will be replaced, so it's important to ensure that your marker
        includes the beginning of the text you wish to replace.

    content
        The content to be used between the two lines identified by marker_start
        and marker_stop.

    append_if_not_found : False
        If markers are not found and set to ``True`` then, the markers and
        content will be appended to the file.

    prepend_if_not_found : False
        If markers are not found and set to ``True`` then, the markers and
        content will be prepended to the file.

    insert_before_match
        If markers are not found, this parameter can be set to a regex which will
        insert the block before the first found occurrence in the file.

        .. versionadded:: Neon

    insert_after_match
        If markers are not found, this parameter can be set to a regex which will
        insert the block after the first found occurrence in the file.

        .. versionadded:: Neon

    backup
        The file extension to use for a backup of the file if any edit is made.
        Set to ``False`` to skip making a backup.

    dry_run : False
        If ``True``, do not make any edits to the file and simply return the
        changes that *would* be made.

    show_changes : True
        Controls how changes are presented. If ``True``, this function will
        return a unified diff of the changes made. If False, then it will
        return a boolean (``True`` if any changes were made, otherwise
        ``False``).

    append_newline : False
        Controls whether or not a newline is appended to the content block. If
        the value of this argument is ``True`` then a newline will be added to
        the content block. If it is ``False``, then a newline will *not* be
        added to the content block. If it is ``None`` then a newline will only
        be added to the content block if it does not already end in a newline.

        .. versionadded:: 2016.3.4
        .. versionchanged:: 2017.7.5,2018.3.1
            New behavior added when value is ``None``.
        .. versionchanged:: 2019.2.0
            The default value of this argument will change to ``None`` to match
            the behavior of the :py:func:`file.blockreplace state
            <salt.states.file.blockreplace>`

    CLI Example:

    .. code-block:: bash

        salt '*' file.blockreplace /etc/hosts '#-- start managed zone foobar : DO NOT EDIT --' \\
        '#-- end managed zone foobar --' $'10.0.1.1 foo.foobar\\n10.0.1.2 bar.foobar' True

def blockreplace(path,
        marker_start='#-- start managed zone --',
        marker_end='#-- end managed zone --',
        content='',
        append_if_not_found=False,
        prepend_if_not_found=False,
        backup='.bak',
        dry_run=False,
        show_changes=True,
        append_newline=False,
        insert_before_match=None,
        insert_after_match=None):
    '''
    .. versionadded:: 2014.1.0

    Replace content of a text block in a file, delimited by line markers

    A block of content delimited by comments can help you manage several lines
    entries without worrying about old entries removal.

    .. note::

        This function will store two copies of the file in-memory (the original
        version and the edited version) in order to detect changes and only
        edit the targeted file if necessary.

    path
        Filesystem path to the file to be edited

    marker_start
        The line content identifying a line as the start of the content block.
        Note that the whole line containing this marker will be considered, so
        whitespace or extra content before or after the marker is included in
        final output

    marker_end
        The line content identifying the end of the content block. As of
        versions 2017.7.5 and 2018.3.1, everything up to the text matching the
        marker will be replaced, so it's important to ensure that your marker
        includes the beginning of the text you wish to replace.

    content
        The content to be used between the two lines identified by marker_start
        and marker_stop.

    append_if_not_found : False
        If markers are not found and set to ``True`` then, the markers and
        content will be appended to the file.

    prepend_if_not_found : False
        If markers are not found and set to ``True`` then, the markers and
        content will be prepended to the file.

    insert_before_match
        If markers are not found, this parameter can be set to a regex which will
        insert the block before the first found occurrence in the file.

        .. versionadded:: Neon

    insert_after_match
        If markers are not found, this parameter can be set to a regex which will
        insert the block after the first found occurrence in the file.

        .. versionadded:: Neon

    backup
        The file extension to use for a backup of the file if any edit is made.
        Set to ``False`` to skip making a backup.

    dry_run : False
        If ``True``, do not make any edits to the file and simply return the
        changes that *would* be made.

    show_changes : True
        Controls how changes are presented. If ``True``, this function will
        return a unified diff of the changes made. If False, then it will
        return a boolean (``True`` if any changes were made, otherwise
        ``False``).

    append_newline : False
        Controls whether or not a newline is appended to the content block. If
        the value of this argument is ``True`` then a newline will be added to
        the content block. If it is ``False``, then a newline will *not* be
        added to the content block. If it is ``None`` then a newline will only
        be added to the content block if it does not already end in a newline.

        .. versionadded:: 2016.3.4
        .. versionchanged:: 2017.7.5,2018.3.1
            New behavior added when value is ``None``.
        .. versionchanged:: 2019.2.0
            The default value of this argument will change to ``None`` to match
            the behavior of the :py:func:`file.blockreplace state
            <salt.states.file.blockreplace>`

    CLI Example:

    .. code-block:: bash

        salt '*' file.blockreplace /etc/hosts '#-- start managed zone foobar : DO NOT EDIT --' \\
        '#-- end managed zone foobar --' $'10.0.1.1 foo.foobar\\n10.0.1.2 bar.foobar' True

    '''
    exclusive_params = [append_if_not_found, prepend_if_not_found, bool(insert_before_match), bool(insert_after_match)]
    if sum(exclusive_params) > 1:
        raise SaltInvocationError(
            'Only one of append_if_not_found, prepend_if_not_found,'
            ' insert_before_match, and insert_after_match is permitted'
        )

    path = os.path.expanduser(path)

    if not os.path.exists(path):
        raise SaltInvocationError('File not found: {0}'.format(path))

    try:
        file_encoding = __utils__['files.get_encoding'](path)
    except CommandExecutionError:
        file_encoding = None

    if __utils__['files.is_binary'](path):
        if not file_encoding:
            raise SaltInvocationError(
                'Cannot perform string replacements on a binary file: {0}'
                .format(path)
        )

    if insert_before_match or insert_after_match:
        if insert_before_match:
            if not isinstance(insert_before_match, six.string_types):
                raise CommandExecutionError(
                    'RegEx expected in insert_before_match parameter.'
                )
        elif insert_after_match:
            if not isinstance(insert_after_match, six.string_types):
                raise CommandExecutionError(
                    'RegEx expected in insert_after_match parameter.'
                )

    if append_newline is None and not content.endswith((os.linesep, '\n')):
        append_newline = True

    # Split the content into a list of lines, removing newline characters. To
    # ensure that we handle both Windows and POSIX newlines, first split on
    # Windows newlines, and then split on POSIX newlines.
    split_content = []
    for win_line in content.split('\r\n'):
        for content_line in win_line.split('\n'):
            split_content.append(content_line)

    line_count = len(split_content)

    has_changes = False
    orig_file = []
    new_file = []
    in_block = False
    block_found = False
    linesep = None

    def _add_content(linesep, lines=None, include_marker_start=True,
                     end_line=None):
        if lines is None:
            lines = []
            include_marker_start = True

        if end_line is None:
            end_line = marker_end
        end_line = end_line.rstrip('\r\n') + linesep

        if include_marker_start:
            lines.append(marker_start + linesep)

        if split_content:
            for index, content_line in enumerate(split_content, 1):
                if index != line_count:
                    lines.append(content_line + linesep)
                else:
                    # We're on the last line of the content block
                    if append_newline:
                        lines.append(content_line + linesep)
                        lines.append(end_line)
                    else:
                        lines.append(content_line + end_line)
        else:
            lines.append(end_line)

        return lines

    # We do not use in-place editing to avoid file attrs modifications when
    # no changes are required and to avoid any file access on a partially
    # written file.
    try:
        fi_file = io.open(path, mode='r', encoding=file_encoding, newline='')
        for line in fi_file:
            write_line_to_new_file = True

            if linesep is None:
                # Auto-detect line separator
                if line.endswith('\r\n'):
                    linesep = '\r\n'
                elif line.endswith('\n'):
                    linesep = '\n'
                else:
                    # No newline(s) in file, fall back to system's linesep
                    linesep = os.linesep

            if marker_start in line:
                # We've entered the content block
                in_block = True
            else:
                if in_block:
                    # We're not going to write the lines from the old file to
                    # the new file until we have exited the block.
                    write_line_to_new_file = False

                    marker_end_pos = line.find(marker_end)
                    if marker_end_pos != -1:
                        # End of block detected
                        in_block = False
                        # We've found and exited the block
                        block_found = True

                        _add_content(linesep, lines=new_file,
                                     include_marker_start=False,
                                     end_line=line[marker_end_pos:])

            # Save the line from the original file
            orig_file.append(line)
            if write_line_to_new_file:
                new_file.append(line)

    except (IOError, OSError) as exc:
        raise CommandExecutionError(
            'Failed to read from {0}: {1}'.format(path, exc)
        )
    finally:
        if linesep is None:
            # If the file was empty, we will not have set linesep yet. Assume
            # the system's line separator. This is needed for when we
            # prepend/append later on.
            linesep = os.linesep
        try:
            fi_file.close()
        except Exception:
            pass

    if in_block:
        # unterminated block => bad, always fail
        raise CommandExecutionError(
            'Unterminated marked block. End of file reached before marker_end.'
        )

    if not block_found:
        if prepend_if_not_found:
            # add the markers and content at the beginning of file
            prepended_content = _add_content(linesep)
            prepended_content.extend(new_file)
            new_file = prepended_content
            block_found = True
        elif append_if_not_found:
            # Make sure we have a newline at the end of the file
            if new_file:
                if not new_file[-1].endswith(linesep):
                    new_file[-1] += linesep
            # add the markers and content at the end of file
            _add_content(linesep, lines=new_file)
            block_found = True
        elif insert_before_match or insert_after_match:
            match_regex = insert_before_match or insert_after_match
            match_idx = [i for i, item in enumerate(orig_file) if re.search(match_regex, item)]
            if match_idx:
                match_idx = match_idx[0]
                for line in _add_content(linesep):
                    if insert_after_match:
                        match_idx += 1
                    new_file.insert(match_idx, line)
                    if insert_before_match:
                        match_idx += 1
                block_found = True

    if not block_found:
        raise CommandExecutionError(
            'Cannot edit marked block. Markers were not found in file.'
        )

    diff = __utils__['stringutils.get_diff'](orig_file, new_file)
    has_changes = diff is not ''
    if has_changes and not dry_run:
        # changes detected
        # backup file attrs
        perms = {}
        perms['user'] = get_user(path)
        perms['group'] = get_group(path)
        perms['mode'] = salt.utils.files.normalize_mode(get_mode(path))

        # backup old content
        if backup is not False:
            backup_path = '{0}{1}'.format(path, backup)
            shutil.copy2(path, backup_path)
            # copy2 does not preserve ownership
            check_perms(backup_path,
                    None,
                    perms['user'],
                    perms['group'],
                    perms['mode'])

        # write new content in the file while avoiding partial reads
        try:
            fh_ = salt.utils.atomicfile.atomic_open(path, 'wb')
            for line in new_file:
                fh_.write(salt.utils.stringutils.to_bytes(line, encoding=file_encoding))
        finally:
            fh_.close()

        # this may have overwritten file attrs
        check_perms(path,
                None,
                perms['user'],
                perms['group'],
                perms['mode'])

    if show_changes:
        return diff

    return has_changes