"""Tests for evaluate() and evaluate_holdout()."""
from __future__ import annotations

import pytest

from kanoniv.source import Source
from kanoniv.spec import Spec

try:
    from kanoniv._native import reconcile_local

    HAS_NATIVE = True
except ImportError:
    HAS_NATIVE = False

pytestmark = pytest.mark.skipif(not HAS_NATIVE, reason="Native extension not built")

# --- Spec with rules-based matching on email ---
SPEC_YAML = """\
api_version: kanoniv/v2
identity_version: test_eval_v1
entity:
  name: customer
sources:
  - name: crm
    system: csv
    table: crm_export
    id: id
    attributes:
      email: email
      name: name
      phone: phone
  - name: orders
    system: csv
    table: order_export
    id: id
    attributes:
      email: email
      name: name
      phone: phone
blocking:
  strategy: composite
  keys:
    - [email]
    - [phone]
rules:
  - name: email_exact
    type: exact
    field: email
    weight: 1.0
  - name: phone_exact
    type: exact
    field: phone
    weight: 1.0
decision:
  thresholds:
    match: 0.8
    review: 0.5
  conflict_strategy: prefer_high_confidence
"""


@pytest.fixture
def perfect_csvs(tmp_path):
    """Two sources where email matching produces perfect results."""
    crm = tmp_path / "crm.csv"
    crm.write_text(
        "id,email,name,phone\n"
        "c1,alice@example.com,Alice,111\n"
        "c2,bob@example.com,Bob,222\n"
    )
    orders = tmp_path / "orders.csv"
    orders.write_text(
        "id,email,name,phone\n"
        "o1,alice@example.com,Alice S,111\n"
        "o2,bob@example.com,Bob J,222\n"
    )
    return str(crm), str(orders)


@pytest.fixture
def overmerge_csvs(tmp_path):
    """Sources where phone matching causes an incorrect merge (false positive)."""
    crm = tmp_path / "crm.csv"
    crm.write_text(
        "id,email,name,phone\n"
        "c1,alice@example.com,Alice,111\n"
        "c2,bob@example.com,Bob,222\n"
        "c3,carol@example.com,Carol,333\n"
    )
    orders = tmp_path / "orders.csv"
    orders.write_text(
        "id,email,name,phone\n"
        "o1,alice@example.com,Alice S,111\n"
        "o2,dave@example.com,Dave,222\n"
    )
    return str(crm), str(orders)


@pytest.fixture
def undermerge_csvs(tmp_path):
    """Sources where records should merge but don't (false negatives)."""
    crm = tmp_path / "crm.csv"
    crm.write_text(
        "id,email,name,phone\n"
        "c1,alice@example.com,Alice,111\n"
        "c2,bob@example.com,Bob,222\n"
    )
    orders = tmp_path / "orders.csv"
    orders.write_text(
        "id,email,name,phone\n"
        # Same alice by email, but different phone - should still merge on email
        "o1,alice@example.com,Alice S,999\n"
        # Bob uses a different email - won't merge
        "o2,robert@example.com,Bob J,222\n"
    )
    return str(crm), str(orders)


class TestEvaluate:
    def test_perfect_prediction_dict_format(self, perfect_csvs):
        from kanoniv.evaluate import evaluate
        from kanoniv.reconcile import reconcile

        crm_path, orders_path = perfect_csvs
        crm = Source.from_csv("crm", crm_path, primary_key="id")
        orders = Source.from_csv("orders", orders_path, primary_key="id")
        spec = Spec.from_string(SPEC_YAML)

        result = reconcile([crm, orders], spec, return_entity_map=True)

        # Ground truth: alice and bob each appear in both sources
        gt = {
            "alice": [("crm", "c1"), ("orders", "o1")],
            "bob": [("crm", "c2"), ("orders", "o2")],
        }

        metrics = evaluate(result, [crm, orders], gt)

        assert metrics.precision == 1.0
        assert metrics.recall == 1.0
        assert metrics.f1 == 1.0
        assert metrics.true_positives == 2  # two correct pairs
        assert metrics.false_positives == 0
        assert metrics.false_negatives == 0

    def test_perfect_prediction_dataframe_format(self, perfect_csvs):
        pd = pytest.importorskip("pandas")
        from kanoniv.evaluate import evaluate
        from kanoniv.reconcile import reconcile

        crm_path, orders_path = perfect_csvs
        crm = Source.from_csv("crm", crm_path, primary_key="id")
        orders = Source.from_csv("orders", orders_path, primary_key="id")
        spec = Spec.from_string(SPEC_YAML)

        result = reconcile([crm, orders], spec, return_entity_map=True)

        gt_df = pd.DataFrame([
            {"record_id": "c1", "source_name": "crm", "true_entity_id": "alice"},
            {"record_id": "o1", "source_name": "orders", "true_entity_id": "alice"},
            {"record_id": "c2", "source_name": "crm", "true_entity_id": "bob"},
            {"record_id": "o2", "source_name": "orders", "true_entity_id": "bob"},
        ])

        metrics = evaluate(result, [crm, orders], gt_df)

        assert metrics.precision == 1.0
        assert metrics.recall == 1.0
        assert metrics.f1 == 1.0

    def test_false_positives_over_merging(self, overmerge_csvs):
        from kanoniv.evaluate import evaluate
        from kanoniv.reconcile import reconcile

        crm_path, orders_path = overmerge_csvs
        crm = Source.from_csv("crm", crm_path, primary_key="id")
        orders = Source.from_csv("orders", orders_path, primary_key="id")
        spec = Spec.from_string(SPEC_YAML)

        result = reconcile([crm, orders], spec, return_entity_map=True)

        # Truth: alice=c1+o1, bob=c2 alone, carol=c3, dave=o2 alone
        # The engine merges bob+dave on phone (222) - that's a false positive
        gt = {
            "alice": [("crm", "c1"), ("orders", "o1")],
        }

        metrics = evaluate(result, [crm, orders], gt)

        # alice pair is correct
        assert metrics.true_positives >= 1
        assert metrics.precision <= 1.0

    def test_false_negatives_under_merging(self, undermerge_csvs):
        from kanoniv.evaluate import evaluate
        from kanoniv.reconcile import reconcile

        crm_path, orders_path = undermerge_csvs
        crm = Source.from_csv("crm", crm_path, primary_key="id")
        orders = Source.from_csv("orders", orders_path, primary_key="id")
        spec = Spec.from_string(SPEC_YAML)

        result = reconcile([crm, orders], spec, return_entity_map=True)

        # Truth says bob (c2) and robert (o2) are the same person
        # Engine can't see this because emails differ - false negative
        gt = {
            "alice": [("crm", "c1"), ("orders", "o1")],
            "bob": [("crm", "c2"), ("orders", "o2")],
        }

        metrics = evaluate(result, [crm, orders], gt)

        # alice merges correctly, bob doesn't
        assert metrics.false_negatives >= 1
        assert metrics.recall < 1.0

    def test_missing_entity_map_raises(self, perfect_csvs):
        from kanoniv.evaluate import evaluate
        from kanoniv.reconcile import reconcile

        crm_path, orders_path = perfect_csvs
        crm = Source.from_csv("crm", crm_path, primary_key="id")
        orders = Source.from_csv("orders", orders_path, primary_key="id")
        spec = Spec.from_string(SPEC_YAML)

        # No return_entity_map=True
        result = reconcile([crm, orders], spec)

        gt = {"alice": [("crm", "c1"), ("orders", "o1")]}

        with pytest.raises(ValueError, match="_entity_map"):
            evaluate(result, [crm, orders], gt)

    def test_evaluate_result_summary(self):
        from kanoniv.evaluate import EvaluateResult

        r = EvaluateResult(
            precision=0.8, recall=0.9, f1=0.8471,
            true_positives=9, false_positives=2, false_negatives=1,
            predicted_pairs=11, ground_truth_pairs=10,
            predicted_clusters=5, ground_truth_clusters=6,
            total_records=20,
        )
        s = r.summary()
        assert "Precision" in s
        assert "0.8000" in s
        assert "0.9000" in s

    def test_evaluate_result_repr(self):
        from kanoniv.evaluate import EvaluateResult

        r = EvaluateResult(
            precision=0.85, recall=0.90, f1=0.8743,
            true_positives=9, false_positives=2, false_negatives=1,
            predicted_pairs=11, ground_truth_pairs=10,
            predicted_clusters=5, ground_truth_clusters=6,
            total_records=20,
        )
        assert "precision=0.8500" in repr(r)
        assert "recall=0.9000" in repr(r)


class TestEvaluateHoldout:
    def test_holdout_with_shared_field(self, tmp_path):
        """Hold out phone, use it as ground truth. Records sharing phone should cluster."""
        from kanoniv.evaluate import evaluate_holdout

        crm = tmp_path / "crm.csv"
        crm.write_text(
            "id,email,name,phone\n"
            "c1,alice@example.com,Alice,555-0001\n"
            "c2,bob@example.com,Bob,555-0002\n"
            "c3,carol@example.com,Carol,555-0003\n"
        )
        orders = tmp_path / "orders.csv"
        orders.write_text(
            "id,email,name,phone\n"
            "o1,alice2@example.com,Alice S,555-0001\n"
            "o2,bob2@example.com,Bob J,555-0002\n"
        )

        crm_src = Source.from_csv("crm", str(crm), primary_key="id")
        orders_src = Source.from_csv("orders", str(orders), primary_key="id")
        spec = Spec.from_string(SPEC_YAML)

        # Hold out phone - ground truth is built from shared phone values
        # Matching must succeed on email alone
        metrics = evaluate_holdout(
            [crm_src, orders_src], spec, "phone"
        )

        assert isinstance(metrics.precision, float)
        assert isinstance(metrics.recall, float)
        assert isinstance(metrics.f1, float)
        assert metrics.ground_truth_clusters >= 1

    def test_holdout_no_valid_clusters_raises(self, tmp_path):
        """If no records share the holdout field value, raise ValueError."""
        from kanoniv.evaluate import evaluate_holdout

        crm = tmp_path / "crm.csv"
        crm.write_text(
            "id,email,name,phone\n"
            "c1,alice@example.com,Alice,unique1\n"
            "c2,bob@example.com,Bob,unique2\n"
        )
        orders = tmp_path / "orders.csv"
        orders.write_text(
            "id,email,name,phone\n"
            "o1,alice@example.com,Alice S,unique3\n"
        )

        crm_src = Source.from_csv("crm", str(crm), primary_key="id")
        orders_src = Source.from_csv("orders", str(orders), primary_key="id")
        spec = Spec.from_string(SPEC_YAML)

        with pytest.raises(ValueError, match="No valid ground truth"):
            evaluate_holdout([crm_src, orders_src], spec, "phone")

    def test_holdout_excludes_empty_values(self, tmp_path):
        """Empty/null holdout values should be excluded from ground truth."""
        from kanoniv.evaluate import _build_holdout_ground_truth

        crm = tmp_path / "crm.csv"
        crm.write_text(
            "id,email,name,phone\n"
            "c1,alice@example.com,Alice,555-0001\n"
            "c2,bob@example.com,Bob,\n"
            "c3,carol@example.com,Carol,555-0001\n"
        )

        src = Source.from_csv("crm", str(crm), primary_key="id")
        clusters = _build_holdout_ground_truth([src], "phone", min_size=2)

        # Only one cluster: c1 and c3 share phone 555-0001
        assert len(clusters) == 1
        cluster = clusters[0]
        assert ("crm", "c1") in cluster
        assert ("crm", "c3") in cluster

    def test_holdout_no_primary_key_raises(self, tmp_path):
        """Source without primary_key should raise ValueError."""
        from kanoniv.evaluate import _build_holdout_ground_truth

        crm = tmp_path / "crm.csv"
        crm.write_text("id,email,phone\nc1,a@b.com,111\n")

        src = Source.from_csv("crm", str(crm))  # no primary_key

        with pytest.raises(ValueError, match="primary_key"):
            _build_holdout_ground_truth([src], "phone")


class TestInternalHelpers:
    def test_pairs_from_clusters(self):
        from kanoniv.evaluate import _pairs_from_clusters

        clusters = [
            {("a", "1"), ("b", "2"), ("c", "3")},
            {("d", "4"), ("e", "5")},
        ]
        pairs = _pairs_from_clusters(clusters)
        # First cluster: 3 pairs (3 choose 2), second: 1 pair
        assert len(pairs) == 4

    def test_pairs_from_single_member_clusters(self):
        from kanoniv.evaluate import _pairs_from_clusters

        clusters = [{("a", "1")}]
        pairs = _pairs_from_clusters(clusters)
        assert len(pairs) == 0

    def test_parse_ground_truth_invalid_type(self):
        from kanoniv.evaluate import _parse_ground_truth

        with pytest.raises(TypeError, match="dict or pandas DataFrame"):
            _parse_ground_truth("invalid")

    def test_remove_field_from_spec(self):
        from kanoniv.evaluate import _remove_field_from_spec

        spec = Spec.from_string(SPEC_YAML)
        modified = _remove_field_from_spec(spec, "email")

        # email should be gone from rules
        for rule in modified.rules:
            assert rule.get("field") != "email"

        # email should be gone from blocking keys
        parsed = modified.parsed
        blocking_keys = parsed.get("blocking", {}).get("keys", [])
        for key_list in blocking_keys:
            assert "email" not in key_list

    def test_remove_field_from_fs_spec(self):
        """Test removing a field from a Fellegi-Sunter spec."""
        from kanoniv.evaluate import _remove_field_from_spec

        fs_spec_yaml = """\
api_version: kanoniv/v2
identity_version: test_v1
entity:
  name: customer
sources:
  - name: crm
    system: csv
    table: crm
    id: id
blocking:
  strategy: composite
  keys:
    - [email]
    - [last_name]
decision:
  scoring:
    strategy: fellegi_sunter
    fields:
      - name: email
        comparator: exact
        weight: 2.0
      - name: last_name
        comparator: jaro_winkler
        weight: 1.0
    thresholds:
      match: 8.0
      possible: 4.0
  thresholds:
    match: 0.9
    review: 0.7
"""
        spec = Spec.from_string(fs_spec_yaml)
        modified = _remove_field_from_spec(spec, "email")

        scoring = modified.parsed.get("decision", {}).get("scoring", {})
        for f in scoring.get("fields", []):
            assert f.get("name") != "email"

        blocking_keys = modified.parsed.get("blocking", {}).get("keys", [])
        for key_list in blocking_keys:
            assert "email" not in key_list
