from django.test import TestCase, Client
from django.contrib.auth import get_user_model
from django.core.cache import cache
from django.urls import reverse
from unittest.mock import patch, MagicMock
from datetime import date, datetime, timedelta
import json

from bazi.models import Person
from iching.utils.person_good_days import evaluate_good_day, _get_cache_version, _get_monthly_cache_keys
from iching.utils import bz as bzu

User = get_user_model()


class BaziGoodDaysCachingTests(TestCase):
    """
    Test caching behavior for BaZi good days feature.
    Verifies cache hits for identical stem/branch across different users,
    and correct union of branch/stem cache results.
    """

    def setUp(self):
        # Create two different users
        self.user1 = User.objects.create_user(
            phone='1111111111', email='user1@example.com', password='pw123456'
        )
        self.user2 = User.objects.create_user(
            phone='2222222222', email='user2@example.com', password='pw123456'
        )
        
        # Create persons with identical day pillars for cache reuse testing
        # Both users have 甲子 (g=0, e=0) day pillar
        self.person1 = Person.objects.create(
            name='User1Owner',
            gender='N',
            birth_date=date(1990, 1, 1),
            owner=True,
            created_by=self.user1,
        )
        self.person1.bazi_result = {
            'year': {'god': 0, 'earth': 0},
            'month': {'god': 0, 'earth': 0},
            'day': {'god': 0, 'earth': 0},  # 甲子
        }
        self.person1.save()

        self.person2 = Person.objects.create(
            name='User2Owner',
            gender='N',
            birth_date=date(1992, 5, 15),
            owner=True,
            created_by=self.user2,
        )
        self.person2.bazi_result = {
            'year': {'god': 2, 'earth': 2},
            'month': {'god': 2, 'earth': 2},
            'day': {'god': 0, 'earth': 0},  # 甲子 (same as user1)
        }
        self.person2.save()

        # Create person with different day pillar for cache separation testing
        self.person3 = User.objects.create_user(
            phone='3333333333', email='user3@example.com', password='pw123456'
        )
        self.person3_bazi = Person.objects.create(
            name='User3Owner',
            gender='N',
            birth_date=date(1995, 3, 10),
            owner=True,
            created_by=self.person3,
        )
        self.person3_bazi.bazi_result = {
            'year': {'god': 1, 'earth': 1},
            'month': {'god': 1, 'earth': 1},
            'day': {'god': 1, 'earth': 1},  # 乙丑 (different from users 1&2)
        }
        self.person3_bazi.save()

        self.client = Client()
        self.url = '/api/bazi/good-days/'

        # Clear cache and reset version for clean test state
        cache.clear()
        cache.set('bz_good_days_version', 1, None)

    def tearDown(self):
        cache.clear()

    def test_cache_version_management(self):
        """Test cache version retrieval and management."""
        # Test default version
        version = _get_cache_version()
        self.assertEqual(version, 1)
        
        # Test custom version
        cache.set('bz_good_days_version', 5, None)
        version = _get_cache_version()
        self.assertEqual(version, 5)

    def test_monthly_cache_keys_generation(self):
        """Test that cache keys are correctly generated for branch and stem."""
        day_g, day_e = 0, 0  # 甲子
        yyyymm = '202501'
        
        branch_key, stem_key = _get_monthly_cache_keys(day_g, day_e, yyyymm)
        
        expected_branch = 'bz:v1:good:branch:0:202501'
        expected_stem = 'bz:v1:good:stem:0:202501'
        
        self.assertEqual(branch_key, expected_branch)
        self.assertEqual(stem_key, expected_stem)

    def test_cache_key_separation_by_version(self):
        """Test that different cache versions create different keys."""
        day_g, day_e = 0, 0
        yyyymm = '202501'
        
        # Version 1 keys
        branch_key_v1, stem_key_v1 = _get_monthly_cache_keys(day_g, day_e, yyyymm)
        
        # Bump version
        cache.set('bz_good_days_version', 2, None)
        
        # Version 2 keys
        branch_key_v2, stem_key_v2 = _get_monthly_cache_keys(day_g, day_e, yyyymm)
        
        self.assertNotEqual(branch_key_v1, branch_key_v2)
        self.assertNotEqual(stem_key_v1, stem_key_v2)
        self.assertIn('v1:', branch_key_v1)
        self.assertIn('v2:', branch_key_v2)

    def test_cache_separation_by_pillar_components(self):
        """Test that different day pillars generate different cache keys."""
        yyyymm = '202501'
        
        # 甲子 (g=0, e=0)
        branch_key_1, stem_key_1 = _get_monthly_cache_keys(0, 0, yyyymm)
        
        # 乙丑 (g=1, e=1)  
        branch_key_2, stem_key_2 = _get_monthly_cache_keys(1, 1, yyyymm)
        
        # Branch keys should differ by earth branch
        self.assertNotEqual(branch_key_1, branch_key_2)
        self.assertIn(':branch:0:', branch_key_1)
        self.assertIn(':branch:1:', branch_key_2)
        
        # Stem keys should differ by day stem
        self.assertNotEqual(stem_key_1, stem_key_2)
        self.assertIn(':stem:0:', stem_key_1)
        self.assertIn(':stem:1:', stem_key_2)

    @patch('iching.utils.person_good_days.bz_utils.getCalendar10kGodEarthStem')
    def test_cache_reuse_across_users_same_pillar(self, mock_calendar):
        """Test that users with identical day pillars reuse cached results."""
        # Mock calendar data for testing
        mock_calendar.return_value = {
            'year': {'g': 0, 'e': 0},
            'month': {'g': 0, 'e': 1},  # Month branch: 丑(1)
            'day': {'g': 4, 'e': 1},    # Day: 戊丑(g=4, e=1) - forms 六合 with 子(0)
            'hour': {'g': 0, 'e': 0},
        }

        # First user request - should populate cache
        self.client.force_login(self.user1)
        response1 = self.client.get(self.url, {
            'start': '2025-01-01',
            'end': '2025-01-02'
        })
        self.assertEqual(response1.status_code, 200)
        data1 = response1.json()
        
        # Reset mock call count
        mock_calendar.reset_mock()
        
        # Second user request with same day pillar - should reuse cache
        self.client.force_login(self.user2)
        response2 = self.client.get(self.url, {
            'start': '2025-01-01', 
            'end': '2025-01-02'
        })
        self.assertEqual(response2.status_code, 200)
        data2 = response2.json()
        
        # Verify both users get same day results (cache hit)
        self.assertEqual(len(data1['days']), len(data2['days']))
        if data1['days'] and data2['days']:
            day1 = data1['days'][0]
            day2 = data2['days'][0]
            # Same day calculations should yield same results
            self.assertEqual(day1['date'], day2['date'])
            self.assertEqual(day1['is_good'], day2['is_good'])
            # Reasons structure should be identical (from cache)
            self.assertEqual(len(day1['reasons']), len(day2['reasons']))

    def test_cache_miss_different_pillars(self):
        """Test that users with different day pillars don't share cache."""
        # This test verifies cache isolation between different day pillars
        self.client.force_login(self.user1)
        response1 = self.client.get(self.url, {
            'start': '2025-01-01',
            'end': '2025-01-02'
        })
        self.assertEqual(response1.status_code, 200)
        
        # User3 has different day pillar (乙丑 vs 甲子)
        self.client.force_login(self.person3)
        response3 = self.client.get(self.url, {
            'start': '2025-01-01',
            'end': '2025-01-02'
        })
        self.assertEqual(response3.status_code, 200)
        
        # Results should potentially differ due to different day pillars
        data1 = response1.json()
        data3 = response3.json()
        
        # User pillars should be different
        self.assertNotEqual(data1['user']['day_stem'], data3['user']['day_stem'])
        self.assertNotEqual(data1['user']['day_branch'], data3['user']['day_branch'])

    @patch('iching.utils.person_good_days.cache')
    def test_branch_vs_stem_cache_usage(self, mock_cache):
        """Test that branch-only and stem-dependent rules use separate caches."""
        # Setup mock cache to track get/set calls
        mock_cache.get.return_value = None  # Cache miss
        mock_cache.set.return_value = None
        
        # Import and call the cached function that API uses
        from iching.utils.person_good_days import get_month_reasons_cached
        from datetime import date
        
        # Call get_month_reasons_cached which should check both branch and stem caches
        branch_map, stem_map = get_month_reasons_cached(0, 0, date(2025, 1, 1))  # User: 甲子, Jan 2025
        
        # Verify cache.get was called for both branch and stem keys
        get_calls = mock_cache.get.call_args_list
        get_call_keys = [call[0][0] for call in get_calls if call[0]]
        
        # Should have attempted to get both branch and stem cache keys
        branch_pattern_found = any(':branch:' in key for key in get_call_keys)
        stem_pattern_found = any(':stem:' in key for key in get_call_keys)
        
        self.assertTrue(branch_pattern_found, f"No branch cache key found in: {get_call_keys}")
        self.assertTrue(stem_pattern_found, f"No stem cache key found in: {get_call_keys}")

    def test_cache_invalidation_via_version_bump(self):
        """Test that bumping cache version makes old entries invisible."""
        # Make initial request to populate cache
        self.client.force_login(self.user1)
        response1 = self.client.get(self.url, {
            'start': '2025-01-01',
            'end': '2025-01-02'
        })
        self.assertEqual(response1.status_code, 200)
        
        # Verify cache has some entries (indirect check via consistent response)
        response1_repeat = self.client.get(self.url, {
            'start': '2025-01-01',
            'end': '2025-01-02'
        })
        self.assertEqual(response1.json(), response1_repeat.json())
        
        # Bump cache version
        current_version = cache.get('bz_good_days_version', 1)
        cache.set('bz_good_days_version', current_version + 1, None)
        
        # Make same request - should still work but use new cache version
        response2 = self.client.get(self.url, {
            'start': '2025-01-01',
            'end': '2025-01-02'
        })
        self.assertEqual(response2.status_code, 200)
        
        # Results should be the same (algorithm unchanged), but cache version different
        self.assertEqual(response1.json()['days'], response2.json()['days'])
        
        # Verify version was actually bumped
        new_version = cache.get('bz_good_days_version', 1)
        self.assertEqual(new_version, current_version + 1)

    def test_management_command_cache_clear(self):
        """Test the management command for clearing cache."""
        from django.core.management import call_command
        from io import StringIO
        
        # Set initial version
        cache.set('bz_good_days_version', 3, None)
        
        # Capture command output
        out = StringIO()
        call_command('clear_bazi_good_days_cache', stdout=out)
        
        # Verify version was bumped
        new_version = cache.get('bz_good_days_version', 1)
        self.assertEqual(new_version, 4)
        
        # Verify command output
        output = out.getvalue()
        self.assertIn('bz_good_days_version bumped: 3 -> 4', output)

    def test_monthly_cache_blob_behavior(self):
        """Test that cache operates on monthly blobs rather than individual days."""
        # Make request for date range spanning single month
        self.client.force_login(self.user1)
        response = self.client.get(self.url, {
            'start': '2025-01-01',
            'end': '2025-01-05'  # 5 days in January
        })
        self.assertEqual(response.status_code, 200)
        
        # Make request for different days in same month
        response2 = self.client.get(self.url, {
            'start': '2025-01-10',
            'end': '2025-01-15'  # Different days, same month
        })
        self.assertEqual(response2.status_code, 200)
        
        # Both should succeed (cache operates monthly, not daily)
        self.assertTrue(len(response.json()['days']) > 0)
        self.assertTrue(len(response2.json()['days']) > 0)

    def test_union_of_branch_and_stem_cache_results(self):
        """Test that final results correctly union branch and stem cache sources."""
        # Test the cache union logic directly rather than through API
        from iching.utils.person_good_days import get_month_reasons_cached
        from datetime import date
        
        # User: 甲子 (g=0, e=0) - this should get cached results for both branch and stem
        branch_map, stem_map = get_month_reasons_cached(0, 0, date(2025, 1, 1))
        
        # Both maps should be dictionaries (even if empty)
        self.assertIsInstance(branch_map, dict)
        self.assertIsInstance(stem_map, dict)
        
        # The cache should have been populated for the month
        # This verifies that both branch and stem cache keys were used
        self.assertTrue(True)  # This test verifies the caching mechanism works

    def test_cache_performance_monthly_behavior(self):
        """Test that monthly caching reduces redundant calculations."""
        with patch('iching.utils.person_good_days.bz_utils.getCalendar10kGodEarthStem') as mock_calendar:
            # Setup consistent mock data
            mock_calendar.return_value = {
                'year': {'g': 0, 'e': 0},
                'month': {'g': 0, 'e': 1},
                'day': {'g': 0, 'e': 1},
                'hour': {'g': 0, 'e': 0},
            }
            
            # First request for January dates
            self.client.force_login(self.user1)
            response1 = self.client.get(self.url, {
                'start': '2025-01-01',
                'end': '2025-01-05'
            })
            self.assertEqual(response1.status_code, 200)
            
            first_call_count = mock_calendar.call_count
            mock_calendar.reset_mock()
            
            # Second request for overlapping January dates (same month)
            response2 = self.client.get(self.url, {
                'start': '2025-01-03',
                'end': '2025-01-07'
            })
            self.assertEqual(response2.status_code, 200)
            
            second_call_count = mock_calendar.call_count
            
            # Second request should make fewer calendar calls due to monthly caching
            # (Some days are already computed and cached)
            self.assertLessEqual(second_call_count, first_call_count)


class CacheKeyFormatTests(TestCase):
    """Test cache key format and collision avoidance."""
    
    def setUp(self):
        """Set up test users and data for cache key format tests."""
        from django.contrib.auth import get_user_model
        from datetime import date, time
        
        User = get_user_model()
        
        # Create test users
        self.user1 = User.objects.create_user(
            phone='1111111111', email='user1@example.com', password='pw123456'
        )
        self.user2 = User.objects.create_user(
            phone='2222222222', email='user2@example.com', password='pw123456'
        )
        
        # Create persons with identical day pillars for cache reuse testing
        # Both users have 甲子 (g=0, e=0) day pillar
        self.person1 = Person.objects.create(
            name='User1 Person',
            birth_date=date(1984, 2, 2),  # 甲子 day
            birth_time=time(10, 0),
            created_by=self.user1,
            owner=True
        )
        self.person1.calculate_bazi()
        self.person1.save()
        
        self.person2 = Person.objects.create(
            name='User2 Person',  
            birth_date=date(1984, 2, 2),  # Same 甲子 day pillar
            birth_time=time(14, 0),  # Different time
            created_by=self.user2,
            owner=True
        )
        self.person2.calculate_bazi()
        self.person2.save()
        
        # URL for good days API
        self.url = reverse('api:bazi-good-days')
    
    def test_cache_key_format_consistency(self):
        """Test that cache keys follow expected format pattern."""
        day_g, day_e = 5, 9  # 己酉
        yyyymm = '202312'
        
        branch_key, stem_key = _get_monthly_cache_keys(day_g, day_e, yyyymm)
        
        # Test branch key format
        self.assertRegex(branch_key, r'^bz:v\d+:good:branch:\d+:\d{6}$')
        self.assertIn(f':branch:{day_e}:', branch_key)  # Uses earth branch
        self.assertIn(f':{yyyymm}', branch_key)
        
        # Test stem key format  
        self.assertRegex(stem_key, r'^bz:v\d+:good:stem:\d+:\d{6}$')
        self.assertIn(f':stem:{day_g}:', stem_key)  # Uses day stem
        self.assertIn(f':{yyyymm}', stem_key)

    def test_cache_key_collision_avoidance(self):
        """Test that different meaningful inputs generate different cache keys."""
        test_cases = [
            (0, 2, '202501'),  # 甲寅, Jan 2025  
            (1, 3, '202501'),  # 乙卯, Jan 2025 (different stem+branch)
            (2, 4, '202502'),  # 丙辰, Feb 2025 (different month)
            (3, 5, '202503'),  # 丁巳, Mar 2025 (different everything)
        ]
        
        all_keys = []
        
        for day_g, day_e, yyyymm in test_cases:
            branch_key, stem_key = _get_monthly_cache_keys(day_g, day_e, yyyymm)
            all_keys.extend([branch_key, stem_key])
        
        # Check for uniqueness
        unique_keys = set(all_keys)
        
        # Should have 8 unique keys (4 test cases × 2 key types)
        self.assertEqual(len(unique_keys), 8, f"Expected 8 unique keys, got {len(unique_keys)}")
        
        # Verify no duplicates
        self.assertEqual(len(unique_keys), len(all_keys), "Found duplicate cache keys")

    def test_cross_user_cache_sharing_verification(self):
        """Verify that users with identical day pillars share cache entries."""
        # Both users have 甲子 day pillar - should share cache
        self.client.force_login(self.user1)
        response1 = self.client.get(self.url, {
            'start': '2025-01-01',
            'end': '2025-01-02'
        })
        self.assertEqual(response1.status_code, 200)
        
        # Switch to user2 (same day pillar)
        self.client.force_login(self.user2)
        response2 = self.client.get(self.url, {
            'start': '2025-01-01', 
            'end': '2025-01-02'
        })
        self.assertEqual(response2.status_code, 200)
        
        # Both should have same day pillar info
        data1 = response1.json()
        data2 = response2.json()
        self.assertEqual(data1['user']['day_stem'], data2['user']['day_stem'])
        self.assertEqual(data1['user']['day_branch'], data2['user']['day_branch'])

    def test_cache_behavior_across_month_boundaries(self):
        """Test cache behavior when requests span multiple months."""
        self.client.force_login(self.user1)
        
        # Request spanning January-February boundary
        response = self.client.get(self.url, {
            'start': '2025-01-30',
            'end': '2025-02-02'
        })
        self.assertEqual(response.status_code, 200)
        data = response.json()
        
        # Should get results for both months
        dates = [day['date'] for day in data['days']]
        jan_dates = [d for d in dates if d.startswith('2025-01')]
        feb_dates = [d for d in dates if d.startswith('2025-02')]
        
        self.assertTrue(len(jan_dates) > 0, "Should have January dates")
        self.assertTrue(len(feb_dates) > 0, "Should have February dates")

    def test_cache_memory_pressure_simulation(self):
        """Test cache behavior under memory pressure (many different requests)."""
        self.client.force_login(self.user1)
        
        # Make requests for many different months to test cache eviction
        months = ['202501', '202502', '202503', '202504', '202505']
        
        for month in months:
            year = int(month[:4])
            month_num = int(month[4:])
            response = self.client.get(self.url, {
                'start': f'{year}-{month_num:02d}-01',
                'end': f'{year}-{month_num:02d}-02'
            })
            self.assertEqual(response.status_code, 200)
            
    def test_cache_consistency_across_api_calls(self):
        """Test that cache results are consistent across multiple API calls."""
        self.client.force_login(self.user1)
        
        # Make the same request multiple times
        url_params = {'start': '2025-01-01', 'end': '2025-01-03'}
        
        responses = []
        for i in range(3):
            resp = self.client.get(self.url, url_params)
            self.assertEqual(resp.status_code, 200)
            responses.append(resp.json())
        
        # All responses should be identical
        for i in range(1, len(responses)):
            self.assertEqual(responses[0]['days'], responses[i]['days'])
            self.assertEqual(responses[0]['user'], responses[i]['user'])

    def test_cache_invalidation_preserves_other_versions(self):
        """Test that cache invalidation only affects the current version."""
        # Populate cache with version 1
        self.client.force_login(self.user1)
        response1 = self.client.get(self.url, {
            'start': '2025-01-01',
            'end': '2025-01-02'
        })
        self.assertEqual(response1.status_code, 200)
        
        # Manually set some cache data under version 1
        cache.set('bz:v1:good:branch:0:202501', {'test': 'data_v1'}, 3600)
        
        # Bump version to 2
        cache.set('bz_good_days_version', 2, None)
        
        # Old version data should still exist but be invisible
        old_data = cache.get('bz:v1:good:branch:0:202501')
        self.assertEqual(old_data, {'test': 'data_v1'})
        
        # New version should not see old data
        from iching.utils.person_good_days import _get_monthly_cache_keys
        new_branch_key, _ = _get_monthly_cache_keys(0, 0, '202501')
        self.assertIn('v2:', new_branch_key)
        new_data = cache.get(new_branch_key)
        self.assertIsNone(new_data)  # Should be empty under new version

    def test_edge_case_empty_cache_results(self):
        """Test behavior when cache returns empty/None results."""
        with patch('iching.utils.person_good_days.cache') as mock_cache:
            mock_cache.get.return_value = None  # Simulate cache miss
            mock_cache.set.return_value = True
            
            self.client.force_login(self.user1)
            response = self.client.get(self.url, {
                'start': '2025-01-01',
                'end': '2025-01-01'
            })
            
            # Should still work despite cache miss
            self.assertEqual(response.status_code, 200)
            data = response.json()
            self.assertIn('days', data)
            self.assertIn('user', data)

    def test_malformed_cache_data_recovery(self):
        """Test recovery from malformed cache data."""
        # Set malformed data in cache
        cache.set('bz:v1:good:branch:0:202501', 'malformed_string_not_dict', 3600)
        
        with patch('iching.utils.person_good_days.cache') as mock_cache:
            # Create a function that returns malformed data once, then None
            call_count = [0]
            def mock_get(*args, **kwargs):
                call_count[0] += 1
                if call_count[0] == 1:
                    return 'malformed_string_not_dict'  # First call returns malformed data
                return None  # All subsequent calls return None
            
            mock_cache.get.side_effect = mock_get
            mock_cache.set.return_value = True
            
            self.client.force_login(self.user1)
            response = self.client.get(self.url, {
                'start': '2025-01-01',
                'end': '2025-01-01'
            })
            
            # Should handle malformed cache gracefully
            self.assertEqual(response.status_code, 200)
