"""
store/services/mpesa_service.py
────────────────────────────────
Production-ready Safaricom Daraja API integration.

Design decisions
────────────────
• MpesaService is a plain class (not a Django model or singleton).
  Instantiate it per-request; it reads credentials from settings each time so
  that a settings reload (e.g. test overrides) is always reflected.

• MOCK_MPESA = True  →  STK Push returns a fake success response immediately,
  no real HTTP call made.  Useful for local dev and CI.

• MPESA_SANDBOX = True  →  Points at sandbox.safaricom.co.ke, and every
  prompt charges KES 1 (see charge_amount) while orders keep the real total.
  Set to False in production to switch to api.safaricom.co.ke.

• Access tokens are NOT cached here (keeps the service stateless).
  In a high-traffic system, cache in Redis with a ~55-minute TTL.

Safaricom STK Push flow
───────────────────────
  1.  POST /oauth/v1/generate  →  access_token
  2.  POST /mpesa/stkpush/v1/processrequest
        → CheckoutRequestID, MerchantRequestID
  3.  Safaricom calls MPESA_CALLBACK_URL asynchronously (5–60 s later)
  4.  Parse callback; update Order status in MpesaCallbackView

Example payloads are documented at the bottom of this file.
"""

import base64
import logging
import time
from datetime import datetime
from typing import Any

import requests
from django.conf import settings

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Custom exception
# ---------------------------------------------------------------------------

class MpesaError(Exception):
    """Raised for any M-Pesa-specific error (network, auth, validation)."""


def _record(event: str, message: str, *, level: str = "info", **kwargs) -> None:
    """Write a payments entry to the System Log (never raises)."""
    from system_logs.services import log_event  # app registry must be ready

    log_event("payments", event, message, level=level, **kwargs)


def _response_body(resp) -> dict:
    try:
        return resp.json()
    except ValueError:
        return {"text": (resp.text or "")[:2000]}


SANDBOX_CHARGE_AMOUNT = 1

# (base_url, consumer_key) -> (token, expires_at monotonic). Per process.
_TOKEN_CACHE: dict[tuple[str, str], tuple[str, float]] = {}
_TOKEN_TTL_SECONDS = 50 * 60

QUERY_SUCCESS = "success"
QUERY_FAILED = "failed"
QUERY_PROCESSING = "processing"

_QUERY_PROCESSING_ERROR_CODES = {"500.001.1001"}
# 4999: "The transaction is still under processing" (newer Daraja responses)
_QUERY_PROCESSING_RESULT_CODES = {4999}


# ---------------------------------------------------------------------------
# MpesaService
# ---------------------------------------------------------------------------

class MpesaService:
    """
    Encapsulates all interactions with the Safaricom Daraja v1 API.

    Attributes read from Django settings (set via environment variables):
        MPESA_CONSUMER_KEY      – Daraja app consumer key
        MPESA_CONSUMER_SECRET   – Daraja app consumer secret
        MPESA_SHORTCODE         – Business / Till shortcode
        MPESA_PASSKEY           – STK Push passkey (from Daraja portal)
        MPESA_CALLBACK_URL      – Public HTTPS URL Safaricom will POST to
        MPESA_SANDBOX           – bool; True = sandbox, False = production
        MOCK_MPESA              – bool; True = skip real HTTP calls (dev/test)
    """

    # Daraja API endpoints (relative to base_url)
    _TOKEN_PATH = "/oauth/v1/generate?grant_type=client_credentials"
    _STK_PUSH_PATH = "/mpesa/stkpush/v1/processrequest"
    _STK_QUERY_PATH = "/mpesa/stkpushquery/v1/query"

    def __init__(self) -> None:
        self.consumer_key: str = settings.MPESA_CONSUMER_KEY
        self.consumer_secret: str = settings.MPESA_CONSUMER_SECRET
        self.shortcode: str = str(settings.MPESA_SHORTCODE)
        self.passkey: str = settings.MPESA_PASSKEY
        self.callback_url: str = settings.MPESA_CALLBACK_URL
        self.sandbox: bool = getattr(settings, "MPESA_SANDBOX", True)
        self.mock: bool = getattr(settings, "MOCK_MPESA", False)

        self.base_url = (
            "https://sandbox.safaricom.co.ke"
            if self.sandbox
            else "https://api.safaricom.co.ke"
        )

    # ------------------------------------------------------------------
    # Private utilities
    # ------------------------------------------------------------------

    def _timestamp(self) -> str:
        """Return now in Safaricom's YYYYMMDDHHmmss format."""
        return datetime.now().strftime("%Y%m%d%H%M%S")

    def _generate_password(self, timestamp: str) -> str:
        """
        Safaricom STK password = Base64(shortcode + passkey + timestamp).
        Must be regenerated for every request because it embeds the timestamp.
        """
        raw = f"{self.shortcode}{self.passkey}{timestamp}"
        return base64.b64encode(raw.encode()).decode()

    def _forget_token(self) -> None:
        _TOKEN_CACHE.pop((self.base_url, self.consumer_key), None)

    def _get_access_token(self) -> str:
        """
        Fetch a short-lived OAuth 2.0 token from the Daraja token endpoint.

        Tokens are valid for ~3600 seconds.  For high-volume services, cache
        the token in Redis and re-fetch only when it's about to expire.

        Raises:
            MpesaError: If the network call fails or the response is malformed.
        """
        # Reuse tokens (valid ~1h): Daraja rate-limits hard (sandbox allows a
        # burst of 3), and a fresh token per call doubled our request count.
        cache_key = (self.base_url, self.consumer_key)
        cached = _TOKEN_CACHE.get(cache_key)
        if cached and cached[1] > time.monotonic():
            return cached[0]

        url = f"{self.base_url}{self._TOKEN_PATH}"
        try:
            resp = requests.get(
                url,
                auth=(self.consumer_key, self.consumer_secret),
                timeout=30,
            )
            resp.raise_for_status()
            token = resp.json().get("access_token")
            if not token:
                raise MpesaError("Empty access_token in Daraja response.")
            _TOKEN_CACHE[cache_key] = (token, time.monotonic() + _TOKEN_TTL_SECONDS)
            return token
        except requests.Timeout as exc:
            _record("mpesa.auth.failed", "M-Pesa token request timed out", level="error", exc=exc, alert=True)
            raise MpesaError("M-Pesa gateway timed out. Please retry.")
        except requests.ConnectionError as exc:
            _record("mpesa.auth.failed", "Cannot reach M-Pesa to get an access token", level="error", exc=exc, alert=True)
            raise MpesaError("Cannot reach M-Pesa gateway. Check your internet connection.")
        except requests.HTTPError as exc:
            status = exc.response.status_code
            # 429 is Daraja rate limiting, not a credentials problem.
            _record(
                "mpesa.auth.failed", f"M-Pesa refused the access-token request (HTTP {status})",
                level="warning" if status == 429 else "error", http_status=status,
                response_data=_response_body(exc.response), alert=status != 429,
            )
            raise MpesaError(f"M-Pesa auth failed (HTTP {status}).")
        except (ValueError, KeyError) as exc:
            _record("mpesa.auth.failed", "Unexpected access-token response from M-Pesa",
                    level="error", exc=exc, alert=True)
            raise MpesaError("Unexpected response from M-Pesa gateway.") from exc

    # ------------------------------------------------------------------
    # Public utilities
    # ------------------------------------------------------------------

    def normalize_phone(self, phone: str) -> str:
        """
        Normalize a Kenyan phone number to the 254XXXXXXXXX format required
        by the Daraja API.

        Accepted input formats:
            0712345678   → 254712345678
            +254712345678 → 254712345678
            254712345678  → 254712345678 (unchanged)
            712345678     → 254712345678

        Raises:
            MpesaError: If the resulting number is not exactly 12 digits.
        """
        cleaned = phone.strip().replace(" ", "").replace("-", "")

        if cleaned.startswith("+"):
            cleaned = cleaned[1:]
        if cleaned.startswith("0"):
            cleaned = "254" + cleaned[1:]
        if not cleaned.startswith("254"):
            cleaned = "254" + cleaned

        if not cleaned.isdigit() or len(cleaned) != 12:
            raise MpesaError(
                f"Invalid phone number '{phone}'. "
                "Expected a 10-digit Kenyan number (07XX or 01XX) or "
                "the full international format (2547XX)."
            )
        return cleaned

    def charge_amount(self, amount) -> int:
        """
        What M-Pesa is actually asked to collect. In sandbox every prompt is
        for KES 1 so test payments never move real money; orders and the
        transaction log still carry the real amount. Live charges in full.
        """
        return SANDBOX_CHARGE_AMOUNT if self.sandbox else int(amount)

    # ------------------------------------------------------------------
    # STK Push
    # ------------------------------------------------------------------

    def stk_push(
        self,
        phone_number: str,
        amount: int,
        order_reference: str,
        account_reference: str | None = None,
    ) -> dict[str, Any]:
        """
        Initiate an M-Pesa Express (Lipa Na M-Pesa Online) STK Push.

        The customer receives a PIN prompt on their phone.  Safaricom calls
        MPESA_CALLBACK_URL asynchronously — there is no synchronous result.

        Args:
            phone_number:      Already-normalised 254XXXXXXXXX number.
            amount:            Integer KES amount (fractions are truncated).
            order_reference:   Used as TransactionDesc (≤13 chars truncated).
            account_reference: Shown on customer's phone (≤12 chars truncated).
                               Defaults to order_reference.

        Returns:
            Daraja response dict, e.g.:
            {
                "MerchantRequestID": "...",
                "CheckoutRequestID": "ws_CO_...",
                "ResponseCode": "0",
                "ResponseDescription": "Success. Request accepted for processing",
                "CustomerMessage": "Success. Request accepted for processing",
            }

        Raises:
            MpesaError: On network failure, auth failure, or Daraja rejection.
        """
        if self.mock:
            logger.info(
                "[MOCK M-Pesa] STK Push | phone=%s | amount=%s | ref=%s",
                phone_number, amount, order_reference,
            )
            return {
                "MerchantRequestID": "mock-merchant-001",
                "CheckoutRequestID": f"mock-checkout-{order_reference}",
                "ResponseCode": "0",
                "ResponseDescription": "Success. Request accepted for processing",
                "CustomerMessage": "Success. Request accepted for processing",
                "_mock": True,
            }

        timestamp = self._timestamp()
        password = self._generate_password(timestamp)
        access_token = self._get_access_token()

        account_ref = (account_reference or order_reference)[:12]
        txn_desc = f"Order {order_reference}"[:13]
        charge = self.charge_amount(amount)
        if charge != int(amount):
            logger.info(
                "Sandbox: charging KES %s instead of KES %s for order %s",
                charge, int(amount), order_reference,
            )

        payload = {
            "BusinessShortCode": self.shortcode,
            "Password": password,
            "Timestamp": timestamp,
            "TransactionType": "CustomerPayBillOnline",
            "Amount": charge,                 # Must be a whole number
            "PartyA": phone_number,
            "PartyB": self.shortcode,
            "PhoneNumber": phone_number,
            "CallBackURL": self.callback_url,
            "AccountReference": account_ref,
            "TransactionDesc": txn_desc,
        }

        headers = {
            "Authorization": f"Bearer {access_token}",
            "Content-Type": "application/json",
        }

        refs = {"reference": order_reference, "phone_number": phone_number, "request_data": payload}
        try:
            resp = requests.post(
                f"{self.base_url}{self._STK_PUSH_PATH}",
                json=payload,
                headers=headers,
                timeout=30,
            )
            resp.raise_for_status()
            data: dict = resp.json()
        except requests.Timeout as exc:
            _record("mpesa.stk_push.failed", "STK prompt request to M-Pesa timed out",
                    level="error", exc=exc, alert=True, **refs)
            raise MpesaError("Payment gateway timed out. Please retry in a few seconds.")
        except requests.ConnectionError as exc:
            _record("mpesa.stk_push.failed", "Cannot reach M-Pesa to send the STK prompt",
                    level="error", exc=exc, alert=True, **refs)
            raise MpesaError("Cannot reach M-Pesa gateway.")
        except requests.HTTPError as exc:
            status = exc.response.status_code
            if status == 401:
                self._forget_token()
            _record(
                "mpesa.stk_push.failed", f"M-Pesa refused the STK prompt (HTTP {status})",
                level="warning" if status == 429 else "error", http_status=status,
                response_data=_response_body(exc.response), alert=status != 429, **refs,
            )
            raise MpesaError(f"M-Pesa returned HTTP {status}.")
        except ValueError as exc:
            _record("mpesa.stk_push.failed", "M-Pesa sent a non-JSON reply to the STK prompt",
                    level="error", exc=exc, http_status=resp.status_code,
                    response_data=_response_body(resp), alert=True, **refs)
            raise MpesaError("Unexpected response from M-Pesa gateway.") from exc

        if data.get("ResponseCode") != "0":
            _record(
                "mpesa.stk_push.rejected",
                f"M-Pesa rejected the STK prompt: {data.get('ResponseDescription', '')}",
                level="error", http_status=resp.status_code, response_data=data, alert=True, **refs,
            )
            raise MpesaError(
                data.get("ResponseDescription", "STK Push rejected by M-Pesa.")
            )

        _record(
            "mpesa.stk_push.sent",
            f"STK prompt for KES {charge} sent to {phone_number}"
            + (f" (sandbox; real amount KES {int(amount)})" if charge != int(amount) else ""),
            checkout_request_id=data.get("CheckoutRequestID", ""),
            http_status=resp.status_code, response_data=data, **refs,
        )
        return data

    # ------------------------------------------------------------------
    # STK Query
    # ------------------------------------------------------------------

    def stk_query(self, checkout_request_id: str) -> dict[str, Any]:
        """
        Ask Daraja for the outcome of an STK Push — the fallback for when the
        callback is late or never arrives.

        Returns:
            {"state": "success" | "failed" | "processing",
             "result_code": int | None, "result_desc": str, "raw": dict}

            A successful query does NOT include the M-Pesa receipt number;
            only the callback carries that.

        Raises:
            MpesaError: On network/auth failure or an unrecognised response.
        """
        if self.mock:
            logger.info("[MOCK M-Pesa] STK Query | checkout=%s", checkout_request_id)
            return {
                "state": QUERY_SUCCESS,
                "result_code": 0,
                "result_desc": "The service request is processed successfully.",
                "raw": {"_mock": True},
            }

        timestamp = self._timestamp()
        payload = {
            "BusinessShortCode": self.shortcode,
            "Password": self._generate_password(timestamp),
            "Timestamp": timestamp,
            "CheckoutRequestID": checkout_request_id,
        }
        headers = {
            "Authorization": f"Bearer {self._get_access_token()}",
            "Content-Type": "application/json",
        }

        refs = {"checkout_request_id": checkout_request_id, "request_data": payload}
        try:
            resp = requests.post(
                f"{self.base_url}{self._STK_QUERY_PATH}",
                json=payload,
                headers=headers,
                timeout=30,
            )
        except requests.Timeout as exc:
            _record("mpesa.query.failed", "M-Pesa status check timed out", level="warning", exc=exc, **refs)
            raise MpesaError("M-Pesa status check timed out.")
        except requests.ConnectionError as exc:
            _record("mpesa.query.failed", "Cannot reach M-Pesa for a status check", level="warning", exc=exc, **refs)
            raise MpesaError("Cannot reach M-Pesa gateway.")

        try:
            data: dict = resp.json()
        except ValueError as exc:
            # e.g. Safaricom's firewall answering with an HTML block page.
            _record("mpesa.query.failed", f"Non-JSON status-check reply from M-Pesa (HTTP {resp.status_code})",
                    level="warning", http_status=resp.status_code, response_data=_response_body(resp), **refs)
            raise MpesaError("Unexpected response from M-Pesa gateway.") from exc

        # While the customer hasn't acted yet, Daraja answers with an error
        # body (HTTP 500, errorCode 500.001.1001) rather than a ResultCode.
        if data.get("errorCode") in _QUERY_PROCESSING_ERROR_CODES:
            _record("mpesa.query", "Status check: still processing", http_status=resp.status_code,
                    response_data=data, **refs)
            return {
                "state": QUERY_PROCESSING,
                "result_code": None,
                "result_desc": data.get("errorMessage", "The transaction is being processed"),
                "raw": data,
            }

        if resp.status_code == 401:
            self._forget_token()
        if not resp.ok or data.get("ResultCode") is None:
            _record(
                "mpesa.query.failed", f"M-Pesa status check failed (HTTP {resp.status_code})",
                level="warning" if resp.status_code == 429 else "error", http_status=resp.status_code,
                response_data=data, alert=resp.status_code in (401, 403), **refs,
            )
            raise MpesaError(
                data.get("errorMessage")
                or data.get("ResponseDescription")
                or f"M-Pesa status check failed (HTTP {resp.status_code})."
            )

        result_code = int(data["ResultCode"])
        if result_code == 0:
            state = QUERY_SUCCESS
        elif result_code in _QUERY_PROCESSING_RESULT_CODES:
            state = QUERY_PROCESSING
        else:
            state = QUERY_FAILED
        _record("mpesa.query", f"Status check: {state} ({result_code} {data.get('ResultDesc', '')})".strip(),
                http_status=resp.status_code, response_data=data, **refs)
        return {
            "state": state,
            "result_code": result_code,
            "result_desc": data.get("ResultDesc", ""),
            "raw": data,
        }

    # ------------------------------------------------------------------
    # Callback parsing
    # ------------------------------------------------------------------

    def parse_callback(self, callback_data: dict) -> dict[str, Any]:
        """
        Parse and normalise a Safaricom STK Push callback payload.

        Safaricom always POST-s the callback with Content-Type: application/json.
        The outer wrapper is {"Body": {"stkCallback": {...}}}.

        Returns a normalised dict:
        {
            "success":              bool,
            "result_code":          int,
            "result_desc":          str,
            "checkout_request_id":  str,
            "merchant_request_id":  str,
            "amount":               Decimal | None,    # only on success
            "mpesa_receipt":        str | None,        # only on success
            "transaction_date":     str | None,        # only on success
            "phone_number":         str | None,        # only on success
        }

        Raises:
            MpesaError: If the payload structure is unrecognisable.
        """
        try:
            stk: dict = callback_data["Body"]["stkCallback"]
        except (KeyError, TypeError) as exc:
            logger.warning("Malformed callback (missing Body.stkCallback): %s", callback_data)
            raise MpesaError("Malformed callback: missing Body.stkCallback.") from exc

        result_code = stk.get("ResultCode")
        if result_code is None:
            raise MpesaError("Malformed callback: missing ResultCode.")

        result_code = int(result_code)

        parsed: dict[str, Any] = {
            "success": result_code == 0,
            "result_code": result_code,
            "result_desc": stk.get("ResultDesc", ""),
            "checkout_request_id": stk.get("CheckoutRequestID", ""),
            "merchant_request_id": stk.get("MerchantRequestID", ""),
            "amount": None,
            "mpesa_receipt": None,
            "transaction_date": None,
            "phone_number": None,
        }

        if result_code == 0:
            # Payment succeeded — extract metadata items list
            items: list[dict] = (
                stk.get("CallbackMetadata", {}).get("Item", [])
            )
            meta: dict = {item["Name"]: item.get("Value") for item in items}

            parsed["amount"] = meta.get("Amount")
            parsed["mpesa_receipt"] = str(meta.get("MpesaReceiptNumber", ""))
            parsed["transaction_date"] = str(meta.get("TransactionDate", ""))
            parsed["phone_number"] = str(meta.get("PhoneNumber", ""))

        logger.debug(
            "Parsed M-Pesa callback: success=%s ref=%s receipt=%s",
            parsed["success"],
            parsed["checkout_request_id"],
            parsed.get("mpesa_receipt"),
        )
        return parsed


# ===========================================================================
# EXAMPLE PAYLOADS  (for documentation / Postman)
# ===========================================================================
#
# ── 1. STK Push request (sent BY our server TO Daraja) ──────────────────────
#
# POST https://sandbox.safaricom.co.ke/mpesa/stkpush/v1/processrequest
# Authorization: Bearer <access_token>
# Content-Type: application/json
#
# {
#   "BusinessShortCode": "174379",
#   "Password": "<base64(shortcode+passkey+timestamp)>",
#   "Timestamp": "20250505143022",
#   "TransactionType": "CustomerPayBillOnline",
#   "Amount": 850,
#   "PartyA": "254712345678",
#   "PartyB": "174379",
#   "PhoneNumber": "254712345678",
#   "CallBackURL": "https://api.taiafropress.com/api/payments/mpesa/callback/",
#   "AccountReference": "ORD-20250505",
#   "TransactionDesc": "Order ORD-2025050"
# }
#
# Daraja success response:
# {
#   "MerchantRequestID": "29115-34620561-1",
#   "CheckoutRequestID": "ws_CO_191220191020363925",
#   "ResponseCode": "0",
#   "ResponseDescription": "Success. Request accepted for processing",
#   "CustomerMessage": "Success. Request accepted for processing"
# }
#
#
# ── 2. Successful payment callback (Safaricom → our MPESA_CALLBACK_URL) ─────
#
# POST /api/payments/mpesa/callback/
# Content-Type: application/json
#
# {
#   "Body": {
#     "stkCallback": {
#       "MerchantRequestID": "29115-34620561-1",
#       "CheckoutRequestID": "ws_CO_191220191020363925",
#       "ResultCode": 0,
#       "ResultDesc": "The service request is processed successfully.",
#       "CallbackMetadata": {
#         "Item": [
#           {"Name": "Amount",              "Value": 850.00},
#           {"Name": "MpesaReceiptNumber",  "Value": "NLJ7RT61SV"},
#           {"Name": "TransactionDate",     "Value": 20250505143055},
#           {"Name": "PhoneNumber",         "Value": 254712345678}
#         ]
#       }
#     }
#   }
# }
#
#
# ── 3. Failed / cancelled payment callback ──────────────────────────────────
#
# {
#   "Body": {
#     "stkCallback": {
#       "MerchantRequestID": "29115-34620561-1",
#       "CheckoutRequestID": "ws_CO_191220191020363925",
#       "ResultCode": 1032,
#       "ResultDesc": "Request cancelled by user."
#     }
#   }
# }
#
# Common ResultCode values:
#   0    – Success
#   1    – Insufficient funds
#   1032 – Request cancelled by user
#   1037 – Timeout (user didn't respond)
#   2001 – Wrong PIN
# ===========================================================================
