"""Tests demonstrating critical fixes for TOIN/CCR implementation.

These tests verify the before/after behavior of critical bug fixes:
1. TOIN confidence math error (line 721)
2. TOIN double-count bug (lines 354-358)
3. compression_feedback.py race condition (lines 481-491)
4. Unbounded strategy dicts in compression_feedback.py
5. SmartCrusher integration with TOIN
"""

import time
from unittest.mock import patch

import pytest


class TestTOINConfidenceMathFix:
    """Test for CRITICAL: Confidence calculation math error in toin.py:721.

    BUG: `user_boost = min(0.3, pattern.user_count / 10 * 0.1)`
    Due to operator precedence: user_count / 10 * 0.1 = user_count * 0.01
    - 3 users: 0.03 boost (too small)
    - 10 users: 0.1 boost
    - 30 users needed to hit 0.3 cap!

    FIX: Should be `min(0.3, pattern.user_count * 0.03)` for meaningful boost
    - 3 users: 0.09 boost
    - 10 users: 0.3 boost (capped)
    """

    def test_confidence_user_boost_at_3_users(self):
        """With 3 users (min for network effect), boost should be meaningful."""
        from headroom.telemetry.toin import (
            TOINConfig,
            ToolIntelligenceNetwork,
            ToolPattern,
            reset_toin,
        )

        reset_toin()
        config = TOINConfig(min_users_for_network_effect=3)
        toin = ToolIntelligenceNetwork(config)

        # Create pattern with 3 users (correct API: tool_signature_hash is first arg)
        pattern = ToolPattern(
            tool_signature_hash="test123",
            user_count=3,
            sample_size=100,  # Good sample size
        )

        confidence = toin._calculate_confidence(pattern)

        # Sample confidence = min(0.7, 100/100) = 0.7
        # User boost for 3 users should be meaningful (>= 0.05)
        # FIX: With user_count * 0.03: boost = 0.09, total = 0.79
        # BUG: With user_count * 0.01: boost = 0.03, total = 0.73

        # After fix, confidence should be at least 0.75
        assert confidence >= 0.75, (
            f"Confidence {confidence} too low for 3 users - user boost not meaningful"
        )

    def test_confidence_user_boost_at_10_users(self):
        """With 10 users, boost should hit or approach cap."""
        from headroom.telemetry.toin import (
            TOINConfig,
            ToolIntelligenceNetwork,
            ToolPattern,
            reset_toin,
        )

        reset_toin()
        config = TOINConfig(min_users_for_network_effect=3)
        toin = ToolIntelligenceNetwork(config)

        pattern = ToolPattern(
            tool_signature_hash="test123",
            user_count=10,
            sample_size=100,
        )

        confidence = toin._calculate_confidence(pattern)

        # With 10 users, should be near cap (0.95)
        # Sample confidence = 0.7, user boost should be 0.3 (capped)
        # Total = min(0.95, 0.7 + 0.3) = 0.95
        # BUG: user_boost = 0.1, total = 0.8

        assert confidence >= 0.9, f"Confidence {confidence} too low for 10 users"


class TestTOINDoubleCountFix:
    """Test for CRITICAL: Double-count bug in toin.py:354-358.

    BUG: When _seen_instance_hashes hits cap (100), new instance_ids are NOT stored
    but user_count IS incremented. Next call with same instance_id:
    - `if self._instance_id not in pattern._seen_instance_hashes` → True (not stored!)
    - user_count incremented AGAIN → Double counting!

    FIX: Use a separate set to track ALL seen instances (no cap for lookup),
    OR check if we already tracked overflow for this instance.
    """

    def test_user_count_no_double_counting_after_cap(self):
        """Same instance shouldn't be counted twice even after cap hit."""
        from headroom.telemetry.models import ToolSignature
        from headroom.telemetry.toin import TOINConfig, ToolIntelligenceNetwork, reset_toin

        reset_toin()
        toin = ToolIntelligenceNetwork(TOINConfig())

        # Create a signature using the correct factory method
        items = [{"field1": "value1", "field2": 123}]
        sig = ToolSignature.from_items(items)

        # Simulate 101 unique instances (exceed the 100 cap)
        # First, fill up the cap with 100 unique instances
        original_instance_id = toin._instance_id
        for i in range(100):
            toin._instance_id = f"instance_{i}"
            toin.record_compression(sig, 100, 10, 1000, 100, strategy="test_strategy")

        # Now add one more instance (exceeds cap)
        toin._instance_id = "instance_100"
        toin.record_compression(sig, 100, 10, 1000, 100, strategy="test_strategy")

        # Get the pattern
        with toin._lock:
            pattern = toin._patterns[sig.structure_hash]
            user_count_after_101 = pattern.user_count

        # Now call again with same instance (instance_100)
        # BUG: This would increment user_count again because instance_100
        # was not stored (cap hit) so the check passes again
        toin.record_compression(sig, 100, 10, 1000, 100, strategy="test_strategy")

        with toin._lock:
            pattern = toin._patterns[sig.structure_hash]
            user_count_after_102 = pattern.user_count

        # Restore instance_id
        toin._instance_id = original_instance_id

        # User count should NOT increase for same instance
        assert user_count_after_102 == user_count_after_101, (
            f"Double-counting bug: user_count went from {user_count_after_101} to "
            f"{user_count_after_102} for same instance after cap hit"
        )


class TestCompressionFeedbackRaceCondition:
    """Test for CRITICAL: Race condition in compression_feedback.py:481-491.

    BUG: _last_event_timestamp is read (line 481) and written (line 491)
    WITHOUT holding the lock. Another thread calling record_retrieval()
    between these could cause events to be missed or double-counted.

    FIX: Move timestamp filtering and update inside the lock.
    """

    def test_analyze_from_store_thread_safety(self):
        """Concurrent analyze_from_store and record_retrieval should not lose events."""
        from headroom.cache.compression_feedback import (
            CompressionFeedback,
            reset_compression_feedback,
        )
        from headroom.cache.compression_store import CompressionStore, RetrievalEvent

        reset_compression_feedback()

        # Create store with mock events
        store = CompressionStore()
        feedback = CompressionFeedback(store=store, analysis_interval=0.0)  # No rate limiting

        # Pre-populate some events with correct API
        base_time = time.time()
        events_recorded = []

        def add_retrieval_event(tool_name: str, timestamp: float):
            event = RetrievalEvent(
                hash="test_hash",
                query=None,
                items_retrieved=10,
                total_items=100,
                tool_name=tool_name,
                timestamp=timestamp,
                retrieval_type="full",
            )
            # Directly add to feedback (simulating what analyze_from_store does)
            feedback.record_retrieval(event)
            events_recorded.append(event)

        # Record some events
        for i in range(10):
            add_retrieval_event(f"tool_{i % 3}", base_time + i)

        with feedback._lock:
            total_retrievals = feedback._total_retrievals
            patterns_count = len(feedback._tool_patterns)

        # All 10 events should be recorded
        assert total_retrievals == 10, f"Expected 10 retrievals, got {total_retrievals}"
        # Should have 3 unique tools (tool_0, tool_1, tool_2)
        assert patterns_count == 3, f"Expected 3 tool patterns, got {patterns_count}"

    def test_timestamp_filtering_inside_lock(self):
        """Verify that timestamp filtering happens atomically with update."""
        from headroom.cache.compression_feedback import (
            CompressionFeedback,
            reset_compression_feedback,
        )
        from headroom.cache.compression_store import CompressionStore, RetrievalEvent

        reset_compression_feedback()
        store = CompressionStore()
        feedback = CompressionFeedback(store=store, analysis_interval=0.0)

        # Manually set last event timestamp
        feedback._last_event_timestamp = 100.0

        # Create mock store with events (correct API)
        mock_events = [
            RetrievalEvent(
                hash="h1",
                query=None,
                items_retrieved=5,
                total_items=50,
                tool_name="tool_a",
                timestamp=99.0,
                retrieval_type="full",
            ),
            RetrievalEvent(
                hash="h2",
                query=None,
                items_retrieved=5,
                total_items=50,
                tool_name="tool_b",
                timestamp=101.0,
                retrieval_type="full",
            ),
            RetrievalEvent(
                hash="h3",
                query="test",
                items_retrieved=5,
                total_items=50,
                tool_name="tool_c",
                timestamp=102.0,
                retrieval_type="search",
            ),
        ]

        # Mock store.get_retrieval_events
        with patch.object(store, "get_retrieval_events", return_value=mock_events):
            feedback.analyze_from_store()

        # Only events with timestamp > 100.0 should be processed (h2, h3)
        with feedback._lock:
            total = feedback._total_retrievals
            # The timestamp should now be 102.0 (max of processed events)
            last_ts = feedback._last_event_timestamp

        assert total == 2, f"Expected 2 new events processed, got {total}"
        assert last_ts == 102.0, f"Expected last_event_timestamp=102.0, got {last_ts}"


class TestUnboundedStrategyDicts:
    """Test for HIGH: Unbounded strategy_compressions/strategy_retrievals dicts.

    BUG: Unlike common_queries (truncated at 100) and queried_fields (truncated at 50),
    the strategy dicts have no size limits and could grow unbounded.

    FIX: Add truncation logic similar to other dicts.
    """

    def test_strategy_dicts_have_size_limits(self):
        """Strategy dicts should be bounded to prevent memory leaks."""
        from headroom.cache.compression_feedback import (
            CompressionFeedback,
            reset_compression_feedback,
        )
        from headroom.cache.compression_store import CompressionStore

        reset_compression_feedback()
        store = CompressionStore()
        feedback = CompressionFeedback(store=store)

        # Record many compressions with different strategies
        for i in range(200):
            feedback.record_compression(
                tool_name="test_tool",
                original_count=100,
                compressed_count=10,
                strategy=f"strategy_{i}",  # 200 unique strategies
            )

        with feedback._lock:
            pattern = feedback._tool_patterns.get("test_tool")
            strategy_count = len(pattern.strategy_compressions) if pattern else 0

        # Strategy dict should be bounded (e.g., to 50 like queried_fields)
        assert strategy_count <= 50, (
            f"strategy_compressions has {strategy_count} entries, should be <= 50"
        )


class TestSmartCrusherTOINIntegration:
    """Test for CRITICAL: SmartCrusher not calling toin.record_compression().

    BUG: SmartCrusher calls feedback.record_compression() but never calls
    toin.record_compression(). This means TOIN only learns from retrieval events,
    not from compression events - breaking the feedback loop.

    FIX: Add toin.record_compression() call after compression in SmartCrusher.
    """

    def test_smart_crusher_records_to_toin(self):
        """SmartCrusher should record compression events to TOIN."""
        from headroom.telemetry.models import ToolSignature
        from headroom.telemetry.toin import get_toin, reset_toin
        from headroom.transforms.smart_crusher import SmartCrusher, SmartCrusherConfig

        reset_toin()

        config = SmartCrusherConfig(
            min_items_to_analyze=5,
            max_items_after_crush=10,
            use_feedback_hints=True,
        )
        crusher = SmartCrusher(config)

        # Create test items that look like search results with a clear score field
        # This pattern is crushable because:
        # 1. Has a clear numeric score field in BOUNDED range [0,1]
        # 2. Has repeated structure with some constant fields (type, language)
        # 3. Score values vary within the bounded range
        items = [
            {
                "name": f"repo_{i}",
                "relevance_score": (50 - i) / 50.0,  # Bounded [0,1] - descending order
                "type": "repository",  # Constant field
                "language": "python" if i % 3 == 0 else "javascript",  # Low cardinality
                "description": f"Description {i % 5}",  # Low cardinality
            }
            for i in range(50)
        ]

        # Get TOIN instance and check initial state
        toin = get_toin()
        len(toin._patterns)

        # Crush the array
        result, info, markers = crusher._crush_array(
            items, query_context="test query", tool_name="test_tool"
        )

        # Verify compression happened (not skipped)
        assert "skip" not in info.lower(), (
            f"Compression was skipped: {info}. Test needs crushable data."
        )

        # Get the signature that would have been created
        sig = ToolSignature.from_items(items)

        # Check TOIN was notified
        with toin._lock:
            pattern = toin._patterns.get(sig.structure_hash)

        # After fix, TOIN should have a pattern for this tool's signature
        assert pattern is not None, (
            f"TOIN should have recorded the compression event. "
            f"Info: {info}, pattern count: {len(toin._patterns)}"
        )
        if pattern:
            assert pattern.total_compressions >= 1, (
                f"Pattern should have at least 1 compression recorded, got {pattern.total_compressions}"
            )


class TestAllFixesIntegrated:
    """Integration tests ensuring all fixes work together."""

    def test_full_feedback_loop(self):
        """Test complete feedback loop: compress -> store -> retrieve -> learn."""
        from headroom.cache.compression_feedback import (
            reset_compression_feedback,
        )
        from headroom.cache.compression_store import reset_compression_store
        from headroom.telemetry.models import ToolSignature
        from headroom.telemetry.toin import get_toin, reset_toin
        from headroom.transforms.smart_crusher import SmartCrusher, SmartCrusherConfig

        # Reset all singletons
        reset_toin()
        reset_compression_store()
        reset_compression_feedback()

        # Setup
        config = SmartCrusherConfig(
            min_items_to_analyze=5,
            max_items_after_crush=10,
            use_feedback_hints=True,
        )
        crusher = SmartCrusher(config)

        # Create test items that look like API responses with scoring
        # This pattern is crushable because:
        # 1. Has a clear numeric score field in BOUNDED range [0,1]
        # 2. Has constant fields (status, type)
        # 3. Has enough items for compression (100)
        items = [
            {
                "priority": (100 - i) / 100.0,  # Bounded [0,1] - descending order
                "status": "ok",  # Constant field
                "type": "response",  # Constant field
                "data": f"content_{i % 10}",  # Low cardinality (only 10 unique values)
            }
            for i in range(100)
        ]

        # Step 1: Compress
        result, info, markers = crusher._crush_array(
            items, query_context="find status", tool_name="api_response"
        )

        # Verify compression happened (not skipped)
        assert "skip" not in info.lower(), (
            f"Compression was skipped: {info}. Test needs crushable data."
        )

        # Step 2: Check TOIN was notified (after fix)
        toin = get_toin()
        sig = ToolSignature.from_items(items)

        with toin._lock:
            toin_pattern = toin._patterns.get(sig.structure_hash)

        # After fix, TOIN should have the pattern
        assert toin_pattern is not None, (
            f"TOIN should have learned from the compression event. Info: {info}"
        )
        assert toin_pattern.total_compressions >= 1, (
            f"TOIN pattern should have recorded compression, got {toin_pattern.total_compressions}"
        )


# Run specific test to verify fix
if __name__ == "__main__":
    pytest.main([__file__, "-v", "--tb=short"])
