#!/usr/bin/env python3
"""
Unit Tests for Robust Timing Analyzer

Tests timing extraction, scoring, and noise tolerance capabilities.
"""

import pytest
import numpy as np
from src.matcher.timing_analyzer import (
    RobustTimingAnalyzer,
    TimingCharacteristics,
    TimingScore
)


class TestTimingExtraction:
    """Test timing characteristic extraction"""

    def test_clean_signal_extraction(self):
        """Test 1: Extract timing from clean PWM signal"""
        analyzer = RobustTimingAnalyzer()

        # Simulated PWM signal: SHORT=500us, LONG=1000us
        # Pattern: 0101010101 (10 bits)
        pulses = [
            500, -500,   # 0
            1000, -500,  # 1
            500, -500,   # 0
            1000, -500,  # 1
            500, -500,   # 0
            1000, -500,  # 1
            500, -500,   # 0
            1000, -500,  # 1
            500, -500,   # 0
            1000, -500,  # 1
        ]

        timing = analyzer.extract_timing(pulses)

        # Verify extracted timings
        assert 450 <= timing.short_pulse_us <= 550, f"Expected short ~500, got {timing.short_pulse_us}"
        assert 950 <= timing.long_pulse_us <= 1050, f"Expected long ~1000, got {timing.long_pulse_us}"
        assert timing.pulse_ratio >= 1.8 and timing.pulse_ratio <= 2.2, f"Expected ratio ~2.0, got {timing.pulse_ratio}"
        assert timing.confidence > 0.5, f"Expected confidence >0.5, got {timing.confidence}"
        assert timing.pulse_count == 10

    def test_noisy_signal_extraction(self):
        """Test 2: Extract timing from noisy signal with outliers"""
        analyzer = RobustTimingAnalyzer()

        # Signal with noise and outliers
        pulses = [
            500, -500,
            1000, -500,
            500, -500,
            5000,  # OUTLIER: glitch
            -500,
            1000, -500,
            500, -500,
            1000, -500,
            50,  # OUTLIER: noise spike
            -500,
            1000, -500,
        ]

        timing = analyzer.extract_timing(pulses)

        # Should still extract reasonable timings despite outliers
        assert 400 <= timing.short_pulse_us <= 600, f"Short pulse should be ~500, got {timing.short_pulse_us}"
        assert 900 <= timing.long_pulse_us <= 1100, f"Long pulse should be ~1000, got {timing.long_pulse_us}"
        assert timing.confidence > 0.3, "Should have some confidence despite noise"

    def test_multi_level_signal(self):
        """Test 3: Handle signal with multiple pulse widths"""
        analyzer = RobustTimingAnalyzer()

        # Tri-level signal: 250us, 500us, 1000us
        pulses = [
            250, -250,
            500, -250,
            1000, -250,
            250, -250,
            500, -250,
            1000, -250,
            250, -250,
            500, -250,
            1000, -250,
        ]

        timing = analyzer.extract_timing(pulses)

        # Should extract shortest and longest
        assert timing.short_pulse_us < 400, "Should identify shortest pulse"
        assert timing.long_pulse_us > 800, "Should identify longest pulse"
        assert timing.pulse_count > 0

    def test_empty_signal(self):
        """Test 4: Handle empty/invalid input gracefully"""
        analyzer = RobustTimingAnalyzer()

        # Empty signal
        timing = analyzer.extract_timing([])
        assert timing.confidence == 0.0
        assert timing.short_pulse_us == 0

        # Too short signal
        timing = analyzer.extract_timing([500, -500])
        assert timing.confidence == 0.0

    def test_duty_cycle_calculation(self):
        """Test 5: Verify duty cycle calculation"""
        analyzer = RobustTimingAnalyzer()

        # 50% duty cycle: equal HIGH and LOW time
        pulses = [1000, -1000, 1000, -1000, 1000, -1000]
        timing = analyzer.extract_timing(pulses)
        assert 0.45 <= timing.duty_cycle <= 0.55, f"Expected duty cycle ~0.5, got {timing.duty_cycle}"

        # 25% duty cycle: SHORT HIGH, long LOW
        pulses = [500, -1500, 500, -1500, 500, -1500]
        timing = analyzer.extract_timing(pulses)
        assert 0.20 <= timing.duty_cycle <= 0.30, f"Expected duty cycle ~0.25, got {timing.duty_cycle}"


class TestProtocolScoring:
    """Test scoring against protocol signatures"""

    def test_perfect_match_scoring(self):
        """Test 6: Score perfect timing match"""
        analyzer = RobustTimingAnalyzer()

        # Perfect match timing
        timing = TimingCharacteristics(
            short_pulse_us=500,
            long_pulse_us=1000,
            short_gap_us=500,
            long_gap_us=500,
            pulse_ratio=2.0,
            gap_ratio=1.0,
            duty_cycle=0.5,
            pulse_count=20,
            confidence=1.0,
            method='kmeans'
        )

        # Score against matching protocol
        score = analyzer.score_against_protocol(
            timing=timing,
            protocol_short_us=500,
            protocol_long_us=1000,
            protocol_frequency=433920000,
            signal_frequency=433920000,
            timing_tolerance=0.25
        )

        # Should have very high scores
        assert score.overall_score > 0.9, f"Expected score >0.9, got {score.overall_score}"
        assert score.pulse_score > 0.95, f"Expected pulse score >0.95, got {score.pulse_score}"
        assert score.frequency_score > 0.95, f"Expected frequency score >0.95, got {score.frequency_score}"

    def test_partial_match_scoring(self):
        """Test 7: Score partial timing match within tolerance"""
        analyzer = RobustTimingAnalyzer()

        # 10% timing error (within 25% tolerance)
        timing = TimingCharacteristics(
            short_pulse_us=550,   # 10% higher than 500
            long_pulse_us=1100,   # 10% higher than 1000
            short_gap_us=500,
            long_gap_us=500,
            pulse_ratio=2.0,
            gap_ratio=1.0,
            duty_cycle=0.5,
            pulse_count=20,
            confidence=0.9,
            method='kmeans'
        )

        score = analyzer.score_against_protocol(
            timing=timing,
            protocol_short_us=500,
            protocol_long_us=1000,
            protocol_frequency=433920000,
            signal_frequency=433920000,
            timing_tolerance=0.25  # ±25%
        )

        # Should still score well (within tolerance)
        assert score.overall_score > 0.7, f"Expected score >0.7 for 10% error, got {score.overall_score}"
        assert score.pulse_score > 0.6, f"Expected pulse score >0.6, got {score.pulse_score}"

    def test_mismatch_scoring(self):
        """Test 8: Score timing mismatch outside tolerance"""
        analyzer = RobustTimingAnalyzer()

        # Large timing error (>50%)
        timing = TimingCharacteristics(
            short_pulse_us=250,   # 50% lower than 500
            long_pulse_us=2000,   # 100% higher than 1000
            short_gap_us=500,
            long_gap_us=500,
            pulse_ratio=8.0,      # Wrong ratio
            gap_ratio=1.0,
            duty_cycle=0.5,
            pulse_count=20,
            confidence=0.8,
            method='kmeans'
        )

        score = analyzer.score_against_protocol(
            timing=timing,
            protocol_short_us=500,
            protocol_long_us=1000,
            protocol_frequency=433920000,
            signal_frequency=433920000,
            timing_tolerance=0.25
        )

        # Should have low score (outside tolerance)
        assert score.overall_score < 0.3, f"Expected score <0.3 for large error, got {score.overall_score}"
        assert score.pulse_score < 0.2, f"Expected pulse score <0.2, got {score.pulse_score}"

    def test_frequency_mismatch_penalty(self):
        """Test 9: Frequency mismatch reduces score"""
        analyzer = RobustTimingAnalyzer()

        # Perfect timing, wrong frequency
        timing = TimingCharacteristics(
            short_pulse_us=500,
            long_pulse_us=1000,
            short_gap_us=500,
            long_gap_us=500,
            pulse_ratio=2.0,
            gap_ratio=1.0,
            duty_cycle=0.5,
            pulse_count=20,
            confidence=1.0,
            method='kmeans'
        )

        score = analyzer.score_against_protocol(
            timing=timing,
            protocol_short_us=500,
            protocol_long_us=1000,
            protocol_frequency=433920000,
            signal_frequency=315000000,  # Wrong frequency!
            timing_tolerance=0.25
        )

        # Frequency mismatch should reduce overall score
        assert score.frequency_score < 0.2, f"Expected low frequency score, got {score.frequency_score}"
        assert score.overall_score < 0.9, "Overall score should be reduced by frequency mismatch"

    def test_confidence_scaling(self):
        """Test 10: Low extraction confidence scales down score"""
        analyzer = RobustTimingAnalyzer()

        # Perfect timing but low confidence (noisy extraction)
        timing = TimingCharacteristics(
            short_pulse_us=500,
            long_pulse_us=1000,
            short_gap_us=500,
            long_gap_us=500,
            pulse_ratio=2.0,
            gap_ratio=1.0,
            duty_cycle=0.5,
            pulse_count=20,
            confidence=0.4,  # Low confidence
            method='percentile'
        )

        score = analyzer.score_against_protocol(
            timing=timing,
            protocol_short_us=500,
            protocol_long_us=1000,
            protocol_frequency=433920000,
            signal_frequency=433920000,
            timing_tolerance=0.25
        )

        # Low confidence should scale down overall score
        assert score.overall_score < 0.5, f"Expected score <0.5 with low confidence, got {score.overall_score}"


class TestOutlierRemoval:
    """Test outlier detection and removal"""

    def test_iqr_outlier_removal(self):
        """Test 11: IQR method removes outliers correctly"""
        analyzer = RobustTimingAnalyzer()

        # Normal pulses + outliers
        values = [500] * 10 + [1000] * 10 + [10000]  # Large outlier

        cleaned = analyzer._remove_outliers_from_values(values)

        # Large outlier should be removed (outside 2.5*IQR)
        assert 10000 not in cleaned, "Large outlier should be removed"
        # Should keep most valid data
        assert len(cleaned) == 20, f"Should have 20 pulses after cleaning, got {len(cleaned)}"

    def test_small_sample_no_removal(self):
        """Test 12: Don't remove outliers from small samples"""
        analyzer = RobustTimingAnalyzer()

        # Too few samples for outlier detection
        values = [500, 1000, 5000]

        cleaned = analyzer._remove_outliers_from_values(values)

        # Should return all samples
        assert len(cleaned) == 3, "Small samples should not be filtered"


class TestMultiMethodExtraction:
    """Test multi-method extraction approach"""

    def test_kmeans_preferred_for_clean(self):
        """Test 13: K-means chosen for clean bimodal signals"""
        try:
            from sklearn.cluster import KMeans
            has_sklearn = True
        except ImportError:
            has_sklearn = False

        if not has_sklearn:
            pytest.skip("sklearn not available")

        analyzer = RobustTimingAnalyzer()

        # Clean bimodal signal (perfect for K-means)
        pulses = [500] * 10 + [1000] * 10

        timing = analyzer.extract_timing(pulses)

        # K-means should be selected (highest confidence)
        assert timing.method == 'kmeans', f"Expected kmeans, got {timing.method}"
        assert timing.confidence > 0.7, f"Expected high confidence, got {timing.confidence}"

    def test_fallback_to_percentile(self):
        """Test 14: Fallback to percentile for difficult signals"""
        analyzer = RobustTimingAnalyzer()

        # Challenging signal: gradual variation
        pulses = list(range(400, 600, 10)) + list(range(900, 1100, 10))

        timing = analyzer.extract_timing(pulses)

        # Should extract reasonable timings using some method
        assert timing.short_pulse_us > 0, "Should extract short pulse"
        assert timing.long_pulse_us > timing.short_pulse_us, "Long > short"
        assert timing.confidence > 0.0, "Should have some confidence"


# Integration test
def test_real_world_lacrosse_signal():
    """Test 15: Process real-world LaCrosse TX141 signal pattern"""
    analyzer = RobustTimingAnalyzer()

    # Simulated LaCrosse TX141-BV2 timing (500us/1000us PWM)
    # 40-bit transmission
    lacrosse_pulses = []
    # Preamble: 4x "10"
    for _ in range(4):
        lacrosse_pulses.extend([1000, -500, 500, -500])
    # Data: random pattern
    data_bits = "10110100" * 4  # 32 bits
    for bit in data_bits:
        if bit == '1':
            lacrosse_pulses.extend([1000, -500])
        else:
            lacrosse_pulses.extend([500, -500])

    # Add some noise
    np.random.seed(42)
    noisy_pulses = [
        int(p * np.random.normal(1.0, 0.05))  # ±5% jitter
        for p in lacrosse_pulses
    ]

    timing = analyzer.extract_timing(noisy_pulses)

    # Should extract LaCrosse-like timing
    assert 450 <= timing.short_pulse_us <= 550, f"Expected short ~500, got {timing.short_pulse_us}"
    assert 950 <= timing.long_pulse_us <= 1050, f"Expected long ~1000, got {timing.long_pulse_us}"
    assert timing.pulse_ratio >= 1.7 and timing.pulse_ratio <= 2.3, "Ratio should be ~2.0"
    assert timing.confidence > 0.5, "Should have good confidence"

    # Score against LaCrosse protocol
    score = analyzer.score_against_protocol(
        timing=timing,
        protocol_short_us=500,
        protocol_long_us=1000,
        protocol_frequency=433920000,
        signal_frequency=433920000,
        timing_tolerance=0.25
    )

    # Should match well
    assert score.overall_score > 0.7, f"Expected good match, got {score.overall_score}"


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