"""Performance tests for Lifetime Predictor components.

This module tests performance characteristics:
- Prediction latency (target < 0.1ms)
- Feature extraction latency (target < 0.05ms)
- Scheduling overhead (target < 5%)
- Memory footprint (history should not grow unbounded)
"""

from __future__ import annotations

import gc
import sys
import time
from datetime import datetime

import pytest

from sagellm_control.predictor.evaluator import PredictionEvaluator
from sagellm_control.predictor.feature_extractor import FeatureExtractor
from sagellm_control.predictor.heuristic import (
    HistoricalPredictor,
    HybridHeuristicPredictor,
    LengthBasedPredictor,
)
from sagellm_control.predictor.types import RequestInfo


@pytest.mark.performance
class TestPredictionLatency:
    """Test prediction latency requirements."""

    @pytest.fixture
    def sample_request(self) -> RequestInfo:
        """Create a sample request for testing."""
        return RequestInfo(
            request_id="req_perf_test",
            model_name="Qwen2-7B",
            prompt_length=200,
            max_output_length=100,
            priority=2,
            temperature=0.8,
            top_p=0.95,
        )

    def _measure_average_latency(
        self, predictor, request: RequestInfo, iterations: int = 10000
    ) -> float:
        """Measure average prediction latency."""
        # Warmup
        for _ in range(100):
            predictor.predict(request)

        # Measure
        gc.collect()
        start = time.perf_counter()
        for _ in range(iterations):
            predictor.predict(request)
        end = time.perf_counter()

        avg_time_ms = ((end - start) / iterations) * 1000
        return avg_time_ms

    def test_length_based_predictor_latency(self, sample_request: RequestInfo) -> None:
        """Test LengthBasedPredictor prediction latency (target < 0.1ms)."""
        predictor = LengthBasedPredictor()

        avg_latency = self._measure_average_latency(predictor, sample_request)

        print(f"\n[LengthBasedPredictor] Average latency: {avg_latency:.6f} ms")
        assert avg_latency < 0.1, f"Latency too high: {avg_latency:.6f} ms (target: < 0.1 ms)"

    def test_historical_predictor_latency_no_history(self, sample_request: RequestInfo) -> None:
        """Test HistoricalPredictor latency with no history (fallback mode)."""
        predictor = HistoricalPredictor()

        avg_latency = self._measure_average_latency(predictor, sample_request)

        print(f"\n[HistoricalPredictor - No History] Average latency: {avg_latency:.6f} ms")
        assert avg_latency < 0.1, f"Latency too high: {avg_latency:.6f} ms (target: < 0.1 ms)"

    def test_historical_predictor_latency_with_history(self, sample_request: RequestInfo) -> None:
        """Test HistoricalPredictor latency with full history."""
        predictor = HistoricalPredictor({"window_size": 100, "split_by_type": False})

        # Fill history
        for i in range(100):
            predictor.update(f"req_{i}", 3.0 + (i % 10) * 0.1)

        avg_latency = self._measure_average_latency(predictor, sample_request)

        print(f"\n[HistoricalPredictor - Full History] Average latency: {avg_latency:.6f} ms")
        assert avg_latency < 0.1, f"Latency too high: {avg_latency:.6f} ms (target: < 0.1 ms)"

    def test_hybrid_predictor_latency_no_history(self, sample_request: RequestInfo) -> None:
        """Test HybridHeuristicPredictor latency with no history."""
        predictor = HybridHeuristicPredictor()

        avg_latency = self._measure_average_latency(predictor, sample_request)

        print(f"\n[HybridPredictor - No History] Average latency: {avg_latency:.6f} ms")
        assert avg_latency < 0.1, f"Latency too high: {avg_latency:.6f} ms (target: < 0.1 ms)"

    def test_hybrid_predictor_latency_with_history(self, sample_request: RequestInfo) -> None:
        """Test HybridHeuristicPredictor latency with full history."""
        predictor = HybridHeuristicPredictor()

        # Fill history
        for i in range(100):
            predictor.update(f"req_{i}", 3.0 + (i % 10) * 0.1)

        avg_latency = self._measure_average_latency(predictor, sample_request)

        print(f"\n[HybridPredictor - Full History] Average latency: {avg_latency:.6f} ms")
        assert avg_latency < 0.1, f"Latency too high: {avg_latency:.6f} ms (target: < 0.1 ms)"

    def test_predict_detailed_latency(self, sample_request: RequestInfo) -> None:
        """Test predict_detailed() latency (includes metadata generation)."""
        predictor = HybridHeuristicPredictor()

        # Warmup
        for _ in range(100):
            predictor.predict_detailed(sample_request)

        # Measure
        iterations = 10000
        gc.collect()
        start = time.perf_counter()
        for _ in range(iterations):
            predictor.predict_detailed(sample_request)
        end = time.perf_counter()

        avg_latency = ((end - start) / iterations) * 1000

        print(f"\n[predict_detailed()] Average latency: {avg_latency:.6f} ms")
        assert avg_latency < 0.15, f"Latency too high: {avg_latency:.6f} ms (target: < 0.15 ms)"

    def test_latency_p99(self, sample_request: RequestInfo) -> None:
        """Test P99 latency (99th percentile)."""
        predictor = HybridHeuristicPredictor()

        # Fill history
        for i in range(100):
            predictor.update(f"req_{i}", 3.0)

        # Measure individual predictions
        iterations = 10000
        latencies = []

        for _ in range(iterations):
            start = time.perf_counter()
            predictor.predict(sample_request)
            end = time.perf_counter()
            latencies.append((end - start) * 1000)

        # Calculate P99
        latencies.sort()
        p99_latency = latencies[int(0.99 * len(latencies))]

        print(f"\n[P99 Latency] {p99_latency:.6f} ms")
        assert p99_latency < 0.2, f"P99 latency too high: {p99_latency:.6f} ms (target: < 0.2 ms)"


@pytest.mark.performance
class TestFeatureExtractionLatency:
    """Test feature extraction latency requirements."""

    @pytest.fixture
    def sample_request(self) -> RequestInfo:
        """Create a sample request for testing."""
        return RequestInfo(
            request_id="req_feature_test",
            model_name="Qwen2-7B",
            prompt_length=200,
            max_output_length=100,
            priority=2,
            arrival_time=datetime.now(),
            temperature=0.8,
            top_p=0.95,
            metadata={"user_id": "test_user", "extra_field": "value"},
        )

    def test_feature_extraction_latency(self, sample_request: RequestInfo) -> None:
        """Test feature extraction latency (target < 0.05ms)."""
        extractor = FeatureExtractor()

        # Warmup
        for _ in range(100):
            extractor.extract(sample_request)

        # Measure
        iterations = 10000
        gc.collect()
        start = time.perf_counter()
        for _ in range(iterations):
            extractor.extract(sample_request)
        end = time.perf_counter()

        avg_latency = ((end - start) / iterations) * 1000

        print(f"\n[Feature Extraction] Average latency: {avg_latency:.6f} ms")
        assert avg_latency < 0.05, f"Latency too high: {avg_latency:.6f} ms (target: < 0.05 ms)"

    def test_batch_extraction_latency(self, sample_request: RequestInfo) -> None:
        """Test batch feature extraction latency."""
        extractor = FeatureExtractor()
        batch_size = 32
        requests = [sample_request] * batch_size

        # Warmup
        for _ in range(10):
            extractor.extract_batch(requests)

        # Measure
        iterations = 1000
        gc.collect()
        start = time.perf_counter()
        for _ in range(iterations):
            extractor.extract_batch(requests)
        end = time.perf_counter()

        total_extractions = iterations * batch_size
        avg_latency_per_request = ((end - start) / total_extractions) * 1000

        print(
            f"\n[Batch Feature Extraction] Average latency per request: {avg_latency_per_request:.6f} ms"
        )
        assert avg_latency_per_request < 0.05, f"Latency too high: {avg_latency_per_request:.6f} ms"


@pytest.mark.performance
class TestSchedulingOverhead:
    """Test scheduling overhead with predictor enabled."""

    @pytest.fixture
    def requests(self) -> list[RequestInfo]:
        """Create a list of sample requests."""
        return [
            RequestInfo(
                request_id=f"req_{i}",
                model_name="Qwen2-7B",
                prompt_length=100 + i * 10,
                max_output_length=50 + i * 5,
                priority=i % 5,
            )
            for i in range(100)
        ]

    def test_baseline_scheduling_time(self, requests: list[RequestInfo]) -> None:
        """Measure baseline scheduling time without prediction."""
        # Simple baseline: just iterate and compute priority
        iterations = 1000

        gc.collect()
        start = time.perf_counter()
        for _ in range(iterations):
            # Simulate more realistic scheduling logic
            priorities = []
            for req in requests:
                # Calculate a simple priority score
                total_tokens = req.prompt_length + req.max_output_length
                priority_score = float(req.priority) * 100 + total_tokens * 0.01
                priorities.append((req, priority_score))
            # Sort by priority
            _ = sorted(priorities, key=lambda x: x[1], reverse=True)
        end = time.perf_counter()

        baseline_ms = ((end - start) / iterations) * 1000
        print(f"\n[Baseline Scheduling] Time: {baseline_ms:.6f} ms")

        # Don't return, just print for reference

    def test_scheduling_with_prediction_overhead(self, requests: list[RequestInfo]) -> None:
        """Test that prediction adds acceptable overhead to scheduling.

        The goal is not to have minimal overhead, but to ensure that:
        1. Prediction latency per request is acceptable (< 0.1ms)
        2. Batch scheduling time is reasonable (< 1ms per 100 requests)
        """
        predictor = HybridHeuristicPredictor()

        # Measure prediction + sorting time for 100 requests
        iterations = 1000
        num_requests = len(requests)

        gc.collect()
        start = time.perf_counter()
        for _ in range(iterations):
            predictions = []
            for req in requests:
                lifetime = predictor.predict(req)
                predictions.append((req, lifetime))
            _ = sorted(predictions, key=lambda x: x[1], reverse=True)
        end = time.perf_counter()

        total_time_ms = ((end - start) / iterations) * 1000
        per_request_ms = total_time_ms / num_requests

        print(f"\n[Scheduling {num_requests} Requests] Total: {total_time_ms:.6f} ms")
        print(f"[Per Request] {per_request_ms:.6f} ms")

        # Each request should take < 0.01ms to predict and schedule
        assert per_request_ms < 0.01, (
            f"Per-request time too high: {per_request_ms:.6f} ms (target: < 0.01 ms)"
        )

        # Total time for 100 requests should be < 1ms
        assert total_time_ms < 1.0, (
            f"Total scheduling time too high: {total_time_ms:.6f} ms (target: < 1 ms)"
        )

    def test_scheduling_queue_reordering_overhead(self, requests: list[RequestInfo]) -> None:
        """Test overhead of queue reordering with lifetime prediction."""
        predictor = HybridHeuristicPredictor()

        # Predict all lifetimes
        lifetimes = [(req, predictor.predict(req)) for req in requests]

        # Measure baseline sort (by request_id)
        iterations = 10000
        gc.collect()
        start = time.perf_counter()
        for _ in range(iterations):
            _ = sorted(requests, key=lambda r: r.request_id)
        end = time.perf_counter()
        baseline_ms = ((end - start) / iterations) * 1000

        # Measure sort with lifetime
        gc.collect()
        start = time.perf_counter()
        for _ in range(iterations):
            _ = sorted(lifetimes, key=lambda x: x[1], reverse=True)
        end = time.perf_counter()
        with_lifetime_ms = ((end - start) / iterations) * 1000

        overhead_pct = ((with_lifetime_ms - baseline_ms) / baseline_ms) * 100

        print(f"\n[Queue Reordering - Baseline] Time: {baseline_ms:.6f} ms")
        print(f"[Queue Reordering - With Lifetime] Time: {with_lifetime_ms:.6f} ms")
        print(f"[Overhead] {overhead_pct:.2f}%")

        # Reordering overhead should be negligible
        assert overhead_pct < 10.0, f"Reordering overhead too high: {overhead_pct:.2f}%"


@pytest.mark.performance
class TestMemoryFootprint:
    """Test memory footprint and growth."""

    def test_historical_predictor_memory_bounded(self) -> None:
        """Test that HistoricalPredictor memory is bounded by window_size."""
        window_size = 1000
        predictor = HistoricalPredictor({"window_size": window_size, "split_by_type": False})

        # Add many updates
        for i in range(window_size * 5):
            predictor.update(f"req_{i}", 3.0 + (i % 100) * 0.01)

        # History should be bounded
        history_size = len(predictor._global_history)
        assert history_size == window_size, (
            f"History not bounded: {history_size} (expected: {window_size})"
        )

        print(f"\n[HistoricalPredictor] History size: {history_size} / {window_size}")

    def test_historical_predictor_memory_with_types(self) -> None:
        """Test HistoricalPredictor memory with split_by_type enabled."""
        window_size = 500
        predictor = HistoricalPredictor({"window_size": window_size, "split_by_type": True})

        request_types = ["llm_chat", "llm_generate", "llm_summarize"]

        # Add many updates across different types
        for i in range(window_size * 3):
            req_type = request_types[i % len(request_types)]
            # Create a request to get type-specific history
            info = RequestInfo(
                request_id=f"req_{i}",
                model_name="Qwen2-7B",
                prompt_length=100,
                max_output_length=50,
                request_type=req_type,
            )
            predictor.predict(info)  # This creates type-specific history
            predictor.update(f"req_{i}", 3.0)

        # Each type should have bounded history
        total_history = len(predictor._global_history)
        for req_type in request_types:
            if req_type in predictor._history:
                type_history_size = len(predictor._history[req_type])
                assert type_history_size <= window_size, (
                    f"Type {req_type} history not bounded: {type_history_size}"
                )

        print(f"\n[HistoricalPredictor - by Type] Global history: {total_history}")
        for req_type, history in predictor._history.items():
            print(f"  {req_type}: {len(history)} entries")

    def test_evaluator_memory_bounded(self) -> None:
        """Test that PredictionEvaluator can be cleared to prevent unbounded growth."""
        evaluator = PredictionEvaluator(report_interval=0)

        # Add many predictions
        for i in range(10000):
            evaluator.record_prediction(
                request_id=f"req_{i}",
                predicted_lifetime=10.0,
                actual_lifetime=12.0,
            )

        records_before = len(evaluator)
        assert records_before == 10000

        # Clear old records
        evaluator.clear()

        records_after = len(evaluator)
        assert records_after == 0

        print(
            f"\n[PredictionEvaluator] Records before clear: {records_before}, after: {records_after}"
        )

    def test_memory_usage_estimation(self) -> None:
        """Test memory usage estimation for predictors."""
        predictor = HistoricalPredictor({"window_size": 1000})

        # Fill history
        for i in range(1000):
            predictor.update(f"req_{i}", 3.0 + i * 0.001)

        # Estimate memory usage
        # Each float is ~8 bytes, window_size = 1000 => ~8KB
        history_size = len(predictor._global_history)
        estimated_bytes = history_size * 8  # Rough estimate for floats
        estimated_kb = estimated_bytes / 1024

        print(
            f"\n[Memory Estimation] History size: {history_size}, Estimated: {estimated_kb:.2f} KB"
        )

        # Memory should be reasonable (< 100 KB per predictor)
        assert estimated_kb < 100, f"Memory usage too high: {estimated_kb:.2f} KB"

    def test_predictor_object_size(self) -> None:
        """Test actual object size of predictors."""
        length_predictor = LengthBasedPredictor()
        historical_predictor = HistoricalPredictor({"window_size": 100})
        hybrid_predictor = HybridHeuristicPredictor()

        # Fill historical predictor
        for i in range(100):
            historical_predictor.update(f"req_{i}", 3.0)

        # Get sizes
        length_size = sys.getsizeof(length_predictor)
        historical_size = sys.getsizeof(historical_predictor)
        hybrid_size = sys.getsizeof(hybrid_predictor)

        print("\n[Object Sizes]")
        print(f"  LengthBasedPredictor: {length_size} bytes")
        print(f"  HistoricalPredictor: {historical_size} bytes")
        print(f"  HybridHeuristicPredictor: {hybrid_size} bytes")

        # All predictors should be reasonably sized (< 1 MB)
        assert length_size < 1024 * 1024
        assert historical_size < 1024 * 1024
        assert hybrid_size < 1024 * 1024


@pytest.mark.performance
class TestThroughput:
    """Test throughput characteristics."""

    def test_prediction_throughput(self) -> None:
        """Test how many predictions can be made per second."""
        predictor = HybridHeuristicPredictor()
        request = RequestInfo(
            request_id="req_throughput",
            model_name="Qwen2-7B",
            prompt_length=200,
            max_output_length=100,
        )

        # Warmup
        for _ in range(100):
            predictor.predict(request)

        # Measure throughput over 1 second
        duration_sec = 1.0
        count = 0

        gc.collect()
        start = time.perf_counter()
        while time.perf_counter() - start < duration_sec:
            predictor.predict(request)
            count += 1

        throughput = count / duration_sec

        print(f"\n[Prediction Throughput] {throughput:.0f} predictions/sec")

        # Should be able to do at least 10,000 predictions per second
        assert throughput > 10000, f"Throughput too low: {throughput:.0f} predictions/sec"

    def test_feature_extraction_throughput(self) -> None:
        """Test feature extraction throughput."""
        extractor = FeatureExtractor()
        request = RequestInfo(
            request_id="req_feature_throughput",
            model_name="Qwen2-7B",
            prompt_length=200,
            max_output_length=100,
        )

        # Warmup
        for _ in range(100):
            extractor.extract(request)

        # Measure throughput over 1 second
        duration_sec = 1.0
        count = 0

        gc.collect()
        start = time.perf_counter()
        while time.perf_counter() - start < duration_sec:
            extractor.extract(request)
            count += 1

        throughput = count / duration_sec

        print(f"\n[Feature Extraction Throughput] {throughput:.0f} extractions/sec")

        # Should be able to do at least 20,000 extractions per second
        assert throughput > 20000, f"Throughput too low: {throughput:.0f} extractions/sec"


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