# Copyright 2022 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."""

import argparse
import http
import logging
import math
import os
import re
import shutil
import signal
import sys
from datetime import datetime
from functools import partial
from itertools import islice
from pathlib import Path
from typing import Any, Callable, Optional

from dateutil.parser import isoparse

from debian.deb822 import Changes, Dsc

from requests.exceptions import RequestException

import yaml
from yaml import YAMLError

from debusine.artifacts import (
    BinaryPackage,
    BinaryPackages,
    LocalArtifact,
    SourcePackage,
    Upload,
)
from debusine.client import exceptions
from debusine.client.config import ConfigHandler
from debusine.client.debusine import Debusine
from debusine.client.exceptions import DebusineError
from debusine.client.models import (
    WorkRequestRequest,
    model_to_json_serializable_dict,
)
from debusine.client.utils import get_debian_package


class Cli:
    """
    Entry point for the command line debusine client.

    Usage:
        main = Cli(sys.argv[1:]) # [1:] to exclude the script name
        main.execute()
    """

    def __init__(self, argv):
        """Initialize object."""
        self._argv = argv

    @staticmethod
    def _exit(signum, frame):  # noqa: U100
        raise SystemExit(0)

    def _parse_args(self):
        """Parse argv and store results in self.args."""
        parser = argparse.ArgumentParser(
            prog='debusine',
            description='Interacts with a debusine server.',
            formatter_class=argparse.ArgumentDefaultsHelpFormatter,
        )

        parser.add_argument(
            '--server',
            help='Set server to be used (use configuration file default '
            'it not specified)',
        )

        parser.add_argument(
            '--config-file',
            default=ConfigHandler.DEFAULT_CONFIG_FILE_PATH,
            help='Config file path',
        )

        parser.add_argument("-s", "--silent", action="store_true")

        parser.add_argument(
            "-d", "--debug", action="store_true", help='Print HTTP traffic'
        )

        subparsers = parser.add_subparsers(
            help='Sub command', dest='sub-command', required=True
        )
        list_work_requests = subparsers.add_parser(
            'list-work-requests',
            help='List all work requests',
        )
        list_work_requests.add_argument(
            '--limit',
            type=int,
            metavar="LIMIT",
            default=20,
            help="List the LIMIT most recent work requests",
        )

        show_work_request = subparsers.add_parser(
            'show-work-request',
            help='Print the status of a work request',
        )
        show_work_request.add_argument(
            'work_request_id',
            type=int,
            help='Work request id to show the information',
        )

        create_work_request = subparsers.add_parser(
            'create-work-request',
            help='Create a work request and schedule the execution. '
            'Work request is read from stdin in YAML format',
        )
        create_work_request.add_argument(
            'task_name', type=str, help='Task name for the work request'
        )
        create_work_request.add_argument(
            "--workspace", type=str, help="Workspace name"
        )
        create_work_request.add_argument(
            "--data",
            type=argparse.FileType("r"),
            help="File path (or - for stdin) to read the data "
            "for the work request. YAML format. Defaults to stdin.",
            default="-",
        )

        create_artifact = subparsers.add_parser(
            "create-artifact", help="Create an artifact"
        )
        create_artifact.add_argument(
            "category", type=str, help="Category of artifact"
        )
        create_artifact.add_argument(
            "--workspace", type=str, help="Workspace for this artifact"
        )
        create_artifact.add_argument(
            "--upload", nargs="+", help="Files to upload", default=[]
        )
        create_artifact.add_argument(
            "--upload-base-directory",
            type=str,
            default=None,
            help="Base directory for files with relative file path",
        )
        create_artifact.add_argument(
            "--expire-at",
            type=isoparse,
            help="If not passed: artifact does not expire. "
            "If passed: set expire date of artifact.",
        )
        create_artifact.add_argument(
            "--data",
            type=argparse.FileType("r"),
            help="File path (or - for stdin) to read the data "
            "for the artifact. YAML format",
        )

        import_debian_artifact = subparsers.add_parser(
            "import-debian-artifact",
            help="Import a Debian source/binary package",
        )
        import_debian_artifact.add_argument(
            "--workspace", type=str, help="Workspace for this artifact"
        )
        import_debian_artifact.add_argument(
            "--expire-at",
            type=isoparse,
            help="If not passed: artifact does not expire. "
            "If passed: set expire date of artifact.",
        )
        import_debian_artifact.add_argument(
            "upload",
            help=".changes or .dsc to upload",
        )

        download_artifact = subparsers.add_parser(
            "download-artifact",
            help="Download an artifact in .tar.gz format",
        )
        download_artifact.add_argument(
            "id", type=int, help="Artifact to download"
        )
        download_artifact.add_argument(
            "--target-directory",
            type=Path,
            help="Directory to save the artifact",
            default=Path.cwd(),
        )
        download_artifact.add_argument(
            "--tarball",
            action="store_true",
            help="Save the artifact as .tar.gz",
        )

        show_artifact = subparsers.add_parser(
            "show-artifact",
            help="Show artifact information",
        )
        show_artifact.add_argument(
            "id", type=int, help="Show information of the artifact"
        )

        on_work_request_completed = subparsers.add_parser(
            "on-work-request-completed",
            help="Execute a command when a work request is completed. "
            "Arguments to the command: WorkRequest.id and "
            "WorkRequest.result",
        )
        on_work_request_completed.add_argument(
            "--workspace",
            nargs="+",
            type=str,
            help="Workspace names to monitor. If not specified: receive "
            "notifications from all the workspaces that the user "
            "has access to",
        )
        on_work_request_completed.add_argument(
            "--last-completed-at",
            type=Path,
            help="If not passed: does not get any past notifications. "
            "If passed: write into it the WorkRequest.completed_at "
            "and when running again retrieve missed notifications",
        )
        on_work_request_completed.add_argument(
            "command",
            type=str,
            help="Path to the command to execute",
        )

        self.args = parser.parse_args(self._argv)

    def _build_debusine_object(self):
        """Return the debusine object matching the command line parameters."""
        configuration = ConfigHandler(
            server_name=self.args.server,
            config_file_path=self.args.config_file,
        )

        server_configuration = configuration.server_configuration()

        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)

        return Debusine(
            base_api_url=server_configuration['api-url'],
            api_token=server_configuration['token'],
            logger=logger,
        )

    def _setup_http_logging(self):
        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):
                requests_log.debug(" ".join(args))

            http.client.print = print_to_log

    def execute(self):
        """Execute the command requested by the user."""
        signal.signal(signal.SIGINT, self._exit)
        signal.signal(signal.SIGTERM, self._exit)

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

        sub_command = getattr(self.args, 'sub-command')

        if sub_command == 'list-work-requests':
            self._list_work_requests(debusine, self.args.limit)
        elif sub_command == 'show-work-request':
            self._show_work_request(debusine, self.args.work_request_id)
        elif sub_command == 'create-work-request':
            data = self.args.data.read()
            self.args.data.close()

            self._create_work_request(
                debusine, self.args.task_name, self.args.workspace, data
            )
        elif sub_command == "create-artifact":
            if self.args.data is not None:
                data = self.args.data.read()
                self.args.data.close()
            else:
                data = None

            self._create_artifact(
                debusine,
                self.args.category,
                self.args.workspace,
                self.args.upload,
                self.args.upload_base_directory,
                self.args.expire_at,
                data,
            )
        elif sub_command == "import-debian-artifact":
            self._import_debian_artifact(
                debusine,
                self.args.workspace,
                self.args.upload,
                self.args.expire_at,
            )
        elif sub_command == "download-artifact":
            self._download_artifact(
                debusine,
                self.args.id,
                tarball=self.args.tarball,
                target_directory=self.args.target_directory,
            )
        elif sub_command == "show-artifact":
            self._show_artifact(debusine, self.args.id)
        elif sub_command == "on-work-request-completed":
            self._on_work_request_completed(
                debusine,
                workspaces=self.args.workspace,
                last_completed_at=self.args.last_completed_at,
                command=self.args.command,
            )
        else:  # pragma: no cover
            pass  # Can never be reached

    @staticmethod
    def _print_yaml(dictionary):
        """Print dictionary to stdout as yaml."""
        output = yaml.safe_dump(dictionary, sort_keys=False, width=math.inf)
        print(output, end="")

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

    def _list_work_requests(self, debusine: Debusine, limit: int):
        """List up to limit most recent work requests."""
        work_requests = self._api_call_or_fail(
            lambda: list(islice(debusine.work_request_iter(), limit))
        )
        result = {
            "results": [work_request.dict() for work_request in work_requests]
        }
        self._print_yaml(result)

    def _show_work_request(self, debusine, work_request_id):
        """Print the task information for work_request_id."""
        work_request = self._api_call_or_fail(
            debusine.work_request_get, work_request_id
        )

        artifacts_information = []
        for artifact_id in work_request.artifacts:
            artifact_information = self._api_call_or_fail(
                debusine.artifact_get, artifact_id
            )
            artifacts_information.append(
                model_to_json_serializable_dict(artifact_information)
            )

        result = work_request.dict()
        result["artifacts"] = artifacts_information

        self._print_yaml(result)

    def _create_work_request(
        self, debusine: Debusine, task_name: str, workspace: str, task_data
    ):
        task_data = self._parse_yaml_data(task_data)

        work_request = WorkRequestRequest(
            task_name=task_name, workspace=workspace, task_data=task_data
        )

        try:
            work_request_created = self._api_call_or_fail(
                debusine.work_request_create, work_request
            )
        except DebusineError as err:
            output = {"result": "failure", "error": err.asdict()}
        else:
            output = {
                'result': 'success',
                'message': f'Work request registered on '
                f'{debusine.base_api_url} '
                f'with id {work_request_created.id}.',
                'work_request_id': work_request_created.id,
            }
        self._print_yaml(output)

    def _api_call_or_fail(self, method, *args, **kwargs):
        """
        Call method with args and kwargs.

        :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:
            result = method(*args, **kwargs)
        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}')

        return result

    @classmethod
    def _parse_yaml_data(cls, data_yaml: Optional[str]) -> dict[str, Any]:
        if data_yaml is not None:
            if data_yaml.strip() == "":
                cls._fail("Error: data must be a dictionary. It is empty")
            try:
                data = yaml.safe_load(data_yaml)
            except 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__}"
                )
        else:
            data = {}

        return data

    def _create_artifact(
        self,
        debusine,
        artifact_type: str,
        workspace: str,
        upload_paths: list[str],
        upload_base_directory: Optional[str],
        expire_at: Optional[datetime],
        data_yaml,
    ):
        data = self._parse_yaml_data(data_yaml)

        try:
            artifact_cls = LocalArtifact.class_from_category(artifact_type)
        except ValueError:
            self._fail(f"Error: invalid artifact category: {artifact_type}")
        local_artifact = artifact_cls(data=data)

        if upload_base_directory is not None:
            upload_base_dir = Path(upload_base_directory).absolute()
        else:
            upload_base_dir = None

        try:
            for upload_path in upload_paths:
                local_artifact.add_local_file(
                    Path(upload_path), artifact_base_dir=upload_base_dir
                )
        except ValueError as exc:
            self._fail(f"Cannot create artifact: {exc}")

        try:
            artifact_created = self._api_call_or_fail(
                debusine.artifact_create,
                local_artifact,
                workspace=workspace,
                expire_at=expire_at,
            )
        except DebusineError as err:
            output = {"result": "failure", "error": err.asdict()}
            self._print_yaml(output)
        else:
            output = {
                "result": "success",
                "message": f"New artifact created in {debusine.base_api_url} "
                f"in workspace {artifact_created.workspace} "
                f"with id {artifact_created.id}.",
                "artifact_id": artifact_created.id,
                "files_to_upload": len(artifact_created.files_to_upload),
                "expire_at": expire_at,
            }

            files_to_upload = {}
            for artifact_path in artifact_created.files_to_upload:
                files_to_upload[artifact_path] = local_artifact.files[
                    artifact_path
                ]

            self._print_yaml(output)
            self._api_call_or_fail(
                debusine.upload_files,
                artifact_created.id,
                files_to_upload,
            )

    def _prepare_changes_for_upload(
        self, changes: Path
    ) -> list[LocalArtifact[Any]]:
        with changes.open("rb") as f:
            parsed = Changes(f)
        architectures = parsed["Architecture"].split()
        artifact_factories: list[Callable[[], LocalArtifact[Any]]] = []
        for architecture in architectures:
            if architecture == "source":
                dscs = [
                    file["name"]
                    for file in parsed["Files"]
                    if file["name"].endswith(".dsc")
                ]
                if len(dscs) != 1:
                    self._fail(
                        f"Expecting exactly 1 .dsc in source package, "
                        f"found {len(dscs)}"
                    )
                artifact_factories.append(
                    partial(
                        self._prepare_dsc_for_upload, changes.parent / dscs[0]
                    )
                )
            else:
                debs = [
                    changes.parent / file["name"]
                    for file in parsed["Files"]
                    if file["name"].endswith(
                        (
                            f"_{architecture}.deb",
                            f"_{architecture}.udeb",
                        )
                    )
                ]
                if len(debs) < 1:
                    self._fail(
                        "Expecting at least one .deb per arch in "
                        "binary packages"
                    )

                srcpkg_name = parsed["Source"]
                srcpkg_version = parsed["Version"]
                m = re.match(
                    r"^(?P<name>\S+) \((?P<version>\S+)\)$", srcpkg_name
                )
                if m:
                    srcpkg_name = m.group("name")
                    srcpkg_version = m.group("version")

                # Create artifacts for each individual .deb.
                for deb in debs:
                    artifact_factories.append(
                        partial(BinaryPackage.create, file=deb)
                    )
                # For convenience, also create a grouped artifact for all
                # the .debs.
                artifact_factories.append(
                    partial(
                        BinaryPackages.create,
                        srcpkg_name=srcpkg_name,
                        srcpkg_version=srcpkg_version,
                        version=parsed["Version"],
                        architecture=architecture,
                        files=debs,
                    )
                )
        artifacts: list[LocalArtifact[Any]] = [
            Upload.create(changes_file=changes)
        ] + [factory() for factory in artifact_factories]
        return artifacts

    def _prepare_dsc_for_upload(self, dsc: Path) -> SourcePackage:
        files = [dsc]
        directory = dsc.parent
        with dsc.open() as f:
            parsed = Dsc(f)
            name = parsed["source"]
            version = parsed["version"]
            for file in parsed["files"]:
                files.append(directory / file["name"])
        return SourcePackage.create(name=name, version=version, files=files)

    def _import_debian_artifact(
        self,
        debusine: Debusine,
        workspace: str,
        upload: str,
        expire_at: Optional[datetime],
    ):
        result: dict[str, Any] = {}
        uploaded = []
        try:
            with get_debian_package(upload) as path:
                artifacts: list[LocalArtifact[Any]]
                if path.suffix == ".changes":
                    artifacts = self._prepare_changes_for_upload(path)
                elif path.suffix == ".dsc":
                    artifacts = [self._prepare_dsc_for_upload(path)]
                elif path.suffix == ".deb":
                    raise ValueError(
                        "Direct uploads of individual .deb files aren't yet "
                        "possible. However they can be uploaded by using a "
                        ".changes file."
                    )
                else:
                    raise ValueError(
                        "Only source packages (.dsc) and source/binary uploads "
                        "(.changes) can be directly imported with this "
                        f"command. {path} is not supported."
                    )
                for artifact in artifacts:
                    remote_artifact = self._api_call_or_fail(
                        debusine.upload_artifact,
                        artifact,
                        workspace=workspace,
                        expire_at=expire_at,
                    )
                    uploaded.append(remote_artifact)
            primary_artifact = uploaded[0]
            result = {
                "result": "success",
                "message": (
                    f"New artifact created in {debusine.base_api_url} "
                    f"in workspace {remote_artifact.workspace} with id "
                    f"{primary_artifact.id}."
                ),
                "artifact_id": primary_artifact.id,
            }
            for related_artifact in uploaded[1:]:
                self._api_call_or_fail(
                    debusine.relation_create,
                    related_artifact.id,
                    remote_artifact.id,
                    "extends",
                )
                result.setdefault("extends", []).append(
                    {"artifact_id": related_artifact.id}
                )
                self._api_call_or_fail(
                    debusine.relation_create,
                    related_artifact.id,
                    remote_artifact.id,
                    "relates-to",
                )
                result.setdefault("relates_to", []).append(
                    {"artifact_id": related_artifact.id}
                )

        except (FileNotFoundError, ValueError, RequestException) as err:
            result["result"] = "failure"
            result["error"] = str(err)
        except DebusineError as err:
            result["result"] = "failure"
            result["error"] = err.asdict()

        self._print_yaml(result)
        if result["result"] != "success":
            raise SystemExit(3)

    def _download_artifact(
        self,
        debusine: Debusine,
        artifact_id: int,
        *,
        target_directory: Path,
        tarball: bool,
    ):
        if not os.access(target_directory, os.W_OK):
            self._fail(f"Error: Cannot write to {target_directory}")

        self._api_call_or_fail(
            debusine.download_artifact,
            artifact_id,
            destination=target_directory,
            tarball=tarball,
        )

    def _show_artifact(self, debusine: Debusine, artifact_id: int):
        artifact = self._api_call_or_fail(debusine.artifact_get, artifact_id)
        self._print_yaml(model_to_json_serializable_dict(artifact))

    def _on_work_request_completed(
        self,
        debusine: Debusine,
        *,
        workspaces: list[str],
        last_completed_at: Optional[Path],
        command: str,
    ):
        if shutil.which(command) is None:
            self._fail(
                f'Error: "{command}" does not exist or is not executable'
            )

        if last_completed_at is not None:
            # Check that the file can be written or created
            if not os.access(last_completed_at.parent, os.W_OK):
                self._fail(
                    f'Error: write access '
                    f'denied for directory "{last_completed_at.parent}"'
                )

            if last_completed_at.exists() and (
                not os.access(last_completed_at, os.W_OK)
                or not os.access(last_completed_at, os.R_OK)
            ):
                self._fail(
                    'Error: write or read access '
                    f'denied for "{last_completed_at}"'
                )

            if not last_completed_at.exists():
                # Create it now. If it fails better now than later
                Debusine.write_last_completed_at(last_completed_at, None)

        debusine.on_work_request_completed(
            workspaces=workspaces,
            last_completed_at=last_completed_at,
            command=command,
            working_directory=Path.cwd(),
        )
