import pytest
import pandas as pd
import numpy as np
import math
from unittest.mock import patch
from pathlib import Path
from fmatch.core.engine import _init_score_cache_db, cached_score, apply_custom_rules
from fmatch.core import engine

# It's good to clear the ruleset cache between test runs if tests might modify it or rely on its state.
@pytest.fixture(autouse=True)
def clear_ruleset_cache():
    if hasattr(engine, "_RULESET_CACHE"):
        engine._RULESET_CACHE.clear()

# --- Tests for calculate_raw_signals ---
def test_calculate_raw_signals_all_identical_keys_dedupe():
    # Arrange
    key_value = "aaa"
    n_records = 100
    key_length = 3
    src_keys = pd.Series([key_value] * n_records)
    mode = engine.BlockingMode.DEDUPE

    expected_signals = {
        "RR": 0.0,
        "H": 0.0,
        "G": 0.0, # Perfect equality (all in one block)
        "P1": 1.0,
        "S1": 0.0,
        "k": float(key_length)
    }

    # Act
    signals = engine.calculate_raw_signals(src_keys, key_length, mode)

    # Assert
    assert signals is not None
    for key, val in expected_signals.items():
        assert signals[key] == pytest.approx(val)

def test_calculate_raw_signals_all_unique_keys_dedupe():
    # Arrange
    n_records = 100
    key_length = 3
    src_keys = pd.Series([f"key_{i:03}" for i in range(n_records)])
    mode = engine.BlockingMode.DEDUPE

    expected_signals = {
        "RR": 1.0,
        "H": 1.0,
        "G": 0.0,
        "P1": 1.0 / n_records,
        "S1": 1.0,
        "k": float(key_length)
    }

    # Act
    signals = engine.calculate_raw_signals(src_keys, key_length, mode)

    # Assert
    assert signals is not None
    for key, val in expected_signals.items():
        assert signals[key] == pytest.approx(val)

# --- Tests for normalize_signals ---
NORMALIZE_SCENARIOS = [
    ("Hn_zero", {"H": 0.0}, {"Hn": 0.0}),
    ("Hn_mid", {"H": 0.5}, {"Hn": 0.5}),
    ("Hn_one", {"H": 1.0}, {"Hn": 1.0}),
    ("Hn_missing", {}, {"Hn": 0.0}),
    ("Hn_outside_bounds_high", {"H": 1.5}, {"Hn": 1.0}),
    ("Hn_outside_bounds_low", {"H": -0.5}, {"Hn": 0.0}),
    ("Gn_G_zero", {"G": 0.0}, {"Gn": 1.0}),
    ("Gn_G_one", {"G": 1.0}, {"Gn": 0.0}),
    ("Gn_G_mid", {"G": 0.4}, {"Gn": 0.6}),
    ("Gn_missing", {}, {"Gn": 1.0}),
    ("Gn_G_outside_bounds_high", {"G": 1.5}, {"Gn": 0.0}),
    ("Gn_G_outside_bounds_low", {"G": -0.5}, {"Gn": 1.0}),
    ("RRn_RR_zero", {"RR": 0.0}, {"RRn": 0.0}),
    ("RRn_RR_one", {"RR": 1.0}, {"RRn": 1.0}),
    ("RRn_RR_mid", {"RR": 0.25}, {"RRn": 0.5}),
    ("RRn_missing", {}, {"RRn": 0.0}),
    ("RRn_RR_outside_bounds_high", {"RR": 1.5}, {"RRn": 1.0}),
    ("RRn_RR_outside_bounds_low", {"RR": -0.5}, {"RRn": 0.0}),
    ("P1n_P1_zero", {"P1": 0.0}, {"P1n": 1.0}),
    ("P1n_P1_at_threshold_edge_low", {"P1": 0.05}, {"P1n": 0.75}),
    ("P1n_P1_at_threshold", {"P1": 0.20}, {"P1n": 0.0}),
    ("P1n_P1_above_threshold", {"P1": 0.30}, {"P1n": 0.0}),
    ("P1n_P1_one", {"P1": 1.0}, {"P1n": 0.0}),
    ("P1n_missing", {}, {"P1n": 1.0}),
    ("P1n_P1_outside_bounds_high", {"P1": 1.5}, {"P1n": 0.0}),
    ("P1n_P1_outside_bounds_low", {"P1": -0.5}, {"P1n": 1.0}),
    ("S1n_S1_sweet_spot", {"S1": 0.10}, {"S1n": 1.0}),
    ("S1n_S1_zero", {"S1": 0.0}, {"S1n": 0.0}),
    ("S1n_S1_double_sweet_spot_boundary", {"S1": 0.20}, {"S1n": 0.0}),
    ("S1n_S1_mid_lower", {"S1": 0.05}, {"S1n": 0.5}),
    ("S1n_S1_mid_upper", {"S1": 0.15}, {"S1n": 0.5}),
    ("S1n_S1_far_high", {"S1": 0.50}, {"S1n": 0.0}),
    ("S1n_S1_far_low_clamped_raw", {"S1": -0.30}, {"S1n": 0.0}),
    ("S1n_missing", {}, {"S1n": 0.0}),
    ("S1n_S1_outside_bounds_high_clamped_raw", {"S1": 1.5}, {"S1n": 0.0}),
    ("S1n_S1_outside_bounds_low_clamped_raw", {"S1": -0.5}, {"S1n": 0.0}),
    ("kn_k_min", {"k": 2.0}, {"kn": 1.0}),
    ("kn_k_mid1", {"k": 3.0}, {"kn": 0.75}),
    ("kn_k_mid2", {"k": 4.0}, {"kn": 0.5}),
    ("kn_k_mid3", {"k": 5.0}, {"kn": 0.25}),
    ("kn_k_max", {"k": 6.0}, {"kn": 0.0}),
    ("kn_k_below_min_clamp", {"k": 1.0}, {"kn": 1.0}),
    ("kn_k_above_max_clamp", {"k": 7.0}, {"kn": 0.0}),
    ("kn_missing", {}, {"kn": 1.0}),
    ("combined_realistic", {"RR": 0.80, "H": 0.75, "G": 0.2, "P1": 0.15, "S1": 0.08, "k": 3.0},
     {"RRn": math.sqrt(0.80),"Hn": 0.75,"Gn": 0.8,"P1n": 0.25,"S1n": 0.8,"kn": 0.75}),
]

@pytest.mark.parametrize("test_name, raw_signals_input, expected_normalized_output", NORMALIZE_SCENARIOS, ids=[s[0] for s in NORMALIZE_SCENARIOS])
def test_normalize_signals(test_name, raw_signals_input, expected_normalized_output):
    normalized_result = engine.normalize_signals(raw_signals_input, "alnum")
    assert normalized_result is not None, f"{test_name} should not return None"
    for key, expected_value in expected_normalized_output.items():
        assert key in normalized_result, f"{test_name}: Expected key '{key}' not in result"
        assert normalized_result[key] == pytest.approx(expected_value, abs=1e-6), f"{test_name}: Mismatch for '{key}'"

# --- Tests for build_key ---
BUILD_KEY_VARIANT_SCENARIOS = [
    ("alnum_simple", ["Acme Inc."], "alnum", 3, "none", ["acm"]),
    ("alnum_with_punctuation", ["Test & Co."], "alnum", 4, "none", ["test"]),
    ("alnum_long_k", ["LongStringValue"], "alnum", 10, "none", ["longstring"]),
    ("alnum_short_k", ["Short"], "alnum", 3, "none", ["sho"]),
    ("alnum_numbers", ["123 Company St."], "alnum", 15, "none", ["123companyst"]),
    ("alnum_empty_input", [""], "alnum", 3, "none", []),
    ("alnum_all_punctuation", ["!@#$%^"], "alnum", 3, "none", []),
    ("alnum_unicode", ["Über Ällës"], "alnum", 6, "none", ["überäl"]),
    ("soundex_robert_k3", ["Robert"], "soundex", 3, "none", ["R163"]),
    ("soundex_robert_k4", ["Robert"], "soundex", 4, "none", ["R163"]),
    ("soundex_rupert_k4", ["Rupert"], "soundex", 4, "none", ["R163"]),
    ("soundex_ashcraft_k4", ["Ashcraft"], "soundex", 4, "none", ["A261"]),
    ("soundex_empty_input", [""], "soundex", 4, "none", []),
    ("soundex_numbers_only", ["12345"], "soundex", 4, "none", []),
    ("soundex_short_name", ["Bo"], "soundex", 4, "none", ["B000"]),
    ("k_len_zero_becomes_one", ["Test"], "alnum", 0, "none", ["t"]),
    ("k_len_negative_becomes_one", ["Test"], "alnum", -2, "none", ["t"]),
]

@pytest.mark.parametrize("test_name, input_list, variant, k_len, ruleset_name, expected_list", BUILD_KEY_VARIANT_SCENARIOS, ids=[s[0] for s in BUILD_KEY_VARIANT_SCENARIOS])
def test_build_key_variants(test_name, input_list, variant, k_len, ruleset_name, expected_list):
    input_series = pd.Series(input_list, dtype="string", name="test_col")
    expected_series = pd.Series(expected_list, dtype="string", name="test_col")
    result_series, _ = engine.build_key(input_series, variant, k_len, ruleset_name)
    pd.testing.assert_series_equal(result_series, expected_series, check_names=False)


def test_soundex_unicode_and_special_chars():
    """
    Tests that the soundex implementation correctly handles non-ASCII
    and special characters, producing a standard code.
    """
    # Arrange
    input_data = ["Élizabeth", "Джеймс", "O'Malley"]
    # Update the expectation for the transliterated Cyrillic string
    expected_codes = ["E421", "D252", "O540"]
    input_series = pd.Series(input_data)

    # Act
    result_codes = input_series.apply(engine.soundex).tolist()

    # Assert
    assert result_codes == expected_codes, "Soundex did not handle Unicode/special chars as expected"


# --- Tests for early_reject ---
EARLY_REJECT_SCENARIOS = [
    ("P1_reject_dominant_block", {"P1": 0.51, "RR": 0.5, "H": 0.5}, True),
    ("P1_borderline_no_reject", {"P1": 0.50, "RR": 0.5, "H": 0.5}, False),
    ("P1_safe_value", {"P1": 0.20, "RR": 0.5, "H": 0.5}, False),
    ("RR_H_reject_fragmentation", {"P1": 0.1, "RR": 0.9995, "H": 0.05}, True),
    ("RR_H_borderline_RR_no_reject", {"P1": 0.1, "RR": 0.999, "H": 0.05}, True),
    ("RR_H_borderline_H_no_reject", {"P1": 0.1, "RR": 0.9995, "H": 0.1}, True),
    ("RR_H_safe_RR", {"P1": 0.1, "RR": 0.900, "H": 0.05}, True),
    ("RR_H_safe_H", {"P1": 0.1, "RR": 0.9995, "H": 0.5}, False),
    ("no_rejection_conditions_met", {"P1": 0.3, "RR": 0.90, "H": 0.6}, False),
    ("missing_P1_key", {"RR": 0.5, "H": 0.5}, False),
    ("missing_RR_key", {"P1": 0.1, "H": 0.5}, False),
    ("missing_H_key", {"P1": 0.1, "RR": 0.9995}, False),
    ("missing_all_relevant_keys", {}, False),
    ("P1_reject_overrides_safe_RR_H", {"P1": 0.6, "RR": 0.90, "H": 0.6}, True),
]

@pytest.mark.parametrize("test_name, raw_signals_input, expected_rejection", EARLY_REJECT_SCENARIOS, ids=[s[0] for s in EARLY_REJECT_SCENARIOS])
def test_early_reject(test_name, raw_signals_input, expected_rejection):
    should_reject = engine.early_reject(raw_signals_input)
    assert should_reject == expected_rejection, f"{test_name}: Rejection status mismatch"

# --- Tests for determine_optimal_blocking_strategy ---
def test_determine_optimal_blocking_strategy_successful_selection_dedupe():
    # Arrange
    np.random.seed(42)
    data = {
        'id': [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
        'name': ["Apple Inc", "Apple Inc.", "Appel Corp", "Banana Ltd", "Banana Ltd", "Bonanza Co", "Cherry Plc", "Cherry Plc", "Cheri Co", "Date Co", "Date Co", "Date Co"],
        'city': ["London", "London", "Paris", "London", "Paris", "Paris", "Berlin", "Berlin", "Tokyo", "Tokyo", "Rome", "Rome"],
        'zip': [str(i) for i in range(100, 112)]
    }
    df_src_data = pd.DataFrame(data)
    mappings = [{"source": "name"}, {"source": "city"}, {"source": "zip"}]

    restrict_to_pairs_data = [(m['source'], m['source']) for m in mappings]

    original_hard_cap, engine.HARD_CAP_COMPS = engine.HARD_CAP_COMPS, 50
    original_min_qs, engine.MIN_ACCEPTABLE_QS = engine.MIN_ACCEPTABLE_QS, 0.1

    try:
        # FIX: Removed the unexpected 'key_len_range' keyword argument to
        # match the function's actual signature in engine.py.
        result = engine.determine_optimal_blocking_strategy(
            src_df=df_src_data,
            ref_df=None,
            restrict_to_pairs=restrict_to_pairs_data,
            preprocessing_variants=("alnum",)
        )
        assert result is not None, "Strategy should not be None"
        assert result.get("apply_blocking") is True
        assert result.get("is_fallback") is False
        assert result.get("source_block_col") == "name"
    finally:
        engine.HARD_CAP_COMPS, engine.MIN_ACCEPTABLE_QS = original_hard_cap, original_min_qs

# --- Recalculated test data for quality_score ---
NEW_WEIGHTS = {'rr': 0.10, 'h': 0.20, 'g': 0.30, 'p1': 0.25, 's1': 0.10, 'k': 0.05}
expected_all_one = sum(NEW_WEIGHTS.values()) * 0.1 # Penalized for RRn > 0.999
expected_only_rr_one = NEW_WEIGHTS['rr'] * 0.1 # Penalized for RRn > 0.999
expected_mixed = ((NEW_WEIGHTS['rr'] * 0.8) + (NEW_WEIGHTS['h'] * 0.7**2) + (NEW_WEIGHTS['g'] * 0.9) + (NEW_WEIGHTS['p1'] * 0.6) + (NEW_WEIGHTS['s1'] * 0.5) + (NEW_WEIGHTS['k'] * 0.75))
# Correct calculation: score is calculated first, then penalized.
# score = (0.10 * 1.0) + (0.20 * 1.0**2) = 0.30. Then, 0.30 * 0.1 (penalty) = 0.03
expected_some_missing = (NEW_WEIGHTS['rr'] * 1.0 + NEW_WEIGHTS['h'] * 1.0**2) * 0.1

QS_SCENARIOS = [
    ("all_normalized_zero", {"RRn": 0.0, "Hn": 0.0, "Gn": 0.0, "P1n": 0.0, "S1n": 0.0, "kn": 0.0}, 0.0),
    ("all_normalized_one", {"RRn": 1.0, "Hn": 1.0, "Gn": 1.0, "P1n": 1.0, "S1n": 1.0, "kn": 1.0}, expected_all_one),
    ("only_RRn_one", {"RRn": 1.0, "Hn": 0.0, "Gn": 0.0, "P1n": 0.0, "S1n": 0.0, "kn": 0.0}, expected_only_rr_one),
    ("only_Hn_one", {"RRn": 0.0, "Hn": 1.0, "Gn": 0.0, "P1n": 0.0, "S1n": 0.0, "kn": 0.0}, NEW_WEIGHTS['h']),
    ("mixed_values_realistic", {"RRn": 0.8, "Hn": 0.7, "Gn": 0.9, "P1n": 0.6, "S1n": 0.5, "kn": 0.75}, expected_mixed),
    ("some_keys_missing", {"RRn": 1.0, "Hn": 1.0}, expected_some_missing),
    ("all_keys_missing", {}, 0.0)
]

@pytest.mark.parametrize("test_name, normalized_signals_input, expected_qs", QS_SCENARIOS, ids=[s[0] for s in QS_SCENARIOS])
def test_quality_score(test_name, normalized_signals_input, expected_qs):
    calculated_qs = engine.quality_score(normalized_signals_input)
    assert calculated_qs == pytest.approx(expected_qs, abs=1e-6), f"{test_name}: Quality score mismatch"

# --- Test for cache roundtrip ---
def test_cached_score_roundtrip(tmp_path: Path):
    db_path = ":memory:"
    _init_score_cache_db(str(db_path))
    score1 = cached_score(s1="apple", s2="apply", algo="WRatio", processing_mode="scale", ruleset_name="test_ruleset", db_path=str(db_path))
    assert isinstance(score1, int) and score1 > 0
    score2 = cached_score(s1="apple", s2="apply", algo="WRatio", processing_mode="scale", ruleset_name="test_ruleset", db_path=str(db_path))
    assert score2 == score1

def test_apply_custom_rules_vectorized():
    """
    Tests that custom rules are applied correctly.
    """
    # Arrange
    df = pd.DataFrame({
        'score_levenshtein': [80, 95, 70],
        'is_exact_match': [False, True, False]
    })
    rules = [
        {"condition": "score_levenshtein > 85", "score_adjustment": 10},
        {"condition": "is_exact_match == True", "score_adjustment": 50}
    ]

    # Act
    result_df = apply_custom_rules(df, rules)

    # Assert
    assert "custom_rule_score" in result_df.columns
    assert result_df.loc[0, 'custom_rule_score'] == 0
    assert result_df.loc[1, 'custom_rule_score'] == 60
    assert result_df.loc[2, 'custom_rule_score'] == 0


def test_apply_custom_rules_security_no_side_effects(monkeypatch):
    """
    Tests that a malicious rule is not executed and has no effect,
    which aligns with the engine's behavior of logging errors instead of raising them.
    """
    # Arrange: Mock os.system to track if it gets called
    mock_system = patch('os.system').start()

    df = pd.DataFrame({'score': [80]})
    # This rule attempts to use a forbidden import and function
    malicious_rule = [{"condition": "__import__('os').system('echo pwned')", "score_adjustment": 1000}]

    # Act
    result_df = apply_custom_rules(df, malicious_rule)

    # Assert: The malicious function was NOT called, and no score was applied
    mock_system.assert_not_called()
    assert result_df['custom_rule_score'].iloc[0] == 0

    patch.stopall() # Clean up the patch
