"""
Tests for django_cronjob_utils hooks integration.

Tests cover standard behavior, edge cases, and outlier scenarios.
"""
from datetime import date
from unittest.mock import patch, MagicMock, call
from django.test import TestCase
from django.utils import timezone

from django_cronjob_utils.base import CronTask, ExecutionResult
from django_cronjob_utils.hooks import (
    CronjobHookContext,
    get_global_cronjob_hook_manager,
    clear_cronjob_hooks,
)
from django_cronjob_utils.models import CronExecution
from django_cronjob_utils.exceptions import ValidationError

from django_package_hooks import HookType, HookRejectionError


class TestTask(CronTask):
    """Test task for hook tests."""
    
    task_name = 'test-task'
    task_code = 'T001'
    
    def execute(self, date: date) -> dict:
        return {'error': False, 'message': 'Success'}


class FailingTask(CronTask):
    """Task that always fails."""
    
    task_name = 'failing-task'
    task_code = 'T002'
    
    def execute(self, date: date) -> dict:
        return {'error': True, 'message': 'Task failed', 'error_code': 'FAIL'}


class ExceptionTask(CronTask):
    """Task that raises exception."""
    
    task_name = 'exception-task'
    task_code = 'T003'
    
    def execute(self, date: date) -> dict:
        raise ValueError("Test exception")


# ============================================================================
# STANDARD TESTS - Normal hook behavior
# ============================================================================

class HookContextCreationTests(TestCase):
    """Test CronjobHookContext creation and properties."""
    
    def setUp(self):
        """Set up test fixtures."""
        self.execution_date = date(2024, 1, 15)
        clear_cronjob_hooks()
    
    def test_context_creation_with_all_fields(self):
        """Test creating context with all fields."""
        context = CronjobHookContext(
            task_name='test-task',
            task_code='T001',
            execution_date=self.execution_date,
            execution_id=123,
            options={'force': True},
            metadetails={'custom': 'data'}
        )
        
        self.assertEqual(context.task_name, 'test-task')
        self.assertEqual(context.task_code, 'T001')
        self.assertEqual(context.execution_date, self.execution_date)
        self.assertEqual(context.execution_id, 123)
        self.assertEqual(context.options['force'], True)
        self.assertEqual(context.metadata['custom'], 'data')
    
    def test_context_creation_minimal(self):
        """Test creating context with minimal required fields."""
        context = CronjobHookContext(
            task_name='test-task',
            task_code='T001',
            execution_date=self.execution_date
        )
        
        self.assertEqual(context.task_name, 'test-task')
        self.assertEqual(context.task_code, 'T001')
        self.assertEqual(context.execution_date, self.execution_date)
        self.assertIsNone(context.execution_id)
        self.assertEqual(context.options, {})
        self.assertEqual(context.metadata, {})
    
    def test_context_is_frozen(self):
        """Test that context is immutable (frozen dataclass)."""
        context = CronjobHookContext(
            task_name='test-task',
            task_code='T001',
            execution_date=self.execution_date
        )
        
        with self.assertRaises(Exception):  # FrozenInstanceError
            context.task_name = 'modified'
    
    def test_context_metadata_is_mutable(self):
        """Test that metadata dict is mutable for hook communication."""
        context = CronjobHookContext(
            task_name='test-task',
            task_code='T001',
            execution_date=self.execution_date,
            metadetails={}
        )
        
        # Should be able to modify metadata
        context.metadata['new_key'] = 'new_value'
        self.assertEqual(context.metadata['new_key'], 'new_value')


class PreHookStandardTests(TestCase):
    """Test standard PRE hook behavior."""
    
    def setUp(self):
        """Set up test fixtures."""
        self.execution_date = date(2024, 1, 15)
        clear_cronjob_hooks()
        CronExecution.objects.all().delete()
    
    def test_pre_hook_allows_execution(self):
        """Test PRE hook that allows execution."""
        hook_called = []
        
        def allow_hook(context):
            hook_called.append(True)            self.assertIsNone(context.execution_id)  # No ID yet
            # Don't raise HookRejection - allow execution
        
        manager = get_global_cronjob_hook_manager()
        manager.register('cronjob.execute', allow_hook, stage=HookType.PRE)
        
        task = TestTask(execution_date=self.execution_date)
        result = task.run()
        
        self.assertTrue(hook_called)
        self.assertFalse(result.error)
        self.assertEqual(result.message, 'Success')
    
    def test_pre_hook_rejects_execution(self):
        """Test PRE hook that rejects execution."""
        def reject_hook(context):
            raise HookRejectionError(
                error_code='DEPENDENCY_NOT_MET',
                message='Required task not completed'
            )
        
        manager = get_global_cronjob_hook_manager()
        manager.register('cronjob.execute', reject_hook, stage=HookType.PRE)
        
        task = TestTask(execution_date=self.execution_date)
        result = task.run()
        
        self.assertTrue(result.error)
        self.assertEqual(result.error_code, 'DEPENDENCY_NOT_MET')
        self.assertEqual(result.message, 'Required task not completed')
        
        # No execution record should be created
        self.assertEqual(CronExecution.objects.count(), 0)
    
    def test_pre_hook_receives_options(self):
        """Test PRE hook receives task options."""
        captured_context = []
        
        def capture_hook(context):
            captured_context.append(context)
        
        manager = get_global_cronjob_hook_manager()
        manager.register('cronjob.execute', capture_hook, stage=HookType.PRE)
        
        task = TestTask(execution_date=self.execution_date, force=True, rerun=True)
        task.run()
        
        self.assertEqual(len(captured_context), 1)
        context = captured_context[0]
        self.assertTrue(context.options.get('force'))
        self.assertTrue(context.options.get('rerun'))
    
    def test_multiple_pre_hooks_priority_order(self):
        """Test multiple PRE hooks execute in priority order."""
        execution_order = []
        
        def hook_priority_10(context):
            execution_order.append('hook_10')
        
        def hook_priority_5(context):
            execution_order.append('hook_5')
        
        def hook_priority_1(context):
            execution_order.append('hook_1')
        
        manager = get_global_cronjob_hook_manager()
        manager.register('cronjob.execute', hook_priority_10, stage=HookType.PRE, priority=10)
        manager.register('cronjob.execute', hook_priority_5, stage=HookType.PRE, priority=5)
        manager.register('cronjob.execute', hook_priority_1, stage=HookType.PRE, priority=1)
        
        task = TestTask(execution_date=self.execution_date)
        task.run()
        
        # Lower priority number = higher priority (executes first)
        self.assertEqual(execution_order, ['hook_1', 'hook_5', 'hook_10'])
    
    def test_first_pre_hook_rejection_stops_execution(self):
        """Test that first PRE hook rejection stops other hooks."""
        execution_order = []
        
        def hook_reject(context):
            execution_order.append('reject')
            raise HookRejectionError(error_code='REJECTED', message='Rejected')
        
        def hook_after(context):
            execution_order.append('after')
        
        manager = get_global_cronjob_hook_manager()
        manager.register('cronjob.execute', hook_reject, stage=HookType.PRE, priority=1)
        manager.register('cronjob.execute', hook_after, stage=HookType.PRE, priority=2)
        
        task = TestTask(execution_date=self.execution_date)
        result = task.run()
        
        self.assertTrue(result.error)
        # Only reject hook should execute
        self.assertEqual(execution_order, ['reject'])


class PostHookStandardTests(TestCase):
    """Test standard POST hook behavior."""
    
    def setUp(self):
        """Set up test fixtures."""
        self.execution_date = date(2024, 1, 15)
        clear_cronjob_hooks()
        CronExecution.objects.all().delete()
    
    def test_post_hook_receives_success_result(self):
        """Test POST hook receives successful execution result."""
        captured_context = []
        
        def post_hook(context):
            captured_context.append(context)
        
        manager = get_global_cronjob_hook_manager()
        manager.register('cronjob.execute', post_hook, stage=HookType.POST)
        
        task = TestTask(execution_date=self.execution_date)
        result = task.run()
        
        self.assertFalse(result.error)
        self.assertEqual(len(captured_context), 1)
        
        context = captured_context[0]
        self.assertIsNotNone(context.execution_id)  # Has ID now
        self.assertEqual(context.task_name, 'test-task')
    
    def test_post_hook_receives_failure_result(self):
        """Test POST hook receives failed execution result."""
        captured_context = []
        
        def post_hook(context):
            captured_context.append(context)
        
        manager = get_global_cronjob_hook_manager()
        manager.register('cronjob.execute', post_hook, stage=HookType.POST)
        
        task = FailingTask(execution_date=self.execution_date)
        result = task.run()
        
        self.assertTrue(result.error)
        self.assertEqual(len(captured_context), 1)
        
        context = captured_context[0]
        self.assertIsNotNone(context.execution_id)
    
    def test_post_hook_cannot_reject(self):
        """Test POST hook rejections don't affect execution result."""
        def post_hook_reject(context):
            # POST hooks should not raise rejections, but if they do,
            # it should not affect the result
            raise HookRejectionError(error_code='POST_REJECT', message='Should be ignored')
        
        manager = get_global_cronjob_hook_manager()
        manager.register('cronjob.execute', post_hook_reject, stage=HookType.POST)
        
        task = TestTask(execution_date=self.execution_date)
        result = task.run()
        
        # Task should still succeed despite POST hook rejection
        self.assertFalse(result.error)
        self.assertEqual(result.message, 'Success')
    
    def test_multiple_post_hooks_all_execute(self):
        """Test multiple POST hooks all execute."""
        execution_order = []
        
        def hook_a(context):
            execution_order.append('a')
        
        def hook_b(context):
            execution_order.append('b')
        
        manager = get_global_cronjob_hook_manager()
        manager.register('cronjob.execute', hook_a, stage=HookType.POST, priority=1)
        manager.register('cronjob.execute', hook_b, stage=HookType.POST, priority=2)
        
        task = TestTask(execution_date=self.execution_date)
        task.run()
        
        self.assertEqual(execution_order, ['a', 'b'])
    
    def test_post_hook_exception_logged_but_not_raised(self):
        """Test POST hook exceptions are logged but don't affect result."""
        def post_hook_exception(context):
            raise ValueError("POST hook error")
        
        manager = get_global_cronjob_hook_manager()
        manager.register('cronjob.execute', post_hook_exception, stage=HookType.POST)
        
        task = TestTask(execution_date=self.execution_date)
        
        # Should not raise exception
        with patch('django_cronjob_utils.base.logger') as mock_logger:
            result = task.run()
            
            # Task should still succeed
            self.assertFalse(result.error)
            
            # Exception should be logged
            mock_logger.error.assert_called()


# ============================================================================
# EDGE CASE TESTS
# ============================================================================

class HookEdgeCaseTests(TestCase):
    """Test edge cases in hook behavior."""
    
    def setUp(self):
        """Set up test fixtures."""
        self.execution_date = date(2024, 1, 15)
        clear_cronjob_hooks()
        CronExecution.objects.all().delete()
    
    def test_pre_hook_modifies_metadata(self):
        """Test PRE hook can modify metadata for POST hooks."""
        def pre_hook(context):
            context.metadata['pre_hook_data'] = 'from_pre'
        
        captured_metadata = []
        
        def post_hook(context):
            captured_metadata.append(context.metadata.copy())
        
        manager = get_global_cronjob_hook_manager()
        manager.register('cronjob.execute', pre_hook, stage=HookType.PRE)
        manager.register('cronjob.execute', post_hook, stage=HookType.POST)
        
        task = TestTask(execution_date=self.execution_date)
        task.run()
        
        # POST hook should see PRE hook's metadata
        self.assertEqual(len(captured_metadata), 1)
        self.assertEqual(captured_metadata[0]['pre_hook_data'], 'from_pre')
    
    def test_hook_with_empty_task_code(self):
        """Test hook with task that has empty code."""
        class NoCodeTask(CronTask):
            task_name = 'no-code-task'
            task_code = ''
            
            def execute(self, date: date) -> dict:
                return {'error': False}
        
        captured_context = []
        
        def pre_hook(context):
            captured_context.append(context)
        
        manager = get_global_cronjob_hook_manager()
        manager.register('cronjob.execute', pre_hook, stage=HookType.PRE)
        
        task = NoCodeTask(execution_date=self.execution_date)
        result = task.run()
        
        self.assertFalse(result.error)
        self.assertEqual(captured_context[0].task_code, '')
    
    def test_hook_with_none_options(self):
        """Test hook when task has no options."""
        captured_context = []
        
        def pre_hook(context):
            captured_context.append(context)
        
        manager = get_global_cronjob_hook_manager()
        manager.register('cronjob.execute', pre_hook, stage=HookType.PRE)
        
        task = TestTask(execution_date=self.execution_date)
        result = task.run()
        
        self.assertFalse(result.error)
        self.assertEqual(captured_context[0].options, {})
    
    def test_pre_hook_rejection_with_minimal_info(self):
        """Test PRE hook rejection with minimal error info."""
        def reject_hook(context):
            # Only provide error_code
            raise HookRejectionError(error_code='MINIMAL')
        
        manager = get_global_cronjob_hook_manager()
        manager.register('cronjob.execute', reject_hook, stage=HookType.PRE)
        
        task = TestTask(execution_date=self.execution_date)
        result = task.run()
        
        self.assertTrue(result.error)
        self.assertEqual(result.error_code, 'MINIMAL')
        # Message should be present even if not provided
        self.assertIsNotNone(result.message)
    
    def test_pre_hook_rejection_with_extra_data(self):
        """Test PRE hook rejection with extra data."""
        def reject_hook(context):
            raise HookRejectionError(
                error_code='WITH_DATA',
                message='Rejected with data',
                details={'reason': 'dependency', 'required_task': 'other-task'}
            )
        
        manager = get_global_cronjob_hook_manager()
        manager.register('cronjob.execute', reject_hook, stage=HookType.PRE)
        
        task = TestTask(execution_date=self.execution_date)
        result = task.run()
        
        self.assertTrue(result.error)
        self.assertEqual(result.error_code, 'WITH_DATA')
        self.assertIsNotNone(result.data)
        self.assertEqual(result.data['reason'], 'dependency')
    
    def test_hook_manager_cleared_between_tests(self):
        """Test that clear_cronjob_hooks() properly clears hooks."""
        hook_called = []
        
        def hook1(context):
            hook_called.append('hook1')
        
        manager = get_global_cronjob_hook_manager()
        manager.register('cronjob.execute', hook1, stage=HookType.PRE)
        
        # Clear hooks
        clear_cronjob_hooks()
        
        # Register different hook
        def hook2(context):
            hook_called.append('hook2')
        
        manager.register('cronjob.execute', hook2, stage=HookType.PRE)
        
        task = TestTask(execution_date=self.execution_date)
        task.run()
        
        # Only hook2 should be called
        self.assertEqual(hook_called, ['hook2'])
    
    def test_task_with_exception_still_calls_post_hooks(self):
        """Test POST hooks are called even when task raises exception."""
        captured_context = []
        
        def post_hook(context):
            captured_context.append(context)
        
        manager = get_global_cronjob_hook_manager()
        manager.register('cronjob.execute', post_hook, stage=HookType.POST)
        
        task = ExceptionTask(execution_date=self.execution_date)
        result = task.run()
        
        self.assertTrue(result.error)
        # POST hook should still be called
        self.assertEqual(len(captured_context), 1)


# ============================================================================
# OUTLIER TESTS - Unusual or unexpected scenarios
# ============================================================================

class HookOutlierTests(TestCase):
    """Test outlier scenarios for hooks."""
    
    def setUp(self):
        """Set up test fixtures."""
        self.execution_date = date(2024, 1, 15)
        clear_cronjob_hooks()
        CronExecution.objects.all().delete()
    
    def test_hook_registered_but_never_triggered(self):
        """Test hook registered for different event is not triggered."""
        hook_called = []
        
        def hook(context):
            hook_called.append(True)
        
        manager = get_global_cronjob_hook_manager()
        # Register for wrong event
        manager.register('cronjob.different_event', hook, stage=HookType.PRE)
        
        task = TestTask(execution_date=self.execution_date)
        task.run()
        
        # Hook should not be called
        self.assertEqual(hook_called, [])
    
    def test_many_hooks_performance(self):
        """Test system with many hooks registered."""
        execution_order = []
        
        # Register 50 hooks
        manager = get_global_cronjob_hook_manager()
        for i in range(50):
            def make_hook(index):
                def hook(context):
                    execution_order.append(index)
                return hook
            
            manager.register('cronjob.execute', make_hook(i), stage=HookType.PRE, priority=i)
        
        task = TestTask(execution_date=self.execution_date)
        result = task.run()
        
        # All hooks should execute in order
        self.assertFalse(result.error)
        self.assertEqual(len(execution_order), 50)
        self.assertEqual(execution_order, list(range(50)))
    
    def test_hook_with_very_long_execution(self):
        """Test hook that takes long time doesn't block indefinitely."""
        import time
        
        def slow_hook(context):
            time.sleep(0.1)  # 100ms delay
        
        manager = get_global_cronjob_hook_manager()
        manager.register('cronjob.execute', slow_hook, stage=HookType.PRE)
        
        task = TestTask(execution_date=self.execution_date)
        
        start_time = timezone.now()
        result = task.run()
        duration = (timezone.now() - start_time).total_seconds()
        
        self.assertFalse(result.error)
        # Should complete (not timeout)
        self.assertLess(duration, 5.0)  # Should finish within 5 seconds
    
    def test_pre_hook_rejection_with_non_standard_error_code(self):
        """Test PRE hook with unusual error code format."""
        def reject_hook(context):
            raise HookRejectionError(
                error_code='very-long-error-code-with-many-dashes-and-special-chars-!@#',
                message='Unusual format'
            )
        
        manager = get_global_cronjob_hook_manager()
        manager.register('cronjob.execute', reject_hook, stage=HookType.PRE)
        
        task = TestTask(execution_date=self.execution_date)
        result = task.run()
        
        self.assertTrue(result.error)
        # Should handle unusual error code
        self.assertIsNotNone(result.error_code)
    
    def test_hook_modifying_context_in_unexpected_ways(self):
        """Test hook that modifies context metadata heavily."""
        def pre_hook(context):
            # Add lots of data
            context.metadata.update({
                'key1': 'value1',
                'key2': [1, 2, 3],
                'key3': {'nested': 'dict'},
                'key4': None,
                'key5': 12345,
            })
        
        captured_metadata = []
        
        def post_hook(context):
            captured_metadata.append(context.metadata)
        
        manager = get_global_cronjob_hook_manager()
        manager.register('cronjob.execute', pre_hook, stage=HookType.PRE)
        manager.register('cronjob.execute', post_hook, stage=HookType.POST)
        
        task = TestTask(execution_date=self.execution_date)
        task.run()
        
        # POST hook should see all metadata
        self.assertEqual(len(captured_metadata), 1)
        metadata = captured_metadata[0]
        self.assertEqual(metadata['key1'], 'value1')
        self.assertEqual(metadata['key2'], [1, 2, 3])
        self.assertIsNone(metadata['key4'])
    
    def test_concurrent_hook_registration_and_execution(self):
        """Test hooks registered during task execution don't affect current run."""
        execution_order = []
        
        def pre_hook(context):
            execution_order.append('pre')
            # Try to register another hook during execution
            manager = get_global_cronjob_hook_manager()
            manager.register('cronjob.execute', lambda s, c: execution_order.append('dynamic'),
                           stage=HookType.POST)
        
        def post_hook(context):
            execution_order.append('post')
        
        manager = get_global_cronjob_hook_manager()
        manager.register('cronjob.execute', pre_hook, stage=HookType.PRE)
        manager.register('cronjob.execute', post_hook, stage=HookType.POST)
        
        task = TestTask(execution_date=self.execution_date)
        task.run()
        
        # Dynamically registered hook might or might not execute
        # depending on hook manager implementation
        self.assertIn('pre', execution_order)
        self.assertIn('post', execution_order)
    
    def test_hook_with_unicode_and_special_chars_in_context(self):
        """Test hooks handle unicode and special characters in context."""
        class UnicodeTask(CronTask):
            task_name = 'test-task-émoji-🎉'
            task_code = 'T-üñíçödé'
            
            def execute(self, date: date) -> dict:
                return {'error': False, 'message': 'Success ✓'}
        
        captured_context = []
        
        def pre_hook(context):
            captured_context.append(context)
        
        manager = get_global_cronjob_hook_manager()
        manager.register('cronjob.execute', pre_hook, stage=HookType.PRE)
        
        task = UnicodeTask(execution_date=self.execution_date)
        result = task.run()
        
        self.assertFalse(result.error)
        self.assertEqual(captured_context[0].task_name, 'test-task-émoji-🎉')
        self.assertEqual(captured_context[0].task_code, 'T-üñíçödé')
    
    def test_pre_hook_rejection_preserves_database_state(self):
        """Test PRE hook rejection doesn't create orphaned records."""
        # Create some initial records
        initial_count = CronExecution.objects.count()
        
        def reject_hook(context):
            raise HookRejectionError(error_code='REJECTED', message='No records')
        
        manager = get_global_cronjob_hook_manager()
        manager.register('cronjob.execute', reject_hook, stage=HookType.PRE)
        
        task = TestTask(execution_date=self.execution_date)
        result = task.run()
        
        self.assertTrue(result.error)
        # No new records should be created
        final_count = CronExecution.objects.count()
        self.assertEqual(initial_count, final_count)


# ============================================================================
# INTEGRATION TESTS - Real-world scenarios
# ============================================================================

class HookIntegrationTests(TestCase):
    """Test real-world hook integration scenarios."""
    
    def setUp(self):
        """Set up test fixtures."""
        self.execution_date = date(2024, 1, 15)
        clear_cronjob_hooks()
        CronExecution.objects.all().delete()
    
    def test_dependency_checking_hook(self):
        """Test realistic dependency checking hook."""
        # Simulate dependency task completion
        dependency_task = TestTask(execution_date=self.execution_date)
        dependency_task.task_name = 'dependency-task'
        dependency_task.task_code = 'DEP001'
        dependency_result = dependency_task.run()
        self.assertFalse(dependency_result.error)
        
        def check_dependency(context):
            if context.task_code == 'T001':
                # Check if dependency completed
                dependency_exists = CronExecution.objects.filter(
                    task_code='DEP001',
                    execution_date=context.execution_date,
                    status='completed',
                    error=False
                ).exists()
                
                if not dependency_exists:
                    raise HookRejectionError(
                        error_code='DEPENDENCY_NOT_MET',
                        message='Required task DEP001 not completed'
                    )
        
        manager = get_global_cronjob_hook_manager()
        manager.register('cronjob.execute', check_dependency, stage=HookType.PRE)
        
        # Now run the dependent task
        task = TestTask(execution_date=self.execution_date)
        result = task.run()
        
        # Should succeed because dependency exists
        self.assertFalse(result.error)
    
    def test_audit_logging_hook(self):
        """Test realistic audit logging POST hook."""
        audit_log = []
        
        def audit_hook(context):
            # Fetch execution record to get full details
            if context.execution_id:
                execution = CronExecution.objects.get(id=context.execution_id)
                audit_log.append({
                    'task': context.task_name,
                    'status': execution.status,
                    'error': execution.error,
                    'duration': execution.duration,
                })
        
        manager = get_global_cronjob_hook_manager()
        manager.register('cronjob.execute', audit_hook, stage=HookType.POST)
        
        # Run successful task
        task1 = TestTask(execution_date=self.execution_date)
        task1.run()
        
        # Run failing task
        task2 = FailingTask(execution_date=self.execution_date)
        task2.run()
        
        # Check audit log
        self.assertEqual(len(audit_log), 2)
        self.assertFalse(audit_log[0]['error'])
        self.assertTrue(audit_log[1]['error'])
    
    def test_retry_limiting_hook(self):
        """Test hook that limits retry attempts."""
        def limit_retries(context):
            if context.task_code == 'T002':  # FailingTask
                # Check previous retry count
                retry_count = CronExecution.objects.filter(
                    task_code=context.task_code,
                    execution_date=context.execution_date
                ).count()
                
                if retry_count >= 3:
                    raise HookRejectionError(
                        error_code='MAX_RETRIES_EXCEEDED',
                        message=f'Task has already failed {retry_count} times'
                    )
        
        manager = get_global_cronjob_hook_manager()
        manager.register('cronjob.execute', limit_retries, stage=HookType.PRE)
        
        # Run failing task multiple times
        task = FailingTask(execution_date=self.execution_date)
        
        # First 3 attempts should execute
        for i in range(3):
            result = task.run(force=True)
            self.assertTrue(result.error)
            self.assertEqual(result.error_code, 'FAIL')
        
        # 4th attempt should be rejected by hook
        result = task.run(force=True)
        self.assertTrue(result.error)
        self.assertEqual(result.error_code, 'MAX_RETRIES_EXCEEDED')
