#!/usr/bin/env python3
# pylint: disable=locally-disabled,invalid-name
"""
Changes one or more values in a YAML file at a specified YAML Path.  Matched
values can be checked before they are replaced to mitigate accidental change.
When matching singular results, the value can be archived to another key
before it is replaced.  Further, EYAML can be employed to encrypt the new
values and/or decrypt old values before checking them.

Copyright 2018, 2019 William W. Kimball, Jr. MBA MSIS
"""
import sys
import argparse
import secrets
import string
from os import remove, access, R_OK
from os.path import isfile, exists
from shutil import copy2

from ruamel.yaml import YAML
from ruamel.yaml.parser import ParserError

from yamlpath import YAMLPath
from yamlpath.exceptions import YAMLPathException
from yamlpath.enums import YAMLValueFormats, PathSeperators
from yamlpath.eyaml.exceptions import EYAMLCommandException
from yamlpath.eyaml.enums import EYAMLOutputFormats
from yamlpath.eyaml import EYAMLProcessor

# pylint: disable=locally-disabled,unused-import
import yamlpath.patches
from yamlpath.func import clone_node
from yamlpath.wrappers import ConsolePrinter

# Implied Constants
MY_VERSION = "1.0.5"

def processcli():
    """Process command-line arguments."""
    parser = argparse.ArgumentParser(
        description="Changes one or more values in a YAML file at a specified\
            YAML Path.  Matched values can be checked before they are replaced\
            to mitigate accidental change. When matching singular results, the\
            value can be archived to another key before it is replaced.\
            Further, EYAML can be employed to encrypt the new values and/or\
            decrypt an old value before checking them.",
        epilog="When no changes are made, no backup is created, even when\
            -b/--backup is specified.  For more information about YAML Paths,\
            please visit https://github.com/wwkimball/yamlpath."
    )
    parser.add_argument("-V", "--version", action="version",
                        version="%(prog)s " + MY_VERSION)

    required_group = parser.add_argument_group("required settings")
    required_group.add_argument(
        "-g", "--change",
        required=True,
        metavar="YAML_PATH",
        help="YAML Path where the target value is found")

    inputex_group = parser.add_argument_group("input options")
    input_group = inputex_group.add_mutually_exclusive_group()
    input_group.add_argument(
        "-a", "--value",
        help="set the new value from the command-line instead of STDIN")
    input_group.add_argument(
        "-f", "--file",
        help="read the new value from file (discarding any trailing\
              new-lines)")
    input_group.add_argument(
        "-i", "--stdin", action="store_true",
        help="accept the new value from STDIN (best for sensitive data)")
    input_group.add_argument(
        "-R", "--random",
        type=int,
        metavar="LENGTH",
        help="randomly generate a replacement value of a set length")

    parser.add_argument(
        "-F", "--format",
        default="default",
        choices=[l.lower() for l in YAMLValueFormats.get_names()],
        type=str.lower,
        help="override automatic formatting of the new value")
    parser.add_argument(
        "-c", "--check",
        help="check the value before replacing it")
    parser.add_argument(
        "-s", "--saveto", metavar="YAML_PATH",
        help="save the old value to YAML_PATH before replacing it; implies\
              --mustexist")
    parser.add_argument(
        "-m", "--mustexist", action="store_true",
        help="require that the --change YAML_PATH already exist in YAML_FILE")
    parser.add_argument(
        "-b", "--backup", action="store_true",
        help="save a backup YAML_FILE with an extra .bak file-extension")
    parser.add_argument(
        "-t", "--pathsep",
        default="auto",
        choices=[l.lower() for l in PathSeperators.get_names()],
        type=str.lower,
        help="force the separator in YAML_PATH when inference fails")

    eyaml_group = parser.add_argument_group(
        "EYAML options", "Left unset, the EYAML keys will default to your\
         system or user defaults.  You do not need to supply a private key\
         unless you enable --check and the old value is encrypted.")
    eyaml_group.add_argument(
        "-e", "--eyamlcrypt", action="store_true",
        help="encrypt the new value using EYAML")
    eyaml_group.add_argument(
        "-x", "--eyaml", default="eyaml",
        help="the eyaml binary to use when it isn't on the PATH")
    eyaml_group.add_argument("-r", "--privatekey", help="EYAML private key")
    eyaml_group.add_argument("-u", "--publickey", help="EYAML public key")

    noise_group = parser.add_mutually_exclusive_group()
    noise_group.add_argument(
        "-d", "--debug", action="store_true",
        help="output debugging details")
    noise_group.add_argument(
        "-v", "--verbose", action="store_true",
        help="increase output verbosity")
    noise_group.add_argument(
        "-q", "--quiet", action="store_true",
        help="suppress all output except errors")

    parser.add_argument(
        "yaml_file", metavar="YAML_FILE",
        help="the YAML file to update")
    return parser.parse_args()

def validateargs(args, log):
    """Validate command-line arguments."""
    has_errors = False

    # Enforce sanity
    # * At least one of --value, --file, --stdin, or --random must be set
    if not (
            args.value
            or args.file
            or args.stdin
            or args.random
    ):
        has_errors = True
        log.error(
            "Exactly one of the following must be set:  --value, --file,"
            + " --stdin, or --random")

    # * When set, --saveto cannot be identical to --change
    if args.saveto and args.saveto == args.change:
        has_errors = True
        log.error(
            "Impossible to save the old value to the same YAML Path as the new"
            + " value!")

    # * When set, --privatekey must be a readable file
    if args.privatekey and not (
            isfile(args.privatekey)
            and access(args.privatekey, R_OK)
    ):
        has_errors = True
        log.error(
            "EYAML private key is not a readable file:  " + args.privatekey)

    # * When set, --publickey must be a readable file
    if args.publickey and not (
            isfile(args.publickey)
            and access(args.publickey, R_OK)
    ):
        has_errors = True
        log.error(
            "EYAML public key is not a readable file:  " + args.publickey)

    if has_errors:
        exit(1)

def main():
    """Main code."""
    args = processcli()
    log = ConsolePrinter(args)
    validateargs(args, log)
    change_path = YAMLPath(args.change, pathsep=args.pathsep)
    backup_file = args.yaml_file + ".bak"

    # Obtain the replacement value
    if args.value:
        new_value = args.value
    elif args.stdin:
        new_value = ''.join(sys.stdin.readlines())
    elif args.file:
        with open(args.file, 'r') as f:
            new_value = f.read().rstrip()
    elif args.random is not None:
        new_value = ''.join(
            secrets.choice(
                string.ascii_uppercase + string.ascii_lowercase + string.digits
            ) for _ in range(args.random)
        )
    else:
        log.critical("Unsupported input method.", 1)

    # Prep the YAML parser
    yaml = YAML()
    yaml.indent(mapping=2, sequence=4, offset=2)
    yaml.explicit_start = True
    yaml.preserve_quotes = True
    yaml.width = sys.maxsize

    # Attempt to open the YAML file; check for parsing errors
    try:
        with open(args.yaml_file, 'r') as f:
            yaml_data = yaml.load(f)
    except FileNotFoundError:
        log.critical("YAML_FILE not found:  {}".format(args.yaml_file), 2)
    except ParserError as ex:
        log.critical(
            "YAML parsing error {}:  {}"
            .format(str(ex.problem_mark).lstrip(), ex.problem)
            , 1
        )

    # Load the present value at the specified YAML Path
    change_nodes = []
    old_format = YAMLValueFormats.DEFAULT
    processor = EYAMLProcessor(
        log, yaml_data, eyaml=args.eyaml,
        publickey=args.publickey, privatekey=args.privatekey)
    try:
        for node in processor.get_nodes(
                change_path, mustexist=(args.mustexist or args.saveto),
                default_value=("" if new_value else " ")):
            log.debug('Got "{}" from {}.'.format(node, change_path))
            change_nodes.append(node)
    except YAMLPathException as ex:
        log.critical(ex, 1)

    if not change_nodes:
        log.warning("Nothing to do!")
        exit(0)
    elif len(change_nodes) == 1:
        # When there is exactly one result, its old format can be known.  This
        # is necessary to retain whether the replacement value should be
        # represented later as a multi-line string when the new value is to be
        # encrypted.
        old_format = YAMLValueFormats.from_node(change_nodes[0])

    log.debug("Collected nodes:")
    log.debug(change_nodes)

    # Check the value(s), if desired
    if args.check:
        for node in change_nodes:
            if processor.is_eyaml_value(node):
                # Sanity check:  If either --publickey or --privatekey were set
                # then they must both be set in order to decrypt this value.
                # This is enforced only when the value must be decrypted due to
                # a --check request.
                if (
                        (args.publickey and not args.privatekey)
                        or (args.privatekey and not args.publickey)
                ):
                    log.error(
                        "Neither or both private and public EYAML keys must be"
                        + " set when --check is required to decrypt the old"
                        + " value.")
                    exit(1)

                try:
                    check_value = processor.decrypt_eyaml(node)
                except EYAMLCommandException as ex:
                    log.critical(ex, 1)
            else:
                check_value = node

            if not args.check == check_value:
                log.critical(
                    '"{}" does not match the check value.'
                    .format(args.check),
                    20
                )

    # Save the old value, if desired and possible
    if args.saveto:
        # Only one can be saved; otherwise it is impossible to meaningfully
        # convey to the end-user from exactly which other YAML node each saved
        # value came.
        if len(change_nodes) > 1:
            log.critical(
                "It is impossible to meaningly save more than one matched"
                + " value.  Please omit --saveto or set --change to affect"
                + " exactly one value.", 1)

        saveto_path = YAMLPath(args.saveto, pathsep=args.pathsep)
        log.verbose("Saving the old value to {}.".format(saveto_path))

        # Folded EYAML values have their embedded newlines converted to spaces
        # when read.  As such, writing them back out breaks their original
        # format, despite being properly typed.  To restore the original
        # written form, reverse the conversion, here.
        old_value = change_nodes[0]
        if (
                (old_format is YAMLValueFormats.FOLDED
                 or old_format is YAMLValueFormats.LITERAL
                )
                and EYAMLProcessor.is_eyaml_value(old_value)
        ):
            old_value = old_value.replace(" ", "\n")

        try:
            processor.set_value(
                saveto_path, clone_node(old_value),
                value_format=old_format)
        except YAMLPathException as ex:
            log.critical(ex, 1)

    # Set the requested value
    log.verbose("Setting the new value for {}.".format(change_path))
    if args.eyamlcrypt:
        try:
            format_type = YAMLValueFormats.from_str(args.format)
        except NameError:
            log.critical(
                "Unknown YAML value format ,{}.".format(args.format), 1
            )

        # If the user hasn't specified a format, use the same format as the
        # value being replaced, if known.
        if format_type is YAMLValueFormats.DEFAULT:
            format_type = old_format

        output_type = EYAMLOutputFormats.STRING
        if format_type in [YAMLValueFormats.FOLDED, YAMLValueFormats.LITERAL]:
            output_type = EYAMLOutputFormats.BLOCK

        try:
            processor.set_eyaml_value(
                change_path, new_value
                , output=output_type
                , mustexist=False
            )
        except YAMLPathException as ex:
            log.critical(ex, 1)
        except EYAMLCommandException as ex:
            log.critical(ex, 2)
    else:
        try:
            processor.set_value(
                change_path, new_value, value_format=args.format
            )
        except YAMLPathException as ex:
            log.critical(ex, 1)

    # Save a backup of the original file, if requested
    if args.backup:
        log.verbose(
            "Saving a backup of {} to {}."
            .format(args.yaml_file, backup_file))
        if exists(backup_file):
            remove(backup_file)
        copy2(args.yaml_file, backup_file)

    # Save the changed file
    log.verbose("Writing changed data to {}.".format(args.yaml_file))
    with open(args.yaml_file, 'w') as yaml_dump:
        yaml.dump(yaml_data, yaml_dump)

if __name__ == "__main__":
    main()
