"""
Minimal acceptance tests for engine improvements v2.0
"""
import unittest
import numpy as np
import logging
from io import StringIO

from src.fmatch.core.engine_config import EngineConfig
from src.fmatch.core.run_stats import RunStats
from src.fmatch.core.scoring_utils import weighted_present_mean, aggregate_scores
from src.fmatch.core.logging_setup import setup_logging, AsciiSanitizer


class TestPresentOnlyNormalization(unittest.TestCase):
    """Test present-only scoring mode"""
    
    def test_single_feature_present(self):
        """1 feature present (w=0.65, sim=0.90) → 0.90 (present) vs 0.585 (legacy)"""
        # Single feature case
        sim = np.array([0.90, 0.0, 0.0])  # Only first feature has similarity
        weights = np.array([0.65, 0.20, 0.15])
        present = np.array([1, 0, 0])  # Only first feature is present
        
        # Present-only mode: 0.90 * 0.65 / 0.65 = 0.90
        present_score = weighted_present_mean(sim, weights, present)
        self.assertAlmostEqual(present_score, 0.90, places=2)
        
        # Legacy mode: 0.90 * 0.65 / 1.0 = 0.585
        legacy_score = aggregate_scores(sim, weights, present, mode="weighted_mean")
        self.assertAlmostEqual(legacy_score, 0.585, places=3)
    
    def test_all_features_present(self):
        """All features present - both modes should match"""
        sim = np.array([0.90, 0.85, 0.80])
        weights = np.array([0.5, 0.3, 0.2])
        present = np.array([1, 1, 1])
        
        present_score = weighted_present_mean(sim, weights, present)
        legacy_score = aggregate_scores(sim, weights, present, mode="weighted_mean")
        
        self.assertAlmostEqual(present_score, legacy_score, places=5)
    
    def test_no_features_present(self):
        """No features present - should return 0"""
        sim = np.array([0.0, 0.0, 0.0])
        weights = np.array([0.5, 0.3, 0.2])
        present = np.array([0, 0, 0])
        
        score = weighted_present_mean(sim, weights, present)
        self.assertEqual(score, 0.0)


class TestZeroReasonClassifier(unittest.TestCase):
    """Test zero match reason classification"""
    
    def test_no_candidates(self):
        """No pairs → no_candidates"""
        stats = RunStats()
        stats.candidate_pairs = 0
        stats.finalize_zero_reason(threshold=0.75)
        self.assertEqual(stats.zero_reason, "no_candidates")
    
    def test_blocked_out(self):
        """Pairs exist, all blocked → blocked_out"""
        stats = RunStats()
        stats.candidate_pairs = 1000
        stats.pairs_after_blocking = 0
        stats.finalize_zero_reason(threshold=0.75)
        self.assertEqual(stats.zero_reason, "blocked_out")
    
    def test_all_below_threshold(self):
        """Pairs exist, max < threshold → all_below_threshold"""
        stats = RunStats()
        stats.candidate_pairs = 1000
        stats.pairs_after_blocking = 500
        stats.max_score_observed = 0.72
        stats.finalize_zero_reason(threshold=0.75)
        self.assertEqual(stats.zero_reason, "all_below_threshold")
    
    def test_matches_found(self):
        """Matches found → None"""
        stats = RunStats()
        stats.candidate_pairs = 1000
        stats.pairs_after_blocking = 500
        stats.max_score_observed = 0.85
        stats.finalize_zero_reason(threshold=0.75)
        self.assertIsNone(stats.zero_reason)
    
    def test_one_line_summary(self):
        """Test one-line summary generation"""
        stats = RunStats()
        stats.total_sources = 100
        stats.candidate_pairs = 5000
        stats.pairs_after_blocking = 2000
        stats.unmatched_sources = 30
        stats.max_score_observed = 0.92
        stats.update_scores([0.85, 0.90, 0.92, 0.88, 0.87])
        
        summary = stats.one_line()
        self.assertIn("sources=100", summary)
        self.assertIn("unmatched=30.0%", summary)
        self.assertIn("max=0.92", summary)


class TestUnicodeLogging(unittest.TestCase):
    """Test Unicode-safe logging"""
    
    def test_unicode_replacement(self):
        """Emit "A → B — ok" → no exceptions; output shows ASCII"""
        # Create a string stream to capture output
        stream = StringIO()
        handler = logging.StreamHandler(stream)
        handler.addFilter(AsciiSanitizer())
        
        # Create logger
        logger = logging.getLogger("test_unicode")
        logger.handlers.clear()
        logger.addHandler(handler)
        logger.setLevel(logging.INFO)
        
        # Log message with Unicode
        logger.info("Process A → B — complete ✓")
        
        # Check output
        output = stream.getvalue()
        self.assertIn("Process A -> B - complete OK", output)
        self.assertNotIn("→", output)
        self.assertNotIn("—", output)
        self.assertNotIn("✓", output)
    
    def test_setup_logging(self):
        """Test complete logging setup"""
        logger = setup_logging(level=logging.INFO)
        
        # Should not raise any exceptions
        logger.info("Testing → with unicode ✗")
        logger.warning("Warning ⚠ message")
        logger.error("Error × occurred")


class TestAutoDisableBlocking(unittest.TestCase):
    """Test auto-disable blocking for missing columns"""
    
    def test_strict_mode_raises(self):
        """Strict mode should raise on missing column"""
        cfg = EngineConfig(strict_blocking_failures=True)
        
        # This would be in your engine code:
        # if blocking_col not in df.columns and cfg.strict_blocking_failures:
        #     raise ValueError("Blocking column missing")
        
        self.assertTrue(cfg.strict_blocking_failures)
    
    def test_non_strict_mode_continues(self):
        """Non-strict mode should disable blocking and continue"""
        cfg = EngineConfig(strict_blocking_failures=False)
        stats = RunStats()
        
        # Simulate missing column scenario
        blocking_col = "Domain"
        df_columns = ["Name", "Email", "Phone"]  # Domain is missing
        
        if blocking_col not in df_columns:
            if cfg.strict_blocking_failures:
                # Would raise in strict mode
                pass
            else:
                # Non-strict: disable blocking
                cfg.apply_blocking = False
                stats.blocking_coverage = 0.0
        
        self.assertFalse(cfg.apply_blocking)
        self.assertEqual(stats.blocking_coverage, 0.0)


class TestSoftWidenTrigger(unittest.TestCase):
    """Test soft-widen triggering logic"""
    
    def test_trigger_conditions(self):
        """Test that soft-widen triggers correctly"""
        cfg = EngineConfig(
            auto_soft_widen=True,
            soft_widen_trigger_pct=0.30,
            soft_widen_trigger_min=100,
            soft_widen_max_blocks=1000
        )
        
        # Scenario: high unmatched percentage
        total_sources = 1000
        unmatched_sources = 350  # 35% unmatched
        
        unmatched_pct = unmatched_sources / total_sources
        
        # Should trigger (35% > 30% AND 350 > 100)
        should_trigger = (
            cfg.auto_soft_widen and
            unmatched_pct >= cfg.soft_widen_trigger_pct and
            unmatched_sources >= cfg.soft_widen_trigger_min
        )
        
        self.assertTrue(should_trigger)
    
    def test_no_trigger_low_percentage(self):
        """Should not trigger with low unmatched percentage"""
        cfg = EngineConfig(
            auto_soft_widen=True,
            soft_widen_trigger_pct=0.30,
            soft_widen_trigger_min=100
        )
        
        total_sources = 1000
        unmatched_sources = 200  # 20% unmatched
        
        unmatched_pct = unmatched_sources / total_sources
        
        should_trigger = (
            cfg.auto_soft_widen and
            unmatched_pct >= cfg.soft_widen_trigger_pct and
            unmatched_sources >= cfg.soft_widen_trigger_min
        )
        
        self.assertFalse(should_trigger)
    
    def test_no_trigger_low_count(self):
        """Should not trigger with low absolute count"""
        cfg = EngineConfig(
            auto_soft_widen=True,
            soft_widen_trigger_pct=0.30,
            soft_widen_trigger_min=100
        )
        
        total_sources = 200
        unmatched_sources = 80  # 40% but only 80 records
        
        unmatched_pct = unmatched_sources / total_sources
        
        should_trigger = (
            cfg.auto_soft_widen and
            unmatched_pct >= cfg.soft_widen_trigger_pct and
            unmatched_sources >= cfg.soft_widen_trigger_min
        )
        
        self.assertFalse(should_trigger)
    
    def test_max_blocks_limit(self):
        """Generated blocks should respect max limit"""
        cfg = EngineConfig(soft_widen_max_blocks=1000)
        
        # Simulate generating widened blocks
        widened_blocks = []
        for i in range(2000):  # Try to generate 2000
            if len(widened_blocks) >= cfg.soft_widen_max_blocks:
                break
            widened_blocks.append(f"block_{i}")
        
        self.assertEqual(len(widened_blocks), 1000)


class TestConfigValidation(unittest.TestCase):
    """Test configuration validation"""
    
    def test_valid_config(self):
        """Valid config should pass validation"""
        cfg = EngineConfig()
        cfg.validate()  # Should not raise
    
    def test_invalid_coverage(self):
        """Invalid coverage should raise"""
        cfg = EngineConfig(min_blocking_coverage=1.5)
        with self.assertRaises(AssertionError):
            cfg.validate()
    
    def test_invalid_sample_rate(self):
        """Invalid sample rate should raise"""
        cfg = EngineConfig(shadow_mode_sample_pct=-0.1)
        with self.assertRaises(AssertionError):
            cfg.validate()


if __name__ == "__main__":
    unittest.main()