"""
DRF viewsets for the moneypools domain.

All viewsets use UUID-based lookup (``id`` field) and require JWT
authentication.  Object-level access is enforced by
``AccountOwnerQuerySetMixin`` (filters list queries to owned objects)
and ``IsAccountOwner`` (guards retrieve/update/delete on individual
objects).

Banks are read-only reference data.  All other resources support the
standard CRUD operations with restrictions documented per-viewset.
"""

# system imports
from datetime import date
from decimal import Decimal

# 3rd party imports
import moneyed
import recurrence as recurrence_lib
from django.utils import timezone
from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.utils import (
    OpenApiParameter,
    OpenApiResponse,
    extend_schema,
    extend_schema_view,
)
from rest_framework import mixins, status, viewsets
from rest_framework.decorators import action, api_view, permission_classes
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response

# Project imports
from moneypools.models import (
    Bank,
    BankAccount,
    BankAccountInvitation,
    Budget,
    FundingEventOccurrence,
    InternalTransaction,
    Transaction,
    TransactionAllocation,
    TransactionCategory,
)
from moneypools.permissions import AccountOwnerQuerySetMixin, IsAccountOwner
from moneypools.service import bank_account as bank_account_svc
from moneypools.service import budget as budget_svc
from moneypools.service import categories as categories_svc
from moneypools.service import funding as funding_svc
from moneypools.service import internal_transaction as internal_transaction_svc
from moneypools.service import invitation as invitation_svc
from moneypools.service import sync_scrape as sync_scrape_svc
from moneypools.service import transaction as transaction_svc
from moneypools.service import transaction_details as transaction_details_svc
from moneypools.service.shared import funding_system_user
from users.permissions import RequiresInteractiveAuth

from .filters import (
    BudgetFilter,
    FundingEventOccurrenceFilter,
    InternalTransactionFilter,
    TransactionAllocationFilter,
    TransactionCategoryFilter,
    TransactionFilter,
)
from .serializers import (
    BankAccountInvitationSerializer,
    BankAccountSerializer,
    BankSerializer,
    BudgetSerializer,
    FundingEventOccurrenceSerializer,
    InternalTransactionSerializer,
    InviteOwnerSerializer,
    PublicInvitationDetailSerializer,
    ResolvePendingSerializer,
    ScrapeSyncReportSerializer,
    ScrapeSyncSerializer,
    TransactionAllocationSerializer,
    TransactionCategorySerializer,
    TransactionDetailsReportSerializer,
    TransactionDetailsSerializer,
    TransactionSerializer,
    TransactionSplitsSerializer,
)


########################################################################
########################################################################
#
@extend_schema_view(
    list=extend_schema(
        summary="List banks",
        description=(
            "Return all banks in the system. Banks are shared reference "
            "data managed through the admin -- any authenticated user "
            "can list and retrieve them."
        ),
    ),
    retrieve=extend_schema(
        summary="Get bank details",
        description="Return a single bank by UUID.",
    ),
)
class BankViewSet(viewsets.ReadOnlyModelViewSet):
    """Read-only reference data for financial institutions."""

    serializer_class = BankSerializer
    queryset = Bank.objects.all()
    lookup_field = "id"
    permission_classes = [IsAuthenticated]
    filter_backends = [OrderingFilter]
    ordering_fields = ["name"]
    ordering = ["name"]


########################################################################
########################################################################
#
@extend_schema_view(
    list=extend_schema(
        summary="List bank accounts",
        description=(
            "Return bank accounts owned by the authenticated user. "
            "Filterable by account_type. Orderable by name or "
            "created_at."
        ),
    ),
    create=extend_schema(
        summary="Create a bank account",
        description=(
            "Create a new bank account. The authenticated user is "
            "automatically added as an owner. An 'Unallocated' budget "
            "is auto-created by a post_save signal. Optionally set "
            "initial posted_balance, available_balance, and currency "
            "(all immutable after creation)."
        ),
    ),
    retrieve=extend_schema(
        summary="Get bank account details",
        description="Return a single bank account by UUID.",
    ),
    update=extend_schema(
        summary="Update a bank account",
        description=(
            "Full update of a bank account. Only 'name' is mutable "
            "after creation -- bank, account_type, currency, and "
            "balances are rejected if changed."
        ),
    ),
    partial_update=extend_schema(
        summary="Partially update a bank account",
        description=(
            "Partial update of a bank account. Only 'name' is mutable "
            "after creation."
        ),
    ),
    destroy=extend_schema(
        summary="Delete a bank account",
        description=(
            "Delete a bank account and all associated budgets, "
            "transactions, and allocations."
        ),
    ),
)
class BankAccountViewSet(AccountOwnerQuerySetMixin, viewsets.ModelViewSet):
    """Bank accounts (checking, savings, credit card) owned by the user."""

    serializer_class = BankAccountSerializer
    queryset = BankAccount.objects.select_related(
        "bank", "unallocated_budget"
    ).all()
    lookup_field = "id"
    permission_classes = [IsAuthenticated, IsAccountOwner]
    filter_backends = [DjangoFilterBackend, OrderingFilter]
    filterset_fields = ["account_type"]
    ordering_fields = ["name", "created_at"]
    ordering = ["name"]

    ####################################################################
    #
    def perform_update(self, serializer: BankAccountSerializer) -> None:
        """Update a bank account via BankAccountService (acquires lock)."""
        bank_account_svc.update(
            serializer.instance, **serializer.validated_data
        )
        serializer.instance.refresh_from_db()

    ####################################################################
    #
    def perform_create(self, serializer: BankAccountSerializer) -> None:
        """Create a bank account via BankAccountService."""
        data = serializer.validated_data
        optional = {
            k: data[k]
            for k in (
                "account_number",
                "currency",
                "posted_balance",
                "available_balance",
            )
            if k in data
        }
        account = bank_account_svc.create(
            bank=data["bank"],
            name=data["name"],
            account_type=data["account_type"],
            owners=[self.request.user],
            **optional,
        )
        serializer.instance = account

    ####################################################################
    #
    @extend_schema(
        summary="Mark import complete",
        description=(
            "Record that a transaction import has been completed for "
            "this account.  Sets last_imported_at to now and advances "
            "last_posted_through to the supplied date (never regresses "
            'an existing value).  Body: {"last_posted_through": "YYYY-MM-DD"}.'
        ),
        request={
            "application/json": {
                "type": "object",
                "properties": {
                    "last_posted_through": {"type": "string", "format": "date"}
                },
                "required": ["last_posted_through"],
            }
        },
        responses={200: BankAccountSerializer},
    )
    @action(detail=True, methods=["post"], url_path="mark-imported")
    def mark_imported(self, request: Request, id: str = "") -> Response:
        """Set last_imported_at=now and advance last_posted_through."""
        account: BankAccount = self.get_object()

        raw = request.data.get("last_posted_through")
        if not raw:
            raise ValidationError(
                {"last_posted_through": "This field is required."}
            )
        try:
            posted_through = date.fromisoformat(str(raw))
        except ValueError as exc:
            raise ValidationError(
                {"last_posted_through": "Expected YYYY-MM-DD format."}
            ) from exc

        new_posted_through = (
            max(account.last_posted_through, posted_through)
            if account.last_posted_through is not None
            else posted_through
        )

        BankAccount.objects.filter(pkid=account.pkid).update(
            last_imported_at=timezone.now(),
            last_posted_through=new_posted_through,
        )
        account.refresh_from_db()

        serializer = self.get_serializer(account)
        return Response(serializer.data)

    ####################################################################
    #
    @extend_schema(
        summary="Sync a bank-side scrape",
        description=(
            "Reconcile this account against a fresh snapshot from a "
            "live bank scraper.  All existing pending transactions on "
            "the account are deleted, posted transactions from the "
            "scrape are de-duplicated against the database, and any "
            "new posted/pending rows are inserted in the order the "
            "scraper supplies (newest-first).  Per-transaction running "
            "balance snapshots and the unallocated-budget allocation "
            "snapshots are recomputed before the request returns.  "
            "Runs atomically under the account + unallocated-budget "
            "locks; on any error the database is unchanged."
        ),
        request=ScrapeSyncSerializer,
        responses={200: ScrapeSyncReportSerializer},
    )
    @action(detail=True, methods=["post"], url_path="sync-scrape")
    def sync_scrape(self, request: Request, id: str = "") -> Response:
        """Reconcile the account against a fresh bank-side scrape."""
        account: BankAccount = self.get_object()

        serializer = ScrapeSyncSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        validated = serializer.validated_data

        scraped_txs = [
            sync_scrape_svc.ScrapedTransaction(
                is_pending=stx["is_pending"],
                posted_date=stx["posted_date"],
                raw_description=stx["raw_description"],
                amount=stx["amount"],
                transaction_type=stx.get("transaction_type", ""),
                running_balance=stx.get("running_balance"),
            )
            for stx in validated["transactions"]
        ]
        payload = sync_scrape_svc.ScrapeSyncPayload(
            scraped_at=validated["scraped_at"],
            ending_balance=validated["ending_balance"],
            transactions=scraped_txs,
        )

        try:
            report = sync_scrape_svc.sync_scrape(account, payload)
        except ValueError as exc:
            raise ValidationError(str(exc)) from exc

        out = ScrapeSyncReportSerializer(
            {
                "deleted_pending": report.deleted_pending,
                "inserted_posted": report.inserted_posted,
                "skipped_posted": report.skipped_posted,
                "inserted_pending": report.inserted_pending,
                "balance_mismatch": report.balance_mismatch,
                "posting_order_mismatches": report.posting_order_mismatches,
                "last_posted_through": report.last_posted_through,
                "new_transaction_ids": report.new_transaction_ids,
                "details_needed": [
                    {"index": row.index, "transaction": row.transaction_id}
                    for row in report.details_needed
                ],
            }
        )
        return Response(out.data)

    ####################################################################
    #
    @extend_schema(
        summary="Apply scraped transaction details",
        description=(
            "Apply per-transaction detail records (merchant name, "
            "location, MCC, virtual card number) fetched by a live "
            "scraper to posted transactions on this account.  Each "
            "raw details dict is stored verbatim on its transaction "
            "and the merchant columns are extracted from it.  An "
            "item's optional `category` is a mibudge category full "
            "name ('{group} : {name}') -- importers translate their "
            "provider's category vocabulary before submitting.  It "
            "seeds the transaction's category (and its unassigned "
            "allocations) when NULL; an unknown name yields a "
            "per-item warning and leaves the transaction unassigned.  "
            "The display description is recomposed on first "
            "enrichment unless the user has edited it.  Rows already "
            "enriched are skipped unless `overwrite` is true; pending "
            "rows are always skipped.  Per-item outcomes are returned "
            "in submission order."
        ),
        request=TransactionDetailsSerializer,
        responses={200: TransactionDetailsReportSerializer},
    )
    @action(detail=True, methods=["post"], url_path="transaction-details")
    def transaction_details(self, request: Request, id: str = "") -> Response:
        """Apply scraped per-transaction details to this account's rows."""
        account: BankAccount = self.get_object()

        serializer = TransactionDetailsSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        validated = serializer.validated_data
        overwrite = validated["overwrite"]

        results = []
        counts = {
            transaction_details_svc.STATUS_APPLIED: 0,
            transaction_details_svc.STATUS_SKIPPED_HAS_DETAILS: 0,
            transaction_details_svc.STATUS_SKIPPED_PENDING: 0,
            transaction_details_svc.STATUS_NOT_FOUND: 0,
        }
        for item in validated["details"]:
            tx = Transaction.objects.filter(
                bank_account=account, id=item["transaction"]
            ).first()
            if tx is None:
                item_status = transaction_details_svc.STATUS_NOT_FOUND
                warnings: list[str] = []
            else:
                category = None
                category_warnings: list[str] = []
                category_name = item.get("category")
                if category_name:
                    category = categories_svc.find_category_for_user(
                        request.user, category_name
                    )
                    if category is None:
                        category_warnings.append(
                            f"unknown category {category_name!r}; "
                            "transaction left unassigned"
                        )
                item_status, warnings = transaction_details_svc.apply_details(
                    tx,
                    item["details"],
                    category=category,
                    overwrite=overwrite,
                )
                warnings = category_warnings + warnings
            counts[item_status] += 1
            results.append(
                {
                    "transaction": item["transaction"],
                    "status": item_status,
                    "warnings": warnings,
                }
            )

        out = TransactionDetailsReportSerializer(
            {
                "applied": counts[transaction_details_svc.STATUS_APPLIED],
                "skipped_has_details": counts[
                    transaction_details_svc.STATUS_SKIPPED_HAS_DETAILS
                ],
                "skipped_pending": counts[
                    transaction_details_svc.STATUS_SKIPPED_PENDING
                ],
                "not_found": counts[transaction_details_svc.STATUS_NOT_FOUND],
                "results": results,
            }
        )
        return Response(out.data)

    ####################################################################
    #
    @extend_schema(
        summary="Run funding",
        description=(
            "Run the funding engine for this account immediately.  "
            "Processes all due fund and recurrence events up to `as_of` "
            "(defaults to today) and returns a summary of what happened.  "
            "Pass `as_of` when calling between import batches so the engine "
            "only sees events up to that batch boundary date."
        ),
        request={
            "application/json": {
                "type": "object",
                "properties": {
                    "as_of": {
                        "type": "string",
                        "format": "date",
                        "description": (
                            "Upper bound for event enumeration (YYYY-MM-DD). "
                            "Defaults to today."
                        ),
                    }
                },
            }
        },
        responses={
            200: OpenApiResponse(
                description="Funding run result.",
                response={
                    "type": "object",
                    "properties": {
                        "transfers": {"type": "integer"},
                        "occurrences_completed": {"type": "integer"},
                        "occurrences_partial": {"type": "integer"},
                        "warnings": {
                            "type": "array",
                            "items": {"type": "string"},
                        },
                        "skipped_budgets": {
                            "type": "array",
                            "items": {"type": "string"},
                        },
                    },
                },
            ),
            409: OpenApiResponse(
                description=(
                    "Either another worker is currently processing this "
                    "account (lock held), or there is nothing due or "
                    "outstanding to run as of the supplied date."
                ),
            ),
        },
    )
    @action(detail=True, methods=["post"], url_path="run-funding")
    def run_funding(self, request: Request, id: str = "") -> Response:
        """Run the funding engine for this account and return a summary."""
        account: BankAccount = self.get_object()

        as_of_raw = request.data.get("as_of")
        if as_of_raw is not None:
            try:
                as_of = date.fromisoformat(str(as_of_raw))
            except ValueError as exc:
                raise ValidationError(
                    {"as_of": "Must be a date in YYYY-MM-DD format."}
                ) from exc
        else:
            as_of = date.today()

        try:
            system_user = funding_system_user()
        except Exception:
            return Response(
                {"detail": "Funding system user not configured."},
                status=status.HTTP_503_SERVICE_UNAVAILABLE,
            )

        report = funding_svc.fund_account(account, as_of, system_user)

        # Another worker is already running funding for this account;
        # refuse rather than wait, so the user gets immediate feedback
        # and we do not duplicate transfers.
        #
        if report.busy:
            return Response(
                {
                    "detail": (
                        "Funding is already running for this account; "
                        "try again in a moment."
                    )
                },
                status=status.HTTP_409_CONFLICT,
            )

        # Nothing material happened -- no transfers, no occurrences
        # transitioned, no paused-skips.  This is the spam-click path
        # after a complete run; surface it as a 409 so the UI can show
        # an idempotent "nothing to do" state instead of a misleading
        # success.
        #
        nothing_to_do = (
            report.transfers == 0
            and report.occurrences_completed == 0
            and report.occurrences_partial == 0
            and not report.skipped_budgets
        )
        if nothing_to_do:
            return Response(
                {
                    "detail": (
                        "No funding events are due or outstanding for "
                        "this account as of the requested date."
                    )
                },
                status=status.HTTP_409_CONFLICT,
            )

        return Response(
            {
                "transfers": report.transfers,
                "occurrences_completed": report.occurrences_completed,
                "occurrences_partial": report.occurrences_partial,
                "warnings": report.warnings,
                "skipped_budgets": report.skipped_budgets,
            }
        )

    ####################################################################
    #
    @extend_schema(
        summary="Funding event dates",
        description=(
            "Return all dates in (after, before] on which at least one "
            "funding or recurrence event is due for this account.  "
            "The importer uses this to find batch-split boundaries."
        ),
        responses={
            200: OpenApiResponse(
                description="Sorted list of event dates.",
                response={
                    "type": "object",
                    "properties": {
                        "dates": {
                            "type": "array",
                            "items": {"type": "string", "format": "date"},
                        }
                    },
                },
            )
        },
    )
    @action(detail=True, methods=["get"], url_path="funding-event-dates")
    def funding_event_dates(self, request: Request, id: str = "") -> Response:
        """Return funding event dates in a query-param date range."""
        account: BankAccount = self.get_object()

        after_raw = request.query_params.get("after")
        before_raw = request.query_params.get("before")

        if not after_raw or not before_raw:
            raise ValidationError(
                {
                    "detail": "Both 'after' and 'before' query params are required."
                }
            )
        try:
            after = date.fromisoformat(after_raw)
            before = date.fromisoformat(before_raw)
        except ValueError as exc:
            raise ValidationError(
                {"detail": "Dates must be in YYYY-MM-DD format."}
            ) from exc

        dates = funding_svc.funding_event_dates(account, after, before)
        return Response({"dates": [d.isoformat() for d in dates]})

    ####################################################################
    #
    @extend_schema(
        summary="Funding summary",
        description=(
            "Return the total amounts that will be automatically funded "
            "at the next event for each distinct funding schedule on this "
            "account.  Only active, schedulable budgets are included -- "
            "paused, archived, completed goals, and RECURRING budgets "
            "that delegate to a fill-up goal are excluded.  Results are "
            "grouped by funding schedule (RRULE string) and sorted by "
            "next event date."
        ),
        responses={
            200: OpenApiResponse(
                description="Per-schedule funding totals.",
                response={
                    "type": "object",
                    "properties": {
                        "schedules": {
                            "type": "array",
                            "items": {
                                "type": "object",
                                "properties": {
                                    "schedule": {"type": "string"},
                                    "next_date": {
                                        "type": "string",
                                        "format": "date",
                                    },
                                    "total_amount": {"type": "string"},
                                    "currency": {"type": "string"},
                                    "budget_count": {"type": "integer"},
                                },
                            },
                        },
                        "total_amount": {"type": "string"},
                        "currency": {"type": "string"},
                    },
                },
            )
        },
    )
    @action(detail=True, methods=["get"], url_path="funding-summary")
    def funding_summary(self, request: Request, id: str = "") -> Response:
        """Aggregate next-event funding amounts across all budgets."""
        account: BankAccount = self.get_object()
        today = date.today()

        budgets = list(Budget.objects.filter(bank_account=account))

        # Map ASSOCIATED_FILLUP_GOAL budget UUID -> parent RECURRING budget,
        # so we can group fill-up goals under the parent's schedule.
        fillup_to_parent: dict[object, Budget] = {}
        for b in budgets:
            if b.fillup_goal_id is not None:
                fillup_to_parent[b.fillup_goal_id] = b

        groups: dict[str, dict] = {}
        grand_total = Decimal("0")
        currency = account.currency

        for budget in budgets:
            info = funding_svc.next_funding_info(budget, today=today)
            if info is None:
                continue

            if budget.budget_type == Budget.BudgetType.ASSOCIATED_FILLUP_GOAL:
                parent = fillup_to_parent.get(budget.id)
                if parent is None:
                    continue
                sched_key = recurrence_lib.serialize(parent.funding_schedule)
            else:
                sched_key = recurrence_lib.serialize(budget.funding_schedule)

            amount = info.amount.amount
            currency = str(info.amount.currency)

            if sched_key not in groups:
                groups[sched_key] = {
                    "schedule": sched_key,
                    "next_date": info.date,
                    "total_amount": Decimal("0"),
                    "currency": currency,
                    "budget_count": 0,
                }

            g = groups[sched_key]
            g["next_date"] = min(g["next_date"], info.date)
            g["total_amount"] += amount
            g["budget_count"] += 1
            grand_total += amount

        schedules = sorted(groups.values(), key=lambda g: g["next_date"])
        for g in schedules:
            g["total_amount"] = str(g["total_amount"])
            g["next_date"] = g["next_date"].isoformat()

        return Response(
            {
                "schedules": schedules,
                "total_amount": str(grand_total),
                "currency": currency,
            }
        )

    ####################################################################
    #
    @extend_schema(
        summary="Invite a co-owner",
        description=(
            "Send a co-ownership invitation to the given email address. "
            "If no mibudge account exists for that address, an inactive "
            "placeholder account is created; the invitee sets their "
            "password after accepting. "
            "Returns 409 if the address is already an owner or a pending "
            "invitation already exists; 429 if too many invitations have "
            "been sent to this address for this account in the rolling "
            "window."
        ),
        request=InviteOwnerSerializer,
        responses={201: None},
    )
    @action(
        detail=True,
        methods=["post"],
        url_path="invite",
        permission_classes=[
            IsAuthenticated,
            IsAccountOwner,
            RequiresInteractiveAuth,
        ],
    )
    def invite(self, request: Request, id: str = "") -> Response:
        """Send a co-ownership invitation email for this bank account."""
        account: BankAccount = self.get_object()
        serializer = InviteOwnerSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        invitee_email = serializer.validated_data["invitee_email"]
        try:
            invitation_svc.create_invitation(
                account, request.user, invitee_email
            )
        except invitation_svc.InviteeAlreadyOwnerError:
            return Response(
                {
                    "detail": "This address is already a co-owner of the account."
                },
                status=status.HTTP_409_CONFLICT,
            )
        except invitation_svc.InvitationAlreadyPendingError:
            return Response(
                {
                    "detail": "A pending invitation for this address already exists."
                },
                status=status.HTTP_409_CONFLICT,
            )
        except invitation_svc.InvitationWindowExceededError as exc:
            return Response(
                {"detail": str(exc)},
                status=status.HTTP_429_TOO_MANY_REQUESTS,
            )
        return Response(status=status.HTTP_201_CREATED)

    ####################################################################
    #
    @extend_schema(
        summary="List pending invitations for this account",
        description="Returns all pending invitations for this bank account.",
        responses={200: BankAccountInvitationSerializer(many=True)},
    )
    @action(
        detail=True,
        methods=["get"],
        url_path="invitations",
        permission_classes=[
            IsAuthenticated,
            IsAccountOwner,
            RequiresInteractiveAuth,
        ],
    )
    def invitations(self, request: Request, id: str = "") -> Response:
        """List pending co-ownership invitations for this bank account."""
        account: BankAccount = self.get_object()
        qs = (
            BankAccountInvitation.objects.select_related(
                "bank_account", "invited_by"
            )
            .filter(
                bank_account=account,
                status=BankAccountInvitation.Status.PENDING,
            )
            .order_by("-created_at")
        )
        serializer = BankAccountInvitationSerializer(qs, many=True)
        return Response(serializer.data)

    ####################################################################
    #
    @extend_schema(
        summary="Cancel a pending invitation",
        description=(
            "Cancel a pending co-ownership invitation by token. "
            "Only the user who sent the invitation may cancel it."
        ),
        parameters=[
            OpenApiParameter(
                "token",
                str,
                OpenApiParameter.PATH,
                description="The invitation's opaque token.",
            ),
        ],
        request=None,
        responses={200: None},
    )
    @action(
        detail=True,
        methods=["post"],
        url_path=r"invitations/(?P<token>[^/.]+)/cancel",
        permission_classes=[
            IsAuthenticated,
            IsAccountOwner,
            RequiresInteractiveAuth,
        ],
    )
    def cancel_invitation(
        self, request: Request, id: str = "", token: str = ""
    ) -> Response:
        """Cancel a pending co-ownership invitation."""
        self.get_object()  # enforce account ownership check
        try:
            invitation = BankAccountInvitation.objects.get(token=token)
        except BankAccountInvitation.DoesNotExist:
            return Response(
                {"detail": "Invitation not found."},
                status=status.HTTP_404_NOT_FOUND,
            )
        if invitation.invited_by_id != request.user.pk:
            return Response(
                {"detail": "Only the sender may cancel this invitation."},
                status=status.HTTP_403_FORBIDDEN,
            )
        try:
            invitation_svc.cancel_invitation(invitation)
        except invitation_svc.TokenAlreadyCancelledError:
            return Response(
                {"detail": "This invitation has already been cancelled."},
                status=status.HTTP_400_BAD_REQUEST,
            )
        except (
            invitation_svc.TokenAlreadyAcceptedError,
            invitation_svc.TokenAlreadyDeclinedError,
        ):
            return Response(
                {"detail": "This invitation can no longer be cancelled."},
                status=status.HTTP_400_BAD_REQUEST,
            )
        return Response(status=status.HTTP_200_OK)


########################################################################
########################################################################
#
@extend_schema_view(
    list=extend_schema(
        summary="List budgets",
        description=(
            "Return budgets belonging to the authenticated user's "
            "accounts. Filterable by bank_account, budget_type, "
            "archived, and paused. Searchable by name. Orderable by "
            "name, created_at, or balance."
        ),
    ),
    create=extend_schema(
        summary="Create a budget",
        description=(
            "Create a new budget under a bank account. Required: "
            "name, bank_account (UUID), budget_type, funding_type, "
            "and target_balance. The bank_account and budget_type are "
            "immutable after creation. Balance is managed by signals "
            "and is always read-only."
        ),
    ),
    retrieve=extend_schema(
        summary="Get budget details",
        description="Return a single budget by UUID.",
    ),
    update=extend_schema(
        summary="Update a budget",
        description=(
            "Full update of a budget. bank_account and budget_type "
            "are immutable. The unallocated budget cannot be renamed."
        ),
    ),
    partial_update=extend_schema(
        summary="Partially update a budget",
        description=(
            "Partial update of a budget. bank_account and budget_type "
            "are immutable. The unallocated budget cannot be renamed."
        ),
    ),
    destroy=extend_schema(
        summary="Delete a budget",
        description=(
            "Delete a budget. The unallocated budget cannot be deleted "
            "(403). A budget with existing transaction allocations cannot "
            "be deleted (400) -- archive it instead."
        ),
    ),
)
class BudgetViewSet(AccountOwnerQuerySetMixin, viewsets.ModelViewSet):
    """Virtual sub-accounts (goals, recurring budgets) within a bank account."""

    serializer_class = BudgetSerializer
    queryset = Budget.objects.select_related(
        "bank_account", "fillup_goal"
    ).all()
    lookup_field = "id"
    permission_classes = [IsAuthenticated, IsAccountOwner]
    filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
    filterset_class = BudgetFilter
    search_fields = ["name"]
    ordering_fields = ["name", "created_at", "balance"]
    ordering = ["name"]

    ####################################################################
    #
    def perform_create(self, serializer: BudgetSerializer) -> None:
        """Create a budget via the service layer so fill-up goal is created.

        Raises:
            ValidationError: On service-layer errors.
        """
        validated = serializer.validated_data
        bank_account = validated.pop("bank_account")
        name = validated.pop("name")
        budget_type = validated.pop("budget_type")
        funding_type = validated.pop("funding_type")
        target_balance = validated.pop("target_balance")
        budget = budget_svc.create(
            bank_account=bank_account,
            name=name,
            budget_type=budget_type,
            funding_type=funding_type,
            target_balance=target_balance,
            **validated,
        )
        serializer.instance = budget

    ####################################################################
    #
    def update(
        self, request: Request, *args: object, **kwargs: object
    ) -> Response:
        """Update a budget and return the result with any unpause warnings.

        Overrides the default DRF update so that warnings emitted by the
        service layer (e.g. missed recur boundaries on unpause) are
        included in the response payload alongside the serialized budget.

        Args:
            request: The incoming HTTP request.
            *args: Positional arguments forwarded from the router.
            **kwargs: Keyword arguments forwarded from the router (may
                include 'partial' for PATCH requests).
        """
        partial = kwargs.pop("partial", False)
        instance = self.get_object()
        serializer = self.get_serializer(
            instance, data=request.data, partial=partial
        )
        serializer.is_valid(raise_exception=True)
        _, warnings = budget_svc.update(instance, **serializer.validated_data)
        instance.refresh_from_db()
        serializer.instance = instance
        return Response({**serializer.data, "warnings": warnings})

    ####################################################################
    #
    def perform_destroy(self, instance: Budget) -> None:
        """Delete a budget via BudgetService.

        Raises:
            PermissionDenied: If the budget is the account's unallocated budget.
            ValidationError: If the budget has existing transaction allocations;
                the caller should archive the budget instead.
        """
        try:
            budget_svc.delete(instance, actor=self.request.user)
        except ValueError as exc:
            msg = str(exc)
            if "unallocated" in msg:
                raise PermissionDenied(msg) from exc
            raise ValidationError(msg) from exc

    ####################################################################
    #
    @extend_schema(
        summary="Archive a budget",
        description=(
            "Archive a budget. Any remaining balance is transferred to the "
            "account's unallocated budget. If the budget has an associated "
            "fill-up goal, that budget is also archived and its balance moved "
            "to unallocated. The unallocated budget cannot be archived."
        ),
        responses={200: BudgetSerializer},
    )
    @action(detail=True, methods=["post"], url_path="archive")
    def archive(self, request: Request, id: str | None = None) -> Response:
        """Archive a budget and move its funds to unallocated."""
        budget = self.get_object()
        try:
            budget = budget_svc.archive(budget, actor=request.user)
        except ValueError as exc:
            msg = str(exc)
            if "unallocated" in msg:
                raise PermissionDenied(msg) from exc
            raise ValidationError(msg) from exc
        return Response(
            self.get_serializer(budget).data, status=status.HTTP_200_OK
        )


########################################################################
########################################################################
#
@extend_schema_view(
    list=extend_schema(
        summary="List transaction categories",
        description=(
            "Return the transaction categories visible to the "
            "authenticated user: the global base set, the user's own "
            "custom categories, categories owned by users they co-own "
            "a bank account with, and categories still referenced by "
            "the user's transactions after sharing ended.  Filterable "
            "by group, archived, and scope (global|mine|shared).  "
            "Searchable by group and name."
        ),
    ),
    create=extend_schema(
        summary="Create a transaction category",
        description=(
            "Create a custom category owned by the authenticated user "
            "(global categories are managed via the admin).  Group and "
            "name are whitespace-normalized; case-insensitive "
            "duplicates of global rows or the user's own rows are "
            "rejected."
        ),
    ),
    retrieve=extend_schema(
        summary="Get transaction category details",
        description="Return a single visible category by UUID.",
    ),
    update=extend_schema(
        summary="Update a transaction category",
        description=(
            "Full update of a category.  Only the owner may update; "
            "global categories are managed via the admin."
        ),
    ),
    partial_update=extend_schema(
        summary="Partially update a transaction category",
        description=(
            "Partial update of a category.  Only the owner may update; "
            "global categories are managed via the admin."
        ),
    ),
    destroy=extend_schema(
        summary="Delete a transaction category",
        description=(
            "Delete a category.  Only the owner may delete; global "
            "categories are managed via the admin.  A category still "
            "referenced by transactions or allocations cannot be "
            "deleted (409) -- archive it instead."
        ),
        responses={
            204: None,
            409: OpenApiResponse(
                description=(
                    "The category is referenced by transactions or "
                    "allocations; archive it instead."
                ),
            ),
        },
    ),
)
class TransactionCategoryViewSet(viewsets.ModelViewSet):
    """Shared + per-user transaction categories.

    Visibility is computed per request via
    TransactionCategoryQuerySet.visible_to.  Mutations are owner-only;
    global rows (owner NULL) are managed exclusively through the
    django-admin.
    """

    serializer_class = TransactionCategorySerializer
    queryset = TransactionCategory.objects.all()
    lookup_field = "id"
    permission_classes = [IsAuthenticated]
    filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
    filterset_class = TransactionCategoryFilter
    search_fields = ["group", "name"]
    ordering_fields = ["group", "name", "created_at"]
    ordering = ["group", "name"]

    ####################################################################
    #
    def get_queryset(self):
        """Restrict to categories visible to the requesting user."""
        return TransactionCategory.objects.visible_to(self.request.user)

    ####################################################################
    #
    def _require_owner(self, category: TransactionCategory) -> None:
        """Reject mutation of global rows and other users' categories.

        Raises:
            PermissionDenied: If the category is global or owned by
                someone else.
        """
        if category.owner_id is None:
            raise PermissionDenied(
                "Global categories are managed by administrators."
            )
        if category.owner_id != self.request.user.pk:
            raise PermissionDenied("Only the category's owner may modify it.")

    ####################################################################
    #
    def perform_create(self, serializer: TransactionCategorySerializer) -> None:
        """Create a category owned by the requesting user."""
        serializer.save(owner=self.request.user)

    ####################################################################
    #
    def perform_update(self, serializer: TransactionCategorySerializer) -> None:
        """Update a category after enforcing owner-only mutation."""
        self._require_owner(serializer.instance)
        serializer.save()

    ####################################################################
    #
    def destroy(
        self, request: Request, *args: object, **kwargs: object
    ) -> Response:
        """Delete a category unless it is still referenced (409)."""
        category = self.get_object()
        self._require_owner(category)
        if category.transactions.exists() or category.allocations.exists():
            return Response(
                {
                    "detail": (
                        "This category is referenced by transactions or "
                        "allocations and cannot be deleted; archive it "
                        "instead."
                    )
                },
                status=status.HTTP_409_CONFLICT,
            )
        self.perform_destroy(category)
        return Response(status=status.HTTP_204_NO_CONTENT)

    ####################################################################
    #
    @extend_schema(
        summary="Archive a transaction category",
        description=(
            "Archive a category so pickers hide it while existing "
            "references stay valid.  Only the owner may archive; "
            "global categories are managed via the admin."
        ),
        request=None,
        responses={200: TransactionCategorySerializer},
    )
    @action(detail=True, methods=["post"], url_path="archive")
    def archive(self, request: Request, id: str | None = None) -> Response:
        """Archive a category (idempotent)."""
        category = self.get_object()
        self._require_owner(category)
        if not category.archived:
            category.archived = True
            category.save(update_fields=["archived", "modified_at"])
        return Response(
            self.get_serializer(category).data, status=status.HTTP_200_OK
        )


########################################################################
########################################################################
#
@extend_schema_view(
    list=extend_schema(
        summary="List transactions",
        description=(
            "Return transactions belonging to the authenticated user's "
            "accounts. Filterable by bank_account, pending status, "
            "transaction_type, and date range (date_from/date_to). "
            "Searchable by description, raw_description, and party. "
            "Orderable by transaction_date, amount, or created_at."
        ),
    ),
    create=extend_schema(
        summary="Create a transaction",
        description=(
            "Create a new bank transaction. Required: bank_account "
            "(UUID), amount, transaction_date, transaction_type, and "
            "raw_description. A default TransactionAllocation to the "
            "bank account's unallocated budget is auto-created. After "
            "creation, only transaction_type, memo, and description "
            "are updatable."
        ),
    ),
    retrieve=extend_schema(
        summary="Get transaction details",
        description="Return a single transaction by UUID.",
    ),
    update=extend_schema(
        summary="Update a transaction",
        description=(
            "Full update of a transaction. Only transaction_type, "
            "memo, and description are mutable after creation."
        ),
    ),
    partial_update=extend_schema(
        summary="Partially update a transaction",
        description=(
            "Partial update of a transaction. Only transaction_type, "
            "memo, and description are mutable after creation."
        ),
    ),
    destroy=extend_schema(
        summary="Delete a transaction",
        description=(
            "Delete a transaction. Balance changes are reversed by "
            "the pre_delete signal. Associated allocations are "
            "cascade-deleted."
        ),
    ),
)
class TransactionViewSet(AccountOwnerQuerySetMixin, viewsets.ModelViewSet):
    """Bank transactions (purchases, deposits, transfers) on user accounts."""

    serializer_class = TransactionSerializer
    queryset = Transaction.objects.select_related("bank_account").all()
    lookup_field = "id"
    permission_classes = [IsAuthenticated, IsAccountOwner]
    filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
    filterset_class = TransactionFilter
    search_fields = ["description", "raw_description", "party"]
    ordering_fields = ["transaction_date", "amount", "created_at"]
    ordering = ["-transaction_date", "-created_at"]

    ####################################################################
    #
    def perform_create(self, serializer: TransactionSerializer) -> None:
        """Create a transaction via TransactionService.

        Applies bank-balance math, seeds the default Unallocated
        allocation, and enqueues the cross-account linker.
        """
        data = serializer.validated_data
        tx = transaction_svc.create(
            bank_account=data["bank_account"],
            amount=data["amount"],
            posted_date=data["posted_date"],
            raw_description=data["raw_description"],
            transaction_date=data.get("transaction_date"),
            pending=data.get("pending", False),
            transaction_type=data.get("transaction_type", ""),
            memo=data.get("memo"),
            description=data.get("description", ""),
        )
        serializer.instance = tx

    ####################################################################
    #
    def perform_update(self, serializer: TransactionSerializer) -> None:
        """Update a transaction via TransactionService.

        Routes through the service so that a pending → posted transition
        correctly updates the bank account's posted_balance.

        A user changing `description` sets `description_user_edited`,
        which stops the details-import pipeline from ever recomposing
        the description over the user's text.
        """
        changes = dict(serializer.validated_data)
        instance = serializer.instance
        new_description = changes.get("description")
        if (
            new_description is not None
            and new_description != instance.description
        ):
            changes["description_user_edited"] = True
        transaction_svc.update(instance, **changes)
        serializer.instance.refresh_from_db()

    ####################################################################
    #
    def perform_destroy(self, instance: Transaction) -> None:
        """Delete a transaction via TransactionService.

        Reverses bank and budget balances before deletion.
        """
        transaction_svc.delete(instance)

    ####################################################################
    #
    @extend_schema(
        summary="Declare transaction splits",
        description=(
            "Declaratively set how a transaction's amount is split "
            "across budgets. All referenced budgets must belong to "
            "the same bank account as the transaction. The backend "
            "reconciles existing allocations to match: creating, "
            "updating, or deleting as needed. Any unallocated "
            "remainder gets an allocation to the account's "
            "unallocated budget. Returns all allocations for this "
            "transaction after reconciliation."
        ),
        request=TransactionSplitsSerializer,
        responses={200: TransactionAllocationSerializer(many=True)},
    )
    @action(detail=True, methods=["post"], url_path="splits")
    def splits(self, request: Request, id: str | None = None) -> Response:
        """Reconcile transaction allocations to match declared splits.

        Accepts a dict mapping budget UUIDs to positive amounts.  The
        backend creates, updates, or deletes allocations so that each
        budget listed receives exactly its declared amount.  Any
        remainder (transaction amount minus the sum of splits) is
        assigned to the bank account's unallocated budget.  The
        entire operation runs inside a database transaction.

        Args:
            request: DRF request with body
                ``{"splits": {"<budget-uuid>": "<amount>", ...}}``.
                Amounts are positive decimals; sign is inferred from
                the transaction (negative for debits, positive for
                credits).  An empty dict ``{}`` moves the full amount
                back to the unallocated budget.
            id: UUID of the transaction to split.

        Returns:
            Response containing the full list of
            ``TransactionAllocation`` objects for this transaction
            after reconciliation.
        """
        transaction = self.get_object()

        serializer = TransactionSplitsSerializer(
            data=request.data,
            context={"transaction": transaction},
        )
        serializer.is_valid(raise_exception=True)

        splits: dict[str, Decimal] = serializer.validated_data["splits"]

        try:
            allocations = transaction_svc.split(transaction, splits)
        except ValueError as exc:
            raise ValidationError(str(exc)) from exc

        response_serializer = TransactionAllocationSerializer(
            allocations, many=True
        )
        return Response(response_serializer.data)

    ####################################################################
    #
    @extend_schema(
        summary="Resolve a pending transaction to posted",
        description=(
            "Transition a pending transaction to posted status. "
            "Supplies the bank-confirmed posted date and optionally a "
            "final settled amount (which may differ from the pending "
            "estimate). The bank account's posted_balance is credited; "
            "if the amount changed, available_balance and the "
            "Unallocated allocation are adjusted atomically."
        ),
        request=ResolvePendingSerializer,
        responses={200: TransactionSerializer},
    )
    @action(detail=True, methods=["post"], url_path="resolve-pending")
    def resolve_pending(
        self, request: Request, id: str | None = None
    ) -> Response:
        """Transition a pending transaction to posted.

        Args:
            request: DRF request with body
                ``{"posted_date": "<iso-datetime>", "amount": "<value>"}``.
                ``amount`` is optional; when omitted the original pending
                amount is used as the final settled amount.
            id: UUID of the pending transaction to resolve.

        Returns:
            Response containing the updated Transaction serialized by
            ``TransactionSerializer``.
        """
        transaction = self.get_object()
        if not transaction.pending:
            raise ValidationError("Transaction is not pending.")

        serializer = ResolvePendingSerializer(
            data=request.data,
            context={"transaction": transaction},
        )
        serializer.is_valid(raise_exception=True)

        try:
            updated = transaction_svc.resolve_pending_to_posted(
                transaction,
                new_posted_date=serializer.validated_data["posted_date"],
                new_amount=serializer.validated_data.get("amount"),
            )
        except ValueError as exc:
            raise ValidationError(str(exc)) from exc

        return Response(TransactionSerializer(updated).data)


########################################################################
########################################################################
#
@extend_schema_view(
    list=extend_schema(
        summary="List transaction allocations",
        description=(
            "Return allocations belonging to the authenticated user's "
            "transactions. Filterable by transaction, budget, and "
            "category. Orderable by created_at."
        ),
    ),
    retrieve=extend_schema(
        summary="Get allocation details",
        description="Return a single transaction allocation by UUID.",
    ),
)
class TransactionAllocationViewSet(
    AccountOwnerQuerySetMixin,
    mixins.RetrieveModelMixin,
    mixins.ListModelMixin,
    viewsets.GenericViewSet,
):
    """Read-only view of budget allocations for transactions.

    All allocation mutations (create, update, delete) must go through the
    transaction ``splits`` action (``POST /api/v1/transactions/<id>/splits/``).
    This ensures ``budget_balance`` snapshots are always recorded correctly
    and that running-balance recalculation on affected budgets is atomic.
    """

    serializer_class = TransactionAllocationSerializer
    queryset = TransactionAllocation.objects.select_related(
        "transaction", "budget"
    ).all()
    lookup_field = "id"
    permission_classes = [IsAuthenticated, IsAccountOwner]
    filter_backends = [DjangoFilterBackend, OrderingFilter]
    filterset_class = TransactionAllocationFilter
    ordering_fields = ["created_at"]
    ordering = ["created_at"]


########################################################################
########################################################################
#
@extend_schema_view(
    list=extend_schema(
        summary="List internal transactions",
        description=(
            "Return budget-to-budget transfers belonging to the "
            "authenticated user's accounts. Filterable by "
            "bank_account, src_budget, dst_budget, and date range "
            "(date_from/date_to). Orderable by created_at."
        ),
    ),
    create=extend_schema(
        summary="Create an internal transaction",
        description=(
            "Transfer money between two budgets in the same bank "
            "account. Required: bank_account (UUID), amount, "
            "src_budget (UUID), and dst_budget (UUID). The "
            "authenticated user is recorded as the actor. Internal "
            "transactions are write-once -- to reverse a transfer, "
            "create a new one with src and dst swapped."
        ),
    ),
    retrieve=extend_schema(
        summary="Get internal transaction details",
        description="Return a single internal transaction by UUID.",
    ),
)
class InternalTransactionViewSet(
    AccountOwnerQuerySetMixin,
    mixins.CreateModelMixin,
    mixins.RetrieveModelMixin,
    mixins.ListModelMixin,
    viewsets.GenericViewSet,
):
    """Write-once budget-to-budget transfers within a bank account."""

    serializer_class = InternalTransactionSerializer
    queryset = InternalTransaction.objects.select_related(
        "bank_account", "src_budget", "dst_budget", "actor"
    ).all()
    lookup_field = "id"
    permission_classes = [IsAuthenticated, IsAccountOwner]
    filter_backends = [DjangoFilterBackend, OrderingFilter]
    filterset_class = InternalTransactionFilter
    ordering_fields = ["created_at"]
    ordering = ["-created_at"]

    ####################################################################
    #
    def perform_create(self, serializer: InternalTransactionSerializer) -> None:
        """Save the internal transaction with the requesting user as actor."""
        data = serializer.validated_data
        serializer.instance = internal_transaction_svc.create(
            bank_account=data["bank_account"],
            src_budget=data["src_budget"],
            dst_budget=data["dst_budget"],
            amount=data["amount"],
            actor=self.request.user,
            effective_date=data.get("effective_date"),
        )


########################################################################
########################################################################
#
@extend_schema_view(
    list=extend_schema(
        summary="List funding event occurrences",
        description=(
            "Return funding event occurrences for budgets on accounts "
            "owned by the authenticated user.  Filterable by bank_account, "
            "budget, kind, status (multi-value), and scheduled_date range.  "
            "Orderable by scheduled_date or created_at."
        ),
    ),
    retrieve=extend_schema(
        summary="Get a funding event occurrence",
        description="Return a single funding event occurrence by UUID.",
    ),
)
class FundingEventOccurrenceViewSet(viewsets.ReadOnlyModelViewSet):
    """Read-only access to FundingEventOccurrence rows for owned accounts."""

    serializer_class = FundingEventOccurrenceSerializer
    queryset = FundingEventOccurrence.objects.select_related(
        "budget__bank_account"
    ).all()
    lookup_field = "id"
    permission_classes = [IsAuthenticated, IsAccountOwner]
    filter_backends = [DjangoFilterBackend, OrderingFilter]
    filterset_class = FundingEventOccurrenceFilter
    ordering_fields = ["scheduled_date", "created_at"]
    ordering = ["-scheduled_date"]

    ####################################################################
    #
    def get_queryset(self):
        """Restrict to occurrences on accounts the requesting user owns."""
        return FundingEventOccurrence.objects.select_related(
            "budget__bank_account"
        ).filter(budget__bank_account__owners=self.request.user)


########################################################################
########################################################################
#
@extend_schema(
    summary="List supported currencies",
    description=(
        "Return all ISO 4217 currency codes supported by the system, "
        "sorted by code. Each entry includes the code, English name, "
        "and numeric ISO 4217 code. Requires authentication."
    ),
    responses={
        200: OpenApiResponse(
            description="List of supported currencies.",
        ),
    },
)
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def currencies(request: Request) -> Response:
    """Return all supported ISO 4217 currencies, sorted by code."""
    data = [
        {"code": c.code, "name": c.name, "numeric": c.numeric}
        for c in sorted(moneyed.CURRENCIES.values(), key=lambda c: c.code)
    ]
    return Response(data)


########################################################################
########################################################################
# Public invitation endpoints (AllowAny -- token is the credential)
########################################################################
########################################################################
#
@extend_schema(
    summary="Get invitation details (public)",
    description=(
        "Return bank account name, current owners, and invitee status for "
        "the invitation identified by *token*. No authentication required -- "
        "the token is the credential. Used by native apps to render the "
        "acceptance UI; the Django template view renders this server-side."
    ),
    responses={200: PublicInvitationDetailSerializer},
)
@api_view(["GET"])
@permission_classes([AllowAny])
def invitation_detail(request: Request, token: str) -> Response:
    """Return public invitation details by token."""
    try:
        inv = BankAccountInvitation.objects.select_related(
            "bank_account", "bank_account__bank", "invitee_user"
        ).get(token=token)
    except BankAccountInvitation.DoesNotExist:
        return Response(
            {"detail": "Invitation not found."},
            status=status.HTTP_404_NOT_FOUND,
        )
    serializer = PublicInvitationDetailSerializer(inv)
    return Response(serializer.data)


########################################################################
########################################################################
#
@extend_schema(
    summary="Accept an invitation (public)",
    description=(
        "Accept the co-ownership invitation identified by *token*. "
        "Adds the invitee to the account's owners. "
        "For brand-new users (no password set), a password-reset email "
        "is also dispatched. No authentication required."
    ),
    request=None,
    responses={
        200: None,
        400: OpenApiResponse(
            description="Invitation expired, cancelled, or already resolved."
        ),
        404: OpenApiResponse(description="Invitation not found."),
    },
)
@api_view(["POST"])
@permission_classes([AllowAny])
def invitation_accept(request: Request, token: str) -> Response:
    """Accept a co-ownership invitation."""
    try:
        invitation_svc.accept_invitation(token, request=request)
        return Response(status=status.HTTP_200_OK)
    except invitation_svc.TokenNotFoundError:
        return Response(
            {"detail": "Invitation not found."},
            status=status.HTTP_404_NOT_FOUND,
        )
    except invitation_svc.TokenExpiredError:
        return Response(
            {"detail": "This invitation has expired."},
            status=status.HTTP_400_BAD_REQUEST,
        )
    except (
        invitation_svc.TokenAlreadyCancelledError,
        invitation_svc.TokenAlreadyAcceptedError,
        invitation_svc.TokenAlreadyDeclinedError,
    ) as exc:
        match exc:
            case invitation_svc.TokenAlreadyCancelledError():
                detail = "This invitation has been cancelled."
            case invitation_svc.TokenAlreadyAcceptedError():
                detail = "This invitation has already been accepted."
            case _:
                detail = "This invitation has already been declined."
        return Response({"detail": detail}, status=status.HTTP_400_BAD_REQUEST)


########################################################################
########################################################################
#
@extend_schema(
    summary="Decline an invitation (public)",
    description=(
        "Decline the co-ownership invitation identified by *token*. "
        "No authentication required."
    ),
    request=None,
    responses={
        200: None,
        400: OpenApiResponse(
            description="Invitation expired, cancelled, or already resolved."
        ),
        404: OpenApiResponse(description="Invitation not found."),
    },
)
@api_view(["POST"])
@permission_classes([AllowAny])
def invitation_decline(request: Request, token: str) -> Response:
    """Decline a co-ownership invitation."""
    try:
        invitation_svc.decline_invitation(token)
        return Response(status=status.HTTP_200_OK)
    except invitation_svc.TokenNotFoundError:
        return Response(
            {"detail": "Invitation not found."},
            status=status.HTTP_404_NOT_FOUND,
        )
    except invitation_svc.TokenExpiredError:
        return Response(
            {"detail": "This invitation has expired."},
            status=status.HTTP_400_BAD_REQUEST,
        )
    except (
        invitation_svc.TokenAlreadyCancelledError,
        invitation_svc.TokenAlreadyAcceptedError,
        invitation_svc.TokenAlreadyDeclinedError,
    ) as exc:
        match exc:
            case invitation_svc.TokenAlreadyCancelledError():
                detail = "This invitation has been cancelled."
            case invitation_svc.TokenAlreadyDeclinedError():
                detail = "This invitation has already been declined."
            case _:
                detail = "This invitation has already been accepted."
        return Response({"detail": detail}, status=status.HTTP_400_BAD_REQUEST)
