# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

from django.http import HttpResponseRedirect
from django.db.models import Q
from django.shortcuts import get_object_or_404
from django.utils import timezone
from rest_framework.exceptions import ValidationError
from rest_framework import status
from rest_framework.response import Response
from plane.utils.host import base_host

from plane.api.serializers import (
    ExternalContourBoardItemSerializer,
    ExternalContourRequestCreateSerializer,
    ExternalContourRequestDecisionSerializer,
    ExternalContourRequestReplySerializer,
    ExternalContourRequestUpdateSerializer,
    ExternalContourRequestSerializer,
    ExternalContourTargetOptionsSerializer,
    ExternalContourTargetProjectSerializer,
)
from plane.api.serializers.issue import IssueSerializer as IssueCreateSerializer
from plane.app.permissions import ProjectLitePermission
from plane.app.views.base import BaseAPIView
from plane.db.models import FileAsset, Intake, IntakeIssue, Label, Notification, Project, ProjectMember, State, StateGroup
from plane.db.models.intake import IntakeIssueStatus, SourceType
from plane.settings.storage import S3Storage
from plane.utils.external_contours import create_external_contour_issue_comment


class ExternalContourListCreateEndpoint(BaseAPIView):
    permission_classes = [ProjectLitePermission]
    serializer_class = ExternalContourRequestSerializer

    def get_source_project(self, slug, project_id):
        return get_object_or_404(Project, workspace__slug=slug, pk=project_id)

    def get_default_target_state(self, target_project):
        return (
            State.objects.filter(project=target_project, default=True)
            .exclude(group=StateGroup.TRIAGE.value)
            .first()
        ) or State.objects.filter(project=target_project).exclude(group=StateGroup.TRIAGE.value).order_by(
            "sequence", "created_at"
        ).first()

    def get_queryset(self):
        return (
            IntakeIssue.objects.filter(
                workspace__slug=self.kwargs.get("slug"),
                extra__bridge="external-contours",
                extra__source_project_id=str(self.kwargs.get("project_id")),
            )
            .select_related(
                "issue",
                "issue__state",
                "issue__project",
                "issue__created_by",
            )
            .prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
            .order_by("-updated_at")
        )

    def get(self, request, slug, project_id):
        serializer = ExternalContourRequestSerializer(
            self.get_queryset(),
            many=True,
            context={"request": request},
        )
        return Response(
            {
                "results": serializer.data,
                "next_cursor": "",
                "prev_cursor": "",
                "next_page_results": False,
                "prev_page_results": False,
                "total_count": len(serializer.data),
                "count": len(serializer.data),
                "total_pages": 1,
                "extra_stats": None,
                "total_results": len(serializer.data),
            },
            status=status.HTTP_200_OK,
        )

    def post(self, request, slug, project_id):
        source_project = self.get_source_project(slug, project_id)
        serializer = ExternalContourRequestCreateSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        target_project = get_object_or_404(
            Project,
            workspace_id=source_project.workspace_id,
            pk=serializer.validated_data["target_project_id"],
            archived_at__isnull=True,
        )

        if str(target_project.id) == str(source_project.id):
            return Response({"error": "Target project must differ from source project"}, status=status.HTTP_400_BAD_REQUEST)

        if not target_project.intake_view:
            return Response(
                {"error": "Target project is not enabled for external contour routing"},
                status=status.HTTP_400_BAD_REQUEST,
            )

        triage_state = State.triage_objects.filter(project=target_project).first()
        if not triage_state:
            triage_state = State.objects.create(
                name="Triage",
                group=StateGroup.TRIAGE.value,
                project=target_project,
                color="#4E5355",
                sequence=65000,
                default=False,
            )

        target_default_state = self.get_default_target_state(target_project)

        if not target_default_state:
            return Response({"error": "Target project has no available workflow state"}, status=status.HTTP_400_BAD_REQUEST)

        intake = Intake.objects.filter(project=target_project, name="External Contours Bridge").first()
        if not intake:
            intake = Intake.objects.create(
                name="External Contours Bridge",
                description="System bridge intake used for cross-project routing.",
                is_default=False,
                project=target_project,
            )

        issue_payload = serializer.validated_data["issue"]
        issue_serializer = IssueCreateSerializer(
            data={
                "name": issue_payload["name"],
                "description_html": issue_payload.get("description_html") or "<p></p>",
                "priority": issue_payload.get("priority", "none"),
                "assignees": issue_payload.get("assignee_ids", []),
                "labels": issue_payload.get("label_ids", []),
                "target_date": issue_payload.get("target_date"),
                "state_id": str(triage_state.id),
            },
            context={
                "project_id": str(target_project.id),
                "workspace_id": str(target_project.workspace_id),
                "default_assignee_id": target_project.default_assignee_id,
            },
        )
        issue_serializer.is_valid(raise_exception=True)
        issue = issue_serializer.save(state=triage_state)

        intake_issue = IntakeIssue.objects.create(
            intake=intake,
            project=target_project,
            issue=issue,
            source=SourceType.IN_APP,
            status=IntakeIssueStatus.ACCEPTED.value,
            extra={
                "bridge": "external-contours",
                "source_project_id": str(source_project.id),
                "source_project_name": source_project.name,
                "target_project_id": str(target_project.id),
                "target_project_name": target_project.name,
                "requested_by_id": str(request.user.id),
                "requested_by_name": request.user.display_name,
                "requested_at": issue.created_at.isoformat() if issue.created_at else None,
            },
        )

        if issue.state_id != target_default_state.id:
            issue.state = target_default_state
            issue.save()

        response_serializer = ExternalContourRequestSerializer(
            IntakeIssue.objects.select_related(
                "issue",
                "issue__state",
                "issue__project",
                "issue__created_by",
            )
            .prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
            .get(pk=intake_issue.id),
            context={"request": request},
        )
        return Response(response_serializer.data, status=status.HTTP_201_CREATED)


class ExternalContourTargetProjectListEndpoint(BaseAPIView):
    permission_classes = [ProjectLitePermission]
    serializer_class = ExternalContourTargetProjectSerializer

    def get_source_project(self, slug, project_id):
        return get_object_or_404(Project, workspace__slug=slug, pk=project_id)

    def get_queryset(self):
        source_project = self.get_source_project(self.kwargs.get("slug"), self.kwargs.get("project_id"))
        return (
            Project.objects.filter(
                workspace_id=source_project.workspace_id,
                archived_at__isnull=True,
                intake_view=True,
            )
            .exclude(pk=source_project.id)
            .order_by("name")
        )

    def get(self, request, slug, project_id):
        serializer = ExternalContourTargetProjectSerializer(self.get_queryset(), many=True)
        return Response(serializer.data, status=status.HTTP_200_OK)


class ExternalContourTargetOptionsEndpoint(BaseAPIView):
    permission_classes = [ProjectLitePermission]
    serializer_class = ExternalContourTargetOptionsSerializer

    def get_source_project(self, slug, project_id):
        return get_object_or_404(Project, workspace__slug=slug, pk=project_id)

    def get_target_project(self, source_project, target_project_id):
        return get_object_or_404(
            Project,
            workspace_id=source_project.workspace_id,
            pk=target_project_id,
            archived_at__isnull=True,
            intake_view=True,
        )

    def get(self, request, slug, project_id, target_project_id):
        source_project = self.get_source_project(slug, project_id)
        target_project = self.get_target_project(source_project, target_project_id)

        if str(target_project.id) == str(source_project.id):
            return Response({"error": "Target project must differ from source project"}, status=status.HTTP_400_BAD_REQUEST)

        member_ids = list(
            ProjectMember.objects.filter(
                project=target_project,
                workspace_id=target_project.workspace_id,
                is_active=True,
                member__is_bot=False,
                member__member_workspace__workspace_id=target_project.workspace_id,
                member__member_workspace__is_active=True,
            )
            .order_by("member__display_name", "member__email")
            .values_list("member_id", flat=True)
            .distinct()
        )

        labels = Label.objects.filter(project=target_project).order_by("sort_order", "name")
        states = State.objects.filter(project=target_project).exclude(group=StateGroup.TRIAGE.value).order_by(
            "sequence", "created_at"
        )

        serializer = ExternalContourTargetOptionsSerializer(
            {
                "project": target_project,
                "member_ids": member_ids,
                "states": states,
                "labels": labels,
            }
        )
        return Response(serializer.data, status=status.HTTP_200_OK)


class ExternalContourReadMixin:
    CLOSED_STATE_GROUPS = (StateGroup.COMPLETED.value, StateGroup.CANCELLED.value)
    BOARD_ORDERING_MAP = {
        "requested_at": "created_at",
        "updated_at": "updated_at",
        "issue__sequence_id": "issue__sequence_id",
        "target_date": "issue__target_date",
    }

    def get_base_queryset(self):
        return (
            IntakeIssue.objects.filter(
                workspace__slug=self.kwargs.get("slug"),
                extra__bridge="external-contours",
            )
            .select_related(
                "issue",
                "issue__state",
                "issue__project",
                "issue__created_by",
            )
            .prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
        )

    def get_outgoing_queryset(self):
        return self.get_base_queryset().filter(extra__source_project_id=str(self.kwargs.get("project_id")))

    def get_incoming_queryset(self):
        return self.get_base_queryset().filter(project_id=self.kwargs.get("project_id"))

    def get_board_item_queryset(self):
        return self.get_base_queryset().filter(
            Q(extra__source_project_id=str(self.kwargs.get("project_id"))) | Q(project_id=self.kwargs.get("project_id"))
        )

    def parse_csv_param(self, request, key):
        value = (request.query_params.get(key) or "").strip()
        if not value:
            return []
        return [item.strip() for item in value.split(",") if item.strip()]

    def parse_bool_param(self, request, key):
        value = (request.query_params.get(key) or "").strip().lower()
        if value in ["true", "1", "yes"]:
            return True
        if value in ["false", "0", "no"]:
            return False
        return None

    def get_requested_directions(self, request):
        directions = set(self.parse_csv_param(request, "direction"))
        valid_directions = {"outgoing", "incoming"}
        return directions.intersection(valid_directions) or valid_directions

    def get_applied_filters(self, request):
        filters = {}
        for key in [
            "direction",
            "status",
            "state_groups",
            "state_ids",
            "priority",
            "assignee_ids",
            "created_by_ids",
            "requested_by_ids",
            "counterparty_project_ids",
            "source_project_ids",
            "target_project_ids",
            "label_ids",
        ]:
            filters[key] = self.parse_csv_param(request, key)

        filters["has_unread_updates"] = self.parse_bool_param(request, "has_unread_updates")
        filters["search"] = (request.query_params.get("search") or "").strip()
        filters["target_date_exact"] = (request.query_params.get("target_date_exact") or "").strip()
        filters["target_date_from"] = (request.query_params.get("target_date_from") or "").strip()
        filters["target_date_to"] = (request.query_params.get("target_date_to") or "").strip()
        return filters

    def get_sorting(self, request):
        order_by = (request.query_params.get("order_by") or "updated_at").strip()
        sort_by = (request.query_params.get("sort_by") or "desc").strip().lower()

        if order_by not in self.BOARD_ORDERING_MAP:
            order_by = "updated_at"
        if sort_by not in ["asc", "desc"]:
            sort_by = "desc"

        return {
            "order_by": order_by,
            "sort_by": sort_by,
        }

    def get_unread_request_ids(self, user, request_ids=None):
        user_id = getattr(user, "id", None)
        if not user_id:
            return set()

        queryset = Notification.objects.filter(
            receiver_id=user_id,
            sender__startswith="in_app:external_contours:",
            read_at__isnull=True,
        )
        if request_ids:
            queryset = queryset.filter(data__issue__id__in=[str(request_id) for request_id in request_ids])

        return {
            str(request_id)
            for request_id in queryset.values_list("data__issue__id", flat=True)
            if request_id
        }

    def apply_status_filter(self, queryset, statuses):
        normalized_statuses = {status_value for status_value in statuses if status_value in ["open", "closed"]}
        if not normalized_statuses or normalized_statuses == {"open", "closed"}:
            return queryset
        if normalized_statuses == {"open"}:
            return queryset.exclude(issue__state__group__in=self.CLOSED_STATE_GROUPS)
        if normalized_statuses == {"closed"}:
            return queryset.filter(issue__state__group__in=self.CLOSED_STATE_GROUPS)
        return queryset

    def apply_board_filters(self, queryset, request, direction=None):
        filters = self.get_applied_filters(request)

        if filters["search"]:
            queryset = queryset.filter(
                Q(issue__name__icontains=filters["search"]) | Q(issue__description_html__icontains=filters["search"])
            )

        queryset = self.apply_status_filter(queryset, filters["status"])

        if filters["state_groups"]:
            queryset = queryset.filter(issue__state__group__in=filters["state_groups"])
        if filters["state_ids"]:
            queryset = queryset.filter(issue__state_id__in=filters["state_ids"])
        if filters["priority"]:
            queryset = queryset.filter(issue__priority__in=filters["priority"])
        if filters["assignee_ids"]:
            queryset = queryset.filter(issue__issue_assignee__assignee_id__in=filters["assignee_ids"])
        if filters["created_by_ids"]:
            queryset = queryset.filter(issue__created_by_id__in=filters["created_by_ids"])
        if filters["requested_by_ids"]:
            queryset = queryset.filter(extra__requested_by_id__in=filters["requested_by_ids"])
        if filters["counterparty_project_ids"]:
            if direction == "outgoing":
                queryset = queryset.filter(
                    Q(extra__target_project_id__in=filters["counterparty_project_ids"])
                    | Q(issue__project_id__in=filters["counterparty_project_ids"])
                )
            elif direction == "incoming":
                queryset = queryset.filter(extra__source_project_id__in=filters["counterparty_project_ids"])
        if filters["source_project_ids"]:
            queryset = queryset.filter(extra__source_project_id__in=filters["source_project_ids"])
        if filters["target_project_ids"]:
            queryset = queryset.filter(
                Q(extra__target_project_id__in=filters["target_project_ids"])
                | Q(issue__project_id__in=filters["target_project_ids"])
            )
        if filters["label_ids"]:
            queryset = queryset.filter(issue__label_issue__label_id__in=filters["label_ids"])
        if filters["target_date_exact"]:
            queryset = queryset.filter(issue__target_date=filters["target_date_exact"])
        if filters["target_date_from"]:
            queryset = queryset.filter(issue__target_date__gte=filters["target_date_from"])
        if filters["target_date_to"]:
            queryset = queryset.filter(issue__target_date__lte=filters["target_date_to"])
        if filters["has_unread_updates"] is not None:
            unread_request_ids = self.get_unread_request_ids(request.user)
            if filters["has_unread_updates"]:
                queryset = queryset.filter(pk__in=unread_request_ids or ["00000000-0000-0000-0000-000000000000"])
            else:
                queryset = queryset.exclude(pk__in=unread_request_ids)

        sorting = self.get_sorting(request)
        ordering = self.BOARD_ORDERING_MAP[sorting["order_by"]]
        if sorting["sort_by"] == "desc":
            ordering = f"-{ordering}"

        return queryset.order_by(ordering).distinct()

    def build_project_map(self, contour_requests):
        project_ids = {
            str(project_id)
            for contour_request in contour_requests
            for project_id in [
                contour_request.extra.get("source_project_id"),
                contour_request.extra.get("target_project_id") or (str(contour_request.issue.project_id) if contour_request.issue_id else None),
            ]
            if project_id
        }

        if not project_ids:
            return {}

        return {
            str(project.id): project
            for project in Project.objects.filter(pk__in=project_ids)
        }

    def build_member_project_ids(self, request, contour_requests):
        issue_project_ids = {
            str(contour_request.issue.project_id)
            for contour_request in contour_requests
            if contour_request.issue_id and contour_request.issue and contour_request.issue.project_id
        }

        if not issue_project_ids:
            return set()

        return {
            str(project_id)
            for project_id in ProjectMember.objects.filter(
                workspace__slug=self.kwargs.get("slug"),
                member=request.user,
                project_id__in=issue_project_ids,
                is_active=True,
            ).values_list("project_id", flat=True)
        }

    def build_serializer_context(self, request, contour_requests, current_project_id, include_mirror_data=False):
        contour_requests = list(contour_requests)
        unread_request_ids = self.get_unread_request_ids(request.user, request_ids=[contour_request.id for contour_request in contour_requests])
        return {
            "include_mirror_data": include_mirror_data,
            "workspace_slug": self.kwargs.get("slug"),
            "source_project_id": str(current_project_id),
            "current_project_id": str(current_project_id),
            "project_map": self.build_project_map(contour_requests),
            "member_project_ids": self.build_member_project_ids(request, contour_requests),
            "unread_request_ids": unread_request_ids,
            "request": request,
        }

    def mark_request_notifications_read(self, user, contour_request):
        user_id = getattr(user, "id", None)
        if not user_id:
            return

        Notification.objects.filter(
            receiver_id=user_id,
            sender__startswith="in_app:external_contours:",
            read_at__isnull=True,
            data__issue__id=str(contour_request.id),
        ).update(read_at=timezone.now())


class ExternalContourBoardEndpoint(ExternalContourReadMixin, BaseAPIView):
    permission_classes = [ProjectLitePermission]
    serializer_class = ExternalContourBoardItemSerializer

    def get(self, request, slug, project_id):
        requested_directions = self.get_requested_directions(request)
        current_project_id = str(project_id)

        outgoing_queryset = self.apply_board_filters(self.get_outgoing_queryset(), request, direction="outgoing")
        incoming_queryset = self.apply_board_filters(self.get_incoming_queryset(), request, direction="incoming")

        outgoing_requests = list(outgoing_queryset) if "outgoing" in requested_directions else []
        incoming_requests = list(incoming_queryset) if "incoming" in requested_directions else []

        outgoing_serializer = ExternalContourBoardItemSerializer(
            outgoing_requests,
            many=True,
            context=self.build_serializer_context(request, outgoing_requests, current_project_id=current_project_id),
        )
        incoming_serializer = ExternalContourBoardItemSerializer(
            incoming_requests,
            many=True,
            context=self.build_serializer_context(request, incoming_requests, current_project_id=current_project_id),
        )

        return Response(
            {
                "filters": self.get_applied_filters(request),
                "sorting": self.get_sorting(request),
                "columns": [
                    {
                        "key": "outgoing",
                        "title": "Исходящие",
                        "total_count": len(outgoing_requests),
                        "next_cursor": "",
                        "results": outgoing_serializer.data,
                    },
                    {
                        "key": "incoming",
                        "title": "Входящие",
                        "total_count": len(incoming_requests),
                        "next_cursor": "",
                        "results": incoming_serializer.data,
                    },
                ],
            },
            status=status.HTTP_200_OK,
        )


class ExternalContourBoardItemDetailEndpoint(ExternalContourReadMixin, BaseAPIView):
    permission_classes = [ProjectLitePermission]
    serializer_class = ExternalContourBoardItemSerializer

    def get(self, request, slug, project_id, request_id):
        contour_request = get_object_or_404(self.get_board_item_queryset(), pk=request_id)
        self.mark_request_notifications_read(request.user, contour_request)

        serializer = ExternalContourBoardItemSerializer(
            contour_request,
            context=self.build_serializer_context(
                request,
                [contour_request],
                current_project_id=str(project_id),
                include_mirror_data=True,
            ),
        )
        return Response(serializer.data, status=status.HTTP_200_OK)


class ExternalContourDetailEndpoint(BaseAPIView):
    permission_classes = [ProjectLitePermission]
    serializer_class = ExternalContourRequestSerializer

    def get_queryset(self):
        return (
            IntakeIssue.objects.filter(
                workspace__slug=self.kwargs.get("slug"),
                extra__bridge="external-contours",
                extra__source_project_id=str(self.kwargs.get("project_id")),
                pk=self.kwargs.get("request_id"),
            )
            .select_related(
                "issue",
                "issue__state",
                "issue__project",
                "issue__created_by",
            )
            .prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
        )

    def mark_request_notifications_read(self, user, contour_request):
        user_id = getattr(user, "id", None)
        if not user_id:
            return

        Notification.objects.filter(
            receiver_id=user_id,
            sender__startswith="in_app:external_contours:",
            read_at__isnull=True,
            data__issue__id=str(contour_request.id),
        ).update(read_at=timezone.now())

    def get(self, request, slug, project_id, request_id):
        contour_request = get_object_or_404(self.get_queryset())
        self.mark_request_notifications_read(request.user, contour_request)
        serializer = ExternalContourRequestSerializer(
            contour_request,
            context={
                "include_mirror_data": True,
                "workspace_slug": slug,
                "source_project_id": str(project_id),
                "request": request,
            },
        )
        return Response(serializer.data, status=status.HTTP_200_OK)

    def patch(self, request, slug, project_id, request_id):
        contour_request = get_object_or_404(self.get_queryset())
        issue = contour_request.issue

        if not issue:
            return Response({"error": "Target issue was not found"}, status=status.HTTP_404_NOT_FOUND)

        requested_by_id = contour_request.extra.get("requested_by_id") or (
            str(issue.created_by_id) if issue.created_by_id else None
        )
        if str(request.user.id) != str(requested_by_id):
            return Response({"error": "Only the sender can edit this request"}, status=status.HTTP_403_FORBIDDEN)

        if issue.state and issue.state.group in [StateGroup.COMPLETED.value, StateGroup.CANCELLED.value]:
            return Response(
                {"error": "Only open external contour requests can be edited"},
                status=status.HTTP_400_BAD_REQUEST,
            )

        serializer = ExternalContourRequestUpdateSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        issue_update_data = serializer.validated_data.copy()
        assignee_ids = issue_update_data.pop("assignee_ids", None)
        label_ids = issue_update_data.pop("label_ids", None)
        if assignee_ids is not None:
            issue_update_data["assignees"] = assignee_ids
        if label_ids is not None:
            issue_update_data["labels"] = label_ids

        issue_serializer = IssueCreateSerializer(
            issue,
            data=issue_update_data,
            partial=True,
            context={
                "project_id": str(issue.project_id),
                "workspace_id": str(issue.workspace_id),
            },
        )
        issue_serializer.is_valid(raise_exception=True)
        issue_serializer.save()

        contour_request.updated_at = timezone.now()
        contour_request.save(update_fields=["updated_at"])
        contour_request.refresh_from_db()

        response_serializer = ExternalContourRequestSerializer(
            contour_request,
            context={
                "include_mirror_data": True,
                "workspace_slug": slug,
                "source_project_id": str(project_id),
                "request": request,
            },
        )
        return Response(response_serializer.data, status=status.HTTP_200_OK)


class ExternalContourDecisionEndpoint(BaseAPIView):
    permission_classes = [ProjectLitePermission]
    serializer_class = ExternalContourRequestSerializer

    def get_queryset(self):
        return (
            IntakeIssue.objects.filter(
                workspace__slug=self.kwargs.get("slug"),
                extra__bridge="external-contours",
                extra__source_project_id=str(self.kwargs.get("project_id")),
                pk=self.kwargs.get("request_id"),
            )
            .select_related(
                "issue",
                "issue__state",
                "issue__project",
                "issue__created_by",
            )
            .prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
        )

    def post(self, request, slug, project_id, request_id):
        contour_request = get_object_or_404(self.get_queryset())
        serializer = ExternalContourRequestDecisionSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        action = serializer.validated_data["action"]
        comment = (serializer.validated_data.get("comment") or "").strip()
        issue = contour_request.issue

        if not issue or not issue.state or issue.state.group not in [StateGroup.COMPLETED.value, StateGroup.CANCELLED.value]:
            return Response(
                {"error": "Source decision is available only after the target contour finishes processing"},
                status=status.HTTP_400_BAD_REQUEST,
            )

        if action == "accept":
            contour_request.extra = {
                **contour_request.extra,
                "source_decision": "accepted",
                "source_decision_at": timezone.now().isoformat(),
                "source_decision_by_name": request.user.display_name,
            }
            contour_request.save(update_fields=["extra", "updated_at"])
        else:
            target_default_state = (
                State.objects.filter(project=issue.project, default=True)
                .exclude(group=StateGroup.TRIAGE.value)
                .first()
            ) or State.objects.filter(project=issue.project).exclude(group=StateGroup.TRIAGE.value).order_by(
                "sequence", "created_at"
            ).first()

            if not target_default_state:
                return Response({"error": "Target project has no available workflow state"}, status=status.HTTP_400_BAD_REQUEST)

            try:
                create_external_contour_issue_comment(
                    issue=issue,
                    actor=request.user,
                    comment=comment,
                    origin=base_host(request=request, is_app=True),
                )
            except ValidationError as exc:
                return Response({"error": exc.message_dict if hasattr(exc, "message_dict") else str(exc)}, status=status.HTTP_400_BAD_REQUEST)

            issue.state = target_default_state
            issue.save(update_fields=["state", "updated_at"])

            extra = dict(contour_request.extra or {})
            extra.pop("source_decision", None)
            extra.pop("source_decision_at", None)
            extra.pop("source_decision_by_name", None)
            extra["last_decline_comment"] = comment
            extra["last_reopened_at"] = issue.updated_at.isoformat() if issue.updated_at else None
            extra["last_reopened_by_name"] = request.user.display_name
            contour_request.extra = extra
            contour_request.save(update_fields=["extra", "updated_at"])

        contour_request.refresh_from_db()
        Notification.objects.filter(
            receiver_id=request.user.id,
            sender__startswith="in_app:external_contours:",
            read_at__isnull=True,
            data__issue__id=str(contour_request.id),
        ).update(read_at=timezone.now())
        serializer = ExternalContourRequestSerializer(
            IntakeIssue.objects.select_related(
                "issue",
                "issue__state",
                "issue__project",
                "issue__created_by",
            )
            .prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
            .get(pk=contour_request.id),
            context={
                "include_mirror_data": True,
                "workspace_slug": slug,
                "source_project_id": str(project_id),
                "request": request,
            },
        )
        return Response(serializer.data, status=status.HTTP_200_OK)


class ExternalContourReplyEndpoint(BaseAPIView):
    permission_classes = [ProjectLitePermission]
    serializer_class = ExternalContourRequestSerializer

    def get_queryset(self):
        return (
            IntakeIssue.objects.filter(
                workspace__slug=self.kwargs.get("slug"),
                extra__bridge="external-contours",
                extra__source_project_id=str(self.kwargs.get("project_id")),
                pk=self.kwargs.get("request_id"),
            )
            .select_related(
                "issue",
                "issue__state",
                "issue__project",
                "issue__created_by",
            )
            .prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
        )

    def post(self, request, slug, project_id, request_id):
        contour_request = get_object_or_404(self.get_queryset())
        serializer = ExternalContourRequestReplySerializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        issue = contour_request.issue
        if not issue:
            return Response({"error": "Target issue was not found"}, status=status.HTTP_404_NOT_FOUND)

        try:
            create_external_contour_issue_comment(
                issue=issue,
                actor=request.user,
                comment=serializer.validated_data["comment"],
                origin=base_host(request=request, is_app=True),
            )
        except ValidationError as exc:
            return Response({"error": exc.message_dict if hasattr(exc, "message_dict") else str(exc)}, status=status.HTTP_400_BAD_REQUEST)

        contour_request.refresh_from_db()
        Notification.objects.filter(
            receiver_id=request.user.id,
            sender__startswith="in_app:external_contours:",
            read_at__isnull=True,
            data__issue__id=str(contour_request.id),
        ).update(read_at=timezone.now())
        response_serializer = ExternalContourRequestSerializer(
            contour_request,
            context={
                "include_mirror_data": True,
                "workspace_slug": slug,
                "source_project_id": str(project_id),
                "request": request,
            },
        )
        return Response(response_serializer.data, status=status.HTTP_200_OK)


class ExternalContourAttachmentDownloadEndpoint(ExternalContourReadMixin, BaseAPIView):
    permission_classes = [ProjectLitePermission]

    def get_queryset(self):
        return self.get_board_item_queryset().filter(pk=self.kwargs.get("request_id")).select_related(
            "issue", "issue__project", "workspace"
        )

    def get(self, request, slug, project_id, request_id, attachment_id):
        contour_request = get_object_or_404(self.get_queryset())
        attachment = get_object_or_404(
            FileAsset,
            pk=attachment_id,
            issue_id=contour_request.issue_id,
            entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
            is_uploaded=True,
            is_deleted=False,
        )

        storage = S3Storage(request=request)
        presigned_url = storage.generate_presigned_url(
            object_name=attachment.asset.name,
            disposition="attachment",
            filename=attachment.attributes.get("name"),
        )
        return HttpResponseRedirect(presigned_url)
