import unittest
import uuid

import mock

from ..models import User, Coupon, Shop, is_user_name_valid
from ..models.crypto_payment import CryptoPayment
from ..models.invoice import Invoice
from ..models.meta import now_timestamp, short_id_to_bytes, id_to_uuid
from ..views.signals import classify_referrer, classify_device

mock_always_none = mock.Mock(return_value=None)
mock_always_true = mock.Mock(return_value=True)
mock_false_then_true = mock.Mock(side_effect=[False, False, True])


class TestUser(unittest.TestCase):
    @mock.patch("make_post_sell.models.user.is_user_name_available", mock_always_true)
    def setUp(self):
        self.user = User("russell@ballestrini.net")

    def test_created_timestamp_set(self):
        self.assertGreater(self.user.created_timestamp, 100000)

    def test_email_set(self):
        self.assertEqual(self.user.email, "russell@ballestrini.net")

    def test_new_password(self):
        raw_password = self.user.new_password()
        self.assertEqual(len(raw_password), 6)

    def test_check_password_success(self):
        raw_password = self.user.new_password()
        self.assertTrue(self.user.check_password(raw_password))

    def test_check_password_failure(self):
        raw_password = self.user.new_password()
        self.assertFalse(self.user.check_password("fake password"))

    def test_is_user_name_valid(self):
        self.assertTrue(is_user_name_valid("validusername"))
        self.assertTrue(is_user_name_valid("validusername2"))
        self.assertTrue(is_user_name_valid("ValidUsername"))
        self.assertTrue(is_user_name_valid("valid-username"))
        self.assertTrue(is_user_name_valid("valid username"))

        self.assertFalse(is_user_name_valid("invalid!!!"))
        self.assertFalse(is_user_name_valid(""))

    @mock.patch("make_post_sell.models.user.is_user_name_available", mock_always_true)
    def test_generate_user_name_no_dash_prefix(self):
        u = User("tim@example.com")
        self.assertFalse(u.name.startswith("-"))


class TestCart(unittest.TestCase):

    # TODO: please test the fitness of the the Cart model.
    #
    #       We have deliberately not written these tests but we should
    #       before going to much further. Unit tests are much faster and
    #       more grainular than functional tests.
    #
    #       I'm moving forward without creating these tests because I have
    #       a functional test running as a safety net.
    #
    #       That said unit tests are much better at capturing expected
    #       behavior and have better tooling for test and codepath coverage.
    #
    #       Please help write unit tests soon!

    def setUp(self):
        pass

    def validate_attached_coupon_codes(self):
        """Make sure all coupons attached to the cart met the terms."""
        pass

    def test_cart_requires_payment_above_threshold(self):
        """Test that cart requires payment when total is above $0.64 (64 cents)."""
        from make_post_sell.models.cart import Cart

        cart = Cart()
        # Mock the total_in_cents property to return value above threshold
        with mock.patch.object(
            type(cart), "total_in_cents", new_callable=mock.PropertyMock
        ) as mock_total:
            mock_total.return_value = 65  # Above 64 cent threshold
            self.assertTrue(cart.requires_payment)

    def test_cart_requires_payment_at_threshold(self):
        """Test that cart requires payment at exactly $0.64 (64 cents)."""
        from make_post_sell.models.cart import Cart

        cart = Cart()
        with mock.patch.object(
            type(cart), "total_in_cents", new_callable=mock.PropertyMock
        ) as mock_total:
            mock_total.return_value = 64  # At threshold
            self.assertFalse(cart.requires_payment)

    def test_cart_requires_payment_below_threshold(self):
        """Test that cart does not require payment when total is below $0.64."""
        from make_post_sell.models.cart import Cart

        cart = Cart()
        with mock.patch.object(
            type(cart), "total_in_cents", new_callable=mock.PropertyMock
        ) as mock_total:
            mock_total.return_value = 30  # Below 64 cent threshold
            self.assertFalse(cart.requires_payment)

    def test_cart_requires_payment_zero_total(self):
        """Test that cart does not require payment when total is zero (free cart)."""
        from make_post_sell.models.cart import Cart

        cart = Cart()
        with mock.patch.object(
            type(cart), "total_in_cents", new_callable=mock.PropertyMock
        ) as mock_total:
            mock_total.return_value = 0  # Free cart
            self.assertFalse(cart.requires_payment)

    def test_cart_is_not_public_property(self):
        """Test cart.is_not_public property logic."""
        from make_post_sell.models.cart import Cart

        cart = Cart()

        # Test the actual logic: is_not_public should return opposite of public
        cart.public = True
        self.assertFalse(cart.is_not_public)

        cart.public = False
        self.assertTrue(cart.is_not_public)

    def test_cart_empty_method(self):
        """Test cart.empty() method clears cart contents."""
        from make_post_sell.models.cart import Cart

        cart = Cart()
        cart.set_cart({"product1": 2, "product2": 3})

        # Cart should have items
        self.assertNotEqual(cart.get_cart(), {})

        # Empty should clear everything
        cart.empty()
        self.assertEqual(cart.get_cart(), {})

    def test_cart_set_and_get_cart(self):
        """Test cart.set_cart() and get_cart() methods."""
        from make_post_sell.models.cart import Cart

        cart = Cart()
        test_cart_data = {"product1": 2, "product2": 3}

        cart.set_cart(test_cart_data)
        retrieved_cart = cart.get_cart()

        self.assertEqual(retrieved_cart, test_cart_data)

    def test_cart_add_product(self):
        """Test cart.add_product() method."""
        from make_post_sell.models.cart import Cart

        cart = Cart()

        # Mock product with proper uuid_str
        mock_product = mock.Mock()
        mock_product.uuid_str = "product1"  # This is what cart uses as key

        # Add product
        cart.add_product(mock_product)

        # Should have quantity 1
        self.assertEqual(cart.get_product_quantity(mock_product), 1)

        # Add same product again
        cart.add_product(mock_product)

        # Should have quantity 2
        self.assertEqual(cart.get_product_quantity(mock_product), 2)

    def test_cart_remove_product(self):
        """Test cart.remove_product() method."""
        from make_post_sell.models.cart import Cart

        cart = Cart()

        # Mock product
        mock_product = mock.Mock()
        mock_product.uuid_str = "product1"

        # Add product first
        cart.add_product(mock_product)
        cart.add_product(mock_product)  # quantity = 2

        # Remove product completely (removes all quantity)
        cart.remove_product(mock_product)
        self.assertEqual(cart.get_product_quantity(mock_product), 0)

    def test_cart_set_product_quantity(self):
        """Test cart.set_product_quantity() method."""
        from make_post_sell.models.cart import Cart

        cart = Cart()

        # Mock product
        mock_product = mock.Mock()
        mock_product.uuid_str = "product1"

        # Set quantity directly
        cart.set_product_quantity(mock_product, 5)
        self.assertEqual(cart.get_product_quantity(mock_product), 5)

        # Set to zero should remove product
        cart.set_product_quantity(mock_product, 0)
        self.assertEqual(cart.get_product_quantity(mock_product), 0)

    def test_cart_get_product_quantity(self):
        """Test cart.get_product_quantity() method."""
        from make_post_sell.models.cart import Cart

        cart = Cart()

        # Mock product
        mock_product = mock.Mock()
        mock_product.uuid_str = "product1"

        # Should return 0 for product not in cart
        self.assertEqual(cart.get_product_quantity(mock_product), 0)

        # Add product and test
        cart.set_product_quantity(mock_product, 3)
        self.assertEqual(cart.get_product_quantity(mock_product), 3)

    def test_cart_merge_in_cart(self):
        """Test cart.merge_in_cart() method."""
        from make_post_sell.models.cart import Cart

        cart1 = Cart()
        cart2 = Cart()

        # Set up cart1
        cart1.set_cart({"product1": 2, "product2": 1})

        # Set up cart2
        cart2.set_cart({"product2": 1, "product3": 3})

        # Merge cart2 into cart1
        cart1.merge_in_cart(cart2)

        result = cart1.get_cart()

        # Should have product1: 2, product2: 2 (1+1), product3: 3
        self.assertEqual(result["product1"], 2)
        self.assertEqual(result["product2"], 2)  # Merged
        self.assertEqual(result["product3"], 3)

    def test_cart_validate_attached_coupons_no_coupons(self):
        """Test coupon validation when no coupons attached."""
        from make_post_sell.models.cart import Cart

        cart = Cart()

        # Mock the coupons property to return empty list
        with mock.patch.object(
            type(cart), "coupons", new_callable=mock.PropertyMock
        ) as mock_coupons:
            mock_coupons.return_value = []

            errors = cart.validate_attached_coupons()
            self.assertEqual(errors, [])

    def test_cart_remove_handling_if_no_physical_products(self):
        """Test remove_handling_if_no_physical_products method."""
        from make_post_sell.models.cart import Cart

        cart = Cart()
        cart.handling_option = "pickup"
        cart.handling_cost_in_cents = 500

        # Mock no physical products by mocking the property
        with mock.patch.object(
            type(cart), "physical_products", new_callable=mock.PropertyMock
        ) as mock_physical:
            mock_physical.return_value = {}

            cart.remove_handling_if_no_physical_products()

            # Should clear handling
            self.assertIsNone(cart.handling_option)
            self.assertEqual(cart.handling_cost_in_cents, 0)

    def test_cart_bust_memoized_attributes(self):
        """Test that _bust_memoized_attributes clears all cached properties."""
        from make_post_sell.models.cart import Cart

        cart = Cart()

        # Set some memoized attributes
        cart._count = 5
        cart._line_totals_in_cents = {"test": 100}
        cart._products = {"test": "product"}
        cart._physical_products = {"test": "physical"}
        cart._shops = {"test": "shop"}
        cart._shop_product_dict = {"test": []}
        cart._shop_totals_in_cents = {"test": 100}
        cart._shop_totals = {"test": 1.0}
        cart._discounted_shop_totals_in_cents = {"test": 90}
        cart._discounted_shop_totals = {"test": 0.9}
        cart._line_totals = {"test": 1.0}

        # Call the bust method
        cart._bust_memoized_attributes()

        # Verify all attributes are cleared
        self.assertFalse(hasattr(cart, "_count"))
        self.assertFalse(hasattr(cart, "_line_totals_in_cents"))
        self.assertFalse(hasattr(cart, "_products"))
        self.assertFalse(hasattr(cart, "_physical_products"))
        self.assertFalse(hasattr(cart, "_shops"))
        self.assertFalse(hasattr(cart, "_shop_product_dict"))
        self.assertFalse(hasattr(cart, "_shop_totals_in_cents"))
        self.assertFalse(hasattr(cart, "_shop_totals"))
        self.assertFalse(hasattr(cart, "_discounted_shop_totals_in_cents"))
        self.assertFalse(hasattr(cart, "_discounted_shop_totals"))
        self.assertFalse(hasattr(cart, "_line_totals"))

    def test_cart_set_product_quantity_max_limit(self):
        """Test that set_product_quantity enforces 999 max limit."""
        from make_post_sell.models.cart import Cart

        cart = Cart()
        mock_product = mock.Mock()
        mock_product.uuid_str = "product1"

        # Test setting quantity above 999
        cart.set_product_quantity(mock_product, 1500)
        self.assertEqual(cart.get_product_quantity(mock_product), 999)

        # Test setting quantity at 999
        cart.set_product_quantity(mock_product, 999)
        self.assertEqual(cart.get_product_quantity(mock_product), 999)

    def test_cart_merge_in_cart_with_handling_and_coupons(self):
        """Test merge_in_cart copies handling options."""
        from make_post_sell.models.cart import Cart

        cart1 = Cart()
        cart2 = Cart()

        # Set up cart1
        cart1.set_cart({"product1": 2})
        cart1.handling_cost_in_cents = 100

        # Set up cart2 with handling
        cart2.set_cart({"product2": 3})
        cart2.handling_option = "shipping"
        cart2.handling_cost_in_cents = 200

        # Initialize coupons as empty lists to avoid SQLAlchemy issues
        cart1.coupons = []
        cart2.coupons = []

        # Merge cart2 into cart1
        cart1.merge_in_cart(cart2)

        # Check products merged
        result = cart1.get_cart()
        self.assertEqual(result["product1"], 2)
        self.assertEqual(result["product2"], 3)

        # Check handling option copied
        self.assertEqual(cart1.handling_option, "shipping")

    def test_cart_line_totals_property(self):
        """Test line_totals property converts cents to dollars."""
        from make_post_sell.models.cart import Cart

        cart = Cart()

        # Mock line_totals_in_cents to return test data
        with mock.patch.object(
            type(cart), "line_totals_in_cents", new_callable=mock.PropertyMock
        ) as mock_line_totals_cents:
            mock_line_totals_cents.return_value = {"product1": 1500, "product2": 2000}

            line_totals = cart.line_totals

            # Should convert cents to dollars
            self.assertEqual(line_totals["product1"], 15.00)
            self.assertEqual(line_totals["product2"], 20.00)

    def test_cart_shop_totals_property(self):
        """Test shop_totals property converts shop totals from cents to dollars."""
        from make_post_sell.models.cart import Cart

        cart = Cart()

        # Mock shop_totals_in_cents to return test data
        with mock.patch.object(
            type(cart), "shop_totals_in_cents", new_callable=mock.PropertyMock
        ) as mock_shop_totals_cents:
            mock_shop_totals_cents.return_value = {"shop1": 2500, "shop2": 3000}

            shop_totals = cart.shop_totals

            # Should convert cents to dollars
            self.assertEqual(shop_totals["shop1"], 25.00)
            self.assertEqual(shop_totals["shop2"], 30.00)

    def test_cart_discounted_shop_totals_property(self):
        """Test discounted_shop_totals property converts cents to dollars."""
        from make_post_sell.models.cart import Cart

        cart = Cart()

        # Mock discounted_shop_totals_in_cents to return test data
        with mock.patch.object(
            type(cart),
            "discounted_shop_totals_in_cents",
            new_callable=mock.PropertyMock,
        ) as mock_discounted_cents:
            mock_discounted_cents.return_value = {"shop1": 2000, "shop2": 2500}

            discounted_totals = cart.discounted_shop_totals

            # Should convert cents to dollars
            self.assertEqual(discounted_totals["shop1"], 20.00)
            self.assertEqual(discounted_totals["shop2"], 25.00)

    def test_cart_human_timestamp_properties(self):
        """Test human_updated_timestamp and human_created_timestamp properties."""
        from make_post_sell.models.cart import Cart
        from make_post_sell.lib.time_funcs import timestamp_to_ago_string

        cart = Cart()

        # Mock the timestamp_to_ago_string function
        with mock.patch(
            "make_post_sell.models.cart.timestamp_to_ago_string"
        ) as mock_ago_string:
            mock_ago_string.return_value = "2 hours ago"

            # Test human_updated_timestamp
            result = cart.human_updated_timestamp
            mock_ago_string.assert_called_with(cart.updated_timestamp)
            self.assertEqual(result, "2 hours ago")

            # Reset mock for second test
            mock_ago_string.reset_mock()
            mock_ago_string.return_value = "3 hours ago"

            # Test human_created_timestamp
            result = cart.human_created_timestamp
            mock_ago_string.assert_called_with(cart.created_timestamp)
            self.assertEqual(result, "3 hours ago")

    def test_cart_total_price_in_cents_with_handling(self):
        """Test total_price_in_cents includes handling cost."""
        from make_post_sell.models.cart import Cart

        cart = Cart()
        cart.handling_cost_in_cents = 500  # $5.00 handling

        # Mock line_totals_in_cents to return some total
        with mock.patch.object(
            type(cart), "line_totals_in_cents", new_callable=mock.PropertyMock
        ) as mock_line_totals:
            mock_line_totals.return_value = {
                "product1": 1000,
                "product2": 1500,
            }  # $25.00

            total = cart.total_price_in_cents

            # Should include handling cost: 1000 + 1500 + 500 = 3000
            self.assertEqual(total, 3000)

    def test_cart_total_price_in_cents_no_handling(self):
        """Test total_price_in_cents without handling cost."""
        from make_post_sell.models.cart import Cart

        cart = Cart()
        cart.handling_cost_in_cents = 0  # No handling

        # Mock line_totals_in_cents to return some total
        with mock.patch.object(
            type(cart), "line_totals_in_cents", new_callable=mock.PropertyMock
        ) as mock_line_totals:
            mock_line_totals.return_value = {"product1": 1000}  # $10.00

            total = cart.total_price_in_cents

            # Should just be line totals: 1000
            self.assertEqual(total, 1000)

    def test_cart_update_handling_cost_local_pickup(self):
        """Test update_handling_cost for local pickup (free)."""
        from make_post_sell.models.cart import Cart

        cart = Cart()
        cart.handling_option = "local_pickup"

        mock_shop_location = mock.Mock()

        cart.update_handling_cost(mock_shop_location)

        # Local pickup should be free
        self.assertEqual(cart.handling_cost_in_cents, 0)

    def test_cart_update_handling_cost_local_delivery(self):
        """Test update_handling_cost for local delivery."""
        from make_post_sell.models.cart import Cart

        cart = Cart()
        cart.handling_option = "local_delivery"

        mock_shop_location = mock.Mock()
        mock_shop_location.local_delivery_rate_in_cents = 500  # $5.00

        cart.update_handling_cost(mock_shop_location)

        # Should use local delivery rate
        self.assertEqual(cart.handling_cost_in_cents, 500)

    def test_cart_update_handling_cost_local_shipping(self):
        """Test update_handling_cost for local shipping."""
        from make_post_sell.models.cart import Cart

        cart = Cart()
        cart.handling_option = "local_shipping"

        mock_shop_location = mock.Mock()
        mock_shop_location.local_shipping_rate_in_cents = 750  # $7.50

        cart.update_handling_cost(mock_shop_location)

        # Should use local shipping rate
        self.assertEqual(cart.handling_cost_in_cents, 750)

    def test_cart_update_handling_cost_international_shipping(self):
        """Test update_handling_cost for international shipping."""
        from make_post_sell.models.cart import Cart

        cart = Cart()
        cart.handling_option = "international_shipping"

        mock_shop_location = mock.Mock()
        mock_shop_location.international_shipping_rate_in_cents = 1500  # $15.00

        cart.update_handling_cost(mock_shop_location)

        # Should use international shipping rate
        self.assertEqual(cart.handling_cost_in_cents, 1500)

    def test_transient_cart_count_is_zero(self):
        """Test that an in-memory Cart (not added to DB) has count=0."""
        from make_post_sell.models.cart import Cart

        cart = Cart()
        self.assertEqual(cart.count, 0)

    def test_transient_cart_is_empty(self):
        """Test that an in-memory Cart reports is_empty=True."""
        from make_post_sell.models.cart import Cart

        cart = Cart()
        self.assertTrue(cart.is_empty)

    def test_transient_cart_dbsession_is_none(self):
        """Test that a transient Cart (never added to a DB session) has no dbsession.

        This is critical for lazy cart creation: the login flow checks
        session_cart.dbsession to decide whether to call dbsession.delete().
        """
        from make_post_sell.models.cart import Cart

        cart = Cart()
        self.assertIsNone(cart.dbsession)

    def test_transient_cart_has_valid_uuid(self):
        """Test that a transient Cart has a valid UUID id and uuid_str."""
        from make_post_sell.models.cart import Cart

        cart = Cart()
        self.assertIsNotNone(cart.id)
        self.assertIsInstance(cart.uuid_str, str)
        self.assertGreater(len(cart.uuid_str), 0)

    def test_transient_cart_total_is_zero(self):
        """Test that an in-memory empty Cart returns total=0.00.

        The nav bar accesses request.active_cart.total on every page.
        For lazy carts, this must work without a DB session.
        """
        from make_post_sell.models.cart import Cart

        cart = Cart()

        # Mock the products and coupons to avoid DB access
        with mock.patch.object(
            type(cart), "products", new_callable=mock.PropertyMock
        ) as mock_products:
            mock_products.return_value = {}

            with mock.patch.object(
                type(cart), "coupons", new_callable=mock.PropertyMock
            ) as mock_coupons:
                mock_coupons.return_value = []

                self.assertEqual(cart.total, 0.00)
                self.assertEqual(cart.count, 0)

    def test_transient_cart_uuid_reusable(self):
        """Test that a Cart's id can be overwritten to reuse a UUID.

        Lazy cart creation reuses the session UUID when recreating
        an in-memory cart across requests.
        """
        from make_post_sell.models.cart import Cart
        import uuid as uuid_mod

        cart = Cart()
        original_id = cart.id

        # Overwrite with a specific UUID (simulates lazy cart reuse)
        new_uuid = uuid_mod.UUID("75b1de48-414b-11e7-aecf-9c4e369c7158")
        cart.id = new_uuid

        self.assertEqual(cart.id, new_uuid)
        self.assertNotEqual(cart.id, original_id)
        self.assertEqual(cart.uuid_str, str(new_uuid))

    def test_cart_total_property_not_discounted(self):
        """Test Cart.total property when cart is not discounted (line 347)."""
        from make_post_sell.models.cart import Cart

        cart = Cart()

        # Mock properties to ensure cart is not discounted
        with mock.patch.object(
            type(cart), "is_discounted", new_callable=mock.PropertyMock
        ) as mock_is_discounted:
            with mock.patch.object(
                type(cart), "total_price", new_callable=mock.PropertyMock
            ) as mock_total_price:
                mock_is_discounted.return_value = False  # Not discounted
                mock_total_price.return_value = 25.00  # Regular price

                # This should hit line 347: return self.total_price
                total = cart.total
                self.assertEqual(total, 25.00)
                mock_total_price.assert_called_once()


class TestLazySessionCart(unittest.TestCase):
    """Test the lazy cart creation logic in add_session_cart.

    Session carts are NOT persisted to the database until a product is
    actually added.  This prevents bots and crawlers from creating
    empty cart rows on every page visit.
    """

    def _make_mock_request(self, session_data=None, shop=None, db_cart=None):
        """Build a mock Pyramid request for testing add_session_cart."""
        request = mock.MagicMock()
        request.session = dict(session_data or {})
        request.shop = shop

        mock_dbsession = mock.MagicMock()
        request.dbsession = mock_dbsession

        # Make get_cart_by_id return db_cart when called
        return request, db_cart

    def test_returns_persisted_cart_when_found_in_db(self):
        """When session has a cart_id and it exists in the DB, return it."""
        from make_post_sell.models.cart import Cart

        persisted_cart = Cart()
        request, _ = self._make_mock_request(
            session_data={"active_cart_id": persisted_cart.uuid_str},
        )

        with mock.patch(
            "make_post_sell.request_methods.get_cart_by_id",
            return_value=persisted_cart,
        ) as mock_get:
            # Import and call the function under test
            from make_post_sell.request_methods import includeme

            # We need to test the inner function directly. Extract it by
            # calling includeme and capturing the registered function.
            captured = {}

            def fake_add_request_method(func, name, **kwargs):
                captured[name] = func

            config = mock.MagicMock()
            config.get_settings.return_value = {}
            config.add_request_method = fake_add_request_method
            includeme(config)

            add_session_cart = captured["session_cart"]
            result = add_session_cart(request)

            mock_get.assert_called_once_with(
                request.dbsession, persisted_cart.uuid_str
            )
            self.assertEqual(result, persisted_cart)

    def test_creates_inmemory_cart_when_no_session_id(self):
        """When session has no cart_id, create an in-memory Cart and store UUID."""
        request, _ = self._make_mock_request()

        with mock.patch(
            "make_post_sell.request_methods.get_cart_by_id",
            return_value=None,
        ):
            captured = {}

            def fake_add_request_method(func, name, **kwargs):
                captured[name] = func

            config = mock.MagicMock()
            config.get_settings.return_value = {}
            config.add_request_method = fake_add_request_method

            from make_post_sell.request_methods import includeme

            includeme(config)
            add_session_cart = captured["session_cart"]
            result = add_session_cart(request)

            # Should have stored the cart UUID in the session
            self.assertIn("active_cart_id", request.session)
            self.assertEqual(request.session["active_cart_id"], result.uuid_str)

            # Cart should NOT be added to the DB session
            request.dbsession.add.assert_not_called()
            request.dbsession.flush.assert_not_called()

            # Cart should be usable (count=0, empty)
            self.assertEqual(result.count, 0)
            self.assertTrue(result.is_empty)
            self.assertTrue(result.active)

    def test_reuses_uuid_from_session_when_cart_not_in_db(self):
        """When session has a cart_id but DB returns None, reuse the same UUID."""
        import uuid as uuid_mod

        stale_uuid = str(uuid_mod.uuid4())
        request, _ = self._make_mock_request(
            session_data={"active_cart_id": stale_uuid},
        )

        with mock.patch(
            "make_post_sell.request_methods.get_cart_by_id",
            return_value=None,
        ):
            captured = {}

            def fake_add_request_method(func, name, **kwargs):
                captured[name] = func

            config = mock.MagicMock()
            config.get_settings.return_value = {}
            config.add_request_method = fake_add_request_method

            from make_post_sell.request_methods import includeme

            includeme(config)
            add_session_cart = captured["session_cart"]
            result = add_session_cart(request)

            # UUID should match what was in the session
            self.assertEqual(result.uuid_str, stale_uuid)

            # Session should NOT be overwritten
            self.assertEqual(request.session["active_cart_id"], stale_uuid)

            # Cart should NOT be added to the DB
            request.dbsession.add.assert_not_called()


class TestCoupon(unittest.TestCase):
    @mock.patch("make_post_sell.models.user.is_user_name_available", mock_always_true)
    def setUp(self):

        # if the year is 2040 and this code is still in use and these tests
        # break, it is safe to raise this date another +20 years.
        future_date = "2040-01-15"
        past_date = "2020-01-15"

        self.shop = Shop(
            "my-shop",
            "860-555-5555",
            "1 wayward way",
            "my shop description",
        )
        self.coupon = Coupon(
            shop=self.shop,
            code="a-coupon-code",
            description="a valid coupon",
            action_type="dollar-off",
            action_value=500,
            max_redemptions=100,
            max_redemptions_per_user=1,
            expiration_date=future_date,
            cart_qualifier=20,
        )
        self.old_coupon = Coupon(
            shop=self.shop,
            code="old-coupon-code",
            description="a invalid coupon",
            action_type="dollar-off",
            action_value=500,
            max_redemptions=100,
            max_redemptions_per_user=1,
            expiration_date=past_date,
            cart_qualifier=20,
        )
        self.disabled_coupon = Coupon(
            shop=self.shop,
            code="disabled-coupon-code",
            description="a disabled coupon",
            action_type="dollar-off",
            action_value=500,
            max_redemptions=100,
            max_redemptions_per_user=1,
            expiration_date=future_date,
            cart_qualifier=20,
        )
        self.disabled_coupon.disabled = True

    def test_coupon_code(self):
        self.assertNotEqual(self.coupon.code, "invalid-code")
        self.assertEqual(self.coupon.code, "a-coupon-code")

        # make sure coupon is not expired.
        self.assertTrue(self.coupon.is_valid)
        self.assertTrue(self.coupon.is_not_expired)

        # test inverse.
        self.assertFalse(self.coupon.is_expired)
        self.assertFalse(self.coupon.is_not_valid)

    def test_expired_coupon_code(self):
        self.assertEqual(self.old_coupon.code, "old-coupon-code")

        # make sure old_coupon is_expired and is_not_valid
        self.assertTrue(self.old_coupon.is_expired)
        self.assertTrue(self.old_coupon.is_not_valid)

        # test inverse.
        self.assertFalse(self.old_coupon.is_valid)
        self.assertFalse(self.old_coupon.is_not_expired)

    def test_disabled_coupon_code(self):
        self.assertEqual(self.disabled_coupon.code, "disabled-coupon-code")

        # make sure disabled_coupon is_not_expired and is_not_valid
        self.assertTrue(self.disabled_coupon.is_not_expired)
        self.assertTrue(self.disabled_coupon.is_not_valid)

        # test inverse.
        self.assertFalse(self.disabled_coupon.is_valid)
        self.assertFalse(self.disabled_coupon.is_expired)


class TestProductS3Security(unittest.TestCase):
    """Test S3 security controls for product visibility."""

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

        # Create test shop and product
        self.shop = Shop(
            "test-shop",
            "555-555-5555",
            "123 Test St",
            "Test shop description",
        )
        self.product = Product("Test Product", "Test product description")
        self.product.shop = self.shop

        # Mock file metadata to simulate uploaded files
        self.product.json_file_metadata = (
            '{"extensions": {"product": "pdf", "preview": "jpg", "thumbnail1": "jpg"}}'
        )
        self.product._file_metadata = {
            "extensions": {"product": "pdf", "preview": "jpg", "thumbnail1": "jpg"}
        }

    def test_get_s3_acl_for_public_product(self):
        """Test S3 ACL logic for public products."""
        self.product.visibility = 1  # public

        # Product files should always be private
        self.assertEqual(self.product.get_s3_acl_for_file_key("product"), "private")

        # Public files should be public-read for public products
        self.assertEqual(self.product.get_s3_acl_for_file_key("preview"), "public-read")
        self.assertEqual(
            self.product.get_s3_acl_for_file_key("thumbnail1"), "public-read"
        )

    def test_get_s3_acl_for_unlisted_product(self):
        """Test S3 ACL logic for unlisted products."""
        self.product.visibility = 2  # unlisted

        # Product files should always be private
        self.assertEqual(self.product.get_s3_acl_for_file_key("product"), "private")

        # Public files should be public-read for unlisted products
        self.assertEqual(self.product.get_s3_acl_for_file_key("preview"), "public-read")
        self.assertEqual(
            self.product.get_s3_acl_for_file_key("thumbnail1"), "public-read"
        )

    def test_get_s3_acl_for_private_product(self):
        """Test S3 ACL logic for private products."""
        self.product.visibility = 0  # private

        # All files should be private for private products
        self.assertEqual(self.product.get_s3_acl_for_file_key("product"), "private")
        self.assertEqual(self.product.get_s3_acl_for_file_key("preview"), "private")
        self.assertEqual(self.product.get_s3_acl_for_file_key("thumbnail1"), "private")

    @mock.patch("builtins.print")  # Mock print to avoid test output
    def test_update_s3_acls_with_mock_client(self, mock_print):
        """Test S3 ACL updates with mocked S3 client."""
        # Create mock S3 client
        mock_s3_client = mock.Mock()
        bucket_name = "test-bucket"

        self.product.visibility = 1  # public

        # Should call put_object_acl for each file
        self.product.update_s3_acls(mock_s3_client, bucket_name)

        # Verify put_object_acl was called for each file type
        expected_calls = len(self.product.extensions)
        self.assertEqual(mock_s3_client.put_object_acl.call_count, expected_calls)

    def test_set_visibility_without_s3_client(self):
        """Test set_visibility without S3 client (no ACL updates)."""
        old_visibility = self.product.visibility
        new_visibility = 0  # private

        # Should update visibility without error
        self.product.set_visibility(new_visibility)
        self.assertEqual(self.product.visibility, new_visibility)

    @mock.patch("builtins.print")  # Mock print to avoid test output
    def test_set_visibility_with_s3_client(self, mock_print):
        """Test set_visibility with S3 client (should update ACLs)."""
        mock_s3_client = mock.Mock()
        bucket_name = "test-bucket"

        old_visibility = 1  # public
        new_visibility = 0  # private
        self.product.visibility = old_visibility

        # Should update visibility and call update_s3_acls
        self.product.set_visibility(new_visibility, mock_s3_client, bucket_name)

        self.assertEqual(self.product.visibility, new_visibility)
        # Verify S3 ACL update was called
        expected_calls = len(self.product.extensions)
        self.assertEqual(mock_s3_client.put_object_acl.call_count, expected_calls)

    def test_visibility_property_helpers(self):
        """Test visibility helper properties work correctly."""
        # Test public
        self.product.visibility = 1
        self.assertTrue(self.product.is_public)
        self.assertFalse(self.product.is_unlisted)
        self.assertFalse(self.product.is_private)

        # Test unlisted
        self.product.visibility = 2
        self.assertFalse(self.product.is_public)
        self.assertTrue(self.product.is_unlisted)
        self.assertFalse(self.product.is_private)

        # Test private
        self.product.visibility = 0
        self.assertFalse(self.product.is_public)
        self.assertFalse(self.product.is_unlisted)
        self.assertTrue(self.product.is_private)

    def test_karaoke_tracks_in_file_keys(self):
        """Karaoke tracks (instrumentals, vocals) are registered in file_keys."""
        self.assertIn("instrumentals", self.product.file_keys)
        self.assertIn("vocals", self.product.file_keys)

    def test_karaoke_acl_public_product(self):
        """Karaoke tracks follow same ACL as product file for public products."""
        self.product.visibility = 1
        self.assertEqual(
            self.product.get_s3_acl_for_file_key("instrumentals"),
            self.product.get_s3_acl_for_file_key("product"),
        )
        self.assertEqual(
            self.product.get_s3_acl_for_file_key("vocals"),
            self.product.get_s3_acl_for_file_key("product"),
        )

    def test_karaoke_acl_private_product(self):
        """Karaoke tracks are private when product is private."""
        self.product.visibility = 0
        self.assertEqual(self.product.get_s3_acl_for_file_key("instrumentals"), "private")
        self.assertEqual(self.product.get_s3_acl_for_file_key("vocals"), "private")

    def test_karaoke_acl_unlisted_product(self):
        """Karaoke tracks follow same ACL as product file for unlisted products."""
        self.product.visibility = 2
        self.assertEqual(
            self.product.get_s3_acl_for_file_key("instrumentals"),
            self.product.get_s3_acl_for_file_key("product"),
        )
        self.assertEqual(
            self.product.get_s3_acl_for_file_key("vocals"),
            self.product.get_s3_acl_for_file_key("product"),
        )

    @mock.patch("builtins.print")
    def test_update_s3_acls_includes_karaoke_tracks(self, mock_print):
        """update_s3_acls sets ACL on karaoke tracks when they exist in extensions."""
        self.product._file_metadata = {
            "extensions": {
                "product": "mp3",
                "thumbnail1": "jpg",
                "instrumentals": "wav",
                "vocals": "wav",
            }
        }
        self.product.visibility = 1

        mock_s3 = mock.Mock()
        self.product.update_s3_acls(mock_s3, "test-bucket")

        # Should call put_object_acl for all 4 file keys in extensions
        self.assertEqual(mock_s3.put_object_acl.call_count, 4)

        # Verify karaoke tracks were included by checking the Key arguments
        called_keys = [
            call.kwargs.get("Key") or call[1].get("Key")
            for call in mock_s3.put_object_acl.call_args_list
        ]
        s3_path = self.product.s3_path
        self.assertIn(f"{s3_path}/instrumentals", called_keys)
        self.assertIn(f"{s3_path}/vocals", called_keys)


class TestMetaFunctions(unittest.TestCase):
    """Test meta.py utility functions for base64 and UUID handling."""

    def test_short_id_to_bytes(self):
        """Test short_id_to_bytes function converts base64 strings correctly."""
        # Test case from the docstring
        result = short_id_to_bytes("dbHeSEFLEeeuz5xONpxxWA")
        expected = b"u\xb1\xdeHAK\x11\xe7\xae\xcf\x9cN6\x9cqX"
        self.assertEqual(result, expected)

        # Test with various base64-encoded strings
        test_cases = [
            "YWJjZA",  # 'abcd' in base64
            "dGVzdA",  # 'test' in base64
            "SGVsbG8",  # 'Hello' in base64
        ]

        for test_case in test_cases:
            # Should not raise an exception
            result = short_id_to_bytes(test_case)
            self.assertIsInstance(result, bytes)

    def test_id_to_uuid_with_short_id(self):
        """Test id_to_uuid function works with short base64 IDs."""
        # Test case from the docstring
        result = id_to_uuid("dbHeSEFLEeeuz5xONpxxWA")
        expected = uuid.UUID("75b1de48-414b-11e7-aecf-9c4e369c7158")
        self.assertEqual(result, expected)

    def test_id_to_uuid_with_hex_string(self):
        """Test id_to_uuid function works with hex UUID strings."""
        # Test case from the docstring
        result = id_to_uuid("75b1de48414b11e7aecf9c4e369c7158")
        expected = uuid.UUID("75b1de48-414b-11e7-aecf-9c4e369c7158")
        self.assertEqual(result, expected)

    def test_id_to_uuid_with_dashed_uuid(self):
        """Test id_to_uuid function works with dashed UUID strings."""
        # Test case from the docstring
        result = id_to_uuid("75b1de48-414b-11e7-aecf-9c4e369c7158")
        expected = uuid.UUID("75b1de48-414b-11e7-aecf-9c4e369c7158")
        self.assertEqual(result, expected)

    def test_id_to_uuid_with_uuid_object(self):
        """Test id_to_uuid function returns UUID objects unchanged."""
        test_uuid = uuid.UUID("75b1de48-414b-11e7-aecf-9c4e369c7158")
        result = id_to_uuid(test_uuid)
        self.assertEqual(result, test_uuid)
        self.assertIs(result, test_uuid)  # Should be the same object

    def test_id_to_uuid_with_invalid_input(self):
        """Test id_to_uuid function returns None for invalid input."""
        result = id_to_uuid("invalid-input")
        self.assertIsNone(result)

        result = id_to_uuid("")
        self.assertIsNone(result)

    def test_id_to_uuid_with_none_raises_typeerror(self):
        """Test id_to_uuid function raises TypeError for None input."""
        # None input causes TypeError in uuid.UUID, not ValueError,
        # so it's not caught by the exception handlers
        with self.assertRaises(TypeError):
            id_to_uuid(None)


class TestInvoice(unittest.TestCase):
    """Test Invoice model and its discount calculation logic."""

    @mock.patch("make_post_sell.models.user.is_user_name_available", mock_always_true)
    def setUp(self):
        from ..models.invoice import Invoice, InvoiceLineItem
        from ..models.product import Product
        from ..models.price import Price
        from ..models.coupon_redemption import CouponRedemption

        # Create test shop
        self.shop = Shop(
            "test-shop",
            "555-555-5555",
            "123 Test St",
            "Test shop description",
        )

        # Create test user
        self.user = User("test@example.com")

        # Create test product with price
        self.product = Product("Test Product", "Test description")
        self.product.shop = self.shop

        # Create price for product
        self.price = Price(self.product, 1000)  # $10.00

        # Create invoice
        self.invoice = Invoice(self.user)
        self.invoice.shop = self.shop

    def test_subtotal_in_cents_single_item(self):
        """Test subtotal calculation with single line item."""
        from ..models.invoice import InvoiceLineItem

        # Add line item
        line_item = InvoiceLineItem(
            invoice=self.invoice,
            product=self.product,
            price=self.price,
            shop=self.shop,
            quantity=1,
        )
        self.invoice.line_items.append(line_item)

        self.assertEqual(self.invoice.subtotal_in_cents, 1000)

    def test_subtotal_in_cents_multiple_items(self):
        """Test subtotal calculation with multiple line items."""
        from ..models.invoice import InvoiceLineItem
        from ..models.product import Product
        from ..models.price import Price

        # Add first line item
        line_item1 = InvoiceLineItem(
            invoice=self.invoice,
            product=self.product,
            price=self.price,
            shop=self.shop,
            quantity=2,
        )
        self.invoice.line_items.append(line_item1)

        # Create second product
        product2 = Product("Test Product 2", "Test description 2")
        product2.shop = self.shop
        price2 = Price(product2, 500)  # $5.00

        # Add second line item
        line_item2 = InvoiceLineItem(
            invoice=self.invoice,
            product=product2,
            price=price2,
            shop=self.shop,
            quantity=3,
        )
        self.invoice.line_items.append(line_item2)

        # Should be (1000 * 2) + (500 * 3) = 2000 + 1500 = 3500
        self.assertEqual(self.invoice.subtotal_in_cents, 3500)

    def test_discount_amount_in_cents_no_coupons(self):
        """Test discount amount when no coupons applied."""
        self.assertEqual(self.invoice.discount_amount_in_cents, 0)

    def test_discount_amount_in_cents_dollar_off_coupon(self):
        """Test discount amount with dollar-off coupon."""
        from ..models.coupon_redemption import CouponRedemption
        from ..models.invoice import InvoiceLineItem

        # Add line item ($10.00)
        line_item = InvoiceLineItem(
            invoice=self.invoice,
            product=self.product,
            price=self.price,
            shop=self.shop,
            quantity=1,
        )
        self.invoice.line_items.append(line_item)

        # Create $3 off coupon
        coupon = Coupon(
            shop=self.shop,
            code="TEST3OFF",
            description="$3 off",
            action_type="dollar-off",
            action_value=3.00,  # Will be converted to 300 cents
            cart_qualifier=5.00,  # $5 minimum
        )

        # Create coupon redemption
        redemption = CouponRedemption(
            coupon=coupon, invoice=self.invoice, shop=self.shop, user=self.user
        )
        self.invoice.coupon_redemptions.append(redemption)

        # Should be $3.00 off = 300 cents
        self.assertEqual(self.invoice.discount_amount_in_cents, 300)

    def test_discount_amount_in_cents_percent_off_coupon(self):
        """Test discount amount with percent-off coupon."""
        from ..models.coupon_redemption import CouponRedemption
        from ..models.invoice import InvoiceLineItem

        # Add line item ($10.00)
        line_item = InvoiceLineItem(
            invoice=self.invoice,
            product=self.product,
            price=self.price,
            shop=self.shop,
            quantity=1,
        )
        self.invoice.line_items.append(line_item)

        # Create 25% off coupon
        coupon = Coupon(
            shop=self.shop,
            code="TEST25PCT",
            description="25% off",
            action_type="percent-off",
            action_value=25,  # Will be converted to 0.25
        )

        # Create coupon redemption
        redemption = CouponRedemption(
            coupon=coupon, invoice=self.invoice, shop=self.shop, user=self.user
        )
        self.invoice.coupon_redemptions.append(redemption)

        # Should be 25% of $10.00 = $2.50 = 250 cents
        self.assertEqual(self.invoice.discount_amount_in_cents, 250)

    def test_discount_amount_in_cents_exceeds_subtotal(self):
        """Test discount amount when coupon exceeds subtotal."""
        from ..models.coupon_redemption import CouponRedemption
        from ..models.invoice import InvoiceLineItem

        # Add line item ($10.00)
        line_item = InvoiceLineItem(
            invoice=self.invoice,
            product=self.product,
            price=self.price,
            shop=self.shop,
            quantity=1,
        )
        self.invoice.line_items.append(line_item)

        # Create $15 off coupon (more than subtotal)
        coupon = Coupon(
            shop=self.shop,
            code="TEST15OFF",
            description="$15 off",
            action_type="dollar-off",
            action_value=15.00,  # Will be converted to 1500 cents
        )

        # Create coupon redemption
        redemption = CouponRedemption(
            coupon=coupon, invoice=self.invoice, shop=self.shop, user=self.user
        )
        self.invoice.coupon_redemptions.append(redemption)

        # Should be full $10.00 = 1000 cents (can't discount more than subtotal)
        self.assertEqual(self.invoice.discount_amount_in_cents, 1000)

    def test_total_in_cents_with_handling_and_discount(self):
        """Test total calculation with subtotal, discount, and handling."""
        from ..models.coupon_redemption import CouponRedemption
        from ..models.invoice import InvoiceLineItem

        # Add line item ($10.00)
        line_item = InvoiceLineItem(
            invoice=self.invoice,
            product=self.product,
            price=self.price,
            shop=self.shop,
            quantity=1,
        )
        self.invoice.line_items.append(line_item)

        # Add handling cost
        self.invoice.handling_cost_in_cents = 500  # $5.00

        # Create $3 off coupon
        coupon = Coupon(
            shop=self.shop,
            code="TEST3OFF",
            description="$3 off",
            action_type="dollar-off",
            action_value=3.00,
        )

        # Create coupon redemption
        redemption = CouponRedemption(
            coupon=coupon, invoice=self.invoice, shop=self.shop, user=self.user
        )
        self.invoice.coupon_redemptions.append(redemption)

        # Should be: $10.00 - $3.00 + $5.00 = $12.00 = 1200 cents
        self.assertEqual(self.invoice.total_in_cents, 1200)

    def test_total_in_cents_never_negative(self):
        """Test that total never goes negative even with large discount."""
        from ..models.coupon_redemption import CouponRedemption
        from ..models.invoice import InvoiceLineItem

        # Add line item ($10.00)
        line_item = InvoiceLineItem(
            invoice=self.invoice,
            product=self.product,
            price=self.price,
            shop=self.shop,
            quantity=1,
        )
        self.invoice.line_items.append(line_item)

        # Create $20 off coupon (more than subtotal)
        coupon = Coupon(
            shop=self.shop,
            code="TEST20OFF",
            description="$20 off",
            action_type="dollar-off",
            action_value=20.00,
        )

        # Create coupon redemption
        redemption = CouponRedemption(
            coupon=coupon, invoice=self.invoice, shop=self.shop, user=self.user
        )
        self.invoice.coupon_redemptions.append(redemption)

        # Should be 0, not negative
        self.assertEqual(self.invoice.total_in_cents, 0)

    def test_requires_payment_with_free_invoice(self):
        """Test requires_payment returns False for free invoice."""
        from ..models.coupon_redemption import CouponRedemption
        from ..models.invoice import InvoiceLineItem

        # Add line item ($10.00)
        line_item = InvoiceLineItem(
            invoice=self.invoice,
            product=self.product,
            price=self.price,
            shop=self.shop,
            quantity=1,
        )
        self.invoice.line_items.append(line_item)

        # Create $10 off coupon (makes it free)
        coupon = Coupon(
            shop=self.shop,
            code="TEST10OFF",
            description="$10 off",
            action_type="dollar-off",
            action_value=10.00,
        )

        # Create coupon redemption
        redemption = CouponRedemption(
            coupon=coupon, invoice=self.invoice, shop=self.shop, user=self.user
        )
        self.invoice.coupon_redemptions.append(redemption)

        # Total should be 0
        self.assertEqual(self.invoice.total_in_cents, 0)
        # Should not require payment
        self.assertFalse(self.invoice.requires_payment)

    def test_requires_payment_at_threshold(self):
        """Test requires_payment at exactly 64 cents threshold."""
        from ..models.invoice import InvoiceLineItem
        from ..models.product import Product
        from ..models.price import Price

        # Create product with exact threshold price
        product = Product("Cheap Product", "Test")
        product.shop = self.shop
        price = Price(product, 64)  # Exactly 64 cents

        # Add line item
        line_item = InvoiceLineItem(
            invoice=self.invoice,
            product=product,
            price=price,
            shop=self.shop,
            quantity=1,
        )
        self.invoice.line_items.append(line_item)

        # At threshold, should not require payment
        self.assertEqual(self.invoice.total_in_cents, 64)
        self.assertFalse(self.invoice.requires_payment)

    def test_requires_payment_above_threshold(self):
        """Test requires_payment above 64 cents threshold."""
        from ..models.invoice import InvoiceLineItem
        from ..models.product import Product
        from ..models.price import Price

        # Create product above threshold price
        product = Product("Cheap Product", "Test")
        product.shop = self.shop
        price = Price(product, 65)  # 65 cents

        # Add line item
        line_item = InvoiceLineItem(
            invoice=self.invoice,
            product=product,
            price=price,
            shop=self.shop,
            quantity=1,
        )
        self.invoice.line_items.append(line_item)

        # Above threshold, should require payment
        self.assertEqual(self.invoice.total_in_cents, 65)
        self.assertTrue(self.invoice.requires_payment)

    def test_invalid_coupon_not_applied(self):
        """Test that invalid (expired/disabled) coupons don't apply discount."""
        from ..models.coupon_redemption import CouponRedemption
        from ..models.invoice import InvoiceLineItem

        # Add line item ($10.00)
        line_item = InvoiceLineItem(
            invoice=self.invoice,
            product=self.product,
            price=self.price,
            shop=self.shop,
            quantity=1,
        )
        self.invoice.line_items.append(line_item)

        # Create disabled coupon
        coupon = Coupon(
            shop=self.shop,
            code="DISABLED",
            description="$5 off",
            action_type="dollar-off",
            action_value=5.00,
        )
        coupon.disabled = True  # Disable the coupon

        # Create coupon redemption
        redemption = CouponRedemption(
            coupon=coupon, invoice=self.invoice, shop=self.shop, user=self.user
        )
        self.invoice.coupon_redemptions.append(redemption)

        # Should have no discount since coupon is disabled
        self.assertEqual(self.invoice.discount_amount_in_cents, 0)
        self.assertEqual(self.invoice.total_in_cents, 1000)

    def test_invoice_line_item_automatic_price_from_product_regression(self):
        """Test the production failure scenario: InvoiceLineItem automatically getting price from product.current_price."""
        from ..models.invoice import InvoiceLineItem
        from ..models.product import Product
        from ..models.price import Price

        # Create product (simulating production scenario)
        product = Product("Production Test Product", "Test")
        product.shop = self.shop

        # Create price for product (this sets up the price_history relationship)
        price = Price(product, 750)  # $7.50

        # Create line item explicitly passing price (this is what the fix enables)
        # In production, this would call product.current_price automatically
        # but unit tests need explicit price due to no database session
        line_item = InvoiceLineItem(
            invoice=self.invoice,
            product=product,
            price=price,  # Explicit price for unit test
            shop=self.shop,
            quantity=2,  # 2x $7.50 = $15.00
        )
        self.invoice.line_items.append(line_item)

        # Test that the invoice can calculate subtotal_in_cents correctly
        # This tests the line: item.price.price_in_cents * item.quantity
        expected_subtotal = 750 * 2  # $15.00
        self.assertEqual(self.invoice.subtotal_in_cents, expected_subtotal)

        # Test that total_in_cents works (accessing the new properties)
        expected_total = expected_subtotal  # No discounts or handling
        self.assertEqual(self.invoice.total_in_cents, expected_total)

        # Test that the Price object is correctly linked
        self.assertEqual(line_item.price.price_in_cents, 750)
        self.assertEqual(line_item.product, product)
        self.assertEqual(line_item.quantity, 2)


class TestUserCryptoRefundAddress(unittest.TestCase):
    @mock.patch("make_post_sell.models.user.is_user_name_available", mock_always_true)
    def setUp(self):
        self.user = User("crypto@example.com")
        self.shop = Shop(
            name="TestShop",
            phone_number="123-456-7890",
            billing_address="123 Test St",
            description="Test Shop",
        )

    def test_create_crypto_refund_address(self):
        """Test creating a crypto refund address."""
        from ..models.user_crypto_refund_address import UserCryptoRefundAddress

        addr = UserCryptoRefundAddress(
            user=self.user,
            shop=self.shop,
            coin_type="XMR",
            address="44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A",
            label="My Monero Wallet",
        )

        self.assertEqual(addr.user, self.user)
        self.assertEqual(addr.coin_type, "XMR")
        self.assertEqual(
            addr.address,
            "44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A",
        )
        self.assertEqual(addr.label, "My Monero Wallet")
        self.assertIsNotNone(addr.id)
        self.assertGreater(addr.created_timestamp, 0)
        self.assertGreater(addr.updated_timestamp, 0)

    def test_create_crypto_refund_address_uppercase_coin_type(self):
        """Test that coin type is uppercased."""
        from ..models.user_crypto_refund_address import UserCryptoRefundAddress

        addr = UserCryptoRefundAddress(
            user=self.user,
            shop=self.shop,
            coin_type="btc",
            address="1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa",
        )

        self.assertEqual(addr.coin_type, "BTC")

    def test_create_crypto_refund_address_no_label(self):
        """Test creating address without label."""
        from ..models.user_crypto_refund_address import UserCryptoRefundAddress

        addr = UserCryptoRefundAddress(
            user=self.user,
            shop=self.shop,
            coin_type="DOGE",
            address="DH5yaieqoZN36fDVciNyRueRGvGLR3mr7L",
        )

        self.assertEqual(addr.coin_type, "DOGE")
        self.assertIsNone(addr.label)

    def test_get_user_crypto_refund_address_none(self):
        """Test getting non-existent refund address returns None."""
        from ..models.user_crypto_refund_address import get_user_crypto_refund_address

        # Mock dbsession
        mock_session = mock.Mock()
        mock_query = mock.Mock()
        mock_filter = mock.Mock()

        mock_session.query.return_value = mock_query
        mock_query.filter.return_value = mock_filter
        mock_filter.first.return_value = None

        result = get_user_crypto_refund_address(
            mock_session, self.user, self.shop, "XMR"
        )
        self.assertIsNone(result)

    def test_get_user_crypto_refund_address_found(self):
        """Test getting existing refund address."""
        from ..models.user_crypto_refund_address import (
            UserCryptoRefundAddress,
            get_user_crypto_refund_address,
        )

        # Create address
        expected_addr = UserCryptoRefundAddress(
            user=self.user,
            shop=self.shop,
            coin_type="XMR",
            address="44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A",
        )

        # Mock dbsession
        mock_session = mock.Mock()
        mock_query = mock.Mock()
        mock_filter = mock.Mock()

        mock_session.query.return_value = mock_query
        mock_query.filter.return_value = mock_filter
        mock_filter.first.return_value = expected_addr

        result = get_user_crypto_refund_address(
            mock_session, self.user, self.shop, "XMR"
        )
        self.assertEqual(result, expected_addr)


class TestCryptoPayment(unittest.TestCase):
    """Unit tests for CryptoPayment model string representation methods."""

    @mock.patch("make_post_sell.models.user.is_user_name_available", mock_always_true)
    def setUp(self):
        """Set up test fixtures."""
        self.user = User("alice@example.com")
        self.user.name = "Alice"

        self.shop = Shop(
            name="Alice's Shop",
            phone_number="555-1234",
            billing_address="123 Main St",
            description="Test shop",
        )

        # Create test payment without invoice for simpler testing
        self.payment = CryptoPayment(
            invoice=None,  # Skip invoice relationship for unit tests
            user=self.user,
            shop=self.shop,
            address="44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A",
            account_index=0,
            subaddress_index=1,
            coin_type="XMR",
            expected_amount=1000000000000,  # 1.0 XMR in piconero
            rate_locked_usd_per_coin=150.00,
            quote_expires_at_ms=now_timestamp() + 3600000,  # 1 hour from now
            confirmations_required=10,
        )

    def test_str_basic_pending_payment(self):
        """Test __str__ for basic pending payment."""
        result = str(self.payment)

        # Should include payment ID, status, coin type, amounts, confirmations, address, user, shop
        self.assertIn(self.payment.uuid_str[:8], result)
        self.assertIn("[pending]", result)  # status shown as-is
        self.assertIn("XMR", result)
        self.assertIn(
            "0/1", result
        )  # received/expected in XMR (trailing zeros stripped)
        self.assertIn("conf:0/10", result)
        self.assertIn("addr:", result)
        self.assertIn("user:Alice", result)
        self.assertIn("shop:Alice's Shop", result)

    def test_str_received_payment_with_confirmations(self):
        """Test __str__ for received payment with some confirmations."""
        self.payment.status = CryptoPayment.STATUS_RECEIVED
        self.payment.received_amount = 800000000000  # 0.8 XMR
        self.payment.current_confirmations = 5

        result = str(self.payment)

        self.assertIn("[received]", result)
        self.assertIn("0.8/1", result)  # trailing zeros stripped
        self.assertIn("conf:5/10", result)

    def test_str_confirmed_overpayment(self):
        """Test __str__ for confirmed overpayment."""
        self.payment.status = CryptoPayment.STATUS_CONFIRMED_OVERPAY
        self.payment.received_amount = 1200000000000  # 1.2 XMR
        self.payment.current_confirmations = 12

        result = str(self.payment)

        self.assertIn("[confirmed-overpay]", result)  # actual status string
        self.assertIn("1.2/1", result)  # trailing zeros stripped
        self.assertIn("conf:12/10", result)

    def test_str_dogecoin_precision(self):
        """Test __str__ with DOGE amounts (8 decimal places)."""
        self.payment.coin_type = "DOGE"
        self.payment.expected_amount = 100000000000  # 1000.0 DOGE in satoshis
        self.payment.received_amount = 95000000000  # 950.0 DOGE

        result = str(self.payment)

        self.assertIn("DOGE", result)
        self.assertIn("950/1000", result)  # trailing zeros stripped

    def test_str_no_user_display_name_fallback_to_username(self):
        """Test __str__ shows user.name field."""
        # Since User model has 'name' field, not 'display_name' or 'username'
        self.user.name = "alice_user"

        result = str(self.payment)

        self.assertIn("user:alice_user", result)
        self.assertNotIn("user:Alice", result)

    def test_str_no_user_info(self):
        """Test __str__ when user has no name."""
        self.user.name = None

        result = str(self.payment)

        # Should not include user info but should still include shop
        self.assertNotIn("user:", result)
        self.assertIn("shop:Alice's Shop", result)

    def test_str_no_shop_name(self):
        """Test __str__ when shop has no name."""
        self.shop.name = None

        result = str(self.payment)

        # Should not include shop info but should still include user
        self.assertNotIn("shop:", result)
        self.assertIn("user:Alice", result)

    def test_repr_method(self):
        """Test __repr__ method for debugging."""
        result = repr(self.payment)

        self.assertIn("CryptoPayment(", result)
        self.assertIn(f"id={self.payment.uuid_str[:8]}", result)
        self.assertIn("status=pending", result)
        self.assertIn("coin=XMR", result)
        self.assertIn("expected=1000000000000", result)
        self.assertIn("received=0", result)
        self.assertIn("confirmations=0/10", result)  # Should show 0 due to our fix

    def test_format_amount_details_xmr(self):
        """Test format_amount_details for XMR."""
        self.payment.received_amount = 800000000000  # 0.8 XMR

        result = self.payment.format_amount_details()

        self.assertIn("expected:1", result)
        self.assertIn("received:0.8", result)
        self.assertIn("due:0.2", result)
        self.assertIn("XMR", result)

    def test_format_amount_details_doge(self):
        """Test format_amount_details for DOGE."""
        self.payment.coin_type = "DOGE"
        self.payment.expected_amount = 100000000000  # 1000.0 DOGE
        self.payment.received_amount = 95000000000  # 950.0 DOGE

        result = self.payment.format_amount_details()

        self.assertIn("expected:1000", result)
        self.assertIn("received:950", result)
        self.assertIn("due:50", result)
        self.assertIn("DOGE", result)

    def test_format_amount_details_no_due_amount(self):
        """Test format_amount_details when fully paid."""
        self.payment.received_amount = 1000000000000  # 1.0 XMR - exactly expected

        result = self.payment.format_amount_details()

        self.assertIn("due:0", result)

    def test_format_confirmation_status_pending(self):
        """Test format_confirmation_status for pending confirmations."""
        self.payment.current_confirmations = 5

        result = self.payment.format_confirmation_status()

        self.assertEqual(result, "5/10")

    def test_format_confirmation_status_confirmed(self):
        """Test format_confirmation_status for fully confirmed."""
        self.payment.current_confirmations = 15  # More than required

        result = self.payment.format_confirmation_status()

        self.assertEqual(result, "15/10")


class TestPayPalUserShop(unittest.TestCase):
    """Unit tests for PayPalUserShop model."""

    @mock.patch("make_post_sell.models.user.is_user_name_available", mock_always_true)
    def setUp(self):
        self.user = User("paypal@example.com")
        self.shop = Shop(
            name="PayPal Test Shop",
            phone_number="555-1234",
            billing_address="123 Main St",
            description="Test shop",
        )

    def test_create_paypal_user_shop(self):
        """Test creating a PayPalUserShop record."""
        from ..models.paypal_user_shop import PayPalUserShop

        pus = PayPalUserShop(user=self.user, shop=self.shop)

        self.assertEqual(pus.user, self.user)
        self.assertEqual(pus.shop, self.shop)
        self.assertIsNotNone(pus.id)
        self.assertIsNone(pus.payer_id)
        self.assertIsNone(pus.billing_agreement_id)
        self.assertIsNone(pus.active_payment_token)

    def test_has_billing_agreement_false(self):
        """Test has_billing_agreement when no agreement exists."""
        from ..models.paypal_user_shop import PayPalUserShop

        pus = PayPalUserShop(user=self.user, shop=self.shop)

        self.assertFalse(pus.has_billing_agreement)

    def test_has_billing_agreement_true(self):
        """Test has_billing_agreement when agreement exists."""
        from ..models.paypal_user_shop import PayPalUserShop

        pus = PayPalUserShop(user=self.user, shop=self.shop)
        pus.billing_agreement_id = "BA-1234567890"

        self.assertTrue(pus.has_billing_agreement)

    def test_has_saved_payment_method_false(self):
        """Test has_saved_payment_method when no token exists."""
        from ..models.paypal_user_shop import PayPalUserShop

        pus = PayPalUserShop(user=self.user, shop=self.shop)

        self.assertFalse(pus.has_saved_payment_method)

    def test_has_saved_payment_method_true(self):
        """Test has_saved_payment_method when token exists."""
        from ..models.paypal_user_shop import PayPalUserShop

        pus = PayPalUserShop(user=self.user, shop=self.shop)
        pus.active_payment_token = "VAULT-TOKEN-123"

        self.assertTrue(pus.has_saved_payment_method)

    def test_payer_id_storage(self):
        """Test storing payer ID."""
        from ..models.paypal_user_shop import PayPalUserShop

        pus = PayPalUserShop(user=self.user, shop=self.shop)
        pus.payer_id = "PAYERID123ABC"

        self.assertEqual(pus.payer_id, "PAYERID123ABC")


class TestInvoicePayPal(unittest.TestCase):
    """Unit tests for Invoice PayPal functionality."""

    @mock.patch("make_post_sell.models.user.is_user_name_available", mock_always_true)
    def setUp(self):
        self.user = User("paypal_invoice@example.com")
        self.shop = Shop(
            name="PayPal Invoice Shop",
            phone_number="555-1234",
            billing_address="123 Main St",
            description="Test shop",
        )

    def test_invoice_payment_method_stripe_default(self):
        """Test that invoice with no PayPal or crypto returns stripe."""
        invoice = Invoice(self.user)
        invoice.shop = self.shop

        self.assertEqual(invoice.payment_method, "stripe")

    def test_invoice_payment_method_paypal(self):
        """Test that invoice with paypal_order_id returns paypal."""
        invoice = Invoice(self.user)
        invoice.shop = self.shop
        invoice.paypal_order_id = "PAYPAL-ORDER-123"

        self.assertEqual(invoice.payment_method, "paypal")

    def test_invoice_paypal_columns_nullable(self):
        """Test that PayPal columns are nullable by default."""
        invoice = Invoice(self.user)
        invoice.shop = self.shop

        self.assertIsNone(invoice.paypal_order_id)
        self.assertIsNone(invoice.paypal_capture_id)

    def test_invoice_paypal_capture_id_storage(self):
        """Test storing PayPal capture ID."""
        invoice = Invoice(self.user)
        invoice.shop = self.shop
        invoice.paypal_order_id = "PAYPAL-ORDER-123"
        invoice.paypal_capture_id = "CAPTURE-456"

        self.assertEqual(invoice.paypal_order_id, "PAYPAL-ORDER-123")
        self.assertEqual(invoice.paypal_capture_id, "CAPTURE-456")

    def test_invoice_payment_status_for_paypal(self):
        """Test that PayPal invoice payment_status is 'paid'."""
        invoice = Invoice(self.user)
        invoice.shop = self.shop
        invoice.paypal_order_id = "PAYPAL-ORDER-123"

        # PayPal invoices (like Stripe) are "paid" if they exist
        self.assertEqual(invoice.payment_status, "paid")

    def test_invoice_is_paid_for_paypal(self):
        """Test that PayPal invoice is_paid returns True."""
        invoice = Invoice(self.user)
        invoice.shop = self.shop
        invoice.paypal_order_id = "PAYPAL-ORDER-123"

        self.assertTrue(invoice.is_paid)


class TestInvoiceStripe(unittest.TestCase):
    """Unit tests for Invoice Stripe functionality."""

    @mock.patch("make_post_sell.models.user.is_user_name_available", mock_always_true)
    def setUp(self):
        self.user = User("stripe_invoice@example.com")
        self.shop = Shop(
            name="Stripe Invoice Shop",
            phone_number="555-1234",
            billing_address="123 Main St",
            description="Test shop",
        )

    def test_invoice_stripe_columns_nullable(self):
        """Test that Stripe columns are nullable by default."""
        invoice = Invoice(self.user)
        invoice.shop = self.shop

        self.assertIsNone(invoice.stripe_payment_intent_id)
        self.assertIsNone(invoice.stripe_charge_id)

    def test_invoice_stripe_payment_intent_id_storage(self):
        """Test storing Stripe payment intent ID."""
        invoice = Invoice(self.user)
        invoice.shop = self.shop
        invoice.stripe_payment_intent_id = "pi_1234567890abcdef"

        self.assertEqual(invoice.stripe_payment_intent_id, "pi_1234567890abcdef")

    def test_invoice_stripe_charge_id_storage(self):
        """Test storing Stripe charge ID."""
        invoice = Invoice(self.user)
        invoice.shop = self.shop
        invoice.stripe_payment_intent_id = "pi_1234567890abcdef"
        invoice.stripe_charge_id = "ch_1234567890abcdef"

        self.assertEqual(invoice.stripe_charge_id, "ch_1234567890abcdef")

    def test_invoice_payment_method_stripe_explicit(self):
        """Test that invoice with stripe_payment_intent_id returns stripe."""
        invoice = Invoice(self.user)
        invoice.shop = self.shop
        invoice.stripe_payment_intent_id = "pi_1234567890abcdef"

        self.assertEqual(invoice.payment_method, "stripe")

    def test_invoice_payment_method_stripe_default(self):
        """Test that invoice with no payment refs returns stripe (legacy)."""
        invoice = Invoice(self.user)
        invoice.shop = self.shop

        # Legacy invoices without payment tracking default to stripe
        self.assertEqual(invoice.payment_method, "stripe")

    def test_invoice_payment_status_for_stripe(self):
        """Test that Stripe invoice payment_status is 'paid'."""
        invoice = Invoice(self.user)
        invoice.shop = self.shop
        invoice.stripe_payment_intent_id = "pi_1234567890abcdef"

        self.assertEqual(invoice.payment_status, "paid")

    def test_invoice_is_paid_for_stripe(self):
        """Test that Stripe invoice is_paid returns True."""
        invoice = Invoice(self.user)
        invoice.shop = self.shop
        invoice.stripe_payment_intent_id = "pi_1234567890abcdef"

        self.assertTrue(invoice.is_paid)

    def test_invoice_payment_method_priority_paypal_over_stripe(self):
        """Test that PayPal takes priority if both are set (edge case)."""
        invoice = Invoice(self.user)
        invoice.shop = self.shop
        invoice.paypal_order_id = "PAYPAL-ORDER-123"
        invoice.stripe_payment_intent_id = "pi_1234567890abcdef"

        # PayPal is checked first in payment_method
        self.assertEqual(invoice.payment_method, "paypal")

    def test_invoice_payment_method_adyen(self):
        """Test that Adyen payment method is detected correctly."""
        invoice = Invoice(self.user)
        invoice.shop = self.shop
        invoice.adyen_psp_reference = "PSP123456789"

        self.assertEqual(invoice.payment_method, "adyen")

    def test_invoice_payment_method_priority_paypal_over_adyen(self):
        """Test that PayPal takes priority over Adyen (edge case)."""
        invoice = Invoice(self.user)
        invoice.shop = self.shop
        invoice.paypal_order_id = "PAYPAL-ORDER-123"
        invoice.adyen_psp_reference = "PSP123456789"

        # PayPal is checked first in payment_method
        self.assertEqual(invoice.payment_method, "paypal")

    def test_invoice_payment_method_priority_adyen_over_stripe(self):
        """Test that Adyen takes priority over Stripe (edge case)."""
        invoice = Invoice(self.user)
        invoice.shop = self.shop
        invoice.adyen_psp_reference = "PSP123456789"
        invoice.stripe_payment_intent_id = "pi_1234567890abcdef"

        # Adyen is checked before Stripe in payment_method
        self.assertEqual(invoice.payment_method, "adyen")


class TestAdyen(unittest.TestCase):
    """Test Adyen-related model functionality."""

    @mock.patch("make_post_sell.models.user.is_user_name_available", mock_always_true)
    def setUp(self):
        # Create test shop
        self.shop = Shop(
            "test-shop",
            "555-555-5555",
            "123 Test St",
            "Test shop description",
        )
        # Create test user
        self.user = User("test@example.com")

    def test_shop_is_adyen_ready_without_credentials(self):
        """Test that shop is not Adyen-ready without credentials."""
        self.assertFalse(self.shop.is_adyen_ready)

    def test_shop_is_adyen_ready_with_api_key_only(self):
        """Test that shop is not Adyen-ready with just API key."""
        self.shop.adyen_api_key = "test_api_key"
        self.assertFalse(self.shop.is_adyen_ready)

    def test_shop_is_adyen_ready_with_merchant_account_only(self):
        """Test that shop is not Adyen-ready with just merchant account."""
        self.shop.adyen_merchant_account = "TestMerchant"
        self.assertFalse(self.shop.is_adyen_ready)

    def test_shop_is_adyen_ready_with_both_credentials(self):
        """Test that shop is Adyen-ready with both API key and merchant account."""
        self.shop.adyen_api_key = "test_api_key"
        self.shop.adyen_merchant_account = "TestMerchant"
        self.assertTrue(self.shop.is_adyen_ready)

    def test_shop_is_adyen_not_ready(self):
        """Test the inverse property for convenience."""
        self.assertTrue(self.shop.is_adyen_not_ready)
        self.shop.adyen_api_key = "test_api_key"
        self.shop.adyen_merchant_account = "TestMerchant"
        self.assertFalse(self.shop.is_adyen_not_ready)

    def test_shop_adyen_enabled_default(self):
        """Test that Adyen enabled is None before DB insert (server_default handles it)."""
        # Before DB insert, the value is None (server_default of '1' applies on insert)
        # When retrieved from DB after insert, it would be True
        self.assertIsNone(self.shop.adyen_enabled)

    def test_invoice_adyen_psp_reference_default(self):
        """Test that Adyen PSP reference is None by default."""
        invoice = Invoice(self.user)
        invoice.shop = self.shop
        self.assertIsNone(invoice.adyen_psp_reference)

    def test_invoice_adyen_psp_reference_set(self):
        """Test setting Adyen PSP reference."""
        invoice = Invoice(self.user)
        invoice.shop = self.shop
        invoice.adyen_psp_reference = "882619391893263J"
        self.assertEqual(invoice.adyen_psp_reference, "882619391893263J")


class TestStemmer(unittest.TestCase):
    """Test the stemmer and related products functions."""

    def test_stem_word_basic(self):
        """Test common suffixes stripped correctly."""
        from ..models.product import stem_word

        self.assertEqual(stem_word("running"), "runn")
        self.assertEqual(stem_word("played"), "play")
        self.assertEqual(stem_word("quickly"), "quick")
        self.assertEqual(stem_word("cats"), "cat")
        self.assertEqual(stem_word("boxes"), "box")
        self.assertEqual(stem_word("creation"), "crea")
        self.assertEqual(stem_word("movement"), "move")
        self.assertEqual(stem_word("darkness"), "dark")
        self.assertEqual(stem_word("readable"), "read")
        self.assertEqual(stem_word("hopeful"), "hope")
        self.assertEqual(stem_word("careless"), "care")
        self.assertEqual(stem_word("dangerous"), "danger")
        self.assertEqual(stem_word("creative"), "creat")
        self.assertEqual(stem_word("bigger"), "bigg")
        self.assertEqual(stem_word("biggest"), "bigg")

    def test_stem_word_short_preserved(self):
        """Test words too short to stem are unchanged."""
        from ..models.product import stem_word

        # Words where remaining stem would be < 3 chars should not be stripped
        self.assertEqual(stem_word("it"), "it")
        self.assertEqual(stem_word("is"), "is")
        self.assertEqual(stem_word("an"), "an")
        self.assertEqual(stem_word("the"), "the")
        self.assertEqual(stem_word("bed"), "bed")

    def test_stem_word_case_insensitive(self):
        """Test stemming is case insensitive."""
        from ..models.product import stem_word

        self.assertEqual(stem_word("Running"), stem_word("running"))
        self.assertEqual(stem_word("PLAYED"), stem_word("played"))

    def test_tokenize_and_stem(self):
        """Test sentence -> set of stems."""
        from ..models.product import tokenize_and_stem

        result = tokenize_and_stem("The cats are running quickly")
        # "the" -> "the" (3 chars, no suffix stripped)
        # "cats" -> "cat", "are" -> "are", "running" -> "runn", "quickly" -> "quick"
        self.assertIn("cat", result)
        self.assertIn("runn", result)
        self.assertIn("quick", result)
        self.assertIn("are", result)
        self.assertIn("the", result)
        # Short words (<3 chars) are excluded by the regex
        self.assertNotIn("is", result)
        self.assertNotIn("a", result)

    def test_tokenize_and_stem_empty(self):
        """Test empty string returns empty set."""
        from ..models.product import tokenize_and_stem

        self.assertEqual(tokenize_and_stem(""), set())
        self.assertEqual(tokenize_and_stem("12 34"), set())

    def test_get_related_products(self):
        """Test returns ranked matches, excludes self and non-public."""
        from ..models.product import Product, get_related_products

        shop = Shop(
            "test-shop",
            "555-555-5555",
            "123 Test St",
            "Test shop description",
        )

        # Create products with related titles
        product1 = Product("Blues Guitar Lessons", "Learn acoustic guitar blues riffs")
        product1.shop = shop
        product1.visibility = 1

        # shares: guitar, learn = 2 stems
        product2 = Product("Jazz Guitar Tutorial", "Learn jazz techniques")
        product2.shop = shop
        product2.visibility = 1

        # shares: learn = 1 stem (no guitar/blues overlap)
        product3 = Product("Piano for Beginners", "Learn piano basics")
        product3.shop = shop
        product3.visibility = 1

        # shares: guitar, blues, learn, riff = 4 stems (most overlap)
        product4 = Product("Guitar Blues Collection", "Learn blues guitar riffs and licks")
        product4.shop = shop
        product4.visibility = 1

        product5_private = Product("Secret Guitar Video", "Private guitar content")
        product5_private.shop = shop
        product5_private.visibility = 0  # private

        # Mock shop.products to return our test products
        all_products = [product1, product2, product3, product4, product5_private]
        with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mock_products:
            mock_products.return_value = all_products

            related = get_related_products(product1)

            # Should not include product1 itself
            self.assertNotIn(product1, related)

            # Should not include private product
            self.assertNotIn(product5_private, related)

            # Should include related products
            related_titles = [p.title for p in related]
            self.assertIn("Guitar Blues Collection", related_titles)
            self.assertIn("Jazz Guitar Tutorial", related_titles)
            self.assertIn("Piano for Beginners", related_titles)

            # product4 shares most stems with product1
            # so it should be ranked higher (earlier index) than product3
            self.assertLess(related.index(product4), related.index(product3))

    def test_get_related_products_empty_description(self):
        """Test with empty title/description still returns results via fallback."""
        from ..models.product import Product, get_related_products

        shop = Shop(
            "test-shop",
            "555-555-5555",
            "123 Test St",
            "Test shop description",
        )

        product = Product("", "")
        product.shop = shop
        product.visibility = 1

        with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mock_products:
            mock_products.return_value = []
            related = get_related_products(product)
            self.assertEqual(related, [])

    def test_get_related_products_empty_description_with_others(self):
        """Test orphan product (no stems) gets fallback results."""
        from ..models.product import Product, get_related_products

        shop = Shop(
            "test-shop",
            "555-555-5555",
            "123 Test St",
            "Test shop description",
        )

        product = Product("", "")
        product.shop = shop
        product.visibility = 1
        product.created_timestamp = 100

        other1 = Product("Song One", "A great song")
        other1.shop = shop
        other1.visibility = 1
        other1.created_timestamp = 200

        other2 = Product("Song Two", "Another song")
        other2.shop = shop
        other2.visibility = 1
        other2.created_timestamp = 300

        all_products = [product, other1, other2]
        with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mock_products:
            mock_products.return_value = all_products
            related = get_related_products(product)
            # Should return fallback results (tier 3: any public product)
            self.assertEqual(len(related), 2)
            self.assertIn(other1, related)
            self.assertIn(other2, related)

    def test_get_related_products_limit(self):
        """Test that limit parameter is respected."""
        from ..models.product import Product, get_related_products

        shop = Shop(
            "test-shop",
            "555-555-5555",
            "123 Test St",
            "Test shop description",
        )

        product1 = Product("Guitar Music", "Guitar tutorial")
        product1.shop = shop
        product1.visibility = 1

        # Create many related products
        other_products = []
        for i in range(15):
            p = Product(f"Guitar Video {i}", f"Guitar lesson {i}")
            p.shop = shop
            p.visibility = 1
            other_products.append(p)

        all_products = [product1] + other_products
        with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mock_products:
            mock_products.return_value = all_products

            # Default limit is 8
            related = get_related_products(product1)
            self.assertLessEqual(len(related), 8)

            # Custom limit
            related = get_related_products(product1, limit=3)
            self.assertLessEqual(len(related), 3)

    def test_get_related_products_same_media_type_fallback(self):
        """Test tier 2: same media type fills remaining slots."""
        from ..models.product import Product, get_related_products

        shop = Shop(
            "test-shop",
            "555-555-5555",
            "123 Test St",
            "Test shop description",
        )

        def _make_product(title, desc, timestamp, ext=None):
            p = Product(title, desc)
            p.shop = shop
            p.visibility = 1
            p.created_timestamp = timestamp
            if ext:
                p._file_metadata = {"extensions": {"product": ext}, "originals": {}}
            return p

        # Product with no stem overlap to anything
        product1 = _make_product("Xylophone Jam", "Unique xylophone music", 100, "mp3")

        # Same media type (audio) but no stem overlap
        product2 = _make_product("Drums Solo", "Percussion performance", 200, "mp3")

        # Different media type (video) and no stem overlap
        product3 = _make_product("Cat Video", "Funny cat footage", 300, "mp4")

        # Another audio with no stem overlap
        product4 = _make_product("Bass Line", "Groovy bass track", 400, "wav")

        all_products = [product1, product2, product3, product4]
        with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mock_products:
            mock_products.return_value = all_products

            related = get_related_products(product1)
            # Should include all 3 others
            self.assertEqual(len(related), 3)

            # Same media type (audio) should come before different type (video)
            audio_indices = []
            video_indices = []
            for i, p in enumerate(related):
                ext = p.extensions.get("product", "")
                if ext in ("mp3", "wav"):
                    audio_indices.append(i)
                elif ext == "mp4":
                    video_indices.append(i)

            # All audio items should come before video items
            if audio_indices and video_indices:
                self.assertLess(max(audio_indices), min(video_indices))

    def test_get_related_products_deterministic(self):
        """Test that results are deterministic (consistent ordering)."""
        from ..models.product import Product, get_related_products

        shop = Shop(
            "test-shop",
            "555-555-5555",
            "123 Test St",
            "Test shop description",
        )

        product1 = Product("Guitar Music", "Guitar tutorial video")
        product1.shop = shop
        product1.visibility = 1
        product1.created_timestamp = 100

        product2 = Product("Guitar Songs", "Guitar song collection")
        product2.shop = shop
        product2.visibility = 1
        product2.created_timestamp = 200

        product3 = Product("Guitar Chords", "Guitar chord lessons")
        product3.shop = shop
        product3.visibility = 1
        product3.created_timestamp = 300

        all_products = [product1, product2, product3]
        with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mock_products:
            mock_products.return_value = all_products

            # Run multiple times to verify determinism
            related1 = get_related_products(product1)
            related2 = get_related_products(product1)
            self.assertEqual(
                [p.title for p in related1],
                [p.title for p in related2],
            )


class TestDiscoveryRing(unittest.TestCase):
    """Test the discovery ring precomputation algorithm."""

    def _make_shop(self):
        return Shop(
            "test-shop",
            "555-555-5555",
            "123 Test St",
            "Test shop description",
        )

    def _make_product(self, title, desc, timestamp, visibility=1):
        from ..models.product import Product
        p = Product(title, desc)
        p.visibility = visibility
        p.created_timestamp = timestamp
        return p

    def test_empty_shop(self):
        """Empty shop returns empty ring."""
        from ..models.shop import compute_discovery_ring
        shop = self._make_shop()
        with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mp:
            mp.return_value = []
            self.assertEqual(compute_discovery_ring(shop), [])

    def test_single_product(self):
        """Single public product returns ring of one."""
        from ..models.shop import compute_discovery_ring
        shop = self._make_shop()
        p = self._make_product("Solo", "Only item", 100)
        with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mp:
            mp.return_value = [p]
            ring = compute_discovery_ring(shop)
            self.assertEqual(ring, [str(p.id)])

    def test_all_public_products_included(self):
        """Every public product appears exactly once, no dupes."""
        from ..models.shop import compute_discovery_ring
        shop = self._make_shop()
        products = [self._make_product(f"Item {i}", f"Description {i}", 100 + i) for i in range(10)]
        with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mp:
            mp.return_value = products
            ring = compute_discovery_ring(shop)
            ids = [str(p.id) for p in products]
            self.assertEqual(len(ring), 10)
            self.assertEqual(set(ring), set(ids))
            # No duplicates
            self.assertEqual(len(ring), len(set(ring)))

    def test_private_and_unlisted_excluded(self):
        """Only visibility==1 products appear in the ring."""
        from ..models.shop import compute_discovery_ring
        shop = self._make_shop()
        public = self._make_product("Public Song", "Visible", 300, visibility=1)
        private = self._make_product("Private Song", "Hidden", 200, visibility=0)
        unlisted = self._make_product("Unlisted Song", "Unlisted", 100, visibility=2)
        with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mp:
            mp.return_value = [public, private, unlisted]
            ring = compute_discovery_ring(shop)
            self.assertEqual(ring, [str(public.id)])
            self.assertNotIn(str(private.id), ring)
            self.assertNotIn(str(unlisted.id), ring)

    def test_starts_with_newest(self):
        """Newest product (highest created_timestamp) is at position 0."""
        from ..models.shop import compute_discovery_ring
        shop = self._make_shop()
        old = self._make_product("Old Song", "First release", 100)
        mid = self._make_product("Mid Song", "Second release", 200)
        new = self._make_product("New Song", "Latest release", 300)
        with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mp:
            mp.return_value = [old, mid, new]
            ring = compute_discovery_ring(shop)
            self.assertEqual(ring[0], str(new.id))

    def test_similar_products_adjacent(self):
        """Guitar cluster should appear before unrelated piano cluster."""
        from ..models.shop import compute_discovery_ring
        shop = self._make_shop()
        g1 = self._make_product("Blues Guitar Jam", "Electric guitar blues riffs", 400)
        g2 = self._make_product("Guitar Solo Practice", "Learn guitar solo techniques", 300)
        g3 = self._make_product("Acoustic Guitar Chords", "Guitar chord progressions", 200)
        piano = self._make_product("Piano Sonata", "Classical piano performance", 100)
        with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mp:
            mp.return_value = [g1, g2, g3, piano]
            ring = compute_discovery_ring(shop)
            # All guitar items should cluster together before piano
            guitar_ids = {str(g1.id), str(g2.id), str(g3.id)}
            guitar_positions = [i for i, rid in enumerate(ring) if rid in guitar_ids]
            piano_pos = ring.index(str(piano.id))
            # Guitar items should be contiguous (adjacent)
            self.assertEqual(max(guitar_positions) - min(guitar_positions), 2)
            # Piano should be after all guitar items
            self.assertGreater(piano_pos, max(guitar_positions))

    def test_deterministic(self):
        """Same input produces same output every time."""
        from ..models.shop import compute_discovery_ring
        shop = self._make_shop()
        products = [self._make_product(f"Song {i}", f"Description {i}", 100 + i) for i in range(8)]
        with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mp:
            mp.return_value = products
            ring1 = compute_discovery_ring(shop)
            ring2 = compute_discovery_ring(shop)
            self.assertEqual(ring1, ring2)

    def test_ring_is_list_of_strings(self):
        """Ring elements are UUID strings, not UUID objects."""
        from ..models.shop import compute_discovery_ring
        shop = self._make_shop()
        p = self._make_product("Test Song", "Some description", 100)
        with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mp:
            mp.return_value = [p]
            ring = compute_discovery_ring(shop)
            self.assertIsInstance(ring, list)
            for item in ring:
                self.assertIsInstance(item, str)

    def test_reforge_sets_shop_attribute(self):
        """reforge_discovery_ring stores the ring on the shop object."""
        from ..models.shop import reforge_discovery_ring
        shop = self._make_shop()
        p1 = self._make_product("Song A", "Alpha", 200)
        p2 = self._make_product("Song B", "Beta", 100)
        with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mp:
            mp.return_value = [p1, p2]
            ring = reforge_discovery_ring(shop)
            self.assertEqual(shop.discovery_ring, ring)
            self.assertEqual(len(ring), 2)
            # Verify JSON column was set
            import json
            self.assertEqual(json.loads(shop.json_discovery_ring), ring)


class TestValidateDiscoveryRing(unittest.TestCase):
    """Test the ring topology validator."""

    def _make_shop(self):
        return Shop(
            "validate-shop",
            "555-555-5555",
            "123 Ring St",
            "Shop for validator tests",
        )

    def _make_product(self, title, desc, visibility=1):
        from ..models.product import Product
        p = Product(title, desc)
        p.visibility = visibility
        p.created_timestamp = 100
        return p

    def test_valid_ring(self):
        """Ring exactly matches public products → valid."""
        from ..models.shop import validate_discovery_ring
        shop = self._make_shop()
        p1 = self._make_product("A", "alpha")
        p2 = self._make_product("B", "beta")
        p3 = self._make_product("C", "gamma")
        shop.discovery_ring = [str(p1.id), str(p2.id), str(p3.id)]
        with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mp:
            mp.return_value = [p1, p2, p3]
            health = validate_discovery_ring(shop)
            self.assertTrue(health["valid"])
            self.assertEqual(health["ring_length"], 3)
            self.assertEqual(health["public_count"], 3)
            self.assertEqual(health["duplicates"], [])
            self.assertEqual(health["orphans"], [])
            self.assertEqual(health["stale"], [])
            self.assertFalse(health["length_mismatch"])

    def test_empty_shop_empty_ring_is_valid(self):
        """No products + empty ring is still a valid state."""
        from ..models.shop import validate_discovery_ring
        shop = self._make_shop()
        shop.discovery_ring = []
        with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mp:
            mp.return_value = []
            health = validate_discovery_ring(shop)
            self.assertTrue(health["valid"])
            self.assertEqual(health["ring_length"], 0)
            self.assertEqual(health["public_count"], 0)

    def test_detects_duplicates(self):
        """Same ID appearing twice in the ring is flagged."""
        from ..models.shop import validate_discovery_ring
        shop = self._make_shop()
        p1 = self._make_product("A", "alpha")
        p2 = self._make_product("B", "beta")
        shop.discovery_ring = [str(p1.id), str(p2.id), str(p1.id)]
        with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mp:
            mp.return_value = [p1, p2]
            health = validate_discovery_ring(shop)
            self.assertFalse(health["valid"])
            self.assertEqual(health["duplicates"], [str(p1.id)])

    def test_detects_orphans(self):
        """Public products missing from ring are flagged as orphans."""
        from ..models.shop import validate_discovery_ring
        shop = self._make_shop()
        p1 = self._make_product("A", "alpha")
        p2 = self._make_product("B", "beta")
        p3 = self._make_product("C", "gamma")
        # p3 is public but not in ring (added after reforge)
        shop.discovery_ring = [str(p1.id), str(p2.id)]
        with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mp:
            mp.return_value = [p1, p2, p3]
            health = validate_discovery_ring(shop)
            self.assertFalse(health["valid"])
            self.assertEqual(health["orphans"], [str(p3.id)])
            self.assertTrue(health["length_mismatch"])

    def test_detects_stale(self):
        """Ring IDs whose products became non-public are stale."""
        from ..models.shop import validate_discovery_ring
        shop = self._make_shop()
        p1 = self._make_product("A", "alpha", visibility=1)
        p2 = self._make_product("B", "beta", visibility=2)   # unlisted now
        p3 = self._make_product("C", "gamma", visibility=0)  # private now
        shop.discovery_ring = [str(p1.id), str(p2.id), str(p3.id)]
        with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mp:
            mp.return_value = [p1, p2, p3]
            health = validate_discovery_ring(shop)
            self.assertFalse(health["valid"])
            self.assertEqual(sorted(health["stale"]), sorted([str(p2.id), str(p3.id)]))

    def test_detects_stale_deleted_product(self):
        """Ring IDs that don't exist in shop.products at all are stale."""
        from ..models.shop import validate_discovery_ring
        shop = self._make_shop()
        p1 = self._make_product("A", "alpha")
        ghost_id = "deadbeef-dead-beef-dead-beefdeadbeef"
        shop.discovery_ring = [str(p1.id), ghost_id]
        with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mp:
            mp.return_value = [p1]
            health = validate_discovery_ring(shop)
            self.assertFalse(health["valid"])
            self.assertIn(ghost_id, health["stale"])

    def test_length_mismatch_without_orphans_or_stale(self):
        """Ring with duplicates but all IDs present has length mismatch."""
        from ..models.shop import validate_discovery_ring
        shop = self._make_shop()
        p1 = self._make_product("A", "alpha")
        p2 = self._make_product("B", "beta")
        # Duplicate of p1 — ring length 3, public_count 2
        shop.discovery_ring = [str(p1.id), str(p1.id), str(p2.id)]
        with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mp:
            mp.return_value = [p1, p2]
            health = validate_discovery_ring(shop)
            self.assertFalse(health["valid"])
            self.assertTrue(health["length_mismatch"])
            self.assertEqual(health["ring_length"], 3)
            self.assertEqual(health["public_count"], 2)


class TestCacheVersion(unittest.TestCase):
    """Test the cache_version helper used to invalidate client ring state."""

    def _make_shop(self, ring=None):
        shop = Shop("cv-shop", "555-555-5555", "123 CV St", "desc")
        if ring is not None:
            shop.discovery_ring = ring
        return shop

    def test_stable_for_unchanged_inputs(self):
        """Same GIT_HASH + same ring → same cache_version."""
        from ..lib.cache_version import compute_cache_version
        shop = self._make_shop(ring=["a", "b", "c"])
        self.assertEqual(
            compute_cache_version(shop),
            compute_cache_version(shop),
        )

    def test_changes_when_ring_content_changes(self):
        """Any change to the ring list shifts the cache_version."""
        from ..lib.cache_version import compute_cache_version
        v1 = compute_cache_version(self._make_shop(ring=["a", "b", "c"]))
        v2 = compute_cache_version(self._make_shop(ring=["a", "b", "d"]))
        self.assertNotEqual(v1, v2)

    def test_changes_when_ring_order_changes(self):
        """Reordering the ring (reforge) shifts the cache_version."""
        from ..lib.cache_version import compute_cache_version
        v1 = compute_cache_version(self._make_shop(ring=["a", "b", "c"]))
        v2 = compute_cache_version(self._make_shop(ring=["c", "b", "a"]))
        self.assertNotEqual(v1, v2)

    def test_none_shop_returns_token(self):
        """Passing None shop does not crash — returns a stable token."""
        from ..lib.cache_version import compute_cache_version
        v = compute_cache_version(None)
        self.assertIsInstance(v, str)
        self.assertTrue(len(v) > 0)

    def test_empty_ring_is_not_error(self):
        """Shop with no ring still gets a cache_version."""
        from ..lib.cache_version import compute_cache_version
        v = compute_cache_version(self._make_shop(ring=[]))
        self.assertIsInstance(v, str)
        self.assertTrue(len(v) > 0)

    def test_changes_with_git_hash(self):
        """GIT_HASH flip changes cache_version (simulated via patch)."""
        from ..lib import cache_version as cv_module
        shop = self._make_shop(ring=["a", "b"])
        with mock.patch.object(cv_module, "GIT_HASH", "hash-one"):
            v1 = cv_module.compute_cache_version(shop)
        with mock.patch.object(cv_module, "GIT_HASH", "hash-two"):
            v2 = cv_module.compute_cache_version(shop)
        self.assertNotEqual(v1, v2)


class TestAsyncDiscoveryRing(unittest.TestCase):
    """Test the async ring reforge with dirty bit debounce."""

    def setUp(self):
        # Reset module-level state between tests
        from ..models import shop as shop_module
        shop_module._reforge_running.clear()
        shop_module._reforge_dirty.clear()

    def tearDown(self):
        from ..models import shop as shop_module
        shop_module._reforge_running.clear()
        shop_module._reforge_dirty.clear()

    def test_async_reforge_spawns_thread(self):
        """reforge_discovery_ring_async starts a background thread."""
        import threading
        from ..models.shop import reforge_discovery_ring_async

        initial_threads = threading.active_count()
        started = threading.Event()
        proceed = threading.Event()

        mock_factory = mock.Mock()
        mock_session = mock.Mock()
        mock_factory.return_value.get_bind.return_value = "fake_bind"

        mock_shop = mock.Mock()
        mock_shop.products = []
        mock_shop.name = "test"
        mock_shop.environment = 0
        mock_shop.is_non_production = False

        # Patch Session and compute to block until we signal
        with mock.patch("make_post_sell.models.shop.SASession") as MockSession:
            MockSession.return_value = mock_session
            mock_session.get.return_value = mock_shop

            original_compute = None
            def slow_compute(shop):
                started.set()
                proceed.wait(timeout=5)
                return ["id1", "id2"]

            with mock.patch(
                "make_post_sell.models.shop.compute_discovery_ring",
                side_effect=slow_compute,
            ):
                t = reforge_discovery_ring_async("shop123", mock_factory)

                # Thread should have started
                self.assertTrue(started.wait(timeout=5))

                # Let it finish
                proceed.set()

        # Wait for thread to complete
        t.join(timeout=5)

        mock_session.commit.assert_called()

    def test_dirty_bit_set_when_already_running(self):
        """Second call during active reforge sets dirty bit instead of spawning."""
        import threading
        import time
        from ..models.shop import reforge_discovery_ring_async
        from ..models import shop as shop_module

        started = threading.Event()
        proceed = threading.Event()

        mock_factory = mock.Mock()
        mock_session = mock.Mock()
        mock_factory.return_value.get_bind.return_value = "fake_bind"

        mock_shop = mock.Mock()
        mock_shop.products = []
        mock_shop.name = "test"
        mock_shop.environment = 0
        mock_shop.is_non_production = False

        call_count = [0]

        def slow_compute(shop):
            call_count[0] += 1
            if call_count[0] == 1:
                started.set()
                proceed.wait(timeout=5)
            return ["id1"]

        patcher1 = mock.patch("make_post_sell.models.shop.SASession")
        patcher2 = mock.patch(
            "make_post_sell.models.shop.compute_discovery_ring",
            side_effect=slow_compute,
        )
        MockSession = patcher1.start()
        patcher2.start()
        MockSession.return_value = mock_session
        mock_session.get.return_value = mock_shop

        try:
            # First call starts the thread
            t = reforge_discovery_ring_async("shop_dirty", mock_factory)
            self.assertTrue(started.wait(timeout=5))

            # Second call while running should set dirty bit
            reforge_discovery_ring_async("shop_dirty", mock_factory)
            self.assertTrue(shop_module._reforge_dirty.get("shop_dirty"))

            # Let the first run finish — it should loop and run again
            proceed.set()
            t.join(timeout=5)
        finally:
            patcher2.stop()
            patcher1.stop()

        # compute was called twice (original + dirty re-run)
        self.assertEqual(call_count[0], 2)
        # State is cleaned up
        self.assertNotIn("shop_dirty", shop_module._reforge_running)
        self.assertNotIn("shop_dirty", shop_module._reforge_dirty)

    def test_no_queue_only_one_dirty_bit(self):
        """Multiple calls during active reforge produce at most one re-run."""
        import threading
        import time
        from ..models.shop import reforge_discovery_ring_async
        from ..models import shop as shop_module

        started = threading.Event()
        proceed = threading.Event()

        mock_factory = mock.Mock()
        mock_session = mock.Mock()
        mock_factory.return_value.get_bind.return_value = "fake_bind"

        mock_shop = mock.Mock()
        mock_shop.products = []
        mock_shop.name = "test"
        mock_shop.environment = 0
        mock_shop.is_non_production = False

        call_count = [0]

        def slow_compute(shop):
            call_count[0] += 1
            if call_count[0] == 1:
                started.set()
                proceed.wait(timeout=5)
            return ["id1"]

        patcher1 = mock.patch("make_post_sell.models.shop.SASession")
        patcher2 = mock.patch(
            "make_post_sell.models.shop.compute_discovery_ring",
            side_effect=slow_compute,
        )
        MockSession = patcher1.start()
        patcher2.start()
        MockSession.return_value = mock_session
        mock_session.get.return_value = mock_shop

        try:
            # First call starts thread
            t = reforge_discovery_ring_async("shop_multi", mock_factory)
            self.assertTrue(started.wait(timeout=5))

            # Rapid-fire 5 more calls — should all collapse to one dirty bit
            for _ in range(5):
                reforge_discovery_ring_async("shop_multi", mock_factory)

            # Still just one dirty flag
            self.assertTrue(shop_module._reforge_dirty.get("shop_multi"))

            proceed.set()
            t.join(timeout=5)
        finally:
            patcher2.stop()
            patcher1.stop()

        # Exactly 2 compute calls: initial + one dirty re-run (not 6)
        self.assertEqual(call_count[0], 2)

    def test_session_expire_all_on_dirty_rerun(self):
        """Dirty re-run calls session.expire_all() to get fresh DB state."""
        import threading
        import time
        from ..models.shop import reforge_discovery_ring_async

        started = threading.Event()
        proceed = threading.Event()

        mock_factory = mock.Mock()
        mock_session = mock.Mock()
        mock_factory.return_value.get_bind.return_value = "fake_bind"

        mock_shop = mock.Mock()
        mock_shop.products = []
        mock_shop.name = "test"
        mock_shop.environment = 0
        mock_shop.is_non_production = False

        call_count = [0]

        def slow_compute(shop):
            call_count[0] += 1
            if call_count[0] == 1:
                started.set()
                proceed.wait(timeout=5)
            return ["id1"]

        patcher1 = mock.patch("make_post_sell.models.shop.SASession")
        patcher2 = mock.patch(
            "make_post_sell.models.shop.compute_discovery_ring",
            side_effect=slow_compute,
        )
        MockSession = patcher1.start()
        patcher2.start()
        MockSession.return_value = mock_session
        mock_session.get.return_value = mock_shop

        try:
            t = reforge_discovery_ring_async("shop_expire", mock_factory)
            self.assertTrue(started.wait(timeout=5))

            # Trigger dirty bit
            reforge_discovery_ring_async("shop_expire", mock_factory)
            proceed.set()
            t.join(timeout=5)
        finally:
            patcher2.stop()
            patcher1.stop()

        # expire_all called before the dirty re-run
        mock_session.expire_all.assert_called()

    def test_exception_cleans_up_state(self):
        """If reforge crashes, locks are still cleaned up."""
        import threading
        from ..models.shop import reforge_discovery_ring_async
        from ..models import shop as shop_module

        mock_factory = mock.Mock()
        mock_session = mock.Mock()
        mock_factory.return_value.get_bind.return_value = "fake_bind"
        mock_session.get.side_effect = Exception("DB exploded")

        with mock.patch("make_post_sell.models.shop.SASession") as MockSession:
            MockSession.return_value = mock_session

            t = reforge_discovery_ring_async("shop_crash", mock_factory)

        t.join(timeout=5)

        # State should be cleaned up despite exception
        self.assertNotIn("shop_crash", shop_module._reforge_running)
        self.assertNotIn("shop_crash", shop_module._reforge_dirty)
        mock_session.rollback.assert_called()
        mock_session.close.assert_called()

    def test_shop_not_found_returns_cleanly(self):
        """If shop is deleted between trigger and execution, exits cleanly."""
        from ..models.shop import reforge_discovery_ring_async
        from ..models import shop as shop_module

        mock_factory = mock.Mock()
        mock_session = mock.Mock()
        mock_factory.return_value.get_bind.return_value = "fake_bind"
        mock_session.get.return_value = None  # shop deleted

        with mock.patch("make_post_sell.models.shop.SASession") as MockSession:
            MockSession.return_value = mock_session

            t = reforge_discovery_ring_async("shop_gone", mock_factory)

        t.join(timeout=5)

        # Should not have tried to commit
        mock_session.commit.assert_not_called()
        # State cleaned up
        self.assertNotIn("shop_gone", shop_module._reforge_running)


class TestShopSubscription(unittest.TestCase):
    """Test ShopSubscription model."""

    @mock.patch("make_post_sell.models.user.is_user_name_available", mock_always_true)
    def setUp(self):
        self.shop = Shop(
            "test-shop",
            "555-555-5555",
            "123 Test St",
            "Test shop description",
        )
        self.user = User("subscriber@example.com")

    def test_create_anonymous_subscription(self):
        """Test creating a subscription without a user (anonymous)."""
        from ..models.shop_subscription import ShopSubscription

        sub = ShopSubscription("anon@example.com", self.shop.id)
        self.assertEqual(sub.email, "anon@example.com")
        self.assertEqual(sub.shop_id, self.shop.id)
        self.assertIsNone(sub.user_id)
        self.assertFalse(sub.verified)
        self.assertIsNotNone(sub.verify_token)
        self.assertIsNotNone(sub.unsubscribe_token)
        self.assertFalse(sub.disabled)
        self.assertGreater(sub.created_timestamp, 0)
        self.assertGreater(sub.last_digest_timestamp, 0)

    def test_create_logged_in_subscription(self):
        """Test creating a subscription with a user (auto-verified)."""
        from ..models.shop_subscription import ShopSubscription

        sub = ShopSubscription("user@example.com", self.shop.id, user_id=self.user.id)
        self.assertEqual(sub.user_id, self.user.id)
        self.assertTrue(sub.verified)
        self.assertIsNone(sub.verify_token)

    def test_unique_tokens(self):
        """Test that each subscription gets unique tokens."""
        from ..models.shop_subscription import ShopSubscription

        sub1 = ShopSubscription("a@example.com", self.shop.id)
        sub2 = ShopSubscription("b@example.com", self.shop.id)
        self.assertNotEqual(sub1.unsubscribe_token, sub2.unsubscribe_token)
        self.assertNotEqual(sub1.verify_token, sub2.verify_token)

    def test_default_frequency_daily(self):
        """Test that default frequency is daily."""
        from ..models.shop_subscription import ShopSubscription, FREQUENCY_DAILY

        sub = ShopSubscription("test@example.com", self.shop.id)
        self.assertEqual(sub.frequency, FREQUENCY_DAILY)

    def test_human_frequency(self):
        """Test human_frequency property."""
        from ..models.shop_subscription import (
            ShopSubscription,
            FREQUENCY_DAILY,
            FREQUENCY_WEEKLY,
        )

        sub = ShopSubscription("test@example.com", self.shop.id)
        sub.frequency = FREQUENCY_WEEKLY
        self.assertEqual(sub.human_frequency, "Weekly")

        sub.frequency = FREQUENCY_DAILY
        self.assertEqual(sub.human_frequency, "Daily")


class TestSubscribeViewCurrentFrequency(unittest.TestCase):
    """Test that subscribe view returns correct current_frequency for the template."""

    def _make_request(self, user=None, shop=None):
        """Build a mock request for the subscribe view."""
        request = mock.MagicMock()
        request.method = "GET"
        request.shop = shop
        request.user = user
        request.session = mock.MagicMock()
        request.session.flash = mock.Mock()
        return request

    def _make_shop(self):
        shop = mock.MagicMock()
        shop.subscriptions_enabled = True
        shop.id = "shop-id-123"
        shop.name = "Test Shop"
        return shop

    def _make_user(self, email="test@example.com"):
        user = mock.MagicMock()
        user.authenticated = True
        user.email = email
        return user

    @mock.patch("make_post_sell.views.subscribe.get_subscription_for_email_and_shop")
    def test_returns_active_frequency_daily(self, mock_get_sub):
        """Logged-in user with active daily subscription sees freq=0."""
        from ..views.subscribe import subscribe

        sub = mock.MagicMock()
        sub.disabled = False
        sub.frequency = 0  # FREQUENCY_DAILY
        mock_get_sub.return_value = sub

        request = self._make_request(
            user=self._make_user(), shop=self._make_shop()
        )
        result = subscribe(request)
        self.assertEqual(result["current_frequency"], 0)

    @mock.patch("make_post_sell.views.subscribe.get_subscription_for_email_and_shop")
    def test_returns_active_frequency_weekly(self, mock_get_sub):
        """Logged-in user with active weekly subscription sees freq=1."""
        from ..views.subscribe import subscribe

        sub = mock.MagicMock()
        sub.disabled = False
        sub.frequency = 1  # FREQUENCY_WEEKLY
        mock_get_sub.return_value = sub

        request = self._make_request(
            user=self._make_user(), shop=self._make_shop()
        )
        result = subscribe(request)
        self.assertEqual(result["current_frequency"], 1)

    @mock.patch("make_post_sell.views.subscribe.get_subscription_for_email_and_shop")
    def test_returns_active_frequency_immediate(self, mock_get_sub):
        """Logged-in user with active immediate subscription sees freq=2."""
        from ..views.subscribe import subscribe

        sub = mock.MagicMock()
        sub.disabled = False
        sub.frequency = 2  # FREQUENCY_IMMEDIATE
        mock_get_sub.return_value = sub

        request = self._make_request(
            user=self._make_user(), shop=self._make_shop()
        )
        result = subscribe(request)
        self.assertEqual(result["current_frequency"], 2)

    @mock.patch("make_post_sell.views.subscribe.get_subscription_for_email_and_shop")
    def test_disabled_subscription_returns_negative_one(self, mock_get_sub):
        """Logged-in user with disabled subscription sees freq=-1 (None radio)."""
        from ..views.subscribe import subscribe

        sub = mock.MagicMock()
        sub.disabled = True
        mock_get_sub.return_value = sub

        request = self._make_request(
            user=self._make_user(), shop=self._make_shop()
        )
        result = subscribe(request)
        self.assertEqual(result["current_frequency"], -1)

    @mock.patch("make_post_sell.views.subscribe.get_subscription_for_email_and_shop")
    def test_no_subscription_returns_none(self, mock_get_sub):
        """Logged-in user with no subscription sees current_frequency=None."""
        from ..views.subscribe import subscribe

        mock_get_sub.return_value = None

        request = self._make_request(
            user=self._make_user(), shop=self._make_shop()
        )
        result = subscribe(request)
        self.assertIsNone(result["current_frequency"])

    def test_anonymous_user_returns_none(self):
        """Anonymous user (not logged in) sees current_frequency=None."""
        from ..views.subscribe import subscribe

        request = self._make_request(user=None, shop=self._make_shop())
        result = subscribe(request)
        self.assertIsNone(result["current_frequency"])


class TestMentionParsing(unittest.TestCase):
    """Test @mention parsing from comment text."""

    def test_simple_mention(self):
        from ..lib.mentions import extract_mentions

        result = extract_mentions("Hello @user-abcd1234 nice post!")
        self.assertEqual(result, ["user-abcd1234"])

    def test_multiple_mentions(self):
        from ..lib.mentions import extract_mentions

        result = extract_mentions("@user-abc123 and @user-def456 check this out")
        self.assertEqual(len(result), 2)
        self.assertIn("user-abc123", result)
        self.assertIn("user-def456", result)

    def test_duplicate_mentions(self):
        from ..lib.mentions import extract_mentions

        result = extract_mentions("@user-abc123 said hi, @user-abc123 said bye")
        self.assertEqual(len(result), 1)
        self.assertEqual(result[0], "user-abc123")

    def test_no_mentions(self):
        from ..lib.mentions import extract_mentions

        result = extract_mentions("No mentions here.")
        self.assertEqual(result, [])

    def test_empty_text(self):
        from ..lib.mentions import extract_mentions

        result = extract_mentions("")
        self.assertEqual(result, [])

    def test_none_text(self):
        from ..lib.mentions import extract_mentions

        result = extract_mentions(None)
        self.assertEqual(result, [])

    def test_email_not_matched(self):
        """Email addresses should not be matched as mentions."""
        from ..lib.mentions import extract_mentions

        result = extract_mentions("Email me at person@example.com")
        # @example would not match because it follows a non-whitespace char
        self.assertEqual(result, [])

    def test_start_of_string(self):
        from ..lib.mentions import extract_mentions

        result = extract_mentions("@user-start123 at the start")
        self.assertEqual(result, ["user-start123"])

    def test_mention_too_short(self):
        """Usernames under 3 chars should not match."""
        from ..lib.mentions import extract_mentions

        result = extract_mentions("@ab is too short")
        self.assertEqual(result, [])


class TestDigestBuilding(unittest.TestCase):
    """Test digest email building functions."""

    def test_build_digest_text(self):
        from ..lib.digest_sender import build_digest_text

        mock_item1 = mock.Mock()
        mock_item1.title = "Cool Product"
        mock_item1.is_sellable = True

        mock_item2 = mock.Mock()
        mock_item2.title = "Blog Post"
        mock_item2.is_sellable = False

        result = build_digest_text(
            "Test Shop", [mock_item1, mock_item2], "https://example.com/unsub"
        )
        self.assertIn("Test Shop", result)
        self.assertIn("Cool Product", result)
        self.assertIn("Blog Post", result)
        self.assertIn("[Product]", result)
        self.assertIn("[Content]", result)
        self.assertIn("https://example.com/unsub", result)

    def test_build_digest_html(self):
        from ..lib.digest_sender import build_digest_html

        mock_item = mock.Mock()
        mock_item.title = "New Item"
        mock_item.is_sellable = True
        mock_item.extensions = {}
        mock_item.s3_path = "test/path"
        mock_item.updated_timestamp = 12345

        result = build_digest_html(
            "Test Shop", [mock_item], "https://example.com/unsub"
        )
        self.assertIn("Test Shop", result)
        self.assertIn("New Item", result)
        self.assertIn("https://example.com/unsub", result)

    def test_build_digest_html_with_thumbnail(self):
        from ..lib.digest_sender import build_digest_html

        mock_item = mock.Mock()
        mock_item.title = "Item With Image"
        mock_item.is_sellable = False
        mock_item.extensions = {"thumbnail1": "jpg"}
        mock_item.s3_path = "shop/product"
        mock_item.updated_timestamp = 99999

        result = build_digest_html(
            "Test Shop",
            [mock_item],
            "https://example.com/unsub",
            get_endpoint="https://cdn.example.com",
        )
        self.assertIn("https://cdn.example.com/shop/product/thumbnail1", result)


class TestProductViewCount(unittest.TestCase):
    """Test Product.human_view_count formatting."""

    def _make_product(self, view_count):
        p = mock.Mock()
        p.view_count = view_count
        from ..models.product import Product
        p.human_view_count = Product.human_view_count.fget(p)
        return p

    def test_zero_returns_empty(self):
        self.assertEqual(self._make_product(0).human_view_count, "")

    def test_none_returns_empty(self):
        self.assertEqual(self._make_product(None).human_view_count, "")

    def test_one_view_singular(self):
        self.assertEqual(self._make_product(1).human_view_count, "1 view")

    def test_plural_views(self):
        self.assertEqual(self._make_product(420).human_view_count, "420 views")

    def test_one_thousand(self):
        self.assertEqual(self._make_product(1000).human_view_count, "1K views")

    def test_twelve_hundred(self):
        self.assertEqual(self._make_product(1200).human_view_count, "1.2K views")

    def test_one_million(self):
        self.assertEqual(self._make_product(1_000_000).human_view_count, "1M views")

    def test_one_point_eight_million(self):
        self.assertEqual(self._make_product(1_800_000).human_view_count, "1.8M views")

    def test_999_views(self):
        self.assertEqual(self._make_product(999).human_view_count, "999 views")


class TestSignalClassifiers(unittest.TestCase):
    """Test classify_referrer and classify_device functions."""

    # --- classify_referrer (returns (class, domain, query) tuple) ---

    def test_referrer_direct_empty(self):
        self.assertEqual(classify_referrer("", "example.com"), (0, None, None))

    def test_referrer_direct_none(self):
        self.assertEqual(classify_referrer(None, "example.com"), (0, None, None))

    def test_referrer_search_google(self):
        cls, domain, query = classify_referrer(
            "https://www.google.com/search?q=foo", "example.com"
        )
        self.assertEqual(cls, 1)
        self.assertEqual(domain, "www.google.com")
        self.assertEqual(query, "foo")

    def test_referrer_search_duckduckgo(self):
        cls, domain, query = classify_referrer(
            "https://duckduckgo.com/?q=bar", "example.com"
        )
        self.assertEqual(cls, 1)
        self.assertEqual(domain, "duckduckgo.com")
        self.assertEqual(query, "bar")

    def test_referrer_search_no_query(self):
        cls, domain, query = classify_referrer(
            "https://www.google.com/", "example.com"
        )
        self.assertEqual(cls, 1)
        self.assertEqual(domain, "www.google.com")
        self.assertIsNone(query)

    def test_referrer_social_twitter(self):
        cls, domain, query = classify_referrer(
            "https://twitter.com/user/status/123", "example.com"
        )
        self.assertEqual(cls, 2)
        self.assertEqual(domain, "twitter.com")
        self.assertIsNone(query)

    def test_referrer_social_reddit(self):
        cls, domain, query = classify_referrer(
            "https://www.reddit.com/r/test", "example.com"
        )
        self.assertEqual(cls, 2)
        self.assertEqual(domain, "www.reddit.com")
        self.assertIsNone(query)

    def test_referrer_internal(self):
        cls, domain, query = classify_referrer(
            "https://example.com/some/page", "example.com"
        )
        self.assertEqual(cls, 3)
        self.assertEqual(domain, "example.com")
        self.assertIsNone(query)

    def test_referrer_unknown_external(self):
        cls, domain, query = classify_referrer(
            "https://randomsite.org/page", "example.com"
        )
        self.assertEqual(cls, 4)
        self.assertEqual(domain, "randomsite.org")
        self.assertIsNone(query)

    def test_referrer_yahoo_query(self):
        cls, domain, query = classify_referrer(
            "https://search.yahoo.com/search?p=beats", "example.com"
        )
        self.assertEqual(cls, 1)
        self.assertEqual(query, "beats")

    # --- classify_device ---

    def test_device_mobile(self):
        self.assertEqual(classify_device(375), 0)

    def test_device_mobile_boundary(self):
        self.assertEqual(classify_device(768), 0)

    def test_device_tablet(self):
        self.assertEqual(classify_device(800), 1)

    def test_device_tablet_boundary(self):
        self.assertEqual(classify_device(1024), 1)

    def test_device_desktop(self):
        self.assertEqual(classify_device(1920), 2)

    def test_device_desktop_boundary(self):
        self.assertEqual(classify_device(1025), 2)

    def test_device_none(self):
        self.assertIsNone(classify_device(None))


class TestAnalyticsHelpers(unittest.TestCase):
    """Unit tests for analytics view helper functions."""

    def test_fmt_ms_none(self):
        from ..views.analytics import _fmt_ms
        self.assertEqual(_fmt_ms(None), "\u2014")

    def test_fmt_ms_seconds(self):
        from ..views.analytics import _fmt_ms
        self.assertEqual(_fmt_ms(42000), "42s")

    def test_fmt_ms_minutes(self):
        from ..views.analytics import _fmt_ms
        self.assertEqual(_fmt_ms(125000), "2m 5s")

    def test_fmt_ms_hours(self):
        from ..views.analytics import _fmt_ms
        self.assertEqual(_fmt_ms(3661000), "1h 1m")

    def test_fmt_ms_zero(self):
        from ..views.analytics import _fmt_ms
        self.assertEqual(_fmt_ms(0), "0s")

    def test_fmt_pct_none(self):
        from ..views.analytics import _fmt_pct
        self.assertEqual(_fmt_pct(None), "\u2014")

    def test_fmt_pct_half(self):
        from ..views.analytics import _fmt_pct
        self.assertEqual(_fmt_pct(0.5), "50%")

    def test_fmt_pct_full(self):
        from ..views.analytics import _fmt_pct
        self.assertEqual(_fmt_pct(1.0), "100%")

    def test_fmt_pct_zero(self):
        from ..views.analytics import _fmt_pct
        self.assertEqual(_fmt_pct(0.0), "0%")

    def test_fmt_score_none(self):
        from ..views.analytics import _fmt_score
        self.assertEqual(_fmt_score(None), "\u2014")

    def test_fmt_score_value(self):
        from ..views.analytics import _fmt_score
        self.assertEqual(_fmt_score(3.14159), "3.1")

    def test_fmt_score_zero(self):
        from ..views.analytics import _fmt_score
        self.assertEqual(_fmt_score(0.0), "0.0")

    def test_cutoffs_returns_expected_keys(self):
        from ..views.analytics import _cutoffs
        cuts = _cutoffs()
        day_ms = 24 * 60 * 60 * 1000
        for key in ("1d", "7d", "14d", "21d", "28d", "365d"):
            self.assertIn(key, cuts)
        self.assertAlmostEqual(cuts["7d"] - cuts["14d"], 7 * day_ms, delta=1000)
        self.assertAlmostEqual(cuts["14d"] - cuts["21d"], 7 * day_ms, delta=1000)

    def test_referrer_labels_complete(self):
        from ..views.analytics import REFERRER_LABELS
        self.assertEqual(len(REFERRER_LABELS), 5)
        self.assertIn(0, REFERRER_LABELS)
        self.assertIn(4, REFERRER_LABELS)

    def test_device_labels_complete(self):
        from ..views.analytics import DEVICE_LABELS
        self.assertEqual(len(DEVICE_LABELS), 3)
        self.assertEqual(DEVICE_LABELS[0], "Mobile")
        self.assertEqual(DEVICE_LABELS[2], "Desktop")


class TestSentiment(unittest.TestCase):
    """Unit tests for the rule-based comment sentiment classifier."""

    def _classify(self, text):
        from ..lib.sentiment import classify_sentiment
        return classify_sentiment(text)

    def test_positive_comment(self):
        self.assertEqual(self._classify("This is amazing and wonderful!"), 1)

    def test_negative_comment(self):
        self.assertEqual(self._classify("Terrible product, total waste"), -1)

    def test_neutral_comment(self):
        self.assertEqual(self._classify("I downloaded the file yesterday"), 0)

    def test_empty_string(self):
        self.assertEqual(self._classify(""), 0)

    def test_none_input(self):
        self.assertEqual(self._classify(None), 0)

    def test_negation_flips_positive(self):
        self.assertEqual(self._classify("not good"), -1)

    def test_negation_flips_negative(self):
        self.assertEqual(self._classify("not bad"), 1)

    def test_intensifier_boosts_score(self):
        # "really great" should still be positive
        self.assertEqual(self._classify("really great"), 1)
        # "very terrible" should still be negative
        self.assertEqual(self._classify("very terrible"), -1)

    def test_mixed_sentiment_leans_neutral(self):
        # Roughly equal positive and negative words
        self.assertEqual(self._classify("good but also bad"), 0)

    def test_only_punctuation_and_numbers(self):
        self.assertEqual(self._classify("123 !!! ???"), 0)

    def test_short_positive(self):
        self.assertEqual(self._classify("love it"), 1)

    def test_short_negative(self):
        self.assertEqual(self._classify("hate it"), -1)


class TestGiftCard(unittest.TestCase):

    def test_gift_card_creation(self):
        from make_post_sell.models.gift_card import GiftCard
        shop = mock.MagicMock()
        shop.id = uuid.uuid1()
        gc = GiftCard(shop=shop, amount_in_cents=5000)
        self.assertEqual(gc.initial_amount_in_cents, 5000)
        self.assertEqual(gc.balance_in_cents, 5000)
        self.assertFalse(gc.disabled)
        self.assertTrue(gc.code.startswith("GC-"))
        self.assertEqual(len(gc.code), 19)  # GC- + 16 hex chars

    def test_gift_card_is_valid(self):
        from make_post_sell.models.gift_card import GiftCard
        shop = mock.MagicMock()
        shop.id = uuid.uuid1()
        gc = GiftCard(shop=shop, amount_in_cents=5000)
        self.assertTrue(gc.is_valid)

    def test_gift_card_disabled_not_valid(self):
        from make_post_sell.models.gift_card import GiftCard
        shop = mock.MagicMock()
        shop.id = uuid.uuid1()
        gc = GiftCard(shop=shop, amount_in_cents=5000)
        gc.disabled = True
        self.assertFalse(gc.is_valid)

    def test_gift_card_zero_balance_not_valid(self):
        from make_post_sell.models.gift_card import GiftCard
        shop = mock.MagicMock()
        shop.id = uuid.uuid1()
        gc = GiftCard(shop=shop, amount_in_cents=5000)
        gc.balance_in_cents = 0
        self.assertFalse(gc.is_valid)

    def test_gift_card_deduct(self):
        from make_post_sell.models.gift_card import GiftCard
        shop = mock.MagicMock()
        shop.id = uuid.uuid1()
        gc = GiftCard(shop=shop, amount_in_cents=5000)
        deducted = gc.deduct(2000)
        self.assertEqual(deducted, 2000)
        self.assertEqual(gc.balance_in_cents, 3000)

    def test_gift_card_deduct_more_than_balance(self):
        from make_post_sell.models.gift_card import GiftCard
        shop = mock.MagicMock()
        shop.id = uuid.uuid1()
        gc = GiftCard(shop=shop, amount_in_cents=1000)
        deducted = gc.deduct(5000)
        self.assertEqual(deducted, 1000)
        self.assertEqual(gc.balance_in_cents, 0)

    def test_gift_card_balance_property(self):
        from make_post_sell.models.gift_card import GiftCard
        shop = mock.MagicMock()
        shop.id = uuid.uuid1()
        gc = GiftCard(shop=shop, amount_in_cents=5000)
        self.assertEqual(gc.balance, 50.00)
        self.assertEqual(gc.initial_amount, 50.00)

    def test_gift_card_is_fully_redeemed(self):
        from make_post_sell.models.gift_card import GiftCard
        shop = mock.MagicMock()
        shop.id = uuid.uuid1()
        gc = GiftCard(shop=shop, amount_in_cents=5000)
        self.assertFalse(gc.is_fully_redeemed)
        gc.balance_in_cents = 0
        self.assertTrue(gc.is_fully_redeemed)

    def test_gift_card_code_uniqueness(self):
        from make_post_sell.models.gift_card import generate_gift_card_code
        codes = set()
        for _ in range(100):
            codes.add(generate_gift_card_code())
        self.assertEqual(len(codes), 100)

    def test_gift_card_with_gift_email(self):
        from make_post_sell.models.gift_card import GiftCard
        shop = mock.MagicMock()
        shop.id = uuid.uuid1()
        gc = GiftCard(
            shop=shop,
            amount_in_cents=2500,
            purchaser_email="buyer@test.com",
            gift_email="friend@test.com",
            gift_message="Happy birthday!",
        )
        self.assertEqual(gc.gift_email, "friend@test.com")
        self.assertEqual(gc.gift_message, "Happy birthday!")
        self.assertEqual(gc.purchaser_email, "buyer@test.com")


class TestShopEnvironment(unittest.TestCase):
    """MPS-14: Dev & Stage environment properties."""

    def _make_shop(self, environment=0):
        shop = Shop("env-test", "555-0000", "123 Test St", "test shop")
        shop.environment = environment
        return shop

    def test_default_is_production(self):
        shop = self._make_shop()
        self.assertTrue(shop.is_production)
        self.assertFalse(shop.is_non_production)
        self.assertEqual(shop.environment_label, "Production")

    def test_staging_environment(self):
        shop = self._make_shop(environment=1)
        self.assertTrue(shop.is_staging)
        self.assertTrue(shop.is_non_production)
        self.assertFalse(shop.is_production)
        self.assertEqual(shop.environment_label, "Staging")

    def test_development_environment(self):
        shop = self._make_shop(environment=2)
        self.assertTrue(shop.is_development)
        self.assertTrue(shop.is_non_production)
        self.assertFalse(shop.is_production)
        self.assertEqual(shop.environment_label, "Development")

    def test_unknown_environment_defaults_to_production_label(self):
        shop = self._make_shop(environment=99)
        self.assertEqual(shop.environment_label, "Production")
        self.assertTrue(shop.is_non_production)


class TestShopTrial(unittest.TestCase):
    """MPS-15: 21-day free trial properties."""

    def _make_shop(self, trial_started_ms=None, plan_active=False):
        shop = Shop("trial-test", "555-0000", "123 Test St", "test shop")
        shop.trial_started_timestamp = trial_started_ms
        shop.plan_active = plan_active
        return shop

    def test_grandfathered_shop_is_active(self):
        """Pre-trial shops (NULL timestamp) are always active."""
        shop = self._make_shop(trial_started_ms=None)
        self.assertTrue(shop.is_active)
        self.assertFalse(shop.is_trial_active)
        self.assertFalse(shop.is_trial_expired)
        self.assertIsNone(shop.trial_expiry_timestamp)

    @mock.patch("make_post_sell.models.shop.time")
    def test_trial_active_within_21_days(self, mock_time):
        import time as real_time
        now_ms = int(real_time.time() * 1000)
        # Started 10 days ago
        started = now_ms - (10 * 24 * 60 * 60 * 1000)
        shop = self._make_shop(trial_started_ms=started)
        mock_time.time.return_value = now_ms / 1000.0
        self.assertTrue(shop.is_trial_active)
        self.assertFalse(shop.is_trial_expired)
        self.assertTrue(shop.is_active)
        self.assertGreater(shop.trial_days_remaining, 0)

    @mock.patch("make_post_sell.models.shop.time")
    def test_trial_expired_after_21_days(self, mock_time):
        import time as real_time
        now_ms = int(real_time.time() * 1000)
        # Started 22 days ago
        started = now_ms - (22 * 24 * 60 * 60 * 1000)
        shop = self._make_shop(trial_started_ms=started)
        mock_time.time.return_value = now_ms / 1000.0
        self.assertFalse(shop.is_trial_active)
        self.assertTrue(shop.is_trial_expired)
        self.assertFalse(shop.is_active)
        self.assertEqual(shop.trial_days_remaining, 0)

    @mock.patch("make_post_sell.models.shop.time")
    def test_paid_plan_overrides_trial(self, mock_time):
        import time as real_time
        now_ms = int(real_time.time() * 1000)
        # Started 22 days ago but plan is active
        started = now_ms - (22 * 24 * 60 * 60 * 1000)
        shop = self._make_shop(trial_started_ms=started, plan_active=True)
        mock_time.time.return_value = now_ms / 1000.0
        self.assertFalse(shop.is_trial_active)
        self.assertFalse(shop.is_trial_expired)
        self.assertTrue(shop.is_active)

    def test_trial_expiry_timestamp(self):
        shop = self._make_shop(trial_started_ms=1000000)
        expected = 1000000 + (21 * 24 * 60 * 60 * 1000)
        self.assertEqual(shop.trial_expiry_timestamp, expected)


class TestShopBYOB(unittest.TestCase):
    """MPS-16: Bring Your Own Bucket properties."""

    def _make_shop(self, enabled=False, **kwargs):
        shop = Shop("byob-test", "555-0000", "123 Test St", "test shop")
        shop.primary_s3_enabled = enabled
        shop.primary_s3_endpoint = kwargs.get("endpoint", "https://nyc3.digitaloceanspaces.com")
        shop.primary_s3_region = kwargs.get("region", "nyc3")
        shop.primary_s3_bucket = kwargs.get("bucket", "my-bucket")
        shop.primary_s3_access_key = kwargs.get("access_key", "AKID")
        shop.primary_s3_secret_key = kwargs.get("secret_key", "SECRET")
        shop.primary_s3_cdn_endpoint = kwargs.get("cdn_endpoint", "https://my-bucket.nyc3.cdn.digitaloceanspaces.com")
        return shop

    def test_has_primary_s3_when_enabled_and_configured(self):
        shop = self._make_shop(enabled=True)
        self.assertTrue(shop.has_primary_s3)

    def test_has_primary_s3_false_when_disabled(self):
        shop = self._make_shop(enabled=False)
        self.assertFalse(shop.has_primary_s3)

    def test_has_primary_s3_false_when_missing_fields(self):
        shop = self._make_shop(enabled=True, access_key="")
        self.assertFalse(shop.has_primary_s3)

    def test_media_cdn_endpoint_returns_custom_when_configured(self):
        shop = self._make_shop(enabled=True)
        self.assertEqual(shop.media_cdn_endpoint, "https://my-bucket.nyc3.cdn.digitaloceanspaces.com")

    def test_media_cdn_endpoint_returns_none_when_not_configured(self):
        shop = self._make_shop(enabled=False)
        self.assertIsNone(shop.media_cdn_endpoint)


class TestTorrentLib(unittest.TestCase):
    """Unit tests for lib/torrent.py — bundle generation and DB persistence."""

    def _session_factory(self, product):
        session = mock.MagicMock()
        session.get.return_value = product
        session_cm = mock.MagicMock()
        session_cm.__enter__ = mock.Mock(return_value=session)
        session_cm.__exit__ = mock.Mock(return_value=False)
        return mock.Mock(return_value=session_cm)

    def _make_torf_mock(self, magnet="magnet:?xt=urn:btih:deadbeef"):
        """Return a mock torf.Torrent class whose instances behave correctly."""
        torrent_instance = mock.MagicMock()
        torrent_instance.magnet.return_value = magnet
        torf_mod = mock.MagicMock()
        torf_mod.Torrent.return_value = torrent_instance
        return torf_mod, torrent_instance

    def _files(self, cdn="https://cdn.example.com"):
        return [
            {
                "s3_key": "shop1/prod-001/preview",
                "filename": "preview.mp3",
                "webseed_url": f"{cdn}/shop1/prod-001/preview",
            },
            {
                "s3_key": "shop1/prod-001/thumbnail1",
                "filename": "thumbnail1.jpg",
                "webseed_url": f"{cdn}/shop1/prod-001/thumbnail1",
            },
        ]

    def test_webseed_urls_embedded_from_files_list(self):
        """generate_torrent passes webseed URLs from the files list into the torrent."""
        from ..lib.torrent import generate_torrent

        s3_client = mock.MagicMock()
        product = mock.MagicMock()
        product.id = "prod-001"
        product.torrent_magnet_link = None
        product.torrent_file_url = None
        session_factory = self._session_factory(product)
        torf_mod, torrent_instance = self._make_torf_mock()

        with mock.patch.dict("sys.modules", {"torf": torf_mod}):
            generate_torrent(
                s3_client, "my-bucket",
                bundle_name="my-product",
                files=self._files(),
                description="A great product.",
                s3_path="shop1/prod-001",
                product_id="prod-001",
                session_factory=session_factory,
                cdn_endpoint="https://cdn.example.com",
            )

        call_kwargs = torf_mod.Torrent.call_args.kwargs
        webseeds = call_kwargs.get("webseeds", [])
        self.assertIn("https://cdn.example.com/shop1/prod-001/preview", webseeds)
        self.assertIn("https://cdn.example.com/shop1/prod-001/thumbnail1", webseeds)

    def test_torrent_file_url_saved_as_bundle_torrent(self):
        """torrent_file_url is set to cdn_endpoint/{s3_path}/bundle.torrent."""
        from ..lib.torrent import generate_torrent

        s3_client = mock.MagicMock()
        product = mock.MagicMock()
        product.id = "prod-001"
        product.torrent_magnet_link = None
        product.torrent_file_url = None
        session_factory = self._session_factory(product)
        torf_mod, _ = self._make_torf_mock()

        with mock.patch.dict("sys.modules", {"torf": torf_mod}):
            generate_torrent(
                s3_client, "my-bucket",
                bundle_name="my-product",
                files=self._files(),
                description="",
                s3_path="shop1/prod-001",
                product_id="prod-001",
                session_factory=session_factory,
                cdn_endpoint="https://cdn.example.com",
            )

        self.assertEqual(
            product.torrent_file_url,
            "https://cdn.example.com/shop1/prod-001/bundle.torrent",
        )

    def test_torrent_file_url_none_without_cdn_endpoint(self):
        """Without cdn_endpoint, torrent_file_url is None."""
        from ..lib.torrent import generate_torrent

        s3_client = mock.MagicMock()
        product = mock.MagicMock()
        product.id = "prod-001"
        product.torrent_magnet_link = None
        product.torrent_file_url = None
        session_factory = self._session_factory(product)
        torf_mod, _ = self._make_torf_mock()

        with mock.patch.dict("sys.modules", {"torf": torf_mod}):
            generate_torrent(
                s3_client, "my-bucket",
                bundle_name="my-product",
                files=self._files(cdn=None),
                description="",
                s3_path="shop1/prod-001",
                product_id="prod-001",
                session_factory=session_factory,
                cdn_endpoint=None,
            )

        self.assertIsNone(product.torrent_file_url)

    def test_empty_files_list_skips_generation(self):
        """generate_torrent with no files does nothing (no S3 downloads, no DB write)."""
        from ..lib.torrent import generate_torrent

        s3_client = mock.MagicMock()
        product = mock.MagicMock()
        product.torrent_file_url = None
        session_factory = mock.MagicMock()

        generate_torrent(
            s3_client, "my-bucket",
            bundle_name="empty",
            files=[],
            description="",
            s3_path="shop1/prod-001",
            product_id="prod-001",
            session_factory=session_factory,
            cdn_endpoint="https://cdn.example.com",
        )

        s3_client.download_file.assert_not_called()
        session_factory.assert_not_called()

    def test_build_bundle_files_paid_product_excludes_product_file(self):
        """build_bundle_files for a paid product must not include the product file."""
        from ..lib.torrent import build_bundle_files

        product = mock.MagicMock()
        product.is_sellable = True
        product.s3_path = "shop1/prod-001"
        product.originals = {"product": "song.mp3", "preview": "preview.mp3", "thumbnail1": "cover.jpg"}
        product.extensions = {"product": "mp3", "preview": "mp3", "thumbnail1": "jpg"}

        files = build_bundle_files(product, "https://cdn.example.com")
        s3_keys = [f["s3_key"] for f in files]

        self.assertIn("shop1/prod-001/preview", s3_keys)
        self.assertIn("shop1/prod-001/thumbnail1", s3_keys)
        self.assertNotIn("shop1/prod-001/product", s3_keys)

    def test_build_bundle_files_free_content_includes_content_file(self):
        """build_bundle_files for free content includes the product/content file."""
        from ..lib.torrent import build_bundle_files

        product = mock.MagicMock()
        product.is_sellable = False
        product.s3_path = "shop1/cont-001"
        product.originals = {"product": "video.mp4", "thumbnail1": "thumb.jpg"}
        product.extensions = {"product": "mp4", "thumbnail1": "jpg"}

        files = build_bundle_files(product, "https://cdn.example.com")
        s3_keys = [f["s3_key"] for f in files]

        self.assertIn("shop1/cont-001/product", s3_keys)
        self.assertIn("shop1/cont-001/thumbnail1", s3_keys)
        # filename for content file should be content.mp4, not product.mp4
        content_file = next(f for f in files if f["s3_key"].endswith("/product"))
        self.assertEqual(content_file["filename"], "content.mp4")


class TestMpsApiKey(unittest.TestCase):
    """Unit tests for MpsApiKey model."""

    def _make_key(self, label=None):
        from ..models.api_key import MpsApiKey
        shop = mock.MagicMock()
        shop.id = "shop-test-id"
        key, secret = MpsApiKey.generate(shop, label=label)
        return key, secret

    def test_generate_returns_key_and_secret(self):
        key, secret = self._make_key()
        self.assertIsNotNone(key)
        self.assertIsNotNone(secret)

    def test_public_key_prefix(self):
        key, _ = self._make_key()
        self.assertTrue(key.public_key.startswith("mps_pub_"))

    def test_secret_key_prefix(self):
        key, secret = self._make_key()
        self.assertTrue(secret.startswith("mps_sec_"))
        self.assertEqual(key.secret_key, secret)

    def test_label_stored(self):
        key, _ = self._make_key(label="permacomputer CI")
        self.assertEqual(key.label, "permacomputer CI")

    def test_is_active_default(self):
        key, _ = self._make_key()
        self.assertTrue(key.is_active)

    def test_masked_secret_format(self):
        key, secret = self._make_key()
        masked = key.masked_secret
        self.assertTrue(masked.startswith("mps_sec_..."))
        self.assertEqual(masked[-4:], secret[-4:])

    def test_masked_secret_does_not_reveal_full_secret(self):
        key, secret = self._make_key()
        self.assertNotIn(secret, key.masked_secret)

    def test_verify_signature_valid(self):
        import hashlib
        import hmac
        import time

        key, secret = self._make_key()
        method = "POST"
        path = "/api/v1/products"
        timestamp = str(int(time.time()))
        body = b'{"title":"test"}'
        body_hash = hashlib.sha256(body).hexdigest()
        string_to_sign = f"{method}\n{path}\n{timestamp}\n{body_hash}"
        sig = "sha256=" + hmac.new(
            secret.encode(), string_to_sign.encode(), hashlib.sha256
        ).hexdigest()

        self.assertTrue(key.verify_signature(method, path, timestamp, body, sig))

    def test_verify_signature_wrong_secret(self):
        import hashlib, hmac, time
        key, secret = self._make_key()
        method, path = "POST", "/api/v1/products"
        timestamp = str(int(time.time()))
        body = b'{"title":"test"}'
        body_hash = hashlib.sha256(body).hexdigest()
        string_to_sign = f"{method}\n{path}\n{timestamp}\n{body_hash}"
        sig = "sha256=" + hmac.new(
            b"wrong_secret", string_to_sign.encode(), hashlib.sha256
        ).hexdigest()
        self.assertFalse(key.verify_signature(method, path, timestamp, body, sig))

    def test_verify_signature_replayed(self):
        import hashlib, hmac, time
        key, secret = self._make_key()
        method, path = "POST", "/api/v1/products"
        # timestamp 10 minutes in the past
        timestamp = str(int(time.time()) - 601)
        body = b'{"title":"test"}'
        body_hash = hashlib.sha256(body).hexdigest()
        string_to_sign = f"{method}\n{path}\n{timestamp}\n{body_hash}"
        sig = "sha256=" + hmac.new(
            secret.encode(), string_to_sign.encode(), hashlib.sha256
        ).hexdigest()
        self.assertFalse(key.verify_signature(method, path, timestamp, body, sig))

    def test_verify_signature_tampered_body(self):
        import hashlib, hmac, time
        key, secret = self._make_key()
        method, path = "POST", "/api/v1/products"
        timestamp = str(int(time.time()))
        original_body = b'{"title":"test"}'
        body_hash = hashlib.sha256(original_body).hexdigest()
        string_to_sign = f"{method}\n{path}\n{timestamp}\n{body_hash}"
        sig = "sha256=" + hmac.new(
            secret.encode(), string_to_sign.encode(), hashlib.sha256
        ).hexdigest()
        # tamper the body before verification
        tampered_body = b'{"title":"evil"}'
        self.assertFalse(key.verify_signature(method, path, timestamp, tampered_body, sig))

    def test_unique_keys_per_call(self):
        key1, secret1 = self._make_key()
        key2, secret2 = self._make_key()
        self.assertNotEqual(key1.public_key, key2.public_key)
        self.assertNotEqual(secret1, secret2)


class TestFeatureKillSwitches(unittest.TestCase):
    """MPS-22: karaoke + torrent global feature flags.

    Both default off when the ini key is missing or falsy. We test the
    same resolution logic the request properties use without spinning
    up Pyramid — the only state that matters is the app settings dict.
    """

    @staticmethod
    def _karaoke_resolve(app_dict):
        val = app_dict.get("features.karaoke.enabled")
        if isinstance(val, str):
            return val.strip().lower() in ("1", "true", "yes", "on")
        elif isinstance(val, bool):
            return val
        return False

    @staticmethod
    def _torrent_resolve(app_dict):
        val = app_dict.get("features.torrent.enabled")
        if isinstance(val, str):
            return val.strip().lower() in ("1", "true", "yes", "on")
        elif isinstance(val, bool):
            return val
        return False

    def test_karaoke_default_off_when_key_missing(self):
        self.assertFalse(self._karaoke_resolve({}))

    def test_torrent_default_off_when_key_missing(self):
        self.assertFalse(self._torrent_resolve({}))

    def test_karaoke_truthy_strings(self):
        for v in ("True", "true", "1", "yes", "on", "TRUE", " on "):
            self.assertTrue(
                self._karaoke_resolve({"features.karaoke.enabled": v}),
                f"expected truthy for {v!r}",
            )

    def test_karaoke_falsy_strings(self):
        for v in ("False", "false", "0", "no", "off", "", "  "):
            self.assertFalse(
                self._karaoke_resolve({"features.karaoke.enabled": v}),
                f"expected falsy for {v!r}",
            )

    def test_torrent_truthy_strings(self):
        for v in ("True", "true", "1", "yes", "on"):
            self.assertTrue(
                self._torrent_resolve({"features.torrent.enabled": v}),
                f"expected truthy for {v!r}",
            )

    def test_torrent_falsy_strings(self):
        for v in ("False", "false", "0", "no", "off"):
            self.assertFalse(
                self._torrent_resolve({"features.torrent.enabled": v}),
                f"expected falsy for {v!r}",
            )

    def test_karaoke_bool_passthrough(self):
        self.assertTrue(self._karaoke_resolve({"features.karaoke.enabled": True}))
        self.assertFalse(self._karaoke_resolve({"features.karaoke.enabled": False}))

    def test_torrent_bool_passthrough(self):
        self.assertTrue(self._torrent_resolve({"features.torrent.enabled": True}))
        self.assertFalse(self._torrent_resolve({"features.torrent.enabled": False}))


class TestProductPricingMode(unittest.TestCase):
    """MPS-20 + MPS-21: pricing_mode helper properties on Product."""

    def _make_product(self, pricing_mode=0, allow_offers=None,
                      shop_offer_enabled=False):
        """Stub Product/Shop with only the attributes pricing_mode helpers need.
        Avoids spinning up SQLAlchemy mappers (Mock(spec=Shop) would import them)."""
        from types import SimpleNamespace
        from ..models.product import Product

        product = SimpleNamespace(
            pricing_mode=pricing_mode,
            allow_offers=allow_offers,
            shop=SimpleNamespace(offer_enabled=shop_offer_enabled),
        )
        # Bind real properties for direct testing.
        product.is_fixed_price = Product.is_fixed_price.fget(product)
        product.is_auction = Product.is_auction.fget(product)
        product.is_buy_now_allowed = Product.is_buy_now_allowed.fget(product)
        product.is_offer_mode = Product.is_offer_mode.fget(product)
        product.offers_allowed = Product.offers_allowed.fget(product)
        return product

    def test_fixed_mode_zero(self):
        p = self._make_product(pricing_mode=0)
        self.assertTrue(p.is_fixed_price)
        self.assertFalse(p.is_auction)
        self.assertTrue(p.is_buy_now_allowed)
        self.assertFalse(p.is_offer_mode)

    def test_auction_only_mode_one(self):
        p = self._make_product(pricing_mode=1)
        self.assertFalse(p.is_fixed_price)
        self.assertTrue(p.is_auction)
        self.assertFalse(p.is_buy_now_allowed)
        self.assertFalse(p.is_offer_mode)

    def test_auction_with_buy_now_mode_two(self):
        p = self._make_product(pricing_mode=2)
        self.assertTrue(p.is_auction)
        self.assertTrue(p.is_buy_now_allowed)
        self.assertFalse(p.is_offer_mode)

    def test_offer_only_mode_three(self):
        p = self._make_product(pricing_mode=3)
        self.assertFalse(p.is_auction)
        self.assertFalse(p.is_buy_now_allowed)
        self.assertTrue(p.is_offer_mode)

    def test_offer_with_buy_now_mode_four(self):
        p = self._make_product(pricing_mode=4)
        self.assertTrue(p.is_offer_mode)
        self.assertTrue(p.is_buy_now_allowed)

    def test_offers_allowed_inherits_shop_when_override_null(self):
        p = self._make_product(
            pricing_mode=3, allow_offers=None, shop_offer_enabled=True,
        )
        self.assertTrue(p.offers_allowed)

        p2 = self._make_product(
            pricing_mode=3, allow_offers=None, shop_offer_enabled=False,
        )
        self.assertFalse(p2.offers_allowed)

    def test_offers_allowed_per_product_override_wins(self):
        # Override True even when shop is False.
        p = self._make_product(
            pricing_mode=3, allow_offers=True, shop_offer_enabled=False,
        )
        self.assertTrue(p.offers_allowed)

        # Override False even when shop is True.
        p2 = self._make_product(
            pricing_mode=3, allow_offers=False, shop_offer_enabled=True,
        )
        self.assertFalse(p2.offers_allowed)

    def test_offers_allowed_false_outside_offer_mode(self):
        # Even with override True, offers are blocked when pricing_mode != 3 or 4.
        p = self._make_product(
            pricing_mode=0, allow_offers=True, shop_offer_enabled=True,
        )
        self.assertFalse(p.offers_allowed)


class TestMpsAuctionStateHelpers(unittest.TestCase):
    """MPS-20: state helpers on MpsAuction (no DB; we exercise pure properties)."""

    def _make_auction(self, state=0, end_timestamp=None,
                      reserve_price_in_cents=None,
                      buy_now_price_in_cents=None,
                      start_price_in_cents=0):
        from types import SimpleNamespace
        from ..models.auction import MpsAuction

        a = SimpleNamespace(
            state=state,
            end_timestamp=end_timestamp,
            reserve_price_in_cents=reserve_price_in_cents,
            buy_now_price_in_cents=buy_now_price_in_cents,
            start_price_in_cents=start_price_in_cents,
        )
        for name in [
            "is_draft", "is_scheduled", "is_active", "is_ended",
            "is_settled", "is_cancelled", "is_terminal",
            "state_human", "time_remaining_ms",
            "has_buy_now", "has_reserve",
        ]:
            setattr(a, name, getattr(MpsAuction, name).fget(a))
        return a

    def test_state_classifiers(self):
        from ..models.auction import (
            AUCTION_STATE_DRAFT, AUCTION_STATE_SCHEDULED, AUCTION_STATE_ACTIVE,
            AUCTION_STATE_ENDED, AUCTION_STATE_SETTLED, AUCTION_STATE_CANCELLED,
        )
        cases = [
            (AUCTION_STATE_DRAFT,     "is_draft",     "Draft"),
            (AUCTION_STATE_SCHEDULED, "is_scheduled", "Scheduled"),
            (AUCTION_STATE_ACTIVE,    "is_active",    "Active"),
            (AUCTION_STATE_ENDED,     "is_ended",     "Ended"),
            (AUCTION_STATE_SETTLED,   "is_settled",   "Settled"),
            (AUCTION_STATE_CANCELLED, "is_cancelled", "Cancelled"),
        ]
        for state, prop, human in cases:
            a = self._make_auction(state=state)
            self.assertTrue(getattr(a, prop), f"{prop} should be True for state {state}")
            self.assertEqual(a.state_human, human)

    def test_terminal_states(self):
        from ..models.auction import AUCTION_STATE_SETTLED, AUCTION_STATE_CANCELLED
        for terminal in (AUCTION_STATE_SETTLED, AUCTION_STATE_CANCELLED):
            self.assertTrue(self._make_auction(state=terminal).is_terminal)
        for non_terminal in (0, 1, 2, 3):
            self.assertFalse(self._make_auction(state=non_terminal).is_terminal)

    def test_time_remaining_ms_no_end(self):
        self.assertEqual(self._make_auction(end_timestamp=None).time_remaining_ms, 0)

    def test_time_remaining_ms_past(self):
        from ..models.auction import now_timestamp
        past = now_timestamp() - 60_000
        self.assertEqual(self._make_auction(end_timestamp=past).time_remaining_ms, 0)

    def test_time_remaining_ms_future(self):
        from ..models.auction import now_timestamp
        future = now_timestamp() + 60_000
        remaining = self._make_auction(end_timestamp=future).time_remaining_ms
        self.assertGreater(remaining, 50_000)
        self.assertLessEqual(remaining, 60_000)

    def test_has_buy_now_and_reserve(self):
        self.assertFalse(self._make_auction().has_buy_now)
        self.assertFalse(self._make_auction().has_reserve)
        self.assertTrue(self._make_auction(buy_now_price_in_cents=10000).has_buy_now)
        self.assertTrue(self._make_auction(reserve_price_in_cents=5000).has_reserve)


class TestMpsOfferStateHelpers(unittest.TestCase):
    """MPS-21: state helpers on MpsOffer."""

    def _make_offer(self, state=0, current_party=1,
                    expires_timestamp=None,
                    current_amount_in_cents=1000):
        from types import SimpleNamespace
        from ..models.offer import MpsOffer, now_timestamp

        o = SimpleNamespace(
            state=state,
            current_party=current_party,
            expires_timestamp=expires_timestamp or (now_timestamp() + 60_000),
            current_amount_in_cents=current_amount_in_cents,
        )
        for name in [
            "is_pending", "is_countered", "is_open", "is_accepted", "is_paid",
            "is_terminal", "state_human",
            "time_remaining_ms", "is_expired",
            "waiting_on_buyer", "waiting_on_seller",
        ]:
            setattr(o, name, getattr(MpsOffer, name).fget(o))
        return o

    def test_state_classifiers(self):
        from ..models.offer import (
            OFFER_STATE_PENDING, OFFER_STATE_COUNTERED, OFFER_STATE_ACCEPTED,
            OFFER_STATE_DECLINED, OFFER_STATE_EXPIRED, OFFER_STATE_WITHDRAWN,
            OFFER_STATE_PAID,
        )
        self.assertTrue(self._make_offer(state=OFFER_STATE_PENDING).is_pending)
        self.assertTrue(self._make_offer(state=OFFER_STATE_COUNTERED).is_countered)
        self.assertTrue(self._make_offer(state=OFFER_STATE_ACCEPTED).is_accepted)
        self.assertTrue(self._make_offer(state=OFFER_STATE_PAID).is_paid)
        # is_open: pending or countered only
        self.assertTrue(self._make_offer(state=OFFER_STATE_PENDING).is_open)
        self.assertTrue(self._make_offer(state=OFFER_STATE_COUNTERED).is_open)
        self.assertFalse(self._make_offer(state=OFFER_STATE_ACCEPTED).is_open)
        self.assertFalse(self._make_offer(state=OFFER_STATE_DECLINED).is_open)
        # terminal: accepted/declined/expired/withdrawn/paid
        for terminal in (
            OFFER_STATE_ACCEPTED, OFFER_STATE_DECLINED, OFFER_STATE_EXPIRED,
            OFFER_STATE_WITHDRAWN, OFFER_STATE_PAID,
        ):
            self.assertTrue(self._make_offer(state=terminal).is_terminal)
        for non_terminal in (OFFER_STATE_PENDING, OFFER_STATE_COUNTERED):
            self.assertFalse(self._make_offer(state=non_terminal).is_terminal)

    def test_waiting_on_party(self):
        from ..models.offer import (
            OFFER_PARTY_BUYER, OFFER_PARTY_SELLER,
            OFFER_STATE_PENDING, OFFER_STATE_ACCEPTED,
        )
        # Open offer → exactly one party is waiting
        buyer_turn = self._make_offer(
            state=OFFER_STATE_PENDING, current_party=OFFER_PARTY_BUYER,
        )
        self.assertTrue(buyer_turn.waiting_on_buyer)
        self.assertFalse(buyer_turn.waiting_on_seller)

        seller_turn = self._make_offer(
            state=OFFER_STATE_PENDING, current_party=OFFER_PARTY_SELLER,
        )
        self.assertFalse(seller_turn.waiting_on_buyer)
        self.assertTrue(seller_turn.waiting_on_seller)

        # Terminal offer → nobody waiting
        terminal = self._make_offer(
            state=OFFER_STATE_ACCEPTED, current_party=OFFER_PARTY_BUYER,
        )
        self.assertFalse(terminal.waiting_on_buyer)
        self.assertFalse(terminal.waiting_on_seller)

    def test_time_remaining_and_expired(self):
        from ..models.offer import (
            now_timestamp, OFFER_STATE_PENDING, OFFER_STATE_PAID,
        )
        future = now_timestamp() + 60_000
        past = now_timestamp() - 60_000

        live = self._make_offer(state=OFFER_STATE_PENDING, expires_timestamp=future)
        self.assertGreater(live.time_remaining_ms, 50_000)
        self.assertFalse(live.is_expired)

        dead = self._make_offer(state=OFFER_STATE_PENDING, expires_timestamp=past)
        self.assertEqual(dead.time_remaining_ms, 0)
        self.assertTrue(dead.is_expired)

        # is_expired only fires for non-terminal offers
        terminal_past = self._make_offer(
            state=OFFER_STATE_PAID, expires_timestamp=past,
        )
        self.assertFalse(terminal_past.is_expired)


class TestAuctionLibPureFunctions(unittest.TestCase):
    """MPS-20: lib/auction.py pure functions — no DB."""

    def test_validate_bid_rejects_inactive(self):
        from ..lib.auction import validate_bid, BidRejected
        from ..models.auction import (
            AUCTION_STATE_DRAFT, AUCTION_STATE_SCHEDULED,
            AUCTION_STATE_ENDED, AUCTION_STATE_CANCELLED,
        )
        for state in (AUCTION_STATE_DRAFT, AUCTION_STATE_SCHEDULED,
                      AUCTION_STATE_ENDED, AUCTION_STATE_CANCELLED):
            with self.assertRaises(BidRejected):
                validate_bid(
                    auction_state=state,
                    current_high_in_cents=0,
                    bid_increment_in_cents=100,
                    start_price_in_cents=1000,
                    has_bids=False,
                    amount_in_cents=1000,
                )

    def test_validate_bid_rejects_below_start_price_first_bid(self):
        from ..lib.auction import validate_bid, BidRejected
        from ..models.auction import AUCTION_STATE_ACTIVE
        with self.assertRaises(BidRejected):
            validate_bid(
                auction_state=AUCTION_STATE_ACTIVE,
                current_high_in_cents=1000,
                bid_increment_in_cents=100,
                start_price_in_cents=1000,
                has_bids=False,
                amount_in_cents=999,
            )

    def test_validate_bid_accepts_first_bid_at_start_price(self):
        from ..lib.auction import validate_bid
        from ..models.auction import AUCTION_STATE_ACTIVE
        validate_bid(
            auction_state=AUCTION_STATE_ACTIVE,
            current_high_in_cents=1000,
            bid_increment_in_cents=100,
            start_price_in_cents=1000,
            has_bids=False,
            amount_in_cents=1000,
        )  # no raise

    def test_validate_bid_rejects_below_increment_floor(self):
        from ..lib.auction import validate_bid, BidRejected
        from ..models.auction import AUCTION_STATE_ACTIVE
        # current high = 1000, increment = 100 → floor = 1100
        with self.assertRaises(BidRejected):
            validate_bid(
                auction_state=AUCTION_STATE_ACTIVE,
                current_high_in_cents=1000,
                bid_increment_in_cents=100,
                start_price_in_cents=500,
                has_bids=True,
                amount_in_cents=1099,
            )

    def test_validate_bid_accepts_at_increment_floor(self):
        from ..lib.auction import validate_bid
        from ..models.auction import AUCTION_STATE_ACTIVE
        validate_bid(
            auction_state=AUCTION_STATE_ACTIVE,
            current_high_in_cents=1000,
            bid_increment_in_cents=100,
            start_price_in_cents=500,
            has_bids=True,
            amount_in_cents=1100,
        )

    def test_validate_bid_rejects_negative_or_zero_amount(self):
        from ..lib.auction import validate_bid, BidRejected
        from ..models.auction import AUCTION_STATE_ACTIVE
        for bad in (0, -1, -1000):
            with self.assertRaises(BidRejected):
                validate_bid(
                    auction_state=AUCTION_STATE_ACTIVE,
                    current_high_in_cents=0,
                    bid_increment_in_cents=100,
                    start_price_in_cents=0,
                    has_bids=False,
                    amount_in_cents=bad,
                )

    def test_validate_bid_rejects_proxy_below_amount(self):
        from ..lib.auction import validate_bid, BidRejected
        from ..models.auction import AUCTION_STATE_ACTIVE
        with self.assertRaises(BidRejected):
            validate_bid(
                auction_state=AUCTION_STATE_ACTIVE,
                current_high_in_cents=0,
                bid_increment_in_cents=100,
                start_price_in_cents=0,
                has_bids=False,
                amount_in_cents=1000,
                max_proxy_in_cents=999,
            )

    def test_validate_bid_accepts_proxy_equal_to_amount(self):
        from ..lib.auction import validate_bid
        from ..models.auction import AUCTION_STATE_ACTIVE
        validate_bid(
            auction_state=AUCTION_STATE_ACTIVE,
            current_high_in_cents=0,
            bid_increment_in_cents=100,
            start_price_in_cents=0,
            has_bids=False,
            amount_in_cents=1000,
            max_proxy_in_cents=1000,
        )

    def test_is_within_soft_close(self):
        from ..lib.auction import is_within_soft_close
        # 60-second window, 30 seconds remaining → inside.
        end = 1_000_000
        now_inside = end - 30_000
        self.assertTrue(is_within_soft_close(end, now_inside, 60))
        # 60-second window, 90 seconds remaining → outside.
        now_outside = end - 90_000
        self.assertFalse(is_within_soft_close(end, now_outside, 60))
        # No end timestamp → never inside.
        self.assertFalse(is_within_soft_close(None, now_inside, 60))
        # No window → never inside.
        self.assertFalse(is_within_soft_close(end, now_inside, 0))
        # Past end → not inside (already over).
        self.assertFalse(is_within_soft_close(end, end + 1_000, 60))

    def test_extended_end_timestamp(self):
        from ..lib.auction import extended_end_timestamp
        self.assertEqual(extended_end_timestamp(1_000_000, 60), 1_060_000)
        self.assertEqual(extended_end_timestamp(2_000, 0), 2_000)


class TestAuctionLibProxyResolution(unittest.TestCase):
    """MPS-20: resolve_proxy edge cases — pure math, no DB."""

    def test_no_existing_proxy_new_bid_wins_at_face_value(self):
        # Top: 1000 (no proxy). New: 1100 (no proxy).
        # New > top, but no proxy auto-bid is needed since neither has a proxy.
        # Floor: max(top + increment, new_amount) = max(1100, 1100) = 1100.
        from ..lib.auction import resolve_proxy
        winning, new_wins = resolve_proxy(
            top_amount_in_cents=1000,
            top_max_proxy_in_cents=None,
            new_amount_in_cents=1100,
            new_max_proxy_in_cents=None,
            bid_increment_in_cents=100,
        )
        self.assertTrue(new_wins)
        self.assertEqual(winning, 1100)

    def test_top_proxy_defends_against_lower_proxy(self):
        # Top: visible 1000, proxy 2000. New: visible 1100, proxy 1500.
        # Top stays. Their visible amount = min(1500 + 100, 2000) = 1600.
        from ..lib.auction import resolve_proxy
        winning, new_wins = resolve_proxy(
            top_amount_in_cents=1000,
            top_max_proxy_in_cents=2000,
            new_amount_in_cents=1100,
            new_max_proxy_in_cents=1500,
            bid_increment_in_cents=100,
        )
        self.assertFalse(new_wins)
        self.assertEqual(winning, 1600)

    def test_new_proxy_breaks_through_top_proxy(self):
        # Top: visible 1000, proxy 1500. New: visible 1100, proxy 2500.
        # New wins. Their visible amount = min(1500 + 100, 2500) = 1600.
        from ..lib.auction import resolve_proxy
        winning, new_wins = resolve_proxy(
            top_amount_in_cents=1000,
            top_max_proxy_in_cents=1500,
            new_amount_in_cents=1100,
            new_max_proxy_in_cents=2500,
            bid_increment_in_cents=100,
        )
        self.assertTrue(new_wins)
        self.assertEqual(winning, 1600)

    def test_tie_on_proxy_existing_top_stays(self):
        # Top: visible 1000, proxy 2000. New: visible 1500, proxy 2000.
        # Tie → top stays. Top auto-bids to min(2000 + 100, 2000) = 2000
        # (capped at own ceiling).
        from ..lib.auction import resolve_proxy
        winning, new_wins = resolve_proxy(
            top_amount_in_cents=1000,
            top_max_proxy_in_cents=2000,
            new_amount_in_cents=1500,
            new_max_proxy_in_cents=2000,
            bid_increment_in_cents=100,
        )
        self.assertFalse(new_wins)
        self.assertEqual(winning, 2000)

    def test_new_amount_above_top_proxy_new_wins(self):
        # Top: visible 1000, proxy 1500. New: visible 2000, no proxy.
        # New visible (2000) > top proxy (1500) → new wins.
        # Winning = min(1500 + 100, 2000) = 1600 (capped at new's effective proxy=2000)
        # Floor: max(1600, 2000) = 2000.
        from ..lib.auction import resolve_proxy
        winning, new_wins = resolve_proxy(
            top_amount_in_cents=1000,
            top_max_proxy_in_cents=1500,
            new_amount_in_cents=2000,
            new_max_proxy_in_cents=None,
            bid_increment_in_cents=100,
        )
        self.assertTrue(new_wins)
        self.assertEqual(winning, 2000)

    def test_proxy_capped_at_ceiling(self):
        # Top: visible 1000, proxy 5000. New: visible 1100, proxy 4000.
        # Top stays. Their visible amount = min(4000 + 100, 5000) = 4100.
        # Top doesn't max out at 5000 because 4100 is enough to outbid new.
        from ..lib.auction import resolve_proxy
        winning, new_wins = resolve_proxy(
            top_amount_in_cents=1000,
            top_max_proxy_in_cents=5000,
            new_amount_in_cents=1100,
            new_max_proxy_in_cents=4000,
            bid_increment_in_cents=100,
        )
        self.assertFalse(new_wins)
        self.assertEqual(winning, 4100)


class TestOfferLibPureFunctions(unittest.TestCase):
    """MPS-21: lib/offer.py pure validators — no DB."""

    def test_validate_actor_turn_rejects_terminal(self):
        from ..lib.offer import validate_actor_turn, OfferRejected
        from ..models.offer import (
            OFFER_PARTY_BUYER, OFFER_PARTY_SELLER,
            OFFER_TERMINAL_STATES,
        )
        for terminal in OFFER_TERMINAL_STATES:
            with self.assertRaises(OfferRejected):
                validate_actor_turn(
                    actor_party=OFFER_PARTY_BUYER,
                    current_party=OFFER_PARTY_BUYER,
                    offer_state=terminal,
                )

    def test_validate_actor_turn_rejects_wrong_party(self):
        from ..lib.offer import validate_actor_turn, OfferRejected
        from ..models.offer import (
            OFFER_PARTY_BUYER, OFFER_PARTY_SELLER, OFFER_STATE_PENDING,
        )
        with self.assertRaises(OfferRejected):
            validate_actor_turn(
                actor_party=OFFER_PARTY_BUYER,
                current_party=OFFER_PARTY_SELLER,
                offer_state=OFFER_STATE_PENDING,
            )

    def test_validate_actor_turn_accepts_correct_party(self):
        from ..lib.offer import validate_actor_turn
        from ..models.offer import (
            OFFER_PARTY_SELLER, OFFER_STATE_PENDING, OFFER_STATE_COUNTERED,
        )
        for state in (OFFER_STATE_PENDING, OFFER_STATE_COUNTERED):
            validate_actor_turn(
                actor_party=OFFER_PARTY_SELLER,
                current_party=OFFER_PARTY_SELLER,
                offer_state=state,
            )  # no raise

    def test_validate_round_cap_rejects_at_or_above(self):
        from ..lib.offer import validate_round_cap, OfferRejected
        with self.assertRaises(OfferRejected):
            validate_round_cap(round_count=3, max_rounds=3)
        with self.assertRaises(OfferRejected):
            validate_round_cap(round_count=4, max_rounds=3)

    def test_validate_round_cap_accepts_below(self):
        from ..lib.offer import validate_round_cap
        validate_round_cap(round_count=0, max_rounds=3)
        validate_round_cap(round_count=2, max_rounds=3)

    def test_validate_floor_passes_when_no_floor(self):
        from ..lib.offer import validate_floor
        validate_floor(amount_in_cents=100, floor_in_cents=None)

    def test_validate_floor_rejects_below(self):
        from ..lib.offer import validate_floor, OfferRejected
        with self.assertRaises(OfferRejected):
            validate_floor(amount_in_cents=99, floor_in_cents=100)

    def test_validate_floor_accepts_at(self):
        from ..lib.offer import validate_floor
        validate_floor(amount_in_cents=100, floor_in_cents=100)

    def test_auto_resolve_accept(self):
        from ..lib.offer import auto_resolve_open
        # 95% of 1000 = 950. Offer at 950+ → accept.
        self.assertEqual(
            auto_resolve_open(950, 1000, 95, 50), "accept",
        )
        self.assertEqual(
            auto_resolve_open(1000, 1000, 95, 50), "accept",
        )

    def test_auto_resolve_decline(self):
        from ..lib.offer import auto_resolve_open
        # 50% of 1000 = 500. Offer below 500 → decline.
        self.assertEqual(
            auto_resolve_open(499, 1000, 95, 50), "decline",
        )
        self.assertEqual(
            auto_resolve_open(100, 1000, 95, 50), "decline",
        )

    def test_auto_resolve_queue(self):
        from ..lib.offer import auto_resolve_open
        # Between 500 (50%) and 950 (95%) → queue.
        self.assertEqual(
            auto_resolve_open(700, 1000, 95, 50), "queue",
        )
        self.assertEqual(
            auto_resolve_open(500, 1000, 95, 50), "queue",
        )
        self.assertEqual(
            auto_resolve_open(949, 1000, 95, 50), "queue",
        )

    def test_auto_resolve_free_product_queues(self):
        # list_price=0 → never auto-resolve (defensive).
        from ..lib.offer import auto_resolve_open
        self.assertEqual(
            auto_resolve_open(100, 0, 95, 50), "queue",
        )


class TestAuctionTickPureFunctions(unittest.TestCase):
    """MPS-20: lib/auction_tick.py pure transition checks (no DB)."""

    def _stub_auction(self, state, start_timestamp=None, end_timestamp=None):
        from types import SimpleNamespace
        return SimpleNamespace(
            state=state,
            start_timestamp=start_timestamp,
            end_timestamp=end_timestamp,
        )

    def test_scheduled_to_active_when_start_passed(self):
        from ..lib.auction_tick import transition_scheduled_to_active
        from ..models.auction import (
            AUCTION_STATE_SCHEDULED, AUCTION_STATE_ACTIVE,
        )
        # Start in past → True.
        a = self._stub_auction(AUCTION_STATE_SCHEDULED, start_timestamp=1000)
        self.assertTrue(transition_scheduled_to_active(a, now_ms=2000))
        # Start in future → False.
        a2 = self._stub_auction(AUCTION_STATE_SCHEDULED, start_timestamp=2000)
        self.assertFalse(transition_scheduled_to_active(a2, now_ms=1000))
        # Wrong state → False.
        a3 = self._stub_auction(AUCTION_STATE_ACTIVE, start_timestamp=1000)
        self.assertFalse(transition_scheduled_to_active(a3, now_ms=2000))
        # Missing start_timestamp → False.
        a4 = self._stub_auction(AUCTION_STATE_SCHEDULED, start_timestamp=None)
        self.assertFalse(transition_scheduled_to_active(a4, now_ms=2000))

    def test_active_to_ended_when_end_passed(self):
        from ..lib.auction_tick import transition_active_to_ended
        from ..models.auction import (
            AUCTION_STATE_ACTIVE, AUCTION_STATE_ENDED,
        )
        a = self._stub_auction(AUCTION_STATE_ACTIVE, end_timestamp=1000)
        self.assertTrue(transition_active_to_ended(a, now_ms=2000))
        a2 = self._stub_auction(AUCTION_STATE_ACTIVE, end_timestamp=2000)
        self.assertFalse(transition_active_to_ended(a2, now_ms=1000))
        a3 = self._stub_auction(AUCTION_STATE_ENDED, end_timestamp=1000)
        self.assertFalse(transition_active_to_ended(a3, now_ms=2000))
        a4 = self._stub_auction(AUCTION_STATE_ACTIVE, end_timestamp=None)
        self.assertFalse(transition_active_to_ended(a4, now_ms=2000))


class TestAuctionQuantity(unittest.TestCase):
    """MPS-20: lot-size auctions (multi-unit)."""

    def test_is_lot_auction_default_false(self):
        from types import SimpleNamespace
        from ..models.auction import MpsAuction
        a = SimpleNamespace(quantity=1)
        self.assertFalse(MpsAuction.is_lot_auction.fget(a))

    def test_is_lot_auction_true_when_quantity_above_one(self):
        from types import SimpleNamespace
        from ..models.auction import MpsAuction
        for n in (2, 5, 100):
            a = SimpleNamespace(quantity=n)
            self.assertTrue(MpsAuction.is_lot_auction.fget(a))


class TestMailFromHeader(unittest.TestCase):
    """MPS-23: From header carries a display name (the shop, or a configured
    platform fallback) in front of the single warm sending address."""

    def test_format_from_header_bare(self):
        from ..lib.mail import format_from_header

        self.assertEqual(
            format_from_header("", "no-reply@origin.makepostsell.com"),
            "no-reply@origin.makepostsell.com",
        )

    def test_format_from_header_with_name(self):
        from ..lib.mail import format_from_header

        self.assertEqual(
            format_from_header("Acme Shop", "no-reply@origin.makepostsell.com"),
            "Acme Shop <no-reply@origin.makepostsell.com>",
        )

    def test_format_from_header_quotes_special(self):
        from ..lib.mail import format_from_header

        # formataddr quotes a display name containing a comma.
        self.assertEqual(
            format_from_header("Acme, Inc.", "no-reply@origin.makepostsell.com"),
            '"Acme, Inc." <no-reply@origin.makepostsell.com>',
        )

    @mock.patch("make_post_sell.lib.mail.send_email")
    def test_send_pyramid_email_uses_shop_name(self, mock_send_email):
        from ..lib.mail import send_pyramid_email
        from types import SimpleNamespace

        request = mock.Mock()
        request.domain = "shop.example"
        request.debug_mode = True
        request.shop = SimpleNamespace(name="Acme Shop")
        request.app = {"email.sender": "no-reply@origin.makepostsell.com"}

        send_pyramid_email(request, "buyer@example.com", "Hi", "text", "<p>html</p>")
        # from_name is the final positional arg of send_email().
        _, kwargs = mock_send_email.call_args
        args = mock_send_email.call_args[0]
        self.assertEqual(args[-1], "Acme Shop")
        self.assertEqual(args[1], "no-reply@origin.makepostsell.com")

    @mock.patch("make_post_sell.lib.mail.send_email")
    def test_send_pyramid_email_falls_back_to_configured_name(self, mock_send_email):
        from ..lib.mail import send_pyramid_email

        request = mock.Mock()
        request.domain = "my.makepostsell.com"
        request.debug_mode = True
        request.shop = None
        request.app = {
            "email.sender": "no-reply@origin.makepostsell.com",
            "email.from_name": "Make Post Sell",
        }

        send_pyramid_email(request, "u@example.com", "Hi", "text", "<p>html</p>")
        args = mock_send_email.call_args[0]
        self.assertEqual(args[-1], "Make Post Sell")


class TestEmailNotificationContent(unittest.TestCase):
    """MPS-20 + MPS-21: email helpers format templates with the right
    fields. Mocks the underlying send_pyramid_email to verify the call."""

    @mock.patch("make_post_sell.lib.mail.send_pyramid_email")
    def test_send_auction_outbid_email(self, mock_send):
        from ..lib.mail import send_auction_outbid_email
        from types import SimpleNamespace

        product = SimpleNamespace(title="Treasure")
        auction = SimpleNamespace(
            uuid_str="abc-123",
            product=product,
            current_high=42.50,
        )
        request = mock.Mock()
        request.host_url = "https://shop.example"

        send_auction_outbid_email(request, "loser@example.com", auction)
        self.assertEqual(mock_send.call_count, 1)
        args, kwargs = mock_send.call_args
        # Args: request, to_email, subject, text, html.
        self.assertEqual(args[1], "loser@example.com")
        self.assertIn("outbid", args[2].lower())
        self.assertIn("Treasure", args[2])
        self.assertIn("42.50", args[3])
        self.assertIn("/a/abc-123", args[4])

    @mock.patch("make_post_sell.lib.mail.send_pyramid_email")
    def test_send_offer_received_email(self, mock_send):
        from ..lib.mail import send_offer_received_email
        from types import SimpleNamespace

        product = SimpleNamespace(title="Item X")
        offer = SimpleNamespace(
            uuid_str="off-456",
            product=product,
            current_amount=85.00,
        )
        request = mock.Mock()
        request.host_url = "https://shop.example"

        send_offer_received_email(request, "seller@example.com", offer)
        self.assertEqual(mock_send.call_count, 1)
        args, _ = mock_send.call_args
        self.assertEqual(args[1], "seller@example.com")
        self.assertIn("New offer", args[2])
        self.assertIn("Item X", args[2])
        self.assertIn("85.00", args[3])
        self.assertIn("/o/off-456", args[4])

    @mock.patch("make_post_sell.lib.mail.send_pyramid_email")
    def test_send_offer_accepted_email(self, mock_send):
        from ..lib.mail import send_offer_accepted_email
        from types import SimpleNamespace

        product = SimpleNamespace(title="Negotiated")
        offer = SimpleNamespace(
            uuid_str="off-789",
            product=product,
            current_amount=120.00,
        )
        request = mock.Mock()
        request.host_url = "https://shop.example"

        send_offer_accepted_email(request, "buyer@example.com", offer)
        self.assertEqual(mock_send.call_count, 1)
        args, _ = mock_send.call_args
        self.assertEqual(args[1], "buyer@example.com")
        self.assertIn("accepted", args[2].lower())
        self.assertIn("120.00", args[3])
        self.assertIn("/o/off-789", args[4])
