"""
Comprehensive stress tests for BGX library.
Tests designed to push the system to its limits and find edge cases.
"""

import pytest
import time
import threading
import random
import string
from concurrent.futures import ThreadPoolExecutor, as_completed
from unittest.mock import Mock

from bgx import (
    BackgroundEngine,
    Task,
    TaskState,
    Priority,
    RetryPolicy,
    ResultVault,
    TaskGroup,
    GroupPattern,
    CronParser,
    TaskScheduler,
    ScheduledHandle,
    exponential_retry_policy,
    fixed_retry_policy,
)


class TestEdgeCases:
    """Test edge cases and boundary conditions."""
    
    def test_empty_task_function(self):
        """Test task with empty function."""
        with BackgroundEngine(workers=2) as engine:
            def empty_func():
                pass
            
            task = engine.submit(empty_func)
            result = task.wait(timeout=5)
            # Empty function returns None
            assert result is None
    
    def test_task_with_no_return(self):
        """Test task that returns None explicitly."""
        with BackgroundEngine(workers=2) as engine:
            def return_none():
                return None
            
            task = engine.submit(return_none)
            assert task.wait(timeout=5) is None
    
    def test_task_with_large_return_value(self):
        """Test task returning large data."""
        with BackgroundEngine(workers=2) as engine:
            def large_return():
                # Return 10MB of data
                return "x" * (10 * 1024 * 1024)
            
            task = engine.submit(large_return)
            result = task.wait(timeout=10)
            assert len(result) == 10 * 1024 * 1024
    
    def test_task_with_many_arguments(self):
        """Test task with many arguments."""
        with BackgroundEngine(workers=2) as engine:
            def many_args(a, b, c, d, e, f, g, h, i, j):
                return sum([a, b, c, d, e, f, g, h, i, j])
            
            task = engine.submit(many_args, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
            assert task.wait(timeout=5) == 55
    
    def test_task_with_kwargs_only(self):
        """Test task with only keyword arguments."""
        with BackgroundEngine(workers=2) as engine:
            def kwargs_only(**kwargs):
                return kwargs
            
            task = engine.submit(kwargs_only, a=1, b=2, c=3)
            result = task.wait(timeout=5)
            assert result == {'a': 1, 'b': 2, 'c': 3}
    
    def test_task_with_exception_in_function(self):
        """Test task that raises exception."""
        with BackgroundEngine(workers=2) as engine:
            def raise_exception():
                raise ValueError("Test exception")
            
            task = engine.submit(raise_exception)
            # Wait for task to complete - it should be in FAILED state
            task.wait(timeout=5)
            assert task.state == TaskState.FAILED
            assert isinstance(task.error, ValueError)
    
    def test_task_with_zero_timeout(self):
        """Test task with zero timeout (should fail validation)."""
        with pytest.raises(ValueError, match="timeout must be positive"):
            Task(func=lambda: None, timeout=0)
    
    def test_task_with_negative_timeout(self):
        """Test task with negative timeout (should fail validation)."""
        with pytest.raises(ValueError, match="timeout must be positive"):
            Task(func=lambda: None, timeout=-1)
    
    def test_task_with_very_small_timeout(self):
        """Test task with very small timeout."""
        with BackgroundEngine(workers=2) as engine:
            def slow_task():
                time.sleep(0.1)
                return "done"
            
            task = Task(func=slow_task, timeout=0.001)
            engine._submit_task(task)
            # Wait for task to complete - should timeout
            task.wait(timeout=5)
            assert task.state == TaskState.TIMEOUT
    
    def test_vault_with_zero_ttl(self):
        """Test vault with zero TTL."""
        vault = ResultVault(ttl=0, max_size=100)
        vault.store("key1", "value1")
        # Should be immediately expired
        assert vault.get("key1") is None
    
    def test_vault_with_zero_max_size(self):
        """Test vault with zero max size."""
        vault = ResultVault(ttl=60, max_size=0)
        vault.store("key1", "value1")
        # Should not store anything
        assert vault.get("key1") is None
    
    def test_vault_with_very_large_max_size(self):
        """Test vault with very large max size."""
        vault = ResultVault(ttl=60, max_size=1000000)
        for i in range(1000):
            vault.store(f"key{i}", f"value{i}")
        assert len(vault._entries) == 1000
    
    def test_engine_with_zero_workers(self):
        """Test engine with zero workers (should use defaults)."""
        with BackgroundEngine(workers=0) as engine:
            assert engine.executor.workers > 0
            task = engine.submit(lambda: 42)
            assert task.wait(timeout=5) == 42
    
    def test_engine_with_negative_workers(self):
        """Test engine with negative workers (should use defaults)."""
        with BackgroundEngine(workers=-1) as engine:
            assert engine.executor.workers > 0
            task = engine.submit(lambda: 42)
            assert task.wait(timeout=5) == 42
    
    def test_engine_with_very_large_workers(self):
        """Test engine with very large worker count."""
        with BackgroundEngine(workers=100) as engine:
            tasks = []
            for i in range(50):
                tasks.append(engine.submit(lambda x: x * 2, i))
            
            results = [t.wait(timeout=10) for t in tasks]
            assert len(results) == 50


class TestConcurrencyIssues:
    """Test concurrent operations and race conditions."""
    
    def test_concurrent_submissions(self):
        """Test submitting many tasks concurrently."""
        with BackgroundEngine(workers=10) as engine:
            def task_func(x):
                time.sleep(0.01)
                return x * 2
            
            tasks = []
            with ThreadPoolExecutor(max_workers=10) as executor:
                for i in range(100):
                    future = executor.submit(engine.submit, task_func, i)
                    tasks.append(future)
            
            results = [f.result().wait(timeout=10) for f in tasks]
            assert len(results) == 100
    
    def test_concurrent_vault_operations(self):
        """Test concurrent vault read/write operations."""
        vault = ResultVault(ttl=60, max_size=1000)
        errors = []
        
        def writer(start, end):
            try:
                for i in range(start, end):
                    vault.store(f"key{i}", f"value{i}")
            except Exception as e:
                errors.append(e)
        
        def reader(start, end):
            try:
                for i in range(start, end):
                    vault.get(f"key{i}")
            except Exception as e:
                errors.append(e)
        
        threads = []
        for i in range(10):
            threads.append(threading.Thread(target=writer, args=(i*100, (i+1)*100)))
            threads.append(threading.Thread(target=reader, args=(i*100, (i+1)*100)))
        
        for t in threads:
            t.start()
        for t in threads:
            t.join()
        
        assert len(errors) == 0
    
    def test_concurrent_group_operations(self):
        """Test concurrent task group operations."""
        with BackgroundEngine(workers=10) as engine:
            groups = []
            
            for _ in range(10):
                group = TaskGroup(engine)
                for i in range(10):
                    task = Task(func=lambda x: x * 2, args=[i])
                    group.add_task(task)
                groups.append(group)
            
            # Execute all groups concurrently
            threads = []
            results = {}
            
            def execute_group(idx, group):
                task_ids = list(group._nodes.keys())
                results[idx] = group.execute_sequential(task_ids)
            
            for idx, group in enumerate(groups):
                t = threading.Thread(target=execute_group, args=(idx, group))
                threads.append(t)
                t.start()
            
            for t in threads:
                t.join()
            
            assert len(results) == 10
    
    def test_race_condition_in_task_state(self):
        """Test for race conditions in task state transitions."""
        with BackgroundEngine(workers=10) as engine:
            task = Task(func=lambda: time.sleep(0.01))
            engine._submit_task(task)
            
            # Try to access task state from multiple threads
            states = []
            errors = []
            
            def check_state():
                try:
                    states.append(task.state)
                except Exception as e:
                    errors.append(e)
            
            threads = [threading.Thread(target=check_state) for _ in range(20)]
            for t in threads:
                t.start()
            for t in threads:
                t.join()
            
            assert len(errors) == 0
            task.wait(timeout=5)
    
    def test_scheduler_concurrent_scheduling(self):
        """Test concurrent scheduling operations."""
        with BackgroundEngine(workers=10) as engine:
            handles = []
            call_counts = {'count': 0}
            lock = threading.Lock()
            
            def callback():
                with lock:
                    call_counts['count'] += 1
            
            # Schedule many tasks concurrently
            threads = []
            for i in range(50):
                t = threading.Thread(
                    target=lambda: handles.append(engine.schedule(callback, delay=0.1))
                )
                threads.append(t)
                t.start()
            
            for t in threads:
                t.join()
            
            time.sleep(0.3)
            assert call_counts['count'] >= 40  # Most should have executed


class TestErrorHandling:
    """Test error handling and recovery."""
    
    def test_task_with_memory_error(self):
        """Test task that causes memory error."""
        with BackgroundEngine(workers=2) as engine:
            def memory_hog():
                # Try to allocate huge amount of memory
                try:
                    return "x" * (1024**3)  # 1GB
                except MemoryError:
                    return "caught_memory_error"
            
            task = engine.submit(memory_hog)
            result = task.wait(timeout=10)
            # Should either succeed or handle memory error gracefully
            assert result in ["caught_memory_error", None] or len(result) > 0
    
    def test_task_with_recursion_error(self):
        """Test task that causes recursion error."""
        with BackgroundEngine(workers=2) as engine:
            def deep_recursion(n=1000):
                if n > 0:
                    return deep_recursion(n - 1)
                return "done"
            
            task = engine.submit(deep_recursion)
            task.wait(timeout=5)
            assert task.state == TaskState.FAILED
            assert isinstance(task.error, RecursionError)
    
    def test_task_with_keyboard_interrupt(self):
        """Test task that raises KeyboardInterrupt."""
        with BackgroundEngine(workers=2) as engine:
            def raise_interrupt():
                raise KeyboardInterrupt()
            
            task = engine.submit(raise_interrupt)
            task.wait(timeout=5)
            # Should handle gracefully - task should be FAILED
            assert task.state == TaskState.FAILED
    
    def test_task_with_system_exit(self):
        """Test task that calls sys.exit."""
        with BackgroundEngine(workers=2) as engine:
            import sys
            def exit_task():
                sys.exit(1)
            
            task = engine.submit(exit_task)
            task.wait(timeout=5)
            # Should handle gracefully - task should be FAILED
            assert task.state == TaskState.FAILED
    
    def test_retry_with_always_failing_task(self):
        """Test retry policy with task that always fails."""
        policy = RetryPolicy(max_attempts=3, base_delay=0.01, max_delay=0.1)
        task = Task(
            func=lambda: 1/0,  # Always raises ZeroDivisionError
            retry_policy=policy
        )
        
        with BackgroundEngine(workers=2) as engine:
            engine._submit_task(task)
            task.wait(timeout=5)
            # Should have attempted 3 times
            assert task.attempt == 3
            assert task.state == TaskState.DEAD
    
    def test_vault_eviction_under_pressure(self):
        """Test vault eviction when under memory pressure."""
        vault = ResultVault(ttl=60, max_size=10)
        
        # Store more than max_size
        for i in range(100):
            vault.store(f"key{i}", f"value{i}")
        
        # Should have evicted old entries
        assert len(vault._store) <= 10
    
    def test_engine_shutdown_during_task_execution(self):
        """Test shutting down engine while tasks are running."""
        engine = BackgroundEngine(workers=5)
        
        def slow_task():
            time.sleep(1)
            return "done"
        
        tasks = [engine.submit(slow_task) for _ in range(10)]
        
        # Shutdown immediately
        engine.shutdown(wait=False)
        
        # Some tasks may not complete
        completed = 0
        for t in tasks:
            try:
                t.wait(timeout=0.5)
                completed += 1
            except:
                pass
        
        # At least some should have started
        assert completed < 10


class TestCronParserEdgeCases:
    """Test cron parser with edge cases."""
    
    def test_cron_at_boundary_values(self):
        """Test cron with boundary values."""
        # Minute at 0 and 59
        parser = CronParser("0 * * * *")
        assert 0 in parser.fields['minute'].values
        
        parser = CronParser("59 * * * *")
        assert 59 in parser.fields['minute'].values
        
        # Hour at 0 and 23
        parser = CronParser("* 0 * * *")
        assert 0 in parser.fields['hour'].values
        
        parser = CronParser("* 23 * * *")
        assert 23 in parser.fields['hour'].values
    
    def test_cron_with_all_wildcards(self):
        """Test cron with all wildcards."""
        parser = CronParser("* * * * *")
        assert parser.fields['minute'].is_wildcard
        assert parser.fields['hour'].is_wildcard
        assert parser.fields['day_of_month'].is_wildcard
        assert parser.fields['month'].is_wildcard
        assert parser.fields['day_of_week'].is_wildcard
    
    def test_cron_with_step_at_boundary(self):
        """Test cron with step at boundary."""
        parser = CronParser("*/60 * * * *")
        # Should handle gracefully (may be empty or wrap around)
        assert isinstance(parser.fields['minute'].values, set)
    
    def test_cron_next_run_at_year_boundary(self):
        """Test cron next run calculation at year boundary."""
        parser = CronParser("0 0 1 1 *")  # Midnight on Jan 1
        # Start from Dec 31, 23:59
        current = time.strptime("2023-12-31 23:59", "%Y-%m-%d %H:%M")
        current_dt = time.mktime(current)
        
        next_dt = parser.next_run_time(current_dt)
        # Should be Jan 1 of next year
        assert next_dt is not None
    
    def test_invalid_cron_expressions(self):
        """Test various invalid cron expressions."""
        invalid_expressions = [
            "",  # Empty
            "* * * *",  # Too few fields
            "* * * * * *",  # Too many fields
            "60 * * * *",  # Invalid minute (will be caught)
            "* 25 * * *",  # Invalid hour (will be caught)
            "abc * * * *",  # Non-numeric (will be caught)
        ]
        
        for expr in invalid_expressions:
            with pytest.raises(ValueError):
                CronParser(expr)


class TestTaskGroupEdgeCases:
    """Test task group with edge cases."""
    
    def test_group_with_single_task(self):
        """Test group with only one task."""
        with BackgroundEngine(workers=2) as engine:
            group = TaskGroup(engine)
            task = Task(func=lambda: 42)
            task_id = group.add_task(task)
            
            results = group.execute_sequential([task_id])
            assert results[task_id] == 42
    
    def test_group_with_circular_dependency(self):
        """Test group with circular dependency."""
        with BackgroundEngine(workers=2) as engine:
            group = TaskGroup(engine)
            
            task_a = Task(func=lambda: "a")
            task_b = Task(func=lambda: "b")
            task_c = Task(func=lambda: "c")
            
            group.add_task(task_a)
            group.add_task(task_b, dependencies=[task_a.id])
            group.add_task(task_c, dependencies=[task_b.id])
            group.add_dependency(task_a.id, task_c.id)  # Creates cycle
            
            with pytest.raises(ValueError, match="Circular dependency"):
                group._build_execution_plan()
    
    def test_group_with_self_dependency(self):
        """Test group with task depending on itself."""
        with BackgroundEngine(workers=2) as engine:
            group = TaskGroup(engine)
            task = Task(func=lambda: 42)
            task_id = group.add_task(task)
            
            # Try to add self-dependency
            group.add_dependency(task_id, task_id)
            
            # Should detect cycle
            with pytest.raises(ValueError, match="Circular dependency"):
                group._build_execution_plan()
    
    def test_group_with_many_dependencies(self):
        """Test group with many dependencies."""
        with BackgroundEngine(workers=10) as engine:
            group = TaskGroup(engine)
            
            # Create 10 independent tasks
            tasks = [Task(func=lambda x=i: x) for i in range(10)]
            task_ids = [group.add_task(t) for t in tasks]
            
            # Create one task that depends on all 10
            final_task = Task(func=lambda: "final")
            final_id = group.add_task(final_task, dependencies=task_ids)
            
            results = group.execute_sequential(task_ids + [final_id])
            assert results[final_id] == "final"
    
    def test_group_reset_during_execution(self):
        """Test resetting group during execution."""
        with BackgroundEngine(workers=2) as engine:
            group = TaskGroup(engine)
            
            task = Task(func=lambda: time.sleep(0.1))
            task_id = group.add_task(task)
            
            # Start execution in thread
            def execute():
                group.execute_sequential([task_id])
            
            t = threading.Thread(target=execute)
            t.start()
            
            # Reset while executing
            time.sleep(0.05)
            group.reset()
            
            t.join()
            # Should handle gracefully (may or may not complete)
    
    def test_group_with_failing_task(self):
        """Test group with task that fails."""
        with BackgroundEngine(workers=2) as engine:
            group = TaskGroup(engine)
            
            task_a = Task(func=lambda: "success")
            task_b = Task(func=lambda: 1/0)  # Will fail
            task_c = Task(func=lambda: "should_not_run")
            
            group.add_task(task_a)
            group.add_task(task_b, dependencies=[task_a.id])
            group.add_task(task_c, dependencies=[task_b.id])
            
            results = group.execute_sequential([task_a.id, task_b.id, task_c.id])
            
            # First task should succeed
            assert results[task_a.id] == "success"
            # Second task should fail
            assert task_b.state == TaskState.FAILED
            # Third task should not have run
            assert task_c.state == TaskState.PENDING


class TestResourceExhaustion:
    """Test behavior under resource exhaustion."""
    
    def test_many_concurrent_tasks(self):
        """Test submitting many concurrent tasks."""
        with BackgroundEngine(workers=20) as engine:
            tasks = []
            for i in range(1000):
                tasks.append(engine.submit(lambda x: x * 2, i))
            
            results = [t.wait(timeout=30) for t in tasks]
            assert len(results) == 1000
            assert all(r % 2 == 0 for r in results)
    
    def test_many_vault_entries(self):
        """Test vault with many entries."""
        vault = ResultVault(ttl=60, max_size=10000)
        
        for i in range(5000):
            vault.store(f"key{i}", f"value{i}")
        
        # Should not exceed max_size
        assert len(vault._store) <= 10000
    
    def test_many_scheduled_tasks(self):
        """Test scheduler with many scheduled tasks."""
        with BackgroundEngine(workers=10) as engine:
            handles = []
            call_count = {'count': 0}
            lock = threading.Lock()
            
            def callback():
                with lock:
                    call_count['count'] += 1
            
            # Schedule 100 tasks
            for i in range(100):
                handles.append(engine.schedule(callback, delay=0.01))
            
            time.sleep(0.5)
            # Most should have executed
            assert call_count['count'] >= 80
    
    def test_many_task_groups(self):
        """Test creating many task groups."""
        with BackgroundEngine(workers=10) as engine:
            groups = []
            
            for _ in range(100):
                group = TaskGroup(engine)
                for i in range(10):
                    task = Task(func=lambda x: x, args=[i])
                    group.add_task(task)
                groups.append(group)
            
            # Should handle all groups
            assert len(groups) == 100


class TestTimingIssues:
    """Test timing-related issues."""
    
    def test_task_with_exact_timeout(self):
        """Test task with timeout exactly matching execution time."""
        with BackgroundEngine(workers=2) as engine:
            def slow_task():
                time.sleep(0.1)
                return "done"
            
            task = Task(func=slow_task, timeout=0.1)
            engine._submit_task(task)
            
            # May or may not complete due to timing
            try:
                result = task.wait(timeout=5)
                assert result == "done"
            except TimeoutError:
                pass  # Acceptable
    
    def test_scheduler_with_zero_delay(self):
        """Test scheduling with zero delay."""
        with BackgroundEngine(workers=2) as engine:
            call_count = {'count': 0}
            
            def callback():
                call_count['count'] += 1
            
            handle = engine.schedule(callback, delay=0)
            time.sleep(0.5)  # Give more time for execution
            
            # Should execute immediately
            assert call_count['count'] >= 1
    
    def test_scheduler_with_negative_delay(self):
        """Test scheduling with negative delay (should execute immediately)."""
        with BackgroundEngine(workers=2) as engine:
            call_count = {'count': 0}
            
            def callback():
                call_count['count'] += 1
            
            handle = engine.schedule(callback, delay=-1)
            time.sleep(0.5)  # Give more time for execution
            
            # Should execute immediately
            assert call_count['count'] >= 1
    
    def test_interval_with_very_small_delay(self):
        """Test interval scheduling with very small delay."""
        with BackgroundEngine(workers=5) as engine:
            call_count = {'count': 0}
            
            def callback():
                call_count['count'] += 1
            
            handle = engine.every(callback, interval=0.01)
            time.sleep(0.5)  # Give more time for multiple executions
            handle.cancel()
            
            # Should have executed multiple times
            assert call_count['count'] >= 10
    
    def test_vault_entry_expiration_timing(self):
        """Test vault entry expiration timing."""
        vault = ResultVault(ttl=0.1, max_size=100)
        
        vault.store("key1", "value1")
        assert vault.get("key1") == "value1"
        
        time.sleep(0.15)
        assert vault.get("key1") is None


class TestInvalidInputs:
    """Test with invalid inputs."""
    
    def test_submit_non_callable(self):
        """Test submitting non-callable."""
        with BackgroundEngine(workers=2) as engine:
            with pytest.raises(TypeError):
                engine.submit("not a function")
    
    def test_task_with_invalid_priority(self):
        """Test task with invalid priority."""
        with pytest.raises(TypeError):
            Task(func=lambda: None, priority="invalid")
    
    def test_task_with_invalid_state(self):
        """Test task with invalid state."""
        task = Task(func=lambda: None)
        with pytest.raises(ValueError, match="Invalid state transition"):
            task.transition_to(TaskState.SUCCESS)  # Can't go from PENDING to SUCCESS
    
    def test_vault_with_invalid_key(self):
        """Test vault with invalid key types."""
        vault = ResultVault(ttl=60, max_size=100)
        
        # These should work or raise appropriate errors
        try:
            vault.store(123, "value")  # Non-string key
            result = vault.get(123)
        except TypeError:
            pass  # Acceptable
    
    def test_group_with_invalid_task_id(self):
        """Test group with non-existent task ID."""
        with BackgroundEngine(workers=2) as engine:
            group = TaskGroup(engine)
            
            with pytest.raises(ValueError, match="not found"):
                group.execute_sequential(["non_existent_id"])
    
    def test_cron_with_invalid_expression(self):
        """Test cron with various invalid expressions."""
        invalid = [
            None,
            123,
            "",
            "invalid",
        ]
        
        for expr in invalid:
            with pytest.raises((ValueError, TypeError, AttributeError)):
                CronParser(expr)


# Run all stress tests
if __name__ == "__main__":
    pytest.main([__file__, "-v", "--tb=short"])
