"""Formal evals for SmartCrusher quality retention.

These tests verify that SmartCrusher GUARANTEES 100% retention of critical items:
1. Error items: Items containing error keywords
2. Anomaly items: Items with values > 2 std from mean
3. Relevance items: Items matching user query context

This is a FORMAL EVAL - any failure here is a CRITICAL BUG.
"""

import json

import pytest

from headroom.providers.anthropic import AnthropicTokenCounter
from headroom.tokenizer import Tokenizer
from headroom.transforms.smart_crusher import (
    SmartCrusher,
    SmartCrusherConfig,
    smart_crush_tool_output,
)


class TestErrorRetention:
    """Verify 100% retention of error items."""

    ERROR_KEYWORDS = ["error", "exception", "failed", "failure", "critical", "fatal"]

    @pytest.fixture
    def large_dataset(self):
        """Create large dataset with known errors."""
        items = []
        error_indices = []

        for i in range(1000):
            items.append(
                {
                    "id": f"item_{i}",
                    "value": i,
                    "status": "ok",
                    "message": f"Normal operation {i}",
                }
            )

        # Insert errors at specific positions
        for idx in [10, 50, 100, 250, 500, 750, 999]:
            items[idx]["status"] = "failed"
            items[idx]["error"] = f"Error at position {idx}"
            error_indices.append(idx)

        return items, error_indices

    def test_all_error_items_retained(self, large_dataset):
        """CRITICAL: Every item with error keywords MUST be retained."""
        items, error_indices = large_dataset

        config = SmartCrusherConfig(max_items_after_crush=20)
        content = json.dumps(items)
        compressed_str, _, _ = smart_crush_tool_output(content, config)
        compressed = json.loads(compressed_str)

        # Count errors before and after
        errors_before = len(error_indices)
        errors_after = sum(1 for x in compressed if x.get("error"))

        assert errors_after == errors_before, (
            f"QUALITY FAILURE: Lost {errors_before - errors_after} error items! "
            f"Expected {errors_before}, got {errors_after}"
        )

    @pytest.mark.parametrize("keyword", ERROR_KEYWORDS)
    def test_each_error_keyword_detected(self, keyword):
        """Each error keyword must trigger retention."""
        items = [{"id": f"item_{i}", "msg": f"Normal {i}"} for i in range(100)]
        items[50]["msg"] = f"This contains {keyword} keyword"

        config = SmartCrusherConfig(max_items_after_crush=15)
        compressed_str, _, _ = smart_crush_tool_output(json.dumps(items), config)
        compressed = json.loads(compressed_str)

        matching = [x for x in compressed if keyword in str(x).lower()]
        assert len(matching) >= 1, f"Item with '{keyword}' keyword was dropped!"

    def test_error_in_nested_structure(self):
        """Errors in nested objects must be detected."""
        items = [{"id": i, "data": {"status": "ok"}} for i in range(100)]
        items[50]["data"]["status"] = "failed"
        items[50]["data"]["error"] = "Nested error"

        config = SmartCrusherConfig(max_items_after_crush=15)
        compressed_str, _, _ = smart_crush_tool_output(json.dumps(items), config)
        compressed = json.loads(compressed_str)

        nested_errors = [x for x in compressed if x.get("data", {}).get("error")]
        assert len(nested_errors) >= 1, "Nested error item was dropped!"

    def test_multiple_errors_all_retained(self):
        """When errors exceed max_items, ALL errors must still be retained."""
        # Create 100 items where 30 are errors (more than max_items_after_crush)
        items = []
        for i in range(100):
            item = {"id": i, "value": i}
            if i % 3 == 0:  # Every 3rd item is an error (33 total)
                item["error"] = f"Error {i}"
                item["status"] = "failed"
            items.append(item)

        error_count_before = sum(1 for x in items if x.get("error"))
        assert error_count_before == 34  # 0,3,6,...,99 = 34 items

        # Compress with max 20 items
        config = SmartCrusherConfig(max_items_after_crush=20)
        compressed_str, _, _ = smart_crush_tool_output(json.dumps(items), config)
        compressed = json.loads(compressed_str)

        error_count_after = sum(1 for x in compressed if x.get("error"))

        # When errors > max_items, we should keep ALL errors (errors take priority)
        # This tests the _prioritize_indices logic
        assert error_count_after == error_count_before, (
            f"CRITICAL: Errors were dropped! "
            f"Before: {error_count_before}, After: {error_count_after}"
        )


class TestAnomalyRetention:
    """Verify 100% retention of anomalous numeric values."""

    def test_numeric_anomalies_retained(self):
        """Items with values > 2 std from mean must be retained."""
        items = []
        anomaly_indices = []

        # Create items with normal values around mean=100, std=10
        for i in range(1000):
            items.append(
                {
                    "id": f"item_{i}",
                    "value": 100 + (i % 20) - 10,  # Values 90-110
                    "name": f"Normal item {i}",
                }
            )

        # Insert anomalies (> 2 std = > 120 or < 80)
        for idx in [100, 300, 500, 700, 900]:
            items[idx]["value"] = 999999  # Extreme anomaly
            items[idx]["is_anomaly"] = True  # Mark for verification
            anomaly_indices.append(idx)

        config = SmartCrusherConfig(max_items_after_crush=20)
        compressed_str, _, _ = smart_crush_tool_output(json.dumps(items), config)
        compressed = json.loads(compressed_str)

        anomalies_after = sum(1 for x in compressed if x.get("is_anomaly"))

        assert anomalies_after == len(anomaly_indices), (
            f"QUALITY FAILURE: Lost anomaly items! "
            f"Expected {len(anomaly_indices)}, got {anomalies_after}"
        )

    def test_negative_anomalies_retained(self):
        """Negative outliers must also be retained."""
        items = [{"id": i, "value": 100} for i in range(100)]
        items[50]["value"] = -999  # Negative anomaly
        items[50]["is_anomaly"] = True

        config = SmartCrusherConfig(max_items_after_crush=15)
        compressed_str, _, _ = smart_crush_tool_output(json.dumps(items), config)
        compressed = json.loads(compressed_str)

        anomalies = [x for x in compressed if x.get("is_anomaly")]
        assert len(anomalies) == 1, "Negative anomaly was dropped!"


class TestRelevanceRetention:
    """Verify retention of items matching query context."""

    def test_relevance_with_query_context(self):
        """Items matching query should be retained when context is provided."""
        items = [{"id": i, "content": f"Generic content about topic {i}"} for i in range(100)]

        # Insert a specific item that matches our query
        # Note: This also contains "error" keyword which will trigger error retention
        items[50]["content"] = "Authentication error: invalid JWT token expired"
        items[50]["is_target"] = True

        # Use SmartCrusher with query context (via message-based API)
        config = SmartCrusherConfig(max_items_after_crush=15)
        crusher = SmartCrusher(config)

        # Create tokenizer with proper counter
        model = "claude-3-5-sonnet-20241022"
        token_counter = AnthropicTokenCounter(model)
        tokenizer = Tokenizer(token_counter, model)

        # Create messages with query context
        messages = [
            {"role": "user", "content": "Why is JWT authentication failing?"},
            {"role": "tool", "tool_call_id": "call_1", "content": json.dumps(items)},
        ]

        result = crusher.apply(messages, tokenizer)
        tool_msg = next(m for m in result.messages if m.get("role") == "tool")
        compressed = json.loads(tool_msg["content"].split("\n")[0])  # Remove marker

        targets = [x for x in compressed if x.get("is_target")]
        assert len(targets) >= 1, "Target item was dropped despite matching query context!"


class TestFirstLastRetention:
    """Verify first K and last K items are always retained."""

    def test_first_items_retained(self):
        """First 3 items must always be retained."""
        items = [{"id": i, "value": i} for i in range(100)]

        config = SmartCrusherConfig(max_items_after_crush=15)
        compressed_str, _, _ = smart_crush_tool_output(json.dumps(items), config)
        compressed = json.loads(compressed_str)

        ids = [x["id"] for x in compressed]
        assert 0 in ids, "First item (id=0) was dropped!"
        assert 1 in ids, "Second item (id=1) was dropped!"
        assert 2 in ids, "Third item (id=2) was dropped!"

    def test_last_items_retained(self):
        """Last 2 items must always be retained."""
        items = [{"id": i, "value": i} for i in range(100)]

        config = SmartCrusherConfig(max_items_after_crush=15)
        compressed_str, _, _ = smart_crush_tool_output(json.dumps(items), config)
        compressed = json.loads(compressed_str)

        ids = [x["id"] for x in compressed]
        assert 98 in ids, "Second-to-last item (id=98) was dropped!"
        assert 99 in ids, "Last item (id=99) was dropped!"


class TestCombinedRetention:
    """Test retention when multiple preservation criteria apply."""

    def test_error_and_anomaly_both_retained(self):
        """Items that are both errors AND anomalies must be retained."""
        items = [{"id": i, "value": 100} for i in range(100)]

        # Item is both an error AND an anomaly
        items[50]["value"] = 999999
        items[50]["error"] = "Critical failure"
        items[50]["is_both"] = True

        config = SmartCrusherConfig(max_items_after_crush=10)
        compressed_str, _, _ = smart_crush_tool_output(json.dumps(items), config)
        compressed = json.loads(compressed_str)

        both = [x for x in compressed if x.get("is_both")]
        assert len(both) == 1, "Item with both error and anomaly was dropped!"

    def test_high_volume_critical_items(self):
        """Even with many critical items, none should be dropped."""
        items = []
        critical_count = 0

        for i in range(500):
            item = {"id": i, "value": 100}

            # Make every 5th item an error
            if i % 5 == 0:
                item["error"] = f"Error {i}"
                critical_count += 1

            # Make every 7th item an anomaly (some overlap)
            if i % 7 == 0:
                item["value"] = 999999
                if "error" not in item:
                    critical_count += 1

            items.append(item)

        config = SmartCrusherConfig(max_items_after_crush=30)
        compressed_str, _, _ = smart_crush_tool_output(json.dumps(items), config)
        compressed = json.loads(compressed_str)

        # Count retained critical items
        errors_retained = sum(1 for x in compressed if x.get("error"))
        sum(1 for x in compressed if x.get("value", 0) > 900000)

        # All errors should be retained
        errors_original = sum(1 for x in items if x.get("error"))
        assert errors_retained == errors_original, (
            f"Some errors dropped: {errors_original} -> {errors_retained}"
        )


class TestCompressionRatio:
    """Verify compression achieves target while preserving quality."""

    def test_compression_with_quality(self):
        """Compression should reduce size significantly while keeping critical items."""
        # Create realistic large dataset
        items = []
        for i in range(1000):
            items.append(
                {
                    "id": f"doc_{i}",
                    "score": 0.5,
                    "title": f"Document {i} about various topics",
                    "snippet": "Lorem ipsum " * 20,
                    "metadata": {"source": "web", "date": "2024-01-01"},
                }
            )

        # Add some critical items
        items[100]["error"] = "Parse error"
        items[500]["value"] = 999999  # Add numeric field for anomaly

        original_size = len(json.dumps(items))

        config = SmartCrusherConfig(max_items_after_crush=50)
        compressed_str, _, _ = smart_crush_tool_output(json.dumps(items), config)
        compressed = json.loads(compressed_str)

        compressed_size = len(json.dumps(compressed))

        # Should achieve significant compression
        compression_ratio = 1 - (compressed_size / original_size)
        assert compression_ratio > 0.9, f"Compression too low: {compression_ratio:.1%}"

        # But critical items must be preserved
        assert any(x.get("error") for x in compressed), "Error item lost during compression!"


class TestEdgeCases:
    """Test edge cases and boundary conditions."""

    def test_empty_array(self):
        """Empty array should return empty."""
        compressed_str, was_modified, _ = smart_crush_tool_output("[]")
        assert compressed_str == "[]"
        assert not was_modified

    def test_small_array_unchanged(self):
        """Arrays smaller than min_items_to_analyze should be unchanged."""
        items = [{"id": i} for i in range(3)]
        original = json.dumps(items)

        compressed_str, was_modified, _ = smart_crush_tool_output(original)

        # Small arrays shouldn't be modified
        assert json.loads(compressed_str) == items

    def test_all_items_are_errors(self):
        """When all items are errors, all should be retained."""
        items = [{"id": i, "error": f"Error {i}"} for i in range(50)]

        config = SmartCrusherConfig(max_items_after_crush=20)
        compressed_str, _, _ = smart_crush_tool_output(json.dumps(items), config)
        compressed = json.loads(compressed_str)

        # All 50 errors should be retained (errors override max_items)
        assert len(compressed) == 50, (
            f"Some errors dropped when all items are errors! Expected 50, got {len(compressed)}"
        )

    def test_unicode_content(self):
        """Unicode content should not break error detection."""
        items = [{"id": i, "content": f"内容 {i}"} for i in range(100)]
        items[50]["error"] = "错误: Unicode error message"

        config = SmartCrusherConfig(max_items_after_crush=15)
        compressed_str, _, _ = smart_crush_tool_output(json.dumps(items), config)
        compressed = json.loads(compressed_str)

        errors = [x for x in compressed if x.get("error")]
        assert len(errors) == 1, "Unicode error item was dropped!"
