"""Real Performance Use Case Tests.

This module tests actual performance scenarios to verify
performance characteristics of the Truthound framework.
"""

from __future__ import annotations

import gc
import sys
import time
from pathlib import Path

import polars as pl
import pytest

# Add src to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))

from truthound import check, learn, profile
from truthound.benchmark.base import BenchmarkConfig, BenchmarkSize
from truthound.benchmark.generators import (
    TabularDataGenerator,
    FinancialDataGenerator,
    GeneratorConfig,
)
from truthound.benchmark.runner import BenchmarkRunner, BenchmarkSuite, RunnerConfig
from truthound.validators import BUILTIN_VALIDATORS, get_validator
from truthound.validators.optimization.orchestrator import (
    ValidatorDAG,
    ParallelExecutionStrategy,
    SequentialExecutionStrategy,
    AdaptiveExecutionStrategy,
    create_execution_plan,
    execute_validators,
)
from truthound.validators.optimization.profiling import (
    ValidatorProfiler,
    ProfilerConfig,
    ProfilerMode,
    profile_validator,
    get_default_profiler,
)
from truthound.stores.caching.backends import LRUCache, LFUCache, TTLCache
from truthound.stores.caching.base import CacheConfig
from truthound.stores.batching.buffer import BatchBuffer, MemoryAwareBuffer
from truthound.stores.batching.writer import BatchWriter


def generate_tabular_data(row_count: int, seed: int | None = None) -> pl.DataFrame:
    """Generate tabular test data."""
    generator = TabularDataGenerator()
    config = GeneratorConfig(row_count=row_count, seed=seed)
    return generator.generate(config)


class TestPerformanceTargets:
    """Test performance targets from CLAUDE.md."""

    @pytest.fixture
    def small_data(self) -> pl.LazyFrame:
        """Generate 100K rows for quick tests."""
        return generate_tabular_data(100_000).lazy()

    @pytest.fixture
    def medium_data(self) -> pl.LazyFrame:
        """Generate 1M rows for medium tests."""
        return generate_tabular_data(1_000_000).lazy()

    def test_profile_performance_100k(self, small_data: pl.LazyFrame) -> None:
        """Test profile() performance with 100K rows.

        Target: < 0.2s for 10M rows, so < 0.02s for 100K rows.
        """
        gc.collect()
        start = time.perf_counter()

        result = profile(small_data)

        duration = time.perf_counter() - start

        assert result is not None
        assert duration < 1.0, f"profile() took {duration:.3f}s, expected < 1.0s for 100K rows"
        print(f"\nprofile(100K rows): {duration:.3f}s")

    def test_learn_performance_100k(self, small_data: pl.LazyFrame) -> None:
        """Test learn() performance with 100K rows.

        Target: < 0.3s for 10M rows, so < 0.03s for 100K rows.
        """
        gc.collect()
        start = time.perf_counter()

        schema = learn(small_data)

        duration = time.perf_counter() - start

        assert schema is not None
        assert len(schema.columns) > 0
        assert duration < 2.0, f"learn() took {duration:.3f}s, expected < 2.0s for 100K rows"
        print(f"\nlearn(100K rows): {duration:.3f}s, columns: {len(schema.columns)}")

    def test_check_performance_100k(self, small_data: pl.LazyFrame) -> None:
        """Test check() performance with 100K rows.

        Target: < 10s for 10M rows, so < 1s for 100K rows.
        """
        schema = learn(small_data)

        gc.collect()
        start = time.perf_counter()

        result = check(small_data, schema=schema)

        duration = time.perf_counter() - start

        assert result is not None
        assert duration < 10.0, f"check() took {duration:.3f}s, expected < 10.0s for 100K rows"
        print(f"\ncheck(100K rows): {duration:.3f}s, issues: {len(result.issues)}")

    def test_memory_usage_100k(self, small_data: pl.LazyFrame) -> None:
        """Test memory usage stays reasonable for 100K rows."""
        import tracemalloc

        tracemalloc.start()

        schema = learn(small_data)
        _ = check(small_data, schema=schema)

        current, peak = tracemalloc.get_traced_memory()
        tracemalloc.stop()

        peak_mb = peak / (1024 * 1024)

        # Target: < 2GB for 10M rows, so < 200MB for 100K rows
        assert peak_mb < 500, f"Peak memory {peak_mb:.1f}MB, expected < 500MB"
        print(f"\nPeak memory (100K rows): {peak_mb:.1f}MB")


class TestDAGExecutionPerformance:
    """Test DAG-based execution performance."""

    @pytest.fixture
    def test_data(self) -> pl.LazyFrame:
        """Generate test data."""
        return generate_tabular_data(50_000).lazy()

    @pytest.fixture
    def validators(self, test_data: pl.LazyFrame):
        """Get validators from built-in validators."""
        # Get a subset of built-in validators (instantiated)
        validator_names = list(BUILTIN_VALIDATORS.keys())[:20]
        return [get_validator(name)() for name in validator_names]

    def test_sequential_vs_parallel_execution(
        self, test_data: pl.LazyFrame, validators
    ) -> None:
        """Compare sequential vs parallel execution performance."""
        if len(validators) < 5:
            pytest.skip("Need at least 5 validators for meaningful comparison")

        # Create execution plan
        plan = create_execution_plan(validators[:20])

        # Sequential execution
        sequential_strategy = SequentialExecutionStrategy()
        gc.collect()
        start = time.perf_counter()
        seq_result = plan.execute(test_data, sequential_strategy)
        seq_duration = time.perf_counter() - start

        # Parallel execution
        parallel_strategy = ParallelExecutionStrategy(max_workers=4)
        gc.collect()
        start = time.perf_counter()
        par_result = plan.execute(test_data, parallel_strategy)
        par_duration = time.perf_counter() - start

        print(f"\nSequential: {seq_duration:.3f}s, Parallel: {par_duration:.3f}s")
        print(f"Speedup: {seq_duration / par_duration:.2f}x")
        print(f"Parallelism factor: {par_result.get_metrics()['parallelism_factor']:.2f}")

        # Parallel should not be significantly slower
        assert par_duration < seq_duration * 1.5, "Parallel execution too slow"

    def test_adaptive_strategy_selection(
        self, test_data: pl.LazyFrame, validators
    ) -> None:
        """Test adaptive strategy chooses appropriate execution mode."""
        if len(validators) < 3:
            pytest.skip("Need at least 3 validators")

        # Create plan with few validators
        plan = create_execution_plan(validators[:2])
        adaptive = AdaptiveExecutionStrategy(parallel_threshold=3)

        gc.collect()
        result = plan.execute(test_data, adaptive)

        # With 2 validators, should use sequential
        assert result.strategy_name == "adaptive"
        print(f"\nAdaptive with 2 validators: completed in {result.total_duration_ms:.2f}ms")

        # Create plan with more validators
        if len(validators) >= 5:
            plan = create_execution_plan(validators[:5])
            result = plan.execute(test_data, adaptive)
            print(f"Adaptive with 5 validators: completed in {result.total_duration_ms:.2f}ms")

    def test_dag_cycle_handling(self, test_data: pl.LazyFrame, validators) -> None:
        """Test DAG handles cycle detection gracefully."""
        if len(validators) < 3:
            pytest.skip("Need at least 3 validators")

        dag = ValidatorDAG()

        # Add validators with circular dependency
        v1 = validators[0]
        v2 = validators[1]
        v3 = validators[2]

        dag.add_validator(v1, dependencies={v3.name})
        dag.add_validator(v2, dependencies={v1.name})
        dag.add_validator(v3, dependencies={v2.name})

        # Should handle cycle gracefully
        plan = dag.build_execution_plan()

        assert plan.has_cycles, "Should detect cycle"
        assert plan.total_nodes == 3

        # Should still execute (fallback to phase ordering)
        result = plan.execute(test_data)
        assert result.total_validators == 3
        print(f"\nCycle detected, fallback execution: {result.total_duration_ms:.2f}ms")


class TestValidatorProfilingPerformance:
    """Test validator profiling performance."""

    @pytest.fixture
    def test_data(self) -> pl.LazyFrame:
        """Generate test data."""
        return generate_tabular_data(10_000).lazy()

    @pytest.fixture
    def validators(self, test_data: pl.LazyFrame):
        """Get validators from built-in validators."""
        validator_names = list(BUILTIN_VALIDATORS.keys())[:10]
        # Instantiate validators (get_validator returns classes)
        return [get_validator(name)() for name in validator_names]

    def test_profiling_overhead(self, test_data: pl.LazyFrame, validators) -> None:
        """Test profiling overhead is minimal."""
        if len(validators) < 5:
            pytest.skip("Need at least 5 validators")

        # Run without profiling
        profiler_disabled = ValidatorProfiler(
            ProfilerConfig(mode=ProfilerMode.DISABLED)
        )

        gc.collect()
        start = time.perf_counter()
        for v in validators:
            with profiler_disabled.profile(v.name, row_count=10000):
                v.validate(test_data)
        no_profile_duration = time.perf_counter() - start

        # Run with basic profiling
        profiler_basic = ValidatorProfiler(
            ProfilerConfig(mode=ProfilerMode.BASIC)
        )

        gc.collect()
        start = time.perf_counter()
        for v in validators:
            with profiler_basic.profile(v.name, row_count=10000):
                v.validate(test_data)
        basic_profile_duration = time.perf_counter() - start

        overhead = (basic_profile_duration - no_profile_duration) / no_profile_duration * 100

        print(f"\nWithout profiling: {no_profile_duration:.4f}s")
        print(f"With basic profiling: {basic_profile_duration:.4f}s")
        print(f"Overhead: {overhead:.1f}%")

        # Profiling overhead should be < 100%
        assert overhead < 200, f"Profiling overhead {overhead:.1f}% too high"

    def test_profiler_metrics_accuracy(self, test_data: pl.LazyFrame, validators) -> None:
        """Test profiler captures accurate metrics."""
        profiler = ValidatorProfiler(
            ProfilerConfig(mode=ProfilerMode.DETAILED)
        )

        profiler.start_session("test_session")

        for v in validators[:5]:
            with profiler.profile(v.name, row_count=10000):
                v.validate(test_data)

        profiler.end_session()

        # Check metrics
        summary = profiler.summary()

        # Summary should have some data (key names may vary)
        assert summary is not None
        assert len(summary) > 0

        print(f"\nProfiler summary: {summary}")

    def test_slowest_validators_detection(self, test_data: pl.LazyFrame, validators) -> None:
        """Test detection of slowest validators."""
        profiler = ValidatorProfiler(
            ProfilerConfig(mode=ProfilerMode.STANDARD)
        )

        if len(validators) < 5:
            pytest.skip("Need at least 5 validators")

        for v in validators:
            with profiler.profile(v.name, row_count=10000):
                v.validate(test_data)

        slowest = profiler.get_slowest_validators(n=3)

        assert len(slowest) <= 3

        if len(slowest) >= 2:
            # Should be ordered by duration (slowest first)
            durations = [s[1] for s in slowest]
            assert durations == sorted(durations, reverse=True)

        print(f"\nSlowest validators: {slowest}")


class TestCachingPerformance:
    """Test caching layer performance."""

    def test_lru_cache_hit_rate(self) -> None:
        """Test LRU cache achieves good hit rate."""
        cache = LRUCache(max_size=100, ttl_seconds=3600.0)

        # Populate cache
        for i in range(100):
            cache.set(f"key_{i}", f"value_{i}")

        # Access pattern: recent items more often
        hits = 0
        misses = 0

        gc.collect()
        start = time.perf_counter()

        import random
        for _ in range(10000):
            # 80% recent items, 20% old items
            if random.random() < 0.8:
                key = f"key_{random.randint(80, 99)}"
            else:
                key = f"key_{random.randint(0, 79)}"

            result = cache.get(key)
            if result is not None:
                hits += 1
            else:
                misses += 1

        duration = time.perf_counter() - start
        hit_rate = hits / (hits + misses) * 100
        ops_per_sec = 10000 / duration

        print(f"\nLRU Cache: {hit_rate:.1f}% hit rate, {ops_per_sec:.0f} ops/sec")

        assert hit_rate > 50, f"Hit rate {hit_rate:.1f}% too low"
        assert ops_per_sec > 10000, f"Too slow: {ops_per_sec:.0f} ops/sec"

    def test_lfu_cache_hit_rate(self) -> None:
        """Test LFU cache achieves good hit rate."""
        cache = LFUCache(max_size=100, ttl_seconds=3600.0)

        # Populate cache
        for i in range(100):
            cache.set(f"key_{i}", f"value_{i}")

        # Access some keys frequently
        for i in range(80, 100):
            for _ in range(10):
                cache.get(f"key_{i}")

        hits = 0
        misses = 0

        gc.collect()
        start = time.perf_counter()

        import random
        for _ in range(10000):
            # 80% frequently accessed, 20% rarely accessed
            if random.random() < 0.8:
                key = f"key_{random.randint(80, 99)}"
            else:
                key = f"key_{random.randint(0, 79)}"

            result = cache.get(key)
            if result is not None:
                hits += 1
            else:
                misses += 1

        duration = time.perf_counter() - start
        hit_rate = hits / (hits + misses) * 100
        ops_per_sec = 10000 / duration

        print(f"\nLFU Cache: {hit_rate:.1f}% hit rate, {ops_per_sec:.0f} ops/sec")

        assert hit_rate > 50, f"Hit rate {hit_rate:.1f}% too low"

    def test_ttl_cache_expiration(self) -> None:
        """Test TTL cache expires items correctly."""
        cache = TTLCache(max_size=100, ttl_seconds=0.1)

        # Set items
        for i in range(10):
            cache.set(f"key_{i}", f"value_{i}")

        # Should be available immediately
        assert cache.get("key_0") is not None

        # Wait for expiration
        time.sleep(0.15)

        # Should be expired
        assert cache.get("key_0") is None

        print("\nTTL Cache: expiration works correctly")


class TestBatchingPerformance:
    """Test batch processing performance."""

    def test_batch_buffer_throughput(self) -> None:
        """Test batch buffer throughput."""
        buffer = BatchBuffer(max_size=1000)

        gc.collect()
        start = time.perf_counter()

        # Add items
        for i in range(10000):
            if buffer.is_full:
                buffer.drain_all()
            buffer.add({"id": i, "data": f"value_{i}"})

        # Drain remaining
        buffer.drain_all()

        duration = time.perf_counter() - start
        throughput = 10000 / duration

        print(f"\nBatch buffer: {throughput:.0f} items/sec")

        assert throughput > 10000, f"Too slow: {throughput:.0f} items/sec"

    def test_memory_aware_buffer(self) -> None:
        """Test memory-aware buffer respects limits."""
        buffer = MemoryAwareBuffer(
            max_size=1000,
            max_memory_mb=1.0,  # 1MB
        )

        items_added = 0

        gc.collect()
        start = time.perf_counter()

        # Add items until memory limit
        for i in range(100000):
            item = {"id": i, "data": "x" * 100}  # ~100 bytes per item

            if not buffer.can_add(item):
                buffer.drain_all()

            if buffer.add(item):
                items_added += 1
            else:
                break

        duration = time.perf_counter() - start

        print(f"\nMemory-aware buffer:")
        print(f"  Items added: {items_added}")
        print(f"  Duration: {duration:.3f}s")

        # Should have processed many items
        assert items_added > 1000

    def test_batch_writer_throughput(self) -> None:
        """Test batch writer throughput."""
        from truthound.stores.batching.writer import BatchConfig

        written_batches = []

        def write_func(batch: list) -> None:
            written_batches.append(batch)

        config = BatchConfig(batch_size=100)
        writer = BatchWriter(
            write_func=write_func,
            config=config,
        )

        gc.collect()
        start = time.perf_counter()

        with writer:
            for i in range(10000):
                writer.add({"id": i, "data": f"value_{i}"})

        duration = time.perf_counter() - start
        throughput = 10000 / duration

        print(f"\nBatch writer: {throughput:.0f} items/sec")
        print(f"  Batches written: {len(written_batches)}")

        # Should have written ~100 batches
        assert len(written_batches) >= 90
        assert throughput > 10000


class TestBenchmarkSystemPerformance:
    """Test the benchmark system itself."""

    def test_benchmark_runner_quick_suite(self) -> None:
        """Test running quick benchmark suite."""
        config = RunnerConfig.quick()
        runner = BenchmarkRunner(config=config)
        suite = BenchmarkSuite.quick()

        gc.collect()
        start = time.perf_counter()

        results = runner.run_suite(suite)

        duration = time.perf_counter() - start

        print(f"\nQuick benchmark suite:")
        print(f"  Duration: {duration:.2f}s")
        print(f"  Benchmarks: {results.total_benchmarks}")
        print(f"  Success rate: {results.success_rate * 100:.0f}%")

        assert results.success_rate >= 0.5, "Too many benchmark failures"
        assert duration < 60, "Quick suite took too long"

    def test_single_benchmark_run(self) -> None:
        """Test running a single benchmark."""
        runner = BenchmarkRunner()

        gc.collect()
        start = time.perf_counter()

        result = runner.run("profile", row_count=10000)

        duration = time.perf_counter() - start

        print(f"\nProfile benchmark (10K rows):")
        print(f"  Duration: {duration:.3f}s")
        print(f"  Mean: {result.metrics.mean_duration:.4f}s")

        assert result.success

    def test_data_generator_performance(self) -> None:
        """Test data generator performance."""
        generator = TabularDataGenerator()

        # Test different sizes
        sizes = [10_000, 100_000, 500_000]

        for size in sizes:
            gc.collect()
            start = time.perf_counter()

            config = GeneratorConfig(row_count=size)
            df = generator.generate(config)

            duration = time.perf_counter() - start
            rows_per_sec = size / duration

            print(f"\nGenerate {size:,} rows: {duration:.3f}s ({rows_per_sec:.0f} rows/sec)")

            assert len(df) == size
            assert rows_per_sec > 10000, f"Too slow: {rows_per_sec:.0f} rows/sec"


class TestLazyLoadingPerformance:
    """Test lazy loading performance."""

    def test_profiler_module_lazy_loading(self) -> None:
        """Test profiler module loads lazily."""
        import importlib
        import sys

        # Remove cached modules
        modules_to_remove = [k for k in list(sys.modules.keys()) if 'truthound.profiler' in k]
        for mod in modules_to_remove:
            if mod in sys.modules:
                del sys.modules[mod]

        gc.collect()
        start = time.perf_counter()

        # Import profiler module
        from truthound import profiler

        initial_import = time.perf_counter() - start

        # Access an attribute (should trigger lazy load)
        start = time.perf_counter()
        _ = profiler.DataProfiler
        first_access = time.perf_counter() - start

        # Access again (should be cached)
        start = time.perf_counter()
        _ = profiler.DataProfiler
        second_access = time.perf_counter() - start

        print(f"\nLazy loading performance:")
        print(f"  Initial import: {initial_import:.4f}s")
        print(f"  First access: {first_access:.4f}s")
        print(f"  Second access: {second_access:.6f}s")

        # Second access should be much faster (cached)
        assert second_access < first_access or second_access < 0.001


class TestQueryPushdownPerformance:
    """Test query pushdown optimization."""

    def test_polars_lazy_evaluation(self) -> None:
        """Test Polars lazy evaluation performance."""
        # Create eager DataFrame
        df = generate_tabular_data(100_000)

        # Get available columns
        columns = df.columns[:2]  # Use first 2 columns

        # Create lazy query
        lazy = df.lazy()

        gc.collect()

        # Lazy operation (should be instant)
        start = time.perf_counter()
        result_lazy = lazy.filter(pl.col("id") > 50000).select(columns)
        lazy_build_time = time.perf_counter() - start

        # Collect (actual execution)
        start = time.perf_counter()
        _ = result_lazy.collect()
        collect_time = time.perf_counter() - start

        print(f"\nPolars lazy evaluation:")
        print(f"  Query build: {lazy_build_time:.6f}s")
        print(f"  Collect: {collect_time:.4f}s")

        # Query building should be near-instant
        assert lazy_build_time < 0.01, "Lazy query building too slow"


class TestScalabilityPerformance:
    """Test scalability with different data sizes."""

    def test_scaling_with_row_count(self) -> None:
        """Test that performance scales reasonably with data size."""
        sizes = [10_000, 50_000, 100_000]
        times = {}

        for size in sizes:
            gc.collect()
            data = generate_tabular_data(size).lazy()

            start = time.perf_counter()
            schema = learn(data)
            _ = check(data, schema=schema)
            duration = time.perf_counter() - start

            times[size] = duration
            print(f"\n{size:,} rows: {duration:.3f}s")

        # Check that scaling is roughly linear (not exponential)
        # 10x data should take roughly 10x time (with some overhead)
        if 10_000 in times and 100_000 in times:
            ratio = times[100_000] / times[10_000]
            print(f"\n10x data scaling factor: {ratio:.2f}x (ideal: 10x)")
            # Allow up to 30x due to overhead
            assert ratio < 30, f"Scaling too poor: {ratio:.2f}x for 10x data"

    def test_column_count_scaling(self) -> None:
        """Test performance with varying column counts."""
        row_count = 10_000
        generator = TabularDataGenerator()

        for num_cols in [5, 10, 20]:
            gc.collect()

            # Generate data with specific column count
            config = GeneratorConfig(row_count=row_count)
            df = generator.generate(config)

            # Select subset of columns
            cols = df.columns[:num_cols] if len(df.columns) > num_cols else df.columns
            data = df.select(cols).lazy()

            start = time.perf_counter()
            schema = learn(data)
            _ = check(data, schema=schema)
            duration = time.perf_counter() - start

            print(f"\n{num_cols} columns: {duration:.3f}s, schema columns: {len(schema.columns)}")


if __name__ == "__main__":
    pytest.main([__file__, "-v", "-s"])
