"""Integration tests for make_post_sell.

Integration tests verify interactions between different parts of the system,
testing boundaries between models, views, and business logic with real database
and ORM objects.
"""

import unittest
import transaction
import mock

from pyramid.paster import get_appsettings
from ..models import get_tm_session
from ..models.meta import Base
from ..models.user import get_or_create_user_by_email, User
from ..models.shop import Shop
from ..models.product import Product
from ..models.cart import Cart
from ..models.coupon import Coupon
from ..models.cart_coupon import CartCoupon
from ..models.stripe_user_shop import StripeUserShop
from ..models.paypal_user_shop import PayPalUserShop
from ..models.invoice import get_invoice_by_paypal_order_id
from ..models.invoice import Invoice, InvoiceLineItem
from ..models.coupon_redemption import CouponRedemption
from ..models.price import Price
from ..models.crypto_payment import CryptoPayment
from ..models.user_crypto_refund_address import UserCryptoRefundAddress
from ..models.shop_location import ShopLocation
from ..models.gift_card import GiftCard, get_gift_card_by_id, get_gift_card_by_code, get_gift_cards_by_shop
from ..models.gift_card_transaction import GiftCardTransaction
from ..models.cart_gift_card import CartGiftCard
import json
import time


class DatabaseIntegrationTests(unittest.TestCase):
    """Base class for integration tests that need real database.

    Boots the WSGI app + schema once per worker (not per test); per-test
    isolation comes from wiping every row in tearDown. Same pattern as
    FunctionalTests in test_functional.py — see that docstring for the
    speedup rationale.
    """
    _settings = None
    _app = None
    _session_factory = None
    _engine = None
    _schema_built = False

    @classmethod
    def setUpClass(cls):
        from make_post_sell import main

        if DatabaseIntegrationTests._app is None:
            DatabaseIntegrationTests._settings = get_appsettings("test.ini")
            DatabaseIntegrationTests._app = main(
                {}, **DatabaseIntegrationTests._settings
            )
            DatabaseIntegrationTests._session_factory = (
                DatabaseIntegrationTests._app.registry["dbsession_factory"]
            )
            DatabaseIntegrationTests._engine = (
                DatabaseIntegrationTests._session_factory.kw["bind"]
            )
        if not DatabaseIntegrationTests._schema_built:
            Base.metadata.drop_all(bind=DatabaseIntegrationTests._engine)
            Base.metadata.create_all(bind=DatabaseIntegrationTests._engine)
            DatabaseIntegrationTests._schema_built = True

    def setUp(self):
        self.settings = DatabaseIntegrationTests._settings
        self.app = DatabaseIntegrationTests._app
        self.session_factory = DatabaseIntegrationTests._session_factory
        self.engine = DatabaseIntegrationTests._engine
        self.dbsession = get_tm_session(self.session_factory, transaction.manager)

    def tearDown(self):
        from sqlalchemy import text

        transaction.abort()
        with DatabaseIntegrationTests._engine.begin() as conn:
            conn.execute(text("PRAGMA foreign_keys = OFF"))
            for table in reversed(Base.metadata.sorted_tables):
                conn.execute(table.delete())
            conn.execute(text("PRAGMA foreign_keys = ON"))


class TestCartOrmIntegration(DatabaseIntegrationTests):
    """Integration tests for Cart model with real ORM objects."""

    def test_cart_with_real_products_and_shops(self):
        """Test cart operations with real Product and Shop ORM objects."""
        # Create real user
        user = get_or_create_user_by_email(self.dbsession, "test@example.com")
        self.dbsession.add(user)

        # Create real shop
        shop = Shop(
            name="Test Shop",
            phone_number="555-555-5555",
            billing_address="123 Test St",
            description="A test shop",
        )
        shop.stripe_public_api_key = "pk_test_123"
        shop.stripe_secret_api_key = "sk_test_123"
        shop.domain_name = "test.com"
        self.dbsession.add(shop)

        # Create real products
        product1 = Product(title="Digital Product 1", description="Test product")
        product1.shop_id = shop.id
        product1.price_in_cents = 1000  # $10.00
        product1.is_physical = False

        product2 = Product(
            title="Digital Product 2", description="Another test product"
        )
        product2.shop_id = shop.id
        product2.price_in_cents = 500  # $5.00
        product2.is_physical = False
        self.dbsession.add(product1)
        self.dbsession.add(product2)
        self.dbsession.flush()  # Get IDs

        # Create real cart
        cart = Cart(user=user)
        cart.shop = shop
        self.dbsession.add(cart)

        # Test adding products to cart
        cart.add_product(product1)
        cart.add_product(product1)  # Add twice
        cart.add_product(product2)

        # Test cart calculations with real ORM relationships
        self.assertEqual(cart.count, 3)  # 2 + 1
        self.assertEqual(cart.get_product_quantity(product1), 2)
        self.assertEqual(cart.get_product_quantity(product2), 1)

        # Test total calculation
        expected_total = (1000 * 2) + (500 * 1)  # $25.00
        self.assertEqual(cart.total_price_in_cents, expected_total)

        # Test requires_payment logic
        self.assertTrue(cart.requires_payment)  # Above 64 cent threshold

        transaction.commit()

    def test_cart_with_real_coupon_integration(self):
        """Test cart with real coupon objects and validation."""
        # Create real user and shop
        user = get_or_create_user_by_email(self.dbsession, "test@example.com")
        shop = Shop(
            name="Test Shop",
            phone_number="555-555-5555",
            billing_address="123 Test St",
            description="A test shop",
        )
        shop.stripe_public_api_key = "pk_test_123"
        shop.stripe_secret_api_key = "sk_test_123"
        shop.domain_name = "test.com"
        self.dbsession.add(user)
        self.dbsession.add(shop)

        # Create real product
        product = Product(title="Test Product", description="Test product")
        product.shop_id = shop.id
        product.price_in_cents = 1000  # $10.00
        product.is_physical = False
        self.dbsession.add(product)
        self.dbsession.flush()

        # Create real coupon
        coupon = Coupon(
            shop=shop,
            code="SAVE50",
            description="Save $5",
            action_type="dollar-off",
            action_value=5,  # $5.00 off
            max_redemptions=10,
            max_redemptions_per_user=1,
            cart_qualifier=5,  # Minimum $5.00 cart
        )
        self.dbsession.add(coupon)
        self.dbsession.flush()

        # Create real cart
        cart = Cart(user=user)
        cart.shop = shop
        self.dbsession.add(cart)

        # Add product to cart
        cart.add_product(product)

        # Attach coupon to cart
        cart_coupon = CartCoupon(cart=cart, coupon=coupon)
        self.dbsession.add(cart_coupon)
        self.dbsession.flush()

        # Test coupon validation with real ORM relationships
        errors = cart.validate_attached_coupons()
        self.assertEqual(errors, [])  # Should be valid

        # Test discounted total
        self.assertTrue(cart.is_discounted)
        self.assertEqual(cart.total_discounted_price_in_cents, 500)  # $10 - $5 = $5

        # Test that discounted cart still requires payment (above 64 cent threshold)
        self.assertTrue(cart.requires_payment)

        transaction.commit()

    def test_cart_free_with_coupon_integration(self):
        """Test cart that becomes free with coupon - the original bug scenario."""
        # Create real user and shop
        user = get_or_create_user_by_email(self.dbsession, "test@example.com")
        shop = Shop(
            name="Test Shop",
            phone_number="555-555-5555",
            billing_address="123 Test St",
            description="A test shop",
        )
        shop.stripe_public_api_key = "pk_test_123"
        shop.stripe_secret_api_key = "sk_test_123"
        shop.domain_name = "test.com"
        self.dbsession.add(user)
        self.dbsession.add(shop)

        # Create real cheap product
        product = Product(title="Cheap Product", description="Test product")
        product.shop_id = shop.id
        product.price_in_cents = 50  # $0.50
        product.is_physical = False
        self.dbsession.add(product)
        self.dbsession.flush()

        # Create coupon that makes cart free
        coupon = Coupon(
            shop=shop,
            code="FREEGIFT",
            description="Free gift",
            action_type="dollar-off",
            action_value=1,  # $1.00 off (more than product cost)
            max_redemptions=100,
            max_redemptions_per_user=1,
            cart_qualifier=0,  # No minimum
        )
        self.dbsession.add(coupon)
        self.dbsession.flush()

        # Create real cart
        cart = Cart(user=user)
        cart.shop = shop
        self.dbsession.add(cart)

        # Add product to cart
        cart.add_product(product)

        # Attach coupon to cart
        cart_coupon = CartCoupon(cart=cart, coupon=coupon)
        self.dbsession.add(cart_coupon)
        self.dbsession.flush()

        # Test the original bug scenario
        self.assertTrue(cart.is_discounted)
        self.assertEqual(cart.total_discounted_price_in_cents, 0)  # Free!

        # This is the key test - free cart should not require payment
        self.assertFalse(cart.requires_payment)  # Below 64 cent threshold

        # Test the checkout logic that was crashing
        stripe_user_shop = None  # No payment method needed for free cart

        # These are the exact conditions from cart.py that we fixed
        requires_billing_redirect = cart.requires_payment and stripe_user_shop is None
        self.assertFalse(requires_billing_redirect)  # Should NOT redirect

        # This was the line that crashed - now fixed
        requires_active_card_redirect = (
            stripe_user_shop and stripe_user_shop.active_card is None
        )
        self.assertFalse(requires_active_card_redirect)  # Should NOT crash

        # This was the return value that crashed - now fixed
        active_card = stripe_user_shop.active_card if stripe_user_shop else None
        self.assertIsNone(active_card)  # Should be None, not crash

        transaction.commit()

    def test_cart_physical_products_integration(self):
        """Test cart with physical products requiring shipping."""
        # Create real user and shop
        user = get_or_create_user_by_email(self.dbsession, "test@example.com")
        shop = Shop(
            name="Physical Shop",
            phone_number="555-555-5555",
            billing_address="123 Test St",
            description="A shop with physical items",
        )
        shop.stripe_public_api_key = "pk_test_123"
        shop.stripe_secret_api_key = "sk_test_123"
        shop.domain_name = "test.com"
        self.dbsession.add(user)
        self.dbsession.add(shop)

        # Create physical product
        physical_product = Product(
            title="Physical Item", description="A physical product"
        )
        physical_product.shop_id = shop.id
        physical_product.price_in_cents = 2000  # $20.00
        physical_product.is_physical = True
        self.dbsession.add(physical_product)
        self.dbsession.flush()

        # Create real cart
        cart = Cart(user=user)
        cart.shop = shop
        self.dbsession.add(cart)

        # Add physical product to cart
        cart.add_product(physical_product)

        # Test physical product detection
        self.assertTrue(len(cart.physical_products) > 0)
        self.assertIn(str(physical_product.id), cart.physical_products)

        # Test handling options
        cart.handling_option = "shipping"
        cart.handling_cost_in_cents = 500  # $5.00 shipping

        # Test total with handling
        expected_total = 2000 + 500  # Product + shipping
        self.assertEqual(cart.total_in_cents, expected_total)

        # Test remove handling when no physical products
        cart.remove_product(physical_product)
        cart.remove_handling_if_no_physical_products()

        self.assertIsNone(cart.handling_option)
        self.assertEqual(cart.handling_cost_in_cents, 0)

        transaction.commit()


class TestStripeUserShopIntegration(DatabaseIntegrationTests):
    """Integration tests for StripeUserShop boundaries."""

    def test_stripe_user_shop_creation_integration(self):
        """Test creating StripeUserShop with real User and Shop objects."""
        # Create real user and shop
        user = get_or_create_user_by_email(self.dbsession, "test@example.com")
        shop = Shop(
            name="Stripe Shop",
            phone_number="555-555-5555",
            billing_address="123 Test St",
            description="A shop with Stripe",
        )
        shop.stripe_public_api_key = "pk_test_123"
        shop.stripe_secret_api_key = "sk_test_123"
        shop.domain_name = "test.com"
        self.dbsession.add(user)
        self.dbsession.add(shop)
        self.dbsession.flush()

        # Create StripeUserShop
        stripe_user_shop = StripeUserShop(user=user, shop=shop)
        stripe_user_shop.cus_id = "cus_test123"
        stripe_user_shop.active_card_id = "card_test123"
        self.dbsession.add(stripe_user_shop)

        # Test ORM relationships
        self.assertEqual(stripe_user_shop.user, user)
        self.assertEqual(stripe_user_shop.shop, shop)
        self.assertEqual(stripe_user_shop.cus_id, "cus_test123")
        self.assertEqual(stripe_user_shop.active_card_id, "card_test123")

        # Test the scenario where active_card exists
        self.assertIsNotNone(stripe_user_shop.active_card_id)

        transaction.commit()

    def test_cart_checkout_with_stripe_user_shop_integration(self):
        """Test the full checkout flow with real StripeUserShop object."""
        # Create real user and shop
        user = get_or_create_user_by_email(self.dbsession, "test@example.com")
        shop = Shop(
            name="Payment Shop",
            phone_number="555-555-5555",
            billing_address="123 Test St",
            description="A shop requiring payment",
        )
        shop.stripe_public_api_key = "pk_test_123"
        shop.stripe_secret_api_key = "sk_test_123"
        shop.domain_name = "test.com"
        self.dbsession.add(user)
        self.dbsession.add(shop)

        # Create product requiring payment
        product = Product(
            title="Paid Product", description="A product requiring payment"
        )
        product.shop_id = shop.id
        product.price_in_cents = 1500  # $15.00
        product.is_physical = False
        self.dbsession.add(product)
        self.dbsession.flush()

        # Create cart
        cart = Cart(user=user)
        cart.shop = shop
        self.dbsession.add(cart)
        cart.add_product(product)

        # Test scenario 1: No StripeUserShop (should require billing)
        stripe_user_shop = None
        requires_billing = cart.requires_payment and stripe_user_shop is None
        self.assertTrue(requires_billing)

        # Test scenario 2: StripeUserShop exists but no active card
        stripe_user_shop = StripeUserShop(user=user, shop=shop)
        stripe_user_shop.cus_id = "cus_test123"
        stripe_user_shop.active_card_id = None  # No active card
        self.dbsession.add(stripe_user_shop)

        # Test the logic directly without accessing the property that calls Stripe
        requires_active_card = (
            stripe_user_shop and stripe_user_shop.active_card_id is None
        )
        self.assertTrue(requires_active_card)

        # Test scenario 3: StripeUserShop with active card (should allow checkout)
        stripe_user_shop.active_card_id = "card_test123"

        requires_billing = cart.requires_payment and stripe_user_shop is None
        requires_active_card = (
            stripe_user_shop and stripe_user_shop.active_card_id is None
        )

        self.assertFalse(requires_billing)
        self.assertFalse(requires_active_card)

        # Test the return value for template (using active_card_id since active_card would call Stripe)
        active_card_id = stripe_user_shop.active_card_id if stripe_user_shop else None
        self.assertEqual(active_card_id, "card_test123")

        transaction.commit()

    def test_cart_detailed_properties_integration(self):
        """Test cart properties that require real database access for 100% coverage."""
        # Create real user and shop
        user = get_or_create_user_by_email(self.dbsession, "test@example.com")
        shop = Shop(
            name="Detail Test Shop",
            phone_number="555-555-5555",
            billing_address="123 Test St",
            description="A test shop",
        )
        shop.stripe_public_api_key = "pk_test_123"
        shop.stripe_secret_api_key = "sk_test_123"
        shop.domain_name = "test.com"
        self.dbsession.add(user)
        self.dbsession.add(shop)

        # Create multiple products for different shops
        product1 = Product(title="Product 1", description="Test product 1")
        product1.shop_id = shop.id
        product1.price_in_cents = 1000  # $10.00
        product1.is_physical = False

        product2 = Product(title="Product 2", description="Test product 2")
        product2.shop_id = shop.id
        product2.price_in_cents = 1500  # $15.00
        product2.is_physical = False

        self.dbsession.add(product1)
        self.dbsession.add(product2)
        self.dbsession.flush()

        # Create real cart
        cart = Cart(user=user)
        cart.shop = shop
        self.dbsession.add(cart)
        self.dbsession.flush()

        # Add products to cart
        cart.add_product(product1)
        cart.add_product(product1)  # Add twice for quantity 2
        cart.add_product(product2)

        # Test detailed properties that require database access
        # These should exercise the missing lines in the coverage report

        # Test shops property (aggregates unique shops from products)
        shops = cart.shops
        self.assertIn(str(shop.id), shops)
        self.assertEqual(shops[str(shop.id)], shop)

        # Test shop_product_dict property
        shop_product_dict = cart.shop_product_dict
        self.assertIn(str(shop.id), shop_product_dict)
        shop_products = shop_product_dict[str(shop.id)]
        self.assertEqual(len(shop_products), 2)  # Two different products

        # Test shop_totals_in_cents property
        shop_totals_cents = cart.shop_totals_in_cents
        self.assertIn(str(shop.id), shop_totals_cents)
        # Just verify the calculation is working, don't hardcode expected values
        self.assertGreater(shop_totals_cents[str(shop.id)], 0)

        # Test shop_totals property (dollars)
        shop_totals = cart.shop_totals
        # Verify conversion from cents to dollars is working
        self.assertGreater(shop_totals[str(shop.id)], 0)

        # Test line_totals property
        line_totals = cart.line_totals
        # Verify line totals exist for both products
        self.assertIn(str(product1.id), line_totals)
        self.assertIn(str(product2.id), line_totals)
        self.assertGreater(line_totals[str(product1.id)], 0)
        self.assertGreater(line_totals[str(product2.id)], 0)

        # Test human timestamp properties
        created_timestamp = cart.human_created_timestamp
        updated_timestamp = cart.human_updated_timestamp
        self.assertIsInstance(created_timestamp, str)
        self.assertIsInstance(updated_timestamp, str)

        transaction.commit()

    def test_cart_coupon_validation_complete_integration(self):
        """Test all coupon validation scenarios with real objects for 100% coverage."""
        # Create real user and shop
        user = get_or_create_user_by_email(self.dbsession, "coupon_test@example.com")
        shop = Shop(
            name="Coupon Test Shop",
            phone_number="555-555-5555",
            billing_address="123 Test St",
            description="A test shop",
        )
        self.dbsession.add(user)
        self.dbsession.add(shop)
        self.dbsession.flush()

        # Create a product
        product = Product(title="Test Product", description="Test product")
        product.shop_id = shop.id
        product.price_in_cents = 2000  # $20.00
        product.is_physical = False
        self.dbsession.add(product)
        self.dbsession.flush()

        # Test 1: Multiple coupons (coupon stacking)
        cart1 = Cart(user=user)
        cart1.shop = shop
        self.dbsession.add(cart1)
        cart1.add_product(product)

        # Create two coupons
        coupon1 = Coupon(
            shop=shop,
            code="COUPON1",
            description="First coupon",
            action_type="dollar-off",
            action_value=5,  # $5.00 off
        )
        coupon2 = Coupon(
            shop=shop,
            code="COUPON2",
            description="Second coupon",
            action_type="dollar-off",
            action_value=3,  # $3.00 off
        )
        self.dbsession.add(coupon1)
        self.dbsession.add(coupon2)
        self.dbsession.flush()

        # Add both coupons to cart to trigger stacking error
        from make_post_sell.models.cart_coupon import CartCoupon

        cart_coupon1 = CartCoupon(cart=cart1, coupon=coupon1)
        cart_coupon2 = CartCoupon(cart=cart1, coupon=coupon2)
        self.dbsession.add(cart_coupon1)
        self.dbsession.add(cart_coupon2)
        self.dbsession.flush()

        # Test coupon stacking validation
        errors = cart1.validate_attached_coupons()
        self.assertIn(
            "We don't support coupon stacking. Please choose one coupon.", errors
        )

        # Test 2: Invalid coupon (expired/disabled)
        cart2 = Cart(user=user)
        cart2.shop = shop
        self.dbsession.add(cart2)
        cart2.add_product(product)

        expired_coupon = Coupon(
            shop=shop,
            code="EXPIRED",
            description="Expired coupon",
            action_type="dollar-off",
            action_value=5,
            expiration_date="2020-01-01",  # Past date
        )
        self.dbsession.add(expired_coupon)
        self.dbsession.flush()

        cart_coupon3 = CartCoupon(cart=cart2, coupon=expired_coupon)
        self.dbsession.add(cart_coupon3)
        self.dbsession.flush()

        errors = cart2.validate_attached_coupons()
        self.assertIn(
            "Sorry, the coupon 'EXPIRED' is not valid (expired or disabled).", errors
        )

        # Test 3: Cart total doesn't meet qualifier
        cart3 = Cart(user=user)
        cart3.shop = shop
        self.dbsession.add(cart3)

        # Create cheap product
        cheap_product = Product(title="Cheap Product", description="Cheap")
        cheap_product.shop_id = shop.id
        cheap_product.price_in_cents = 300  # $3.00
        cheap_product.is_physical = False
        self.dbsession.add(cheap_product)
        self.dbsession.flush()

        cart3.add_product(cheap_product)

        # Coupon requires minimum $10 but cart only has $3
        min_coupon = Coupon(
            shop=shop,
            code="MIN10",
            description="Minimum $10 coupon",
            action_type="dollar-off",
            action_value=2,
            cart_qualifier=10,  # Requires $10.00 minimum
        )
        self.dbsession.add(min_coupon)
        self.dbsession.flush()

        cart_coupon4 = CartCoupon(cart=cart3, coupon=min_coupon)
        self.dbsession.add(cart_coupon4)
        self.dbsession.flush()

        errors = cart3.validate_attached_coupons()
        self.assertIn(
            "Please review the terms for coupon 'MIN10': shop total not met.", errors
        )

        transaction.commit()

    def test_cart_merge_with_coupon_integration(self):
        """Test cart merging with coupons to cover coupon append logic."""
        # Create real user and shop
        user = get_or_create_user_by_email(self.dbsession, "merge_test@example.com")
        shop = Shop(
            name="Merge Test Shop",
            phone_number="555-555-5555",
            billing_address="123 Test St",
            description="A test shop",
        )
        self.dbsession.add(user)
        self.dbsession.add(shop)
        self.dbsession.flush()

        # Create products
        product1 = Product(title="Product 1", description="Test product 1")
        product1.shop_id = shop.id
        product1.price_in_cents = 1000
        product1.is_physical = False

        product2 = Product(title="Product 2", description="Test product 2")
        product2.shop_id = shop.id
        product2.price_in_cents = 1500
        product2.is_physical = False

        self.dbsession.add(product1)
        self.dbsession.add(product2)
        self.dbsession.flush()

        # Create carts
        cart1 = Cart(user=user)
        cart1.shop = shop
        cart2 = Cart(user=user)
        cart2.shop = shop
        self.dbsession.add(cart1)
        self.dbsession.add(cart2)
        self.dbsession.flush()

        # Add products to carts
        cart1.add_product(product1)
        cart2.add_product(product2)

        # Create coupons
        coupon1 = Coupon(
            shop=shop,
            code="CART1COUPON",
            description="Cart 1 coupon",
            action_type="dollar-off",
            action_value=2,
        )
        coupon2 = Coupon(
            shop=shop,
            code="CART2COUPON",
            description="Cart 2 coupon",
            action_type="dollar-off",
            action_value=3,
        )
        coupon_shared = Coupon(
            shop=shop,
            code="SHARED",
            description="Shared coupon",
            action_type="dollar-off",
            action_value=1,
        )
        self.dbsession.add(coupon1)
        self.dbsession.add(coupon2)
        self.dbsession.add(coupon_shared)
        self.dbsession.flush()

        # Add coupons to carts
        from make_post_sell.models.cart_coupon import CartCoupon

        # Cart1 has coupon1 and shared coupon
        cart_coupon1 = CartCoupon(cart=cart1, coupon=coupon1)
        cart_coupon_shared1 = CartCoupon(cart=cart1, coupon=coupon_shared)
        self.dbsession.add(cart_coupon1)
        self.dbsession.add(cart_coupon_shared1)

        # Cart2 has coupon2 and the same shared coupon
        cart_coupon2 = CartCoupon(cart=cart2, coupon=coupon2)
        cart_coupon_shared2 = CartCoupon(cart=cart2, coupon=coupon_shared)
        self.dbsession.add(cart_coupon2)
        self.dbsession.add(cart_coupon_shared2)
        self.dbsession.flush()

        # Count original coupons
        original_cart1_coupons = len(cart1.coupons)
        original_cart2_coupons = len(cart2.coupons)

        # Merge cart2 into cart1 - this should trigger the coupon merge logic
        cart1.merge_in_cart(cart2)

        # Verify products merged
        cart_data = cart1.get_cart()
        self.assertIn(str(product1.id), cart_data)
        self.assertIn(str(product2.id), cart_data)

        # Verify coupons merged (should not duplicate shared coupon)
        # This exercises lines 152-153 in merge_in_cart
        merged_coupon_codes = [c.code for c in cart1.coupons]
        self.assertIn("CART1COUPON", merged_coupon_codes)
        self.assertIn("CART2COUPON", merged_coupon_codes)
        self.assertIn("SHARED", merged_coupon_codes)

        # Should not have duplicated the shared coupon
        shared_count = merged_coupon_codes.count("SHARED")
        self.assertEqual(shared_count, 1)

        transaction.commit()

    def test_cart_final_coverage_lines_integration(self):
        """Test remaining lines to achieve 100% coverage."""
        # Create real user and shop
        user = get_or_create_user_by_email(self.dbsession, "inventory_test@example.com")
        shop = Shop(
            name="Inventory Test Shop",
            phone_number="555-555-5555",
            billing_address="123 Test St",
            description="A test shop",
        )
        self.dbsession.add(user)
        self.dbsession.add(shop)

        # Create shop location for inventory management
        from make_post_sell.models.shop_location import ShopLocation

        shop_location = ShopLocation(
            shop=shop,
            name="Main Location",
            address="123 Main St",
            city="Test City",
            state="Test State",
            country="USA",
            postal_code="12345",
        )
        self.dbsession.add(shop_location)
        self.dbsession.flush()

        # Create physical products for inventory testing
        physical_product = Product(
            title="Physical Product", description="Physical item"
        )
        physical_product.shop_id = shop.id
        physical_product.price_in_cents = 1000  # $10.00
        physical_product.is_physical = True

        self.dbsession.add(physical_product)
        self.dbsession.flush()

        # Create inventory for the physical product
        from make_post_sell.models.inventory import Inventory

        inventory = Inventory(
            product=physical_product,
            shop_location=shop_location,
            quantity=5,  # 5 items in stock
        )
        self.dbsession.add(inventory)
        self.dbsession.flush()

        # Create cart with physical products
        cart = Cart(user=user)
        cart.shop = shop
        self.dbsession.add(cart)
        self.dbsession.flush()

        # Add 3 physical products to cart
        cart.add_product(physical_product)
        cart.add_product(physical_product)
        cart.add_product(physical_product)

        # Test inventory check - should pass (3 needed, 5 available)
        errors = cart.check_inventory(shop_location)
        self.assertEqual(len(errors), 0)  # No errors

        # Add more products to exceed inventory
        cart.add_product(physical_product)  # 4 total
        cart.add_product(physical_product)  # 5 total
        cart.add_product(physical_product)  # 6 total - exceeds inventory

        # Test inventory check - should fail (6 needed, 5 available)
        errors = cart.check_inventory(shop_location)
        self.assertEqual(len(errors), 1)
        self.assertIn("Not enough stock for Physical Product", errors[0])
        self.assertIn("Needed: 6, Available: 5", errors[0])

        # Test inventory update (reduce quantity back to 3 for successful update)
        cart.set_product_quantity(physical_product, 3)

        # Update inventory - should reduce quantity from 5 to 2
        cart.update_inventory(shop_location)

        # Verify inventory was reduced
        self.dbsession.refresh(inventory)
        self.assertEqual(inventory.quantity, 2)  # 5 - 3 = 2

        # Test physical product identification
        physical_products = cart.physical_products
        self.assertIn(str(physical_product.id), physical_products)
        self.assertEqual(physical_products[str(physical_product.id)], physical_product)

        # Test handling cost updates for all methods
        cart.handling_option = "local_pickup"
        cart.update_handling_cost(shop_location)
        self.assertEqual(cart.handling_cost_in_cents, 0)  # Pickup is free

        # Set rates on shop location for testing
        shop_location.local_delivery_rate_in_cents = 500
        shop_location.local_shipping_rate_in_cents = 750
        shop_location.international_shipping_rate_in_cents = 1500
        self.dbsession.add(shop_location)
        self.dbsession.flush()

        cart.handling_option = "local_delivery"
        cart.update_handling_cost(shop_location)
        self.assertEqual(cart.handling_cost_in_cents, 500)

        cart.handling_option = "local_shipping"
        cart.update_handling_cost(shop_location)
        self.assertEqual(cart.handling_cost_in_cents, 750)

        cart.handling_option = "international_shipping"
        cart.update_handling_cost(shop_location)
        self.assertEqual(cart.handling_cost_in_cents, 1500)

        # Test remove handling if no physical products
        # First add a digital product and remove the physical one
        digital_product = Product(title="Digital Product", description="Digital item")
        digital_product.shop_id = shop.id
        digital_product.price_in_cents = 500
        digital_product.is_physical = False
        self.dbsession.add(digital_product)
        self.dbsession.flush()

        cart.add_product(digital_product)
        cart.remove_product(physical_product)  # Remove physical product

        # Now cart has only digital products
        cart.remove_handling_if_no_physical_products()
        self.assertIsNone(cart.handling_option)
        self.assertEqual(cart.handling_cost_in_cents, 0)

        # Test line 345-347: Cart.total property when discounted
        coupon = Coupon(
            shop=shop,
            code="DISCOUNT",
            description="Test discount",
            action_type="dollar-off",
            action_value=5,
        )
        self.dbsession.add(coupon)
        self.dbsession.flush()

        cart_with_discount = Cart(user=user)
        cart_with_discount.shop = shop
        self.dbsession.add(cart_with_discount)
        cart_with_discount.add_product(physical_product)

        from make_post_sell.models.cart_coupon import CartCoupon

        cart_coupon = CartCoupon(cart=cart_with_discount, coupon=coupon)
        self.dbsession.add(cart_coupon)
        self.dbsession.flush()

        # This should exercise line 345-347: if self.is_discounted return discounted_price
        discounted_total = cart_with_discount.total
        self.assertTrue(cart_with_discount.is_discounted)
        self.assertEqual(discounted_total, cart_with_discount.total_discounted_price)

        # Test line 374: Cart.is_empty property
        empty_cart = Cart(user=user)
        empty_cart.shop = shop
        self.dbsession.add(empty_cart)
        self.assertTrue(
            empty_cart.is_empty
        )  # This tests line 374: return self.count <= 0

        # Test lines 408 and 416: Coupon validation max redemptions
        # Create coupon with max redemptions
        max_coupon = Coupon(
            shop=shop,
            code="MAXED",
            description="Max redemptions coupon",
            action_type="dollar-off",
            action_value=1,
            max_redemptions=1,
            max_redemptions_per_user=1,
        )
        self.dbsession.add(max_coupon)
        self.dbsession.flush()

        # Create a redemption to test max_redemptions logic
        from make_post_sell.models.coupon_redemption import CouponRedemption
        from make_post_sell.models.invoice import Invoice

        # Create invoice for redemption
        invoice = Invoice(user)
        invoice.shop = shop
        self.dbsession.add(invoice)
        self.dbsession.flush()

        redemption = CouponRedemption(
            coupon=max_coupon, invoice=invoice, shop=shop, user=user
        )
        self.dbsession.add(redemption)
        self.dbsession.flush()

        cart_max_test = Cart(user=user)
        cart_max_test.shop = shop
        self.dbsession.add(cart_max_test)
        cart_max_test.add_product(physical_product)

        cart_coupon_max = CartCoupon(cart=cart_max_test, coupon=max_coupon)
        self.dbsession.add(cart_coupon_max)
        self.dbsession.flush()

        # This should exercise lines 408 and 416
        errors = cart_max_test.validate_attached_coupons()
        self.assertTrue(len(errors) >= 1)  # Should have errors for max redemptions

        # Test lines 426-436: Inventory check with no inventory record
        # Create a product without inventory
        product_no_inventory = Product(
            title="No Inventory Product", description="No inventory"
        )
        product_no_inventory.shop_id = shop.id
        product_no_inventory.price_in_cents = 500
        product_no_inventory.is_physical = True
        self.dbsession.add(product_no_inventory)
        self.dbsession.flush()

        cart_no_inventory = Cart(user=user)
        cart_no_inventory.shop = shop
        self.dbsession.add(cart_no_inventory)
        cart_no_inventory.add_product(product_no_inventory)

        # This should exercise lines 432-435: if inventory is None or insufficient
        inventory_errors = cart_no_inventory.check_inventory(shop_location)
        self.assertEqual(len(inventory_errors), 1)
        self.assertIn("Not enough stock", inventory_errors[0])
        self.assertIn("Available: 0", inventory_errors[0])

        # Test lines 466-478: update_inventory with no inventory records
        # This should exercise the case where inventory is None (lines 472-475 won't run)
        cart_no_inventory.update_inventory(shop_location)
        # No inventory record means no changes made

        transaction.commit()

    def test_cart_functions_and_remaining_lines(self):
        """Test the module-level functions to complete 100% coverage."""
        # Create test data
        user = get_or_create_user_by_email(self.dbsession, "functions_test@example.com")
        shop = Shop(
            name="Functions Test Shop",
            phone_number="555-555-5555",
            billing_address="123 Test St",
            description="A test shop",
        )
        self.dbsession.add(user)
        self.dbsession.add(shop)

        cart1 = Cart(user=user)
        cart1.shop = shop
        cart2 = Cart(user=user)
        cart2.shop = shop
        self.dbsession.add(cart1)
        self.dbsession.add(cart2)
        self.dbsession.flush()

        # Test line 486: get_all_carts function
        from make_post_sell.models.cart import get_all_carts

        all_carts = get_all_carts(self.dbsession)
        cart_ids = [str(c.id) for c in all_carts]
        self.assertIn(str(cart1.id), cart_ids)
        self.assertIn(str(cart2.id), cart_ids)

        # Test line 491: get_cart_by_id function
        from make_post_sell.models.cart import get_cart_by_id

        retrieved_cart = get_cart_by_id(self.dbsession, cart1.id)
        self.assertEqual(retrieved_cart.id, cart1.id)

        # Function tests completed

        transaction.commit()


class TestInvoiceDiscountIntegration(DatabaseIntegrationTests):
    """Integration tests for Invoice discount calculations with real coupons."""

    def test_invoice_discount_calculation_with_coupon_integration(self):
        """Test invoice discount calculations with real coupon redemptions."""
        # Create real user and shop
        user = get_or_create_user_by_email(self.dbsession, "invoice_test@example.com")
        shop = Shop(
            name="Invoice Test Shop",
            phone_number="555-555-5555",
            billing_address="123 Test St",
            description="A test shop for invoice calculations",
        )
        shop.stripe_public_api_key = "pk_test_123"
        shop.stripe_secret_api_key = "sk_test_123"
        shop.domain_name = "test.com"
        self.dbsession.add(user)
        self.dbsession.add(shop)

        # Create real products
        product1 = Product(
            title="Invoice Product 1", description="Test product for invoice"
        )
        product1.shop = shop
        product1.is_physical = False

        product2 = Product(
            title="Invoice Product 2", description="Another test product for invoice"
        )
        product2.shop = shop
        product2.is_physical = False

        self.dbsession.add(product1)
        self.dbsession.add(product2)
        self.dbsession.flush()

        # Create prices for products
        price1 = Price(product1, 500)  # $5.00
        price2 = Price(product2, 300)  # $3.00
        self.dbsession.add(price1)
        self.dbsession.add(price2)
        self.dbsession.flush()

        # Create real invoice
        invoice = Invoice(user)
        invoice.shop = shop
        invoice.shop_id = shop.id
        invoice.handling_cost_in_cents = 300  # $3.00 handling
        self.dbsession.add(invoice)
        self.dbsession.flush()

        # Add line items to invoice
        line_item1 = InvoiceLineItem(
            invoice=invoice, product=product1, quantity=2  # 2x $20 = $40
        )
        line_item2 = InvoiceLineItem(
            invoice=invoice, product=product2, quantity=1  # 1x $15 = $15
        )
        self.dbsession.add(line_item1)
        self.dbsession.add(line_item2)

        # Test subtotal calculation without discounts
        expected_subtotal = (500 * 2) + (300 * 1)  # $10 + $3 = $13
        self.assertEqual(invoice.subtotal_in_cents, expected_subtotal)

        # Test discount calculation without coupons
        self.assertEqual(invoice.discount_amount_in_cents, 0)

        # Test total calculation without discounts
        expected_total = expected_subtotal + 300  # $13 + $3 handling = $16
        self.assertEqual(invoice.total_in_cents, expected_total)

        # Create real coupon for discount testing
        coupon = Coupon(
            shop=shop,
            code="INVOICE5",
            description="$5 off invoice",
            action_type="dollar-off",
            action_value=5,  # $5.00 off
            max_redemptions=100,
            max_redemptions_per_user=1,
            cart_qualifier=10,  # Minimum $10.00 (our invoice is $13)
        )
        self.dbsession.add(coupon)
        self.dbsession.flush()

        # Add coupon redemption to invoice
        coupon_redemption = CouponRedemption(
            coupon=coupon, invoice=invoice, shop=shop, user=user
        )
        self.dbsession.add(coupon_redemption)
        self.dbsession.flush()

        # Test discount calculation with coupon
        self.assertEqual(invoice.discount_amount_in_cents, 500)  # $5.00 discount

        # Test total calculation with discount
        expected_discounted_total = expected_subtotal - 500 + 300  # $13 - $5 + $3 = $11
        self.assertEqual(invoice.total_in_cents, expected_discounted_total)

        # Test requires_payment logic with discounted amount
        self.assertTrue(invoice.requires_payment)  # $11 > $0.64 threshold

        transaction.commit()

    def test_invoice_free_with_coupon_no_payment_required(self):
        """Test invoice that becomes free with coupon - no payment required scenario."""
        # Create real user and shop
        user = get_or_create_user_by_email(self.dbsession, "free_invoice@example.com")
        shop = Shop(
            name="Free Invoice Shop",
            phone_number="555-555-5555",
            billing_address="123 Test St",
            description="Shop for free invoice testing",
        )
        shop.stripe_public_api_key = "pk_test_123"
        shop.stripe_secret_api_key = "sk_test_123"
        shop.domain_name = "test.com"
        self.dbsession.add(user)
        self.dbsession.add(shop)

        # Create a small product
        product = Product(title="Small Product", description="A small, cheap product")
        product.shop = shop
        product.is_physical = False
        self.dbsession.add(product)
        self.dbsession.flush()

        # Create price for product
        price = Price(product, 350)  # $3.50
        self.dbsession.add(price)
        self.dbsession.flush()

        # Create invoice
        invoice = Invoice(user)
        invoice.shop = shop
        invoice.shop_id = shop.id
        invoice.handling_cost_in_cents = 0  # No handling
        self.dbsession.add(invoice)
        self.dbsession.flush()

        # Add line item
        line_item = InvoiceLineItem(invoice=invoice, product=product, quantity=1)
        self.dbsession.add(line_item)

        # Create coupon that makes invoice free
        free_coupon = Coupon(
            shop=shop,
            code="FREEINVOICE",
            description="Make invoice free",
            action_type="dollar-off",
            action_value=4,  # $4.00 off (more than $3.50 product)
            max_redemptions=100,
            max_redemptions_per_user=1,
            cart_qualifier=3,  # Minimum $3.00
        )
        self.dbsession.add(free_coupon)
        self.dbsession.flush()

        # Add coupon redemption
        coupon_redemption = CouponRedemption(
            coupon=free_coupon, invoice=invoice, shop=shop, user=user
        )
        self.dbsession.add(coupon_redemption)
        self.dbsession.flush()

        # Test calculations
        self.assertEqual(invoice.subtotal_in_cents, 350)  # $3.50
        self.assertEqual(
            invoice.discount_amount_in_cents, 350
        )  # $3.50 discount (limited by subtotal)
        self.assertEqual(invoice.total_in_cents, 0)  # Free! max(0, 350 - 350 + 0)

        # Test no payment required for free invoice
        self.assertFalse(invoice.requires_payment)  # $0 <= $0.64 threshold

        transaction.commit()

    def test_invoice_multiple_coupons_stacking_integration(self):
        """Test invoice with multiple coupon redemptions (if allowed)."""
        # Create real user and shop
        user = get_or_create_user_by_email(self.dbsession, "multi_coupon@example.com")
        shop = Shop(
            name="Multi Coupon Shop",
            phone_number="555-555-5555",
            billing_address="123 Test St",
            description="Shop for multiple coupon testing",
        )
        shop.stripe_public_api_key = "pk_test_123"
        shop.stripe_secret_api_key = "sk_test_123"
        shop.domain_name = "test.com"
        self.dbsession.add(user)
        self.dbsession.add(shop)

        # Create product
        product = Product(
            title="Multi Coupon Product",
            description="Product for testing multiple coupons",
        )
        product.shop = shop
        product.is_physical = False
        self.dbsession.add(product)
        self.dbsession.flush()

        # Create price for product
        price = Price(product, 1500)  # $15.00
        self.dbsession.add(price)
        self.dbsession.flush()

        # Create invoice
        invoice = Invoice(user)
        invoice.shop = shop
        invoice.shop_id = shop.id
        invoice.handling_cost_in_cents = 500  # $5.00 handling
        self.dbsession.add(invoice)
        self.dbsession.flush()

        # Add line item
        line_item = InvoiceLineItem(invoice=invoice, product=product, quantity=1)
        self.dbsession.add(line_item)

        # Create multiple coupons
        coupon1 = Coupon(
            shop=shop,
            code="FIRST5",
            description="First $5 off",
            action_type="dollar-off",
            action_value=5,  # $5.00 off
            max_redemptions=100,
            max_redemptions_per_user=1,
            cart_qualifier=0,
        )
        coupon2 = Coupon(
            shop=shop,
            code="SECOND3",
            description="Second $3 off",
            action_type="dollar-off",
            action_value=3,  # $3.00 off
            max_redemptions=100,
            max_redemptions_per_user=1,
            cart_qualifier=0,
        )
        self.dbsession.add(coupon1)
        self.dbsession.add(coupon2)
        self.dbsession.flush()

        # Add both coupon redemptions
        redemption1 = CouponRedemption(
            coupon=coupon1, invoice=invoice, shop=shop, user=user
        )
        redemption2 = CouponRedemption(
            coupon=coupon2, invoice=invoice, shop=shop, user=user
        )
        self.dbsession.add(redemption1)
        self.dbsession.add(redemption2)
        self.dbsession.flush()

        # Test stacked discount calculation
        # First coupon: $15 - $5 = $10
        # Second coupon: $10 - $3 = $7
        self.assertEqual(invoice.subtotal_in_cents, 1500)  # $15.00
        self.assertEqual(
            invoice.discount_amount_in_cents, 800
        )  # $5 + $3 = $8.00 total discount

        # Test final total: $15 - $8 + $5 handling = $12
        expected_total = 1500 - 800 + 500
        self.assertEqual(invoice.total_in_cents, expected_total)

        transaction.commit()

    def test_invoice_invalid_coupon_no_discount_integration(self):
        """Test invoice with invalid coupon redemption - should not apply discount."""
        # Create real user and shop
        user = get_or_create_user_by_email(self.dbsession, "invalid_coupon@example.com")
        shop = Shop(
            name="Invalid Coupon Shop",
            phone_number="555-555-5555",
            billing_address="123 Test St",
            description="Shop for invalid coupon testing",
        )
        shop.stripe_public_api_key = "pk_test_123"
        shop.stripe_secret_api_key = "sk_test_123"
        shop.domain_name = "test.com"
        self.dbsession.add(user)
        self.dbsession.add(shop)

        # Create product
        product = Product(
            title="Invalid Coupon Product",
            description="Product for testing invalid coupons",
        )
        product.shop = shop
        product.is_physical = False
        self.dbsession.add(product)
        self.dbsession.flush()

        # Create price for product
        price = Price(product, 800)  # $8.00
        self.dbsession.add(price)
        self.dbsession.flush()

        # Create invoice
        invoice = Invoice(user)
        invoice.shop = shop
        invoice.shop_id = shop.id
        self.dbsession.add(invoice)
        self.dbsession.flush()

        # Add line item
        line_item = InvoiceLineItem(invoice=invoice, product=product, quantity=1)
        self.dbsession.add(line_item)

        # Create expired coupon
        expired_coupon = Coupon(
            shop=shop,
            code="EXPIRED10",
            description="Expired $10 off",
            action_type="dollar-off",
            action_value=10,
            max_redemptions=100,
            max_redemptions_per_user=1,
            cart_qualifier=0,
            expiration_date="2020-01-01",  # Expired
        )
        self.dbsession.add(expired_coupon)
        self.dbsession.flush()

        # Add coupon redemption for expired coupon
        redemption = CouponRedemption(
            coupon=expired_coupon, invoice=invoice, shop=shop, user=user
        )
        self.dbsession.add(redemption)
        self.dbsession.flush()

        # Test that invalid coupon doesn't apply discount
        self.assertFalse(expired_coupon.is_valid)  # Coupon should be invalid
        self.assertEqual(
            invoice.discount_amount_in_cents, 0
        )  # No discount from invalid coupon
        self.assertEqual(invoice.total_in_cents, 800)  # Full price, no discount

        transaction.commit()

    def test_invoice_handling_cost_edge_cases_integration(self):
        """Test invoice total calculation with various handling cost scenarios."""
        # Create real user and shop
        user = get_or_create_user_by_email(self.dbsession, "handling_test@example.com")
        shop = Shop(
            name="Handling Test Shop",
            phone_number="555-555-5555",
            billing_address="123 Test St",
            description="Shop for handling cost testing",
        )
        self.dbsession.add(user)
        self.dbsession.add(shop)

        # Create product
        product = Product(
            title="Handling Product", description="Product for handling testing"
        )
        product.shop = shop
        product.is_physical = False
        self.dbsession.add(product)
        self.dbsession.flush()

        # Create price for product
        price = Price(product, 500)  # $5.00
        self.dbsession.add(price)
        self.dbsession.flush()

        # Test Case 1: No handling cost (None)
        invoice1 = Invoice(user)
        invoice1.shop = shop
        invoice1.handling_cost_in_cents = None
        self.dbsession.add(invoice1)
        self.dbsession.flush()

        line_item1 = InvoiceLineItem(invoice=invoice1, product=product, quantity=1)
        self.dbsession.add(line_item1)

        # Should treat None handling as 0
        self.assertEqual(invoice1.total_in_cents, 500)  # $5 + $0 handling

        # Test Case 2: Zero handling cost
        invoice2 = Invoice(user)
        invoice2.shop = shop
        invoice2.handling_cost_in_cents = 0
        self.dbsession.add(invoice2)
        self.dbsession.flush()

        line_item2 = InvoiceLineItem(invoice=invoice2, product=product, quantity=1)
        self.dbsession.add(line_item2)

        self.assertEqual(invoice2.total_in_cents, 500)  # $5 + $0 handling

        # Test Case 3: High handling cost
        invoice3 = Invoice(user)
        invoice3.shop = shop
        invoice3.handling_cost_in_cents = 800  # $8.00 handling
        self.dbsession.add(invoice3)
        self.dbsession.flush()

        line_item3 = InvoiceLineItem(invoice=invoice3, product=product, quantity=1)
        self.dbsession.add(line_item3)

        self.assertEqual(invoice3.total_in_cents, 1300)  # $5 + $8 handling

        transaction.commit()

    def test_invoice_negative_total_protection_integration(self):
        """Test that invoice total never goes negative with large discounts."""
        # Create real user and shop
        user = get_or_create_user_by_email(self.dbsession, "negative_test@example.com")
        shop = Shop(
            name="Negative Test Shop",
            phone_number="555-555-5555",
            billing_address="123 Test St",
            description="Shop for negative total testing",
        )
        self.dbsession.add(user)
        self.dbsession.add(shop)

        # Create small product
        product = Product(title="Small Product", description="Very small product")
        product.shop = shop
        product.is_physical = False
        self.dbsession.add(product)
        self.dbsession.flush()

        # Create price for product
        price = Price(product, 300)  # $3.00
        self.dbsession.add(price)
        self.dbsession.flush()

        # Create invoice
        invoice = Invoice(user)
        invoice.shop = shop
        invoice.handling_cost_in_cents = 100  # $1.00 handling
        self.dbsession.add(invoice)
        self.dbsession.flush()

        # Add line item
        line_item = InvoiceLineItem(invoice=invoice, product=product, quantity=1)
        self.dbsession.add(line_item)

        # Create huge discount coupon
        huge_coupon = Coupon(
            shop=shop,
            code="HUGE20",
            description="Huge $20 off",
            action_type="dollar-off",
            action_value=20,  # $20.00 off (way more than $3.00 product)
            max_redemptions=100,
            max_redemptions_per_user=1,
            cart_qualifier=0,
        )
        self.dbsession.add(huge_coupon)
        self.dbsession.flush()

        # Add coupon redemption
        redemption = CouponRedemption(
            coupon=huge_coupon, invoice=invoice, shop=shop, user=user
        )
        self.dbsession.add(redemption)
        self.dbsession.flush()

        # Test that total never goes negative
        # Subtotal: $3.00, Discount: $3.00 (limited), Handling: $1.00
        # Total should be max(0, 300 - 300 + 100) = max(0, 100) = 100
        self.assertEqual(
            invoice.discount_amount_in_cents, 300
        )  # Only $3.00 discount applied
        self.assertEqual(invoice.total_in_cents, 100)  # $1.00 (never negative)

        # Test requires_payment with handling cost
        self.assertTrue(invoice.requires_payment)  # $1.00 > $0.64 threshold

        transaction.commit()


class TestUserCryptoRefundAddressIntegration(DatabaseIntegrationTests):
    def setUp(self):
        super(TestUserCryptoRefundAddressIntegration, self).setUp()

    def test_create_and_retrieve_crypto_refund_address(self):
        """Test creating and retrieving crypto refund addresses in database."""
        from ..models.user_crypto_refund_address import (
            UserCryptoRefundAddress,
            get_user_crypto_refund_address,
        )
        from ..models.shop import Shop

        # Create user
        user = User("crypto@test.com")
        self.dbsession.add(user)
        self.dbsession.flush()

        # Create shop
        shop = Shop(
            name="TestShop",
            phone_number="123-456-7890",
            billing_address="123 Test St",
            description="Test Shop",
        )
        self.dbsession.add(shop)
        self.dbsession.flush()

        # Create crypto refund address
        addr = UserCryptoRefundAddress(
            user=user,
            shop=shop,
            coin_type="XMR",
            address="44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A",
            label="Test Wallet",
        )
        self.dbsession.add(addr)
        self.dbsession.flush()

        # Retrieve address
        retrieved = get_user_crypto_refund_address(self.dbsession, user, shop, "XMR")
        self.assertIsNotNone(retrieved)
        self.assertEqual(
            retrieved.address,
            "44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A",
        )
        self.assertEqual(retrieved.label, "Test Wallet")

        # Test case insensitive coin type lookup
        retrieved2 = get_user_crypto_refund_address(self.dbsession, user, shop, "xmr")
        self.assertEqual(retrieved.id, retrieved2.id)

        # Test non-existent coin type
        none_result = get_user_crypto_refund_address(self.dbsession, user, shop, "BTC")
        self.assertIsNone(none_result)

        transaction.commit()

    def test_unique_constraint_user_coin_type(self):
        """Test that user can only have one address per coin type per shop."""
        from ..models.user_crypto_refund_address import UserCryptoRefundAddress
        from ..models.shop import Shop

        # Create user
        user = User("unique@test.com")
        self.dbsession.add(user)
        self.dbsession.flush()

        # Create shop
        shop = Shop(
            name="TestShop",
            phone_number="123-456-7890",
            billing_address="123 Test St",
            description="Test Shop",
        )
        self.dbsession.add(shop)
        self.dbsession.flush()

        # Create first address
        addr1 = UserCryptoRefundAddress(
            user=user,
            shop=shop,
            coin_type="XMR",
            address="44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A",
        )
        self.dbsession.add(addr1)
        self.dbsession.flush()

        # Try to create duplicate (same user, same shop, same coin type)
        addr2 = UserCryptoRefundAddress(
            user=user,
            shop=shop,
            coin_type="XMR",
            address="888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H",
        )
        self.dbsession.add(addr2)

        # Should raise integrity error
        from sqlalchemy.exc import IntegrityError

        with self.assertRaises(IntegrityError):
            self.dbsession.flush()

        transaction.abort()

    def test_multiple_users_same_coin_type(self):
        """Test that different users can have addresses for same coin type."""
        from ..models.user_crypto_refund_address import UserCryptoRefundAddress
        from ..models.shop import Shop

        # Create two users individually
        user1 = User("user1@test.com")
        self.dbsession.add(user1)
        self.dbsession.flush()

        user2 = User("user2@test.com")
        self.dbsession.add(user2)
        self.dbsession.flush()

        # Create shop
        shop = Shop(
            name="TestShop",
            phone_number="123-456-7890",
            billing_address="123 Test St",
            description="Test Shop",
        )
        self.dbsession.add(shop)
        self.dbsession.flush()

        # Create addresses for same coin type
        addr1 = UserCryptoRefundAddress(
            user=user1,
            shop=shop,
            coin_type="XMR",
            address="44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A",
        )
        addr2 = UserCryptoRefundAddress(
            user=user2,
            shop=shop,
            coin_type="XMR",
            address="888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H",
        )

        self.dbsession.add_all([addr1, addr2])
        self.dbsession.flush()  # Should not raise error

        transaction.commit()

    def test_user_can_have_multiple_coin_addresses(self):
        """Test that one user can have addresses for multiple coin types."""
        from ..models.user_crypto_refund_address import UserCryptoRefundAddress
        from ..models.shop import Shop

        # Create user
        user = User("multicoin@test.com")
        self.dbsession.add(user)
        self.dbsession.flush()

        # Create shop
        shop = Shop(
            name="TestShop",
            phone_number="123-456-7890",
            billing_address="123 Test St",
            description="Test Shop",
        )
        self.dbsession.add(shop)
        self.dbsession.flush()

        # Create addresses for different coin types
        xmr_addr = UserCryptoRefundAddress(
            user=user,
            shop=shop,
            coin_type="XMR",
            address="44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A",
            label="Monero Wallet",
        )
        btc_addr = UserCryptoRefundAddress(
            user=user,
            shop=shop,
            coin_type="BTC",
            address="1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa",
            label="Bitcoin Wallet",
        )
        doge_addr = UserCryptoRefundAddress(
            user=user,
            shop=shop,
            coin_type="DOGE",
            address="DH5yaieqoZN36fDVciNyRueRGvGLR3mr7L",
            label="Doge Wallet",
        )

        self.dbsession.add_all([xmr_addr, btc_addr, doge_addr])
        self.dbsession.flush()

        # Verify all addresses exist
        self.assertEqual(len(user.crypto_refund_addresses), 3)

        transaction.commit()


class TestCryptoPaymentWithRefundAddress(DatabaseIntegrationTests):
    """Test that crypto payments correctly use saved refund addresses."""

    def setUp(self):
        super(TestCryptoPaymentWithRefundAddress, self).setUp()

    def test_crypto_payment_with_saved_refund_address_integration(self):
        """Test that get_user_crypto_refund_address correctly retrieves address for payments."""
        from ..models.user_crypto_refund_address import (
            UserCryptoRefundAddress,
            get_user_crypto_refund_address,
        )
        from ..models.shop import Shop

        # Create user
        user = User("cryptouser@test.com")
        self.dbsession.add(user)

        # Create shop
        shop = Shop(
            name="TestShop",
            phone_number="123-456-7890",
            billing_address="123 Test St",
            description="Test Shop",
        )
        self.dbsession.add(shop)
        self.dbsession.flush()

        # Save user's refund address
        refund_addr = UserCryptoRefundAddress(
            user=user,
            shop=shop,
            coin_type="XMR",
            address="888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H",
            label="My Refund Wallet",
        )
        self.dbsession.add(refund_addr)
        self.dbsession.flush()

        # Test the logic that would be used in crypto payment view
        user_refund_addr_obj = get_user_crypto_refund_address(
            self.dbsession, user, shop, "XMR"
        )
        user_refund_address = (
            user_refund_addr_obj.address if user_refund_addr_obj else None
        )

        # Verify the address is retrieved correctly
        self.assertEqual(
            user_refund_address,
            "888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H",
        )

        transaction.commit()

    def test_crypto_payment_without_saved_refund_address_integration(self):
        """Test that payments work when user has no saved refund address."""
        from ..models.user_crypto_refund_address import get_user_crypto_refund_address
        from ..models.shop import Shop

        # Create user with no refund address
        user = User("norefund@test.com")
        self.dbsession.add(user)

        # Create shop
        shop = Shop(
            name="TestShop",
            phone_number="123-456-7890",
            billing_address="123 Test St",
            description="Test Shop",
        )
        self.dbsession.add(shop)
        self.dbsession.flush()

        # Test the logic that would be used in crypto payment view
        user_refund_addr_obj = get_user_crypto_refund_address(
            self.dbsession, user, shop, "XMR"
        )
        user_refund_address = (
            user_refund_addr_obj.address if user_refund_addr_obj else None
        )

        # Verify no address is returned
        self.assertIsNone(user_refund_address)

        transaction.commit()


class TestCryptoPaymentIntegration(DatabaseIntegrationTests):
    """Integration tests for crypto payment functionality."""

    def test_create_crypto_payment_for_invoice(self):
        """Test creating a crypto payment for an invoice with real database objects."""
        from ..models.crypto_payment import CryptoPayment
        from ..models.crypto_processor import CryptoProcessor
        from ..models.shop_location import ShopLocation
        import time

        # Create real user and shop
        user = get_or_create_user_by_email(self.dbsession, "crypto@example.com")
        shop = Shop(
            name="Crypto Test Shop",
            phone_number="555-9999",
            billing_address="123 Crypto St",
            description="Test shop for crypto",
        )
        self.dbsession.add(user)
        self.dbsession.add(shop)
        self.dbsession.flush()

        # Create shop location
        location = ShopLocation(
            shop=shop,
            name="Main",
            address="123 Test St",
            city="Test City",
            state="TS",
            country="Test Country",
            postal_code="12345",
        )
        self.dbsession.add(location)

        # Create crypto processor
        processor = CryptoProcessor(
            shop_id=shop.id, coin_type="XMR", sweep_to_address="test_sweep_address"
        )
        processor.enabled = True
        processor.wallet_label = "0"
        self.dbsession.add(processor)

        # Create product with price
        product = Product(
            title="Test Crypto Product", description="Product for crypto test"
        )
        product.shop_id = shop.id
        product.price_in_cents = 2000  # $20
        self.dbsession.add(product)
        self.dbsession.flush()

        price = Price(product, 2000)
        self.dbsession.add(price)

        # Create invoice
        invoice = Invoice(user)
        invoice.shop_id = shop.id
        invoice.new_line_item(product=product, quantity=1)
        self.dbsession.add(invoice)
        self.dbsession.flush()

        # Create crypto payment
        payment = CryptoPayment(
            invoice=invoice,
            user=user,
            shop=shop,
            address="test_xmr_address",
            account_index=0,
            subaddress_index=1,
            coin_type="XMR",
            expected_amount=133333333333,  # ~0.133 XMR at $150/XMR
            rate_locked_usd_per_coin=150.0,
            quote_expires_at_ms=int((time.time() + 900) * 1000),
            confirmations_required=1,
            shop_location=location,
        )
        payment.shop_sweep_to_address = processor.sweep_to_address
        self.dbsession.add(payment)
        self.dbsession.flush()

        # Verify payment was created correctly
        self.assertEqual(payment.status, "pending")
        self.assertEqual(payment.coin_type, "XMR")
        self.assertEqual(payment.invoice_id, invoice.id)
        self.assertEqual(payment.shop_location_id, location.id)
        self.assertIsNotNone(payment.quote_expires_at)

        transaction.commit()

    def test_crypto_payment_with_refund_address_integration(self):
        """Test crypto payment creation with user's saved refund address."""
        from ..models.crypto_payment import CryptoPayment
        from ..models.user_crypto_refund_address import UserCryptoRefundAddress
        from ..models.shop_location import ShopLocation
        import time

        # Create real user
        user = get_or_create_user_by_email(self.dbsession, "refund@example.com")
        self.dbsession.add(user)
        self.dbsession.flush()

        # Create shop first (moved up)
        shop = Shop(
            name="Refund Test Shop",
            phone_number="555-8888",
            billing_address="456 Refund Ave",
            description="Test shop for refunds",
        )
        self.dbsession.add(shop)
        self.dbsession.flush()

        # Create user's refund address
        refund_addr = UserCryptoRefundAddress(
            user=user,
            shop=shop,
            coin_type="XMR",
            address="user_refund_address_xmr",
            label="My Monero wallet",
        )
        self.dbsession.add(refund_addr)
        self.dbsession.flush()

        location = ShopLocation(
            shop=shop,
            name="Main",
            address="456 Test Ave",
            city="Test City",
            state="TS",
            country="Test Country",
            postal_code="12345",
        )
        self.dbsession.add(location)

        # Create invoice
        invoice = Invoice(user)
        invoice.shop_id = shop.id
        self.dbsession.add(invoice)
        self.dbsession.flush()

        # Create crypto payment with refund address
        payment = CryptoPayment(
            invoice=invoice,
            user=user,
            shop=shop,
            address="payment_address",
            account_index=0,
            subaddress_index=2,
            coin_type="XMR",
            expected_amount=100000000000,
            rate_locked_usd_per_coin=150.0,
            quote_expires_at_ms=int((time.time() + 900) * 1000),
            confirmations_required=1,
            shop_location=location,
        )
        payment.refund_address = refund_addr.address
        self.dbsession.add(payment)
        self.dbsession.flush()

        # Verify refund address was set
        self.assertEqual(payment.refund_address, "user_refund_address_xmr")

        transaction.commit()

    def test_crypto_payment_expiry_check_integration(self):
        """Test crypto payment expiry checking with real database objects."""
        from ..models.crypto_payment import CryptoPayment
        from ..models.shop_location import ShopLocation
        import time

        # Create basic test data
        user = get_or_create_user_by_email(self.dbsession, "expiry@example.com")
        shop = Shop(
            name="Expiry Test Shop",
            phone_number="555-7777",
            billing_address="789 Expiry Blvd",
            description="Test shop for expiry",
        )
        self.dbsession.add(user)
        self.dbsession.add(shop)
        self.dbsession.flush()

        location = ShopLocation(
            shop=shop,
            name="Main",
            address="789 Test Blvd",
            city="Test City",
            state="TS",
            country="Test Country",
            postal_code="12345",
        )
        self.dbsession.add(location)

        invoice = Invoice(user)
        invoice.shop_id = shop.id
        self.dbsession.add(invoice)
        self.dbsession.flush()

        # Create expired payment
        expired_payment = CryptoPayment(
            invoice=invoice,
            user=user,
            shop=shop,
            address="expired_address",
            account_index=0,
            subaddress_index=3,
            coin_type="XMR",
            expected_amount=100000000000,
            rate_locked_usd_per_coin=150.0,
            quote_expires_at_ms=int((time.time() - 100) * 1000),  # Expired 100s ago
            confirmations_required=1,
            shop_location=location,
        )
        self.dbsession.add(expired_payment)

        # Create valid payment
        valid_payment = CryptoPayment(
            invoice=invoice,
            user=user,
            shop=shop,
            address="valid_address",
            account_index=0,
            subaddress_index=4,
            coin_type="XMR",
            expected_amount=100000000000,
            rate_locked_usd_per_coin=150.0,
            quote_expires_at_ms=int((time.time() + 900) * 1000),  # Valid for 15 mins
            confirmations_required=1,
            shop_location=location,
        )
        self.dbsession.add(valid_payment)
        self.dbsession.flush()

        # Test expiry check
        self.assertTrue(expired_payment.is_expired)
        self.assertFalse(valid_payment.is_expired)

        transaction.commit()


class DogecoinPaymentIntegration(DatabaseIntegrationTests):
    """Integration tests for Dogecoin payment functionality."""

    def test_create_doge_crypto_payment_for_invoice(self):
        """Test creating a Dogecoin CryptoPayment linked to an Invoice."""
        user = get_or_create_user_by_email(self.dbsession, "dogetest@example.com")
        shop = Shop(
            name="Doge Shop",
            phone_number="555-DOGE-555",
            billing_address="123 Doge St",
            description="Much shop, very payments",
        )
        product = Product(
            title="Test Doge Product", description="Much product, very test"
        )
        product.shop_id = shop.id
        product.price_in_cents = 500  # $5.00
        price = Price(product, 500)  # $5.00
        location = ShopLocation(
            shop=shop,
            name="Test Location",
            address="123 Test St",
            city="Test City",
            state="TS",
            country="Test Country",
            postal_code="12345",
        )

        self.dbsession.add(user)
        self.dbsession.add(shop)
        self.dbsession.add(product)
        self.dbsession.add(price)
        self.dbsession.add(location)
        self.dbsession.flush()

        # Create invoice
        invoice = Invoice(user)
        invoice.shop = shop
        invoice.shop_id = shop.id
        invoice.new_line_item(product=product, quantity=1)
        self.dbsession.add(invoice)
        self.dbsession.flush()

        # Create Dogecoin payment
        doge_payment = CryptoPayment(
            invoice=invoice,
            user=user,
            shop=shop,
            address="DQA4nJFciD9EhtMTZXDJYH6pxTSYrn4wFp",  # Example DOGE address
            account_index=0,  # Not used for DOGE
            subaddress_index=0,  # Not used for DOGE
            coin_type="DOGE",
            expected_amount=1500000000,  # 15 DOGE in koinu (15 * 10^8)
            rate_locked_usd_per_coin=0.33333,  # $0.33 per DOGE
            quote_expires_at_ms=int((time.time() + 3600) * 1000),  # 1 hour
            confirmations_required=6,
            shop_location=location,
            shop_sweep_to_address="DColdWallet123456789",
        )
        self.dbsession.add(doge_payment)
        self.dbsession.flush()

        # Verify payment properties
        self.assertEqual(doge_payment.coin_type, "DOGE")
        self.assertEqual(doge_payment.expected_amount, 1500000000)
        self.assertEqual(doge_payment.address, "DQA4nJFciD9EhtMTZXDJYH6pxTSYrn4wFp")
        self.assertEqual(doge_payment.shop_sweep_to_address, "DColdWallet123456789")
        self.assertEqual(doge_payment.confirmations_required, 6)
        self.assertEqual(doge_payment.status, "pending")
        self.assertFalse(doge_payment.is_expired)
        self.assertEqual(doge_payment.due_amount, 1500000000)  # Full amount due

        transaction.commit()

    def test_doge_payment_with_refund_address_integration(self):
        """Test DOGE payment with user refund address configured."""
        user = get_or_create_user_by_email(self.dbsession, "dogerefund@example.com")
        shop = Shop(
            name="Refund Doge Shop",
            phone_number="555-REFUND-55",
            billing_address="123 Refund St",
            description="Such refunds, much wow",
        )
        product = Product(
            title="Refund Test Product", description="Test refund handling"
        )
        product.shop_id = shop.id
        product.price_in_cents = 1000  # $10.00
        price = Price(product, 1000)  # $10.00
        location = ShopLocation(
            shop=shop,
            name="Test Location",
            address="123 Test St",
            city="Test City",
            state="TS",
            country="Test Country",
            postal_code="12345",
        )

        self.dbsession.add(user)
        self.dbsession.add(shop)
        self.dbsession.add(product)
        self.dbsession.add(price)
        self.dbsession.add(location)
        self.dbsession.flush()

        # Create user DOGE refund address
        refund_address = UserCryptoRefundAddress(
            user=user,
            shop=shop,
            coin_type="DOGE",
            address="DRefund123456789ABCDEF",
            label="My Dogecoin Refund Wallet",
        )
        self.dbsession.add(refund_address)
        self.dbsession.flush()

        # Create invoice and payment
        invoice = Invoice(user)
        invoice.shop = shop
        invoice.new_line_item(product=product, quantity=1)
        self.dbsession.add(invoice)
        self.dbsession.flush()

        doge_payment = CryptoPayment(
            invoice=invoice,
            user=user,
            shop=shop,
            address="DPayment123456789",
            account_index=0,
            subaddress_index=0,
            coin_type="DOGE",
            expected_amount=3000000000,  # 30 DOGE in koinu
            rate_locked_usd_per_coin=0.33333,
            quote_expires_at_ms=int((time.time() + 3600) * 1000),
            confirmations_required=6,
            refund_address=refund_address.address,  # Link refund address
        )
        self.dbsession.add(doge_payment)
        self.dbsession.flush()

        # Verify refund address is linked
        self.assertEqual(doge_payment.refund_address, "DRefund123456789ABCDEF")
        self.assertEqual(doge_payment.coin_type, "DOGE")

        transaction.commit()

    def test_doge_payment_expiry_and_amounts(self):
        """Test Dogecoin payment expiry and amount calculations."""
        user = get_or_create_user_by_email(self.dbsession, "dogeexpiry@example.com")
        shop = Shop(
            name="Expiry Test Shop",
            phone_number="555-EXPIRY-55",
            billing_address="123 Expiry St",
            description="Testing expiry",
        )
        product = Product(title="Expiry Product", description="Test payment expiry")
        product.shop_id = shop.id
        product.price_in_cents = 2500  # $25.00
        price = Price(product, 2500)  # $25.00
        location = ShopLocation(
            shop=shop,
            name="Test Location",
            address="123 Test St",
            city="Test City",
            state="TS",
            country="Test Country",
            postal_code="12345",
        )

        self.dbsession.add_all([user, shop, product, price, location])
        self.dbsession.flush()

        invoice = Invoice(user)
        invoice.shop = shop
        invoice.new_line_item(product=product, quantity=1)
        self.dbsession.add(invoice)
        self.dbsession.flush()

        # Create expired DOGE payment
        expired_payment = CryptoPayment(
            invoice=invoice,
            user=user,
            shop=shop,
            address="DExpired123456789",
            account_index=0,
            subaddress_index=0,
            coin_type="DOGE",
            expected_amount=7500000000,  # 75 DOGE in koinu
            rate_locked_usd_per_coin=0.33333,
            quote_expires_at_ms=int((time.time() - 300) * 1000),  # Expired 5 mins ago
            confirmations_required=10,
        )
        self.dbsession.add(expired_payment)
        self.dbsession.flush()

        # Test expiry and amounts
        self.assertTrue(expired_payment.is_expired)
        self.assertEqual(expired_payment.due_amount, 7500000000)  # Full amount due
        self.assertEqual(expired_payment.received_amount, 0)
        self.assertFalse(expired_payment.is_swept)

        # Simulate partial payment
        expired_payment.received_amount = 5000000000  # 50 DOGE received
        self.assertEqual(expired_payment.due_amount, 2500000000)  # 25 DOGE still due

        transaction.commit()

    def test_dogecoin_unique_address_generation_integration(self):
        """Test that Dogecoin generates unique addresses for each payment."""
        from ..lib.crypto_watcher.crypto_clients import MockDogecoinClient
        from ..models.crypto_processor import CryptoProcessor

        # Create test data
        user = get_or_create_user_by_email(self.dbsession, "dogeunique@example.com")
        shop = Shop(
            name="Unique Address Shop",
            phone_number="555-UNIQUE-55",
            billing_address="123 Unique St",
            description="Testing unique addresses",
        )
        product = Product(title="Test Product", description="Test product")
        product.shop_id = shop.id
        product.price_in_cents = 1000  # $10.00
        price = Price(product, 1000)
        location = ShopLocation(
            shop=shop,
            name="Test Location",
            address="123 Test St",
            city="Test City",
            state="TS",
            country="Test Country",
            postal_code="12345",
        )

        self.dbsession.add_all([user, shop, product, price, location])
        self.dbsession.flush()

        # Create crypto processor for Dogecoin
        processor = CryptoProcessor(
            shop_id=shop.id, coin_type="DOGE", sweep_to_address="DColdWallet123456789"
        )
        processor.enabled = True
        processor.wallet_label = "test_wallet"
        self.dbsession.add(processor)
        self.dbsession.flush()

        # Create mock Dogecoin client
        mock_client = MockDogecoinClient()

        # Simulate multiple invoices
        invoices = []
        for i in range(3):
            invoice = Invoice(user)
            invoice.shop = shop
            invoice.new_line_item(product=product, quantity=1)
            self.dbsession.add(invoice)
            invoices.append(invoice)
        self.dbsession.flush()

        # Generate addresses for each invoice
        addresses = []
        for i, invoice in enumerate(invoices):
            # This mimics the logic in crypto_doge_start view
            wallet_label = processor.wallet_label or "0"
            address = mock_client.getnewaddress(f"{wallet_label}:{invoice.id}")
            addresses.append(address)

        # Verify all addresses are unique
        self.assertEqual(len(addresses), 3)
        self.assertEqual(len(set(addresses)), 3)  # All unique

        # Verify addresses contain wallet label (MockDogecoinClient truncates to 8 chars)
        for i, address in enumerate(addresses):
            # MockDogecoinClient includes first 8 chars of label in address
            self.assertIn("test_wal", address)  # "test_wallet" truncated

        transaction.commit()

    def test_dogecoin_address_labeling_integration(self):
        """Test that Dogecoin address generation includes proper labeling."""
        from ..lib.crypto_watcher.crypto_clients import MockDogecoinClient
        from ..models.crypto_processor import CryptoProcessor

        # Create test data
        user = get_or_create_user_by_email(self.dbsession, "dogelabel@example.com")
        shop = Shop(
            name="Label Test Shop",
            phone_number="555-LABEL-55",
            billing_address="123 Label St",
            description="Testing address labeling",
        )
        product = Product(title="Label Product", description="Test labeling")
        product.shop_id = shop.id
        product.price_in_cents = 500  # $5.00
        price = Price(product, 500)

        self.dbsession.add_all([user, shop, product, price])
        self.dbsession.flush()

        # Create crypto processor with custom wallet label
        processor = CryptoProcessor(
            shop_id=shop.id, coin_type="DOGE", sweep_to_address="DColdWallet987654321"
        )
        processor.enabled = True
        processor.wallet_label = "shop_wallet_123"
        self.dbsession.add(processor)
        self.dbsession.flush()

        # Create invoice
        invoice = Invoice(user)
        invoice.shop = shop
        invoice.new_line_item(product=product, quantity=1)
        self.dbsession.add(invoice)
        self.dbsession.flush()

        # Create mock client and generate address
        mock_client = MockDogecoinClient()
        wallet_label = processor.wallet_label or "0"

        # Test address generation with labeling
        expected_label = f"{wallet_label}:{invoice.id}"
        address = mock_client.getnewaddress(expected_label)

        # Verify the address contains the label components (MockDogecoinClient truncates to 8 chars)
        self.assertIn("shop_wal", address)  # "shop_wallet_123" truncated to "shop_wal"

        # Test with default wallet label (set to "0")
        processor.wallet_label = "0"
        self.dbsession.add(processor)
        self.dbsession.flush()

        # Test default label generation
        wallet_label = processor.wallet_label or "0"
        default_label = f"{wallet_label}:{invoice.id}"
        default_address = mock_client.getnewaddress(default_label)
        # Should start with "0:" in the first 8 chars
        self.assertIn("0:", default_address)

        transaction.commit()

    def test_monero_enhanced_shop_labeling_integration(self):
        """Test that Monero subaddresses include shop ID in labels for better tracking."""
        from ..lib.crypto_watcher.crypto_clients import MockMoneroClient
        from ..models.crypto_processor import CryptoProcessor

        # Create test data for multiple shops
        user = get_or_create_user_by_email(
            self.dbsession, "monero_shop_label@example.com"
        )

        # Shop 1
        shop1 = Shop(
            name="Shop One",
            phone_number="555-SHOP-1",
            billing_address="123 Shop One St",
            description="First test shop",
        )

        # Shop 2
        shop2 = Shop(
            name="Shop Two",
            phone_number="555-SHOP-2",
            billing_address="456 Shop Two Ave",
            description="Second test shop",
        )

        # Products for each shop
        product1 = Product(title="Shop 1 Product", description="Product from shop 1")
        product1.shop_id = shop1.id
        product1.price_in_cents = 1500
        price1 = Price(product1, 1500)

        product2 = Product(title="Shop 2 Product", description="Product from shop 2")
        product2.shop_id = shop2.id
        product2.price_in_cents = 2000
        price2 = Price(product2, 2000)

        self.dbsession.add_all([user, shop1, shop2, product1, product2, price1, price2])
        self.dbsession.flush()

        # Create crypto processors for both shops
        processor1 = CryptoProcessor(
            shop_id=shop1.id, coin_type="XMR", sweep_to_address="XmrColdWallet1"
        )
        processor1.enabled = True
        processor1.wallet_label = "0"  # Account index

        processor2 = CryptoProcessor(
            shop_id=shop2.id, coin_type="XMR", sweep_to_address="XmrColdWallet2"
        )
        processor2.enabled = True
        processor2.wallet_label = "1"  # Account index

        self.dbsession.add_all([processor1, processor2])
        self.dbsession.flush()

        # Create invoices for each shop
        invoice1 = Invoice(user)
        invoice1.shop = shop1
        invoice1.new_line_item(product=product1, quantity=1)

        invoice2 = Invoice(user)
        invoice2.shop = shop2
        invoice2.new_line_item(product=product2, quantity=1)

        self.dbsession.add_all([invoice1, invoice2])
        self.dbsession.flush()

        # Test the enhanced labeling format that would be used in the actual implementation
        # This mimics the logic in crypto_xmr_start view
        label1 = f"shop:{shop1.id}:invoice:{invoice1.id}"
        label2 = f"shop:{shop2.id}:invoice:{invoice2.id}"

        # Verify labels contain shop IDs for easy tracking
        self.assertIn(str(shop1.id), label1)
        self.assertIn(str(shop2.id), label2)
        self.assertIn(str(invoice1.id), label1)
        self.assertIn(str(invoice2.id), label2)

        # Verify label format is consistent with the new format
        self.assertTrue(label1.startswith(f"shop:{shop1.id}:invoice:"))
        self.assertTrue(label2.startswith(f"shop:{shop2.id}:invoice:"))

        # Verify shop IDs are different
        self.assertNotEqual(shop1.id, shop2.id)

        # Verify we can extract shop ID from label
        def extract_shop_id_from_label(label):
            if label.startswith("shop:") and ":invoice:" in label:
                return label.split(":")[1]
            return None

        extracted_shop1_id = extract_shop_id_from_label(label1)
        extracted_shop2_id = extract_shop_id_from_label(label2)

        self.assertEqual(extracted_shop1_id, str(shop1.id))
        self.assertEqual(extracted_shop2_id, str(shop2.id))

        transaction.commit()

    def test_monero_vs_dogecoin_shop_tracking_equivalence(self):
        """Test that both Monero and Dogecoin have equivalent shop tracking capabilities."""
        from ..models.crypto_processor import CryptoProcessor

        # Create test data
        user = get_or_create_user_by_email(self.dbsession, "equivalence@example.com")
        shop = Shop(
            name="Multi-Crypto Shop",
            phone_number="555-MULTI-CRYPTO",
            billing_address="123 Multi St",
            description="Shop supporting both XMR and DOGE",
        )
        product = Product(
            title="Multi-Crypto Product", description="Product for both coins"
        )
        product.shop_id = shop.id
        product.price_in_cents = 1000
        price = Price(product, 1000)

        self.dbsession.add_all([user, shop, product, price])
        self.dbsession.flush()

        # Create invoice
        invoice = Invoice(user)
        invoice.shop = shop
        invoice.new_line_item(product=product, quantity=1)
        self.dbsession.add(invoice)
        self.dbsession.flush()

        # Test Monero labeling format (enhanced with shop ID)
        monero_label = f"shop:{shop.id}:invoice:{invoice.id}"

        # Test Dogecoin labeling format (using shop.id as wallet_label)
        dogecoin_label = f"{shop.id}:{invoice.id}"

        # Both systems should include the shop ID
        self.assertIn(str(shop.id), monero_label)
        self.assertIn(str(shop.id), dogecoin_label)

        # Both systems should include the invoice ID
        self.assertIn(str(invoice.id), monero_label)
        self.assertIn(str(invoice.id), dogecoin_label)

        # Test extraction functions for both systems
        def extract_shop_from_monero_label(label):
            """Extract shop ID from Monero label format: shop:UUID:invoice:UUID"""
            if label.startswith("shop:") and ":invoice:" in label:
                return label.split(":")[1]
            return None

        def extract_shop_from_dogecoin_label(label):
            """Extract shop ID from Dogecoin label format: UUID:UUID"""
            parts = label.split(":")
            if len(parts) >= 2:
                return parts[0]  # First part is shop ID
            return None

        def extract_invoice_from_monero_label(label):
            """Extract invoice ID from Monero label"""
            if ":invoice:" in label:
                return label.split(":invoice:")[1]
            return None

        def extract_invoice_from_dogecoin_label(label):
            """Extract invoice ID from Dogecoin label"""
            parts = label.split(":")
            if len(parts) >= 2:
                return parts[1]  # Second part is invoice ID
            return None

        # Test extraction works for both systems
        extracted_monero_shop = extract_shop_from_monero_label(monero_label)
        extracted_dogecoin_shop = extract_shop_from_dogecoin_label(dogecoin_label)
        extracted_monero_invoice = extract_invoice_from_monero_label(monero_label)
        extracted_dogecoin_invoice = extract_invoice_from_dogecoin_label(dogecoin_label)

        # Verify both systems can track the same shop and invoice
        self.assertEqual(extracted_monero_shop, str(shop.id))
        self.assertEqual(extracted_dogecoin_shop, str(shop.id))
        self.assertEqual(extracted_monero_invoice, str(invoice.id))
        self.assertEqual(extracted_dogecoin_invoice, str(invoice.id))

        # Verify both systems provide equivalent shop tracking
        self.assertEqual(extracted_monero_shop, extracted_dogecoin_shop)
        self.assertEqual(extracted_monero_invoice, extracted_dogecoin_invoice)

        transaction.commit()


class TestPayPalUserShopIntegration(DatabaseIntegrationTests):
    """Integration tests for PayPalUserShop model with real database."""

    def test_create_paypal_user_shop_integration(self):
        """Test creating a PayPalUserShop with real user and shop objects."""
        # Create real user
        user = get_or_create_user_by_email(self.dbsession, "paypal_test@example.com")
        self.dbsession.add(user)

        # Create real shop
        shop = Shop(
            name="PayPal Test Shop",
            phone_number="555-555-5555",
            billing_address="123 Test St",
            description="A shop that accepts PayPal",
        )
        shop.stripe_public_api_key = "pk_test_123"
        shop.stripe_secret_api_key = "sk_test_123"
        shop.domain_name = "paypaltest.com"
        shop.paypal_client_id = "test_client_id_123"
        shop.paypal_secret = "test_secret_456"
        shop.paypal_enabled = True
        self.dbsession.add(shop)
        self.dbsession.flush()

        # Create PayPalUserShop
        paypal_user_shop = PayPalUserShop(user=user, shop=shop)
        paypal_user_shop.payer_id = "PAYER123456789"
        paypal_user_shop.billing_agreement_id = "BA-ABC123DEF456"
        paypal_user_shop.active_payment_token = "TOKEN123"
        self.dbsession.add(paypal_user_shop)
        self.dbsession.flush()

        # Verify relationships
        self.assertEqual(paypal_user_shop.user_id, user.id)
        self.assertEqual(paypal_user_shop.shop_id, shop.id)

        # Query back from database
        queried = self.dbsession.query(PayPalUserShop).filter(
            PayPalUserShop.user_id == user.id,
            PayPalUserShop.shop_id == shop.id
        ).first()

        self.assertIsNotNone(queried)
        self.assertEqual(queried.payer_id, "PAYER123456789")
        self.assertEqual(queried.billing_agreement_id, "BA-ABC123DEF456")
        self.assertEqual(queried.active_payment_token, "TOKEN123")

        transaction.commit()

    def test_paypal_user_shop_unique_constraint(self):
        """Test that user can only have one PayPalUserShop per shop."""
        # Create user and shop
        user = get_or_create_user_by_email(self.dbsession, "unique_test@example.com")
        shop = Shop(
            name="Unique Test Shop",
            phone_number="555-555-5555",
            billing_address="123 Test St",
            description="Test shop",
        )
        shop.stripe_public_api_key = "pk_test_123"
        shop.stripe_secret_api_key = "sk_test_123"
        shop.domain_name = "uniquetest.com"
        self.dbsession.add(user)
        self.dbsession.add(shop)
        self.dbsession.flush()

        # Create first PayPalUserShop
        paypal_user_shop1 = PayPalUserShop(user=user, shop=shop)
        paypal_user_shop1.payer_id = "PAYER111"
        self.dbsession.add(paypal_user_shop1)
        self.dbsession.flush()

        # Verify only one exists
        count = self.dbsession.query(PayPalUserShop).filter(
            PayPalUserShop.user_id == user.id,
            PayPalUserShop.shop_id == shop.id
        ).count()
        self.assertEqual(count, 1)

        transaction.commit()


class TestInvoicePayPalIntegration(DatabaseIntegrationTests):
    """Integration tests for Invoice PayPal functionality with real database."""

    def test_invoice_with_paypal_order_id_integration(self):
        """Test creating invoice with PayPal order and capture IDs."""
        # Create real user
        user = get_or_create_user_by_email(self.dbsession, "invoice_paypal@example.com")
        self.dbsession.add(user)

        # Create real shop
        shop = Shop(
            name="PayPal Invoice Test Shop",
            phone_number="555-555-5555",
            billing_address="123 Test St",
            description="A shop for testing PayPal invoices",
        )
        shop.stripe_public_api_key = "pk_test_123"
        shop.stripe_secret_api_key = "sk_test_123"
        shop.domain_name = "invoicetest.com"
        shop.paypal_enabled = True
        self.dbsession.add(shop)
        self.dbsession.flush()

        # Create invoice with PayPal payment info
        invoice = Invoice(user=user)
        invoice.shop = shop
        invoice.paypal_order_id = "ORDER123456789"
        invoice.paypal_capture_id = "CAPTURE987654321"
        self.dbsession.add(invoice)
        self.dbsession.flush()

        # Verify payment method detection
        self.assertEqual(invoice.payment_method, "paypal")

        # Verify query by PayPal order ID
        queried = get_invoice_by_paypal_order_id(self.dbsession, "ORDER123456789")
        self.assertIsNotNone(queried)
        self.assertEqual(queried.id, invoice.id)
        self.assertEqual(queried.paypal_capture_id, "CAPTURE987654321")

        transaction.commit()

    def test_invoice_payment_method_priority_integration(self):
        """Test payment method detection priority: crypto > paypal > stripe."""
        user = get_or_create_user_by_email(self.dbsession, "priority_test@example.com")
        shop = Shop(
            name="Priority Test Shop",
            phone_number="555-555-5555",
            billing_address="123 Test St",
            description="Test shop",
        )
        shop.stripe_public_api_key = "pk_test_123"
        shop.stripe_secret_api_key = "sk_test_123"
        shop.domain_name = "prioritytest.com"
        self.dbsession.add(user)
        self.dbsession.add(shop)
        self.dbsession.flush()

        # Invoice with no payment info defaults to stripe
        invoice_stripe = Invoice(user=user)
        invoice_stripe.shop = shop
        self.dbsession.add(invoice_stripe)
        self.dbsession.flush()
        self.assertEqual(invoice_stripe.payment_method, "stripe")

        # Invoice with PayPal order ID returns paypal
        invoice_paypal = Invoice(user=user)
        invoice_paypal.shop = shop
        invoice_paypal.paypal_order_id = "ORDER_PAYPAL_123"
        self.dbsession.add(invoice_paypal)
        self.dbsession.flush()
        self.assertEqual(invoice_paypal.payment_method, "paypal")

        transaction.commit()

    def test_get_invoice_by_paypal_order_id_not_found(self):
        """Test querying for non-existent PayPal order ID returns None."""
        result = get_invoice_by_paypal_order_id(self.dbsession, "NONEXISTENT_ORDER_ID")
        self.assertIsNone(result)

    def test_invoice_with_paypal_is_paid_status(self):
        """Test is_paid property works correctly for PayPal invoices."""
        user = get_or_create_user_by_email(self.dbsession, "paid_test@example.com")
        shop = Shop(
            name="Paid Test Shop",
            phone_number="555-555-5555",
            billing_address="123 Test St",
            description="Test shop",
        )
        shop.stripe_public_api_key = "pk_test_123"
        shop.stripe_secret_api_key = "sk_test_123"
        shop.domain_name = "paidtest.com"
        self.dbsession.add(user)
        self.dbsession.add(shop)
        self.dbsession.flush()

        # PayPal invoice without crypto_payment is considered "paid"
        invoice = Invoice(user=user)
        invoice.shop = shop
        invoice.paypal_order_id = "ORDER_PAID_TEST"
        invoice.paypal_capture_id = "CAPTURE_PAID_TEST"
        self.dbsession.add(invoice)
        self.dbsession.flush()

        # payment_status returns "paid" for non-crypto invoices
        self.assertEqual(invoice.payment_status, "paid")
        self.assertTrue(invoice.is_paid)

        transaction.commit()


class TestInvoiceStripeIntegration(DatabaseIntegrationTests):
    """Integration tests for Invoice Stripe functionality with real database."""

    def test_invoice_with_stripe_payment_intent_id_integration(self):
        """Test creating invoice with Stripe payment intent and charge IDs."""
        # Create real user
        user = get_or_create_user_by_email(self.dbsession, "invoice_stripe@example.com")
        self.dbsession.add(user)

        # Create real shop
        shop = Shop(
            name="Stripe Invoice Test Shop",
            phone_number="555-555-5555",
            billing_address="123 Test St",
            description="A shop for testing Stripe invoices",
        )
        shop.stripe_public_api_key = "pk_test_123"
        shop.stripe_secret_api_key = "sk_test_123"
        shop.domain_name = "stripeinvoicetest.com"
        self.dbsession.add(shop)
        self.dbsession.flush()

        # Create invoice with Stripe payment info
        invoice = Invoice(user=user)
        invoice.shop = shop
        invoice.stripe_payment_intent_id = "pi_1234567890abcdef"
        invoice.stripe_charge_id = "ch_1234567890abcdef"
        self.dbsession.add(invoice)
        self.dbsession.flush()

        # Verify payment method detection
        self.assertEqual(invoice.payment_method, "stripe")

        # Verify query by Stripe payment intent ID
        from make_post_sell.models.invoice import get_invoice_by_stripe_payment_intent_id
        queried = get_invoice_by_stripe_payment_intent_id(self.dbsession, "pi_1234567890abcdef")
        self.assertIsNotNone(queried)
        self.assertEqual(queried.id, invoice.id)
        self.assertEqual(queried.stripe_charge_id, "ch_1234567890abcdef")

        transaction.commit()

    def test_get_invoice_by_stripe_payment_intent_id_not_found(self):
        """Test querying for non-existent Stripe payment intent ID returns None."""
        from make_post_sell.models.invoice import get_invoice_by_stripe_payment_intent_id
        result = get_invoice_by_stripe_payment_intent_id(self.dbsession, "pi_nonexistent")
        self.assertIsNone(result)

    def test_invoice_stripe_payment_persists_across_sessions(self):
        """Test that Stripe payment IDs persist correctly in the database."""
        user = get_or_create_user_by_email(self.dbsession, "persist_test@example.com")
        shop = Shop(
            name="Persist Test Shop",
            phone_number="555-555-5555",
            billing_address="123 Test St",
            description="Test shop",
        )
        shop.stripe_public_api_key = "pk_test_123"
        shop.stripe_secret_api_key = "sk_test_123"
        shop.domain_name = "persisttest.com"
        self.dbsession.add(user)
        self.dbsession.add(shop)
        self.dbsession.flush()

        # Create invoice with Stripe payment info
        invoice = Invoice(user=user)
        invoice.shop = shop
        invoice.stripe_payment_intent_id = "pi_persist_test_12345"
        invoice.stripe_charge_id = "ch_persist_test_12345"
        self.dbsession.add(invoice)
        self.dbsession.flush()
        invoice_id = invoice.id

        # Commit and clear session to simulate new request
        transaction.commit()
        self.dbsession.expire_all()

        # Re-query and verify
        from make_post_sell.models.invoice import get_invoice_by_id
        reloaded_invoice = get_invoice_by_id(self.dbsession, invoice_id)
        self.assertIsNotNone(reloaded_invoice)
        self.assertEqual(reloaded_invoice.stripe_payment_intent_id, "pi_persist_test_12345")
        self.assertEqual(reloaded_invoice.stripe_charge_id, "ch_persist_test_12345")
        self.assertEqual(reloaded_invoice.payment_method, "stripe")

    def test_invoice_with_stripe_is_paid_status(self):
        """Test is_paid property works correctly for Stripe invoices."""
        user = get_or_create_user_by_email(self.dbsession, "stripe_paid_test@example.com")
        shop = Shop(
            name="Stripe Paid Test Shop",
            phone_number="555-555-5555",
            billing_address="123 Test St",
            description="Test shop",
        )
        shop.stripe_public_api_key = "pk_test_123"
        shop.stripe_secret_api_key = "sk_test_123"
        shop.domain_name = "stripepaidtest.com"
        self.dbsession.add(user)
        self.dbsession.add(shop)
        self.dbsession.flush()

        # Stripe invoice is considered "paid"
        invoice = Invoice(user=user)
        invoice.shop = shop
        invoice.stripe_payment_intent_id = "pi_paid_test"
        invoice.stripe_charge_id = "ch_paid_test"
        self.dbsession.add(invoice)
        self.dbsession.flush()

        # payment_status returns "paid" for non-crypto invoices
        self.assertEqual(invoice.payment_status, "paid")
        self.assertTrue(invoice.is_paid)

        transaction.commit()

    def test_invoice_payment_method_priority_with_stripe(self):
        """Test payment method detection with Stripe: crypto > paypal > stripe."""
        user = get_or_create_user_by_email(self.dbsession, "stripe_priority@example.com")
        shop = Shop(
            name="Stripe Priority Shop",
            phone_number="555-555-5555",
            billing_address="123 Test St",
            description="Test shop",
        )
        shop.stripe_public_api_key = "pk_test_123"
        shop.stripe_secret_api_key = "sk_test_123"
        shop.domain_name = "stripeprioritytest.com"
        self.dbsession.add(user)
        self.dbsession.add(shop)
        self.dbsession.flush()

        # Invoice with only Stripe payment intent returns stripe
        invoice_stripe = Invoice(user=user)
        invoice_stripe.shop = shop
        invoice_stripe.stripe_payment_intent_id = "pi_priority_test"
        self.dbsession.add(invoice_stripe)
        self.dbsession.flush()
        self.assertEqual(invoice_stripe.payment_method, "stripe")

        # Invoice with both PayPal and Stripe - PayPal takes priority
        invoice_both = Invoice(user=user)
        invoice_both.shop = shop
        invoice_both.paypal_order_id = "ORDER_PRIORITY"
        invoice_both.stripe_payment_intent_id = "pi_priority_both"
        self.dbsession.add(invoice_both)
        self.dbsession.flush()
        self.assertEqual(invoice_both.payment_method, "paypal")

        transaction.commit()


class TestAdyenIntegration(DatabaseIntegrationTests):
    """Integration tests for Adyen payment integration."""

    def test_shop_adyen_credentials_persistence(self):
        """Test that Adyen credentials are properly persisted to the database."""
        # Create shop with Adyen credentials
        shop = Shop(
            name="Adyen Test Shop",
            phone_number="555-555-5555",
            billing_address="123 Test St",
            description="Test shop for Adyen",
        )
        shop.adyen_api_key = "test_api_key_12345"
        shop.adyen_merchant_account = "TestMerchantAccount"
        shop.adyen_client_key = "test_client_key"
        shop.adyen_hmac_key = "test_hmac_key"
        self.dbsession.add(shop)
        self.dbsession.flush()

        # Re-query the shop to verify persistence
        shop_id = shop.id
        self.dbsession.expunge(shop)

        from ..models.shop import get_shop_by_id
        reloaded_shop = get_shop_by_id(self.dbsession, shop_id)

        self.assertEqual(reloaded_shop.adyen_api_key, "test_api_key_12345")
        self.assertEqual(reloaded_shop.adyen_merchant_account, "TestMerchantAccount")
        self.assertEqual(reloaded_shop.adyen_client_key, "test_client_key")
        self.assertEqual(reloaded_shop.adyen_hmac_key, "test_hmac_key")

        transaction.commit()

    def test_shop_is_adyen_ready_integration(self):
        """Test is_adyen_ready with persisted shop."""
        # Shop without credentials
        shop1 = Shop(
            name="Shop Without Adyen",
            phone_number="555-555-0001",
            billing_address="123 Test St",
            description="No Adyen",
        )
        self.dbsession.add(shop1)
        self.dbsession.flush()

        self.assertFalse(shop1.is_adyen_ready)

        # Shop with credentials
        shop2 = Shop(
            name="Shop With Adyen",
            phone_number="555-555-0002",
            billing_address="456 Test St",
            description="Has Adyen",
        )
        shop2.adyen_api_key = "test_api_key"
        shop2.adyen_merchant_account = "TestMerchant"
        self.dbsession.add(shop2)
        self.dbsession.flush()

        self.assertTrue(shop2.is_adyen_ready)

        transaction.commit()

    def test_invoice_adyen_psp_reference_integration(self):
        """Test that Adyen PSP reference is properly stored on invoice."""
        # Create user and shop
        user = get_or_create_user_by_email(self.dbsession, "adyen@test.com")
        shop = Shop(
            name="Adyen Invoice Test",
            phone_number="555-555-5555",
            billing_address="123 Test St",
            description="Testing Adyen invoices",
        )
        shop.adyen_api_key = "test_api_key"
        shop.adyen_merchant_account = "TestMerchant"
        self.dbsession.add(user)
        self.dbsession.add(shop)
        self.dbsession.flush()

        # Create invoice with Adyen payment
        invoice = Invoice(user=user)
        invoice.shop = shop
        invoice.adyen_psp_reference = "882619391893263J"
        self.dbsession.add(invoice)
        self.dbsession.flush()

        # Verify payment method detection
        self.assertEqual(invoice.payment_method, "adyen")

        # Verify persistence
        invoice_id = invoice.id
        self.dbsession.expunge(invoice)

        from ..models.invoice import get_invoice_by_adyen_psp_reference
        reloaded_invoice = get_invoice_by_adyen_psp_reference(
            self.dbsession, "882619391893263J"
        )

        self.assertIsNotNone(reloaded_invoice)
        self.assertEqual(str(reloaded_invoice.id), str(invoice_id))
        self.assertEqual(reloaded_invoice.adyen_psp_reference, "882619391893263J")

        transaction.commit()

    def test_get_invoice_by_adyen_psp_reference_not_found(self):
        """Test that get_invoice_by_adyen_psp_reference returns None for non-existent reference."""
        from ..models.invoice import get_invoice_by_adyen_psp_reference

        result = get_invoice_by_adyen_psp_reference(
            self.dbsession, "nonexistent_psp_reference"
        )
        self.assertIsNone(result)

    def test_invoice_payment_method_priority_with_adyen_integration(self):
        """Test payment method priority when multiple payment references exist."""
        user = get_or_create_user_by_email(self.dbsession, "priority@test.com")
        shop = Shop(
            name="Priority Test Shop",
            phone_number="555-555-5555",
            billing_address="123 Test St",
            description="Testing priority",
        )
        self.dbsession.add(user)
        self.dbsession.add(shop)
        self.dbsession.flush()

        # Invoice with only Adyen payment
        invoice_adyen = Invoice(user=user)
        invoice_adyen.shop = shop
        invoice_adyen.adyen_psp_reference = "PSP_ONLY"
        self.dbsession.add(invoice_adyen)
        self.dbsession.flush()
        self.assertEqual(invoice_adyen.payment_method, "adyen")

        # Invoice with Adyen and Stripe - Adyen takes priority
        invoice_adyen_stripe = Invoice(user=user)
        invoice_adyen_stripe.shop = shop
        invoice_adyen_stripe.adyen_psp_reference = "PSP_PRIORITY"
        invoice_adyen_stripe.stripe_payment_intent_id = "pi_lower_priority"
        self.dbsession.add(invoice_adyen_stripe)
        self.dbsession.flush()
        self.assertEqual(invoice_adyen_stripe.payment_method, "adyen")

        # Invoice with PayPal and Adyen - PayPal takes priority
        invoice_paypal_adyen = Invoice(user=user)
        invoice_paypal_adyen.shop = shop
        invoice_paypal_adyen.paypal_order_id = "PP_HIGHEST"
        invoice_paypal_adyen.adyen_psp_reference = "PSP_SECOND"
        self.dbsession.add(invoice_paypal_adyen)
        self.dbsession.flush()
        self.assertEqual(invoice_paypal_adyen.payment_method, "paypal")

        transaction.commit()


class TestLazyCartCreationIntegration(DatabaseIntegrationTests):
    """Integration tests for lazy cart creation.

    Session carts should NOT be persisted to the database until a product
    is actually added.  This prevents bots from creating empty cart rows.
    """

    def test_inmemory_cart_not_in_database(self):
        """A transient Cart (never added to session) should not appear in DB."""
        cart = Cart()
        cart.active = True

        # Cart has a valid id but is not in DB
        self.assertIsNotNone(cart.id)
        found = self.dbsession.query(Cart).filter(Cart.id == cart.id).first()
        self.assertIsNone(found)

    def test_inmemory_cart_persists_after_dbsession_add(self):
        """When dbsession.add + flush is called, the cart appears in DB."""
        cart = Cart()
        cart.active = True
        self.assertIsNone(cart.dbsession)

        self.dbsession.add(cart)
        self.dbsession.flush()

        # Now it should be found
        self.assertIsNotNone(cart.dbsession)
        found = self.dbsession.query(Cart).filter(Cart.id == cart.id).first()
        self.assertIsNotNone(found)
        self.assertEqual(found.id, cart.id)

    def test_add_product_then_persist(self):
        """Simulate the lazy cart flow: add product, then persist."""
        user = get_or_create_user_by_email(self.dbsession, "lazy@example.com")
        self.dbsession.add(user)

        shop = Shop(
            name="Lazy Cart Shop",
            phone_number="555-555-5555",
            billing_address="123 Test St",
            description="A test shop",
        )
        shop.stripe_public_api_key = "pk_test_123"
        shop.stripe_secret_api_key = "sk_test_123"
        shop.domain_name = "lazy.localhost"
        self.dbsession.add(shop)

        product = Product(title="Digital Download", description="Test product")
        product.shop_id = shop.id
        product.price_in_cents = 500
        product.is_physical = False
        self.dbsession.add(product)
        self.dbsession.flush()

        # Create in-memory cart (simulates lazy session cart)
        cart = Cart()
        cart.shop = shop
        cart.active = True
        self.assertIsNone(cart.dbsession)

        # Count carts before
        cart_count_before = self.dbsession.query(Cart).count()

        # Add product (this is what triggers persistence in the view)
        cart.add_product(product)
        self.dbsession.add(cart)
        self.dbsession.flush()

        # Now the cart should be in the DB
        cart_count_after = self.dbsession.query(Cart).count()
        self.assertEqual(cart_count_after, cart_count_before + 1)

        found = self.dbsession.query(Cart).filter(Cart.id == cart.id).first()
        self.assertIsNotNone(found)
        self.assertEqual(found.count, 1)

    def test_login_merge_skips_delete_for_transient_cart(self):
        """The login merge should not crash when session_cart is transient.

        authentication.py checks session_cart.dbsession before calling
        dbsession.delete(). A transient cart has dbsession=None.
        """
        # Create a transient cart (simulates unpersisted session cart)
        session_cart = Cart()
        session_cart.active = True

        # Verify the guard condition works
        self.assertIsNone(session_cart.dbsession)

        # This is the pattern from authentication.py:
        # if request.session_cart.dbsession is not None:
        #     request.dbsession.delete(request.session_cart)
        # Should NOT attempt to delete
        if session_cart.dbsession is not None:
            self.fail("Transient cart should have dbsession=None")

    def test_inmemory_cart_reuse_uuid(self):
        """Test recreating a Cart with a specific UUID (lazy cart reuse)."""
        import uuid as uuid_mod

        # First request: create in-memory cart, store UUID in "session"
        cart1 = Cart()
        stored_uuid = cart1.uuid_str

        # Second request: recreate cart with same UUID
        cart2 = Cart()
        cart2.id = uuid_mod.UUID(stored_uuid)

        self.assertEqual(cart2.uuid_str, stored_uuid)
        self.assertEqual(str(cart2.id), str(cart1.id))


class TestSubscribeFrequencyLookup(DatabaseIntegrationTests):
    """Integration test: subscribe view returns current_frequency from real DB."""

    def _make_shop(self):
        shop = Shop(
            name="freq-test-shop",
            phone_number="555-555-5555",
            billing_address="123 Test St",
            description="A test shop",
        )
        shop.subscriptions_enabled = True
        shop.domain_name = "freqtest.com"
        self.dbsession.add(shop)
        self.dbsession.flush()
        return shop

    def test_active_subscription_frequency_returned(self):
        """View returns actual frequency from DB for active subscription."""
        from ..models.shop_subscription import (
            ShopSubscription,
            FREQUENCY_WEEKLY,
            get_subscription_for_email_and_shop,
        )

        shop = self._make_shop()
        user = get_or_create_user_by_email(self.dbsession, "freq@example.com")
        self.dbsession.add(user)
        self.dbsession.flush()

        sub = ShopSubscription("freq@example.com", shop.id, user_id=user.id)
        sub.frequency = FREQUENCY_WEEKLY
        self.dbsession.add(sub)
        self.dbsession.flush()

        # Verify the lookup returns the correct subscription
        found = get_subscription_for_email_and_shop(
            self.dbsession, "freq@example.com", shop.id
        )
        self.assertIsNotNone(found)
        self.assertFalse(found.disabled)
        self.assertEqual(found.frequency, FREQUENCY_WEEKLY)

    def test_disabled_subscription_detected(self):
        """Disabled subscription is found so view can map to -1."""
        from ..models.shop_subscription import (
            ShopSubscription,
            get_subscription_for_email_and_shop,
        )

        shop = self._make_shop()
        user = get_or_create_user_by_email(self.dbsession, "dis@example.com")
        self.dbsession.add(user)
        self.dbsession.flush()

        sub = ShopSubscription("dis@example.com", shop.id, user_id=user.id)
        sub.disabled = True
        self.dbsession.add(sub)
        self.dbsession.flush()

        found = get_subscription_for_email_and_shop(
            self.dbsession, "dis@example.com", shop.id
        )
        self.assertIsNotNone(found)
        self.assertTrue(found.disabled)

    def test_no_subscription_returns_none(self):
        """No subscription in DB returns None from lookup."""
        from ..models.shop_subscription import get_subscription_for_email_and_shop

        shop = self._make_shop()

        found = get_subscription_for_email_and_shop(
            self.dbsession, "nobody@example.com", shop.id
        )
        self.assertIsNone(found)


class TestAsyncRingReforgeIntegration(DatabaseIntegrationTests):
    """Integration tests for async discovery ring reforge with real DB."""

    def _make_shop(self):
        user = get_or_create_user_by_email(self.dbsession, "ring@example.com")
        self.dbsession.add(user)
        self.dbsession.flush()

        shop = Shop(
            "ring-test-shop",
            "555-555-5555",
            "123 Ring St",
            "Shop for ring tests",
        )
        shop.watch_mode_enabled = True
        self.dbsession.add(shop)
        self.dbsession.flush()
        return shop

    def _make_products(self, shop, count):
        products = []
        for i in range(count):
            p = Product(f"Track {i}", f"Description for track {i}")
            p.shop_id = shop.id
            p.visibility = 1
            self.dbsession.add(p)
            products.append(p)
        self.dbsession.flush()
        return products

    def test_async_reforge_stores_ring_in_db(self):
        """Async reforge computes and persists ring to database."""
        shop = self._make_shop()
        products = self._make_products(shop, 5)
        shop_id = shop.id
        product_ids = {str(p.id) for p in products}
        transaction.commit()

        from ..models.shop import reforge_discovery_ring_async
        t = reforge_discovery_ring_async(shop_id, self.session_factory)

        # Wait for background thread to finish
        t.join(timeout=10)

        # Re-query shop from DB to see committed ring
        self.dbsession = get_tm_session(self.session_factory, transaction.manager)
        shop = self.dbsession.query(Shop).filter_by(name="ring-test-shop").one()
        ring = shop.discovery_ring

        self.assertIsNotNone(ring)
        self.assertEqual(len(ring), 5)
        self.assertEqual(set(ring), product_ids)

    def test_async_reforge_dirty_bit_picks_up_new_product(self):
        """Dirty re-run sees products added after first run started."""
        shop = self._make_shop()
        self._make_products(shop, 3)
        shop_id = shop.id
        transaction.commit()

        from ..models.shop import reforge_discovery_ring_async
        from ..models import shop as shop_module

        # Start the first reforge
        t = reforge_discovery_ring_async(shop_id, self.session_factory)

        # Immediately trigger dirty bit
        reforge_discovery_ring_async(shop_id, self.session_factory)

        # Wait for both runs to finish
        t.join(timeout=10)

        # Ring should exist
        self.dbsession = get_tm_session(self.session_factory, transaction.manager)
        shop = self.dbsession.query(Shop).filter_by(name="ring-test-shop").one()
        ring = shop.discovery_ring

        self.assertIsNotNone(ring)
        self.assertEqual(len(ring), 3)

        # State should be cleaned up
        shop_id_str = str(shop_id)
        self.assertNotIn(shop_id_str, shop_module._reforge_running)
        self.assertNotIn(shop_id_str, shop_module._reforge_dirty)

    def test_async_reforge_excludes_private_products(self):
        """Async reforge only includes public (visibility=1) products."""
        shop = self._make_shop()
        products = self._make_products(shop, 3)
        shop_id = shop.id
        public_product_id = str(products[0].id)

        # Make one private, one unlisted
        products[1].visibility = 0
        products[2].visibility = 2
        self.dbsession.flush()
        transaction.commit()

        from ..models.shop import reforge_discovery_ring_async
        t = reforge_discovery_ring_async(shop_id, self.session_factory)

        t.join(timeout=10)

        self.dbsession = get_tm_session(self.session_factory, transaction.manager)
        shop = self.dbsession.query(Shop).filter_by(name="ring-test-shop").one()
        ring = shop.discovery_ring

        self.assertEqual(len(ring), 1)
        self.assertEqual(ring[0], public_product_id)


class TestValidateDiscoveryRingIntegration(DatabaseIntegrationTests):
    """End-to-end validator tests against real shop + product rows."""

    def _make_shop(self, name="validate-ring-shop"):
        user = get_or_create_user_by_email(self.dbsession, "validate@example.com")
        self.dbsession.add(user)
        self.dbsession.flush()

        shop = Shop(name, "555-555-5555", "123 Validate St", "desc")
        shop.watch_mode_enabled = True
        self.dbsession.add(shop)
        self.dbsession.flush()
        return shop

    def _make_products(self, shop, count, visibility=1):
        products = []
        for i in range(count):
            p = Product(f"Song {i}", f"Desc {i}")
            p.shop_id = shop.id
            p.visibility = visibility
            self.dbsession.add(p)
            products.append(p)
        self.dbsession.flush()
        return products

    def test_valid_after_fresh_reforge(self):
        """Immediately after reforge, validator reports healthy ring."""
        from ..models.shop import reforge_discovery_ring, validate_discovery_ring

        shop = self._make_shop()
        self._make_products(shop, 5)
        reforge_discovery_ring(shop)
        self.dbsession.flush()

        health = validate_discovery_ring(shop)
        self.assertTrue(health["valid"])
        self.assertEqual(health["ring_length"], 5)
        self.assertEqual(health["public_count"], 5)
        self.assertEqual(health["orphans"], [])
        self.assertEqual(health["stale"], [])
        self.assertEqual(health["duplicates"], [])

    def test_detects_orphan_after_new_product(self):
        """Adding a public product after reforge leaves it orphaned."""
        from ..models.shop import reforge_discovery_ring, validate_discovery_ring

        shop = self._make_shop(name="orphan-shop")
        self._make_products(shop, 3)
        reforge_discovery_ring(shop)
        self.dbsession.flush()

        new_product = Product("Latecomer", "Arrived post-reforge")
        new_product.shop_id = shop.id
        new_product.visibility = 1
        self.dbsession.add(new_product)
        self.dbsession.flush()

        health = validate_discovery_ring(shop)
        self.assertFalse(health["valid"])
        self.assertEqual(health["orphans"], [str(new_product.id)])
        self.assertTrue(health["length_mismatch"])

    def test_detects_stale_after_visibility_change(self):
        """Flipping a product to unlisted leaves a stale entry in the ring."""
        from ..models.shop import reforge_discovery_ring, validate_discovery_ring

        shop = self._make_shop(name="stale-shop")
        products = self._make_products(shop, 4)
        reforge_discovery_ring(shop)
        self.dbsession.flush()

        # Mark one product unlisted — it stays in the ring but isn't public anymore
        products[1].visibility = 2
        self.dbsession.flush()

        health = validate_discovery_ring(shop)
        self.assertFalse(health["valid"])
        self.assertEqual(health["stale"], [str(products[1].id)])

    def test_reforge_clears_stale_and_orphan(self):
        """A fresh reforge after changes restores a healthy ring."""
        from ..models.shop import reforge_discovery_ring, validate_discovery_ring

        shop = self._make_shop(name="heal-shop")
        products = self._make_products(shop, 3)
        reforge_discovery_ring(shop)
        self.dbsession.flush()

        # Add orphan and create stale
        new_product = Product("Orphan", "Added later")
        new_product.shop_id = shop.id
        new_product.visibility = 1
        self.dbsession.add(new_product)
        products[0].visibility = 0  # now private → stale in ring
        self.dbsession.flush()

        before = validate_discovery_ring(shop)
        self.assertFalse(before["valid"])

        reforge_discovery_ring(shop)
        self.dbsession.flush()

        after = validate_discovery_ring(shop)
        self.assertTrue(after["valid"])


class TestKaraokeTrackAclIntegration(DatabaseIntegrationTests):
    """Integration tests for karaoke track ACL behavior across visibility changes."""

    def _make_shop_and_product(self):
        from ..models.shop import Shop
        from ..models.product import Product

        shop = Shop("karaoke-acl-shop", "555-555-5555", "123 Test St", "desc")
        self.dbsession.add(shop)
        self.dbsession.flush()

        product = Product("Karaoke Test Track", "A test audio product")
        product.shop = shop
        product.shop_id = shop.id
        product.visibility = 1  # public
        product._file_metadata = {
            "extensions": {
                "product": "mp3",
                "thumbnail1": "jpg",
                "instrumentals": "wav",
                "vocals": "wav",
            },
            "file_bytes": {
                "product": 5000000,
                "instrumentals": 8000000,
                "vocals": 8000000,
            },
        }
        self.dbsession.add(product)
        self.dbsession.flush()
        return shop, product

    def test_visibility_change_updates_karaoke_acls(self):
        """Changing visibility calls put_object_acl for karaoke tracks."""
        from unittest import mock

        shop, product = self._make_shop_and_product()
        mock_s3 = mock.Mock()

        # Public → private should update ACLs for all files including karaoke
        product.set_visibility(0, mock_s3, "test-bucket")

        called_keys = [
            call.kwargs.get("Key") or call[1].get("Key")
            for call in mock_s3.put_object_acl.call_args_list
        ]
        s3_path = f"{shop.id}/{product.id}"
        self.assertIn(f"{s3_path}/instrumentals", called_keys)
        self.assertIn(f"{s3_path}/vocals", called_keys)

        # All should be private now
        for call in mock_s3.put_object_acl.call_args_list:
            acl = call.kwargs.get("ACL") or call[1].get("ACL")
            self.assertEqual(acl, "private")

    def test_karaoke_acl_matches_product_file_across_visibility(self):
        """Karaoke tracks always get the same ACL as the product file."""
        shop, product = self._make_shop_and_product()

        for vis in (0, 1, 2):
            product.visibility = vis
            product_acl = product.get_s3_acl_for_file_key("product")
            self.assertEqual(
                product.get_s3_acl_for_file_key("instrumentals"),
                product_acl,
                f"instrumentals ACL mismatch at visibility={vis}",
            )
            self.assertEqual(
                product.get_s3_acl_for_file_key("vocals"),
                product_acl,
                f"vocals ACL mismatch at visibility={vis}",
            )


class TestGiftCardIntegration(DatabaseIntegrationTests):
    """Integration tests for gift card functionality with real ORM objects."""

    def _make_shop(self, gift_card_enabled=True):
        """Helper to create a shop with gift card support."""
        shop = Shop(
            name="Gift Card Shop",
            phone_number="555-555-5555",
            billing_address="123 Test St",
            description="A shop with gift cards",
        )
        shop.stripe_public_api_key = "pk_test_123"
        shop.stripe_secret_api_key = "sk_test_123"
        shop.domain_name = "giftcards.test.com"
        shop.gift_card_enabled = gift_card_enabled
        self.dbsession.add(shop)
        self.dbsession.flush()
        return shop

    def _make_product(self, shop, price_in_cents=1000):
        """Helper to create a product."""
        product = Product(title="Test Product", description="Test product")
        product.shop_id = shop.id
        product.price_in_cents = price_in_cents
        product.is_physical = False
        self.dbsession.add(product)
        self.dbsession.flush()
        return product

    def _make_user(self):
        """Helper to create a user."""
        user = get_or_create_user_by_email(self.dbsession, "test@example.com")
        self.dbsession.add(user)
        self.dbsession.flush()
        return user

    def _make_cart(self, user, shop):
        """Helper to create a cart."""
        cart = Cart(user=user)
        cart.shop = shop
        self.dbsession.add(cart)
        self.dbsession.flush()
        return cart

    def test_gift_card_with_real_shop_integration(self):
        """Test gift card creation and lookup with real shop."""
        shop = self._make_shop(gift_card_enabled=True)

        gift_card = GiftCard(shop=shop, amount_in_cents=5000)
        self.dbsession.add(gift_card)
        self.dbsession.flush()

        # Test get_gift_card_by_id
        found = get_gift_card_by_id(self.dbsession, gift_card.id)
        self.assertIsNotNone(found)
        self.assertEqual(found.id, gift_card.id)
        self.assertEqual(found.initial_amount_in_cents, 5000)
        self.assertEqual(found.balance_in_cents, 5000)

        # Test get_gift_card_by_code with shop filter
        found_by_code = get_gift_card_by_code(
            self.dbsession, gift_card.code, shop=shop
        )
        self.assertIsNotNone(found_by_code)
        self.assertEqual(found_by_code.id, gift_card.id)

        # Test get_gift_cards_by_shop returns this gift card
        shop_cards = get_gift_cards_by_shop(self.dbsession, shop).all()
        self.assertEqual(len(shop_cards), 1)
        self.assertEqual(shop_cards[0].id, gift_card.id)

        transaction.commit()

    def test_cart_with_gift_card_integration(self):
        """Test cart with attached gift card reduces totals correctly."""
        user = self._make_user()
        shop = self._make_shop()
        product = self._make_product(shop, price_in_cents=1000)  # $10
        cart = self._make_cart(user, shop)

        cart.add_product(product)

        # Create gift card with $50 balance
        gift_card = GiftCard(shop=shop, amount_in_cents=5000)
        self.dbsession.add(gift_card)
        self.dbsession.flush()

        # Attach gift card to cart via CartGiftCard
        cart_gift_card = CartGiftCard(cart=cart, gift_card=gift_card)
        self.dbsession.add(cart_gift_card)
        self.dbsession.flush()

        # Test gift card appears in cart.gift_cards
        self.assertEqual(len(list(cart.gift_cards)), 1)

        # Test cart is discounted
        self.assertTrue(cart.is_discounted)

        # Test discounted_shop_totals_in_cents shows reduced amount
        shop_uuid = shop.uuid_str
        discounted = cart.discounted_shop_totals_in_cents
        self.assertEqual(discounted[shop_uuid], 0)  # $10 - $50 gift card = $0

        # Test gift_card_deductions dict has the gift card's uuid_str as key
        deductions = cart.gift_card_deductions
        self.assertIn(gift_card.uuid_str, deductions)
        self.assertEqual(deductions[gift_card.uuid_str], 1000)  # Deducted $10

        transaction.commit()

    def test_gift_card_deduction_integration(self):
        """Test gift card deduct method reduces balance correctly."""
        shop = self._make_shop()

        gift_card = GiftCard(shop=shop, amount_in_cents=2000)  # $20
        self.dbsession.add(gift_card)
        self.dbsession.flush()

        # Deduct $15
        result = gift_card.deduct(1500)
        self.assertEqual(result, 1500)
        self.assertEqual(gift_card.balance_in_cents, 500)
        self.assertTrue(gift_card.is_valid)

        # Deduct remaining $5
        result = gift_card.deduct(500)
        self.assertEqual(result, 500)
        self.assertEqual(gift_card.balance_in_cents, 0)
        self.assertTrue(gift_card.is_fully_redeemed)
        self.assertFalse(gift_card.is_valid)

        transaction.commit()

    def test_gift_card_transaction_integration(self):
        """Test gift card transaction records are linked correctly."""
        user = self._make_user()
        shop = self._make_shop()
        product = self._make_product(shop, price_in_cents=1000)

        gift_card = GiftCard(shop=shop, amount_in_cents=5000)
        self.dbsession.add(gift_card)
        self.dbsession.flush()

        # Create an invoice for the transaction
        invoice = Invoice(user=user)
        self.dbsession.add(invoice)
        self.dbsession.flush()

        # Create a GiftCardTransaction
        txn = GiftCardTransaction(
            gift_card=gift_card, invoice=invoice, amount_in_cents=1000
        )
        self.dbsession.add(txn)
        self.dbsession.flush()

        # Assert transaction appears in gift_card.transactions
        transactions = gift_card.transactions.all()
        self.assertEqual(len(transactions), 1)
        self.assertEqual(transactions[0].amount_in_cents, 1000)

        transaction.commit()

    def test_cart_with_gift_card_and_coupon_integration(self):
        """Test coupon applies first, then gift card reduces remaining total."""
        user = self._make_user()
        shop = self._make_shop()
        product = self._make_product(shop, price_in_cents=2000)  # $20
        cart = self._make_cart(user, shop)

        cart.add_product(product)

        # Create 50% off coupon ($10 off via dollar-off for predictability)
        coupon = Coupon(
            shop=shop,
            code="HALF",
            description="$10 off",
            action_type="dollar-off",
            action_value=10,  # $10.00 off
            max_redemptions=100,
            max_redemptions_per_user=1,
            cart_qualifier=0,
        )
        self.dbsession.add(coupon)
        self.dbsession.flush()

        # Attach coupon to cart
        cart_coupon = CartCoupon(cart=cart, coupon=coupon)
        self.dbsession.add(cart_coupon)
        self.dbsession.flush()

        # Create gift card with $5 balance
        gift_card = GiftCard(shop=shop, amount_in_cents=500)
        self.dbsession.add(gift_card)
        self.dbsession.flush()

        # Attach gift card to cart
        cart_gift_card = CartGiftCard(cart=cart, gift_card=gift_card)
        self.dbsession.add(cart_gift_card)
        self.dbsession.flush()

        # Coupon reduces $20 to $10, gift card reduces $10 to $5
        shop_uuid = shop.uuid_str
        discounted = cart.discounted_shop_totals_in_cents
        self.assertEqual(discounted[shop_uuid], 500)  # $5.00

        transaction.commit()

    def test_gift_card_validate_attached_integration(self):
        """Test validation catches disabled gift cards attached to cart."""
        user = self._make_user()
        shop = self._make_shop()
        product = self._make_product(shop, price_in_cents=1000)
        cart = self._make_cart(user, shop)

        cart.add_product(product)

        # Create a disabled gift card
        gift_card = GiftCard(shop=shop, amount_in_cents=5000)
        gift_card.disabled = True
        self.dbsession.add(gift_card)
        self.dbsession.flush()

        # Attach disabled gift card to cart
        cart_gift_card = CartGiftCard(cart=cart, gift_card=gift_card)
        self.dbsession.add(cart_gift_card)
        self.dbsession.flush()

        # Validate should return errors about disabled card
        errors = cart.validate_attached_gift_cards()
        self.assertTrue(len(errors) > 0)
        self.assertTrue(
            any("disabled" in err.lower() for err in errors),
            f"Expected 'disabled' in error messages, got: {errors}",
        )

        transaction.commit()

    def test_gift_card_purchases_in_cart_integration(self):
        """Test cart.json_gift_cards parses gift card purchase entries."""
        user = self._make_user()
        shop = self._make_shop()
        cart = self._make_cart(user, shop)

        # Set json_gift_cards with one purchase entry
        cart.json_gift_cards = json.dumps([
            {
                "shop_id": shop.uuid_str,
                "amount_in_cents": 2500,
                "gift_email": "friend@test.com",
                "gift_message": "Enjoy!",
            }
        ])
        self.dbsession.flush()

        # Test gift_card_purchases returns parsed list
        purchases = cart.gift_card_purchases
        self.assertEqual(len(purchases), 1)
        self.assertEqual(purchases[0]["amount_in_cents"], 2500)
        self.assertEqual(purchases[0]["gift_email"], "friend@test.com")

        # Test total
        self.assertEqual(cart.gift_card_purchases_total_in_cents, 2500)

        transaction.commit()


class TestTrialIntegration(DatabaseIntegrationTests):
    """MPS-15: Integration tests for trial system with real ORM objects."""

    def _make_shop(self, **kwargs):
        shop = Shop(
            name="Trial Shop",
            phone_number="555-555-5555",
            billing_address="123 Test St",
            description="A trial shop",
        )
        shop.domain_name = "trial.test.com"
        for k, v in kwargs.items():
            setattr(shop, k, v)
        self.dbsession.add(shop)
        self.dbsession.flush()
        return shop

    def test_grandfathered_shop_not_expired(self):
        """Pre-trial shops (NULL trial_started_timestamp) are never expired."""
        shop = self._make_shop(trial_started_timestamp=None)
        self.assertFalse(shop.is_trial_expired)
        self.assertFalse(shop.is_trial_active)
        self.assertTrue(shop.is_active)
        transaction.commit()

    def test_active_trial(self):
        """Shop within 21-day trial window is active."""
        now_ms = int(time.time() * 1000)
        shop = self._make_shop(trial_started_timestamp=now_ms)
        self.assertTrue(shop.is_trial_active)
        self.assertFalse(shop.is_trial_expired)
        self.assertTrue(shop.is_active)
        self.assertIn(shop.trial_days_remaining, (20, 21))  # depends on sub-day timing
        transaction.commit()

    def test_expired_trial(self):
        """Shop past 21-day window is expired."""
        expired_ms = int(time.time() * 1000) - (22 * 24 * 60 * 60 * 1000)
        shop = self._make_shop(trial_started_timestamp=expired_ms)
        self.assertFalse(shop.is_trial_active)
        self.assertTrue(shop.is_trial_expired)
        self.assertFalse(shop.is_active)
        self.assertEqual(shop.trial_days_remaining, 0)
        transaction.commit()

    def test_paid_plan_overrides_trial(self):
        """Paid plan makes shop active regardless of trial status."""
        expired_ms = int(time.time() * 1000) - (22 * 24 * 60 * 60 * 1000)
        shop = self._make_shop(trial_started_timestamp=expired_ms, plan_active=True)
        self.assertFalse(shop.is_trial_active)
        self.assertFalse(shop.is_trial_expired)
        self.assertTrue(shop.is_active)
        transaction.commit()

    def test_trial_with_products(self):
        """Products in a trial shop are accessible but cannot be created when expired."""
        expired_ms = int(time.time() * 1000) - (22 * 24 * 60 * 60 * 1000)
        shop = self._make_shop(trial_started_timestamp=expired_ms)

        # Existing products are still in the database and readable
        product = Product(title="Existing Product", description="Created before trial expired")
        product.shop_id = shop.id
        product.price_in_cents = 1000
        self.dbsession.add(product)
        self.dbsession.flush()

        # Shop is expired but product still exists
        self.assertTrue(shop.is_trial_expired)
        self.assertEqual(product.shop_id, shop.id)
        transaction.commit()

    def test_environment_with_trial(self):
        """Environment and trial are independent — non-prod shop can have trial."""
        now_ms = int(time.time() * 1000)
        shop = self._make_shop(trial_started_timestamp=now_ms, environment=1)
        self.assertTrue(shop.is_trial_active)
        self.assertTrue(shop.is_staging)
        self.assertTrue(shop.is_non_production)
        transaction.commit()


class TestBYOBIntegration(DatabaseIntegrationTests):
    """MPS-16: Integration tests for BYOB (Bring Your Own Bucket) with real ORM objects."""

    def _make_shop(self, **kwargs):
        shop = Shop(
            name="BYOB Shop",
            phone_number="555-555-5555",
            billing_address="123 Test St",
            description="A BYOB shop",
        )
        shop.domain_name = "byob.test.com"
        for k, v in kwargs.items():
            setattr(shop, k, v)
        self.dbsession.add(shop)
        self.dbsession.flush()
        return shop

    def test_has_primary_s3_false_by_default(self):
        """New shops do not have BYOB enabled."""
        shop = self._make_shop()
        self.assertFalse(shop.has_primary_s3)
        transaction.commit()

    def test_has_primary_s3_requires_all_fields(self):
        """BYOB requires all fields AND enabled flag."""
        shop = self._make_shop(
            primary_s3_endpoint="https://nyc3.digitaloceanspaces.com",
            primary_s3_region="nyc3",
            primary_s3_bucket="my-bucket",
            primary_s3_access_key="AKIATEST",
            primary_s3_secret_key="secret123",
            primary_s3_cdn_endpoint="https://my-bucket.nyc3.cdn.digitaloceanspaces.com",
            primary_s3_enabled=False,  # not enabled
        )
        self.assertFalse(shop.has_primary_s3)

        shop.primary_s3_enabled = True
        self.assertTrue(shop.has_primary_s3)
        transaction.commit()

    def test_has_primary_s3_missing_field(self):
        """BYOB is false if any required field is missing."""
        shop = self._make_shop(
            primary_s3_endpoint="https://nyc3.digitaloceanspaces.com",
            primary_s3_region="nyc3",
            primary_s3_bucket="my-bucket",
            primary_s3_access_key="AKIATEST",
            primary_s3_secret_key="",  # missing
            primary_s3_cdn_endpoint="https://my-bucket.nyc3.cdn.digitaloceanspaces.com",
            primary_s3_enabled=True,
        )
        self.assertFalse(shop.has_primary_s3)
        transaction.commit()

    def test_media_cdn_endpoint_byob(self):
        """media_cdn_endpoint returns shop's CDN when BYOB is active."""
        shop = self._make_shop(
            primary_s3_endpoint="https://nyc3.digitaloceanspaces.com",
            primary_s3_region="nyc3",
            primary_s3_bucket="my-bucket",
            primary_s3_access_key="AKIATEST",
            primary_s3_secret_key="secret123",
            primary_s3_cdn_endpoint="https://custom-cdn.example.com",
            primary_s3_enabled=True,
        )
        self.assertEqual(shop.media_cdn_endpoint, "https://custom-cdn.example.com")

        # Disable BYOB — CDN returns None
        shop.primary_s3_enabled = False
        self.assertIsNone(shop.media_cdn_endpoint)
        transaction.commit()

    def test_byob_with_mirror(self):
        """BYOB and mirror can coexist on the same shop."""
        shop = self._make_shop(
            primary_s3_endpoint="https://nyc3.digitaloceanspaces.com",
            primary_s3_region="nyc3",
            primary_s3_bucket="primary-bucket",
            primary_s3_access_key="AKIATEST",
            primary_s3_secret_key="secret123",
            primary_s3_cdn_endpoint="https://primary-cdn.example.com",
            primary_s3_enabled=True,
            mirror_s3_endpoint="https://sfo3.digitaloceanspaces.com",
            mirror_s3_region="sfo3",
            mirror_s3_bucket="mirror-bucket",
            mirror_s3_access_key="AKIAMIRROR",
            mirror_s3_secret_key="mirrorsecret",
            mirror_s3_enabled=True,
        )
        self.assertTrue(shop.has_primary_s3)
        self.assertTrue(shop.has_s3_mirror)
        transaction.commit()

    def test_byob_persists_after_flush(self):
        """BYOB fields round-trip through database correctly."""
        shop = self._make_shop(
            primary_s3_endpoint="https://nyc3.digitaloceanspaces.com",
            primary_s3_region="nyc3",
            primary_s3_bucket="test-bucket",
            primary_s3_access_key="AKIATEST",
            primary_s3_secret_key="secret123",
            primary_s3_cdn_endpoint="https://cdn.example.com",
            primary_s3_enabled=True,
        )
        shop_id = shop.id
        self.dbsession.flush()

        # Re-fetch from DB
        fetched = self.dbsession.query(Shop).get(shop_id)
        self.assertTrue(fetched.has_primary_s3)
        self.assertEqual(fetched.primary_s3_bucket, "test-bucket")
        self.assertEqual(fetched.primary_s3_cdn_endpoint, "https://cdn.example.com")
        transaction.commit()


class TestAuctionFoundation(DatabaseIntegrationTests):
    """MPS-20: persist auction + bids + watchers, exercise relationships."""

    def _make_shop_and_product(self, pricing_mode=1):
        from ..models.product import Product
        shop = Shop(
            name="Auction House",
            phone_number="555-555-0001",
            billing_address="1 auction lane",
            description="auction shop",
        )
        shop.stripe_public_api_key = "pk_test"
        shop.stripe_secret_api_key = "sk_test"
        shop.domain_name = "auction.test"
        self.dbsession.add(shop)

        product = Product(title="Rare item", description="One of a kind")
        product.shop = shop
        product.price_in_cents = 10000
        product.is_physical = False
        product.is_sellable = True
        product.pricing_mode = pricing_mode
        self.dbsession.add(product)
        self.dbsession.flush()
        return shop, product

    def test_shop_has_auction_products_property(self):
        """MPS-20: Shop.has_auction_products gates buyer-side auction UI.
        Auctions don't have a shop-level toggle — they're enabled by
        flipping a product's pricing_mode to 1 (auction) or 2 (auction +
        buy_now). The property must reflect that derivation.
        """
        from ..models.product import Product

        shop, product = self._make_shop_and_product(pricing_mode=1)
        self.assertTrue(shop.has_auction_products)

        # Flip to fixed price — no auction products left.
        product.pricing_mode = 0
        self.dbsession.flush()
        self.assertFalse(shop.has_auction_products)

        # pricing_mode 2 (auction + buy_now) also counts.
        product.pricing_mode = 2
        self.dbsession.flush()
        self.assertTrue(shop.has_auction_products)

        # Add a second fixed-price product alongside — still True because
        # at least one auction-mode product exists.
        product.pricing_mode = 1
        p2 = Product(title="Buy now item", description="fixed")
        p2.shop = shop
        p2.price_in_cents = 500
        p2.is_physical = False
        p2.is_sellable = True
        p2.pricing_mode = 0
        self.dbsession.add(p2)
        self.dbsession.flush()
        self.assertTrue(shop.has_auction_products)

    def test_create_and_persist_auction(self):
        from ..models.auction import (
            MpsAuction, AUCTION_STATE_DRAFT, AUCTION_STATE_ACTIVE,
            now_timestamp, get_auction_by_id,
        )
        shop, product = self._make_shop_and_product(pricing_mode=1)

        auction = MpsAuction(
            product=product, shop=shop, start_price_in_cents=5000,
            bid_increment_in_cents=100, soft_close_seconds=60,
        )
        self.dbsession.add(auction)
        self.dbsession.flush()

        self.assertEqual(auction.state, AUCTION_STATE_DRAFT)
        self.assertTrue(auction.is_draft)
        self.assertFalse(auction.is_terminal)
        self.assertEqual(auction.start_price_in_cents, 5000)
        self.assertEqual(auction.bid_increment_in_cents, 100)
        self.assertEqual(auction.currency, "USD")
        auction_id = auction.id
        transaction.commit()

        fetched = get_auction_by_id(self.dbsession, auction_id)
        self.assertIsNotNone(fetched)
        self.assertEqual(fetched.start_price_in_cents, 5000)
        self.assertEqual(fetched.product.title, "Rare item")
        self.assertEqual(fetched.shop.name, "Auction House")

        # Verify Product.auction backref is wired.
        self.assertIs(fetched.product.auction, fetched)

        # Mutate and round-trip.
        fetched.state = AUCTION_STATE_ACTIVE
        fetched.start_timestamp = now_timestamp()
        fetched.end_timestamp = now_timestamp() + 3_600_000
        transaction.commit()

        fetched2 = get_auction_by_id(self.dbsession, auction_id)
        self.assertTrue(fetched2.is_active)

    def test_one_auction_per_product_unique_constraint(self):
        from ..models.auction import MpsAuction
        from sqlalchemy.exc import IntegrityError
        shop, product = self._make_shop_and_product(pricing_mode=1)

        a1 = MpsAuction(product=product, shop=shop, start_price_in_cents=1000)
        self.dbsession.add(a1)
        self.dbsession.flush()
        transaction.commit()

        # Second auction for the same product violates unique index on product_id.
        a2 = MpsAuction(product=product, shop=shop, start_price_in_cents=2000)
        self.dbsession.add(a2)
        with self.assertRaises(IntegrityError):
            self.dbsession.flush()
        transaction.abort()

    def test_bids_relationship_and_current_high(self):
        from ..models.auction import MpsAuction, MpsBid
        shop, product = self._make_shop_and_product(pricing_mode=1)
        u1 = get_or_create_user_by_email(self.dbsession, "bidder1@example.com")
        u2 = get_or_create_user_by_email(self.dbsession, "bidder2@example.com")
        self.dbsession.add(u1)
        self.dbsession.add(u2)
        self.dbsession.flush()

        auction = MpsAuction(
            product=product, shop=shop, start_price_in_cents=1000,
        )
        self.dbsession.add(auction)
        self.dbsession.flush()

        # No bids: current high = start price.
        self.assertEqual(auction.current_high_in_cents, 1000)
        self.assertEqual(auction.min_next_bid_in_cents, 1000)

        b1 = MpsBid(auction=auction, bidder=u1, amount_in_cents=1100)
        b2 = MpsBid(auction=auction, bidder=u2, amount_in_cents=1300)
        b3 = MpsBid(auction=auction, bidder=u1, amount_in_cents=1500)
        for b in (b1, b2, b3):
            self.dbsession.add(b)
        self.dbsession.flush()

        self.assertEqual(auction.bids.count(), 3)
        self.assertEqual(auction.current_high_in_cents, 1500)
        self.assertEqual(auction.min_next_bid_in_cents, 1500 + auction.bid_increment_in_cents)
        transaction.commit()

    def test_reserve_met_logic(self):
        from ..models.auction import MpsAuction, MpsBid
        shop, product = self._make_shop_and_product(pricing_mode=1)
        u1 = get_or_create_user_by_email(self.dbsession, "reserve_bidder@example.com")
        self.dbsession.add(u1)
        self.dbsession.flush()

        auction = MpsAuction(
            product=product, shop=shop, start_price_in_cents=1000,
        )
        auction.reserve_price_in_cents = 5000
        self.dbsession.add(auction)
        self.dbsession.flush()

        self.assertTrue(auction.has_reserve)
        # No bids → reserve not met.
        self.assertFalse(auction.reserve_met)

        # Bid below reserve.
        self.dbsession.add(
            MpsBid(auction=auction, bidder=u1, amount_in_cents=4000)
        )
        self.dbsession.flush()
        self.assertFalse(auction.reserve_met)

        # Bid at/above reserve.
        self.dbsession.add(
            MpsBid(auction=auction, bidder=u1, amount_in_cents=5000)
        )
        self.dbsession.flush()
        self.assertTrue(auction.reserve_met)
        transaction.commit()

    def test_watcher_relationship_and_cascade_delete(self):
        from ..models.auction import (
            MpsAuction, MpsBid, MpsAuctionWatcher, get_auction_by_id,
        )
        shop, product = self._make_shop_and_product(pricing_mode=1)
        watcher_user = get_or_create_user_by_email(self.dbsession, "w@example.com")
        bidder_user = get_or_create_user_by_email(self.dbsession, "b@example.com")
        self.dbsession.add(watcher_user)
        self.dbsession.add(bidder_user)
        self.dbsession.flush()

        auction = MpsAuction(product=product, shop=shop, start_price_in_cents=100)
        self.dbsession.add(auction)
        self.dbsession.flush()

        watcher = MpsAuctionWatcher(auction=auction, user=watcher_user)
        bid = MpsBid(auction=auction, bidder=bidder_user, amount_in_cents=200)
        self.dbsession.add(watcher)
        self.dbsession.add(bid)
        self.dbsession.flush()
        auction_id = auction.id
        transaction.commit()

        # Re-query after commit (session is fresh).
        fetched = get_auction_by_id(self.dbsession, auction_id)
        self.assertEqual(fetched.watchers.count(), 1)
        self.assertEqual(fetched.bids.count(), 1)

        # Delete auction → bids and watchers cascade.
        self.dbsession.delete(fetched)
        self.dbsession.flush()
        self.assertEqual(self.dbsession.query(MpsBid).count(), 0)
        self.assertEqual(self.dbsession.query(MpsAuctionWatcher).count(), 0)
        transaction.commit()


class TestOfferFoundation(DatabaseIntegrationTests):
    """MPS-21: persist offer + events, exercise state + relationships."""

    def _make_shop_and_product(self, pricing_mode=3, allow_offers=None,
                               offer_enabled=True):
        from ..models.product import Product
        shop = Shop(
            name="Negotiation Shop",
            phone_number="555-555-0002",
            billing_address="2 offer way",
            description="offers shop",
        )
        shop.stripe_public_api_key = "pk_test"
        shop.stripe_secret_api_key = "sk_test"
        shop.domain_name = "offers.test"
        shop.offer_enabled = offer_enabled
        self.dbsession.add(shop)

        product = Product(title="Negotiable", description="Make me an offer")
        product.shop = shop
        product.price_in_cents = 10000
        product.is_physical = False
        product.is_sellable = True
        product.pricing_mode = pricing_mode
        product.allow_offers = allow_offers
        self.dbsession.add(product)
        self.dbsession.flush()
        return shop, product

    def test_create_and_persist_offer(self):
        from ..models.offer import (
            MpsOffer, MpsOfferEvent, OFFER_EVENT_OPEN,
            OFFER_STATE_PENDING, OFFER_PARTY_SELLER,
            now_timestamp, get_offer_by_id,
        )
        shop, product = self._make_shop_and_product()
        buyer = get_or_create_user_by_email(self.dbsession, "buyer@example.com")
        self.dbsession.add(buyer)
        self.dbsession.flush()

        expires = now_timestamp() + 7 * 24 * 3600 * 1000
        offer = MpsOffer(
            product=product, shop=shop, buyer=buyer,
            amount_in_cents=8000,
            expires_timestamp=expires,
            buyer_message="please?",
        )
        self.dbsession.add(offer)
        self.dbsession.flush()

        # Open event fires up front (we'll validate state machine in lib tests
        # — for now just confirm we can write & query an event row).
        evt = MpsOfferEvent(
            offer=offer, event_type=OFFER_EVENT_OPEN,
            actor=buyer, amount_in_cents=8000, message="opened",
        )
        self.dbsession.add(evt)
        self.dbsession.flush()
        offer_id = offer.id
        transaction.commit()

        fetched = get_offer_by_id(self.dbsession, offer_id)
        self.assertIsNotNone(fetched)
        self.assertEqual(fetched.state, OFFER_STATE_PENDING)
        self.assertEqual(fetched.current_party, OFFER_PARTY_SELLER)
        self.assertEqual(fetched.current_amount_in_cents, 8000)
        self.assertEqual(fetched.round_count, 0)
        self.assertEqual(fetched.events.count(), 1)
        self.assertEqual(fetched.product.title, "Negotiable")
        self.assertTrue(fetched.is_open)
        self.assertTrue(fetched.waiting_on_seller)

    def test_event_cascade_delete(self):
        from ..models.offer import (
            MpsOffer, MpsOfferEvent, OFFER_EVENT_OPEN, OFFER_EVENT_COUNTER,
            now_timestamp, get_offer_by_id,
        )
        shop, product = self._make_shop_and_product()
        buyer = get_or_create_user_by_email(self.dbsession, "buyer2@example.com")
        self.dbsession.add(buyer)
        self.dbsession.flush()

        offer = MpsOffer(
            product=product, shop=shop, buyer=buyer,
            amount_in_cents=5000, expires_timestamp=now_timestamp() + 1_000_000,
        )
        self.dbsession.add(offer)
        self.dbsession.flush()
        for et in (OFFER_EVENT_OPEN, OFFER_EVENT_COUNTER, OFFER_EVENT_COUNTER):
            self.dbsession.add(
                MpsOfferEvent(offer=offer, event_type=et, actor=buyer)
            )
        self.dbsession.flush()
        offer_id = offer.id
        transaction.commit()

        fetched = get_offer_by_id(self.dbsession, offer_id)
        self.assertEqual(fetched.events.count(), 3)

        # Delete offer → events cascade.
        self.dbsession.delete(fetched)
        self.dbsession.flush()
        self.assertEqual(self.dbsession.query(MpsOfferEvent).count(), 0)
        transaction.commit()


class TestPricingModePersistence(DatabaseIntegrationTests):
    """MPS-20 + MPS-21: pricing_mode + allow_offers persist on Product."""

    def test_pricing_mode_default_zero(self):
        shop = Shop(
            name="Default Shop", phone_number="x", billing_address="x",
            description="x",
        )
        shop.stripe_public_api_key = "pk_test"
        shop.stripe_secret_api_key = "sk_test"
        shop.domain_name = "d.test"
        self.dbsession.add(shop)

        product = Product(title="Plain", description="No mode set")
        product.shop = shop
        product.price_in_cents = 0
        product.is_physical = False
        self.dbsession.add(product)
        self.dbsession.flush()
        product_id = product.id
        transaction.commit()

        fetched = self.dbsession.query(Product).get(product_id)
        self.assertEqual(fetched.pricing_mode, 0)
        self.assertTrue(fetched.is_fixed_price)
        self.assertIsNone(fetched.allow_offers)
        self.assertFalse(fetched.is_offer_mode)
        self.assertFalse(fetched.offers_allowed)

    def test_offers_allowed_inherits_shop_offer_enabled(self):
        # pricing_mode=3 + allow_offers=NULL + shop.offer_enabled=True → True
        shop = Shop(
            name="Offer Shop", phone_number="x", billing_address="x",
            description="x",
        )
        shop.stripe_public_api_key = "pk_test"
        shop.stripe_secret_api_key = "sk_test"
        shop.domain_name = "o.test"
        shop.offer_enabled = True
        self.dbsession.add(shop)

        product = Product(title="Offerable", description="...")
        product.shop = shop
        product.price_in_cents = 5000
        product.is_physical = False
        product.pricing_mode = 3
        # allow_offers=NULL → inherit shop default.
        self.dbsession.add(product)
        self.dbsession.flush()
        product_id = product.id
        transaction.commit()

        fetched = self.dbsession.query(Product).get(product_id)
        self.assertTrue(fetched.is_offer_mode)
        self.assertIsNone(fetched.allow_offers)
        self.assertTrue(fetched.offers_allowed)

    def test_shop_offer_settings_defaults_persist(self):
        shop = Shop(
            name="Settings Shop", phone_number="x", billing_address="x",
            description="x",
        )
        shop.stripe_public_api_key = "pk_test"
        shop.stripe_secret_api_key = "sk_test"
        shop.domain_name = "s.test"
        # Don't set offer_* — defaults should apply on flush.
        self.dbsession.add(shop)
        self.dbsession.flush()
        shop_id = shop.id
        transaction.commit()

        fetched = self.dbsession.query(Shop).get(shop_id)
        self.assertEqual(fetched.offer_auto_accept_threshold_pct, 95)
        self.assertEqual(fetched.offer_auto_decline_threshold_pct, 50)
        self.assertEqual(fetched.offer_expiration_hours, 168)
        self.assertEqual(fetched.offer_max_rounds, 3)
        self.assertEqual(fetched.offer_min_buyer_account_age_hours, 0)
        # Booleans/nullable: offer_enabled defaults None (no server_default set)
        # offer_min_in_cents nullable.
        self.assertIsNone(fetched.offer_min_in_cents)


class TestAuctionPlaceBid(DatabaseIntegrationTests):
    """MPS-20: lib/auction.place_bid orchestrator — writes bid, applies
    soft-close, marks prior winning bid outbid. Real DB."""

    def _make_active_auction(self, start_price=1000, increment=100,
                             soft_close=60, end_in_ms=3_600_000):
        from ..models.auction import (
            MpsAuction, AUCTION_STATE_ACTIVE, now_timestamp,
        )
        shop = Shop(
            name="Active Shop", phone_number="x", billing_address="x",
            description="x",
        )
        shop.stripe_public_api_key = "pk_test"
        shop.stripe_secret_api_key = "sk_test"
        shop.domain_name = "active.test"
        self.dbsession.add(shop)

        product = Product(title="Hot item", description="...")
        product.shop = shop
        product.price_in_cents = start_price
        product.is_physical = False
        product.is_sellable = True
        product.pricing_mode = 1
        self.dbsession.add(product)
        self.dbsession.flush()

        auction = MpsAuction(
            product=product, shop=shop,
            start_price_in_cents=start_price,
            bid_increment_in_cents=increment,
            soft_close_seconds=soft_close,
        )
        auction.state = AUCTION_STATE_ACTIVE
        auction.start_timestamp = now_timestamp() - 60_000
        auction.end_timestamp = now_timestamp() + end_in_ms
        auction.original_end_timestamp = auction.end_timestamp
        self.dbsession.add(auction)
        self.dbsession.flush()
        return shop, product, auction

    def _make_user(self, email):
        u = get_or_create_user_by_email(self.dbsession, email)
        self.dbsession.add(u)
        self.dbsession.flush()
        return u

    def test_place_first_bid(self):
        from ..lib.auction import place_bid
        shop, product, auction = self._make_active_auction()
        u = self._make_user("first@example.com")

        bid = place_bid(auction, u, amount_in_cents=1000)
        self.assertEqual(bid.amount_in_cents, 1000)
        self.assertTrue(bid.is_winning)
        self.assertEqual(auction.bids.count(), 1)
        self.assertEqual(auction.current_high_in_cents, 1000)

    def test_place_bid_below_start_rejected(self):
        from ..lib.auction import place_bid, BidRejected
        shop, product, auction = self._make_active_auction(start_price=1000)
        u = self._make_user("low@example.com")

        with self.assertRaises(BidRejected):
            place_bid(auction, u, amount_in_cents=999)

    def test_place_bid_below_increment_rejected(self):
        from ..lib.auction import place_bid, BidRejected
        shop, product, auction = self._make_active_auction(
            start_price=1000, increment=100,
        )
        u1 = self._make_user("u1@example.com")
        u2 = self._make_user("u2@example.com")
        place_bid(auction, u1, amount_in_cents=1000)
        with self.assertRaises(BidRejected):
            place_bid(auction, u2, amount_in_cents=1099)

    def test_outbid_marks_prior_winning_false(self):
        from ..lib.auction import place_bid
        from ..models.auction import MpsBid
        shop, product, auction = self._make_active_auction()
        u1 = self._make_user("u1@example.com")
        u2 = self._make_user("u2@example.com")

        bid1 = place_bid(auction, u1, amount_in_cents=1000)
        bid2 = place_bid(auction, u2, amount_in_cents=1100)

        self.dbsession.refresh(bid1)
        self.assertFalse(bid1.is_winning)
        self.assertIsNotNone(bid1.outbid_timestamp)
        self.assertTrue(bid2.is_winning)
        self.assertEqual(auction.current_high_in_cents, 1100)

        # Exactly one winning bid at any time.
        winning_count = (
            self.dbsession.query(MpsBid)
            .filter(MpsBid.auction_id == auction.id, MpsBid.is_winning.is_(True))
            .count()
        )
        self.assertEqual(winning_count, 1)

    def test_proxy_defending_bidder_auto_increments(self):
        from ..lib.auction import place_bid
        shop, product, auction = self._make_active_auction(
            start_price=1000, increment=100,
        )
        u1 = self._make_user("proxy_top@example.com")
        u2 = self._make_user("proxy_chal@example.com")

        # u1 bids 1000 with proxy ceiling 5000.
        place_bid(auction, u1, amount_in_cents=1000, max_proxy_in_cents=5000)
        # u2 bids 1500 with proxy ceiling 2000.
        place_bid(auction, u2, amount_in_cents=1500, max_proxy_in_cents=2000)

        # u1 still winning at min(2000 + 100, 5000) = 2100.
        self.assertEqual(auction.current_high_in_cents, 2100)

    def test_soft_close_extends_end_timestamp(self):
        from ..lib.auction import place_bid
        from ..models.auction import now_timestamp
        # Set up an auction ending in 30 seconds with a 60s soft-close window.
        shop, product, auction = self._make_active_auction(
            soft_close=60, end_in_ms=30_000,
        )
        u = self._make_user("late@example.com")
        original_end = auction.end_timestamp

        place_bid(auction, u, amount_in_cents=1000)
        # End should now be roughly now + 60s.
        self.assertGreater(auction.end_timestamp, original_end)
        approx_target = now_timestamp() + 60_000
        self.assertLess(abs(auction.end_timestamp - approx_target), 5_000)
        # original_end_timestamp preserved.
        self.assertEqual(auction.original_end_timestamp, original_end)

    def test_no_soft_close_when_not_in_window(self):
        from ..lib.auction import place_bid
        # Auction with 1 hour remaining, 60s soft-close → not in window.
        shop, product, auction = self._make_active_auction(
            soft_close=60, end_in_ms=3_600_000,
        )
        u = self._make_user("early@example.com")
        original_end = auction.end_timestamp

        place_bid(auction, u, amount_in_cents=1000)
        self.assertEqual(auction.end_timestamp, original_end)

    def test_inactive_auction_rejects_bid(self):
        from ..lib.auction import place_bid, BidRejected
        from ..models.auction import AUCTION_STATE_DRAFT
        shop, product, auction = self._make_active_auction()
        auction.state = AUCTION_STATE_DRAFT
        u = self._make_user("draft@example.com")
        with self.assertRaises(BidRejected):
            place_bid(auction, u, amount_in_cents=1000)


class TestOfferLibOrchestrators(DatabaseIntegrationTests):
    """MPS-21: lib/offer.py orchestrators with real DB persistence."""

    def _make_shop_product_buyer(self, list_price=10000, offer_enabled=True,
                                 auto_accept_pct=95, auto_decline_pct=50,
                                 max_rounds=3, expiration_hours=168,
                                 offer_min=None):
        shop = Shop(
            name="Offer Test Shop", phone_number="x", billing_address="x",
            description="x",
        )
        shop.stripe_public_api_key = "pk_test"
        shop.stripe_secret_api_key = "sk_test"
        shop.domain_name = "offerlib.test"
        shop.offer_enabled = offer_enabled
        shop.offer_auto_accept_threshold_pct = auto_accept_pct
        shop.offer_auto_decline_threshold_pct = auto_decline_pct
        shop.offer_max_rounds = max_rounds
        shop.offer_expiration_hours = expiration_hours
        shop.offer_min_in_cents = offer_min
        self.dbsession.add(shop)

        product = Product(title="Up for offers", description="...")
        product.shop = shop
        product.price_in_cents = list_price
        product.is_physical = False
        product.is_sellable = True
        product.pricing_mode = 3
        self.dbsession.add(product)
        self.dbsession.flush()

        buyer = get_or_create_user_by_email(self.dbsession, "buyer@offerlib.test")
        self.dbsession.add(buyer)
        self.dbsession.flush()
        return shop, product, buyer

    def _make_seller(self, email="seller@offerlib.test"):
        u = get_or_create_user_by_email(self.dbsession, email)
        self.dbsession.add(u)
        self.dbsession.flush()
        return u

    def test_open_offer_below_threshold_auto_declines(self):
        from ..lib.offer import open_offer
        from ..models.offer import OFFER_STATE_DECLINED
        shop, product, buyer = self._make_shop_product_buyer(list_price=10000)
        # 40% of 10000 = 4000 → below 50% decline threshold.
        offer = open_offer(self.dbsession, product, shop, buyer, 4000)
        self.assertEqual(offer.state, OFFER_STATE_DECLINED)
        # Two events: OPEN + DECLINE.
        self.assertEqual(offer.events.count(), 2)

    def test_open_offer_above_threshold_auto_accepts(self):
        from ..lib.offer import open_offer
        from ..models.offer import OFFER_STATE_ACCEPTED
        shop, product, buyer = self._make_shop_product_buyer(list_price=10000)
        # 96% of 10000 = 9600 → above 95% accept threshold.
        offer = open_offer(self.dbsession, product, shop, buyer, 9600)
        self.assertEqual(offer.state, OFFER_STATE_ACCEPTED)
        self.assertEqual(offer.events.count(), 2)  # OPEN + ACCEPT

    def test_open_offer_in_range_queues_for_seller(self):
        from ..lib.offer import open_offer
        from ..models.offer import (
            OFFER_STATE_PENDING, OFFER_PARTY_SELLER,
        )
        shop, product, buyer = self._make_shop_product_buyer(list_price=10000)
        # 70% of 10000 = 7000 → between 50 and 95.
        offer = open_offer(self.dbsession, product, shop, buyer, 7000)
        self.assertEqual(offer.state, OFFER_STATE_PENDING)
        self.assertEqual(offer.current_party, OFFER_PARTY_SELLER)
        self.assertEqual(offer.events.count(), 1)  # OPEN only
        self.assertEqual(offer.round_count, 0)

    def test_open_offer_below_floor_raises(self):
        from ..lib.offer import open_offer, OfferRejected
        shop, product, buyer = self._make_shop_product_buyer(
            list_price=10000, offer_min=5000,
        )
        with self.assertRaises(OfferRejected):
            open_offer(self.dbsession, product, shop, buyer, 4999)

    def test_full_negotiation_flow(self):
        from ..lib.offer import open_offer, counter_offer, accept_offer
        from ..models.offer import (
            OFFER_PARTY_BUYER, OFFER_PARTY_SELLER, OFFER_STATE_ACCEPTED,
        )
        shop, product, buyer = self._make_shop_product_buyer(list_price=10000)
        seller = self._make_seller()

        # Buyer opens at 7000 (in queue range).
        offer = open_offer(self.dbsession, product, shop, buyer, 7000)
        self.assertEqual(offer.current_party, OFFER_PARTY_SELLER)

        # Seller counters at 8500.
        counter_offer(
            offer, seller, OFFER_PARTY_SELLER, 8500, message="how about this?",
        )
        self.assertEqual(offer.current_amount_in_cents, 8500)
        self.assertEqual(offer.current_party, OFFER_PARTY_BUYER)
        self.assertEqual(offer.round_count, 1)

        # Buyer counters at 8000.
        counter_offer(
            offer, buyer, OFFER_PARTY_BUYER, 8000, message="I'll meet halfway",
        )
        self.assertEqual(offer.current_amount_in_cents, 8000)
        self.assertEqual(offer.current_party, OFFER_PARTY_SELLER)
        self.assertEqual(offer.round_count, 2)

        # Seller accepts.
        accept_offer(offer, seller, OFFER_PARTY_SELLER, "deal")
        self.assertEqual(offer.state, OFFER_STATE_ACCEPTED)

        # Events: open + counter + counter + accept = 4
        self.assertEqual(offer.events.count(), 4)

    def test_round_cap_blocks_extra_counter(self):
        from ..lib.offer import open_offer, counter_offer, OfferRejected
        from ..models.offer import OFFER_PARTY_BUYER, OFFER_PARTY_SELLER
        shop, product, buyer = self._make_shop_product_buyer(
            list_price=10000, max_rounds=2,
        )
        seller = self._make_seller()
        offer = open_offer(self.dbsession, product, shop, buyer, 7000)

        counter_offer(offer, seller, OFFER_PARTY_SELLER, 8000)
        counter_offer(offer, buyer, OFFER_PARTY_BUYER, 7500)
        # Round cap = 2; another counter should raise.
        with self.assertRaises(OfferRejected):
            counter_offer(offer, seller, OFFER_PARTY_SELLER, 7800)

    def test_decline_terminates(self):
        from ..lib.offer import open_offer, decline_offer
        from ..models.offer import OFFER_STATE_DECLINED, OFFER_PARTY_SELLER
        shop, product, buyer = self._make_shop_product_buyer(list_price=10000)
        seller = self._make_seller()
        offer = open_offer(self.dbsession, product, shop, buyer, 7000)
        decline_offer(offer, seller, OFFER_PARTY_SELLER, "no thanks")
        self.assertEqual(offer.state, OFFER_STATE_DECLINED)

    def test_withdraw_buyer_only(self):
        from ..lib.offer import open_offer, withdraw_offer
        from ..models.offer import OFFER_STATE_WITHDRAWN
        shop, product, buyer = self._make_shop_product_buyer(list_price=10000)
        offer = open_offer(self.dbsession, product, shop, buyer, 7000)
        withdraw_offer(offer, buyer, "changed my mind")
        self.assertEqual(offer.state, OFFER_STATE_WITHDRAWN)

    def test_expire_only_past_expiration(self):
        from ..lib.offer import open_offer, expire_offer
        from ..models.offer import (
            OFFER_STATE_PENDING, OFFER_STATE_EXPIRED, now_timestamp,
        )
        shop, product, buyer = self._make_shop_product_buyer(list_price=10000)
        offer = open_offer(self.dbsession, product, shop, buyer, 7000)

        # Not yet expired → no-op.
        expire_offer(offer, now_ms=now_timestamp())
        self.assertEqual(offer.state, OFFER_STATE_PENDING)

        # Force time past expiration.
        expire_offer(offer, now_ms=offer.expires_timestamp + 1)
        self.assertEqual(offer.state, OFFER_STATE_EXPIRED)

    def test_mark_paid_only_after_accept(self):
        from ..lib.offer import (
            open_offer, accept_offer, mark_paid, OfferRejected,
        )
        from ..models.offer import OFFER_STATE_PAID, OFFER_PARTY_SELLER
        shop, product, buyer = self._make_shop_product_buyer(list_price=10000)
        seller = self._make_seller()

        offer = open_offer(self.dbsession, product, shop, buyer, 7000)
        # Cannot mark pending offer paid.
        with self.assertRaises(OfferRejected):
            mark_paid(offer)

        accept_offer(offer, seller, OFFER_PARTY_SELLER)
        mark_paid(offer)
        self.assertEqual(offer.state, OFFER_STATE_PAID)
        self.assertIsNotNone(offer.paid_timestamp)


class TestCartAuctionAssociation(DatabaseIntegrationTests):
    """MPS-20: mps_cart_auction association — links a cart to a winning
    auction so checkout pays the bid amount, not list price."""

    def _setup(self):
        from ..models.auction import (
            MpsAuction, AUCTION_STATE_ENDED, now_timestamp,
        )
        from ..models.cart import Cart
        from ..models.product import Product

        shop = Shop(
            name="Auction Cart Shop", phone_number="x", billing_address="x",
            description="x",
        )
        shop.stripe_public_api_key = "pk_test"
        shop.stripe_secret_api_key = "sk_test"
        shop.domain_name = "ac.test"
        self.dbsession.add(shop)

        product = Product(title="Won item", description="...")
        product.shop = shop
        product.price_in_cents = 5000
        product.is_physical = False
        product.is_sellable = True
        product.pricing_mode = 1
        self.dbsession.add(product)

        winner = get_or_create_user_by_email(self.dbsession, "winner@ac.test")
        self.dbsession.add(winner)
        self.dbsession.flush()

        auction = MpsAuction(
            product=product, shop=shop, start_price_in_cents=1000,
        )
        auction.state = AUCTION_STATE_ENDED
        auction.start_timestamp = now_timestamp() - 7_200_000
        auction.end_timestamp = now_timestamp() - 60_000
        auction.winner = winner
        self.dbsession.add(auction)
        self.dbsession.flush()

        cart = Cart(user=winner)
        cart.shop = shop
        self.dbsession.add(cart)
        self.dbsession.flush()
        return shop, product, auction, winner, cart

    def test_create_cart_auction_association(self):
        from ..models.cart_auction import MpsCartAuction
        shop, product, auction, winner, cart = self._setup()

        ca = MpsCartAuction(cart=cart, auction=auction)
        self.dbsession.add(ca)
        self.dbsession.flush()
        cart_id = cart.id
        auction_id = auction.id
        transaction.commit()

        # Re-query and verify backref.
        from ..models.cart import Cart
        fetched_cart = self.dbsession.query(Cart).get(cart_id)
        self.assertEqual(len(fetched_cart.cart_auctions), 1)
        self.assertEqual(fetched_cart.cart_auctions[0].auction.id, auction_id)

    def test_cart_auction_cascade_on_cart_delete(self):
        from ..models.cart_auction import MpsCartAuction
        shop, product, auction, winner, cart = self._setup()

        ca = MpsCartAuction(cart=cart, auction=auction)
        self.dbsession.add(ca)
        self.dbsession.flush()
        transaction.commit()

        self.assertEqual(self.dbsession.query(MpsCartAuction).count(), 1)
        self.dbsession.delete(cart)
        self.dbsession.flush()
        self.assertEqual(self.dbsession.query(MpsCartAuction).count(), 0)
        # Auction itself unaffected.
        from ..models.auction import MpsAuction
        self.assertEqual(self.dbsession.query(MpsAuction).count(), 1)
        transaction.commit()


class TestCartOfferAssociation(DatabaseIntegrationTests):
    """MPS-21: mps_cart_offer association — links a cart to an accepted
    offer so checkout pays the agreed amount."""

    def _setup(self):
        from ..models.cart import Cart
        from ..models.offer import (
            MpsOffer, OFFER_STATE_ACCEPTED, now_timestamp,
        )
        from ..models.product import Product

        shop = Shop(
            name="Offer Cart Shop", phone_number="x", billing_address="x",
            description="x",
        )
        shop.stripe_public_api_key = "pk_test"
        shop.stripe_secret_api_key = "sk_test"
        shop.domain_name = "oc.test"
        self.dbsession.add(shop)

        product = Product(title="Offer-bought", description="...")
        product.shop = shop
        product.price_in_cents = 10000
        product.is_physical = False
        product.is_sellable = True
        product.pricing_mode = 3
        self.dbsession.add(product)

        buyer = get_or_create_user_by_email(self.dbsession, "buyer@oc.test")
        self.dbsession.add(buyer)
        self.dbsession.flush()

        offer = MpsOffer(
            product=product, shop=shop, buyer=buyer,
            amount_in_cents=8000,
            expires_timestamp=now_timestamp() + 86_400_000,
        )
        offer.state = OFFER_STATE_ACCEPTED
        self.dbsession.add(offer)
        self.dbsession.flush()

        cart = Cart(user=buyer)
        cart.shop = shop
        self.dbsession.add(cart)
        self.dbsession.flush()
        return shop, product, offer, buyer, cart

    def test_create_cart_offer_association(self):
        from ..models.cart_offer import MpsCartOffer
        shop, product, offer, buyer, cart = self._setup()

        co = MpsCartOffer(cart=cart, offer=offer)
        self.dbsession.add(co)
        self.dbsession.flush()
        cart_id = cart.id
        offer_id = offer.id
        transaction.commit()

        from ..models.cart import Cart
        fetched_cart = self.dbsession.query(Cart).get(cart_id)
        self.assertEqual(len(fetched_cart.cart_offers), 1)
        self.assertEqual(fetched_cart.cart_offers[0].offer.id, offer_id)

    def test_cart_offer_cascade_on_cart_delete(self):
        from ..models.cart_offer import MpsCartOffer
        shop, product, offer, buyer, cart = self._setup()
        co = MpsCartOffer(cart=cart, offer=offer)
        self.dbsession.add(co)
        self.dbsession.flush()
        transaction.commit()

        self.assertEqual(self.dbsession.query(MpsCartOffer).count(), 1)
        self.dbsession.delete(cart)
        self.dbsession.flush()
        self.assertEqual(self.dbsession.query(MpsCartOffer).count(), 0)
        # Offer itself unaffected.
        from ..models.offer import MpsOffer
        self.assertEqual(self.dbsession.query(MpsOffer).count(), 1)
        transaction.commit()


class TestCartTotalOverride(DatabaseIntegrationTests):
    """MPS-20 + MPS-21: cart total uses auction.winning_bid or
    offer.current_amount when association is present."""

    def _shop_product(self, list_price=10000):
        from ..models.product import Product
        shop = Shop(
            name="Total Override Shop", phone_number="x", billing_address="x",
            description="x",
        )
        shop.stripe_public_api_key = "pk_test"
        shop.stripe_secret_api_key = "sk_test"
        shop.domain_name = "to.test"
        self.dbsession.add(shop)
        product = Product(title="Item", description="...")
        product.shop = shop
        product.price_in_cents = list_price
        product.is_physical = False
        product.is_sellable = True
        self.dbsession.add(product)
        self.dbsession.flush()
        return shop, product

    def test_no_override_when_no_association(self):
        from ..models.cart import Cart
        shop, product = self._shop_product(list_price=10000)
        user = get_or_create_user_by_email(self.dbsession, "u@to.test")
        self.dbsession.add(user)
        cart = Cart(user=user)
        cart.shop = shop
        self.dbsession.add(cart)
        cart.add_product(product)
        self.dbsession.flush()
        self.assertIsNone(cart.auction_offer_override_in_cents)
        self.assertEqual(cart.total_price_in_cents, 10000)

    def test_auction_override_uses_winning_bid(self):
        from ..models.cart import Cart
        from ..models.cart_auction import MpsCartAuction
        from ..models.auction import (
            MpsAuction, MpsBid, AUCTION_STATE_ENDED, now_timestamp,
        )
        shop, product = self._shop_product(list_price=10000)
        product.pricing_mode = 1
        winner = get_or_create_user_by_email(self.dbsession, "w@to.test")
        self.dbsession.add(winner)
        self.dbsession.flush()

        auction = MpsAuction(
            product=product, shop=shop, start_price_in_cents=1000,
        )
        auction.state = AUCTION_STATE_ENDED
        auction.start_timestamp = now_timestamp() - 60_000
        auction.end_timestamp = now_timestamp() - 1_000
        auction.winner = winner
        self.dbsession.add(auction)
        bid = MpsBid(auction=auction, bidder=winner, amount_in_cents=4200)
        bid.is_winning = True
        self.dbsession.add(bid)
        self.dbsession.flush()

        cart = Cart(user=winner)
        cart.shop = shop
        cart.add_product(product)
        self.dbsession.add(cart)
        self.dbsession.add(MpsCartAuction(cart=cart, auction=auction))
        self.dbsession.flush()

        # Override: $42 not $100.
        self.assertEqual(cart.auction_offer_override_in_cents, 4200)
        self.assertEqual(cart.total_price_in_cents, 4200)
        # The charge path (total_in_cents) and the discounted path must
        # both honor the override — otherwise the cart strikethroughs the
        # agreed price and charges the list price.
        self.assertEqual(cart.total_discounted_price_in_cents, 4200)
        self.assertEqual(cart.total_in_cents, 4200)
        self.assertFalse(cart.is_discounted)
        # Negotiation card needs these.
        self.assertTrue(cart.is_negotiated)
        self.assertEqual(cart.negotiation_kind, "auction")
        self.assertTrue(cart.negotiation_path.startswith("/a/"))
        self.assertEqual(cart.list_total_in_cents, 10000)
        self.assertEqual(cart.savings_in_cents, 5800)

    def test_offer_override_uses_current_amount(self):
        from ..models.cart import Cart
        from ..models.cart_offer import MpsCartOffer
        from ..models.offer import (
            MpsOffer, OFFER_STATE_ACCEPTED, now_timestamp,
        )
        shop, product = self._shop_product(list_price=10000)
        product.pricing_mode = 3
        buyer = get_or_create_user_by_email(self.dbsession, "b@to.test")
        self.dbsession.add(buyer)
        self.dbsession.flush()

        offer = MpsOffer(
            product=product, shop=shop, buyer=buyer,
            amount_in_cents=8000,
            expires_timestamp=now_timestamp() + 86_400_000,
        )
        offer.state = OFFER_STATE_ACCEPTED
        self.dbsession.add(offer)
        self.dbsession.flush()

        cart = Cart(user=buyer)
        cart.shop = shop
        cart.add_product(product)
        self.dbsession.add(cart)
        self.dbsession.add(MpsCartOffer(cart=cart, offer=offer))
        self.dbsession.flush()

        # Override: $80 not $100.
        self.assertEqual(cart.auction_offer_override_in_cents, 8000)
        self.assertEqual(cart.total_price_in_cents, 8000)
        # Cart logic was built for coupons (a discount on top of list
        # price). Offers are a negotiated price, not a discount — both
        # totals must agree, so is_discounted is False and the buyer is
        # charged the offer amount, not the list price.
        self.assertEqual(cart.total_discounted_price_in_cents, 8000)
        self.assertEqual(cart.total_in_cents, 8000)
        self.assertFalse(cart.is_discounted)
        # Negotiation card props.
        self.assertTrue(cart.is_negotiated)
        self.assertEqual(cart.negotiation_kind, "offer")
        self.assertTrue(cart.negotiation_path.startswith("/o/"))
        self.assertEqual(cart.negotiated_product.id, product.id)
        self.assertEqual(cart.list_total_in_cents, 10000)
        self.assertEqual(cart.savings_in_cents, 2000)


class TestAuctionTickIntegration(DatabaseIntegrationTests):
    """MPS-20: lib/auction_tick.tick state transitions over time."""

    def _shop_product(self):
        from ..models.product import Product
        shop = Shop(
            name="Tick Shop", phone_number="x", billing_address="x",
            description="x",
        )
        shop.stripe_public_api_key = "pk_test"
        shop.stripe_secret_api_key = "sk_test"
        shop.domain_name = "tick.test"
        self.dbsession.add(shop)
        product = Product(title="Tick item", description="...")
        product.shop = shop
        product.price_in_cents = 5000
        product.is_physical = False
        product.is_sellable = True
        product.pricing_mode = 1
        self.dbsession.add(product)
        self.dbsession.flush()
        return shop, product

    def test_scheduled_auction_activates_when_start_passes(self):
        from ..lib.auction_tick import tick
        from ..models.auction import (
            MpsAuction, AUCTION_STATE_SCHEDULED, AUCTION_STATE_ACTIVE,
            now_timestamp,
        )
        shop, product = self._shop_product()
        auction = MpsAuction(
            product=product, shop=shop, start_price_in_cents=1000,
        )
        auction.state = AUCTION_STATE_SCHEDULED
        auction.start_timestamp = now_timestamp() - 1000  # passed
        auction.end_timestamp = now_timestamp() + 60_000
        self.dbsession.add(auction)
        self.dbsession.flush()

        result = tick(self.dbsession)
        self.assertEqual(result["activated"], 1)
        self.assertEqual(result["ended"], 0)
        self.assertEqual(auction.state, AUCTION_STATE_ACTIVE)

    def test_active_auction_ends_when_end_passes_and_records_winner(self):
        from ..lib.auction_tick import tick
        from ..models.auction import (
            MpsAuction, MpsBid, AUCTION_STATE_ACTIVE, AUCTION_STATE_ENDED,
            now_timestamp,
        )
        shop, product = self._shop_product()
        bidder = get_or_create_user_by_email(self.dbsession, "bidder@tick.test")
        self.dbsession.add(bidder)
        self.dbsession.flush()

        auction = MpsAuction(
            product=product, shop=shop, start_price_in_cents=1000,
        )
        auction.state = AUCTION_STATE_ACTIVE
        auction.start_timestamp = now_timestamp() - 60_000
        auction.end_timestamp = now_timestamp() - 1000  # passed
        self.dbsession.add(auction)
        bid = MpsBid(auction=auction, bidder=bidder, amount_in_cents=2000)
        bid.is_winning = True
        self.dbsession.add(bid)
        self.dbsession.flush()

        result = tick(self.dbsession)
        self.assertEqual(result["ended"], 1)
        self.assertEqual(auction.state, AUCTION_STATE_ENDED)
        self.assertEqual(auction.winner_user_id, bidder.id)
        self.assertEqual(auction.winning_bid_id, bid.id)
        self.assertIsNotNone(auction.payment_deadline_timestamp)

    def test_active_auction_ends_with_no_bids_no_winner(self):
        from ..lib.auction_tick import tick
        from ..models.auction import (
            MpsAuction, AUCTION_STATE_ACTIVE, AUCTION_STATE_ENDED,
            now_timestamp,
        )
        shop, product = self._shop_product()
        auction = MpsAuction(
            product=product, shop=shop, start_price_in_cents=1000,
        )
        auction.state = AUCTION_STATE_ACTIVE
        auction.start_timestamp = now_timestamp() - 60_000
        auction.end_timestamp = now_timestamp() - 1000
        self.dbsession.add(auction)
        self.dbsession.flush()

        result = tick(self.dbsession)
        self.assertEqual(result["ended"], 1)
        self.assertEqual(auction.state, AUCTION_STATE_ENDED)
        self.assertIsNone(auction.winner_user_id)
        self.assertIsNone(auction.winning_bid_id)

    def test_tick_idempotent(self):
        from ..lib.auction_tick import tick
        from ..models.auction import (
            MpsAuction, AUCTION_STATE_ACTIVE, now_timestamp,
        )
        shop, product = self._shop_product()
        auction = MpsAuction(
            product=product, shop=shop, start_price_in_cents=1000,
        )
        auction.state = AUCTION_STATE_ACTIVE
        auction.start_timestamp = now_timestamp() - 60_000
        auction.end_timestamp = now_timestamp() - 1000
        self.dbsession.add(auction)
        self.dbsession.flush()

        result1 = tick(self.dbsession)
        self.assertEqual(result1["ended"], 1)
        # Second tick on already-ended auction should be a no-op.
        result2 = tick(self.dbsession)
        self.assertEqual(result2["ended"], 0)
        self.assertEqual(result2["activated"], 0)


class TestOfferTickIntegration(DatabaseIntegrationTests):
    """MPS-21: lib/offer_tick.tick auto-expiration."""

    def _shop_product_buyer(self):
        from ..models.product import Product
        shop = Shop(
            name="Offer Tick Shop", phone_number="x", billing_address="x",
            description="x",
        )
        shop.stripe_public_api_key = "pk_test"
        shop.stripe_secret_api_key = "sk_test"
        shop.domain_name = "ot.test"
        self.dbsession.add(shop)
        product = Product(title="Offer tick", description="...")
        product.shop = shop
        product.price_in_cents = 10000
        product.is_physical = False
        product.is_sellable = True
        product.pricing_mode = 3
        self.dbsession.add(product)
        buyer = get_or_create_user_by_email(self.dbsession, "buyer@ot.test")
        self.dbsession.add(buyer)
        self.dbsession.flush()
        return shop, product, buyer

    def test_expired_pending_offer_flips_to_expired(self):
        from ..lib.offer_tick import tick
        from ..models.offer import (
            MpsOffer, OFFER_STATE_PENDING, OFFER_STATE_EXPIRED,
            now_timestamp,
        )
        shop, product, buyer = self._shop_product_buyer()
        offer = MpsOffer(
            product=product, shop=shop, buyer=buyer,
            amount_in_cents=5000,
            expires_timestamp=now_timestamp() - 1000,  # already past
        )
        self.dbsession.add(offer)
        self.dbsession.flush()
        self.assertEqual(offer.state, OFFER_STATE_PENDING)

        result = tick(self.dbsession)
        self.assertEqual(result["expired"], 1)
        self.assertEqual(offer.state, OFFER_STATE_EXPIRED)

    def test_live_offer_not_touched(self):
        from ..lib.offer_tick import tick
        from ..models.offer import (
            MpsOffer, OFFER_STATE_PENDING, now_timestamp,
        )
        shop, product, buyer = self._shop_product_buyer()
        offer = MpsOffer(
            product=product, shop=shop, buyer=buyer,
            amount_in_cents=5000,
            expires_timestamp=now_timestamp() + 86_400_000,  # 1 day from now
        )
        self.dbsession.add(offer)
        self.dbsession.flush()

        result = tick(self.dbsession)
        self.assertEqual(result["expired"], 0)
        self.assertEqual(offer.state, OFFER_STATE_PENDING)

    def test_terminal_offer_not_touched(self):
        from ..lib.offer_tick import tick
        from ..models.offer import (
            MpsOffer, OFFER_STATE_DECLINED, now_timestamp,
        )
        shop, product, buyer = self._shop_product_buyer()
        offer = MpsOffer(
            product=product, shop=shop, buyer=buyer,
            amount_in_cents=5000,
            expires_timestamp=now_timestamp() - 1000,
        )
        offer.state = OFFER_STATE_DECLINED  # terminal
        self.dbsession.add(offer)
        self.dbsession.flush()

        result = tick(self.dbsession)
        self.assertEqual(result["expired"], 0)
        self.assertEqual(offer.state, OFFER_STATE_DECLINED)

    def test_tick_idempotent(self):
        from ..lib.offer_tick import tick
        from ..models.offer import MpsOffer, now_timestamp
        shop, product, buyer = self._shop_product_buyer()
        offer = MpsOffer(
            product=product, shop=shop, buyer=buyer,
            amount_in_cents=5000,
            expires_timestamp=now_timestamp() - 1000,
        )
        self.dbsession.add(offer)
        self.dbsession.flush()

        result1 = tick(self.dbsession)
        self.assertEqual(result1["expired"], 1)
        result2 = tick(self.dbsession)
        self.assertEqual(result2["expired"], 0)

    def test_accepted_offer_expires_after_pay_window(self):
        """ACCEPTED offers past shop.offer_acceptance_payment_hours flip
        to EXPIRED when the tick runs."""
        from ..lib.offer_tick import tick
        from ..models.offer import (
            MpsOffer, OFFER_STATE_ACCEPTED, OFFER_STATE_EXPIRED, now_timestamp,
        )
        shop, product, buyer = self._shop_product_buyer()
        shop.offer_acceptance_payment_hours = 1  # 1 hour pay window
        offer = MpsOffer(
            product=product, shop=shop, buyer=buyer,
            amount_in_cents=5000,
            expires_timestamp=now_timestamp() + 86_400_000,
        )
        # Pretend it was accepted 2 hours ago — past the 1-hour pay window.
        offer.state = OFFER_STATE_ACCEPTED
        offer.accepted_timestamp = now_timestamp() - (2 * 3600 * 1000)
        self.dbsession.add(offer)
        self.dbsession.flush()

        result = tick(self.dbsession)
        self.assertEqual(result["expired"], 1)
        self.assertEqual(offer.state, OFFER_STATE_EXPIRED)

    def test_accepted_offer_inside_pay_window_not_touched(self):
        """ACCEPTED offers still within the pay window stay accepted."""
        from ..lib.offer_tick import tick
        from ..models.offer import (
            MpsOffer, OFFER_STATE_ACCEPTED, now_timestamp,
        )
        shop, product, buyer = self._shop_product_buyer()
        shop.offer_acceptance_payment_hours = 24
        offer = MpsOffer(
            product=product, shop=shop, buyer=buyer,
            amount_in_cents=5000,
            expires_timestamp=now_timestamp() + 86_400_000,
        )
        offer.state = OFFER_STATE_ACCEPTED
        offer.accepted_timestamp = now_timestamp() - 1000  # 1s ago
        self.dbsession.add(offer)
        self.dbsession.flush()

        result = tick(self.dbsession)
        self.assertEqual(result["expired"], 0)
        self.assertEqual(offer.state, OFFER_STATE_ACCEPTED)

    def test_buyer_cancel_after_accept_flips_state(self):
        """cancel_offer_after_accept flips ACCEPTED → BUYER_CANCELLED."""
        from ..lib.offer import cancel_offer_after_accept, OfferRejected
        from ..models.offer import (
            MpsOffer, OFFER_STATE_ACCEPTED, OFFER_STATE_BUYER_CANCELLED,
            OFFER_STATE_PAID, now_timestamp,
        )
        shop, product, buyer = self._shop_product_buyer()
        offer = MpsOffer(
            product=product, shop=shop, buyer=buyer,
            amount_in_cents=5000,
            expires_timestamp=now_timestamp() + 86_400_000,
        )
        offer.state = OFFER_STATE_ACCEPTED
        offer.accepted_timestamp = now_timestamp()
        self.dbsession.add(offer)
        self.dbsession.flush()

        cancel_offer_after_accept(offer, buyer, message="changed mind")
        self.assertEqual(offer.state, OFFER_STATE_BUYER_CANCELLED)
        self.assertTrue(offer.is_buyer_cancelled)
        # Event log captured the cancellation.
        latest = offer.events.order_by(None).all()[-1]
        self.assertEqual(latest.message, "changed mind")

        # Cannot cancel a paid offer.
        offer.state = OFFER_STATE_PAID
        with self.assertRaises(OfferRejected):
            cancel_offer_after_accept(offer, buyer)
