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)


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 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"

        # 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,
            ):
                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
        import time
        time.sleep(0.1)

        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"

        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
            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()
            time.sleep(1)
        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"

        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
            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()
            time.sleep(1)
        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"

        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:
            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()
            time.sleep(1)
        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

            reforge_discovery_ring_async("shop_crash", mock_factory)

        import time
        time.sleep(0.2)

        # 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

            reforge_discovery_ring_async("shop_gone", mock_factory)

        import time
        time.sleep(0.2)

        # 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 ---

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

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

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

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

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

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

    def test_referrer_internal(self):
        self.assertEqual(
            classify_referrer("https://example.com/some/page", "example.com"), 3
        )

    def test_referrer_unknown_external(self):
        self.assertEqual(
            classify_referrer("https://randomsite.org/page", "example.com"), 4
        )

    # --- 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)
