# Copyright © The Debusine Developers
# See the AUTHORS file at the top-level directory of this distribution
#
# This file is part of Debusine. It is subject to the license terms
# in the LICENSE file found in the top-level directory of this
# distribution. No part of Debusine, including this file, may be copied,
# modified, propagated, or distributed except according to the terms
# contained in the LICENSE file.

"""Debusine command line interface, class-based subparsers."""

import abc
import argparse
import contextlib
import copy
import http
import inspect
import logging
import math
import os
import sys
from collections import defaultdict
from collections.abc import Generator, Iterable
from contextlib import contextmanager
from pathlib import Path
from typing import Any, ClassVar, Generic, NoReturn, Optional, TextIO, TypeVar

import rich.console
import rich.json
import yaml

from debusine.client import exceptions
from debusine.client.client_utils import copy_file
from debusine.client.config import ConfigHandler
from debusine.client.debusine import Debusine
from debusine.client.exceptions import DebusineError
from debusine.client.models import (
    FileResponse,
    StrictBaseModel,
    model_to_json_serializable_dict,
)

Model = TypeVar("Model", bound=StrictBaseModel)


def _get_help(obj: Any) -> str | None:
    """Extract the first line from an object's docstring."""
    if obj.__doc__ is None:
        raise AssertionError(f"{obj!r} lacks a docstring")
    assert isinstance(obj.__doc__, str)
    return " ".join(
        stripped
        for line in obj.__doc__.splitlines()
        if (stripped := line.strip())
    ).rstrip(".")


class Command(abc.ABC):
    """
    Base class for defining argparse subparsers as classes.

    Subcommands are self-registering and self-documenting:

    * The subcommand name is the lowercased class name by default, or the
      ``name`` attribute of the class if present.
    * The help string is the first line of the class docstring.
    * ``Command.add_subparsers`` adds all registered subparsers to an existing
      ``ArgumentParser.add_subparsers`` instance
    """

    #: Command name
    name: ClassVar[str]
    #: String to show on help if the command is deprecated
    deprecated: ClassVar[str | None]
    #: Registry of commands by command group. A key of None means "no group"
    commands_by_group: dict[str | None, list[type["Command"]]] = defaultdict(
        list
    )

    #: Set to True if YAML data was provided on stdin
    yaml_in_input: bool

    def __init_subclass__(
        cls,
        name: str | None = None,
        group: str | None = None,
        deprecated: str | None = None,
        **kwargs: Any,
    ) -> None:
        """Register this class as a subcommand."""
        super().__init_subclass__(**kwargs)
        if inspect.isabstract(cls):
            return
        cls.name = name or cls.__name__.lower()
        cls.deprecated = deprecated
        cls.commands_by_group[group].append(cls)

    def __init__(self, args: argparse.Namespace) -> None:
        """Make the parsed arguments available as ``self.args``."""
        self.args = args
        self.yaml_in_input = False

    @classmethod
    def add_subparser(
        cls,
        subparsers: "argparse._SubParsersAction[Any]",
        group: str,
        *,
        argument: str | None = None,
        argument_help: str,
        subparser_help: str,
    ) -> None:
        """
        Add a subparser with entries from a Command group.

        :param group: the group name
        :param argument: the subcommand name for the parser (defaults to the
          group name)
        :param argument_help: help string for the subcommand
        :param subparser_help: help string for the subparser
        """
        parser = subparsers.add_parser(argument or group, help=argument_help)
        parser_commands = parser.add_subparsers(
            help=subparser_help, required=True
        )
        cls.add_parsers(parser_commands, group)

    @classmethod
    def add_parsers(
        cls,
        subparsers: "argparse._SubParsersAction[Any]",
        group: str | None = None,
    ) -> None:
        """Add all registered Command subclasses to the subparsers instance."""
        if group not in cls.commands_by_group:
            raise KeyError(
                f"Command group {group!r} not found in "
                + ', '.join(repr(x) for x in cls.commands_by_group.keys())
            )
        for command_cls in cls.commands_by_group[group]:
            description = _get_help(command_cls)
            kwargs: dict[str, Any] = {"help": description}
            if command_cls.deprecated:
                del kwargs["help"]
                description = f"Deprecated: {command_cls.deprecated}"
            subparser = subparsers.add_parser(
                command_cls.name, description=description, **kwargs
            )
            subparser.set_defaults(command_cls=command_cls)
            command_cls.configure(subparser)

    @classmethod
    def create(cls, args: argparse.Namespace) -> Optional["Command"]:
        """
        Run the command activated by the parsed command line.

        :param args: the parsed command line arguments
        :returns: True if a command was selected and run, False otherwise
        """
        if (command_cls := getattr(args, "command_cls", None)) is not None:
            assert isinstance(command_cls, type)
            assert issubclass(command_cls, Command)
            return command_cls(args)
        return None

    @classmethod
    def configure(cls, parser: argparse.ArgumentParser) -> None:
        """Configure the ArgumentParser for this subcommand."""
        parser.add_argument(
            "--yaml",
            action="store_true",
            help="Output data as machine readable YAML.",
        )

    @abc.abstractmethod
    def run(self) -> None:
        """Run the command."""

    def run_porcelain(self) -> None:
        """Run the command, managing well known exceptions."""
        try:
            self.run()
        except DebusineError as e:
            self.show_error(e)

    @staticmethod
    def _fail(error: object, *, summary: str | None = None) -> NoReturn:
        print(error, file=sys.stderr)
        if summary is not None:
            print(summary, file=sys.stderr)
        raise SystemExit(3)

    @staticmethod
    def _print_yaml(data: Any, *, file: TextIO | None = None) -> None:
        """Print data to stdout as yaml."""
        output = yaml.safe_dump(data, sort_keys=False, width=math.inf)

        print(output, file=file, end="")

    def print_yaml_output(self, data: Any) -> None:
        """Print the command output as a data structure in YAML."""
        if self.yaml_in_input:
            print("---")  # avoid confusion between input and output
        self._print_yaml(data)

    def _show_error_yaml(self, error: Exception) -> None:
        """Output a Debusine error as YAML."""
        if isinstance(error, DebusineError):
            self.print_yaml_output(
                {"result": "failure", "error": error.asdict()}
            )
        else:
            self.print_yaml_output({"result": "failure", "error": str(error)})

    def _show_error_rich(self, error: Exception) -> None:
        """Output a Debusine error for humans."""
        console = rich.console.Console()
        if isinstance(error, DebusineError):
            if error.status_code is not None:
                console.print(
                    f"Error ({error.status_code}):",
                    style="red",
                    markup=False,
                    highlight=False,
                    end=" ",
                )
            else:
                console.print(
                    "Error:",
                    style="red",
                    markup=False,
                    highlight=False,
                    end=" ",
                )
            console.print(
                error.title, style="red", markup=False, highlight=False
            )
            if error.detail is not None:
                console.print(
                    error.detail, style="yellow", markup=False, highlight=False
                )
            if error.validation_errors is not None:
                console.print(
                    rich.json.JSON.from_data(error.validation_errors, indent=2)
                )
        else:
            console.print("[red]Command failed:[/]", end=" ")
            console.print(str(error), highlight=False, markup=False)

    def show_error(self, error: Exception) -> NoReturn:
        """Output a Debusine error."""
        if self.args.yaml:
            self._show_error_yaml(error)
        else:
            self._show_error_rich(error)
        raise SystemExit(3)

    @classmethod
    def _parse_yaml_data(cls, data_yaml: str) -> dict[str, Any]:
        if data_yaml.strip() == "":
            cls._fail("Error: data must be a dictionary. It is empty")
        try:
            data = yaml.safe_load(data_yaml)
        except yaml.YAMLError as err:
            cls._fail(
                f"Error parsing YAML: {err}",
                summary="Fix the YAML data",
            )

        if (data_type := type(data)) != dict:
            cls._fail(
                f"Error: data must be a dictionary. "
                f"It is: {data_type.__name__}"
            )

        assert isinstance(data, dict)
        return data

    @staticmethod
    @contextlib.contextmanager
    def preserve_registry() -> Generator[None, None, None]:
        """Make Command registry changes ephemeral."""
        orig = Command.commands_by_group
        Command.commands_by_group = copy.deepcopy(orig)
        try:
            yield
        finally:
            Command.commands_by_group = orig


class DebusineCommand(Command, abc.ABC):
    """Command that uses a debusine object."""

    debusine: Debusine

    def _build_debusine_object(self) -> Debusine:
        """Return the debusine object matching the command line parameters."""
        return Debusine(
            base_api_url=self.server_configuration.api_url,
            scope=self.args.scope or self.server_configuration.scope,
            api_token=self.server_configuration.api_token,
            logger=logging.getLogger("debusine"),
        )

    def _setup_logging(self) -> None:
        logging_level = logging.WARNING if self.args.silent else logging.INFO

        logger = logging.getLogger("debusine")
        logger.propagate = False
        logger.setLevel(logging_level)

        handler = logging.StreamHandler(sys.stderr)
        formatter = logging.Formatter('%(message)s')
        handler.setFormatter(formatter)

        logger.addHandler(handler)

    def _setup_http_logging(self) -> None:
        if self.args.debug:
            """Do setup urllib3&requests traces."""
            # https://docs.python-requests.org/en/latest/api/#api-changes
            # Debug connection establishment
            # Use a separate DEBUG logger
            urllib3_log = logging.getLogger("urllib3")
            urllib3_log.setLevel(logging.DEBUG)
            sh = logging.StreamHandler()
            sh.setLevel(logging.DEBUG)
            sh.setFormatter(logging.Formatter(logging.BASIC_FORMAT))
            urllib3_log.addHandler(sh)

            # Debug at http.client level (requests->urllib3->http.client)
            # Use a separate DEBUG logger
            # Display the REQUEST, including HEADERS and DATA, and
            # RESPONSE with HEADERS but without DATA.  The only thing
            # missing will be the response.body which is not logged.
            http.client.HTTPConnection.debuglevel = 1
            # Divert to new logger (stderr) to avoid polluting CLI output
            requests_log = logging.getLogger("requests")
            requests_log.setLevel(logging.DEBUG)
            requests_log.addHandler(sh)

            def print_to_log(*args: str) -> None:
                requests_log.debug(" ".join(args))

            setattr(http.client, "print", print_to_log)

    @contextmanager
    def _api_call_or_fail(self) -> Generator[None, None, None]:
        """
        Context manager to handle failures from calling a method.

        :raises: exceptions.NotFoundError: server returned 404.
        :raises: UnexpectedResponseError: e.g. invalid JSON.
        :raises: ClientConnectionError: e.g. cannot connect to the server.
        :raises: DebusineError (via method() call) when debusine server
          reports an error.
        """
        try:
            yield
        except exceptions.NotFoundError as exc:
            self._fail(exc)
        except exceptions.UnexpectedResponseError as exc:
            self._fail(exc)
        except exceptions.ClientForbiddenError as server_error:
            self._fail(f'Server rejected connection: {server_error}')
        except exceptions.ClientConnectionError as client_error:
            self._fail(f'Error connecting to debusine: {client_error}')

    def _fetch_local_file(
        self, src: Path, dest: Path, artifact_file: FileResponse
    ) -> None:
        """Copy src to dest and verify that its hash matches artifact_file."""
        if not src.exists():
            self._fail(f"{str(src)!r} does not exist.")
        hashes = copy_file(src, dest, artifact_file.checksums.keys())
        if hashes["size"] != artifact_file.size:
            self._fail(
                f"{str(src)!r} size mismatch (expected {artifact_file.size} "
                f"bytes)"
            )

        for hash_name, expected_value in artifact_file.checksums.items():
            if hashes[hash_name] != expected_value:
                self._fail(
                    f"{str(src)!r} hash mismatch (expected {hash_name} "
                    f"= {expected_value})"
                )

    def __init__(self, args: argparse.Namespace) -> None:
        """Make the parsed arguments available as ``self.args``."""
        super().__init__(args)
        server_name: str | None = os.environ.get("DEBUSINE_SERVER_NAME")
        if self.args.server is not None:
            server_name = self.args.server

        configuration = ConfigHandler(
            server_name=server_name, config_file_path=self.args.config_file
        )

        try:
            self.server_configuration = configuration.server_configuration()
        except ValueError as exc:
            self._fail(exc)

        self._setup_logging()
        self.debusine = self._build_debusine_object()
        self._setup_http_logging()


class WorkspaceCommand(DebusineCommand, abc.ABC):
    """Command that operates on a workspace."""

    workspace: str
    workspace_arg_set: bool

    @classmethod
    def configure(cls, parser: argparse.ArgumentParser) -> None:
        """Configure the ArgumentParser for this subcommand."""
        super().configure(parser)
        parser.add_argument(
            "--workspace",
            "-w",
            type=str,
            metavar="NAME",
            help="Workspace name",
        )

    def __init__(self, args: argparse.Namespace) -> None:
        """Populate self.workspace."""
        super().__init__(args)
        if args.workspace is None:
            configured_default = self.server_configuration.default_workspace
            self.workspace = configured_default or "System"
            self.workspace_arg_set = False
        else:
            self.workspace = args.workspace
            self.workspace_arg_set = True


class ModelCommand(Command, abc.ABC, Generic[Model]):
    """Base for commands that list and show client models."""

    def _list_yaml(self, instances: Iterable[Model]) -> None:
        """Print the list as YAML."""
        if self.yaml_in_input:
            print("---")  # avoid confusion between input and output
        self._print_yaml(
            [model_to_json_serializable_dict(i) for i in instances]
        )

    @abc.abstractmethod
    def _list_rich(self, instances: Iterable[Model]) -> None:
        """Print the list as a table."""

    def list(self, instances: Iterable[Model]) -> None:
        """Output a list of instances."""
        if self.args.yaml:
            self._list_yaml(instances)
        else:
            self._list_rich(instances)

    def _show_yaml(self, instance: Model) -> None:
        """Show an instance in machine parsable YAML."""
        if self.yaml_in_input:
            print("---")  # avoid confusion between input and output
        self._print_yaml(model_to_json_serializable_dict(instance))

    @abc.abstractmethod
    def _show_rich(self, instance: Model) -> None:
        """Show an instance for humans."""

    def show(self, instance: Model) -> None:
        """Output a list of collections."""
        if self.args.yaml:
            self._show_yaml(instance)
        else:
            self._show_rich(instance)


class InputDataCommand(Command, abc.ABC):
    """Common code to read input for ``--data``."""

    def read_input_data(self, file: str) -> dict[str, Any]:
        """
        Read input data given a file name.

        If file is ``"-"``, read from stdin.
        """
        data: str
        match file:
            case "-":
                if sys.stdout.isatty() and sys.stdin.isatty():
                    console = rich.console.Console()
                    console.print(
                        "[bold]Waiting for YAML input...[/]"
                        " [grey50]([bold]CTRL+D[/bold] to terminate the input."
                        " Empty input defaults to [green]'{}'[/green])[/]",
                        highlight=False,
                    )
                data = sys.stdin.read()
                sys.stdin.close()
                self.yaml_in_input = True
                # Empty stdin defaults to {}
                if not data or data.isspace():
                    return {}
                pass  # Extra statement to attempt to fix coverage reporting
            case _:
                try:
                    with open(file) as fd:
                        data = fd.read()
                except OSError as e:
                    print(f"can't open {file!r}: {e}", file=sys.stderr)
                    raise SystemExit(2)

        return self._parse_yaml_data(data)


class OptionalInputDataCommand(InputDataCommand, abc.ABC):
    """Command reading YAML in input with ``--data``, if provided."""

    #: Parsed YAML data
    input_data: dict[str, Any] | None

    def __init__(self, args: argparse.Namespace) -> None:
        """Read YAML data from input."""
        super().__init__(args)
        if self.args.data is None:
            self.input_data = None
        else:
            self.input_data = self.read_input_data(self.args.data)

    @classmethod
    def configure(cls, parser: argparse.ArgumentParser) -> None:
        """Configure the ArgumentParser for this subcommand."""
        super().configure(parser)
        parser.add_argument(
            "--data",
            type=str,
            default=None,
            help="File path (or - for stdin) to read the data "
            "for the asset. YAML format. Default: data is an empty dict.",
        )


class RequiredInputDataCommand(InputDataCommand, abc.ABC):
    """Command reading YAML in input with ``--data``."""

    #: Parsed YAML data
    input_data: dict[str, Any]

    def __init__(self, args: argparse.Namespace) -> None:
        """Read YAML data from input."""
        super().__init__(args)
        self.input_data = self.read_input_data(self.args.data)

    @classmethod
    def configure(cls, parser: argparse.ArgumentParser) -> None:
        """Configure the ArgumentParser for this subcommand."""
        super().configure(parser)
        parser.add_argument(
            "--data",
            type=str,
            default="-",
            help="File path (or - for stdin) to read the data "
            "for the asset. YAML format. Default: read from stdin.",
        )
