# 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 WorkRequest view."""

import abc
import datetime as dt
import functools
from dataclasses import dataclass
from typing import Any, TYPE_CHECKING, cast, override

from django.contrib import messages
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.http import HttpRequest, HttpResponse
from django.shortcuts import redirect, render
from django.template.defaultfilters import filesizeformat
from django.utils.duration import duration_string
from django.views.generic.base import View
from rest_framework import status

import debusine.worker.tags as wtags
from debusine.artifacts.models import TaskTypes
from debusine.db.context import context
from debusine.db.models import Artifact, User, WorkRequest
from debusine.db.models.work_requests import (
    CannotAbort,
    CannotRetry,
    CannotUnblock,
    WorkRequestQuerySet,
)
from debusine.db.tasks import DBTask
from debusine.server.tasks.wait import Confirm
from debusine.server.workflows import Workflow
from debusine.server.workflows.models import WorkRequestManualUnblockAction
from debusine.tasks.models import LookupMultiple, LookupSingle, OutputData
from debusine.web.forms import WorkRequestConfirmForm, WorkRequestUnblockForm
from debusine.web.views import sidebar
from debusine.web.views.base import (
    DetailViewBase,
    FormMixinBase,
    ListViewBase,
    SingleObjectMixinBase,
    WorkspaceView,
)
from debusine.web.views.http_errors import HttpError400, catch_http_errors
from debusine.web.views.input_fields import TaskInputsWidget
from debusine.web.views.mixins import RightbarUIMixin
from debusine.web.views.places import Place
from debusine.web.views.table import TableMixin
from debusine.web.views.tables import ArtifactTable, WorkRequestTable
from debusine.web.views.ui.work_requests import WorkRequestUI
from debusine.web.views.view_utils import (
    highlight_as_json,
    highlight_as_yaml,
    syntax_highlight,
)
from debusine.web.views.widgets import TemplateWidget

if TYPE_CHECKING:
    from debusine.db.models.artifacts import ArtifactWithComplete

    ArtifactWithComplete  # fake usage for vulture


class Tab(TemplateWidget):
    """Define a tab."""

    def __init__(
        self, name: str, label: str, context: dict[str, Any] | None = None
    ) -> None:
        """Store name and label."""
        self.name = name
        self.label = label
        # TODO: this is here only to ease transition from previous WorkRequest
        # plugins. Eventually plugins will move their get_context_data to a Tab
        # subclass
        self.context = context or {}

    @override
    def get_template_name(self) -> str:
        return f"web/work_request-detail-{self.name}.html"

    @override
    def get_context_data(self) -> dict[str, Any]:
        ctx = super().get_context_data()
        ctx.update(self.context)
        return ctx


class WorkRequestObjectMixin(WorkspaceView):
    """
    Add permission filtering to a Single/MultipleObjectMixin.

    Since this inherits from WorkspaceView, this also sets the workspace in the
    context and checks that it is accessible.
    """

    def get_queryset(self) -> WorkRequestQuerySet[WorkRequest]:
        """Filter work requests by current workspace."""
        # Both SingleObjectMixin and MultipleObjectsMixin define a get_queryset
        # without a common base, so we cannot inherit from a base class with
        # the right signature.
        qs = cast(
            WorkRequestQuerySet[WorkRequest],
            super().get_queryset(),  # type: ignore[misc]
        )
        return qs.in_current_workspace().can_display(context.pc)


@dataclass(frozen=True)
class InputArtifactsRow:
    """Represents a InputArtifact row for the template."""

    input_label: str
    lookup_display: LookupSingle | LookupMultiple
    artifacts: list[Artifact]


class WorkRequestDetailView(
    RightbarUIMixin,
    WorkRequestObjectMixin,
    DetailViewBase[WorkRequest],
):
    """Show a work request."""

    queryset = WorkRequest.objects.select_related(
        "workspace__scope", "workflow_root"
    )
    context_object_name = "work_request"
    template_name = "web/work_request-detail.html"

    @functools.cached_property
    def _validation_error_message(self) -> str | None:
        """If the work request fails validation, return a suitable message."""
        try:
            self.object.full_clean()
        except ValidationError as e:
            return str(e)
        else:
            return None

    @functools.cached_property
    def ui(self) -> WorkRequestUI:
        """Return the UI helper for this work request."""
        return self.object.ui(self.request)

    @functools.cached_property
    def task(self) -> DBTask[Any] | None:
        """
        Get the task for the current work request.

        :return: the task, or None if the task data fails to validate
        """
        return self.ui.task

    @functools.cached_property
    def plugin(self) -> "WorkRequestPlugin | None":
        """Return the work request plugin, if defined."""
        if self.task is None:
            return None
        return WorkRequestPlugin.plugin_for(self.task)

    @override
    def get_sidebar_items(self) -> list[sidebar.SidebarItem]:
        items = super().get_sidebar_items()
        items.append(self.ui.sidebar_work_request())
        if hasattr(self.object, "superseded"):
            items.append(self.ui.sidebar_work_request_superseded())
        if self.object.supersedes:
            items.append(self.ui.sidebar_work_request_supersedes())
        items.append(self.ui.sidebar_workflow())
        items.append(self.ui.sidebar_work_request_status())
        items.append(sidebar.create_workspace(self.object.workspace))
        items.append(
            sidebar.create_user(self.object.created_by, context=self.object)
        )
        items.append(sidebar.create_created_at(self.object.created_at))
        items.append(sidebar.create_worker(self.object.worker))
        items.append(self.ui.sidebar_work_request_started_at())
        items.append(self.ui.sidebar_work_request_duration())
        items.append(sidebar.create_expire_at(self.object.expire_at))
        return items

    @override
    def get_place(self) -> Place:
        return self.ui.place

    def get_manual_unblock_log(self) -> list[dict[str, Any]]:
        """Get a representation of the manual unblock log."""
        workflow_data = self.object.workflow_data
        if workflow_data.manual_unblock is None:
            return []

        users = {
            user.id: user
            for user in get_user_model().objects.filter(
                id__in={log.user_id for log in workflow_data.manual_unblock.log}
            )
        }
        return [
            {
                "timestamp": log.timestamp,
                "user": users.get(log.user_id, f"Deleted user {log.user_id}"),
                "action": log.action,
                "notes": log.notes,
            }
            for log in workflow_data.manual_unblock.log
        ]

    def get_default_description(self) -> str | None:
        """Return default description for the Task."""
        if self.task:
            return self.task.get_description()

        try:
            return DBTask.class_from_name(
                TaskTypes(self.object.task_type), self.object.task_name
            ).__doc__
        except ValueError:
            return None

    def get_tabs(self) -> list[Tab]:
        """Return the tabs to show in the view."""
        tabs: list[Tab] = [
            Tab(name="work_request", label="Work request"),
        ]
        if not self.object.is_workflow or self.object.output_data:
            tabs += [
                Tab(name="output", label="Output"),
            ]
        tabs += [
            Tab(name="dependencies", label="Dependencies"),
            Tab(name="internals", label="Internals"),
        ]

        # After the initial tab list is set, allow the plugin to add to it
        if self.plugin is not None:
            self.plugin.add_tabs(tabs)
        return tabs

    @staticmethod
    def _get_workflow_input_artifacts(
        task: Workflow[Any, Any],
    ) -> list[InputArtifactsRow]:
        """
        Build context data describing the input artifacts of a workflow.

        Return tuple:
        * list of InputArtifactsRows (containing information for rendering)
        * list of all referenced Artifact objects
        """
        input_artifacts = task.get_input_artifacts()

        # Artifact ids for each InputArtifact
        input_artifacts_artifact_ids: list[list[int]] = []

        all_artifact_ids: set[int] = set()

        # Collect all the artifact_ids
        for input_artifact in input_artifacts:
            artifact_ids = input_artifact.resolved_ids()
            input_artifacts_artifact_ids.append(artifact_ids)
            all_artifact_ids.update(artifact_ids)

        # Query all the artifacts
        artifacts_qs = (
            Artifact.objects.filter(pk__in=all_artifact_ids)
            .order_by("pk")
            .annotate_complete()
        )

        all_artifacts = list(artifacts_qs)
        artifacts_by_id: dict[int, Artifact] = {a.id: a for a in all_artifacts}

        input_artifact_rows: list[InputArtifactsRow] = []

        for input_artifact, artifact_ids in zip(
            input_artifacts, input_artifacts_artifact_ids, strict=True
        ):
            artifacts_for_row = [
                artifacts_by_id[artifact_id]
                for artifact_id in sorted(set(artifact_ids))
                if artifact_id in artifacts_by_id
            ]

            match input_artifact.lookup:
                case str() | int():
                    lookup_display = syntax_highlight(
                        "text", str(input_artifact.lookup)
                    )
                case _:
                    lookup_display = highlight_as_yaml(
                        input_artifact.lookup.export()
                    )

            input_artifact_rows.append(
                InputArtifactsRow(
                    input_label=input_artifact.label,
                    lookup_display=lookup_display,
                    artifacts=artifacts_for_row,
                )
            )

        return input_artifact_rows

    @staticmethod
    def _get_workflow_parents(work_request: WorkRequest) -> list[WorkRequest]:
        workflow_parents = []

        parent = work_request.parent
        while parent is not None:
            workflow_parents.append(parent)
            parent = parent.parent

        return list(reversed(workflow_parents))

    @override
    def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
        """Return context for this view's tab."""
        ctx = super().get_context_data(**kwargs)

        ctx["tabs"] = self.get_tabs()

        ctx["description_template"] = (
            "web/_work_request-generic-description.html"
        )
        ctx["description_data"] = {
            "default_description": self.get_default_description()
        }

        ctx["manual_unblock_log_entries"] = self.get_manual_unblock_log()
        ctx["manual_unblock_form"] = WorkRequestUnblockForm()

        if self._validation_error_message is not None:
            ctx["validation_error"] = self._validation_error_message
        elif self.plugin:
            # plugin_view for WorkRequest.task_name exists, but the
            # response will return the generic WorkRequest view
            ctx["specialized_view_path"] = self.request.path

        if self.object.requires_confirmation:
            assert isinstance(self.task, Confirm)
            ctx["confirm_form"] = WorkRequestConfirmForm()
            ctx["confirm_label"] = self.task.data.confirm_label
            ctx["deny_label"] = self.task.data.deny_label

        if isinstance(self.task, Workflow):
            ctx["input_artifacts_list"] = self._get_workflow_input_artifacts(
                self.task
            )
        elif self.task is not None:
            ctx["input_fields"] = TaskInputsWidget(self.task)
            ctx["input_fields_internal"] = TaskInputsWidget(
                self.task,
                internal=True,
                element_id="task-input-fields-internal",
            )

        built_artifacts_table = ArtifactTable(
            self.request,
            Artifact.objects.filter(
                created_by_work_request=self.object
            ).annotate_complete(),
            prefix="built_artifacts",
            fragment="output",
        )
        built_artifacts_table_paginator = built_artifacts_table.get_paginator(
            per_page=20
        )
        ctx["built_artifacts_table"] = built_artifacts_table_paginator

        ctx["task_data"] = highlight_as_yaml(self.object.task_data)

        if (dynamic_task_data := self.object.dynamic_task_data) is not None:
            dynamic_task_data = dynamic_task_data.copy()
            task_input_fields = dynamic_task_data.pop("task_input_fields", None)
            ctx["show_task_data_dynamic"] = True
            ctx["task_data_dynamic"] = highlight_as_yaml(dynamic_task_data)
            if self.task is None:
                ctx["task_input_fields"] = highlight_as_yaml(task_input_fields)
                ctx["task_input_fields_raw"] = True
        else:
            ctx["show_task_data_dynamic"] = False

        if (
            configured_task_data := self.object.configured_task_data
        ) is not None:
            ctx["show_task_data_configured"] = True
            ctx["task_data_configured"] = highlight_as_yaml(
                configured_task_data
            )
            ctx["scheduler_tags"] = highlight_as_yaml(
                {
                    "provided": sorted(self.object.scheduler_tags_provided),
                    "required": sorted(self.object.scheduler_tags_required),
                }
            )
        else:
            ctx["show_task_data_configured"] = False

        ctx["work_request_output_data"] = self._format_output_data(
            self.object.output_data
        )
        ctx["work_request_event_reactions_json"] = highlight_as_json(
            self.object.event_reactions_json
        )
        ctx["work_request_output_data_json"] = highlight_as_json(
            self.object.output_data_json
        )
        ctx["work_request_workflow_data_json"] = highlight_as_json(
            self.object.workflow_data_json
        )

        ctx["work_request_dependencies"] = WorkRequestTable(
            self.request, self.object.dependencies.all(), prefix="dependencies"
        ).get_paginator(per_page=10)
        ctx["work_request_reverse_dependencies"] = WorkRequestTable(
            self.request,
            self.object.reverse_dependencies.all(),
            prefix="reverse_dependencies",
        ).get_paginator(per_page=10)

        ctx["workflow_parents"] = self._get_workflow_parents(self.object)

        # Let the plugin override parts of the context, if needed
        if self.plugin:
            ctx.update(self.plugin.get_context_data())

        return ctx

    @staticmethod
    def _format_output_data(
        output_data: OutputData | None,
    ) -> dict[str, Any]:
        """
        Format OutputData into a dictionary with human-readable values.

        - Memory and disk sizes are formatted as MB/GB.
        - Durations are shown in hours, minutes, and seconds.
        """
        if output_data is None:
            return {}

        runtime_statistics = {}

        if output_data.runtime_statistics is not None:
            for key, value in output_data.runtime_statistics:
                if (
                    key
                    in (
                        "memory",
                        "available_memory",
                        "disk_space",
                        "available_disk_space",
                    )
                    and value is not None
                ):
                    value = filesizeformat(value)

                if key in ("duration", "cpu_time") and value is not None:
                    value = duration_string(dt.timedelta(seconds=value))

                key = key.replace("_", " ").capitalize().replace("Cpu ", "CPU ")

                runtime_statistics[key] = value

        errors: list[dict[str, str]] = []
        if output_data.errors is not None:
            errors += [
                {"message": error.message, "code": error.code}
                for error in output_data.errors
            ]

        formatted = {
            "Runtime statistics": runtime_statistics,
            "Errors": errors,
        }
        if output_data.skip_reason is not None:
            formatted["Skipped"] = output_data.skip_reason
        return formatted


class WorkRequestPlugin(abc.ABC):
    """
    WorkRequests with specific outputs must subclass it.

    When subclassing, the subclass:
    - Is automatically used by the /work-request/ID/ endpoints
    - Must define "template_name", "task_type", and "task_name"
    - Must implement "get_context_data()"
    """

    model = WorkRequest
    task_type: str
    task_name: str
    task: DBTask[Any, Any]
    template_name: str

    #: Maps (task_type, task_name) tuples to their associated plugin class.
    #: Used to look up the correct plugin for a given work request.
    _work_request_plugins: dict[tuple[str, str], type["WorkRequestPlugin"]] = {}

    @override
    def __init_subclass__(cls, **kwargs: Any) -> None:  # noqa: U100
        """Register the plugin."""
        cls._work_request_plugins[(cls.task_type, cls.task_name)] = cls

    def __init__(self, task: DBTask[Any]) -> None:
        """Store the work_request to display."""
        self.task = task
        self.work_request = task.work_request

    @classmethod
    def plugin_for(cls, task: DBTask[Any]) -> "WorkRequestPlugin | None":
        """Return WorkRequestPlugin for task_name or None."""
        plugin_class = cls._work_request_plugins.get(
            (task.TASK_TYPE, task.name)
        )
        if plugin_class is None:
            return None
        plugin = plugin_class(task)
        if not plugin.is_enabled():
            return None
        return plugin

    def is_enabled(self) -> bool:
        """Check if the tab should appear."""
        return True

    def add_tabs(self, tabs: list[Tab]) -> None:
        """Add extra tabs to the tab list."""

    def get_description_data(self) -> dict[str, Any]:
        """Return data for the description or {} if dynamic_data is None."""
        if self.task.dynamic_data is None:
            return {}

        return self.do_get_description_data()

    def do_get_description_data(self) -> dict[str, Any]:
        """Return data used for the description."""
        return {}

    def get_context_data(self) -> dict[str, Any]:
        """Must be implemented by subclasses."""
        raise NotImplementedError()


class WorkRequestListView(
    TableMixin[WorkRequest], WorkRequestObjectMixin, ListViewBase[WorkRequest]
):
    """List work requests."""

    model = WorkRequest
    table_class = WorkRequestTable
    template_name = "web/work_request-list.html"
    context_object_name = "work_request_list"
    title = "List of work requests"
    paginate_by = 50

    @override
    def get_place(self) -> Place:
        place = super().get_place()
        place.parent = context.require_workspace().ui(self.request).place
        return place

    @staticmethod
    def _filter_by_architecture(
        work_requests: WorkRequestQuerySet[WorkRequest], architecture: str
    ) -> WorkRequestQuerySet[WorkRequest]:
        return work_requests.filter(
            scheduler_tags_required__contains=[
                wtags.BUILD_ARCH_PREFIX + architecture
            ]
        )

    @override
    def get_queryset(self) -> WorkRequestQuerySet[WorkRequest]:
        """Filter work requests displayed by the workspace GET parameter."""
        queryset = super().get_queryset().exclude(task_type=TaskTypes.INTERNAL)

        arch_param = self.request.GET.get("arch")
        status_param = self.request.GET.get("status")

        status_mapping = {
            "pending": WorkRequest.Statuses.PENDING,
            "running": WorkRequest.Statuses.RUNNING,
            "completed": WorkRequest.Statuses.COMPLETED,
            "aborted": WorkRequest.Statuses.ABORTED,
            "blocked": WorkRequest.Statuses.BLOCKED,
        }

        status_value = (
            None if status_param is None else status_mapping.get(status_param)
        )

        if status_param is not None and status_value is None:
            messages.warning(
                self.request, 'Invalid "status" parameter, ignoring it'
            )

        if status_value:
            queryset = queryset.filter(status=status_value)

        if arch_param:
            if status_value == WorkRequest.Statuses.PENDING:
                # Filtering by architecture is only allowed for PENDING
                # Work Requests. Filtering by architecture, currently,
                # requires instantiating the Task and calling
                # build_architecture()
                queryset = self._filter_by_architecture(queryset, arch_param)
            else:
                messages.warning(
                    self.request,
                    'Filter by architecture is only supported when '
                    'also filtering by "status=pending", ignoring architecture'
                    'filtering',
                )

        return queryset


class WorkRequestRetryView(
    WorkRequestObjectMixin, SingleObjectMixinBase[WorkRequest], View
):
    """Form view for retrying a work request."""

    model = WorkRequest

    def retry(self, work_request: WorkRequest) -> HttpResponse:
        """Retry the work request."""
        try:
            new_work_request = work_request.retry()
        except CannotRetry as e:
            messages.error(self.request, f"Cannot retry: {e}")
            return redirect(work_request.get_absolute_url())

        return redirect(new_work_request.get_absolute_url())

    def post(
        self,
        request: HttpRequest,  # noqa: ARG002, U100
        *args: Any,
        **kwargs: Any,
    ) -> HttpResponse:
        """Handle POST requests."""
        work_request = self.get_object()
        self.enforce(work_request.can_retry)
        return self.retry(work_request)


class WorkRequestAbortView(
    WorkRequestObjectMixin, SingleObjectMixinBase[WorkRequest], View
):
    """Form view for aborting a work request."""

    model = WorkRequest

    def abort(self, work_request: WorkRequest) -> HttpResponse:
        """Abort the work request."""
        # This view requires a permission, so the user can't be an
        # AnonymousUser by this point.
        assert isinstance(self.request.user, User)

        try:
            work_request.abort(user=self.request.user)
        except CannotAbort as e:
            messages.error(self.request, f"Cannot abort: {e}")

        return redirect(work_request.get_absolute_url())

    def post(self, *args: Any, **kwargs: Any) -> HttpResponse:
        """Handle POST requests."""
        work_request = self.get_object()
        self.enforce(work_request.can_abort)
        WorkRequest.objects.filter(
            id=work_request.id
        ).lock_workflows_for_update()
        return self.abort(work_request)


class WorkRequestUnblockView(
    WorkRequestObjectMixin,
    SingleObjectMixinBase[WorkRequest],
    FormMixinBase[WorkRequestUnblockForm],
    View,
):
    """Form view for reviewing a work request awaiting manual approval."""

    model = WorkRequest
    form_class = WorkRequestUnblockForm

    def get_action(self) -> WorkRequestManualUnblockAction | None:
        """Get the unblock action from query arguments."""
        raw_action = self.request.POST["action"]
        match raw_action:
            case "Accept":
                return WorkRequestManualUnblockAction.ACCEPT
            case "Reject":
                return WorkRequestManualUnblockAction.REJECT
            case "Record notes only":
                return None
            case _:
                raise HttpError400(f"Invalid action parameter: {raw_action!r}")

    def unblock(self, work_request: WorkRequest) -> HttpResponse:
        """Perform the unblock."""
        # This view requires a permission, so the user can't be an
        # AnonymousUser by this point.
        assert isinstance(self.request.user, User)

        form = self.get_form()
        if not form.is_valid():
            # TODO: This is ugly and we should probably try to render
            # work_requests:detail with inline unblock form errors instead.
            # However, since the form is so limited it's very difficult to
            # actually hit this case in practice, so it doesn't seem worth
            # the effort for now.
            return render(
                self.request,
                "400.html",
                context={"error": form.errors.as_json()},
                status=status.HTTP_400_BAD_REQUEST,
            )

        try:
            work_request.review_manual_unblock(
                user=self.request.user,
                notes=form.cleaned_data["notes"],
                action=self.get_action(),
            )
        except CannotUnblock as e:
            raise HttpError400(f"Cannot unblock: {e}")
        return redirect(work_request.get_absolute_url())

    @catch_http_errors
    def post(
        self,
        request: HttpRequest,  # noqa: ARG002, U100
        *args: Any,
        **kwargs: Any,
    ) -> HttpResponse:
        """Handle POST requests."""
        work_request = self.get_object()
        self.enforce(work_request.can_unblock)
        return self.unblock(work_request)


class WorkRequestConfirmView(
    WorkRequestObjectMixin,
    SingleObjectMixinBase[WorkRequest],
    FormMixinBase[WorkRequestConfirmForm],
    View,
):
    """Form view for confirming a work request."""

    model = WorkRequest
    form_class = WorkRequestConfirmForm

    @property
    def should_confirm(self) -> bool:
        """Get whether to confirm the work request from query arguments."""
        action = self.request.POST["action"]
        match action:
            case "confirm":
                return True
            case "deny":
                return False
            case _:
                raise HttpError400(f"Invalid action parameter: {action!r}")

    def confirm_or_deny(self, work_request: WorkRequest) -> HttpResponse:
        """Perform the confirmation or denial."""
        # This view requires a permission, so the user can't be an
        # AnonymousUser by this point.
        assert isinstance(self.request.user, User)

        if (
            work_request.task_type != TaskTypes.WAIT
            or work_request.task_name != "confirm"
        ):
            raise HttpError400(
                f"Expected work request to be Wait/Confirm; got "
                f"{work_request.task_type}/{work_request.task_name}"
            )
        task = work_request.get_task()
        assert isinstance(task, Confirm)

        form = self.get_form()
        if not form.is_valid():
            # TODO: This is ugly and we should probably try to render
            # work_requests:detail with inline confirm form errors instead.
            # However, since the form is so limited it's very difficult to
            # actually hit this case in practice, so it doesn't seem worth
            # the effort for now.
            return render(
                self.request,
                "400.html",
                context={"error": form.errors.as_json()},
                status=status.HTTP_400_BAD_REQUEST,
            )

        task.respond(
            user=self.request.user,
            confirm=self.should_confirm,
            comment=form.cleaned_data["comment"],
        )
        return redirect(work_request.get_absolute_url())

    @catch_http_errors
    def post(
        self,
        request: HttpRequest,  # noqa: ARG002, U100
        *args: Any,
        **kwargs: Any,
    ) -> HttpResponse:
        """Handle POST requests."""
        work_request = self.get_object()
        self.enforce(work_request.can_confirm)
        return self.confirm_or_deny(work_request)
