# 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 datetime as dt
import os
import shutil
import subprocess
import tempfile
from collections.abc import Iterable
from itertools import islice
from pathlib import Path
from typing import override

import rich
import rich.json
from rich.table import Table

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


class WorkRequestModelCommand(
    ModelCommand[WorkRequestResponse], DebusineCommand
):
    """Output WorkRequestResponse objects."""

    @override
    def _list_rich(self, instances: Iterable[WorkRequestResponse]) -> None:
        table = Table(box=rich.box.MINIMAL_DOUBLE_HEAD)
        table.add_column("ID")
        table.add_column("Task")
        table.add_column("Status")
        table.add_column("Progress")
        table.add_column("Worker")
        table.add_column("Task data")
        for wr in instances:
            url = self._url(wr)
            status = [wr.status]
            if wr.result != WorkRequestResults.NONE:
                status.append(wr.result)
            progress = [f"Created: {self._format_timestamp(wr.created_at)}"]
            if wr.started_at:
                progress.append(
                    f"Started: {self._format_timestamp(wr.started_at)}"
                )
            if wr.completed_at:
                progress.append(
                    f"Completed: {self._format_timestamp(wr.completed_at)}"
                )
            table.add_row(
                f"[link={url}]#{wr.id}[/]",
                f"{wr.task_type} {wr.task_name}",
                " ".join(status),
                "\n".join(progress),
                # TODO: show the worker name, if we can extend the API to get
                # that information
                self._format_int(wr.worker),
                rich.json.JSON.from_data(wr.task_data),
            )
        rich.print(table)

    def _url(self, instance: WorkRequestResponse) -> str:
        return self.debusine.webui_url(instance.get_link_url("webui_self"))

    def _format_timestamp(self, ts: dt.datetime | None) -> str | None:
        if ts is None:
            return None
        return ts.strftime("%Y-%m-%d %H:%M:%S")

    def _format_int(self, value: int | None) -> str | None:
        if value is None:
            return None
        return str(value)

    def _format_float(self, value: float) -> str | None:
        return f"{value:.2f}"

    @override
    def _show_rich(self, instance: WorkRequestResponse) -> None:
        url = self._url(instance)
        table = Table(box=rich.box.SIMPLE, show_header=False)
        table.add_column("Name", justify="right")
        table.add_row("ID:", f"#{instance.id} [link={url}]{url}[/]")
        table.add_row("Task:", f"{instance.task_type} {instance.task_name}")
        table.add_row("Workspace:", f"{instance.scope}/{instance.workspace}")
        table.add_row(
            "Created at:", self._format_timestamp(instance.created_at)
        )
        if instance.started_at is not None:
            table.add_row(
                "Started at:", self._format_timestamp(instance.started_at)
            )
        if instance.completed_at is not None:
            table.add_row(
                "Completed at:", self._format_timestamp(instance.completed_at)
            )
        if instance.duration is not None:
            table.add_row("Duration:", self._format_float(instance.duration))
        if instance.result == WorkRequestResults.NONE:
            table.add_row("Status:", instance.status)
        else:
            table.add_row("Status:", f"{instance.status} {instance.result}")
        if instance.worker is not None:
            # TODO: show the worker name, if we can extend the API to get that
            # information
            table.add_row("Worker:", self._format_int(instance.worker))
        if instance.priority_base != 0 or instance.priority_adjustment != 0:
            table.add_row(
                "Priority:",
                f"{instance.priority_base} + {instance.priority_adjustment}",
            )
        table.add_row(
            "Task data:", rich.json.JSON.from_data(instance.task_data)
        )
        if instance.dynamic_task_data:
            table.add_row(
                "Dynamic task data:",
                rich.json.JSON.from_data(instance.dynamic_task_data),
            )
        if instance.artifacts:
            table.add_row(
                "Artifacts:",
                rich.json.JSON.from_data(instance.artifacts),
            )
        rich.print(table)


class List(WorkRequestModelCommand, 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."""
        super().configure(parser)
        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)
            )
        self.list(work_requests)


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."""
        super().configure(parser)
        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(
                artifact_information.model_dump(mode="json")
            )

        result = work_request.model_dump()
        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(
    WorkRequestModelCommand,
    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
            )

        self.feedback("Work request started:")
        self.show(work_request_created)


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."""
        super().configure(parser)
        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."""
        super().configure(parser)
        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."""
        super().configure(parser)
        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."""
        super().configure(parser)
        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", ".dsc", ".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."""
        super().configure(parser)
        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
    """
