"""AzamPay adapter (sandbox) — V2 §6.2 / §16.

Talks to AzamPay's Authenticator, Checkout, and (separate host) Disbursement
APIs. Both collection and disbursement are asynchronous: `create_collection`
only starts a mobile-money prompt on the payer's phone, and
`create_disbursement` only submits a transfer — in both cases the real
success/failure arrives later via AzamPay's callbacks
(`payments.views.azampay_callback` and `azampay_disbursement_callback`
respectively), which drive the domain state machines.

Checkout's shape is assembled from AzamPay's public sandbox docs and several
third-party SDKs (AzamPay does not publish a single canonical OpenAPI spec
for it at the time of writing). Disbursement's shape (endpoint, fields,
response, callback payload) is sourced directly from AzamPay's own developer
docs (developerdocs.azampay.co.tz/tanzania/disbursement) and should be
accurate — except the checksum, which is blocked on AzamPay support
providing the public key and the fields it covers (see
`_compute_checksum`). See docs/LOCAL_DEV.md for sandbox setup notes.
"""

from __future__ import annotations

import base64
import hashlib
import logging
import time
from decimal import Decimal

import requests
from cryptography.hazmat.primitives.asymmetric import padding as asym_padding
from cryptography.hazmat.primitives.serialization import load_pem_public_key
from django.conf import settings
from django.core.cache import cache
from django.utils import timezone

from core.settings_store import get_setting

from .base import (
    CollectionIntent,
    DisbursementInstruction,
    PaymentProvider,
    ProviderResult,
)

logger = logging.getLogger(__name__)

_TOKEN_CACHE_KEY = "azampay:access_token"
_REQUEST_TIMEOUT_SECONDS = 20

# MNOs AzamPay's MNO checkout accepts — used to validate a client-chosen
# payment method (step 11) before it's ever sent to AzamPay.
VALID_MNO_PROVIDERS = {"Airtel", "Tigo", "Halopesa", "Azampesa", "Mpesa"}

# Tanzanian MSISDN prefixes -> AzamPay MNO name, for sandbox convenience when
# the client doesn't tell us which network the buyer is on. Not exhaustive;
# falls back to AZAMPAY_DEFAULT_PROVIDER.
_MNO_PREFIXES = {
    "074": "Mpesa", "075": "Mpesa", "076": "Mpesa",
    "078": "Airtel", "068": "Airtel", "069": "Airtel",
    "071": "Tigo", "065": "Tigo", "067": "Tigo",
    "062": "Halopesa", "061": "Halopesa",
}


def resolve_provider_for_phone(phone: str) -> str:
    digits = "".join(ch for ch in str(phone) if ch.isdigit())
    local = digits[-9:]  # drop country code, keep the 9-digit local number
    prefix = f"0{local[:2]}" if len(local) >= 2 else ""
    return _MNO_PREFIXES.get(prefix, settings.AZAMPAY_DEFAULT_PROVIDER)


class AzamPayError(Exception):
    """Raised when AzamPay rejects a request or returns an unexpected shape."""


class AzamPayProvider(PaymentProvider):
    name = "azampay"

    # Credentials are admin-portal-editable (core.settings_store), encrypted
    # at rest, with these AZAMPAY_* env vars as the local-dev fallback so
    # tests/local work without an admin account or DB rows. Base URLs and the
    # default MNO aren't secrets, so they stay plain Django settings.
    def _credential(self, setting_key: str, env_var: str) -> str:
        return get_setting(f"azampay_{setting_key}", env_var)

    def _get_access_token(self) -> str:
        token = cache.get(_TOKEN_CACHE_KEY)
        if token:
            return token

        response = requests.post(
            settings.AZAMPAY_AUTH_URL,
            json={
                "appName": self._credential("app_name", "AZAMPAY_APP_NAME"),
                "clientId": self._credential("client_id", "AZAMPAY_CLIENT_ID"),
                "clientSecret": self._credential("client_secret", "AZAMPAY_CLIENT_SECRET"),
            },
            timeout=_REQUEST_TIMEOUT_SECONDS,
        )
        if response.status_code >= 400:
            raise AzamPayError(f"AzamPay auth failed ({response.status_code}): {response.text[:300]}")

        data = response.json().get("data") or {}
        token = data.get("accessToken")
        if not token:
            raise AzamPayError(f"AzamPay auth response missing accessToken: {response.text[:300]}")

        # Refresh a little early; ignore an unparsable expiry and use a safe default.
        cache.set(_TOKEN_CACHE_KEY, token, timeout=max(60, self._expiry_seconds(data) - 60))
        return token

    @staticmethod
    def _expiry_seconds(data: dict) -> int:
        expire = data.get("expire")
        try:
            expiry_epoch = time.mktime(time.strptime(str(expire), "%m/%d/%Y %I:%M:%S %p"))
            return max(60, int(expiry_epoch - time.time()))
        except (ValueError, TypeError):
            return 3600

    def _request(self, method: str, base_url: str, path: str, *, json_body=None, params=None) -> dict:
        response = requests.request(
            method,
            f"{base_url}{path}",
            json=json_body,
            params=params,
            headers={"Authorization": f"Bearer {self._get_access_token()}"},
            timeout=_REQUEST_TIMEOUT_SECONDS,
        )
        try:
            body = response.json()
        except ValueError:
            raise AzamPayError(f"AzamPay returned a non-JSON response ({response.status_code}): {response.text[:300]}")
        if response.status_code >= 400:
            raise AzamPayError(f"AzamPay request to {path} failed ({response.status_code}): {body}")
        return body

    def _post(self, path: str, payload: dict) -> dict:
        return self._request("POST", settings.AZAMPAY_BASE_URL, path, json_body=payload)

    def _post_disbursement(self, path: str, payload: dict) -> dict:
        payload = {**payload, "checksum": self._compute_checksum(payload)}
        return self._request("POST", settings.AZAMPAY_DISBURSEMENT_BASE_URL, path, json_body=payload)

    def _get_disbursement(self, path: str, params: dict) -> dict:
        return self._request("GET", settings.AZAMPAY_DISBURSEMENT_BASE_URL, path, params=params)

    def _checksum_public_key(self):
        pem = get_setting("azampay_checksum_public_key", "AZAMPAY_CHECKSUM_PUBLIC_KEY")
        if not pem:
            return None
        return load_pem_public_key(pem.encode())

    def _compute_checksum(self, payload: dict) -> str:
        """Base64(RSA(SHA512(string))), PKCS1 padding — per AzamPay's
        disbursement docs. AzamPay does not publish which fields make up
        `string`, their order, or the join format; AZAMPAY_CHECKSUM_FIELDS
        (comma-separated dotted paths into `payload`, joined with no
        separator) is a placeholder for whatever AzamPay support specifies —
        update it once confirmed, no code change needed."""
        public_key = self._checksum_public_key()
        if public_key is None:
            raise AzamPayError(
                "AzamPay disbursement checksum is not configured: contact AzamPay support for "
                "the public key and the fields/order it signs, then set AZAMPAY_CHECKSUM_PUBLIC_KEY "
                "and AZAMPAY_CHECKSUM_FIELDS."
            )
        field_paths = [f.strip() for f in settings.AZAMPAY_CHECKSUM_FIELDS.split(",") if f.strip()]
        if not field_paths:
            raise AzamPayError("AZAMPAY_CHECKSUM_FIELDS is not set — cannot build the checksum input string.")

        raw_string = "".join(str(_dotted_get(payload, path)) for path in field_paths)
        digest = hashlib.sha512(raw_string.encode()).digest()
        encrypted = public_key.encrypt(digest, asym_padding.PKCS1v15())
        return base64.b64encode(encrypted).decode()

    def create_collection(self, intent: CollectionIntent) -> ProviderResult:
        provider = intent.provider or resolve_provider_for_phone(intent.payer_phone)
        body = self._post(
            "/azampay/mno/checkout",
            {
                "accountNumber": intent.payer_phone,
                "amount": str(intent.amount),
                "currency": intent.currency,
                "externalId": intent.reference[:128],
                "provider": provider,
            },
        )
        success = bool(body.get("success"))
        transaction_id = body.get("transactionId") or ""
        return ProviderResult(
            reference=intent.reference,
            status="pending" if success else "failed",
            provider_ref=transaction_id,
            raw=body,
        )

    def get_collection_status(self, provider_ref: str) -> ProviderResult:
        body = self._post("/azampay/gettransactionstatus", {"transactionId": provider_ref})
        return ProviderResult(
            reference=provider_ref,
            status=_normalize_status(body.get("data")),
            provider_ref=provider_ref,
            raw=body,
        )

    def create_disbursement(self, instruction: DisbursementInstruction) -> ProviderResult:
        """Disbursement is ASYNC (per AzamPay's own docs): this call only
        submits the transfer and gets back a `pgReferenceId` with a
        "your transaction is in process" message — it is NOT the final
        result. The real success/failure arrives later on a separate
        disbursement callback (see payments.views.azampay_disbursement_callback
        and normalize_disbursement_callback below), which is why this
        returns "processing", not "succeeded"/"failed". Callers must not
        treat this response as final.

        Exception: AZAMPAY_DEMO_MODE (only reachable with DEBUG=True — never
        in a correctly configured production deployment) simulates an
        immediate success instead of calling AzamPay at all, when the
        checksum isn't configured yet. This lets the whole escrow flow —
        real AzamPay checkout, simulated payout — be demoed/tested end to
        end before AzamPay's checksum spec is in hand.
        """
        if settings.AZAMPAY_DEMO_MODE and self._checksum_public_key() is None:
            logger.warning(
                "AZAMPAY_DEMO_MODE: simulating disbursement success for %s — "
                "checksum not configured. Never active when DEBUG=False.",
                instruction.reference,
            )
            return ProviderResult(
                reference=instruction.reference,
                status="succeeded",
                provider_ref=f"DEMO-{instruction.reference}",
                raw={"demo_mode": True, "note": "Simulated — real AzamPay checksum not configured yet."},
            )

        provider_name = instruction.provider or resolve_provider_for_phone(instruction.payee_phone)
        payload = {
            "source": {
                "countryCode": "TZ",
                "fullName": self._credential("app_name", "AZAMPAY_APP_NAME"),
                "bankName": "AzamPay",
                "accountNumber": self._credential("client_id", "AZAMPAY_CLIENT_ID"),
                "currency": instruction.currency,
            },
            "destination": {
                "countryCode": "TZ",
                "fullName": instruction.payee_phone,
                "bankName": provider_name,
                "accountNumber": instruction.payee_phone,
                "currency": instruction.currency,
            },
            "transferDetails": {
                "type": "mobilemoney",
                "amount": int(Decimal(str(instruction.amount))),
                "dateInEpoch": int(timezone.now().timestamp()),
            },
            "externalReferenceId": instruction.reference[:30],  # docs: maxLength 30
            "remarks": f"KitongaPay payout {instruction.reference}"[:255],
        }
        body = self._post_disbursement("/api/v1/azampay/disburse", payload)
        pg_reference_id = body.get("pgReferenceId") or ""
        success = bool(body.get("success"))
        return ProviderResult(
            reference=instruction.reference,
            status="processing" if success else "failed",
            provider_ref=pg_reference_id,
            raw=body,
        )

    def get_disbursement_status(self, provider_ref: str) -> ProviderResult:
        body = self._get_disbursement(
            "/api/v1/azampay/transactionstatus",
            {"pgReferenceId": provider_ref, "bankName": settings.AZAMPAY_DEFAULT_PROVIDER},
        )
        return ProviderResult(
            reference=provider_ref,
            status=_normalize_status(body.get("message")),
            provider_ref=provider_ref,
            raw=body,
        )

    def create_refund(self, instruction: DisbursementInstruction) -> ProviderResult:
        # AzamPay has no documented programmatic refund API for mobile-money
        # collections; refunds go back out as a manual disbursement/ops action.
        # "unsupported" is not one of the normalized pending|succeeded|failed
        # states — callers must check for it explicitly and fall back to the
        # ledger-only refund path (payments.flows.record_refund).
        return ProviderResult(
            reference=instruction.reference,
            status="unsupported",
            provider_ref="",
            raw={"note": "AzamPay has no programmatic refund API; process manually."},
        )

    def verify_callback(self, request) -> bool:
        # AzamPay's sandbox callback is not cryptographically signed in its
        # public docs, so authenticity is enforced with a shared secret we
        # embed in the callback URL registered with AzamPay (see .env.example).
        expected = self._credential("callback_secret", "AZAMPAY_CALLBACK_SECRET")
        if not expected:
            return False
        provided = request.GET.get("token", "")
        return _constant_time_compare(provided, expected)

    def normalize_callback(self, payload: dict) -> ProviderResult:
        provider_ref = payload.get("reference") or payload.get("transid") or payload.get("utilityref") or ""
        return ProviderResult(
            reference=payload.get("utilityref", ""),
            status=_normalize_status(payload.get("transactionstatus")),
            provider_ref=provider_ref,
            raw=payload,
        )

    def normalize_disbursement_callback(self, payload: dict) -> ProviderResult:
        """Disbursement's callback shape is different from checkout's
        normalize_callback above: {initiatorReferenceId, fspReferenceId,
        pgReferenceId, amount, status, message, operator} per AzamPay's
        disbursement docs — not utilityref/transactionstatus/msisdn."""
        return ProviderResult(
            reference=payload.get("initiatorReferenceId", ""),
            status=_normalize_status(payload.get("status")),
            provider_ref=payload.get("pgReferenceId", ""),
            raw=payload,
        )


def _normalize_status(raw_status) -> str:
    value = str(raw_status or "").strip().lower()
    if value in {"success", "successful", "succeeded", "completed"}:
        return "succeeded"
    if value in {"fail", "failed", "failure", "declined", "cancelled"}:
        return "failed"
    return "pending"


def _constant_time_compare(a: str, b: str) -> bool:
    import hmac

    return hmac.compare_digest(a.encode(), b.encode())


def _dotted_get(payload: dict, dotted_path: str):
    value = payload
    for key in dotted_path.split("."):
        value = value.get(key) if isinstance(value, dict) else None
        if value is None:
            return ""
    return value
