from decimal import Decimal

from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import MethodNotAllowed, PermissionDenied, ValidationError
from rest_framework.response import Response
from django.db import transaction
from django.db.models import Prefetch, Sum
from django.utils import timezone
from drf_spectacular.utils import extend_schema, OpenApiParameter

from apps.asset_management.models import Vendor
from apps.core.pagination import StandardResultsSetPagination
from apps.core.tenancy import TenantScopedMixin, get_active_organization
from .models import Requisition, RequisitionQuote, Approval, RFQRFP, PurchaseOrder, ProcurementContract, ProcurementEvent, GoodsReceipt, Invoice, Payment, ProcurementPolicy
from .access import visible_purchase_orders, visible_requisitions, visible_via_requisition, user_can_request_for
from .workflow import approve_invoice, approve_payment_funds, cancel_purchase_order, issue_purchase_order, record_event, release_payment, sign_contract, terminate_contract
from .serializers import (
    RequisitionSerializer, RequisitionQuoteSerializer, ApprovalSerializer, RFQRFPSerializer,
    PurchaseOrderSerializer, ProcurementContractSerializer,
    GoodsReceiptSerializer, InvoiceSerializer, PaymentSerializer
)


class QueryFilterMixin:
    """Apply exact-match query-string filtering for declared fields.

    Each ViewSet lists the query params it honors in `query_filter_fields`
    (names match both the ?param and the model field). Present params filter
    the queryset; absent or blank ones are ignored. This backs the
    OpenApiParameter declarations on each list() — without it those params are
    documented but silently no-ops.
    """
    query_filter_fields: list[str] = []
    pagination_class = StandardResultsSetPagination

    def get_queryset(self):
        qs = super().get_queryset()
        for field in self.query_filter_fields:
            value = self.request.query_params.get(field)
            if value not in (None, ""):
                qs = qs.filter(**{field: value})
        return qs


class RequisitionViewSet(QueryFilterMixin, viewsets.ModelViewSet):
    """ViewSet for Requisition management."""
    rbac_domain = "procurements"
    queryset = Requisition.objects.select_related("requested_by", "vendor", "budget_item")
    serializer_class = RequisitionSerializer
    query_filter_fields = ["status", "requested_by", "vendor"]

    def get_queryset(self):
        return visible_requisitions(super().get_queryset(), self.request.user, get_active_organization(self.request))

    def perform_create(self, serializer):
        org = get_active_organization(self.request)
        department = serializer.validated_data.get("requesting_department")
        project = serializer.validated_data.get("project")
        if not user_can_request_for(self.request.user, org, department, project):
            raise PermissionDenied("You cannot create a requisition for that scope.")
        vendor = serializer.validated_data.get("vendor")
        if vendor is not None and vendor.organization_id != org.id:
            raise PermissionDenied("The vendor is outside your organization.")
        budget_item = serializer.validated_data.get("budget_item")
        if budget_item is not None and budget_item.category.organization_id != org.id:
            raise PermissionDenied("The budget item is outside your organization.")
        if department is None:
            membership = self.request.user.department_memberships.filter(is_active=True).first()
            department = membership.department if membership else None
        serializer.save(
            requested_by=self.request.user,
            requesting_department=department,
            status="draft",
            organization=org,
        )

    def update(self, request, *args, **kwargs):
        req = self.get_object()
        if "status" in request.data:
            raise ValidationError({"status": "Status changes must use a lifecycle action."})
        if req.status != "draft":
            raise ValidationError("A submitted requisition is immutable; create a revision.")
        return super().update(request, *args, **kwargs)

    def destroy(self, request, *args, **kwargs):
        raise MethodNotAllowed("DELETE", detail="Requisitions are withdrawn or cancelled, never deleted.")

    @extend_schema(
        parameters=[
            OpenApiParameter(name="status", type=str, description="Filter by status"),
            OpenApiParameter(name="requested_by", type=str, description="Filter by requester ID"),
            OpenApiParameter(name="vendor", type=str, description="Filter by vendor ID"),
        ]
    )
    def list(self, request, *args, **kwargs):
        return super().list(request, *args, **kwargs)

    def get_serializer_context(self):
        context = super().get_serializer_context()
        if self.action == "list":
            page = getattr(self, "_paginated_page", None)
            budget_item_ids = {r.budget_item_id for r in (page or []) if r.budget_item_id}
            if budget_item_ids:
                committed = (
                    Requisition.objects.filter(
                        organization=get_active_organization(self.request),
                        budget_item_id__in=budget_item_ids,
                        status__in=["pending", "approved", "fulfilled"],
                    )
                    .exclude(total_amount__isnull=True)
                    .values("budget_item_id")
                    .annotate(total=Sum("total_amount"))
                )
                context["committed_by_budget_item"] = {
                    row["budget_item_id"]: row["total"] or Decimal("0") for row in committed
                }
        return context

    def paginate_queryset(self, queryset):
        page = super().paginate_queryset(queryset)
        self._paginated_page = page
        return page

    @action(detail=True, methods=["post"])
    def submit(self, request, pk=None):
        """Submit a requisition for approval, building the amount-based chain.

        Reads total_amount → policy → ordered role chain. Creates one Approval
        per role (level 1 pending, the rest blocked) and moves the requisition
        to "pending". An empty chain (below the auto-approve floor) approves the
        requisition immediately with no Approval rows.

        Idempotent guard: refuses if the requisition already has approvals or is
        past the draft/pending stage.
        """
        from .approval_policy import chain_for

        req = self.get_object()
        if req.approvals.exists():
            return Response(
                {"detail": "Requisition already submitted."},
                status=409,
            )
        if req.status not in ("draft", "pending"):
            return Response(
                {"detail": f"Cannot submit a requisition in status '{req.status}'."},
                status=409,
            )
        if not req.total_amount or req.total_amount <= 0:
            raise ValidationError({"total_amount": "A positive total is required before submission."})
        if not req.budget_item_id:
            raise ValidationError({"budget_item": "A budget line is required before submission."})

        policy = ProcurementPolicy.current(req.organization)
        if req.procurement_method not in ("single_source", "emergency"):
            if req.total_amount < policy.direct_purchase_limit:
                req.procurement_method = "direct_purchase"
            elif req.total_amount < policy.formal_sourcing_limit:
                req.procurement_method = "three_quotes"
            else:
                req.procurement_method = "formal_rfq"
        elif not req.exception_justification.strip():
            raise ValidationError({"exception_justification": "Single-source and emergency purchases require justification."})

        chain = chain_for(req.total_amount)

        # Advisory budget context — never blocks, just surfaced to the caller.
        budget = req.budget_context()
        warnings = [budget["warning"]] if budget.get("warning") else []

        if not chain:
            chain = ["procurement"]

        approvals = []
        for idx, role in enumerate(chain, start=1):
            approvals.append(Approval.objects.create(
                requisition=req,
                level=idx,
                required_role=role,
                status="pending" if idx == 1 else "blocked",
            ))
        req.status = "pending"
        req.save(update_fields=["status", "procurement_method", "updated_at"])

        for user in _users_in_role(chain[0], req.organization):
            _notify_procurement(user, title="Approval needed", message=f"Requisition “{req.title}” ({_money(req.total_amount)}) needs your approval.", link="/procurements/approvals", priority="high")
        return Response({"status": "pending", "auto": False, "levels": len(chain), "chain": chain, "warnings": warnings}, status=201)

    @action(detail=True, methods=["get"])
    def workspace(self, request, pk=None):
        req = self.get_object()
        purchase_orders = req.purchase_orders.all()
        receipts = GoodsReceipt.objects.filter(purchase_order__in=purchase_orders)
        invoices = Invoice.objects.filter(purchase_order__in=purchase_orders)
        payments = Payment.objects.filter(invoice__in=invoices)
        doc_ids = [str(req.id)] + [str(x.id) for x in [*purchase_orders, *receipts, *invoices, *payments]]
        return Response({
            "requisition": self.get_serializer(req).data,
            "stages": {"requisition": req.status, "sourcing": "awarded" if req.quotes.filter(is_selected=True).exists() else "pending", "ordering": purchase_orders.first().status if purchase_orders.exists() else "pending", "receipt": receipts.first().status if receipts.exists() else "pending", "invoice": invoices.first().status if invoices.exists() else "pending", "payment": payments.first().status if payments.exists() else "pending"},
            "quotes": RequisitionQuoteSerializer(req.quotes.all(), many=True, context={"request": request}).data,
            "approvals": ApprovalSerializer(req.approvals.all(), many=True, context={"request": request}).data,
            "purchase_orders": PurchaseOrderSerializer(purchase_orders, many=True, context={"request": request}).data,
            "receipts": GoodsReceiptSerializer(receipts, many=True, context={"request": request}).data,
            "invoices": InvoiceSerializer(invoices, many=True, context={"request": request}).data,
            "payments": PaymentSerializer(payments, many=True, context={"request": request}).data,
            "events": [{"id": str(e.id), "action": e.action, "from_status": e.from_status, "to_status": e.to_status, "actor_name": e.actor.get_full_name() or e.actor.email, "reason": e.reason, "created_at": e.created_at} for e in ProcurementEvent.objects.filter(document_id__in=doc_ids)],
        })

class RequisitionQuoteViewSet(QueryFilterMixin, viewsets.ModelViewSet):
    """ViewSet for RequisitionQuote management."""
    rbac_domain = "procurements"
    queryset = RequisitionQuote.objects.select_related("requisition", "vendor", "awarded_by")
    serializer_class = RequisitionQuoteSerializer
    query_filter_fields = ["requisition", "vendor"]

    def get_queryset(self):
        return visible_via_requisition(super().get_queryset(), self.request.user, "requisition", get_active_organization(self.request), has_own_organization=False)

    def perform_create(self, serializer):
        org = get_active_organization(self.request)
        req = serializer.validated_data["requisition"]
        if not visible_requisitions(Requisition.objects.filter(pk=req.pk), self.request.user, org).exists():
            raise PermissionDenied("The requisition is outside your operational scope.")
        serializer.save()

    @extend_schema(
        parameters=[
            OpenApiParameter(name="requisition", type=str, description="Filter by requisition ID"),
            OpenApiParameter(name="vendor", type=str, description="Filter by vendor ID"),
        ]
    )
    def list(self, request, *args, **kwargs):
        return super().list(request, *args, **kwargs)

    @action(detail=True, methods=["post"])
    def award(self, request, pk=None):
        """Award this quote — selects it and clears any prior award on the
        requisition (single award enforced).

        Awarding a quote that isn't the cheapest requires `award_reason` in the
        body — a buyer can override the low bid, but must justify it. Records who
        awarded and when.
        """
        quote = self.get_object()
        reason = (request.data.get("award_reason") or "").strip()
        req = quote.requisition
        if req.status != "approved":
            raise ValidationError("Only an approved requisition can be awarded.")
        if req.procurement_method in ("three_quotes", "formal_rfq") and req.quotes.count() < ProcurementPolicy.current(req.organization).minimum_quotes:
            raise ValidationError("The competitive quote minimum has not been met.")
        if req.procurement_method in ("single_source", "emergency") and not req.exception_justification.strip():
            raise ValidationError("The exception justification is required before award.")

        if not quote.is_lowest and not reason:
            return Response(
                {"award_reason": ["Awarding a non-lowest quote requires a justification."]},
                status=400,
            )

        # Clear any existing award on this requisition (the partial unique
        # constraint would otherwise reject a second selected row).
        RequisitionQuote.objects.filter(
            requisition_id=quote.requisition_id, is_selected=True
        ).exclude(id=quote.id).update(
            is_selected=False, award_reason="", awarded_by=None, awarded_at=None
        )

        quote.is_selected = True
        quote.award_reason = reason
        quote.awarded_by = request.user if request.user.is_authenticated else None
        quote.awarded_at = timezone.now()
        quote.save(update_fields=["is_selected", "award_reason", "awarded_by", "awarded_at"])

        _notify_procurement(
            req.requested_by,
            title="Quote awarded",
            message=f"A quote from {quote.vendor.name} was awarded for “{req.title}”.",
            link=f"/procurements/requisitions/{req.id}",
        )
        return Response(self.get_serializer(quote).data)


def _users_in_role(role_slug, organization):
    """Users assigned an RBAC role by slug within organization. Empty if the role doesn't exist."""
    if not role_slug:
        return []
    from django.contrib.auth import get_user_model
    User = get_user_model()
    return list(
        User.objects.filter(user_roles__role__slug=role_slug, user_roles__organization=organization).distinct()
    )


def _money(amount):
    """Format an IDR amount for notification copy."""
    if amount is None:
        return "Rp —"
    try:
        return f"Rp {int(amount):,}".replace(",", ".")
    except (TypeError, ValueError):
        return f"Rp {amount}"


def _notify_procurement(user, *, title, message, link="", priority="medium", related_user=None):
    """Fire an in-app procurement notification; never break the request on failure."""
    from apps.notifications.utils import notify
    notify(
        user, title=title, message=message, link=link, priority=priority,
        related_user=related_user, notification_type="procurement", icon="shopping-cart",
    )


class ApprovalViewSet(QueryFilterMixin, viewsets.ModelViewSet):
    """ViewSet for Approval management."""
    rbac_domain = "procurements"
    queryset = Approval.objects.select_related("requisition", "requisition__vendor", "approver")
    serializer_class = ApprovalSerializer
    query_filter_fields = ["requisition", "status"]
    rbac_action_map = {"decide": "approve"}

    def get_queryset(self):
        return visible_via_requisition(super().get_queryset(), self.request.user, "requisition", get_active_organization(self.request), has_own_organization=False)

    @staticmethod
    def _assert_can_decide(approval, actor):
        """Only a holder of the level's required_role may approve/reject it.

        Superusers bypass. A level with no required_role is open to any
        authenticated user (matches the chain builder's optional roles).
        """
        if actor is None:
            raise PermissionDenied("Authentication required to decide an approval.")
        if approval.requisition.requested_by_id == actor.id:
            raise PermissionDenied("A requester cannot approve their own requisition.")
        role = approval.required_role
        if not role:
            return
        if not actor.user_roles.filter(role__slug=role, organization=approval.requisition.organization).exists():
            raise PermissionDenied(f"This approval requires the “{role}” role.")

    def create(self, request, *args, **kwargs):
        raise MethodNotAllowed("POST", detail="Approval steps are generated by requisition submission.")

    def destroy(self, request, *args, **kwargs):
        raise MethodNotAllowed("DELETE", detail="Approval history cannot be deleted.")

    @action(detail=True, methods=["post"])
    @transaction.atomic
    def decide(self, request, pk=None):
        visible = self.get_object()
        approval = Approval.objects.select_for_update().select_related("requisition").get(pk=visible.pk)
        decision = request.data.get("decision")
        if decision not in ("approve", "reject"):
            raise ValidationError({"decision": "Use 'approve' or 'reject'."})
        if approval.status != "pending":
            raise ValidationError("Only a pending approval can be decided.")
        self._assert_can_decide(approval, request.user)
        approval.status = "approved" if decision == "approve" else "rejected"
        approval.comment = (request.data.get("comment") or "").strip()
        approval.approver = request.user
        approval.resolved_at = timezone.now()
        approval.save(update_fields=["status", "comment", "approver", "resolved_at"])
        self._advance_chain(approval, request.user)
        return Response(self.get_serializer(approval).data)

    def perform_create(self, serializer):
        approval = serializer.save()
        actor = self.request.user if self.request.user.is_authenticated else None
        # New approval task → notify the assigned approver.
        if approval.approver_id and (not actor or approval.approver_id != actor.id):
            _notify_procurement(
                approval.approver,
                title="Approval needed",
                message=f"Requisition “{approval.requisition.title}” needs your approval.",
                link="/procurements",
                priority="high",
                related_user=actor,
            )

    def perform_update(self, serializer):
        old_status = serializer.instance.status
        actor = self.request.user if self.request.user.is_authenticated else None
        new_status = serializer.validated_data.get("status", old_status)

        # Deciding a level requires the level's RBAC role (superusers bypass).
        if new_status != old_status and new_status in ("approved", "rejected"):
            self._assert_can_decide(serializer.instance, actor)

        approval = serializer.save()
        if approval.status == old_status or approval.status not in ("approved", "rejected"):
            return

        # Stamp who decided + when (the chain doesn't pre-assign an approver).
        fields = []
        if actor and approval.approver_id is None:
            approval.approver = actor
            fields.append("approver")
        if approval.resolved_at is None:
            approval.resolved_at = timezone.now()
            fields.append("resolved_at")
        if fields:
            approval.save(update_fields=fields)

        self._advance_chain(approval, actor)

    def _advance_chain(self, approval, actor):
        """Move the requisition forward after a level's decision.

        Rejection at any level rejects the requisition and cancels the rest.
        Approval unblocks the next level; approval of the top level approves the
        requisition.
        """
        req = approval.requisition
        if not req:
            return
        siblings = list(req.approvals.order_by("level", "created_at"))

        if approval.status == "rejected":
            for s in siblings:
                if s.id != approval.id and s.status in ("blocked", "pending"):
                    s.status = "cancelled"
                    s.save(update_fields=["status"])
            if req.status in ("draft", "pending"):
                req.status = "rejected"
                req.save(update_fields=["status", "updated_at"])
            self._notify_requester(req, "rejected", approval.approver)
            return

        # Approved: find the next not-yet-decided level and unblock it.
        nxt = next(
            (s for s in siblings if s.level > approval.level and s.status == "blocked"),
            None,
        )
        if nxt:
            nxt.status = "pending"
            nxt.save(update_fields=["status"])
            for user in _users_in_role(nxt.required_role, req.organization):
                _notify_procurement(
                    user,
                    title="Approval needed",
                    message=f"Requisition “{req.title}” reached your approval level.",
                    link="/procurements/approvals",
                    priority="high",
                )
            return

        # No higher level left → fully approved.
        if req.status in ("draft", "pending"):
            req.status = "approved"
            req.save(update_fields=["status", "updated_at"])
        self._notify_requester(req, "approved", approval.approver)

    def _notify_requester(self, req, decision, approver):
        if req.requested_by_id:
            _notify_procurement(
                req.requested_by,
                title=f"Requisition {decision}",
                message=f"Your requisition “{req.title}” was {decision}.",
                link="/procurements/requisitions",
                priority="high",
                related_user=approver,
            )

    @extend_schema(
        parameters=[
            OpenApiParameter(name="requisition", type=str, description="Filter by requisition ID"),
            OpenApiParameter(name="status", type=str, description="Filter by status"),
        ]
    )
    def list(self, request, *args, **kwargs):
        return super().list(request, *args, **kwargs)


class RFQRFPViewSet(TenantScopedMixin, QueryFilterMixin, viewsets.ModelViewSet):
    """ViewSet for RFQRFP management."""
    rbac_domain = "procurements"
    queryset = RFQRFP.objects.select_related("created_by", "vendor")
    serializer_class = RFQRFPSerializer
    query_filter_fields = ["status", "rfq_rfp_type"]

    def perform_create(self, serializer):
        org = get_active_organization(self.request)
        vendor = serializer.validated_data.get("vendor")
        if vendor is not None and vendor.organization_id != org.id:
            raise PermissionDenied("The vendor is outside your organization.")
        serializer.save(created_by=self.request.user, organization=org)

    @extend_schema(
        parameters=[
            OpenApiParameter(name="status", type=str, description="Filter by status"),
            OpenApiParameter(name="rfq_rfp_type", type=str, description="Filter by type"),
        ]
    )
    def list(self, request, *args, **kwargs):
        return super().list(request, *args, **kwargs)


class PurchaseOrderViewSet(QueryFilterMixin, viewsets.ModelViewSet):
    """ViewSet for PurchaseOrder management."""
    rbac_domain = "procurements"
    queryset = PurchaseOrder.objects.select_related("vendor", "requisition")
    serializer_class = PurchaseOrderSerializer
    query_filter_fields = ["status", "vendor"]
    rbac_action_map = {"issue": "approve", "cancel": "approve"}

    def get_queryset(self):
        return visible_purchase_orders(super().get_queryset(), self.request.user, get_active_organization(self.request))

    def perform_create(self, serializer):
        org = get_active_organization(self.request)
        requisition = serializer.validated_data["requisition"]
        if not visible_requisitions(Requisition.objects.filter(pk=requisition.pk), self.request.user, org).exists():
            raise PermissionDenied("The requisition is outside your operational scope.")
        award = requisition.quotes.filter(is_selected=True).first()
        if requisition.status != "approved" or not award or award.vendor_id != serializer.validated_data["vendor"].id:
            raise ValidationError("A purchase order requires an approved requisition and awarded vendor.")
        serializer.save(created_by=self.request.user, status="draft", organization=org)

    def update(self, request, *args, **kwargs):
        if "status" in request.data or self.get_object().status != "draft":
            raise ValidationError("Issued purchase orders are immutable; use a lifecycle action.")
        return super().update(request, *args, **kwargs)

    def destroy(self, request, *args, **kwargs):
        raise MethodNotAllowed("DELETE", detail="Purchase orders are cancelled or reversed, never deleted.")

    @action(detail=True, methods=["post"])
    def issue(self, request, pk=None):
        po = issue_purchase_order(self.get_object(), request.user)
        if po.requisition:
            _notify_procurement(
                po.requisition.requested_by,
                title="Purchase order issued",
                message=f"PO {po.order_number} for “{po.requisition.title}” has been issued to {po.vendor.name}.",
                link=f"/procurements/purchase-orders/{po.id}",
            )
        return Response(self.get_serializer(po).data)

    @action(detail=True, methods=["post"])
    def cancel(self, request, pk=None):
        po = cancel_purchase_order(self.get_object(), request.user, request.data.get("reason"))
        if po.requisition:
            _notify_procurement(
                po.requisition.requested_by,
                title="Purchase order cancelled",
                message=f"PO {po.order_number} for “{po.requisition.title}” has been cancelled.",
                link=f"/procurements/purchase-orders/{po.id}",
                priority="high",
            )
        return Response(self.get_serializer(po).data)

    @extend_schema(
        parameters=[
            OpenApiParameter(name="status", type=str, description="Filter by status"),
            OpenApiParameter(name="vendor", type=str, description="Filter by vendor ID"),
        ]
    )
    def list(self, request, *args, **kwargs):
        return super().list(request, *args, **kwargs)


class ProcurementContractViewSet(QueryFilterMixin, viewsets.ModelViewSet):
    """ViewSet for ProcurementContract management."""
    rbac_domain = "procurements"
    queryset = ProcurementContract.objects.select_related("vendor", "requisition")
    serializer_class = ProcurementContractSerializer
    query_filter_fields = ["status", "vendor"]
    rbac_action_map = {"sign": "approve", "terminate": "approve"}

    def get_queryset(self):
        return visible_via_requisition(super().get_queryset(), self.request.user, "requisition", get_active_organization(self.request))

    def perform_create(self, serializer):
        org = get_active_organization(self.request)
        req = serializer.validated_data["requisition"]
        if not visible_requisitions(Requisition.objects.filter(pk=req.pk), self.request.user, org).exists():
            raise PermissionDenied("The requisition is outside your operational scope.")
        vendor = serializer.validated_data.get("vendor")
        if vendor is not None and vendor.organization_id != org.id:
            raise PermissionDenied("The vendor is outside your organization.")
        serializer.save(created_by=self.request.user, status="draft", organization=org)

    def update(self, request, *args, **kwargs):
        if "status" in request.data or self.get_object().status != "draft":
            raise ValidationError("Signed contracts are immutable; create an amendment.")
        return super().update(request, *args, **kwargs)

    def destroy(self, request, *args, **kwargs):
        raise MethodNotAllowed("DELETE", detail="Contracts are terminated or amended, never deleted.")

    @action(detail=True, methods=["post"])
    def sign(self, request, pk=None):
        contract = sign_contract(self.get_object(), request.user)
        if contract.requisition:
            _notify_procurement(
                contract.requisition.requested_by,
                title="Contract signed",
                message=f"Contract {contract.contract_number} for “{contract.requisition.title}” has been signed.",
                link=f"/procurements/contracts/{contract.id}",
            )
        return Response(self.get_serializer(contract).data)

    @action(detail=True, methods=["post"])
    def terminate(self, request, pk=None):
        contract = terminate_contract(self.get_object(), request.user, request.data.get("reason"))
        if contract.requisition:
            _notify_procurement(
                contract.requisition.requested_by,
                title="Contract terminated",
                message=f"Contract {contract.contract_number} for “{contract.requisition.title}” has been terminated.",
                link=f"/procurements/contracts/{contract.id}",
                priority="high",
            )
        return Response(self.get_serializer(contract).data)

    @extend_schema(
        parameters=[
            OpenApiParameter(name="status", type=str, description="Filter by status"),
            OpenApiParameter(name="vendor", type=str, description="Filter by vendor ID"),
        ]
    )
    def list(self, request, *args, **kwargs):
        return super().list(request, *args, **kwargs)


class GoodsReceiptViewSet(QueryFilterMixin, viewsets.ModelViewSet):
    """ViewSet for GoodsReceipt management."""
    rbac_domain = "procurements"
    # generate_assets writes new Asset rows → gate it behind the create perm.
    rbac_action_map = {"generate_assets": "create", "accept": "approve"}
    queryset = GoodsReceipt.objects.select_related("purchase_order", "purchase_order__vendor", "received_by")
    serializer_class = GoodsReceiptSerializer
    query_filter_fields = ["purchase_order", "status"]

    def get_queryset(self):
        return visible_via_requisition(super().get_queryset(), self.request.user, "purchase_order__requisition", get_active_organization(self.request))

    def perform_create(self, serializer):
        org = get_active_organization(self.request)
        po = serializer.validated_data["purchase_order"]
        if not visible_purchase_orders(PurchaseOrder.objects.filter(pk=po.pk), self.request.user, org).exists():
            raise PermissionDenied("The purchase order is outside your operational scope.")
        serializer.save(status="pending", received_by=None, organization=org)

    def perform_update(self, serializer):
        if "status" in self.request.data or serializer.instance.status != "pending":
            raise ValidationError("Receipt decisions must use the acceptance action.")
        serializer.save()

    def destroy(self, request, *args, **kwargs):
        raise MethodNotAllowed("DELETE", detail="Receipts are rejected or reversed, never deleted.")

    @action(detail=True, methods=["post"])
    def accept(self, request, pk=None):
        receipt = self.get_object()
        if receipt.status != "pending":
            raise ValidationError("Only a pending receipt can be accepted or rejected.")
        decision = request.data.get("decision")
        if decision not in ("received", "partial", "rejected"):
            raise ValidationError({"decision": "Use received, partial, or rejected."})
        if decision != "rejected":
            key = lambda item: str(item.get("sku") or item.get("id") or item.get("name") or "").strip().casefold()
            qty = lambda item: Decimal(str(item.get("quantity") or item.get("qty") or 0))
            ordered = {key(item): qty(item) for item in receipt.purchase_order.items or []}
            accepted = {}
            for prior in receipt.purchase_order.goods_receipts.filter(status__in=["partial", "received"]).exclude(pk=receipt.pk):
                for item in prior.items or []:
                    accepted[key(item)] = accepted.get(key(item), Decimal("0")) + qty(item)
            for item in receipt.items or []:
                item_key = key(item)
                if item_key not in ordered:
                    raise ValidationError({"items": f"Receipt line '{item_key}' is not on the purchase order."})
                accepted[item_key] = accepted.get(item_key, Decimal("0")) + qty(item)
                if accepted[item_key] > ordered[item_key]:
                    raise ValidationError({"items": f"Accepted quantity for '{item_key}' exceeds the ordered quantity."})
            decision = "received" if ordered and all(accepted.get(k, Decimal("0")) == v for k, v in ordered.items()) else "partial"
        receipt.status, receipt.received_by = decision, request.user
        receipt.save(update_fields=["status", "received_by"])
        record_event(receipt, request.user, "accepted" if decision != "rejected" else "rejected", "pending", decision, reason=(request.data.get("reason") or "").strip())
        self._cascade_received(receipt)
        requisition = getattr(receipt.purchase_order, "requisition", None)
        if requisition:
            _notify_procurement(
                requisition.requested_by,
                title=f"Goods receipt {decision}",
                message=f"Goods receipt for PO {receipt.purchase_order.order_number} was marked {decision}.",
                link=f"/procurements/goods-receipts/{receipt.id}",
                priority="high" if decision == "rejected" else "medium",
            )
        return Response(self.get_serializer(receipt).data)

    @staticmethod
    def _cascade_received(gr):
        """Fully-received goods → advance the PO and its requisition.

        Mirrors the Approval→Requisition cascade so a goods receipt finishes
        the flow without separate manual status edits. Only acts on a complete
        receipt ('received'); 'partial' leaves the PO open.
        """
        if gr.status != "received":
            return
        po = gr.purchase_order
        if not po:
            return
        if po.status != "fulfilled":
            po.status = "fulfilled"
            po.save(update_fields=["status", "updated_at"])

    @action(detail=True, methods=["post"])
    def generate_assets(self, request, pk=None):
        """Create inventory Assets from this goods receipt's line items.

        One Asset per unit (line `quantity`/`qty`), each pre-linked to this GR,
        its PO, and the PO vendor. Optional body: `category` (AssetCategory id),
        `location` (AssetLocation id). Skips lines already turned into assets so
        the action is idempotent per (GR, line) — re-running only fills gaps.

        Line items are heterogeneous (goods + services); the caller decides what
        to assetize by passing `line_indexes` (list of item positions). Omit it
        to assetize every line.
        """
        from apps.asset_management.models import Asset, AssetCategory, AssetLocation
        from apps.asset_management.serializers import AssetSerializer

        gr = self.get_object()
        po = gr.purchase_order
        items = gr.items or []

        wanted = request.data.get("line_indexes")
        if wanted is not None:
            wanted = set(wanted)

        category = None
        if request.data.get("category"):
            category = AssetCategory.objects.filter(pk=request.data["category"], organization=gr.organization).first()
        location = None
        if request.data.get("location"):
            location = AssetLocation.objects.filter(pk=request.data["location"], organization=gr.organization).first()

        # Count assets already generated from this GR to keep tags unique and
        # to skip re-creating on a second run.
        already = Asset.objects.filter(goods_receipt=gr).count()
        gr_seq = str(gr.id)[-6:]

        created = []
        seq = already
        for idx, it in enumerate(items):
            if wanted is not None and idx not in wanted:
                continue
            name = it.get("name") or "Item"
            qty = int(it.get("quantity") or it.get("qty") or 1)
            unit_price = it.get("unit_price") or it.get("price")
            # Per-line idempotency: how many already exist for this line name.
            existing_for_line = Asset.objects.filter(
                goods_receipt=gr, name=name
            ).count()
            for _ in range(max(0, qty - existing_for_line)):
                seq += 1
                asset = Asset.objects.create(
                    organization=gr.organization,
                    name=name,
                    asset_tag=f"GR-{gr_seq}-{seq:03d}",
                    category=category,
                    location=location,
                    vendor=po.vendor if po else None,
                    purchase_order=po,
                    goods_receipt=gr,
                    purchase_date=gr.received_date,
                    purchase_price=Decimal(str(unit_price)) if unit_price is not None else None,
                    status="available",
                )
                created.append(asset)

        data = AssetSerializer(created, many=True).data
        return Response({"created": len(created), "assets": data})

    @extend_schema(
        parameters=[
            OpenApiParameter(name="purchase_order", type=str, description="Filter by PO ID"),
            OpenApiParameter(name="status", type=str, description="Filter by status"),
        ]
    )
    def list(self, request, *args, **kwargs):
        return super().list(request, *args, **kwargs)


class InvoiceViewSet(QueryFilterMixin, viewsets.ModelViewSet):
    """ViewSet for Invoice management."""
    rbac_domain = "procurements"
    queryset = Invoice.objects.select_related("vendor", "purchase_order", "goods_receipt").prefetch_related(
        # Mirrors the filter/exclude clauses inside Invoice.match_detail() so that
        # method's per-row lookups hit these caches instead of issuing new queries.
        Prefetch(
            "purchase_order__goods_receipts",
            queryset=GoodsReceipt.objects.filter(status__in=["partial", "received"]),
            to_attr="matching_goods_receipts",
        ),
        Prefetch(
            "purchase_order__invoices",
            queryset=Invoice.objects.exclude(status="cancelled"),
            to_attr="matching_invoices",
        ),
    )
    serializer_class = InvoiceSerializer
    query_filter_fields = ["status", "vendor"]
    rbac_action_map = {"approve": "approve"}

    def get_queryset(self):
        return visible_via_requisition(super().get_queryset(), self.request.user, "purchase_order__requisition", get_active_organization(self.request), finance_only=True)

    def perform_create(self, serializer):
        org = get_active_organization(self.request)
        po = serializer.validated_data.get("purchase_order")
        if po and not visible_purchase_orders(PurchaseOrder.objects.filter(pk=po.pk), self.request.user, org).exists():
            raise PermissionDenied("The purchase order is outside your operational scope.")
        vendor = serializer.validated_data.get("vendor")
        if vendor is not None and vendor.organization_id != org.id:
            raise PermissionDenied("The vendor is outside your organization.")
        goods_receipt = serializer.validated_data.get("goods_receipt")
        if goods_receipt is not None and goods_receipt.organization_id != org.id:
            raise PermissionDenied("The goods receipt is outside your organization.")
        serializer.save(created_by=self.request.user, status="pending", organization=org)

    def update(self, request, *args, **kwargs):
        if "status" in request.data or self.get_object().status != "pending":
            raise ValidationError("Approved invoices are immutable; use a lifecycle action.")
        return super().update(request, *args, **kwargs)

    def destroy(self, request, *args, **kwargs):
        raise MethodNotAllowed("DELETE", detail="Invoices are cancelled or reversed, never deleted.")

    @action(detail=True, methods=["post"])
    def approve(self, request, pk=None):
        invoice = approve_invoice(self.get_object(), request.user)
        if invoice.created_by_id and invoice.created_by_id != request.user.id:
            _notify_procurement(
                invoice.created_by,
                title="Invoice approved",
                message=f"Your invoice for {_money(invoice.total)} was approved.",
                link="/procurements/invoices",
                priority="high",
                related_user=request.user,
            )
        return Response(self.get_serializer(invoice).data)

    @extend_schema(
        parameters=[
            OpenApiParameter(name="status", type=str, description="Filter by status"),
            OpenApiParameter(name="vendor", type=str, description="Filter by vendor ID"),
        ]
    )
    def list(self, request, *args, **kwargs):
        return super().list(request, *args, **kwargs)

    @action(detail=True, methods=["post"])
    def match(self, request, pk=None):
        """Recompute the 3-way match for this invoice and return the detail.

        Match is also recomputed on every save(); this is the explicit
        "re-check now" button (e.g. after the linked PO/GRN was edited).
        """
        invoice = self.get_object()
        invoice.save(update_fields=["match_status"])
        return Response(invoice.match_detail())


class PaymentViewSet(QueryFilterMixin, viewsets.ModelViewSet):
    """ViewSet for Payment management."""
    rbac_domain = "procurements"
    queryset = Payment.objects.select_related("invoice", "invoice__vendor")
    serializer_class = PaymentSerializer
    query_filter_fields = ["invoice", "status"]
    rbac_action_map = {"release": "approve", "approve_funds": "approve"}

    def get_queryset(self):
        return visible_via_requisition(super().get_queryset(), self.request.user, "invoice__purchase_order__requisition", get_active_organization(self.request), finance_only=True)

    def perform_create(self, serializer):
        org = get_active_organization(self.request)
        invoice = serializer.validated_data.get("invoice")
        if invoice is not None and invoice.organization_id != org.id:
            raise PermissionDenied("The invoice is outside your organization.")
        serializer.save(prepared_by=self.request.user, status="pending", organization=org)

    def update(self, request, *args, **kwargs):
        if "status" in request.data or self.get_object().status != "pending":
            raise ValidationError("Released payments are immutable; use a lifecycle action.")
        return super().update(request, *args, **kwargs)

    def destroy(self, request, *args, **kwargs):
        raise MethodNotAllowed("DELETE", detail="Payments are failed or reversed, never deleted.")

    @action(detail=True, methods=["post"])
    def release(self, request, pk=None):
        payment = release_payment(self.get_object(), request.user, request.data.get("comment"))
        if payment.prepared_by_id and payment.prepared_by_id != request.user.id:
            _notify_procurement(
                payment.prepared_by,
                title="Payment released",
                message=f"Payment of {_money(payment.amount)} was released.",
                link="/procurements/payments",
                priority="high",
                related_user=request.user,
            )
        return Response(self.get_serializer(payment).data)

    @action(detail=True, methods=["post"], url_path="approve-funds")
    def approve_funds(self, request, pk=None):
        payment = approve_payment_funds(self.get_object(), request.user, request.data.get("comment"))
        if payment.prepared_by_id and payment.prepared_by_id != request.user.id:
            _notify_procurement(
                payment.prepared_by,
                title="Payment funds approved",
                message=f"Funds for payment of {_money(payment.amount)} were approved.",
                link="/procurements/payments",
                priority="medium",
                related_user=request.user,
            )
        return Response(self.get_serializer(payment).data)

    @extend_schema(
        parameters=[
            OpenApiParameter(name="invoice", type=str, description="Filter by invoice ID"),
            OpenApiParameter(name="status", type=str, description="Filter by status"),
        ]
    )
    def list(self, request, *args, **kwargs):
        return super().list(request, *args, **kwargs)

class ProcurementReportViewSet(viewsets.ViewSet):
    """Aggregated procurement statistics for the reports dashboard."""
    rbac_domain = "procurements"

    # Dashboard aggregation is expensive (12 sums/counts + 6-month window +
    # top-vendor + recent-order scans). Cache for 60s — slight staleness is
    # acceptable for a stats panel and saves the full scan on every poll.
    CACHE_KEY = "procurement_reports_dashboard"
    CACHE_TTL = 60

    def list(self, request):
        from django.core.cache import cache

        org = get_active_organization(request)
        cache_key = f"{self.CACHE_KEY}:{org.id}:{request.user.pk}"
        cached = cache.get(cache_key)
        if cached is not None:
            return Response(cached)

        payload = self._build(request, org)
        cache.set(cache_key, payload, self.CACHE_TTL)
        return Response(payload)

    def _build(self, request, org):
        from datetime import timedelta
        from django.utils import timezone
        from django.db.models import Count

        def total(qs, field):
            return float(qs.aggregate(s=Sum(field))["s"] or Decimal("0"))

        po_qs = visible_purchase_orders(PurchaseOrder.objects.all(), request.user, org)
        inv_qs = visible_via_requisition(Invoice.objects.all(), request.user, "purchase_order__requisition", org, finance_only=True)
        pay_qs = visible_via_requisition(Payment.objects.filter(status="completed"), request.user, "invoice__purchase_order__requisition", org, finance_only=True)
        contract_qs = visible_via_requisition(ProcurementContract.objects.all(), request.user, "requisition", org)
        req_qs = visible_requisitions(Requisition.objects.all(), request.user, org)
        unpaid_qs = inv_qs.filter(status__in=["pending", "overdue"])
        open_po_qs = po_qs.filter(status__in=["draft", "sent", "acknowledged"])

        stats = {
            "total_spend": total(pay_qs, "amount"),
            "po_count": po_qs.count(),
            "po_open": open_po_qs.count(),
            "po_value": total(po_qs, "total"),
            "invoices_unpaid": unpaid_qs.count(),
            "invoices_unpaid_amount": total(unpaid_qs, "total"),
            "vendor_count": Vendor.objects.filter(organization=org).count(),
            "requisition_count": req_qs.count(),
            "approval_count": Approval.objects.filter(requisition__organization=org).count(),
            "gr_count": GoodsReceipt.objects.filter(organization=org).count(),
            "contract_count": contract_qs.count(),
            "contract_value": total(contract_qs, "value"),
        }

        # ── Monthly procurement spend (last 6 months, by PO created_at) ──────
        now = timezone.now()
        months = []
        cursor = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
        starts = []
        for _ in range(6):
            starts.append(cursor)
            # step to first day of previous month
            prev_last = cursor - timedelta(days=1)
            cursor = prev_last.replace(day=1)
        starts.reverse()
        for i, start in enumerate(starts):
            end = starts[i + 1] if i + 1 < len(starts) else (
                now + timedelta(days=1)
            )
            window = po_qs.filter(created_at__gte=start, created_at__lt=end)
            months.append({
                "month": start.strftime("%b"),
                "spend": total(window, "total"),
                "orders": window.count(),
            })

        # ── Top vendors by PO spend ──────────────────────────────────────────
        top_vendors = []
        vendor_rows = (
            po_qs.values("vendor__id", "vendor__name")
            .annotate(orders=Count("id"), spend=Sum("total"))
            .order_by("-spend")[:6]
        )
        for r in vendor_rows:
            top_vendors.append({
                "vendor_id": str(r["vendor__id"]) if r["vendor__id"] else None,
                "vendor_name": r["vendor__name"] or "—",
                "orders": r["orders"],
                "spend": float(r["spend"] or Decimal("0")),
            })

        # ── Recent purchase orders ───────────────────────────────────────────
        recent_orders = [
            {
                "id": str(po.id),
                "order_number": po.order_number,
                "vendor_name": po.vendor.name if po.vendor_id else None,
                "requisition_number": po.requisition.number if po.requisition_id else None,
                "status": po.status,
                "total": float(po.total or Decimal("0")),
                "created_at": po.created_at.isoformat(),
            }
            for po in po_qs.select_related("vendor", "requisition").order_by("-created_at")[:10]
        ]

        # ── PO status breakdown ──────────────────────────────────────────────
        status_breakdown = [
            {"status": r["status"], "count": r["c"]}
            for r in po_qs.values("status").annotate(c=Count("id")).order_by("-c")
        ]

        return {
            "stats": stats,
            "monthly": months,
            "top_vendors": top_vendors,
            "recent_orders": recent_orders,
            "status_breakdown": status_breakdown,
        }

    @action(detail=False, methods=["get"], url_path="budget-reconciliation")
    def budget_reconciliation(self, request):
        """Budget items where BudgetCommitment drifts from the GL encumbrance balance."""
        from .reconciliation import reconcile_budget_commitments

        findings = reconcile_budget_commitments(get_active_organization(request))
        return Response({
            "drifted_count": len(findings),
            "findings": [
                {**f, "active_commitments": float(f["active_commitments"]), "gl_encumbrance_balance": float(f["gl_encumbrance_balance"]), "drift": float(f["drift"])}
                for f in findings
            ],
        })
