"""Shop view ITs — Chunk 3 of the wallet-expansion sprint.

Three endpoints under test:
  * `POST /dashboard/wallet/shop/buy` (`shop_buy`) — creates a Stripe
    PaymentIntent + a `Purchase` row in PENDING; returns
    `{client_secret, purchase_id}` for Stripe.js confirmCardPayment.
  * `POST /dashboard/wallet/shop/confirm` (`shop_confirm`) — sync follow-up
    after Stripe.js confirms client-side; retrieves the PI from Stripe,
    if `status=='succeeded'` calls `Purchase.fulfill()` (idempotent w. the
    webhook's parallel call).
  * `POST /stripe/webhook` (`stripe_webhook`) — async fulfillment fallback
    + bulletproof for 3DS-completed cards. Verifies signature against
    `STRIPE_WEBHOOK_SECRET`; on `payment_intent.succeeded` calls
    `Purchase.fulfill()` (same idempotent method as `shop_confirm`).

`apps.dashboard.views.stripe` is mocked across all three. The webhook
view's signature verification is also mocked via
`stripe.Webhook.construct_event`.
"""
from unittest import mock

from django.test import TestCase, override_settings

from apps.epic.models import DeckVariant
from apps.lyric.models import PaymentMethod, Purchase, ShopItem, Token, User


def _seed_free_decks():
    """Two free-in-shop decks (RWS + Fiorentine) + one paid deck (Earthman,
    free_in_shop=False) so the claim endpoint's `free_in_shop` guard is
    exercised. Mirrors the seed-migration row shapes (TestCase rolls back
    the data migrations)."""
    rws, _ = DeckVariant.objects.update_or_create(
        slug="tarot-rider-waite-smith",
        defaults={
            "name": "Tarot (Rider-Waite-Smith)", "card_count": 78,
            "family": "english", "has_card_images": False,
            "is_polarized": False, "free_in_shop": True,
        },
    )
    fiorentine, _ = DeckVariant.objects.update_or_create(
        slug="minchiate-fiorentine-1860-1890",
        defaults={
            "name": "Minchiate Fiorentine (1860–1890)", "card_count": 97,
            "family": "italian", "has_card_images": True,
            "is_polarized": False, "free_in_shop": True,
            "description": "97-card Minchiate Fiorentine deck.",
        },
    )
    earthman, _ = DeckVariant.objects.update_or_create(
        slug="earthman",
        defaults={
            "name": "Earthman", "card_count": 106, "is_default": True,
            "is_polarized": True, "has_card_images": False,
            "free_in_shop": False,
        },
    )
    return rws, fiorentine, earthman


class ShopClaimFreeViewTest(TestCase):
    """`POST /dashboard/wallet/shop/claim` (`shop_claim_free`) — adds a free-
    in-shop DeckVariant to the user's `unlocked_decks` so it appears in the
    Game Kit applet. No Stripe, no Purchase row, idempotent."""

    def setUp(self):
        self.rws, self.fiorentine, self.earthman = _seed_free_decks()
        self.user = User.objects.create(email="decker@test.io")
        # Start from a known unlock set — strip the signal's auto-grant so
        # the claim is the only thing that adds a deck.
        self.user.unlocked_decks.clear()
        self.client.force_login(self.user)

    def test_requires_login(self):
        self.client.logout()
        response = self.client.post(
            "/dashboard/wallet/shop/claim",
            {"deck_slug": "tarot-rider-waite-smith"},
        )
        self.assertRedirects(
            response, "/?next=/dashboard/wallet/shop/claim",
            fetch_redirect_response=False,
        )

    def test_claim_adds_deck_to_unlocked(self):
        response = self.client.post(
            "/dashboard/wallet/shop/claim",
            {"deck_slug": "tarot-rider-waite-smith"},
        )
        self.assertEqual(response.status_code, 200)
        self.assertTrue(
            self.user.unlocked_decks.filter(pk=self.rws.pk).exists()
        )

    def test_claim_returns_owned_json(self):
        response = self.client.post(
            "/dashboard/wallet/shop/claim",
            {"deck_slug": "tarot-rider-waite-smith"},
        )
        body = response.json()
        self.assertTrue(body["owned"])
        self.assertEqual(body["deck_name"], "Tarot (Rider-Waite-Smith)")

    def test_claim_is_idempotent(self):
        for _ in range(2):
            response = self.client.post(
                "/dashboard/wallet/shop/claim",
                {"deck_slug": "tarot-rider-waite-smith"},
            )
            self.assertEqual(response.status_code, 200)
        self.assertEqual(
            self.user.unlocked_decks.filter(pk=self.rws.pk).count(), 1
        )

    def test_unknown_slug_returns_404(self):
        response = self.client.post(
            "/dashboard/wallet/shop/claim", {"deck_slug": "no-such-deck"},
        )
        self.assertEqual(response.status_code, 404)

    def test_non_free_deck_cannot_be_claimed(self):
        """Guard: a deck with free_in_shop=False (eg Earthman, or any paid
        deck) is NOT claimable via this $0 endpoint → 404, no unlock."""
        response = self.client.post(
            "/dashboard/wallet/shop/claim", {"deck_slug": "earthman"},
        )
        self.assertEqual(response.status_code, 404)
        self.assertFalse(
            self.user.unlocked_decks.filter(pk=self.earthman.pk).exists()
        )


class WalletFreeDecksContextTest(TestCase):
    """The wallet page exposes `free_decks` — the free-in-shop DeckVariants
    decorated w. a per-user `.owned` flag the Shop applet uses to render
    FREE ITEM vs 'Already owned'."""

    def setUp(self):
        self.rws, self.fiorentine, self.earthman = _seed_free_decks()
        self.user = User.objects.create(email="ctx@test.io")
        self.user.unlocked_decks.clear()
        self.client.force_login(self.user)

    def test_free_decks_in_context_unowned_initially(self):
        response = self.client.get("/dashboard/wallet/")
        free = {d.slug: d for d in response.context["free_decks"]}
        # Both free decks present; the paid Earthman is NOT listed here.
        self.assertIn("tarot-rider-waite-smith", free)
        self.assertIn("minchiate-fiorentine-1860-1890", free)
        self.assertNotIn("earthman", free)
        self.assertFalse(free["tarot-rider-waite-smith"].owned)

    def test_free_deck_marked_owned_after_claim(self):
        self.user.unlocked_decks.add(self.rws)
        response = self.client.get("/dashboard/wallet/")
        free = {d.slug: d for d in response.context["free_decks"]}
        self.assertTrue(free["tarot-rider-waite-smith"].owned)
        self.assertFalse(free["minchiate-fiorentine-1860-1890"].owned)


def _seed_starting_items():
    """Mirror the seed-migration row shape so each TestCase starts w. a
    known catalog (TestCase rolls back the data migration, so the rows
    seeded by `0009_seed_shop_items` aren't there during tests)."""
    ShopItem.objects.update_or_create(
        slug="tithe-1",
        defaults={
            "name": "Tithe Token", "description": "1 Tithe Token + 12 Writs",
            "icon": "fa-piggy-bank", "badge_text": "",
            "price_cents": 100, "granted_token_type": Token.TITHE,
            "granted_count": 1, "granted_writs": 12,
            "max_owned": None, "display_order": 10, "active": True,
        },
    )
    ShopItem.objects.update_or_create(
        slug="band-1",
        defaults={
            "name": "Wristband", "description": "Admit All Entry",
            "shoptalk": "Unlimited free entry (BYOB)",
            "icon": "fa-ring", "badge_text": "",
            "price_cents": 2000, "granted_token_type": Token.BAND,
            "granted_count": 1, "granted_writs": 0,
            "max_owned": 1, "display_order": 30, "active": True,
        },
    )


class ShopBuyViewTest(TestCase):
    def setUp(self):
        _seed_starting_items()
        self.user = User.objects.create(email="buyer@test.io")
        self.user.stripe_customer_id = "cus_buyer"
        self.user.save()
        PaymentMethod.objects.create(
            user=self.user, stripe_pm_id="pm_test_4242",
            last4="4242", brand="visa",
        )
        self.client.force_login(self.user)

    def test_requires_login(self):
        self.client.logout()
        response = self.client.post(
            "/dashboard/wallet/shop/buy", {"shop_item_slug": "tithe-1"}
        )
        self.assertRedirects(
            response, "/?next=/dashboard/wallet/shop/buy",
            fetch_redirect_response=False,
        )

    @mock.patch("apps.dashboard.views.stripe")
    def test_success_creates_payment_intent_and_purchase(self, mock_stripe):
        mock_stripe.PaymentIntent.create.return_value = mock.Mock(
            id="pi_test_abc", client_secret="pi_test_abc_secret",
        )
        response = self.client.post(
            "/dashboard/wallet/shop/buy", {"shop_item_slug": "tithe-1"},
        )
        self.assertEqual(response.status_code, 200)
        body = response.json()
        self.assertEqual(body["client_secret"], "pi_test_abc_secret")
        purchase = Purchase.objects.get(pk=body["purchase_id"])
        self.assertEqual(purchase.user, self.user)
        self.assertEqual(purchase.shop_item.slug, "tithe-1")
        self.assertEqual(purchase.status, Purchase.PENDING)
        self.assertEqual(purchase.stripe_payment_intent_id, "pi_test_abc")
        self.assertEqual(purchase.amount_cents, 100)
        self.assertEqual(purchase.granted_writs, 12)

    @mock.patch("apps.dashboard.views.stripe")
    def test_payment_intent_called_with_correct_args(self, mock_stripe):
        mock_stripe.PaymentIntent.create.return_value = mock.Mock(
            id="pi_a", client_secret="pi_a_secret",
        )
        self.client.post(
            "/dashboard/wallet/shop/buy", {"shop_item_slug": "tithe-1"},
        )
        kwargs = mock_stripe.PaymentIntent.create.call_args.kwargs
        self.assertEqual(kwargs["amount"], 100)
        self.assertEqual(kwargs["currency"], "usd")
        self.assertEqual(kwargs["customer"], "cus_buyer")
        self.assertEqual(kwargs["payment_method"], "pm_test_4242")

    @mock.patch("apps.dashboard.views.stripe")
    def test_unknown_item_slug_returns_404(self, mock_stripe):
        response = self.client.post(
            "/dashboard/wallet/shop/buy", {"shop_item_slug": "no-such-item"},
        )
        self.assertEqual(response.status_code, 404)

    @mock.patch("apps.dashboard.views.stripe")
    def test_inactive_item_returns_404(self, mock_stripe):
        item = ShopItem.objects.get(slug="tithe-1")
        item.active = False
        item.save()
        response = self.client.post(
            "/dashboard/wallet/shop/buy", {"shop_item_slug": "tithe-1"},
        )
        self.assertEqual(response.status_code, 404)

    @mock.patch("apps.dashboard.views.stripe")
    def test_max_owned_violation_returns_409(self, mock_stripe):
        """User already owns 1 BAND (max_owned=1) → buy refused w. 409."""
        Token.objects.create(user=self.user, token_type=Token.BAND)
        response = self.client.post(
            "/dashboard/wallet/shop/buy", {"shop_item_slug": "band-1"},
        )
        self.assertEqual(response.status_code, 409)
        # No PaymentIntent created
        mock_stripe.PaymentIntent.create.assert_not_called()

    @mock.patch("apps.dashboard.views.stripe")
    def test_no_payment_method_returns_402(self, mock_stripe):
        """User w. no saved PaymentMethod → 402 Payment Required."""
        self.user.payment_methods.all().delete()
        response = self.client.post(
            "/dashboard/wallet/shop/buy", {"shop_item_slug": "tithe-1"},
        )
        self.assertEqual(response.status_code, 402)
        mock_stripe.PaymentIntent.create.assert_not_called()


class ShopConfirmViewTest(TestCase):
    def setUp(self):
        _seed_starting_items()
        self.user = User.objects.create(email="confirm@test.io")
        self.user.tokens.all().delete()
        self.user.refresh_from_db()
        self.client.force_login(self.user)
        self.tithe = ShopItem.objects.get(slug="tithe-1")
        self.purchase = Purchase.objects.create(
            user=self.user, shop_item=self.tithe,
            stripe_payment_intent_id="pi_conf_1",
            amount_cents=100, granted_writs=12,
        )

    def test_requires_login(self):
        self.client.logout()
        response = self.client.post(
            "/dashboard/wallet/shop/confirm", {"purchase_id": self.purchase.pk},
        )
        self.assertRedirects(
            response, "/?next=/dashboard/wallet/shop/confirm",
            fetch_redirect_response=False,
        )

    @mock.patch("apps.dashboard.views.stripe")
    def test_succeeded_pi_triggers_fulfill(self, mock_stripe):
        mock_stripe.PaymentIntent.retrieve.return_value = mock.Mock(status="succeeded")
        response = self.client.post(
            "/dashboard/wallet/shop/confirm", {"purchase_id": self.purchase.pk},
        )
        self.assertEqual(response.status_code, 200)
        self.purchase.refresh_from_db()
        self.assertEqual(self.purchase.status, Purchase.SUCCEEDED)
        self.assertEqual(
            self.user.tokens.filter(token_type=Token.TITHE).count(), 1
        )

    @mock.patch("apps.dashboard.views.stripe")
    def test_pending_pi_does_not_fulfill(self, mock_stripe):
        """Stripe still processing → leave Purchase PENDING for the webhook."""
        mock_stripe.PaymentIntent.retrieve.return_value = mock.Mock(status="processing")
        self.client.post(
            "/dashboard/wallet/shop/confirm", {"purchase_id": self.purchase.pk},
        )
        self.purchase.refresh_from_db()
        self.assertEqual(self.purchase.status, Purchase.PENDING)
        self.assertEqual(
            self.user.tokens.filter(token_type=Token.TITHE).count(), 0
        )

    @mock.patch("apps.dashboard.views.stripe")
    def test_idempotent_when_already_succeeded(self, mock_stripe):
        """Webhook already fulfilled — confirm endpoint shouldn't double-mint."""
        self.purchase.fulfill()
        self.assertEqual(self.purchase.status, Purchase.SUCCEEDED)
        mock_stripe.PaymentIntent.retrieve.return_value = mock.Mock(status="succeeded")
        response = self.client.post(
            "/dashboard/wallet/shop/confirm", {"purchase_id": self.purchase.pk},
        )
        self.assertEqual(response.status_code, 200)
        self.assertEqual(
            self.user.tokens.filter(token_type=Token.TITHE).count(), 1
        )

    @mock.patch("apps.dashboard.views.stripe")
    def test_other_users_purchase_returns_404(self, mock_stripe):
        """A user trying to confirm someone else's PendingPurchase gets 404."""
        other = User.objects.create(email="other@test.io")
        other_purchase = Purchase.objects.create(
            user=other, shop_item=self.tithe,
            stripe_payment_intent_id="pi_other",
            amount_cents=100, granted_writs=12,
        )
        response = self.client.post(
            "/dashboard/wallet/shop/confirm", {"purchase_id": other_purchase.pk},
        )
        self.assertEqual(response.status_code, 404)


@override_settings(STRIPE_WEBHOOK_SECRET="whsec_test_123")
class StripeWebhookViewTest(TestCase):
    def setUp(self):
        _seed_starting_items()
        self.user = User.objects.create(email="webhook@test.io")
        self.user.tokens.all().delete()
        self.user.refresh_from_db()
        self.tithe = ShopItem.objects.get(slug="tithe-1")
        self.purchase = Purchase.objects.create(
            user=self.user, shop_item=self.tithe,
            stripe_payment_intent_id="pi_wh_1",
            amount_cents=100, granted_writs=12,
        )

    @mock.patch("apps.dashboard.views.stripe")
    def test_signature_mismatch_returns_400(self, mock_stripe):
        mock_stripe.Webhook.construct_event.side_effect = ValueError("bad sig")
        response = self.client.post(
            "/stripe/webhook",
            data=b"{}",
            content_type="application/json",
            HTTP_STRIPE_SIGNATURE="t=0,v1=deadbeef",
        )
        self.assertEqual(response.status_code, 400)

    @mock.patch("apps.dashboard.views.stripe")
    def test_payment_intent_succeeded_triggers_fulfill(self, mock_stripe):
        # `Mock(intent_obj, ...)` returns a `dict()`-style object that supports
        # `event["type"]` AND `event["data"]["object"]["id"]` — match what the
        # webhook view will read.
        mock_stripe.Webhook.construct_event.return_value = {
            "type": "payment_intent.succeeded",
            "data": {"object": {
                "id": "pi_wh_1",
                "metadata": {"purchase_id": str(self.purchase.pk)},
            }},
        }
        response = self.client.post(
            "/stripe/webhook", data=b"{}",
            content_type="application/json",
            HTTP_STRIPE_SIGNATURE="t=0,v1=goodsig",
        )
        self.assertEqual(response.status_code, 200)
        self.purchase.refresh_from_db()
        self.assertEqual(self.purchase.status, Purchase.SUCCEEDED)
        self.assertEqual(
            self.user.tokens.filter(token_type=Token.TITHE).count(), 1
        )

    @mock.patch("apps.dashboard.views.stripe")
    def test_unknown_event_type_is_noop(self, mock_stripe):
        """Stripe sends a lot of event types we don't care about (eg
        `charge.dispute.created`). Webhook view should return 2xx so
        Stripe doesn't retry, but NOT touch the Purchase."""
        mock_stripe.Webhook.construct_event.return_value = {
            "type": "charge.dispute.created",
            "data": {"object": {"id": "pi_wh_1"}},
        }
        response = self.client.post(
            "/stripe/webhook", data=b"{}",
            content_type="application/json",
            HTTP_STRIPE_SIGNATURE="t=0,v1=goodsig",
        )
        self.assertEqual(response.status_code, 200)
        self.purchase.refresh_from_db()
        self.assertEqual(self.purchase.status, Purchase.PENDING)

    @mock.patch("apps.dashboard.views.stripe")
    def test_duplicate_delivery_is_idempotent(self, mock_stripe):
        """Stripe may redeliver a webhook (network blip, our 5xx, etc.).
        Re-firing the same `payment_intent.succeeded` event must not
        double-mint tokens."""
        mock_stripe.Webhook.construct_event.return_value = {
            "type": "payment_intent.succeeded",
            "data": {"object": {
                "id": "pi_wh_1",
                "metadata": {"purchase_id": str(self.purchase.pk)},
            }},
        }
        self.client.post(
            "/stripe/webhook", data=b"{}",
            content_type="application/json",
            HTTP_STRIPE_SIGNATURE="t=0,v1=goodsig",
        )
        self.client.post(
            "/stripe/webhook", data=b"{}",
            content_type="application/json",
            HTTP_STRIPE_SIGNATURE="t=0,v1=goodsig",
        )
        self.assertEqual(
            self.user.tokens.filter(token_type=Token.TITHE).count(), 1
        )

    @mock.patch("apps.dashboard.views.stripe")
    def test_unknown_purchase_id_in_metadata_is_noop(self, mock_stripe):
        """If metadata.purchase_id doesn't match any row, log + 200 (don't
        crash the webhook listener — Stripe would retry on 5xx)."""
        mock_stripe.Webhook.construct_event.return_value = {
            "type": "payment_intent.succeeded",
            "data": {"object": {
                "id": "pi_unknown",
                "metadata": {"purchase_id": "999999"},
            }},
        }
        response = self.client.post(
            "/stripe/webhook", data=b"{}",
            content_type="application/json",
            HTTP_STRIPE_SIGNATURE="t=0,v1=goodsig",
        )
        self.assertEqual(response.status_code, 200)
        self.purchase.refresh_from_db()
        self.assertEqual(self.purchase.status, Purchase.PENDING)
