# 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, work request management commands."""

import argparse
import os
import shutil
import subprocess
import tempfile
from itertools import islice
from pathlib import Path
from typing import Any

from debusine.artifacts import Upload
from debusine.client.commands.base import (
    DebusineCommand,
    RequiredInputDataCommand,
    WorkspaceCommand,
)
from debusine.client.debusine import Debusine
from debusine.client.models import (
    WorkRequestExternalDebsignRequest,
    WorkRequestRequest,
    WorkRequestResponse,
    model_to_json_serializable_dict,
)


class List(DebusineCommand, group="work-request"):
    """List up to limit most recent work requests."""

    @classmethod
    def configure(cls, parser: argparse.ArgumentParser) -> None:
        """Configure the ArgumentParser for this subcommand."""
        parser.add_argument(
            '--limit',
            type=int,
            metavar="LIMIT",
            default=20,
            help="List the LIMIT most recent work requests",
        )

    def run(self) -> None:
        """Run the command."""
        with self._api_call_or_fail():
            work_requests = list(
                islice(self.debusine.work_request_iter(), self.args.limit)
            )
        result = {
            "results": [work_request.dict() for work_request in work_requests]
        }
        self.print_yaml_output(result)


class LegacyList(
    List, name="list-work-requests", deprecated="see `work-request list`"
):
    """List up to limit most recent work requests."""


class Show(DebusineCommand, group="work-request"):
    """Print the status of a work request."""

    @classmethod
    def configure(cls, parser: argparse.ArgumentParser) -> None:
        """Configure the ArgumentParser for this subcommand."""
        parser.add_argument(
            'work_request_id',
            type=int,
            help='Work request id to show the information',
        )

    def run(self) -> None:
        """Run the command."""
        with self._api_call_or_fail():
            work_request = self.debusine.work_request_get(
                self.args.work_request_id
            )

        artifacts_information = []
        for artifact_id in work_request.artifacts:
            with self._api_call_or_fail():
                artifact_information = self.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_output(result)


class LegacyShow(
    Show, name="show-work-request", deprecated="see `work-request show`"
):
    """Print the status of a work request."""


class Create(RequiredInputDataCommand, WorkspaceCommand, group="work-request"):
    """
    Create a work request and schedule the execution.

    Work request is read from stdin in YAML format
    """

    @classmethod
    def configure(cls, parser: argparse.ArgumentParser) -> None:
        """Configure the ArgumentParser for this subcommand."""
        super().configure(parser)
        parser.add_argument(
            'task_name', type=str, help='Task name for the work request'
        )
        parser.add_argument(
            "--event-reactions",
            type=argparse.FileType("r"),
            help="File path in YAML format requesting notifications.",
        )

    def run(self) -> None:
        """Run the command."""
        event_reactions = '{}'
        if self.args.event_reactions:
            event_reactions = self.args.event_reactions.read()
            self.args.event_reactions.close()

        task_name = self.args.task_name
        workspace = self.workspace
        parsed_task_data = self.input_data

        parsed_event_reactions = self._parse_yaml_data(event_reactions)

        work_request = WorkRequestRequest(
            task_name=task_name,
            workspace=workspace,
            task_data=parsed_task_data,
            event_reactions=parsed_event_reactions,
        )

        with self._api_call_or_fail():
            work_request_created = self.debusine.work_request_create(
                work_request
            )

        output: dict[str, Any] = {
            'result': 'success',
            'message': (f"Work request registered: {work_request_created.url}"),
            'work_request_id': work_request_created.id,
        }
        self.print_yaml_output(output)


class LegacyCreate(
    Create, name="create-work-request", deprecated="see `work-request create`"
):
    """
    Create a work request and schedule the execution.

    Work request is read from stdin in YAML format
    """


class Manage(DebusineCommand, group="work-request"):
    """Manage a work request."""

    @classmethod
    def configure(cls, parser: argparse.ArgumentParser) -> None:
        """Configure the ArgumentParser for this subcommand."""
        parser.add_argument(
            "work_request_id", type=int, help="Work request id to manage"
        )
        parser.add_argument(
            "--set-priority-adjustment",
            type=int,
            metavar="ADJUSTMENT",
            help="Set priority adjustment (positive or negative number)",
        )

    def run(self) -> None:
        """Run the command."""
        work_request_id = self.args.work_request_id
        priority_adjustment = self.args.set_priority_adjustment
        if priority_adjustment is None:
            self._fail("Error: no changes specified")

        with self._api_call_or_fail():
            self.debusine.work_request_update(
                work_request_id, priority_adjustment=priority_adjustment
            )


class LegacyManage(
    Manage, name="manage-work-request", deprecated="see `work-request manage`"
):
    """Manage a work request."""


class Retry(DebusineCommand, group="work-request"):
    """Retry a failed work request."""

    @classmethod
    def configure(cls, parser: argparse.ArgumentParser) -> None:
        """Configure the ArgumentParser for this subcommand."""
        parser.add_argument(
            "work_request_id", type=int, help="Work request id to retry"
        )

    def run(self) -> None:
        """Run the command."""
        with self._api_call_or_fail():
            self.debusine.work_request_retry(self.args.work_request_id)


class LegacyRetry(
    Retry, name="retry-work-request", deprecated="see `work-request retry`"
):
    """Retry a failed work request."""


class Abort(DebusineCommand, group="work-request"):
    """Abort a failed work request."""

    @classmethod
    def configure(cls, parser: argparse.ArgumentParser) -> None:
        """Configure the ArgumentParser for this subcommand."""
        parser.add_argument(
            "work_request_id", type=int, help="Work request id to retry"
        )

    def run(self) -> None:
        """Run the command."""
        with self._api_call_or_fail():
            self.debusine.work_request_abort(self.args.work_request_id)


class LegacyAbort(
    Abort, name="abort-work-request", deprecated="see `work-request abort`"
):
    """Abort a failed work request."""


class Sign(DebusineCommand, group="work-request"):
    """Provide a work request with an external signature."""

    @classmethod
    def configure(cls, parser: argparse.ArgumentParser) -> None:
        """Configure the ArgumentParser for this subcommand."""
        parser.add_argument(
            "work_request_id",
            type=int,
            help="Work request id that needs a signature",
        )
        parser.add_argument(
            "--local-file",
            "-l",
            type=Path,
            help=(
                "Path to the .changes file to sign, locally. "
                "If not specified, it will be downloaded from the server."
            ),
        )
        parser.add_argument(
            "extra_args",
            nargs="*",
            help=(
                "Additional arguments passed to debsign. "
                "Use a -- to pass options, e.g. -- -kMYKEY."
            ),
        )

    def run(self) -> None:
        """Run the command."""
        # Find out what kind of work request we're dealing with.
        with self._api_call_or_fail():
            work_request = self.debusine.work_request_get(
                self.args.work_request_id
            )
        match (work_request.task_type, work_request.task_name):
            case "Wait", "externaldebsign":
                self._provide_signature_debsign(
                    work_request, self.args.local_file, self.args.extra_args
                )
            case _:
                self._fail(
                    f"Don't know how to provide signature for "
                    f"{work_request.task_type}/{work_request.task_name} "
                    f"work request"
                )

    def _provide_signature_debsign(
        self,
        work_request: WorkRequestResponse,
        local_file: Path | None,
        debsign_args: list[str],
    ) -> None:
        """Provide a work request with an external signature using `debsign`."""
        if local_file is not None:
            if local_file.suffix != ".changes":
                self._fail(
                    f"--local-file {str(local_file)!r} is not a .changes file."
                )
            if not local_file.exists():
                self._fail(f"--local-file {str(local_file)!r} does not exist.")
        # Get a version of the work request with its dynamic task data
        # resolved.
        with self._api_call_or_fail():
            work_request = self.debusine.work_request_external_debsign_get(
                work_request.id
            )
        assert work_request.dynamic_task_data is not None
        with self._api_call_or_fail():
            unsigned = self.debusine.artifact_get(
                work_request.dynamic_task_data["unsigned_id"]
            )

        if local_file:
            expected_changes = [
                file
                for file in unsigned.files.keys()
                if file.endswith(".changes")
            ][0]
            if local_file.name not in unsigned.files:
                self._fail(
                    f"{str(local_file)!r} is not part of artifact "
                    f"{work_request.dynamic_task_data['unsigned_id']}. "
                    f"Expecting {expected_changes!r}"
                )

        with tempfile.TemporaryDirectory(
            prefix="debusine-debsign-"
        ) as tmp_name:
            # Get the individual files that need to be signed.
            tmp = Path(tmp_name).resolve()
            for name, file_response in unsigned.files.items():
                path = tmp / name
                assert path.resolve().is_relative_to(tmp)
                assert "/" not in name
                if (
                    name.endswith(".changes")
                    or name.endswith(".dsc")
                    or name.endswith(".buildinfo")
                ):
                    if local_file:
                        self._fetch_local_file(
                            local_file.parent / name, path, file_response
                        )
                    else:
                        with self._api_call_or_fail():
                            self.debusine.download_artifact_file(
                                unsigned, name, path
                            )
            # Upload artifacts are guaranteed to have exactly one .changes
            # file.
            [changes_path] = [
                path for path in tmp.iterdir() if path.name.endswith(".changes")
            ]

            # Call debsign.
            subprocess.run(
                ["debsign", "--re-sign", changes_path.name, *debsign_args],
                cwd=tmp,
                check=True,
            )

            # Create a new artifact and upload the files to it.
            signed_local = Upload.create(
                changes_file=changes_path, allow_remote=True
            )
            with self._api_call_or_fail():
                signed_remote = self.debusine.upload_artifact(
                    signed_local, workspace=unsigned.workspace
                )

            # Complete the work request using the signed artifact.
            with self._api_call_or_fail():
                self.debusine.work_request_external_debsign_complete(
                    work_request.id,
                    WorkRequestExternalDebsignRequest(
                        signed_artifact=signed_remote.id
                    ),
                )


class LegacySign(
    Sign, name="provide-signature", deprecated="see `work-request sign`"
):
    """Provide a work request with an external signature."""


class OnCompleted(DebusineCommand, group="work-request", name="on-completed"):
    """
    Execute a command when a work request is completed.

    Arguments to the command: any additional arguments provided
    here, followed by WorkRequest.id and WorkRequest.result
    """

    @classmethod
    def configure(cls, parser: argparse.ArgumentParser) -> None:
        """Configure the ArgumentParser for this subcommand."""
        parser.add_argument(
            "--workspace",
            "-w",
            nargs="+",
            type=str,
            help="Workspace names to monitor. If not specified: receive "
            "notifications from all the workspaces that the user "
            "has access to",
        )
        parser.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",
        )
        parser.add_argument(
            "command",
            type=str,
            help="Path to the command to execute",
        )
        parser.add_argument(
            "extra_args",
            nargs="*",
            help="Additional arguments to pass to the command to execute",
        )

    def run(self) -> None:
        """Run the command."""
        workspaces = self.args.workspace
        last_completed_at = self.args.last_completed_at
        command = [self.args.command] + self.args.extra_args
        if shutil.which(command[0]) is None:
            self._fail(
                f'Error: "{command[0]}" 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)

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


class LegacyOnCompleted(
    OnCompleted,
    name="on-work-request-completed",
    deprecated="see `work-request on-completed`",
):
    """
    Execute a command when a work request is completed.

    Arguments to the command: any additional arguments provided
    here, followed by WorkRequest.id and WorkRequest.result
    """
