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

"""

dryjq.commandline

Command line functionality

Copyright (C) 2022 Rainer Schwarzbach

This file is part of dryjq.

dryjq is free software: you can redistribute it and/or modify
it under the terms of the MIT License.

dryjq is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
See the LICENSE file for more details.

"""


import argparse
import io
import logging

import sys

from typing import Dict, List, Optional, TextIO, Type

import yaml

from serializable_trees import Tree

import dryjq

from dryjq import commons
from dryjq import streams

from dryjq.queries import ParsedQuery


#
# Constants
#


RETURNCODE_OK = 0
RETURNCODE_ERROR = 1


#
# classes
#


class Program:

    """Command line program"""

    name: str = "dryjq"
    description: str = "Drastically Reduced YAML / JSON Query"
    query_option: bool = True
    merge_program: bool = False
    modify_in_place_option: bool = True
    default_output_format: str = commons.FORMAT_INPUT

    allowed_formats: Dict[str, str] = {
        commons.FORMAT_JSON: "set output format to JSON",
        commons.FORMAT_YAML: "set output fromat to YAML",
        commons.FORMAT_INPUT: "keep input format",
        commons.FORMAT_TOGGLE: "change JSON to YAML or vice versa",
    }

    def __init__(self, args: Optional[List[str]] = None) -> None:
        """Parse command line arguments and initialize the logger

        :param args: a list of command line arguments
        """
        self.__arguments = self._parse_args(args)
        if self.__arguments.loglevel < logging.INFO:
            message_format = (
                "%(levelname)-8s | (%(funcName)s:%(lineno)s) %(message)s"
            )
        else:
            message_format = "%(levelname)-8s | %(message)s"
        #
        if self.query_option and self.__arguments.query is None:
            self.__arguments.query = self.__arguments.separator
        #
        logging.basicConfig(
            format=message_format,
            level=self.__arguments.loglevel,
        )

    @property
    def arguments(self) -> argparse.Namespace:
        """Property: command line arguments

        :returns: the parsed command line arguments
        """
        return self.__arguments

    @classmethod
    def complete_format(cls, input_format: str) -> str:
        """Lookup the matching format

        :param input_format: the (case insensitive) start of
            any allowed input format
        :returns: the matching format
        :raises: ValueError if the no matching format was found
        """
        lower_input_format = input_format.lower()
        for candidate in cls.allowed_formats:
            if candidate.lower().startswith(lower_input_format):
                return candidate
            #
        #
        raise ValueError(f"Unsupported format {input_format!r}!")

    def _parse_args(self, args: Optional[List[str]]) -> argparse.Namespace:
        """Parse command line arguments using argparse
        and return the arguments namespace.

        :param args: a list of command line arguments,
            or None to parse sys.argv
        :returns: the parsed command line arguments as returned
            by argparse.ArgumentParser().parse_args()
        """
        main_parser = argparse.ArgumentParser(
            prog=self.name,
            description=self.description,
        )
        main_parser.set_defaults(
            loglevel=logging.WARNING,
            output_format=self.default_output_format,
            indent=commons.DEFAULT_INDENT,
            inplace=False,
            separator=commons.DEFAULT_SEPARATOR,
            subscript_indicators=commons.DEFAULT_SUBSCRIPT_INDICATORS,
        )
        main_parser.add_argument(
            "--version",
            action="version",
            version=dryjq.__version__,
            help="print version and exit",
        )
        if self.modify_in_place_option:
            main_parser.add_argument(
                "-i",
                "--inplace",
                action="store_true",
                help="modify the input file in place instead of writing"
                " the result to standard output",
            )
        #
        logging_group = main_parser.add_argument_group(
            "Logging options", "control log level (default is WARNING)"
        )
        verbosity = logging_group.add_mutually_exclusive_group()
        verbosity.add_argument(
            "-d",
            "--debug",
            action="store_const",
            const=logging.DEBUG,
            dest="loglevel",
            help="output all messages (log level DEBUG)",
        )
        verbosity.add_argument(
            "-v",
            "--verbose",
            action="store_const",
            const=logging.INFO,
            dest="loglevel",
            help="be more verbose (log level INFO)",
        )
        verbosity.add_argument(
            "-q",
            "--quiet",
            action="store_const",
            const=logging.ERROR,
            dest="loglevel",
            help="be more quiet (log level ERROR)",
        )
        output_group = main_parser.add_argument_group(
            "Output options", "control output formatting"
        )
        format_choices = ", ".join(
            f"{key!r}: {value}"
            for (key, value) in self.allowed_formats.items()
        )
        output_group.add_argument(
            "-o",
            "--output-format",
            type=self.complete_format,
            help=f"output format (choice of {format_choices};"
            " default: %(default)r)",
        )
        output_group.add_argument(
            "--indent",
            choices=(2, 4, 8),
            type=int,
            help="indentation depth of blocks, in spaces"
            " (default: %(default)s)",
        )
        output_group.add_argument(
            "--sort-keys",
            action="store_true",
            help="sort mapping keys"
            " (by default, mapping keys are left in input order)",
        )
        if self.query_option:
            query_syntax_group = main_parser.add_argument_group(
                "Query syntax options", "control the query syntax"
            )
            query_syntax_group.add_argument(
                "--separator",
                help="the separator character (default: %(default)r)",
            )
            query_syntax_group.add_argument(
                "--subscript-indicators",
                help="the subscript indicator character(s)"
                " (default: %(default)r)",
            )
            main_parser.add_argument(
                "query",
                nargs="?",
                help="the query (simplest form of yq/jq syntax,"
                " default is the separator character alone).",
            )
        #
        if self.merge_program:
            main_parser.add_argument(
                "input_file",
                help="the input file name",
            )
            main_parser.add_argument(
                "merge_files",
                nargs="*",
                help="the names of the files to be merged"
                " (if no file name is provided here,"
                " one file will be read from standard input)",
            )
        else:
            main_parser.add_argument(
                "input_file",
                nargs="?",
                help="the input file name"
                " (by default, data will be read from standard input)",
            )
        #
        return main_parser.parse_args(args)

    def _do_merges(self, data_handler: streams.StreamReader) -> None:
        """Merge each file provided as positional parameters
        after the input file into the data structure made from
        the input file (data_handler will be modified in place)

        :param data_handler: the streams.StreamReader
            (or subclass) instance fro the input file
        """
        if not self.arguments.merge_files:
            logging.info("- ↓↓↓ Reading merge data from standard input")
            updating_data_structure = Tree.from_yaml(sys.stdin.read())
            logging.info("----- Finished reading merge data")
            data_handler.execute_merge(updating_data_structure)
            return
        #
        for merge_file_name in self.arguments.merge_files:
            logging.info(
                "----- Reading merge data from file: %s", merge_file_name
            )
            updating_data_structure = Tree.from_file(merge_file_name)
            data_handler.execute_merge(updating_data_structure)
        #

    def _write_output_data(
        self,
        handler_class: Type[streams.StreamReader],
        stream: TextIO,
        parsed_query: Optional[ParsedQuery] = None,
    ) -> int:
        """Execute the query using the data path,
        write output and return the returncode.
        Catch all expected exceptions and log them using ERROR loglevel.

        :param handler_class: streams.StreamReader (or a subclass of it)
        :param stream: the (text) IO stream containing the
            serialized data structure
        :param data_path: the TraversalPath constructed
            from the original query
        :param replacement: the Tree constructed
            from the original query equation right side
        :returns: the returncode for the script
        """
        try:
            stream_handler = handler_class(stream)
        except (yaml.YAMLError, yaml.composer.ComposerError) as error:
            for line in str(error).splitlines():
                logging.error(line)
            #
            return RETURNCODE_ERROR
        #
        try:
            if self.merge_program:
                self._do_merges(stream_handler)
            elif not parsed_query:
                logging.debug("Skipping empty query")
            else:
                stream_handler.execute_single_query(parsed_query)
            #
            stream_handler.set_serialization_format(
                output_format=self.arguments.output_format,
                indent=self.arguments.indent,
                sort_keys=self.arguments.sort_keys,
            )
            stream_handler.write_output()
        except (TypeError, ValueError, yaml.YAMLError) as error:
            for line in str(error).splitlines():
                logging.error(line)
            #
            return RETURNCODE_ERROR
        #
        return RETURNCODE_OK

    def execute(
        self,
        parsed_query: Optional[ParsedQuery] = None,
    ) -> int:
        """Execute the program

        :param data_path: the TraversalPath constructed
            from the original query
        :param replacement: the Tree constructed
            from the original query equation right side
        :returns: the returncode for the script
        """
        display_mode = "extract mode"
        replace_mode = (
            parsed_query is not None and parsed_query.replacement is not None
        )
        if replace_mode:
            display_mode = "replace mode"
        elif not parsed_query:
            display_mode = f"{display_mode} (passthrough)"
        #
        stream_handler_class = streams.StreamReader
        logging.info("Operating in %s.", display_mode)
        if self.arguments.input_file is None:
            if self.arguments.inplace:
                logging.warning("Cannot modify <stdin> in place")
            #
            logging.info("- ↓↓↓ Reading data from standard input")
            return self._write_output_data(
                stream_handler_class,
                sys.stdin,
                parsed_query=parsed_query,
            )
        #
        file_open_mode = "r"
        if replace_mode and self.arguments.inplace:
            file_open_mode = "r+"
            stream_handler_class = streams.FileWriter
        #
        with open(
            self.arguments.input_file,
            mode=file_open_mode,
            encoding="utf-8",
        ) as input_file:
            logging.info(
                "----- Reading data from file: %s", self.arguments.input_file
            )
            # Help mypy to be strict about the file type (TextIO)
            if isinstance(input_file, io.TextIOBase):
                returncode = self._write_output_data(
                    stream_handler_class,
                    input_file,
                    parsed_query=parsed_query,
                )
            else:
                logging.error("Not a text file!")  # NOT TESTABLE
                return RETURNCODE_ERROR  # NOT TESTABLE
            #
        #
        return returncode


# vim: fileencoding=utf-8 ts=4 sts=4 sw=4 autoindent expandtab syntax=python:
