"""End-to-end tests for the reconcile() function."""
import json
import tempfile
import warnings
from pathlib import Path

import pytest

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

# These tests require the native extension (maturin develop) to be installed.
# They will be skipped if the native module is not available.
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_YAML = """\
api_version: kanoniv/v2
identity_version: test_v1.0
entity:
  name: customer
sources:
  - name: crm
    system: csv
    table: crm_export
    id: id
    attributes:
      email: email
      name: name
  - name: orders
    system: csv
    table: order_export
    id: id
    attributes:
      email: email
      name: name
blocking:
  strategy: composite
  keys:
    - [email]
rules:
  - name: email_exact
    type: exact
    field: email
    weight: 1.0
decision:
  thresholds:
    match: 0.8
    review: 0.5
  conflict_strategy: prefer_high_confidence
"""


@pytest.fixture
def crm_csv(tmp_path):
    p = tmp_path / "crm.csv"
    p.write_text(
        "id,email,name\n"
        "c1,alice@example.com,Alice Smith\n"
        "c2,bob@example.com,Bob Jones\n"
        "c3,carol@example.com,Carol White\n"
    )
    return str(p)


@pytest.fixture
def orders_csv(tmp_path):
    p = tmp_path / "orders.csv"
    p.write_text(
        "id,email,name\n"
        "o1,alice@example.com,Alice S.\n"
        "o2,dave@example.com,Dave Brown\n"
    )
    return str(p)


class TestReconcileEndToEnd:
    def test_overlapping_emails_merge(self, crm_csv, orders_csv):
        from kanoniv.reconcile import reconcile

        crm = Source.from_csv("crm", crm_csv, primary_key="id")
        orders = Source.from_csv("orders", orders_csv, primary_key="id")
        spec = Spec.from_string(SPEC_YAML)

        result = reconcile(sources=[crm, orders], spec=spec)

        # Alice appears in both sources — should be merged into one cluster
        # Total entities: 5 (c1, c2, c3, o1, o2)
        # Expected clusters: 4 (alice merged, bob, carol, dave as singletons)
        assert result.cluster_count == 4

    def test_golden_records_count_matches_clusters(self, crm_csv, orders_csv):
        from kanoniv.reconcile import reconcile

        crm = Source.from_csv("crm", crm_csv, primary_key="id")
        orders = Source.from_csv("orders", orders_csv, primary_key="id")
        spec = Spec.from_string(SPEC_YAML)

        result = reconcile(sources=[crm, orders], spec=spec)
        assert len(result.golden_records) == result.cluster_count

    def test_decisions_populated(self, crm_csv, orders_csv):
        from kanoniv.reconcile import reconcile

        crm = Source.from_csv("crm", crm_csv, primary_key="id")
        orders = Source.from_csv("orders", orders_csv, primary_key="id")
        spec = Spec.from_string(SPEC_YAML)

        result = reconcile(sources=[crm, orders], spec=spec)
        # At least one merge decision (alice)
        assert len(result.decisions) >= 1

    def test_telemetry_populated(self, crm_csv, orders_csv):
        from kanoniv.reconcile import reconcile

        crm = Source.from_csv("crm", crm_csv, primary_key="id")
        orders = Source.from_csv("orders", orders_csv, primary_key="id")
        spec = Spec.from_string(SPEC_YAML)

        result = reconcile(sources=[crm, orders], spec=spec)
        assert "rule_telemetry" in result.telemetry
        assert "pairs_evaluated" in result.telemetry

    def test_merge_rate(self, crm_csv, orders_csv):
        from kanoniv.reconcile import reconcile

        crm = Source.from_csv("crm", crm_csv, primary_key="id")
        orders = Source.from_csv("orders", orders_csv, primary_key="id")
        spec = Spec.from_string(SPEC_YAML)

        result = reconcile(sources=[crm, orders], spec=spec)
        # 5 entities, 4 clusters → merge_rate = 1 - 4/5 = 0.2
        assert result.merge_rate == pytest.approx(0.2)

    def test_no_overlap_no_merges(self, tmp_path):
        from kanoniv.reconcile import reconcile

        csv_a = tmp_path / "crm.csv"
        csv_a.write_text("id,email,name\na1,x@x.com,X\n")
        csv_b = tmp_path / "orders.csv"
        csv_b.write_text("id,email,name\nb1,y@y.com,Y\n")

        src_a = Source.from_csv("crm", str(csv_a), primary_key="id")
        src_b = Source.from_csv("orders", str(csv_b), primary_key="id")
        spec = Spec.from_string(SPEC_YAML)

        result = reconcile(sources=[src_a, src_b], spec=spec)
        assert result.cluster_count == 2
        assert result.merge_rate == 0.0


class TestPersistence:
    def test_save_load_roundtrip(self, crm_csv, orders_csv, tmp_path):
        from kanoniv.reconcile import reconcile, ReconcileResult

        crm = Source.from_csv("crm", crm_csv, primary_key="id")
        orders = Source.from_csv("orders", orders_csv, primary_key="id")
        spec = Spec.from_string(SPEC_YAML)

        result = reconcile(sources=[crm, orders], spec=spec)
        knv_path = str(tmp_path / "state.knv")
        result.save(knv_path)

        loaded = ReconcileResult.load(knv_path)
        assert loaded.clusters == result.clusters
        assert loaded.golden_records == result.golden_records
        assert loaded.cluster_count == result.cluster_count

    def test_save_load_evaluate(self, crm_csv, orders_csv, tmp_path):
        from kanoniv.reconcile import reconcile, ReconcileResult

        crm = Source.from_csv("crm", crm_csv, primary_key="id")
        orders = Source.from_csv("orders", orders_csv, primary_key="id")
        spec = Spec.from_string(SPEC_YAML)

        result = reconcile(sources=[crm, orders], spec=spec)
        knv_path = str(tmp_path / "state.knv")
        result.save(knv_path)

        loaded = ReconcileResult.load(knv_path)
        eval_result = loaded.evaluate()
        assert eval_result is not None
        assert eval_result.total_clusters == loaded.cluster_count

    def test_incremental_new_records(self, crm_csv, orders_csv, tmp_path):
        from kanoniv.reconcile import reconcile

        # Initial run: 3 CRM records + 2 order records = 5 entities, 4 clusters
        crm = Source.from_csv("crm", crm_csv, primary_key="id")
        orders = Source.from_csv("orders", orders_csv, primary_key="id")
        spec = Spec.from_string(SPEC_YAML)

        result = reconcile(sources=[crm, orders], spec=spec)
        assert result.cluster_count == 4

        knv_path = str(tmp_path / "state.knv")
        result.save(knv_path)

        # Add new orders: one matching alice, one new person
        new_orders_csv = tmp_path / "new_orders.csv"
        new_orders_csv.write_text(
            "id,email,name\n"
            "o3,alice@example.com,A. Smith\n"
            "o4,eve@example.com,Eve Green\n"
        )
        new_orders = Source.from_csv("orders", str(new_orders_csv), primary_key="id")

        result2 = reconcile(sources=[new_orders], spec=spec, previous=knv_path)

        # o3 (alice) should merge into existing alice cluster
        # o4 (eve) is a new singleton
        # Total clusters: 4 (alice merged) + 1 (eve) = 5
        assert result2.cluster_count == 5

        # Alice cluster should have 3 members now (c1, o1, o3)
        alice_cluster = [c for c in result2.clusters if len(c) >= 3]
        assert len(alice_cluster) == 1
        assert len(alice_cluster[0]) == 3

    def test_incremental_preserves_existing(self, crm_csv, orders_csv, tmp_path):
        from kanoniv.reconcile import reconcile

        crm = Source.from_csv("crm", crm_csv, primary_key="id")
        orders = Source.from_csv("orders", orders_csv, primary_key="id")
        spec = Spec.from_string(SPEC_YAML)

        result = reconcile(sources=[crm, orders], spec=spec)
        original_cluster_count = result.cluster_count

        knv_path = str(tmp_path / "state.knv")
        result.save(knv_path)

        # Add one completely new, non-matching record
        new_csv = tmp_path / "new.csv"
        new_csv.write_text("id,email,name\nn1,frank@example.com,Frank\n")
        new_src = Source.from_csv("orders", str(new_csv), primary_key="id")

        result2 = reconcile(sources=[new_src], spec=spec, previous=knv_path)

        # Should be original clusters + 1 new singleton
        assert result2.cluster_count == original_cluster_count + 1

    def test_spec_hash_mismatch_warns(self, crm_csv, orders_csv, tmp_path):
        from kanoniv.reconcile import reconcile

        crm = Source.from_csv("crm", crm_csv, primary_key="id")
        orders = Source.from_csv("orders", orders_csv, primary_key="id")
        spec = Spec.from_string(SPEC_YAML)

        result = reconcile(sources=[crm, orders], spec=spec)
        knv_path = str(tmp_path / "state.knv")
        result.save(knv_path)

        # Change the spec (different threshold)
        changed_yaml = SPEC_YAML.replace("match: 0.8", "match: 0.6")
        changed_spec = Spec.from_string(changed_yaml)

        new_csv = tmp_path / "new.csv"
        new_csv.write_text("id,email,name\nn1,x@x.com,X\n")
        new_src = Source.from_csv("orders", str(new_csv), primary_key="id")

        with warnings.catch_warnings(record=True) as w:
            warnings.simplefilter("always")
            reconcile(sources=[new_src], spec=changed_spec, previous=knv_path)
            spec_warnings = [x for x in w if "Spec has changed" in str(x.message)]
            assert len(spec_warnings) == 1

    def test_load_nonexistent_file_raises(self):
        from kanoniv.reconcile import ReconcileResult

        with pytest.raises(FileNotFoundError):
            ReconcileResult.load("/nonexistent/path/state.knv")

    def test_incremental_with_result_object(self, crm_csv, orders_csv, tmp_path):
        """Test passing a ReconcileResult directly instead of a path."""
        from kanoniv.reconcile import reconcile

        crm = Source.from_csv("crm", crm_csv, primary_key="id")
        orders = Source.from_csv("orders", orders_csv, primary_key="id")
        spec = Spec.from_string(SPEC_YAML)

        result = reconcile(sources=[crm, orders], spec=spec)

        new_csv = tmp_path / "new.csv"
        new_csv.write_text("id,email,name\nn1,frank@example.com,Frank\n")
        new_src = Source.from_csv("orders", str(new_csv), primary_key="id")

        result2 = reconcile(sources=[new_src], spec=spec, previous=result)
        assert result2.cluster_count == result.cluster_count + 1


class TestEntityLookup:
    def test_entity_lookup_columns(self, crm_csv, orders_csv):
        from kanoniv.reconcile import reconcile

        crm = Source.from_csv("crm", crm_csv, primary_key="id")
        orders = Source.from_csv("orders", orders_csv, primary_key="id")
        spec = Spec.from_string(SPEC_YAML)

        result = reconcile(sources=[crm, orders], spec=spec)
        df = result.entity_lookup

        assert list(df.columns) == ["source_system", "source_id", "kanoniv_id"]
        assert len(df) == 5  # c1, c2, c3, o1, o2

    def test_entity_lookup_all_records_present(self, crm_csv, orders_csv):
        from kanoniv.reconcile import reconcile

        crm = Source.from_csv("crm", crm_csv, primary_key="id")
        orders = Source.from_csv("orders", orders_csv, primary_key="id")
        spec = Spec.from_string(SPEC_YAML)

        result = reconcile(sources=[crm, orders], spec=spec)
        df = result.entity_lookup

        source_ids = set(df["source_id"])
        assert source_ids == {"c1", "c2", "c3", "o1", "o2"}

    def test_merged_records_share_kanoniv_id(self, crm_csv, orders_csv):
        from kanoniv.reconcile import reconcile

        crm = Source.from_csv("crm", crm_csv, primary_key="id")
        orders = Source.from_csv("orders", orders_csv, primary_key="id")
        spec = Spec.from_string(SPEC_YAML)

        result = reconcile(sources=[crm, orders], spec=spec)
        df = result.entity_lookup

        # c1 (alice from crm) and o1 (alice from orders) should share kanoniv_id
        c1_kid = df[df["source_id"] == "c1"]["kanoniv_id"].iloc[0]
        o1_kid = df[df["source_id"] == "o1"]["kanoniv_id"].iloc[0]
        assert c1_kid == o1_kid

        # bob and dave should have different kanoniv_ids
        c2_kid = df[df["source_id"] == "c2"]["kanoniv_id"].iloc[0]
        o2_kid = df[df["source_id"] == "o2"]["kanoniv_id"].iloc[0]
        assert c2_kid != o2_kid

    def test_entity_lookup_after_load(self, crm_csv, orders_csv, tmp_path):
        from kanoniv.reconcile import reconcile, ReconcileResult

        crm = Source.from_csv("crm", crm_csv, primary_key="id")
        orders = Source.from_csv("orders", orders_csv, primary_key="id")
        spec = Spec.from_string(SPEC_YAML)

        result = reconcile(sources=[crm, orders], spec=spec)
        knv_path = str(tmp_path / "state.knv")
        result.save(knv_path)

        loaded = ReconcileResult.load(knv_path)
        df = loaded.entity_lookup
        assert len(df) == 5
        assert set(df["source_id"]) == {"c1", "c2", "c3", "o1", "o2"}


class TestChangeLog:
    def test_incremental_creates_and_grows(self, crm_csv, orders_csv, tmp_path):
        """Incremental run: one record merges (grown), one new (created)."""
        from kanoniv.reconcile import reconcile

        crm = Source.from_csv("crm", crm_csv, primary_key="id")
        orders = Source.from_csv("orders", orders_csv, primary_key="id")
        spec = Spec.from_string(SPEC_YAML)

        result1 = reconcile(sources=[crm, orders], spec=spec)
        assert result1.cluster_count == 4

        new_csv = tmp_path / "new.csv"
        new_csv.write_text(
            "id,email,name\n"
            "o3,alice@example.com,A. Smith\n"  # should merge with alice
            "o4,eve@example.com,Eve Green\n"   # new singleton
        )
        new_orders = Source.from_csv("orders", str(new_csv), primary_key="id")
        result2 = reconcile(sources=[new_orders], spec=spec, previous=result1)

        changelog = result2.changes_since(result1)

        assert len(changelog.created) == 1  # eve
        assert len(changelog.grown) == 1    # alice cluster absorbed o3
        assert changelog.unchanged_count >= 2  # bob, carol, dave unchanged

        # Eve is a new singleton
        eve_change = changelog.created[0]
        assert ("orders", "o4") in eve_change.source_records

        # Alice cluster grew
        alice_change = changelog.grown[0]
        assert ("orders", "o3") in alice_change.new_records
        assert len(alice_change.source_records) == 3  # c1, o1, o3

    def test_no_changes_when_same_result(self, crm_csv, orders_csv):
        from kanoniv.reconcile import reconcile

        crm = Source.from_csv("crm", crm_csv, primary_key="id")
        orders = Source.from_csv("orders", orders_csv, primary_key="id")
        spec = Spec.from_string(SPEC_YAML)

        result = reconcile(sources=[crm, orders], spec=spec)
        changelog = result.changes_since(result)

        assert len(changelog) == 0
        assert changelog.unchanged_count == result.cluster_count
        assert "no changes" not in repr(changelog)  # has unchanged count
        assert "unchanged" in repr(changelog)

    def test_changelog_summary(self, crm_csv, orders_csv, tmp_path):
        from kanoniv.reconcile import reconcile

        crm = Source.from_csv("crm", crm_csv, primary_key="id")
        orders = Source.from_csv("orders", orders_csv, primary_key="id")
        spec = Spec.from_string(SPEC_YAML)

        result1 = reconcile(sources=[crm, orders], spec=spec)

        new_csv = tmp_path / "new.csv"
        new_csv.write_text("id,email,name\no3,alice@example.com,Alice\no4,eve@example.com,Eve\n")
        new_orders = Source.from_csv("orders", str(new_csv), primary_key="id")
        result2 = reconcile(sources=[new_orders], spec=spec, previous=result1)

        changelog = result2.changes_since(result1)
        summary = changelog.summary
        assert "created" in summary
        assert "grown" in summary
        assert "unchanged" in summary

    def test_changelog_to_pandas(self, crm_csv, orders_csv, tmp_path):
        from kanoniv.reconcile import reconcile

        crm = Source.from_csv("crm", crm_csv, primary_key="id")
        orders = Source.from_csv("orders", orders_csv, primary_key="id")
        spec = Spec.from_string(SPEC_YAML)

        result1 = reconcile(sources=[crm, orders], spec=spec)

        new_csv = tmp_path / "new.csv"
        new_csv.write_text("id,email,name\no4,eve@example.com,Eve\n")
        new_orders = Source.from_csv("orders", str(new_csv), primary_key="id")
        result2 = reconcile(sources=[new_orders], spec=spec, previous=result1)

        changelog = result2.changes_since(result1)
        df = changelog.to_pandas()

        assert "change_type" in df.columns
        assert "kanoniv_id" in df.columns
        assert len(df) >= 1

    def test_changelog_iterable(self, crm_csv, orders_csv, tmp_path):
        from kanoniv.reconcile import reconcile

        crm = Source.from_csv("crm", crm_csv, primary_key="id")
        orders = Source.from_csv("orders", orders_csv, primary_key="id")
        spec = Spec.from_string(SPEC_YAML)

        result1 = reconcile(sources=[crm, orders], spec=spec)

        new_csv = tmp_path / "new.csv"
        new_csv.write_text("id,email,name\no4,eve@example.com,Eve\n")
        new_orders = Source.from_csv("orders", str(new_csv), primary_key="id")
        result2 = reconcile(sources=[new_orders], spec=spec, previous=result1)

        changelog = result2.changes_since(result1)
        changes = list(changelog)
        assert len(changes) == len(changelog)


# ============================================================================
# Kevin Thomas scenario: missing fields should not penalize FS scoring
# ============================================================================

FS_SPEC_YAML = """\
api_version: kanoniv/v2
identity_version: fs_missing_v1
entity:
  name: customer
sources:
  - name: crm
    system: csv
    table: crm_export
    id: id
    attributes:
      email: email
      name: name
      phone: phone
      company: company
  - name: billing
    system: csv
    table: billing_export
    id: id
    attributes:
      email: email
      name: name
      phone: phone
      company: company
blocking:
  strategy: composite
  keys:
    - [email]
    - [name]
decision:
  scoring:
    strategy: fellegi_sunter
    fields:
      - name: email
        comparator: exact
        weight: 2.0
        m_probability: 0.99
        u_probability: 0.001
      - name: name
        comparator: jaro_winkler
        weight: 1.0
        m_probability: 0.92
        u_probability: 0.05
      - name: phone
        comparator: exact
        weight: 1.5
        m_probability: 0.95
        u_probability: 0.01
      - name: company
        comparator: jaro_winkler
        weight: 1.0
        m_probability: 0.85
        u_probability: 0.1
    thresholds:
      match: 8.0
      possible: 3.0
      non_match: -4.0
  thresholds:
    match: 0.9
"""


class TestMissingFieldsFS:
    """Kevin Thomas scenario: records with missing fields should still merge
    when the present fields agree strongly enough."""

    def test_missing_fields_dont_penalize(self, tmp_path):
        """A billing record missing phone+company should still merge with a
        CRM record when email+name match."""
        from kanoniv.reconcile import reconcile

        crm_csv = tmp_path / "crm.csv"
        crm_csv.write_text(
            "id,email,name,phone,company\n"
            "c1,kevin@acme.com,Kevin Thomas,555-1234,Acme Corp\n"
            "c2,alice@other.com,Alice Smith,555-5678,Other Inc\n"
        )
        billing_csv = tmp_path / "billing.csv"
        billing_csv.write_text(
            "id,email,name,phone,company\n"
            "b1,kevin@acme.com,Kevin Thomas,,\n"
        )

        crm = Source.from_csv("crm", str(crm_csv), primary_key="id")
        billing = Source.from_csv("billing", str(billing_csv), primary_key="id")
        spec = Spec.from_string(FS_SPEC_YAML)

        result = reconcile(sources=[crm, billing], spec=spec)

        # Kevin from CRM and Kevin from billing should be merged
        # even though billing record is missing phone and company.
        # With null-aware scoring, missing fields contribute 0 (not w_disagree).
        # email (exact match) + name (exact match) should clear the threshold.
        assert result.cluster_count == 2, (
            f"Expected 2 clusters (Kevin merged, Alice singleton), "
            f"got {result.cluster_count}"
        )

        # The merged cluster should have 2 members
        merged = [c for c in result.clusters if len(c) > 1]
        assert len(merged) == 1, f"Expected 1 merged cluster, got {len(merged)}"
        assert len(merged[0]) == 2, f"Kevin cluster should have 2 members, got {len(merged[0])}"

    def test_both_missing_same_field(self, tmp_path):
        """Two records both missing phone should still match on email+name+company."""
        from kanoniv.reconcile import reconcile

        crm_csv = tmp_path / "crm.csv"
        crm_csv.write_text(
            "id,email,name,phone,company\n"
            "c1,kevin@acme.com,Kevin Thomas,,Acme Corp\n"
        )
        billing_csv = tmp_path / "billing.csv"
        billing_csv.write_text(
            "id,email,name,phone,company\n"
            "b1,kevin@acme.com,Kevin Thomas,,Acme Corp\n"
        )

        crm = Source.from_csv("crm", str(crm_csv), primary_key="id")
        billing = Source.from_csv("billing", str(billing_csv), primary_key="id")
        spec = Spec.from_string(FS_SPEC_YAML)

        result = reconcile(sources=[crm, billing], spec=spec)

        assert result.cluster_count == 1, (
            f"Both records match on email+name+company (phone missing on both), "
            f"expected 1 cluster, got {result.cluster_count}"
        )

    def test_incremental_missing_fields(self, tmp_path):
        """Incremental run: new record with missing fields merges into existing cluster."""
        from kanoniv.reconcile import reconcile

        crm_csv = tmp_path / "crm.csv"
        crm_csv.write_text(
            "id,email,name,phone,company\n"
            "c1,kevin@acme.com,Kevin Thomas,555-1234,Acme Corp\n"
            "c2,alice@other.com,Alice Smith,555-5678,Other Inc\n"
        )
        crm = Source.from_csv("crm", str(crm_csv), primary_key="id")
        spec = Spec.from_string(FS_SPEC_YAML)

        result1 = reconcile(sources=[crm], spec=spec)
        assert result1.cluster_count == 2  # two singletons

        # Incremental: add billing record with missing fields
        billing_csv = tmp_path / "billing.csv"
        billing_csv.write_text(
            "id,email,name,phone,company\n"
            "b1,kevin@acme.com,Kevin Thomas,,\n"
        )
        billing = Source.from_csv("billing", str(billing_csv), primary_key="id")
        result2 = reconcile(sources=[billing], spec=spec, previous=result1)

        # Kevin should be merged even without fast-path, purely via FS scoring
        merged = [c for c in result2.clusters if len(c) > 1]
        assert len(merged) == 1, (
            f"Incremental: Kevin from billing (missing phone+company) should "
            f"merge with existing Kevin. Got {len(merged)} merged clusters."
        )


class TestCaseInsensitiveColumns:
    """Warehouse adapters (e.g. Snowflake) return UPPERCASE column names.
    The spec uses lowercase attribute values. Mapping should be case-insensitive."""

    def test_uppercase_columns_match_lowercase_spec(self):
        """Source with UPPERCASE columns should match spec with lowercase attributes."""
        import pandas as pd
        from kanoniv.reconcile import reconcile

        spec = Spec.from_string(SPEC_YAML)

        # Simulate warehouse-style uppercase column names
        crm_df = pd.DataFrame([
            {"ID": "c1", "EMAIL": "alice@example.com", "NAME": "Alice"},
            {"ID": "c2", "EMAIL": "bob@example.com", "NAME": "Bob"},
        ])
        orders_df = pd.DataFrame([
            {"ID": "o1", "EMAIL": "alice@example.com", "NAME": "Alice A"},
        ])

        crm = Source.from_pandas("crm", crm_df, primary_key="ID")
        orders = Source.from_pandas("orders", orders_df, primary_key="ID")

        result = reconcile(sources=[crm, orders], spec=spec)

        # alice@example.com should merge across CRM and orders
        assert result.cluster_count == 2, (
            f"Expected 2 clusters (alice merged + bob solo), got {result.cluster_count}"
        )
        merged = [c for c in result.clusters if len(c) > 1]
        assert len(merged) == 1, "Alice should merge across uppercase/lowercase columns"

    def test_mixed_case_columns(self):
        """Source with MixedCase columns should still map correctly."""
        import pandas as pd
        from kanoniv.reconcile import reconcile

        spec = Spec.from_string(SPEC_YAML)

        crm_df = pd.DataFrame([
            {"Id": "c1", "Email": "same@example.com", "Name": "Same Person"},
        ])
        orders_df = pd.DataFrame([
            {"Id": "o1", "Email": "same@example.com", "Name": "Same Person"},
        ])

        crm = Source.from_pandas("crm", crm_df, primary_key="Id")
        orders = Source.from_pandas("orders", orders_df, primary_key="Id")

        result = reconcile(sources=[crm, orders], spec=spec)
        assert result.cluster_count == 1, "Same person with MixedCase columns should merge"
