from datetime import timedelta
from decimal import Decimal
from unittest.mock import MagicMock, patch

from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from django.core.cache import cache
from django.test import TestCase, override_settings
from django.utils import timezone
from rest_framework.test import APITestCase

from core.models import User
from escrow.models import Deal

from ledger.services import account_balance

from .fees import compute_fee, seed_default_fee_schedule
from .flows import record_funding, record_refund, record_release_and_payout, record_split
from .models import FeeSchedule, PaymentAttempt, Payout, ProviderWebhookEvent, Refund
from .providers import get_provider
from .providers.azampay import AzamPayError, AzamPayProvider, resolve_provider_for_phone
from .providers.base import CollectionIntent, DisbursementInstruction
from .providers.manual import ManualProvider


class ComputeFeeTests(TestCase):
    """payments.apps seeds a default 2.5% (min 500 TZS) schedule on migrate;
    each test here layers a more-recent schedule on top of it so the
    assertions don't depend on that default staying unchanged."""

    def test_no_active_schedule_returns_zero(self):
        FeeSchedule.objects.all().delete()
        self.assertEqual(compute_fee("1000.00"), Decimal("0.00"))

    def test_percentage_and_flat_fee_combine(self):
        FeeSchedule.objects.create(
            name="custom",
            percent_bps=1000,  # 10%
            flat_fee=Decimal("50.00"),
            min_fee=Decimal("0.00"),
            effective_from=timezone.now(),
        )
        # 10% of 5000 + 50 flat = 550
        self.assertEqual(compute_fee("5000.00"), Decimal("550.00"))

    def test_min_fee_floors_a_small_percentage_fee(self):
        FeeSchedule.objects.create(
            name="custom-min",
            percent_bps=200,  # 2%
            flat_fee=Decimal("0.00"),
            min_fee=Decimal("1000.00"),
            effective_from=timezone.now(),
        )
        # 2% of 10000 = 200, below the 1000 floor
        self.assertEqual(compute_fee("10000.00"), Decimal("1000.00"))

    def test_fee_never_exceeds_the_amount(self):
        FeeSchedule.objects.create(
            name="custom-cap",
            percent_bps=10000,  # 100%
            flat_fee=Decimal("1000.00"),
            min_fee=Decimal("0.00"),
            effective_from=timezone.now(),
        )
        self.assertEqual(compute_fee("100.00"), Decimal("100.00"))

    def test_expired_schedule_is_not_used(self):
        now = timezone.now()
        FeeSchedule.objects.create(
            name="expired",
            percent_bps=5000,
            flat_fee=Decimal("0.00"),
            min_fee=Decimal("0.00"),
            effective_from=now - timedelta(days=10),
            effective_to=now - timedelta(days=1),
        )
        # Only the seeded default (2.5%, min 500) should still be active.
        self.assertEqual(compute_fee("10000.00"), Decimal("500.00"))

    def test_seed_default_fee_schedule_is_idempotent(self):
        FeeSchedule.objects.all().delete()
        created_first = seed_default_fee_schedule()
        created_second = seed_default_fee_schedule()

        self.assertTrue(created_first)
        self.assertFalse(created_second)
        self.assertEqual(FeeSchedule.objects.count(), 1)


class FlowsTests(TestCase):
    def setUp(self):
        self.seller = User.objects.create(phone="+255700200001", role=User.Role.SELLER)
        self.buyer = User.objects.create(phone="+255700200002", role=User.Role.BUYER)
        self.deal = Deal.objects.create(
            seller=self.seller,
            buyer=self.buyer,
            code="FLOWTEST1",
            title="Test deal",
            description="For payments.flows tests",
            amount="100000.00",
            fee="0.00",
            net_amount="100000.00",
            status=Deal.Status.FUNDED,
        )

    def test_record_funding_posts_journal_and_payment_attempt(self):
        record_funding(self.deal)

        self.assertEqual(account_balance("1000"), Decimal("100000.00"))
        self.assertEqual(account_balance("2000"), Decimal("-100000.00"))
        self.assertEqual(
            PaymentAttempt.objects.filter(deal=self.deal, status=PaymentAttempt.Status.SUCCEEDED).count(), 1
        )

    def test_record_funding_is_idempotent(self):
        record_funding(self.deal)
        record_funding(self.deal)  # retried request / replayed callback

        self.assertEqual(account_balance("1000"), Decimal("100000.00"))
        self.assertEqual(PaymentAttempt.objects.filter(deal=self.deal).count(), 1)

    def test_record_release_and_payout_splits_fee_from_seller_net(self):
        fee, net = record_release_and_payout(self.deal)

        # Default seeded schedule: 2.5% of 100000 = 2500 (above the 500 floor).
        self.assertEqual(fee, Decimal("2500.00"))
        self.assertEqual(net, Decimal("97500.00"))

        self.deal.refresh_from_db()
        self.assertEqual(self.deal.fee, Decimal("2500.00"))
        self.assertEqual(self.deal.net_amount, Decimal("97500.00"))

        self.assertEqual(account_balance("2100"), Decimal("0"))  # payable in, payout out nets to zero
        self.assertEqual(account_balance("4000"), Decimal("-2500.00"))  # fee revenue (credit-natural)

        payout = Payout.objects.get(deal=self.deal)
        self.assertEqual(payout.amount, Decimal("97500.00"))
        self.assertEqual(payout.status, Payout.Status.SUCCEEDED)

    def test_record_release_and_payout_is_idempotent(self):
        record_release_and_payout(self.deal)
        record_release_and_payout(self.deal)

        self.assertEqual(Payout.objects.filter(deal=self.deal).count(), 1)
        self.assertEqual(account_balance("2100"), Decimal("0"))

    def test_record_refund_reverses_escrow_to_buyer(self):
        record_funding(self.deal)
        record_refund(self.deal, self.deal.amount, reason="buyer requested")

        self.assertEqual(account_balance("2000"), Decimal("0"))  # funded then fully refunded
        refund = Refund.objects.get(deal=self.deal)
        self.assertEqual(refund.amount, Decimal("100000.00"))
        self.assertEqual(refund.status, Refund.Status.COMPLETED)

    def test_record_split_allocates_fee_only_to_seller_share(self):
        fee, seller_net = record_split(self.deal, buyer_amount="30000.00", seller_amount="70000.00")

        # 2.5% of the 70000 seller share = 1750 (above the 500 floor).
        self.assertEqual(fee, Decimal("1750.00"))
        self.assertEqual(seller_net, Decimal("68250.00"))

        self.assertEqual(Payout.objects.get(deal=self.deal).amount, Decimal("68250.00"))
        self.assertEqual(Refund.objects.get(deal=self.deal).amount, Decimal("30000.00"))

    def test_record_split_with_full_buyer_refund_skips_payout(self):
        fee, seller_net = record_split(self.deal, buyer_amount="100000.00", seller_amount="0")

        self.assertEqual(fee, Decimal("0.00"))
        self.assertEqual(seller_net, Decimal("0"))
        self.assertFalse(Payout.objects.filter(deal=self.deal).exists())
        self.assertEqual(Refund.objects.get(deal=self.deal).amount, Decimal("100000.00"))


class ManualProviderTests(TestCase):
    def test_get_provider_defaults_to_manual(self):
        self.assertIsInstance(get_provider(), ManualProvider)

    def test_manual_provider_always_succeeds_but_never_verifies_callbacks(self):
        provider = ManualProvider()

        collection = provider.create_collection(
            CollectionIntent(
                deal_code="D1", amount=Decimal("100.00"), currency="TZS",
                payer_phone="+255700000000", reference="REF-1",
            )
        )
        disbursement = provider.create_disbursement(
            DisbursementInstruction(
                payee_phone="+255700000001", amount=Decimal("100.00"), currency="TZS", reference="REF-2",
            )
        )

        self.assertEqual(collection.status, "succeeded")
        self.assertEqual(disbursement.status, "succeeded")
        # Dev stub never proves authenticity — real signature checks come with AzamPay.
        self.assertFalse(provider.verify_callback(request=None))


@override_settings(
    AZAMPAY_AUTH_URL="https://auth.example/token",
    AZAMPAY_BASE_URL="https://api.example",
    AZAMPAY_APP_NAME="TestApp",
    AZAMPAY_CLIENT_ID="cid",
    AZAMPAY_CLIENT_SECRET="secret",
    AZAMPAY_CALLBACK_SECRET="whsec-test",
    AZAMPAY_DEFAULT_PROVIDER="Airtel",
)
class AzamPayProviderTests(TestCase):
    def setUp(self):
        cache.clear()

    @staticmethod
    def _response(json_data, status_code=200):
        response = MagicMock()
        response.status_code = status_code
        response.json.return_value = json_data
        response.text = str(json_data)
        return response

    @patch("payments.providers.azampay.requests.request")
    @patch("payments.providers.azampay.requests.post")
    def test_create_collection_fetches_token_then_posts_checkout(self, mock_post, mock_request):
        mock_post.return_value = self._response(
            {"data": {"accessToken": "tok-1", "expire": "12/31/2099 11:59:59 PM"}}
        )
        mock_request.return_value = self._response({"success": True, "transactionId": "AZ-1", "message": "ok"})

        provider = AzamPayProvider()
        result = provider.create_collection(
            CollectionIntent(
                deal_code="D1", amount=Decimal("1000"), currency="TZS",
                payer_phone="0688123456", reference="deal:abc",
            )
        )

        self.assertEqual(result.status, "pending")
        self.assertEqual(result.provider_ref, "AZ-1")
        mock_post.assert_called_once()
        self.assertEqual(mock_post.call_args.args[0], "https://auth.example/token")
        mock_request.assert_called_once()
        checkout_call = mock_request.call_args
        self.assertEqual(checkout_call.args[0], "POST")
        self.assertEqual(checkout_call.args[1], "https://api.example/azampay/mno/checkout")
        self.assertEqual(checkout_call.kwargs["headers"]["Authorization"], "Bearer tok-1")
        self.assertEqual(checkout_call.kwargs["json"]["provider"], "Airtel")  # 068 prefix -> Airtel

    @patch("payments.providers.azampay.requests.request")
    @patch("payments.providers.azampay.requests.post")
    def test_access_token_is_cached_across_calls(self, mock_post, mock_request):
        mock_post.return_value = self._response(
            {"data": {"accessToken": "tok-1", "expire": "12/31/2099 11:59:59 PM"}}
        )
        mock_request.side_effect = [
            self._response({"success": True, "transactionId": "AZ-1", "message": "ok"}),
            self._response({"success": True, "transactionId": "AZ-2", "message": "ok"}),
        ]
        provider = AzamPayProvider()
        intent = CollectionIntent(
            deal_code="D1", amount=Decimal("1000"), currency="TZS",
            payer_phone="0688123456", reference="deal:abc",
        )

        provider.create_collection(intent)
        provider.create_collection(intent)

        # One auth call total, then one checkout call per collection — token reused.
        self.assertEqual(mock_post.call_count, 1)
        self.assertEqual(mock_request.call_count, 2)

    @patch("payments.providers.azampay.requests.request")
    @patch("payments.providers.azampay.requests.post")
    def test_checkout_failure_is_normalized_to_failed(self, mock_post, mock_request):
        mock_post.return_value = self._response(
            {"data": {"accessToken": "tok-1", "expire": "12/31/2099 11:59:59 PM"}}
        )
        mock_request.return_value = self._response({"success": False, "message": "insufficient funds"})
        provider = AzamPayProvider()
        result = provider.create_collection(
            CollectionIntent(
                deal_code="D1", amount=Decimal("1000"), currency="TZS",
                payer_phone="0688123456", reference="deal:abc",
            )
        )
        self.assertEqual(result.status, "failed")

    def test_create_refund_is_explicitly_unsupported(self):
        provider = AzamPayProvider()
        result = provider.create_refund(
            DisbursementInstruction(payee_phone="0688123456", amount=Decimal("100"), currency="TZS", reference="ref-1"),
        )
        # Not one of pending|succeeded|failed — callers must check for this
        # explicitly and fall back to the ledger-only refund path.
        self.assertEqual(result.status, "unsupported")

    def test_verify_callback_checks_shared_secret(self):
        provider = AzamPayProvider()
        good_request = MagicMock(GET={"token": "whsec-test"})
        bad_request = MagicMock(GET={"token": "wrong"})
        missing_request = MagicMock(GET={})

        self.assertTrue(provider.verify_callback(good_request))
        self.assertFalse(provider.verify_callback(bad_request))
        self.assertFalse(provider.verify_callback(missing_request))

    def test_normalize_callback_maps_azampay_fields(self):
        provider = AzamPayProvider()
        result = provider.normalize_callback({
            "msisdn": "0688123456", "amount": "1000", "utilityref": "deal:abc",
            "reference": "AZ-1", "transactionstatus": "success",
        })
        self.assertEqual(result.status, "succeeded")
        self.assertEqual(result.provider_ref, "AZ-1")
        self.assertEqual(result.reference, "deal:abc")

    def test_create_disbursement_raises_when_checksum_not_configured(self):
        # No AZAMPAY_CHECKSUM_PUBLIC_KEY set — must fail loudly rather than
        # send an unsigned/guessed request AzamPay would reject anyway.
        provider = AzamPayProvider()
        with self.assertRaises(AzamPayError):
            provider.create_disbursement(
                DisbursementInstruction(
                    payee_phone="0688123456", amount=Decimal("1000"), currency="TZS", reference="PAYOUT-D1",
                ),
            )

    @override_settings(AZAMPAY_DEMO_MODE=True)
    @patch("payments.providers.azampay.requests.request")
    @patch("payments.providers.azampay.requests.post")
    def test_demo_mode_simulates_success_without_checksum_or_network_call(self, mock_post, mock_request):
        provider = AzamPayProvider()
        result = provider.create_disbursement(
            DisbursementInstruction(
                payee_phone="0688123456", amount=Decimal("1000"), currency="TZS", reference="PAYOUT-D1",
            ),
        )

        self.assertEqual(result.status, "succeeded")
        self.assertEqual(result.provider_ref, "DEMO-PAYOUT-D1")
        self.assertTrue(result.raw["demo_mode"])
        mock_post.assert_not_called()
        mock_request.assert_not_called()

    @override_settings(
        AZAMPAY_DEMO_MODE=True,
        AZAMPAY_DISBURSEMENT_BASE_URL="https://disburse.example",
        AZAMPAY_CHECKSUM_FIELDS="externalReferenceId",
    )
    @patch("payments.providers.azampay.requests.request")
    @patch("payments.providers.azampay.requests.post")
    def test_demo_mode_does_not_bypass_once_checksum_is_configured(self, mock_post, mock_request):
        with override_settings(AZAMPAY_CHECKSUM_PUBLIC_KEY=_generate_test_public_key_pem()):
            mock_post.return_value = self._response(
                {"data": {"accessToken": "tok-1", "expire": "12/31/2099 11:59:59 PM"}}
            )
            mock_request.return_value = self._response(
                {"pgReferenceId": "PG-1", "success": True, "message": "processing", "statusCode": 200}
            )

            provider = AzamPayProvider()
            result = provider.create_disbursement(
                DisbursementInstruction(
                    payee_phone="0688123456", amount=Decimal("1000"), currency="TZS", reference="PAYOUT-D1",
                ),
            )

            # Once real credentials exist, demo mode steps aside — real (async) path runs.
            self.assertEqual(result.status, "processing")
            mock_request.assert_called_once()

    @override_settings(
        AZAMPAY_DISBURSEMENT_BASE_URL="https://disburse.example",
        AZAMPAY_CHECKSUM_FIELDS="externalReferenceId,transferDetails.amount",
    )
    @patch("payments.providers.azampay.requests.request")
    @patch("payments.providers.azampay.requests.post")
    def test_create_disbursement_submits_and_returns_processing(self, mock_post, mock_request):
        with override_settings(AZAMPAY_CHECKSUM_PUBLIC_KEY=_generate_test_public_key_pem()):
            mock_post.return_value = self._response(
                {"data": {"accessToken": "tok-1", "expire": "12/31/2099 11:59:59 PM"}}
            )
            mock_request.return_value = self._response(
                {"pgReferenceId": "PG-1", "success": True, "message": "Your transaction is in process", "statusCode": 200}
            )

            provider = AzamPayProvider()
            result = provider.create_disbursement(
                DisbursementInstruction(
                    payee_phone="0688123456", provider="Airtel",
                    amount=Decimal("1000"), currency="TZS", reference="PAYOUT-D1",
                ),
            )

            # Disbursement is async — a submitted ack is "processing", never a final result.
            self.assertEqual(result.status, "processing")
            self.assertEqual(result.provider_ref, "PG-1")
            call = mock_request.call_args
            self.assertEqual(call.args[0], "POST")
            self.assertEqual(call.args[1], "https://disburse.example/api/v1/azampay/disburse")
            self.assertIn("checksum", call.kwargs["json"])
            self.assertEqual(call.kwargs["json"]["destination"]["bankName"], "Airtel")
            self.assertEqual(call.kwargs["json"]["externalReferenceId"], "PAYOUT-D1")

    @override_settings(AZAMPAY_CHECKSUM_FIELDS="externalReferenceId")
    @patch("payments.providers.azampay.requests.request")
    @patch("payments.providers.azampay.requests.post")
    def test_create_disbursement_failure_ack_is_normalized_to_failed(self, mock_post, mock_request):
        with override_settings(AZAMPAY_CHECKSUM_PUBLIC_KEY=_generate_test_public_key_pem()):
            mock_post.return_value = self._response(
                {"data": {"accessToken": "tok-1", "expire": "12/31/2099 11:59:59 PM"}}
            )
            mock_request.return_value = self._response({"success": False, "message": "rejected"})

            provider = AzamPayProvider()
            result = provider.create_disbursement(
                DisbursementInstruction(
                    payee_phone="0688123456", amount=Decimal("1000"), currency="TZS", reference="PAYOUT-D2",
                ),
            )

            self.assertEqual(result.status, "failed")

    @patch("payments.providers.azampay.requests.request")
    @patch("payments.providers.azampay.requests.post")
    def test_get_disbursement_status_issues_get_with_query_params(self, mock_post, mock_request):
        mock_post.return_value = self._response(
            {"data": {"accessToken": "tok-1", "expire": "12/31/2099 11:59:59 PM"}}
        )
        mock_request.return_value = self._response(
            {"pgReferenceId": "PG-1", "message": "success", "success": True, "statusCode": 200}
        )

        provider = AzamPayProvider()
        result = provider.get_disbursement_status("PG-1")

        self.assertEqual(result.status, "succeeded")
        call = mock_request.call_args
        self.assertEqual(call.args[0], "GET")
        self.assertIn("/api/v1/azampay/transactionstatus", call.args[1])
        self.assertEqual(call.kwargs["params"], {"pgReferenceId": "PG-1", "bankName": "Airtel"})

    def test_normalize_disbursement_callback_maps_azampay_fields(self):
        provider = AzamPayProvider()
        result = provider.normalize_disbursement_callback({
            "initiatorReferenceId": "PAYOUT-D1", "fspReferenceId": "FSP-1", "pgReferenceId": "PG-1",
            "amount": "1000", "status": "success", "message": "ok", "operator": "Airtel",
        })
        self.assertEqual(result.status, "succeeded")
        self.assertEqual(result.provider_ref, "PG-1")
        self.assertEqual(result.reference, "PAYOUT-D1")


def _generate_test_public_key_pem() -> str:
    private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
    return private_key.public_key().public_bytes(
        encoding=serialization.Encoding.PEM,
        format=serialization.PublicFormat.SubjectPublicKeyInfo,
    ).decode()


class ResolveProviderForPhoneTests(TestCase):
    def test_known_prefixes_map_to_expected_mno(self):
        self.assertEqual(resolve_provider_for_phone("+255744123456"), "Mpesa")
        self.assertEqual(resolve_provider_for_phone("0688123456"), "Airtel")
        self.assertEqual(resolve_provider_for_phone("+255715123456"), "Tigo")
        self.assertEqual(resolve_provider_for_phone("0621234567"), "Halopesa")

    @override_settings(AZAMPAY_DEFAULT_PROVIDER="Azampesa")
    def test_unknown_prefix_falls_back_to_default(self):
        self.assertEqual(resolve_provider_for_phone("+255700000000"), "Azampesa")


@override_settings(PAYMENT_PROVIDER="azampay")
class ReleaseAndPayoutWithAzamPayTests(TestCase):
    """record_release_and_payout / record_split must only post the payout
    journal once AzamPay confirms the disbursement — never speculatively."""

    def setUp(self):
        self.seller = User.objects.create(phone="+255700300001", role=User.Role.SELLER)
        self.buyer = User.objects.create(phone="+255700300002", role=User.Role.BUYER)
        self.deal = Deal.objects.create(
            seller=self.seller,
            buyer=self.buyer,
            code="AZFLOWTST",
            title="Test deal",
            description="For AzamPay disbursement flow tests",
            amount="100000.00",
            fee="0.00",
            net_amount="100000.00",
            status=Deal.Status.FUNDED,
        )

    @patch("payments.flows.get_provider")
    def test_successful_disbursement_posts_payout_journal(self, mock_get_provider):
        from .providers.base import ProviderResult

        provider = MagicMock(name="azampay")
        provider.name = "azampay"
        provider.create_disbursement.return_value = ProviderResult(
            reference="PAYOUT-AZFLOWTST", status="succeeded", provider_ref="AZ-PAYOUT-1", raw={},
        )
        mock_get_provider.return_value = provider

        record_release_and_payout(self.deal)

        payout = Payout.objects.get(deal=self.deal)
        self.assertEqual(payout.status, Payout.Status.SUCCEEDED)
        self.assertEqual(payout.provider_ref, "AZ-PAYOUT-1")
        self.assertEqual(account_balance("1000"), Decimal("-97500.00"))  # settlement cash left

    @patch("payments.flows.get_provider")
    def test_failed_disbursement_leaves_seller_payable_intact(self, mock_get_provider):
        from .providers.base import ProviderResult

        provider = MagicMock(name="azampay")
        provider.name = "azampay"
        provider.create_disbursement.return_value = ProviderResult(
            reference="PAYOUT-AZFLOWTST", status="failed", provider_ref="", raw={"message": "network error"},
        )
        mock_get_provider.return_value = provider

        record_release_and_payout(self.deal)

        payout = Payout.objects.get(deal=self.deal)
        self.assertEqual(payout.status, Payout.Status.FAILED_RETRYABLE)
        self.assertEqual(payout.retry_count, 1)
        # Seller is still owed the money — cash never left settlement.
        self.assertEqual(account_balance("2100"), Decimal("-97500.00"))
        self.assertEqual(account_balance("1000"), Decimal("0"))

    @patch("payments.flows.get_provider")
    def test_retry_after_failure_can_still_succeed(self, mock_get_provider):
        from .providers.base import ProviderResult

        provider = MagicMock(name="azampay")
        provider.name = "azampay"
        provider.create_disbursement.side_effect = [
            ProviderResult(reference="PAYOUT-AZFLOWTST", status="failed", provider_ref="", raw={}),
            ProviderResult(reference="PAYOUT-AZFLOWTST", status="succeeded", provider_ref="AZ-PAYOUT-2", raw={}),
        ]
        mock_get_provider.return_value = provider

        record_release_and_payout(self.deal)
        record_release_and_payout(self.deal)

        payout = Payout.objects.get(deal=self.deal)
        self.assertEqual(payout.status, Payout.Status.SUCCEEDED)
        self.assertEqual(provider.create_disbursement.call_count, 2)

    @patch("payments.flows.get_provider")
    def test_disbursement_uses_sellers_payout_method_when_set(self, mock_get_provider):
        from .providers.base import ProviderResult

        self.seller.payout_provider = "Halopesa"
        self.seller.payout_phone = "+255620000001"
        self.seller.save(update_fields=["payout_provider", "payout_phone"])

        provider = MagicMock(name="azampay")
        provider.name = "azampay"
        provider.create_disbursement.return_value = ProviderResult(
            reference="PAYOUT-AZFLOWTST", status="succeeded", provider_ref="AZ-PAYOUT-3", raw={},
        )
        mock_get_provider.return_value = provider

        record_release_and_payout(self.deal)

        instruction = provider.create_disbursement.call_args[0][0]
        self.assertEqual(instruction.payee_phone, "+255620000001")
        self.assertEqual(instruction.provider, "Halopesa")

    @patch("payments.flows.get_provider")
    def test_disbursement_falls_back_to_login_phone_when_no_payout_method_set(self, mock_get_provider):
        from .providers.base import ProviderResult

        provider = MagicMock(name="azampay")
        provider.name = "azampay"
        provider.create_disbursement.return_value = ProviderResult(
            reference="PAYOUT-AZFLOWTST", status="succeeded", provider_ref="AZ-PAYOUT-4", raw={},
        )
        mock_get_provider.return_value = provider

        record_release_and_payout(self.deal)

        instruction = provider.create_disbursement.call_args[0][0]
        self.assertEqual(instruction.payee_phone, self.seller.phone)
        self.assertEqual(instruction.provider, "")

    @patch("payments.flows.get_provider")
    def test_disbursement_prefers_default_payment_method_over_legacy_fields(self, mock_get_provider):
        # Merged buyer/seller payment methods (core.models.PaymentMethod) take
        # priority over the legacy single payout_provider/payout_phone pair —
        # one saved-accounts list drives both paying and getting paid.
        from core.models import PaymentMethod
        from .providers.base import ProviderResult

        self.seller.payout_provider = "Halopesa"
        self.seller.payout_phone = "+255620000001"
        self.seller.save(update_fields=["payout_provider", "payout_phone"])
        PaymentMethod.objects.create(
            user=self.seller, provider="Airtel", phone="+255680000009", is_default=True,
        )

        provider = MagicMock(name="azampay")
        provider.name = "azampay"
        provider.create_disbursement.return_value = ProviderResult(
            reference="PAYOUT-AZFLOWTST", status="succeeded", provider_ref="AZ-PAYOUT-5", raw={},
        )
        mock_get_provider.return_value = provider

        record_release_and_payout(self.deal)

        instruction = provider.create_disbursement.call_args[0][0]
        self.assertEqual(instruction.payee_phone, "+255680000009")
        self.assertEqual(instruction.provider, "Airtel")


@override_settings(AZAMPAY_CALLBACK_SECRET="whsec-test")
class AzamPayCallbackViewTests(APITestCase):
    callback_url = "/api/v1/payments/azampay/callback"

    def setUp(self):
        self.seller = User.objects.create(phone="+255700500001", role=User.Role.SELLER)
        self.buyer = User.objects.create(phone="+255700500002", role=User.Role.BUYER)
        self.deal = Deal.objects.create(
            seller=self.seller,
            buyer=self.buyer,
            code="AZCB00001",
            title="Test deal",
            description="For AzamPay callback tests",
            amount="20000.00",
            fee="0.00",
            net_amount="20000.00",
            status=Deal.Status.PROCESSING_PAYMENT,
        )
        self.attempt = PaymentAttempt.objects.create(
            deal=self.deal, amount=self.deal.amount, currency="TZS",
            status=PaymentAttempt.Status.PENDING, provider="azampay", provider_ref="AZ-TXN-1",
        )

    def _post(self, payload, token="whsec-test"):
        return self.client.post(f"{self.callback_url}?token={token}", payload, format="json")

    def test_success_callback_funds_the_deal(self):
        response = self._post({
            "msisdn": self.buyer.phone, "amount": "20000", "utilityref": f"deal:{self.deal.id}",
            "reference": "AZ-TXN-1", "transactionstatus": "success",
        })

        self.assertEqual(response.status_code, 200)
        self.deal.refresh_from_db()
        self.attempt.refresh_from_db()
        self.assertEqual(self.deal.status, Deal.Status.FUNDED)
        self.assertEqual(self.attempt.status, PaymentAttempt.Status.SUCCEEDED)
        self.assertEqual(account_balance("1000"), Decimal("20000.00"))
        self.assertEqual(account_balance("2000"), Decimal("-20000.00"))
        self.assertTrue(ProviderWebhookEvent.objects.filter(provider="azampay", processed=True).exists())

    def test_failed_callback_reverts_deal_to_awaiting_payment(self):
        response = self._post({
            "msisdn": self.buyer.phone, "amount": "20000", "utilityref": f"deal:{self.deal.id}",
            "reference": "AZ-TXN-1", "transactionstatus": "failed",
        })

        self.assertEqual(response.status_code, 200)
        self.deal.refresh_from_db()
        self.attempt.refresh_from_db()
        self.assertEqual(self.deal.status, Deal.Status.AWAITING_PAYMENT)
        self.assertEqual(self.attempt.status, PaymentAttempt.Status.FAILED)

    def test_wrong_token_is_rejected(self):
        response = self._post(
            {"reference": "AZ-TXN-1", "transactionstatus": "success"}, token="wrong-secret",
        )

        self.assertEqual(response.status_code, 403)
        self.deal.refresh_from_db()
        self.assertEqual(self.deal.status, Deal.Status.PROCESSING_PAYMENT)

    def test_duplicate_callback_is_processed_only_once(self):
        payload = {
            "msisdn": self.buyer.phone, "amount": "20000", "utilityref": f"deal:{self.deal.id}",
            "reference": "AZ-TXN-1", "transactionstatus": "success",
        }

        self._post(payload)
        second_response = self._post(payload)

        self.assertEqual(second_response.status_code, 200)
        # Balance reflects a single funding event, not two.
        self.assertEqual(account_balance("1000"), Decimal("20000.00"))
        self.assertEqual(ProviderWebhookEvent.objects.filter(provider="azampay").count(), 1)

    def test_unmatched_callback_is_recorded_but_does_not_error(self):
        response = self._post({
            "utilityref": "deal:does-not-exist", "reference": "AZ-UNKNOWN", "transactionstatus": "success",
        })

        self.assertEqual(response.status_code, 200)
        self.assertTrue(ProviderWebhookEvent.objects.filter(provider="azampay", processed=True).exists())


@override_settings(PAYMENT_PROVIDER="azampay", AZAMPAY_DEMO_MODE=True)
class DemoModeEndToEndPayoutTests(TestCase):
    """The actual AzamPayProvider (not mocked) driving a full release+payout
    with AZAMPAY_DEMO_MODE=True and no checksum configured — this is what
    lets the whole escrow flow be demoed against real AzamPay checkout while
    the disbursement checksum is still pending from AzamPay support."""

    def setUp(self):
        self.seller = User.objects.create(phone="+255700900001", role=User.Role.SELLER)
        self.buyer = User.objects.create(phone="+255700900002", role=User.Role.BUYER)
        self.deal = Deal.objects.create(
            seller=self.seller, buyer=self.buyer, code="DEMOFLOW1", title="Demo deal", description="x",
            amount="30000.00", fee="0.00", net_amount="30000.00", status=Deal.Status.FUNDED,
        )

    def test_release_completes_immediately_via_simulated_disbursement(self):
        fee, net = record_release_and_payout(self.deal)

        payout = Payout.objects.get(deal=self.deal)
        self.assertEqual(payout.status, Payout.Status.SUCCEEDED)
        self.assertTrue(payout.provider_ref.startswith("DEMO-"))
        # Payout journal actually posted — not left pending, unlike the real async path.
        self.assertEqual(account_balance("2100"), Decimal("0"))
        self.assertEqual(account_balance("1000"), Decimal(f"-{net}"))
