"""
store/services/payment_service.py
──────────────────────────────────
Applies M-Pesa STK outcomes to store Orders. Shared by the Safaricom
callback, the customer-triggered status check, and the stale-payment sweep
(expire_mpesa_payments), so every path follows the same rules:

• Results are idempotent — a duplicate callback never double-releases stock
  or double-marks an order.
• A failure only counts for the order's *current* STK request; a late
  failure from an older, superseded prompt is logged and ignored.
• A success always counts (the customer's money has moved), even if it
  arrives for an older prompt or after the order was marked failed.
"""

import logging
from decimal import Decimal
from typing import Any

from django.db import transaction
from django.db.models import F
from django.utils import timezone

from catalog_app.models import Book
from store.models import MpesaTransaction, Order
from system_logs.models import SystemLog
from system_logs.services import log_event
from store.services.mpesa_service import (
    QUERY_FAILED,
    QUERY_PROCESSING,
    QUERY_SUCCESS,
    MpesaService,
)

logger = logging.getLogger(__name__)

_FULFILMENT_STATUSES = (
    Order.STATUS_PAID,
    Order.STATUS_PROCESSING,
    Order.STATUS_SHIPPED,
    Order.STATUS_DELIVERED,
)


class InsufficientStock(Exception):
    pass


def find_order_for_checkout(checkout_request_id: str) -> Order | None:
    """Current request first; fall back to the audit log for superseded prompts."""
    order = Order.objects.filter(checkout_request_id=checkout_request_id).first()
    if order:
        return order
    tx = (
        MpesaTransaction.objects
        .filter(checkout_request_id=checkout_request_id)
        .select_related("order")
        .first()
    )
    return tx.order if tx else None


def release_stock(order: Order) -> None:
    for item in order.items.all():
        Book.objects.filter(pk=item.book_id).update(stock=F("stock") + item.quantity)
    logger.info("Stock released for order %s", order.order_reference)


def reserve_stock(order: Order) -> None:
    """
    Re-reserve stock for an order whose reservation was released after a
    failed payment. Must run inside a transaction.

    Raises:
        InsufficientStock: with a customer-facing message.
    """
    items = list(order.items.all())
    books = {
        book.pk: book
        for book in Book.objects.select_for_update().filter(pk__in=[i.book_id for i in items])
    }
    for item in items:
        book = books.get(item.book_id)
        if book is None or book.stock < item.quantity:
            title = book.title if book else (item.book_snapshot or {}).get("title", "A book")
            available = book.stock if book else 0
            raise InsufficientStock(
                f'"{title}" no longer has enough stock (available: {available}, '
                f"in your order: {item.quantity}). Please place a new order."
            )
    for item in items:
        Book.objects.filter(pk=item.book_id).update(stock=F("stock") - item.quantity)


def apply_result(
    order_id: int,
    checkout_request_id: str,
    *,
    success: bool,
    result_code: int | None = None,
    result_desc: str = "",
    receipt: str = "",
    amount: Any = None,
    phone: str = "",
    transaction_date: str = "",
    merchant_request_id: str = "",
    raw: dict | None = None,
) -> Order:
    sandbox = MpesaService().sandbox
    with transaction.atomic():
        order = Order.objects.select_for_update().get(pk=order_id)

        MpesaTransaction.objects.create(
            order=order,
            checkout_request_id=checkout_request_id,
            merchant_request_id=merchant_request_id,
            phone_number=phone or order.phone_number,
            # Sandbox only ever charges KES 1; keep the log on the real total.
            amount=amount if amount is not None and not sandbox else order.final_amount,
            mpesa_receipt_number=receipt or "",
            transaction_date=transaction_date or "",
            result_code=result_code,
            result_desc=result_desc,
            raw_callback=raw,
        )

        if success:
            _apply_success(order, checkout_request_id, receipt, amount)
        else:
            _apply_failure(order, checkout_request_id, result_code, result_desc)
    return order


def _apply_success(order: Order, checkout_request_id: str, receipt: str, amount: Any) -> None:
    if order.status in _FULFILMENT_STATUSES:
        # Duplicate callback, or the callback arriving after a status check
        # already confirmed payment — just fill in the receipt if we lacked it.
        if receipt and not order.mpesa_receipt_number:
            order.mpesa_receipt_number = receipt
            order.transaction_id = receipt
            order.save(update_fields=["mpesa_receipt_number", "transaction_id", "updated_at"])
        return

    refs = {
        "reference": order.order_reference, "checkout_request_id": checkout_request_id,
        "phone_number": order.phone_number, "mpesa_receipt": receipt, "related": order,
    }
    expected = MpesaService().charge_amount(order.final_amount)
    if amount is not None and Decimal(str(amount)) < expected:
        log_event(
            SystemLog.MODULE_PAYMENTS, "mpesa.order.amount_mismatch",
            f"Order {order.order_reference}: customer paid KES {amount} but KES {expected} was due — "
            "NOT marked paid. Review and refund or collect the difference.",
            level=SystemLog.LEVEL_ERROR, alert=True, **refs,
        )
        return

    if order.status == Order.STATUS_CANCELLED:
        log_event(
            SystemLog.MODULE_PAYMENTS, "mpesa.order.paid_after_cancel",
            f"Payment received for CANCELLED order {order.order_reference} — refund the customer "
            "or reinstate the order.",
            level=SystemLog.LEVEL_ERROR, alert=True, **refs,
        )
        return

    if order.status == Order.STATUS_FAILED:
        # Paid on a prompt we'd already written off, so its stock was released.
        # Take it again — the customer has paid, so the order must stand even
        # if this drives stock negative.
        for item in order.items.all():
            Book.objects.filter(pk=item.book_id).update(stock=F("stock") - item.quantity)
        log_event(
            SystemLog.MODULE_PAYMENTS, "mpesa.order.late_success",
            f"Order {order.order_reference} was paid after being marked failed — confirmed and stock re-reserved.",
            level=SystemLog.LEVEL_WARNING, **refs,
        )

    order.status = Order.STATUS_PAID
    order.checkout_request_id = checkout_request_id
    order.mpesa_receipt_number = receipt or ""
    order.transaction_id = receipt or checkout_request_id
    order.payment_confirmed_at = timezone.now()
    order.save(update_fields=[
        "status", "checkout_request_id", "mpesa_receipt_number", "transaction_id",
        "payment_confirmed_at", "updated_at",
    ])
    log_event(
        SystemLog.MODULE_PAYMENTS, "mpesa.order.paid",
        f"Order {order.order_reference} marked paid (receipt {receipt or 'to follow'})",
        **refs,
    )


def _apply_failure(order: Order, checkout_request_id: str, result_code, result_desc: str) -> None:
    refs = {
        "reference": order.order_reference, "checkout_request_id": checkout_request_id,
        "phone_number": order.phone_number, "related": order,
    }
    if order.status != Order.STATUS_PAYMENT_INITIATED or order.checkout_request_id != checkout_request_id:
        log_event(
            SystemLog.MODULE_PAYMENTS, "mpesa.order.failure_ignored",
            f"Ignored M-Pesa failure ({result_code} {result_desc}) for order {order.order_reference}: "
            f"order is {order.status}, current prompt is {order.checkout_request_id or 'none'}.",
            **refs,
        )
        return
    order.status = Order.STATUS_FAILED
    order.save(update_fields=["status", "updated_at"])
    release_stock(order)
    log_event(
        SystemLog.MODULE_PAYMENTS, "mpesa.order.payment_failed",
        f"Order {order.order_reference} payment failed: {result_code} {result_desc} — stock released.",
        **refs,
    )


def query_and_apply(order: Order, *, timeout_as_failed: bool = False) -> str:
    """
    Ask Daraja for the outcome of the order's current STK request and apply
    it. With timeout_as_failed, a request still "processing" is written off
    (used by the stale-payment sweep, long after the prompt has expired).

    Returns the query state. Raises MpesaError if Daraja can't be reached.
    """
    checkout_request_id = order.checkout_request_id
    result = MpesaService().stk_query(checkout_request_id)
    state = result["state"]

    if state == QUERY_SUCCESS:
        apply_result(
            order.pk, checkout_request_id, success=True,
            result_code=result["result_code"], result_desc=result["result_desc"], raw=result["raw"],
        )
    elif state == QUERY_FAILED:
        apply_result(
            order.pk, checkout_request_id, success=False,
            result_code=result["result_code"], result_desc=result["result_desc"], raw=result["raw"],
        )
    elif state == QUERY_PROCESSING and timeout_as_failed:
        apply_result(
            order.pk, checkout_request_id, success=False,
            result_desc="No response from M-Pesa before the payment window expired.",
            raw=result["raw"],
        )
        state = QUERY_FAILED
    return state
