"""
store/views.py
──────────────
API views for the Tai AfroPress book store.

URL map (registered in store/urls.py):
  GET    /api/cart/                           → CartView
  POST   /api/cart/add/                       → CartAddView
  PATCH  /api/cart/items/<item_id>/           → CartItemView
  DELETE /api/cart/items/<item_id>/           → CartItemView
  GET    /api/delivery-options/               → DeliveryOptionsView
  POST   /api/checkout/                       → StoreCheckoutView
  POST   /api/payments/mpesa/stk-push/        → MpesaSTKPushView
  POST   /api/payments/mpesa/callback/        → MpesaCallbackView
  POST   /api/payments/mpesa/query/           → MpesaQueryView
  GET    /api/orders/                         → OrderListView
  GET    /api/orders/<reference>/             → OrderDetailView

Guest support
─────────────
Every view is AllowAny.  Authenticated users are identified by request.user;
guests are identified by request.session.session_key.  On login, the guest
session cart is automatically merged into the user's cart (see _get_or_create_cart).
"""

import logging
from decimal import Decimal

from django.conf import settings
from django.core.mail import send_mail
from django.db import transaction
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt

from rest_framework import status
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.views import APIView

from catalog_app.models import Book
from .models import (
    Cart, CartItem,
    Coupon,
    DeliveryArea, PickupLocation,
    MpesaTransaction,
    Order, OrderItem,
    PaybillSettings,
)
from .serializers import (
    CartSerializer,
    CheckoutSerializer,
    DeliveryAreaSerializer,
    OrderSerializer,
    PaybillSettingsSerializer,
    PickupLocationSerializer,
    STKPushSerializer,
)
from system_logs.models import SystemLog
from system_logs.services import log_event

from .services import payment_service
from .services.mpesa_service import QUERY_PROCESSING, MpesaError, MpesaService
from .email_utils import BRAND, kv_row, render_email_shell, section_label

logger = logging.getLogger(__name__)


# ===========================================================================
# Cart helpers
# ===========================================================================

def _get_or_create_cart(request) -> Cart:
    """
    Resolve — or lazily create — the active cart for this request.

    Rules:
    • Authenticated user  → cart is keyed on User (OneToOne).
    • Guest               → cart is keyed on Django session_key.
    • On first authenticated request after a guest session, the guest cart is
      merged into the user cart so items are not lost on login.
    """
    if request.user.is_authenticated:
        cart, _ = Cart.objects.get_or_create(
            user=request.user,
            defaults={"session_key": ""},
        )
        # Merge any pre-login guest cart
        session_key = request.session.session_key
        if session_key:
            guest_cart = Cart.objects.filter(
                session_key=session_key, user__isnull=True
            ).first()
            if guest_cart and guest_cart.pk != cart.pk:
                _merge_carts(source=guest_cart, target=cart)
        return cart

    # Guest path — ensure a session exists
    if not request.session.session_key:
        request.session.create()
    session_key = request.session.session_key
    cart, _ = Cart.objects.get_or_create(
        session_key=session_key, user__isnull=True
    )
    return cart


def _merge_carts(source: Cart, target: Cart) -> None:
    """
    Move every item from *source* into *target*, then delete *source*.
    If both carts contain the same book, quantities are summed (capped at 50).
    """
    for src_item in source.items.select_related("book"):
        tgt_item = target.items.filter(book=src_item.book).first()
        if tgt_item:
            tgt_item.quantity = min(tgt_item.quantity + src_item.quantity, 50)
            tgt_item.save(update_fields=["quantity", "updated_at"])
        else:
            src_item.cart = target
            src_item.save(update_fields=["cart"])
    source.delete()


# ===========================================================================
# Cart views
# ===========================================================================

class CartView(APIView):
    """
    GET /api/cart/
    Returns the full cart (items, totals) for the current user / session.
    Creates an empty cart on first access.
    """
    permission_classes = [AllowAny]

    def get(self, request):
        cart = _get_or_create_cart(request)
        return Response(
            CartSerializer(cart, context={"request": request}).data
        )


class CartAddView(APIView):
    """
    POST /api/cart/add/
    Body: { "book_id": <int>, "quantity": <int> }

    Adds a book to the cart.  If the book is already in the cart, the
    quantity is incremented.  Maximum 50 units per title per cart.
    """
    permission_classes = [AllowAny]

    def post(self, request):
        book_id = request.data.get("book_id")
        try:
            quantity = int(request.data.get("quantity", 1))
        except (TypeError, ValueError):
            return Response(
                {"error": "quantity must be an integer."},
                status=status.HTTP_400_BAD_REQUEST,
            )

        if not book_id:
            return Response(
                {"error": "book_id is required."},
                status=status.HTTP_400_BAD_REQUEST,
            )
        if quantity < 1 or quantity > 50:
            return Response(
                {"error": "quantity must be between 1 and 50."},
                status=status.HTTP_400_BAD_REQUEST,
            )

        try:
            book = Book.objects.get(pk=book_id, is_listed=True)
        except Book.DoesNotExist:
            return Response(
                {"error": "Book not found or not available."},
                status=status.HTTP_404_NOT_FOUND,
            )

        if not book.effective_price:
            return Response(
                {"error": "This book is not available for purchase (no price set)."},
                status=status.HTTP_400_BAD_REQUEST,
            )

        # Check stock
        if book.stock < quantity:
            available = book.stock
            return Response(
                {"error": f"Only {available} cop{'y' if available == 1 else 'ies'} in stock."},
                status=status.HTTP_400_BAD_REQUEST,
            )

        cart = _get_or_create_cart(request)

        item, created = CartItem.objects.get_or_create(
            cart=cart, book=book, defaults={"quantity": quantity}
        )
        if not created:
            new_qty = item.quantity + quantity
            item.quantity = min(new_qty, 50)
            item.save(update_fields=["quantity", "updated_at"])

        return Response(
            CartSerializer(cart, context={"request": request}).data,
            status=status.HTTP_200_OK,
        )


class CartItemView(APIView):
    """
    PATCH /api/cart/items/<item_id>/   → update quantity
    DELETE /api/cart/items/<item_id>/  → remove item

    PATCH body: { "quantity": <int> }
    Setting quantity to 0 removes the item (same as DELETE).
    """
    permission_classes = [AllowAny]

    def _get_item(self, request, item_id: int) -> CartItem | None:
        cart = _get_or_create_cart(request)
        try:
            return cart.items.get(pk=item_id)
        except CartItem.DoesNotExist:
            return None

    def patch(self, request, item_id: int):
        item = self._get_item(request, item_id)
        if item is None:
            return Response(
                {"error": "Cart item not found."},
                status=status.HTTP_404_NOT_FOUND,
            )

        try:
            quantity = int(request.data.get("quantity"))
        except (TypeError, ValueError):
            return Response(
                {"error": "quantity must be an integer."},
                status=status.HTTP_400_BAD_REQUEST,
            )

        if quantity < 1:
            item.delete()
            cart = _get_or_create_cart(request)
            return Response(CartSerializer(cart, context={"request": request}).data)

        if quantity > 50:
            return Response(
                {"error": "Maximum quantity per title is 50."},
                status=status.HTTP_400_BAD_REQUEST,
            )

        if item.book.stock < quantity:
            return Response(
                {"error": f"Only {item.book.stock} in stock."},
                status=status.HTTP_400_BAD_REQUEST,
            )

        item.quantity = quantity
        item.save(update_fields=["quantity", "updated_at"])

        cart = _get_or_create_cart(request)
        return Response(CartSerializer(cart, context={"request": request}).data)

    def delete(self, request, item_id: int):
        item = self._get_item(request, item_id)
        if item is None:
            return Response(
                {"error": "Cart item not found."},
                status=status.HTTP_404_NOT_FOUND,
            )
        item.delete()
        cart = _get_or_create_cart(request)
        return Response(CartSerializer(cart, context={"request": request}).data)


# ===========================================================================
# Delivery options
# ===========================================================================

class DeliveryOptionsView(APIView):
    """
    GET /api/delivery-options/
    Returns all active delivery areas (with fees) and pickup locations.
    No auth required — needed before checkout.
    """
    permission_classes = [AllowAny]

    def get(self, request):
        return Response({
            "delivery_areas": DeliveryAreaSerializer(
                DeliveryArea.objects.filter(is_active=True), many=True
            ).data,
            "pickup_locations": PickupLocationSerializer(
                PickupLocation.objects.filter(is_active=True), many=True
            ).data,
        })


# ===========================================================================
# Checkout
# ===========================================================================

class StoreCheckoutView(APIView):
    """
    POST /api/checkout/
    ─────────────────────────────────────────────────────────────────────────
    Atomically converts the submitted `items` into a placed Order.

    Builds directly from the request payload's `items` — there is no
    server-side Cart/CartItem model involved anywhere in this flow (see note
    below). Steps performed inside a DB transaction:
      1. Validate `items` is not empty
      2. Validate every book is purchasable (has price, has stock)
      3. Reserve stock (decrement book.stock)
      4. Calculate total, delivery fee, coupon discount, final amount
      5. Create Order + OrderItems

    Returns the created Order (serialized), including order_reference.
    The client then calls POST /api/payments/mpesa/stk-push/ to pay.

    Why no Cart/CartItem here
    --------------------------
    apps/store's cart lives entirely client-side (Zustand + localStorage) and
    never calls POST /api/cart/add/ — so a server-side Cart tied to the
    request's session has no reliable relationship to what the customer
    actually has in their cart. An earlier version of this view read from
    (and tried to sync) that session Cart, which meant a stale/leftover
    CartItem from unrelated usage (manual API testing, an old frontend
    version, a shared session) could silently be checked out instead of what
    the customer actually submitted. Building the order straight from
    `items` removes that intermediary entirely: what's submitted is what
    gets validated, priced, and purchased — nothing else can intervene.

    Stock reservation
    -----------------
    book.stock is decremented atomically via F('stock') - quantity.
    If payment fails / order is cancelled, stock is released in signals.py.
    """
    permission_classes = [AllowAny]

    def post(self, request):
        response = self._place_order(request)
        if response.status_code >= 400:
            # So "checkout didn't work" can be looked up by the customer's phone.
            log_event(
                SystemLog.MODULE_STORE, "store.checkout.refused",
                f"Checkout refused: {response.data}",
                phone_number=str(request.data.get("phone_number", ""))[:20],
                http_status=response.status_code,
                request_data={k: request.data.get(k) for k in (
                    "customer_name", "customer_email", "phone_number", "delivery_type",
                    "delivery_area_id", "pickup_location_id", "coupon_code", "items",
                )},
                response_data=response.data,
            )
        return response

    def _place_order(self, request):
        serializer = CheckoutSerializer(data=request.data)
        if not serializer.is_valid():
            return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

        data = serializer.validated_data
        items_data = data.get("items") or []
        if not items_data:
            return Response(
                {"error": "Your cart is empty."},
                status=status.HTTP_400_BAD_REQUEST,
            )

        with transaction.atomic():
            # Lock each book row (prevents concurrent checkouts overselling
            # the same stock) and validate purchasability directly against
            # what was submitted — no Cart/CartItem read or write involved.
            locked_items = []  # list of (book, quantity)
            for item_data in items_data:
                book = Book.objects.select_for_update().get(pk=item_data["book"].pk)
                quantity = item_data["quantity"]
                if not book.effective_price:
                    return Response(
                        {"error": f'"{book.title}" has no price set and cannot be purchased.'},
                        status=status.HTTP_400_BAD_REQUEST,
                    )
                if book.stock < quantity:
                    return Response(
                        {
                            "error": (
                                f'Not enough stock for "{book.title}". '
                                f"Available: {book.stock}, requested: {quantity}."
                            )
                        },
                        status=status.HTTP_400_BAD_REQUEST,
                    )
                locked_items.append((book, quantity))

            # ── Calculate amounts ────────────────────────────────────────
            total_amount = Decimal("0")
            for book, quantity in locked_items:
                total_amount += book.effective_price * quantity

            delivery_fee = Decimal("0")
            delivery_area: DeliveryArea | None = data.get("delivery_area_id")
            if data["delivery_type"] == "delivery" and delivery_area:
                delivery_fee = delivery_area.delivery_fee

            discount_amount = Decimal("0")
            coupon: Coupon | None = None
            coupon_code = (data.get("coupon_code") or "").strip().upper()
            if coupon_code:
                try:
                    coupon = Coupon.objects.select_for_update().get(
                        code=coupon_code
                    )
                    valid, msg = coupon.is_valid(order_amount=total_amount)
                    if not valid:
                        return Response(
                            {"error": f"Coupon error: {msg}"},
                            status=status.HTTP_400_BAD_REQUEST,
                        )
                    discount_amount = coupon.calculate_discount(total_amount)
                    coupon.uses_count += 1
                    coupon.save(update_fields=["uses_count"])
                except Coupon.DoesNotExist:
                    return Response(
                        {"error": "Coupon code not found."},
                        status=status.HTTP_400_BAD_REQUEST,
                    )

            final_amount = max(
                total_amount + delivery_fee - discount_amount, Decimal("0")
            )

            # ── Create Order ─────────────────────────────────────────────
            order = Order.objects.create(
                user=request.user if request.user.is_authenticated else None,
                session_key=request.session.session_key or "",
                total_amount=total_amount,
                delivery_fee=delivery_fee,
                discount_amount=discount_amount,
                final_amount=final_amount,
                delivery_type=data["delivery_type"],
                delivery_area=delivery_area,
                pickup_location=data.get("pickup_location_id"),
                delivery_address=data.get("delivery_address"),
                customer_name=data["customer_name"],
                customer_email=data.get("customer_email", ""),
                phone_number=data["phone_number"],
                notes=data.get("notes", ""),
                coupon=coupon,
                status=Order.STATUS_PENDING,
            )

            # ── Create OrderItems + reserve stock ────────────────────────
            for book, quantity in locked_items:
                OrderItem.objects.create(
                    order=order,
                    book=book,
                    unit_price=book.effective_price,
                    quantity=quantity,
                    total_price=book.effective_price * quantity,
                    book_snapshot={
                        "id": book.id,
                        "title": book.title,
                        "author": book.author.name if book.author else None,
                        "isbn": book.isbn,
                        "price": str(book.effective_price),
                        "cover_url": (
                            request.build_absolute_uri(book.cover.url)
                            if book.cover else None
                        ),
                    },
                )
                # Atomic stock decrement (SELECT FOR UPDATE already locked the row)
                Book.objects.filter(pk=book.pk).update(
                    stock=book.stock - quantity
                )

        return Response(
            OrderSerializer(order, context={"request": request}).data,
            status=status.HTTP_201_CREATED,
        )


# ===========================================================================
# M-Pesa STK Push
# ===========================================================================

class MpesaSTKPushView(APIView):
    """
    POST /api/payments/mpesa/stk-push/
    Body: { "order_reference": "ORD-...", "phone_number": "07XX..." }

    Resending
    ─────────
    If a prompt is already out (payment_initiated + checkout_request_id),
    Daraja is asked for its outcome first:
      • paid       → order marked paid, no new prompt
      • processing → 409; the customer must finish/cancel the open prompt
      • failed     → a fresh prompt is sent (stock re-reserved first)
    A failed order can be retried the same way as long as stock remains.

    Auth
    ────
    Authenticated users: order must belong to request.user.
    Guests: any order by reference (the reference is the shared secret —
    session cookies aren't reliable cross-site from the shop domain).
    """
    permission_classes = [AllowAny]

    def post(self, request):
        if not settings.MPESA_STK_ENABLED:
            log_event(
                SystemLog.MODULE_PAYMENTS, "mpesa.stk_push.disabled",
                "STK prompt requested while M-Pesa STK is switched off (MPESA_STK_ENABLED) — refused.",
                level=SystemLog.LEVEL_WARNING,
                reference=str(request.data.get("order_reference", ""))[:100],
                phone_number=str(request.data.get("phone_number", ""))[:20],
            )
            return Response(
                {"error": "M-Pesa prompt payments aren't available yet. Please pay via Paybill."},
                status=status.HTTP_403_FORBIDDEN,
            )
        serializer = STKPushSerializer(data=request.data)
        if not serializer.is_valid():
            return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

        order_reference: str = serializer.validated_data["order_reference"]
        raw_phone: str = serializer.validated_data["phone_number"]

        # ── Resolve order (ownership guard) ─────────────────────────────
        try:
            if request.user.is_authenticated:
                order = Order.objects.get(
                    order_reference=order_reference, user=request.user
                )
            else:
                order = Order.objects.get(order_reference=order_reference)
        except Order.DoesNotExist:
            return Response(
                {"error": "Order not found."},
                status=status.HTTP_404_NOT_FOUND,
            )

        # ── A prompt is already out: find out what happened to it ─────────
        if (
            order.status == Order.STATUS_PAYMENT_INITIATED
            and order.checkout_request_id
        ):
            try:
                state = payment_service.query_and_apply(order)
            except MpesaError as exc:
                # Details already in the System Log (mpesa.query.failed).
                logger.warning("STK Query before resend failed for %s: %s", order_reference, exc)
                return Response(
                    {"error": "We couldn't check your previous M-Pesa prompt. Please wait a moment and try again."},
                    status=status.HTTP_503_SERVICE_UNAVAILABLE,
                )
            order.refresh_from_db()
            if order.status == Order.STATUS_PAID:
                return Response({
                    "message": "Payment already received.",
                    "status": order.status,
                    "order_reference": order.order_reference,
                    "amount": order.final_amount,
                })
            if state == QUERY_PROCESSING:
                return Response(
                    {"error": (
                        "Your previous M-Pesa prompt is still open. Enter your PIN on your phone, "
                        "or cancel it and try again in a moment."
                    )},
                    status=status.HTTP_409_CONFLICT,
                )

        # ── Retrying a failed payment: take the stock back first ──────────
        if order.status == Order.STATUS_FAILED:
            with transaction.atomic():
                order = Order.objects.select_for_update().get(pk=order.pk)
                if order.status == Order.STATUS_FAILED:
                    try:
                        payment_service.reserve_stock(order)
                    except payment_service.InsufficientStock as exc:
                        return Response({"error": str(exc)}, status=status.HTTP_409_CONFLICT)
                    order.status = Order.STATUS_PENDING
                    order.save(update_fields=["status", "updated_at"])

        # ── Guard: only retryable statuses allowed past this point ────────
        # payment_initiated without a checkout_request_id falls through here
        # so a fresh STK push can be sent.
        if order.status not in (
            Order.STATUS_PENDING,
            Order.STATUS_FAILED,
            Order.STATUS_PAYMENT_INITIATED,
        ):
            if order.status == Order.STATUS_PAID:
                return Response(
                    {"error": "This order has already been paid."},
                    status=status.HTTP_400_BAD_REQUEST,
                )
            return Response(
                {
                    "error": (
                        f'Cannot initiate payment for an order '
                        f'with status "{order.get_status_display()}".'
                    )
                },
                status=status.HTTP_400_BAD_REQUEST,
            )

        # ── Normalise phone ──────────────────────────────────────────────
        mpesa = MpesaService()
        try:
            phone = mpesa.normalize_phone(raw_phone)
        except MpesaError as exc:
            return Response({"error": str(exc)}, status=status.HTTP_400_BAD_REQUEST)

        # ── Initiate STK Push ────────────────────────────────────────────
        try:
            result = mpesa.stk_push(
                phone_number=phone,
                amount=int(order.final_amount),
                order_reference=order.order_reference,
            )
        except MpesaError as exc:
            # Details already in the System Log (mpesa.stk_push.*).
            logger.warning("STK Push failed for order %s: %s", order_reference, exc)
            return Response(
                {"error": str(exc)},
                status=status.HTTP_503_SERVICE_UNAVAILABLE,
            )

        # ── Persist request IDs ──────────────────────────────────────────
        order.checkout_request_id = result.get("CheckoutRequestID", "")
        order.merchant_request_id = result.get("MerchantRequestID", "")
        order.status = Order.STATUS_PAYMENT_INITIATED
        order.phone_number = phone
        order.save(update_fields=[
            "checkout_request_id", "merchant_request_id",
            "status", "phone_number", "updated_at",
        ])

        # Audit row — no result fields yet (callback hasn't arrived)
        MpesaTransaction.objects.create(
            order=order,
            checkout_request_id=order.checkout_request_id,
            merchant_request_id=order.merchant_request_id,
            phone_number=phone,
            amount=order.final_amount,
        )

        return Response({
            "message": "STK Push sent. Please enter your M-Pesa PIN on your phone.",
            "status": order.status,
            "checkout_request_id": order.checkout_request_id,
            "order_reference": order.order_reference,
            "amount": order.final_amount,
        })


# ===========================================================================
# M-Pesa Callback
# ===========================================================================

@method_decorator(csrf_exempt, name="dispatch")
class MpesaCallbackView(APIView):
    """
    POST /api/payments/mpesa/callback/
    ─────────────────────────────────────────────────────────────────────────
    Safaricom calls this URL after the customer acts on the STK prompt.
    This endpoint MUST:
      • Be publicly accessible via HTTPS (no VPN / firewall).
      • Return HTTP 200 with {"ResultCode": 0} in all cases.
        If we return a non-200 / non-zero code, Safaricom retries the callback,
        which can lead to duplicate processing.

    No Django authentication or CSRF — Safaricom doesn't send tokens.
    For extra security consider IP-allowlisting Safaricom's callback IPs
    at the nginx / load-balancer layer.
    """
    permission_classes = [AllowAny]
    authentication_classes = []   # Bypass DRF auth for this webhook

    _SAFARICOM_ACK = {"ResultCode": 0, "ResultDesc": "Accepted"}

    def post(self, request):
        callback_data = request.data
        try:
            stk = callback_data["Body"]["stkCallback"]
            meta = {
                item.get("Name"): item.get("Value")
                for item in (stk.get("CallbackMetadata") or {}).get("Item", [])
                if isinstance(item, dict)
            }
        except (KeyError, TypeError, AttributeError):
            stk, meta = {}, {}
        refs = {
            "checkout_request_id": stk.get("CheckoutRequestID", ""),
            "mpesa_receipt": str(meta.get("MpesaReceiptNumber") or ""),
            "phone_number": str(meta.get("PhoneNumber") or ""),
            "response_data": callback_data,
        }
        # Recorded before anything else, so the raw payload is never lost —
        # this is what to check when a customer says "I paid".
        log_event(
            SystemLog.MODULE_PAYMENTS, "mpesa.callback.received",
            f"Callback from Safaricom: {stk.get('ResultCode', '?')} {stk.get('ResultDesc', '')}".strip(),
            **refs,
        )

        try:
            self._process(callback_data, refs)
        except Exception as exc:  # noqa: BLE001 — Safaricom must still get its 200
            log_event(
                SystemLog.MODULE_PAYMENTS, "mpesa.callback.crashed",
                "Our server failed while processing a Safaricom callback — the payment was NOT applied.",
                level=SystemLog.LEVEL_ERROR, exc=exc, alert=True, **refs,
            )
        # Always acknowledge Safaricom with 200
        return Response(self._SAFARICOM_ACK)

    def _process(self, callback_data, refs):
        mpesa = MpesaService()
        try:
            parsed = mpesa.parse_callback(callback_data)
        except MpesaError as exc:
            log_event(
                SystemLog.MODULE_PAYMENTS, "mpesa.callback.invalid",
                f"Unreadable callback from Safaricom: {exc}",
                level=SystemLog.LEVEL_ERROR, alert=True, **refs,
            )
            return

        checkout_request_id = parsed.get("checkout_request_id", "")
        if not checkout_request_id:
            log_event(
                SystemLog.MODULE_PAYMENTS, "mpesa.callback.invalid",
                "Callback from Safaricom has no CheckoutRequestID — can't match it to a payment.",
                level=SystemLog.LEVEL_ERROR, alert=True, **refs,
            )
            return

        order = payment_service.find_order_for_checkout(checkout_request_id)
        if order is None:
            # Prompts sent from the admin POS share this callback URL.
            from inventory.services import pos_mpesa_service

            pos_payment = pos_mpesa_service.apply_result(
                checkout_request_id,
                success=parsed["success"],
                result_code=parsed.get("result_code"),
                result_desc=parsed.get("result_desc", ""),
                receipt=parsed.get("mpesa_receipt") or "",
                amount=parsed.get("amount"),
                raw=callback_data,
            )
            if pos_payment is None:
                log_event(
                    SystemLog.MODULE_PAYMENTS, "mpesa.callback.unmatched",
                    "Callback matched no online order or POS payment"
                    + (" — the customer PAID; find who and record it manually." if parsed["success"] else "."),
                    level=SystemLog.LEVEL_ERROR if parsed["success"] else SystemLog.LEVEL_WARNING,
                    alert=parsed["success"], **refs,
                )
            return

        payment_service.apply_result(
            order.pk,
            checkout_request_id,
            success=parsed["success"],
            result_code=parsed.get("result_code"),
            result_desc=parsed.get("result_desc", ""),
            receipt=parsed.get("mpesa_receipt") or "",
            amount=parsed.get("amount"),
            phone=parsed.get("phone_number") or "",
            transaction_date=parsed.get("transaction_date") or "",
            merchant_request_id=parsed.get("merchant_request_id", ""),
            raw=callback_data,
        )


# ===========================================================================
# M-Pesa status check
# ===========================================================================

class MpesaQueryView(APIView):
    """
    POST /api/payments/mpesa/query/
    Body: { "order_reference": "ORD-..." }

    Fallback for a late or lost callback: asks Daraja for the outcome of the
    order's current prompt and applies it. Always 200 with the order status;
    "state" is the Daraja outcome (success / failed / processing / unknown).
    """
    permission_classes = [AllowAny]

    # Give the customer time to act before asking Daraja (and keep within
    # Daraja's query rate limits).
    _MIN_PROMPT_AGE_SECONDS = 20

    def post(self, request):
        order_reference = str(request.data.get("order_reference", "")).strip()
        if not order_reference:
            return Response(
                {"order_reference": ["This field is required."]},
                status=status.HTTP_400_BAD_REQUEST,
            )

        orders = Order.objects.filter(order_reference=order_reference)
        if request.user.is_authenticated:
            orders = orders.filter(user=request.user)
        order = orders.first()
        if order is None:
            return Response({"error": "Order not found."}, status=status.HTTP_404_NOT_FOUND)

        if order.status != Order.STATUS_PAYMENT_INITIATED or not order.checkout_request_id:
            return Response({"status": order.status, "state": None})

        prompt = (
            order.mpesa_transactions
            .filter(checkout_request_id=order.checkout_request_id)
            .order_by("created_at")
            .first()
        )
        if prompt and (timezone.now() - prompt.created_at).total_seconds() < self._MIN_PROMPT_AGE_SECONDS:
            return Response({"status": order.status, "state": QUERY_PROCESSING})

        try:
            state = payment_service.query_and_apply(order)
        except MpesaError as exc:
            logger.warning("STK Query failed for %s: %s", order_reference, exc)
            return Response({"status": order.status, "state": "unknown"})

        order.refresh_from_db()
        return Response({"status": order.status, "state": state})


# ===========================================================================
# Paybill Payment Proof
# ===========================================================================

def _send_proof_notification(order, confirmation_code, has_image, admin_order_url):
    """Email the admin when a customer submits paybill payment proof."""
    site_settings = PaybillSettings.get()
    recipient = site_settings.admin_notification_email or settings.EMAIL_HOST_USER
    sender = settings.DEFAULT_FROM_EMAIL
    subject = f"[TAI AfroPress] Payment Proof Awaiting Verification — {order.order_reference}"
    message = f"""\
A customer has submitted M-Pesa payment proof and is waiting for order confirmation.

ORDER DETAILS
─────────────
Reference : {order.order_reference}
Customer  : {order.customer_name}
Email     : {order.customer_email}
Phone     : {order.phone_number}
Amount Due: KES {order.final_amount}

PAYMENT PROOF
─────────────
M-Pesa Code: {confirmation_code}
Screenshot : {"Uploaded — view in admin" if has_image else "Not provided"}

ACTION REQUIRED
───────────────
Log in to the admin panel to verify payment and approve or reject the order:

  {admin_order_url}

Use the "Approve payment" bulk action to mark the order as Paid, or
"Reject payment" to release stock if the proof is invalid.

TAI AfroPress Automated Notification
"""

    html_body = render_email_shell(
        heading="Payment proof awaiting verification",
        body_html=f"""
        <p style="margin:0 0 20px;">A customer has submitted M-Pesa payment proof and is waiting for order confirmation.</p>
        {section_label("Order Details")}
        <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:{BRAND['bg']};border:1px solid {BRAND['border']};border-radius:10px;margin-bottom:20px;">
          <tr><td style="padding:16px 18px;">
            <table role="presentation" width="100%" cellpadding="0" cellspacing="0">
              {kv_row("Reference", order.order_reference)}
              {kv_row("Customer", order.customer_name)}
              {kv_row("Email", order.customer_email)}
              {kv_row("Phone", order.phone_number)}
              {kv_row("Amount Due", f"KES {order.final_amount:,.2f}", strong=True, top_border=True)}
            </table>
          </td></tr>
        </table>
        {section_label("Payment Proof")}
        <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:4px;">
          {kv_row("M-Pesa Code", confirmation_code)}
          {kv_row("Screenshot", "Uploaded &mdash; view in admin" if has_image else "Not provided")}
        </table>
        <p style="margin:20px 0 0;font-size:13px;color:{BRAND['muted']};">Use the <strong>&ldquo;Approve payment&rdquo;</strong> bulk action to mark the order as Paid, or <strong>&ldquo;Reject payment&rdquo;</strong> to release stock if the proof is invalid.</p>
        """,
        cta_label="Login to Verify Payment &rarr;",
        cta_url=admin_order_url,
    )

    try:
        send_mail(
            subject=subject,
            message=message,
            from_email=sender,
            recipient_list=[recipient],
            fail_silently=False,
            html_message=html_body,
        )
        logger.info("Admin payment proof notification sent for order %s", order.order_reference)
    except Exception as exc:
        logger.error("Failed to send payment proof notification for order %s: %s", order.order_reference, exc)


class PaybillProofView(APIView):
    """
    POST /api/orders/<reference>/submit-payment-proof/
    ─────────────────────────────────────────────────────────────────────────
    Customer submits their M-Pesa confirmation after paying manually via
    M-Pesa Paybill.

    Accepts multipart/form-data:
      confirmation_code  (required) — M-Pesa transaction code, e.g. NLJ7RT61SV
      proof_image        (optional) — Screenshot of the confirmation SMS

    The order status is moved to payment_initiated so the admin can review
    and confirm payment in the Django admin (or via a future automated flow).
    Idempotent: re-submitting updates the code and image without error.
    """
    permission_classes = [AllowAny]

    def post(self, request, reference):
        # ── Resolve order (ownership guard) ─────────────────────────────
        try:
            if request.user.is_authenticated:
                order = Order.objects.get(order_reference=reference, user=request.user)
            else:
                order = Order.objects.get(order_reference=reference)
        except Order.DoesNotExist:
            return Response({"error": "Order not found."}, status=status.HTTP_404_NOT_FOUND)

        if order.status == Order.STATUS_PAID:
            return Response(
                {"error": "This order is already confirmed as paid."},
                status=status.HTTP_400_BAD_REQUEST,
            )
        if order.status == Order.STATUS_CANCELLED:
            return Response(
                {"error": f'Cannot submit proof for a cancelled order.'},
                status=status.HTTP_400_BAD_REQUEST,
            )

        # ── Validate inputs ──────────────────────────────────────────────
        confirmation_code = request.data.get("confirmation_code", "").strip().upper()
        if not confirmation_code:
            return Response(
                {"error": "M-Pesa confirmation code is required."},
                status=status.HTTP_400_BAD_REQUEST,
            )

        proof_image = request.FILES.get("proof_image")

        # ── Persist proof ────────────────────────────────────────────────
        update_fields = ["paybill_confirmation_code", "paybill_proof_submitted_at", "updated_at"]
        order.paybill_confirmation_code = confirmation_code
        order.paybill_proof_submitted_at = timezone.now()

        if proof_image:
            order.paybill_proof_image = proof_image
            update_fields.append("paybill_proof_image")

        if order.status == Order.STATUS_PENDING:
            order.status = Order.STATUS_PAYMENT_INITIATED
            update_fields.append("status")

        order.save(update_fields=update_fields)

        logger.info(
            "Paybill proof submitted | order=%s | code=%s | has_image=%s",
            reference, confirmation_code, bool(proof_image),
        )

        # Notify admin to verify payment
        admin_url = request.build_absolute_uri(f'/admin/store/order/{order.pk}/change/')
        _send_proof_notification(order, confirmation_code, bool(proof_image), admin_url)

        return Response({
            "message": "Payment proof received. Our team will confirm your payment shortly.",
            "order_reference": reference,
            "confirmation_code": confirmation_code,
        })


# ===========================================================================
# Order views
# ===========================================================================

class OrderListView(APIView):
    """
    GET /api/orders/
    Returns all orders for the current user (auth) or session (guest).
    """
    permission_classes = [AllowAny]

    def get(self, request):
        if request.user.is_authenticated:
            orders = Order.objects.filter(
                user=request.user
            ).prefetch_related("items__book")
        else:
            session_key = request.session.session_key
            if not session_key:
                return Response([])
            orders = Order.objects.filter(
                session_key=session_key
            ).prefetch_related("items__book")

        return Response(
            OrderSerializer(orders, many=True, context={"request": request}).data
        )


class OrderDetailView(APIView):
    """
    GET /api/orders/<reference>/
    Returns a single order.  Ownership is enforced the same way as STK Push.
    """
    permission_classes = [AllowAny]

    def get(self, request, reference: str):
        try:
            if request.user.is_authenticated:
                order = Order.objects.prefetch_related("items__book").get(
                    order_reference=reference, user=request.user
                )
            else:
                # Guests: look up by reference alone — the reference is an
                # unguessable token, so no session_key check is needed.
                # Cross-site setups block the session cookie so session_key
                # would always be empty even for the correct customer.
                order = Order.objects.prefetch_related("items__book").get(
                    order_reference=reference
                )
        except Order.DoesNotExist:
            return Response(
                {"error": "Order not found."},
                status=status.HTTP_404_NOT_FOUND,
            )

        return Response(
            OrderSerializer(order, context={"request": request}).data
        )


class PaybillSettingsView(APIView):
    """
    GET /api/paybill-settings/
    Returns the current M-Pesa Paybill business and account numbers.
    Public — no auth required.
    """
    permission_classes = [AllowAny]

    def get(self, request):
        settings = PaybillSettings.get()
        return Response(PaybillSettingsSerializer(settings).data)
