"""Tests for active learning: feedback, uncertain_pairs, graph clustering."""
import json
import time
import tempfile
import warnings
from pathlib import Path

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")

# --- Specs -------------------------------------------------------------------

# Rules-based spec (weighted sum scoring) with low review threshold
# so we get Review decisions for testing uncertain_pairs
RULES_SPEC = """\
api_version: kanoniv/v2
identity_version: al_rules_v1
entity:
  name: customer
sources:
  - name: crm
    system: csv
    table: crm
    id: id
    attributes:
      email: email
      name: name
      phone: phone
  - name: orders
    system: csv
    table: orders
    id: id
    attributes:
      email: email
      name: name
      phone: phone
blocking:
  strategy: composite
  keys:
    - [email]
    - [name]
rules:
  - name: email_exact
    type: exact
    field: email
    weight: 1.0
  - name: name_sim
    type: similarity
    field: name
    algorithm: jaro_winkler
    threshold: 0.7
    weight: 0.6
decision:
  thresholds:
    match: 0.8
    review: 0.3
  conflict_strategy: prefer_high_confidence
"""

# FS scoring spec for supervised EM tests
FS_SPEC = """\
api_version: kanoniv/v2
identity_version: al_fs_v1
entity:
  name: customer
sources:
  - name: crm
    system: csv
    table: crm
    id: id
    attributes:
      email: email
      name: name
      phone: phone
  - name: orders
    system: csv
    table: orders
    id: id
    attributes:
      email: email
      name: name
      phone: phone
blocking:
  strategy: composite
  keys:
    - [email]
    - [name]
decision:
  scoring:
    strategy: fellegi_sunter
    fields:
      - name: email
        comparator: exact
        weight: 2.0
        m_probability: 0.95
        u_probability: 0.01
      - name: name
        comparator: jaro_winkler
        weight: 1.0
        m_probability: 0.90
        u_probability: 0.05
      - name: phone
        comparator: exact
        weight: 1.5
        m_probability: 0.90
        u_probability: 0.02
    thresholds:
      match: 5.0
      possible: 1.0
      non_match: -3.0
  thresholds:
    match: 0.85
    review: 0.4
"""

# Graph clustering spec
GRAPH_SPEC = """\
api_version: kanoniv/v2
identity_version: graph_v1
entity:
  name: customer
sources:
  - name: crm
    system: csv
    table: crm
    id: id
    attributes:
      email: email
      name: name
  - name: orders
    system: csv
    table: orders
    id: id
    attributes:
      email: email
      name: name
blocking:
  strategy: composite
  keys:
    - [email]
    - [name]
rules:
  - name: email_exact
    type: exact
    field: email
    weight: 1.0
  - name: name_sim
    type: similarity
    field: name
    algorithm: jaro_winkler
    threshold: 0.7
    weight: 0.6
decision:
  thresholds:
    match: 0.5
    review: 0.3
  conflict_strategy: prefer_high_confidence
  clustering:
    method: graph
    min_edge_weight: 0.6
    min_cluster_coherence: 0.6
"""


# --- Fixtures -----------------------------------------------------------------

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


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


@pytest.fixture
def ambiguous_csv(tmp_path):
    """Records with partial overlaps that produce Review decisions."""
    p = tmp_path / "ambiguous.csv"
    p.write_text(
        "id,email,name,phone\n"
        "a1,alice@example.com,Alice Smith,555-0001\n"
        "a2,alice.s@example.com,Alice Smithson,555-0001\n"
        "a3,bob@example.com,Bob Jones,555-0002\n"
        "a4,robert@example.com,Bob Jones,555-0002\n"
        "a5,carol@example.com,Carol White,555-0003\n"
        "a6,carol.w@example.com,Carolyn White,555-0003\n"
    )
    return str(p)


# ==============================================================================
# 1. FeedbackLabel tests
# ==============================================================================

class TestFeedbackLabel:
    def test_import_from_init(self):
        from kanoniv import FeedbackLabel
        label = FeedbackLabel(
            entity_a_id="c1",
            entity_b_id="o1",
            source_a="crm",
            source_b="orders",
            label="match",
        )
        assert label.label == "match"

    def test_to_dict(self):
        from kanoniv.reconcile import FeedbackLabel
        label = FeedbackLabel(
            entity_a_id="c1",
            entity_b_id="o1",
            source_a="crm",
            source_b="orders",
            label="no_match",
        )
        d = label.to_dict()
        assert d == {
            "entity_a_id": "c1",
            "entity_b_id": "o1",
            "source_a": "crm",
            "source_b": "orders",
            "label": "no_match",
        }

    def test_feedback_save_load_roundtrip(self, crm_csv, orders_csv, tmp_path):
        """FeedbackLabels should persist through save/load cycle."""
        from kanoniv.reconcile import reconcile, ReconcileResult, FeedbackLabel

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

        labels = [
            FeedbackLabel("c1", "o1", "crm", "orders", "match"),
            FeedbackLabel("c2", "o2", "crm", "orders", "no_match"),
        ]

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

        loaded = ReconcileResult.load(knv_path)
        assert len(loaded._feedback) == 2
        assert loaded._feedback[0].entity_a_id == "c1"
        assert loaded._feedback[0].label == "match"
        assert loaded._feedback[1].label == "no_match"

    def test_feedback_accumulates_across_incremental(self, crm_csv, orders_csv, tmp_path):
        """Feedback from previous runs should accumulate with new feedback."""
        from kanoniv.reconcile import reconcile, FeedbackLabel

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

        # Run 1: one label
        labels1 = [FeedbackLabel("c1", "o1", "crm", "orders", "match")]
        result1 = reconcile(sources=[crm, orders], spec=spec, feedback=labels1)
        assert len(result1._feedback) == 1

        # Run 2: another label, using previous result
        new_csv = tmp_path / "new.csv"
        new_csv.write_text("id,email,name,phone\no3,eve@example.com,Eve,555-0005\n")
        new_src = Source.from_csv("orders", str(new_csv), primary_key="id")

        labels2 = [FeedbackLabel("c2", "o2", "crm", "orders", "no_match")]
        result2 = reconcile(
            sources=[new_src], spec=spec,
            previous=result1, feedback=labels2,
        )

        # Should have both labels
        assert len(result2._feedback) == 2


# ==============================================================================
# 2. uncertain_pairs() tests
# ==============================================================================

class TestUncertainPairs:
    def test_returns_list(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(RULES_SPEC)

        result = reconcile(sources=[crm, orders], spec=spec)
        pairs = result.uncertain_pairs(n=10)
        assert isinstance(pairs, list)

    def test_returns_review_and_borderline_decisions(self, ambiguous_csv):
        """uncertain_pairs should surface decisions near the boundary."""
        from kanoniv.reconcile import reconcile

        # Use a single source with ambiguous records
        src = Source.from_csv("crm", ambiguous_csv, primary_key="id")
        # Need a single-source spec
        single_spec = Spec.from_string(RULES_SPEC.replace(
            "  - name: orders\n    system: csv\n    table: orders\n    id: id\n"
            "    attributes:\n      email: email\n      name: name\n      phone: phone\n",
            "",
        ))

        # This might not work cleanly with single source, use both sources approach
        spec = Spec.from_string(RULES_SPEC)
        crm = Source.from_csv("crm", ambiguous_csv, primary_key="id")
        # Create an "orders" source with overlapping data
        import tempfile, os
        with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False) as f:
            f.write(
                "id,email,name,phone\n"
                "o1,alice@example.com,Alice Smyth,555-0001\n"
                "o2,robert@example.com,Robert Jones,555-0002\n"
            )
            orders_path = f.name

        try:
            orders = Source.from_csv("orders", orders_path, primary_key="id")
            result = reconcile(sources=[crm, orders], spec=spec)

            pairs = result.uncertain_pairs(n=50)

            if len(pairs) > 0:
                # Each pair should have expected structure
                p = pairs[0]
                assert "entity_a_id" in p
                assert "entity_b_id" in p
                assert "confidence" in p
                assert "a" in p  # record A data
                assert "b" in p  # record B data
                assert "a_source" in p
                assert "b_source" in p
        finally:
            os.unlink(orders_path)

    def test_n_limits_output(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(RULES_SPEC)

        result = reconcile(sources=[crm, orders], spec=spec)
        pairs = result.uncertain_pairs(n=1)
        assert len(pairs) <= 1

    def test_empty_entities_returns_empty(self):
        """If _entities is not set, uncertain_pairs returns []."""
        from kanoniv.reconcile import ReconcileResult

        result = ReconcileResult(
            clusters=[], golden_records=[], decisions=[], telemetry={},
        )
        pairs = result.uncertain_pairs()
        assert pairs == []


# ==============================================================================
# 3. reconcile(feedback=...) tests
# ==============================================================================

class TestReconcileWithFeedback:
    def test_feedback_does_not_crash_rules(self, crm_csv, orders_csv):
        """Rules-based scoring with feedback should run without error."""
        from kanoniv.reconcile import reconcile, FeedbackLabel

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

        labels = [
            FeedbackLabel("c1", "o1", "crm", "orders", "match"),
        ]
        result = reconcile(sources=[crm, orders], spec=spec, feedback=labels)
        assert result.cluster_count >= 1
        assert result.golden_records is not None

    def test_feedback_does_not_crash_fs(self, crm_csv, orders_csv):
        """FS scoring with feedback should run without error."""
        from kanoniv.reconcile import reconcile, FeedbackLabel

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

        labels = [
            FeedbackLabel("c1", "o1", "crm", "orders", "match"),
        ]
        result = reconcile(sources=[crm, orders], spec=spec, feedback=labels)
        assert result.cluster_count >= 1

    def test_fs_feedback_preserves_trained_params(self, crm_csv, orders_csv):
        """After feedback, trained_fs_params should be saved for next run."""
        from kanoniv.reconcile import reconcile, FeedbackLabel

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

        labels = [
            FeedbackLabel("c1", "o1", "crm", "orders", "match"),
        ]
        result = reconcile(sources=[crm, orders], spec=spec, feedback=labels)

        # Should have trained FS params after feedback
        assert result._trained_fs_params is not None, (
            "Trained FS params should be present after feedback run"
        )

    def test_feedback_with_bad_ids_ignored_gracefully(self, crm_csv, orders_csv):
        """Feedback referencing non-existent records should not crash."""
        from kanoniv.reconcile import reconcile, FeedbackLabel

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

        # Non-existent IDs
        labels = [
            FeedbackLabel("FAKE_1", "FAKE_2", "crm", "orders", "match"),
            FeedbackLabel("FAKE_3", "FAKE_4", "nonexistent_source", "orders", "no_match"),
        ]
        # Should not crash - unknown labels are simply skipped
        result = reconcile(sources=[crm, orders], spec=spec, feedback=labels)
        assert result.cluster_count >= 1

    def test_empty_feedback_same_as_no_feedback(self, crm_csv, orders_csv):
        """Empty feedback list should produce identical results to no feedback."""
        from kanoniv.reconcile import reconcile, FeedbackLabel

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

        result_none = reconcile(sources=[crm1, orders1], spec=spec)

        crm2 = Source.from_csv("crm", crm_csv, primary_key="id")
        orders2 = Source.from_csv("orders", orders_csv, primary_key="id")

        result_empty = reconcile(sources=[crm2, orders2], spec=spec, feedback=[])

        assert result_none.cluster_count == result_empty.cluster_count

    def test_full_active_learning_loop(self, crm_csv, orders_csv, tmp_path):
        """End-to-end: reconcile -> uncertain_pairs -> label -> reconcile w/feedback -> evaluate."""
        from kanoniv.reconcile import reconcile, FeedbackLabel

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

        # Step 1: Initial reconcile
        result1 = reconcile(sources=[crm, orders], spec=spec)
        eval1 = result1.evaluate()
        assert eval1.total_clusters >= 1

        # Step 2: Get uncertain pairs
        pairs = result1.uncertain_pairs(n=10)
        # Pairs may be empty if all decisions are confident - that's OK

        # Step 3: Create feedback (we know alice is in both)
        labels = [
            FeedbackLabel("c1", "o1", "crm", "orders", "match"),
        ]

        # Step 4: Re-reconcile with feedback
        crm2 = Source.from_csv("crm", crm_csv, primary_key="id")
        orders2 = Source.from_csv("orders", orders_csv, primary_key="id")
        result2 = reconcile(sources=[crm2, orders2], spec=spec, feedback=labels)

        # Step 5: Evaluate
        eval2 = result2.evaluate()
        assert eval2.total_clusters >= 1

        # Step 6: Save and reload
        knv_path = str(tmp_path / "feedback_result.knv")
        result2.save(knv_path)

        from kanoniv.reconcile import ReconcileResult
        loaded = ReconcileResult.load(knv_path)
        assert len(loaded._feedback) == 1
        assert loaded._trained_fs_params is not None


# ==============================================================================
# 4. Graph clustering via Python SDK
# ==============================================================================

class TestGraphClustering:
    def test_graph_clustering_from_spec(self, crm_csv, orders_csv):
        """Graph clustering spec should parse and run without error."""
        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(GRAPH_SPEC)

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

    def test_graph_vs_simple_may_differ(self, tmp_path):
        """Graph clustering with coherence threshold may produce different
        clusters than simple union-find for weak transitive chains."""
        from kanoniv.reconcile import reconcile

        # Create a chain: A-B share email, B-C share name (weaker link)
        crm_csv = tmp_path / "crm.csv"
        crm_csv.write_text(
            "id,email,name\n"
            "a1,shared@example.com,Alice Smith\n"
            "a2,shared@example.com,Bob Jones\n"
            "a3,different@example.com,Bob Jones\n"
        )

        simple_yaml = """\
api_version: kanoniv/v2
identity_version: simple_v1
entity:
  name: customer
sources:
  - name: crm
    system: csv
    table: crm
    id: id
    attributes:
      email: email
      name: name
blocking:
  strategy: composite
  keys:
    - [email]
    - [name]
rules:
  - name: email_exact
    type: exact
    field: email
    weight: 1.0
  - name: name_exact
    type: exact
    field: name
    weight: 0.8
decision:
  thresholds:
    match: 0.5
    review: 0.2
  conflict_strategy: prefer_high_confidence
"""

        graph_yaml = simple_yaml + """\
  clustering:
    method: graph
    min_edge_weight: 0.0
    min_cluster_coherence: 0.8
"""

        crm_simple = Source.from_csv("crm", str(crm_csv), primary_key="id")
        simple_result = reconcile(
            sources=[crm_simple],
            spec=Spec.from_string(simple_yaml),
        )

        crm_graph = Source.from_csv("crm", str(crm_csv), primary_key="id")
        graph_result = reconcile(
            sources=[crm_graph],
            spec=Spec.from_string(graph_yaml),
        )

        # Graph clustering with high coherence may split what simple merges
        # (if A-C have low confidence, the chain A-B-C may be split)
        # We just verify both produce valid results
        assert simple_result.cluster_count >= 1
        assert graph_result.cluster_count >= 1
        assert graph_result.cluster_count >= simple_result.cluster_count, (
            "Graph clustering with coherence checks should produce >= clusters than simple"
        )


# ==============================================================================
# 5. Performance: uncertain_pairs with large datasets
# ==============================================================================

class TestUncertainPairsPerformance:
    def test_large_decision_set(self, crm_csv, orders_csv):
        """uncertain_pairs should handle results with many decisions efficiently."""
        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(RULES_SPEC)

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

        # Time the uncertain_pairs call
        start = time.monotonic()
        for _ in range(100):
            result.uncertain_pairs(n=10)
        elapsed = time.monotonic() - start

        # 100 calls should complete in under 5 seconds even with JSON deser
        assert elapsed < 5.0, f"100 calls to uncertain_pairs took {elapsed:.2f}s"

    def test_large_synthetic_decisions(self, tmp_path):
        """Generate a larger dataset and verify uncertain_pairs doesn't blow up."""
        from kanoniv.reconcile import reconcile
        import csv

        # Generate 200 records across 2 sources with some overlap
        crm_path = tmp_path / "crm_large.csv"
        orders_path = tmp_path / "orders_large.csv"

        with open(crm_path, "w", newline="") as f:
            w = csv.writer(f)
            w.writerow(["id", "email", "name", "phone"])
            for i in range(100):
                w.writerow([f"c{i}", f"user{i}@example.com", f"User {i}", f"555-{i:04d}"])

        with open(orders_path, "w", newline="") as f:
            w = csv.writer(f)
            w.writerow(["id", "email", "name", "phone"])
            for i in range(100):
                # First 30 overlap with CRM
                if i < 30:
                    w.writerow([f"o{i}", f"user{i}@example.com", f"User {i}", f"555-{i:04d}"])
                else:
                    w.writerow([f"o{i}", f"order{i}@example.com", f"Order User {i}", f"555-{i+1000:04d}"])

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

        start = time.monotonic()
        result = reconcile(sources=[crm, orders], spec=spec)
        reconcile_time = time.monotonic() - start

        start = time.monotonic()
        pairs = result.uncertain_pairs(n=20)
        uncertain_time = time.monotonic() - start

        # Just verify it completes and returns reasonable output
        assert isinstance(pairs, list)
        assert len(pairs) <= 20
        # uncertain_pairs should be fast relative to reconciliation
        assert uncertain_time < reconcile_time * 2, (
            f"uncertain_pairs ({uncertain_time:.2f}s) should not be much slower "
            f"than reconcile itself ({reconcile_time:.2f}s)"
        )


# ==============================================================================
# 6. Graph clustering pathological inputs (Rust engine via SDK)
# ==============================================================================

class TestGraphClusteringPathological:
    def test_long_chain(self, tmp_path):
        """A long chain of records where each pair shares an email.
        With high coherence threshold, graph should split into smaller clusters."""
        from kanoniv.reconcile import reconcile
        import csv

        # 50-node chain: each shares email with next
        path = tmp_path / "chain.csv"
        with open(path, "w", newline="") as f:
            w = csv.writer(f)
            w.writerow(["id", "email", "name"])
            for i in range(50):
                # Each record shares email with i-1 via blocking
                w.writerow([f"n{i}", f"chain{i}@example.com", f"Person {i}"])
                if i > 0:
                    # Also create a record that bridges i-1 and i
                    w.writerow([f"b{i}", f"chain{i}@example.com", f"Person {i-1}"])

        spec_yaml = """\
api_version: kanoniv/v2
identity_version: chain_v1
entity:
  name: customer
sources:
  - name: crm
    system: csv
    table: crm
    id: id
    attributes:
      email: email
      name: name
blocking:
  strategy: composite
  keys:
    - [email]
    - [name]
rules:
  - name: email_exact
    type: exact
    field: email
    weight: 1.0
  - name: name_sim
    type: similarity
    field: name
    algorithm: jaro_winkler
    threshold: 0.7
    weight: 0.5
decision:
  thresholds:
    match: 0.5
    review: 0.2
  conflict_strategy: prefer_high_confidence
  clustering:
    method: graph
    min_edge_weight: 0.0
    min_cluster_coherence: 0.7
    max_cluster_size: 20
"""
        src = Source.from_csv("crm", str(path), primary_key="id")
        spec = Spec.from_string(spec_yaml)

        start = time.monotonic()
        result = reconcile(sources=[src], spec=spec)
        elapsed = time.monotonic() - start

        # Should complete in reasonable time (< 10s)
        assert elapsed < 10.0, f"Long chain took {elapsed:.2f}s"
        # With max_cluster_size=20, no cluster should exceed that
        for cluster in result.clusters:
            assert len(cluster) <= 20, (
                f"Cluster has {len(cluster)} members, exceeds max_cluster_size=20"
            )

    def test_all_singletons(self, tmp_path):
        """No blocking matches = all singletons. Graph clustering should handle this."""
        from kanoniv.reconcile import reconcile
        import csv

        path = tmp_path / "singletons.csv"
        with open(path, "w", newline="") as f:
            w = csv.writer(f)
            w.writerow(["id", "email", "name"])
            for i in range(50):
                w.writerow([f"s{i}", f"unique{i}@example{i}.com", f"Unique Name {i}"])

        spec_yaml = """\
api_version: kanoniv/v2
identity_version: singleton_v1
entity:
  name: customer
sources:
  - name: crm
    system: csv
    table: crm
    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
  clustering:
    method: graph
    min_cluster_coherence: 0.5
"""
        src = Source.from_csv("crm", str(path), primary_key="id")
        spec = Spec.from_string(spec_yaml)

        result = reconcile(sources=[src], spec=spec)
        assert result.cluster_count == 50, (
            f"Expected 50 singletons, got {result.cluster_count}"
        )
