"""Tests for unified knowledge graph."""
import pytest
from greymatter_core.db import SQLiteDatabase
from greymatter_core.knowledge import KnowledgeStore


@pytest.fixture
def db():
    return SQLiteDatabase(":memory:")


@pytest.fixture
def knowledge(db):
    return KnowledgeStore(db)


def test_create_entry(knowledge):
    entry = knowledge.create(
        entry_type="expertise", title="SQLite WAL Mode",
        content="WAL mode allows concurrent reads during writes",
        domain="database",
    )
    assert entry["id"].startswith("kn-")
    assert entry["entry_type"] == "expertise"
    assert entry["scope"] == "cross-cutting"


def test_get_entry(knowledge):
    created = knowledge.create(entry_type="gotcha", title="Test", content="Content")
    fetched = knowledge.get(created["id"])
    assert fetched is not None
    assert fetched["title"] == "Test"


def test_search_by_type(knowledge):
    knowledge.create(entry_type="expertise", title="A", content="A")
    knowledge.create(entry_type="correction", title="B", content="B")
    results = knowledge.search(entry_type="expertise")
    assert len(results) == 1
    assert results[0]["title"] == "A"


def test_search_by_domain(knowledge):
    knowledge.create(entry_type="expertise", title="A", content="A", domain="auth")
    knowledge.create(entry_type="expertise", title="B", content="B", domain="database")
    results = knowledge.search(domain="auth")
    assert len(results) == 1


def test_search_by_text(knowledge):
    knowledge.create(entry_type="expertise", title="SQLite WAL", content="WAL mode details")
    knowledge.create(entry_type="expertise", title="PostgreSQL", content="Postgres details")
    results = knowledge.search(text="WAL")
    assert len(results) == 1
    assert results[0]["title"] == "SQLite WAL"


def test_prune_entry(knowledge):
    entry = knowledge.create(entry_type="gotcha", title="Old", content="Outdated")
    knowledge.prune(entry["id"])
    fetched = knowledge.get(entry["id"])
    assert fetched["pruned_at"] is not None
    # Pruned entries excluded from search by default
    results = knowledge.search()
    assert len(results) == 0


def test_record_application(knowledge):
    entry = knowledge.create(entry_type="expertise", title="A", content="A")
    knowledge.record_application(entry["id"])
    knowledge.record_application(entry["id"])
    fetched = knowledge.get(entry["id"])
    assert fetched["applied_count"] == 2


def test_invalid_entry_type_rejected(knowledge):
    with pytest.raises(ValueError, match="Invalid entry_type"):
        knowledge.create(entry_type="bogus", title="X", content="X")


def test_update_rejects_unknown_columns(knowledge):
    entry = knowledge.create(entry_type="expertise", title="A", content="A")
    # Allowed column names should work
    updated = knowledge.update(entry["id"], title="B")
    assert updated["title"] == "B"
    # Unknown columns are silently ignored (filtered by allowed set)
    updated = knowledge.update(entry["id"], **{"evil_col": "bad"})
    assert updated["title"] == "B"  # unchanged


def test_update_column_regex_validation(knowledge):
    """Defense-in-depth: column names must match ^[a-z_]+$ even if in allowed set."""
    import re
    from greymatter_core.knowledge import KnowledgeStore

    # Verify all allowed column names pass the regex
    allowed = {"title", "content", "domain", "scope", "valence", "tags"}
    for col in allowed:
        assert re.match(r'^[a-z_]+$', col), f"Allowed column '{col}' fails regex"


# --- Auto-pruning tests ---


def _backdate_entry(db, entry_id: str, days: int) -> None:
    """Set created_at and updated_at to `days` ago for testing staleness."""
    db.execute(
        """UPDATE knowledge_entries
           SET created_at = datetime('now', ?),
               updated_at = datetime('now', ?)
           WHERE id = ?""",
        (f"-{days} days", f"-{days} days", entry_id),
    )


def test_flag_stale_ignores_recent_entries(knowledge):
    """Entries created recently should not be flagged."""
    knowledge.create(entry_type="expertise", title="Fresh", content="New stuff")
    flagged = knowledge.flag_stale(stale_days=90)
    assert len(flagged) == 0


def test_flag_stale_flags_old_entries(knowledge, db):
    """Entries older than stale_days without confirmation should be flagged."""
    entry = knowledge.create(entry_type="expertise", title="Old", content="Old stuff")
    _backdate_entry(db, entry["id"], 100)
    flagged = knowledge.flag_stale(stale_days=90)
    assert len(flagged) == 1
    assert flagged[0]["id"] == entry["id"]
    assert flagged[0]["stale_flagged_at"] is not None


def test_flag_stale_skips_already_flagged(knowledge, db):
    """Already-flagged entries should not be re-flagged."""
    entry = knowledge.create(entry_type="expertise", title="Old", content="Old stuff")
    _backdate_entry(db, entry["id"], 100)
    knowledge.flag_stale(stale_days=90)
    # Flag again — should not create duplicates
    flagged = knowledge.flag_stale(stale_days=90)
    assert len(flagged) == 1  # Same entry, not a new flag


def test_flag_stale_skips_pruned_entries(knowledge, db):
    """Already-pruned entries should not be flagged."""
    entry = knowledge.create(entry_type="expertise", title="Old", content="Old")
    _backdate_entry(db, entry["id"], 100)
    knowledge.prune(entry["id"])
    flagged = knowledge.flag_stale(stale_days=90)
    assert len(flagged) == 0


def test_confirm_resets_stale_flag(knowledge, db):
    """Confirming an entry clears the stale flag."""
    entry = knowledge.create(entry_type="expertise", title="Old", content="Old")
    _backdate_entry(db, entry["id"], 100)
    knowledge.flag_stale(stale_days=90)
    assert len(knowledge.get_stale()) == 1

    knowledge.confirm(entry["id"])
    fetched = knowledge.get(entry["id"])
    assert fetched["stale_flagged_at"] is None
    assert fetched["last_confirmed_at"] is not None
    assert len(knowledge.get_stale()) == 0


def test_confirm_nonexistent_returns_false(knowledge):
    assert knowledge.confirm("kn-nonexistent") is False


def test_record_application_resets_stale_flag(knowledge, db):
    """Recording an application clears the stale flag."""
    entry = knowledge.create(entry_type="expertise", title="Old", content="Old")
    _backdate_entry(db, entry["id"], 100)
    knowledge.flag_stale(stale_days=90)
    assert len(knowledge.get_stale()) == 1

    knowledge.record_application(entry["id"])
    fetched = knowledge.get(entry["id"])
    assert fetched["stale_flagged_at"] is None
    assert len(knowledge.get_stale()) == 0


def test_unflag_stale(knowledge, db):
    """Manual unflag removes the stale flag."""
    entry = knowledge.create(entry_type="expertise", title="Old", content="Old")
    _backdate_entry(db, entry["id"], 100)
    knowledge.flag_stale(stale_days=90)
    assert len(knowledge.get_stale()) == 1

    success = knowledge.unflag_stale(entry["id"])
    assert success is True
    assert len(knowledge.get_stale()) == 0


def test_unflag_stale_nonexistent_returns_false(knowledge):
    assert knowledge.unflag_stale("kn-nonexistent") is False


def test_auto_archive_stale_low_confidence(knowledge, db):
    """Entries older than archive_days with low confidence are auto-archived."""
    entry = knowledge.create(entry_type="expertise", title="Old", content="Old")
    _backdate_entry(db, entry["id"], 200)
    # Default confidence is 0.5, which is <= threshold
    archived = knowledge.auto_archive_stale(archive_days=180, confidence_threshold=0.5)
    assert len(archived) == 1
    assert archived[0]["id"] == entry["id"]
    # Verify it's now pruned
    fetched = knowledge.get(entry["id"])
    assert fetched["pruned_at"] is not None


def test_auto_archive_spares_high_confidence(knowledge, db):
    """High-confidence entries should not be auto-archived even if old."""
    entry = knowledge.create(entry_type="expertise", title="Old", content="Old")
    _backdate_entry(db, entry["id"], 200)
    # Boost confidence above threshold
    db.execute(
        "UPDATE knowledge_entries SET confidence = 0.9 WHERE id = ?",
        (entry["id"],),
    )
    archived = knowledge.auto_archive_stale(archive_days=180, confidence_threshold=0.5)
    assert len(archived) == 0
    fetched = knowledge.get(entry["id"])
    assert fetched["pruned_at"] is None


def test_auto_archive_spares_recently_confirmed(knowledge, db):
    """Recently confirmed entries should not be auto-archived."""
    entry = knowledge.create(entry_type="expertise", title="Old", content="Old")
    _backdate_entry(db, entry["id"], 200)
    # Confirm it — sets last_confirmed_at to now
    knowledge.confirm(entry["id"])
    archived = knowledge.auto_archive_stale(archive_days=180, confidence_threshold=0.5)
    assert len(archived) == 0


def test_auto_archive_spares_already_pruned(knowledge, db):
    """Already-pruned entries should not appear in auto-archive results."""
    entry = knowledge.create(entry_type="expertise", title="Old", content="Old")
    _backdate_entry(db, entry["id"], 200)
    knowledge.prune(entry["id"])
    archived = knowledge.auto_archive_stale(archive_days=180, confidence_threshold=0.5)
    assert len(archived) == 0


def test_pruning_summary(knowledge, db):
    """Pruning summary returns correct counts."""
    e1 = knowledge.create(entry_type="expertise", title="Active", content="A")
    e2 = knowledge.create(entry_type="expertise", title="Stale", content="B")
    e3 = knowledge.create(entry_type="expertise", title="Archived", content="C")

    _backdate_entry(db, e2["id"], 100)
    knowledge.flag_stale(stale_days=90)
    knowledge.prune(e3["id"])

    summary = knowledge.pruning_summary()
    assert summary["total_active"] == 2  # e1 + e2 (e3 is pruned)
    assert summary["stale_flagged"] == 1  # e2
    assert summary["archived"] == 1  # e3
    assert summary["never_applied"] == 2  # e1 + e2 (both have 0 applications)


def test_search_excludes_pruned_by_default(knowledge, db):
    """Auto-archived entries should not appear in default search."""
    entry = knowledge.create(entry_type="expertise", title="Old", content="Old")
    _backdate_entry(db, entry["id"], 200)
    knowledge.auto_archive_stale(archive_days=180, confidence_threshold=0.5)
    results = knowledge.search()
    assert len(results) == 0


def test_search_includes_pruned_when_requested(knowledge, db):
    """include_pruned=True should return auto-archived entries."""
    entry = knowledge.create(entry_type="expertise", title="Old", content="Old")
    _backdate_entry(db, entry["id"], 200)
    knowledge.auto_archive_stale(archive_days=180, confidence_threshold=0.5)
    results = knowledge.search(include_pruned=True)
    assert len(results) == 1


def test_confirmed_entry_not_flagged_stale(knowledge, db):
    """An old entry that was recently confirmed should not be flagged stale."""
    entry = knowledge.create(entry_type="expertise", title="Old", content="Old")
    _backdate_entry(db, entry["id"], 100)
    # Confirm it (sets last_confirmed_at to now, which COALESCE picks up)
    knowledge.confirm(entry["id"])
    flagged = knowledge.flag_stale(stale_days=90)
    assert len(flagged) == 0


# --- Near-duplicate detection tests ---


def test_find_near_duplicates_exact_match(knowledge):
    """Entries with identical titles should be flagged as duplicates."""
    knowledge.create(entry_type="expertise", title="SQLite WAL Mode", content="A")
    knowledge.create(entry_type="expertise", title="SQLite WAL Mode", content="B")
    dupes = knowledge.find_near_duplicates()
    assert len(dupes) == 1
    assert dupes[0]["similarity"] == 1.0


def test_find_near_duplicates_similar_titles(knowledge):
    """Entries with overlapping words should be flagged."""
    knowledge.create(entry_type="expertise", title="SQLite WAL Mode Configuration", content="A")
    knowledge.create(entry_type="expertise", title="SQLite WAL Mode Performance", content="B")
    dupes = knowledge.find_near_duplicates(similarity_threshold=0.5)
    assert len(dupes) == 1
    assert dupes[0]["similarity"] >= 0.5


def test_find_near_duplicates_different_titles(knowledge):
    """Entries with unrelated titles should not be flagged."""
    knowledge.create(entry_type="expertise", title="SQLite WAL Mode", content="A")
    knowledge.create(entry_type="correction", title="Python Async Patterns", content="B")
    dupes = knowledge.find_near_duplicates()
    assert len(dupes) == 0


def test_find_near_duplicates_ignores_pruned(knowledge):
    """Pruned entries should not appear in duplicate results."""
    e1 = knowledge.create(entry_type="expertise", title="SQLite WAL Mode", content="A")
    knowledge.create(entry_type="expertise", title="SQLite WAL Mode", content="B")
    knowledge.prune(e1["id"])
    dupes = knowledge.find_near_duplicates()
    assert len(dupes) == 0


def test_find_near_duplicates_strips_stopwords(knowledge):
    """Stopwords should not inflate similarity scores."""
    knowledge.create(entry_type="expertise", title="The use of SQLite", content="A")
    knowledge.create(entry_type="correction", title="Avoid Redis caching", content="B")
    dupes = knowledge.find_near_duplicates()
    assert len(dupes) == 0


# --- Contradiction detection tests ---


def test_find_contradictions_opposing_directives(knowledge):
    """Entries in same domain with opposing language should be flagged."""
    knowledge.create(
        entry_type="expertise", title="WAL mode usage",
        content="Always use WAL mode for concurrent workloads",
        domain="database",
    )
    knowledge.create(
        entry_type="gotcha", title="WAL mode usage warning",
        content="Never use WAL mode on network filesystems",
        domain="database",
    )
    contradictions = knowledge.find_contradictions()
    assert len(contradictions) == 1
    assert contradictions[0]["domain"] == "database"
    assert "negation" in contradictions[0]["reason"] or "affirmative" in contradictions[0]["reason"]


def test_find_contradictions_same_sentiment_not_flagged(knowledge):
    """Entries with similar positive language should not be flagged."""
    knowledge.create(
        entry_type="expertise", title="WAL mode benefits",
        content="Always use WAL mode for writes",
        domain="database",
    )
    knowledge.create(
        entry_type="expertise", title="WAL mode setup",
        content="You should enable WAL mode at startup",
        domain="database",
    )
    contradictions = knowledge.find_contradictions()
    assert len(contradictions) == 0


def test_find_contradictions_different_domains_not_flagged(knowledge):
    """Entries in different domains should not be compared."""
    knowledge.create(
        entry_type="expertise", title="Enable caching",
        content="Always enable caching for performance",
        domain="backend",
    )
    knowledge.create(
        entry_type="gotcha", title="Disable caching",
        content="Never enable caching in test environments",
        domain="testing",
    )
    contradictions = knowledge.find_contradictions()
    assert len(contradictions) == 0


def test_find_contradictions_no_domain_skipped(knowledge):
    """Entries without a domain should be excluded from contradiction checks."""
    knowledge.create(
        entry_type="expertise", title="Use caching",
        content="Always enable caching",
    )
    knowledge.create(
        entry_type="gotcha", title="Use caching carefully",
        content="Never enable caching blindly",
    )
    contradictions = knowledge.find_contradictions()
    assert len(contradictions) == 0


def test_find_contradictions_low_title_overlap_skipped(knowledge):
    """Entries with unrelated titles in the same domain should not be compared."""
    knowledge.create(
        entry_type="expertise", title="Database indexing strategy",
        content="Always create indexes on foreign keys",
        domain="database",
    )
    knowledge.create(
        entry_type="gotcha", title="Connection pool limits",
        content="Never exceed 50 connections",
        domain="database",
    )
    contradictions = knowledge.find_contradictions()
    assert len(contradictions) == 0


# --- Unified health report tests ---


def test_health_returns_all_sections(knowledge):
    """Health report should contain summary, stale, duplicates, contradictions."""
    report = knowledge.health()
    assert "summary" in report
    assert "stale_entries" in report
    assert "near_duplicates" in report
    assert "contradictions" in report


def test_health_summary_includes_validation_counts(knowledge):
    """Summary should include near_duplicate_pairs and contradiction_pairs."""
    knowledge.create(entry_type="expertise", title="Test Entry", content="Test")
    report = knowledge.health()
    assert "near_duplicate_pairs" in report["summary"]
    assert "contradiction_pairs" in report["summary"]
    assert report["summary"]["total_active"] == 1
    assert report["summary"]["near_duplicate_pairs"] == 0
    assert report["summary"]["contradiction_pairs"] == 0


def test_health_detects_all_issues(knowledge, db):
    """Health report should surface stale entries, duplicates, and contradictions."""
    # Create stale entry
    old = knowledge.create(entry_type="expertise", title="Legacy Config", content="Old stuff")
    _backdate_entry(db, old["id"], 100)

    # Create duplicates
    knowledge.create(entry_type="expertise", title="WAL Mode Guide", content="Guide A")
    knowledge.create(entry_type="expertise", title="WAL Mode Guide", content="Guide B")

    # Create contradicting entries
    knowledge.create(
        entry_type="expertise", title="Enable strict mode",
        content="Always enable strict mode for safety",
        domain="config",
    )
    knowledge.create(
        entry_type="gotcha", title="Enable strict mode risks",
        content="Never enable strict mode in legacy codebases",
        domain="config",
    )

    report = knowledge.health(stale_days=90)
    assert report["summary"]["stale_flagged"] >= 1
    assert report["summary"]["near_duplicate_pairs"] >= 1
    assert report["summary"]["contradiction_pairs"] >= 1
    assert len(report["stale_entries"]) >= 1
    assert len(report["near_duplicates"]) >= 1
    assert len(report["contradictions"]) >= 1


# --- Integration tests: end-to-end lifecycle scenarios ---


class TestStalenessLifecycle:
    """Full lifecycle: create → age → flag → reset → re-age → archive."""

    def test_full_staleness_to_archive_pipeline(self, knowledge, db):
        """Entry progresses through fresh → stale → confirmed → stale → archived."""
        entry = knowledge.create(
            entry_type="expertise", title="Pipeline test",
            content="Tests the full staleness pipeline",
        )
        eid = entry["id"]

        # Step 1: Fresh entry is not stale
        assert len(knowledge.flag_stale(stale_days=90)) == 0

        # Step 2: Age it past stale threshold — gets flagged
        _backdate_entry(db, eid, 100)
        flagged = knowledge.flag_stale(stale_days=90)
        assert any(e["id"] == eid for e in flagged)
        assert knowledge.get(eid)["stale_flagged_at"] is not None

        # Step 3: Confirm resets stale flag and updates last_confirmed_at
        knowledge.confirm(eid)
        fetched = knowledge.get(eid)
        assert fetched["stale_flagged_at"] is None
        assert fetched["last_confirmed_at"] is not None

        # Step 4: Re-age past stale threshold — flagged again
        db.execute(
            """UPDATE knowledge_entries
               SET last_confirmed_at = datetime('now', ?),
                   updated_at = datetime('now', ?)
               WHERE id = ?""",
            ("-100 days", "-100 days", eid),
        )
        flagged = knowledge.flag_stale(stale_days=90)
        assert any(e["id"] == eid for e in flagged)

        # Step 5: Auto-archive (low confidence + old) → pruned
        archived = knowledge.auto_archive_stale(
            archive_days=90, confidence_threshold=0.5,
        )
        assert any(e["id"] == eid for e in archived)
        assert knowledge.get(eid)["pruned_at"] is not None

        # Step 6: Archived entry excluded from default search
        assert all(e["id"] != eid for e in knowledge.search())

    def test_application_resets_staleness_and_prevents_archive(self, knowledge, db):
        """Recording an application mid-lifecycle prevents archival."""
        entry = knowledge.create(
            entry_type="gotcha", title="Apply resets test",
            content="Verifies application prevents archive",
        )
        eid = entry["id"]
        _backdate_entry(db, eid, 200)

        # Flag as stale
        knowledge.flag_stale(stale_days=90)
        assert knowledge.get(eid)["stale_flagged_at"] is not None

        # Record application — clears stale flag, bumps updated_at to now
        knowledge.record_application(eid)
        fetched = knowledge.get(eid)
        assert fetched["stale_flagged_at"] is None
        assert fetched["applied_count"] == 1

        # Now auto_archive should NOT pick it up (updated_at is recent)
        archived = knowledge.auto_archive_stale(
            archive_days=180, confidence_threshold=0.5,
        )
        assert len(archived) == 0
        assert knowledge.get(eid)["pruned_at"] is None

    def test_unflag_then_reflag_cycle(self, knowledge, db):
        """Manually unflagging then re-aging allows re-flagging."""
        entry = knowledge.create(
            entry_type="reference", title="Unflag cycle",
            content="Testing unflag then reflag",
        )
        eid = entry["id"]
        _backdate_entry(db, eid, 100)

        # Flag → unflag
        knowledge.flag_stale(stale_days=90)
        assert len(knowledge.get_stale()) == 1
        knowledge.unflag_stale(eid)
        assert len(knowledge.get_stale()) == 0

        # Re-age the updated_at (unflag bumped it to now)
        db.execute(
            "UPDATE knowledge_entries SET updated_at = datetime('now', '-100 days') WHERE id = ?",
            (eid,),
        )
        flagged = knowledge.flag_stale(stale_days=90)
        assert any(e["id"] == eid for e in flagged)


class TestBulkStalenessOperations:
    """Verify staleness operations work correctly across many entries."""

    def test_flag_stale_multiple_entries_mixed_ages(self, knowledge, db):
        """Only entries older than threshold get flagged in a mixed set."""
        fresh = knowledge.create(entry_type="expertise", title="Fresh", content="New")
        old_1 = knowledge.create(entry_type="gotcha", title="Old One", content="A")
        old_2 = knowledge.create(entry_type="pattern", title="Old Two", content="B")
        pruned = knowledge.create(entry_type="correction", title="Pruned", content="C")

        _backdate_entry(db, old_1["id"], 100)
        _backdate_entry(db, old_2["id"], 150)
        _backdate_entry(db, pruned["id"], 200)
        knowledge.prune(pruned["id"])

        flagged = knowledge.flag_stale(stale_days=90)
        flagged_ids = {e["id"] for e in flagged}

        assert fresh["id"] not in flagged_ids
        assert old_1["id"] in flagged_ids
        assert old_2["id"] in flagged_ids
        assert pruned["id"] not in flagged_ids

    def test_auto_archive_respects_confidence_gradient(self, knowledge, db):
        """Auto-archive only prunes entries at or below the confidence threshold."""
        low = knowledge.create(entry_type="expertise", title="Low conf", content="A")
        mid = knowledge.create(entry_type="expertise", title="Mid conf", content="B")
        high = knowledge.create(entry_type="expertise", title="High conf", content="C")

        for eid in [low["id"], mid["id"], high["id"]]:
            _backdate_entry(db, eid, 200)

        db.execute("UPDATE knowledge_entries SET confidence = 0.2 WHERE id = ?", (low["id"],))
        db.execute("UPDATE knowledge_entries SET confidence = 0.5 WHERE id = ?", (mid["id"],))
        db.execute("UPDATE knowledge_entries SET confidence = 0.8 WHERE id = ?", (high["id"],))

        archived = knowledge.auto_archive_stale(
            archive_days=180, confidence_threshold=0.5,
        )
        archived_ids = {e["id"] for e in archived}

        assert low["id"] in archived_ids
        assert mid["id"] in archived_ids
        assert high["id"] not in archived_ids
        assert knowledge.get(high["id"])["pruned_at"] is None

    def test_get_stale_ordering_and_limit(self, knowledge, db):
        """get_stale returns oldest-flagged first and respects limit."""
        entries = []
        for i in range(5):
            e = knowledge.create(
                entry_type="expertise", title=f"Entry {i}", content=f"Content {i}",
            )
            _backdate_entry(db, e["id"], 100 + i * 10)
            entries.append(e)

        knowledge.flag_stale(stale_days=90)
        stale = knowledge.get_stale(limit=3)

        assert len(stale) == 3
        # All 5 should be flagged, but limit=3
        all_stale = knowledge.get_stale(limit=100)
        assert len(all_stale) == 5


class TestValidationIntegration:
    """Near-duplicate and contradiction detection across complex scenarios."""

    def test_duplicates_and_contradictions_coexist(self, knowledge):
        """Health report surfaces both duplicates and contradictions simultaneously."""
        # Duplicate pair
        knowledge.create(entry_type="expertise", title="SQLite WAL Mode", content="Guide A")
        knowledge.create(entry_type="expertise", title="SQLite WAL Mode", content="Guide B")

        # Contradiction pair
        knowledge.create(
            entry_type="expertise", title="Enable strict mode",
            content="Always enable strict mode for safety",
            domain="config",
        )
        knowledge.create(
            entry_type="gotcha", title="Enable strict mode carefully",
            content="Never enable strict mode in legacy codebases",
            domain="config",
        )

        report = knowledge.health()
        assert report["summary"]["near_duplicate_pairs"] >= 1
        assert report["summary"]["contradiction_pairs"] >= 1

    def test_pruned_entries_excluded_from_all_validation(self, knowledge):
        """Pruned entries don't appear in duplicates, contradictions, or stale lists."""
        e1 = knowledge.create(entry_type="expertise", title="SQLite WAL Mode", content="A")
        e2 = knowledge.create(entry_type="expertise", title="SQLite WAL Mode", content="B")
        knowledge.prune(e1["id"])
        knowledge.prune(e2["id"])

        assert len(knowledge.find_near_duplicates()) == 0
        assert len(knowledge.find_contradictions()) == 0
        assert len(knowledge.get_stale()) == 0

    def test_near_duplicates_custom_threshold(self, knowledge):
        """Lower similarity threshold catches looser matches."""
        knowledge.create(
            entry_type="expertise",
            title="SQLite WAL Mode Configuration Guide",
            content="A",
        )
        knowledge.create(
            entry_type="expertise",
            title="SQLite Database Performance Tuning",
            content="B",
        )

        # High threshold: no match (only "sqlite" overlaps)
        strict = knowledge.find_near_duplicates(similarity_threshold=0.8)
        assert len(strict) == 0

        # Low threshold: may catch partial overlap
        loose = knowledge.find_near_duplicates(similarity_threshold=0.1)
        assert len(loose) >= 1

    def test_contradictions_require_both_conditions(self, knowledge):
        """Contradiction needs same domain + title overlap + opposing sentiment."""
        # Same domain, similar title, but both positive — no contradiction
        knowledge.create(
            entry_type="expertise", title="caching strategy",
            content="Always enable caching for hot paths",
            domain="perf",
        )
        knowledge.create(
            entry_type="expertise", title="caching strategy tips",
            content="You should use caching with Redis",
            domain="perf",
        )
        assert len(knowledge.find_contradictions()) == 0


class TestSearchAndCountIntegration:
    """Verify search, search_summary, and count stay consistent."""

    def test_search_summary_matches_search_count(self, knowledge):
        """search_summary total should match the count of search results."""
        for i in range(10):
            knowledge.create(
                entry_type="expertise" if i % 2 == 0 else "gotcha",
                title=f"Entry {i}",
                content=f"Content about topic {i}",
                domain="testing" if i < 5 else "production",
            )

        summary = knowledge.search_summary(entry_type="expertise")
        full = knowledge.search(entry_type="expertise")
        assert summary["total"] == len(full)
        assert summary["returned"] <= summary["total"]

    def test_count_reflects_mutations(self, knowledge):
        """Count tracks creates, prunes, and type filters accurately."""
        assert knowledge.count() == 0

        e1 = knowledge.create(entry_type="expertise", title="A", content="A")
        e2 = knowledge.create(entry_type="gotcha", title="B", content="B")
        knowledge.create(entry_type="expertise", title="C", content="C", domain="auth")

        assert knowledge.count() == 3
        assert knowledge.count(entry_type="expertise") == 2
        assert knowledge.count(domain="auth") == 1

        knowledge.prune(e1["id"])
        assert knowledge.count() == 2
        assert knowledge.count(entry_type="expertise") == 1

    def test_search_combines_all_filters(self, knowledge):
        """Search with type + domain + text filters returns correct subset."""
        knowledge.create(
            entry_type="expertise", title="SQLite WAL",
            content="WAL mode guide", domain="database",
        )
        knowledge.create(
            entry_type="gotcha", title="SQLite Locking",
            content="Lock contention in WAL", domain="database",
        )
        knowledge.create(
            entry_type="expertise", title="Redis Caching",
            content="Redis setup guide", domain="cache",
        )

        results = knowledge.search(
            entry_type="expertise", domain="database", text="WAL",
        )
        assert len(results) == 1
        assert results[0]["title"] == "SQLite WAL"

    def test_search_summary_truncates_content(self, knowledge):
        """search_summary content_preview is limited to 200 characters."""
        long_content = "x" * 500
        knowledge.create(
            entry_type="expertise", title="Long entry", content=long_content,
        )
        summary = knowledge.search_summary()
        assert len(summary["entries"]) == 1
        assert len(summary["entries"][0]["content_preview"]) == 200


class TestUpdateIntegration:
    """Verify update operations and their interaction with other features."""

    def test_update_multiple_fields_at_once(self, knowledge):
        """Updating multiple allowed fields in one call works correctly."""
        entry = knowledge.create(
            entry_type="expertise", title="Original", content="Original content",
            domain="old-domain", tags="tag1",
        )
        updated = knowledge.update(
            entry["id"], title="Updated", content="New content",
            domain="new-domain", tags="tag1,tag2",
        )
        assert updated["title"] == "Updated"
        assert updated["content"] == "New content"
        assert updated["domain"] == "new-domain"
        assert updated["tags"] == "tag1,tag2"

    def test_update_nonexistent_returns_none(self, knowledge):
        """Updating a nonexistent entry returns None."""
        result = knowledge.update("kn-nonexistent", title="X")
        assert result is None

    def test_update_bumps_updated_at(self, knowledge):
        """Update changes updated_at timestamp."""
        entry = knowledge.create(
            entry_type="expertise", title="A", content="A",
        )
        original_updated = entry["updated_at"]
        updated = knowledge.update(entry["id"], title="B")
        assert updated["updated_at"] >= original_updated


class TestEntryTypeValidation:
    """Verify all valid entry types work and invalid ones are rejected."""

    @pytest.mark.parametrize("entry_type", [
        "pattern", "correction", "expertise", "gotcha", "reference", "observation",
    ])
    def test_all_valid_entry_types_accepted(self, knowledge, entry_type):
        """Every entry in ENTRY_TYPES can be created successfully."""
        entry = knowledge.create(
            entry_type=entry_type, title=f"Test {entry_type}",
            content=f"Content for {entry_type}",
        )
        assert entry["entry_type"] == entry_type

    @pytest.mark.parametrize("bad_type", [
        "", "unknown", "EXPERTISE", "Pattern", "fact", "note",
    ])
    def test_invalid_entry_types_rejected(self, knowledge, bad_type):
        """Invalid entry types raise ValueError."""
        with pytest.raises(ValueError, match="Invalid entry_type"):
            knowledge.create(entry_type=bad_type, title="X", content="X")


class TestHealthReportConsistency:
    """Verify health report counts match actual database state."""

    def test_health_counts_match_individual_queries(self, knowledge, db):
        """Health summary counts should match standalone query results."""
        # Create a mix of entries
        for i in range(3):
            e = knowledge.create(
                entry_type="expertise", title=f"Active {i}", content=f"Active {i}",
            )
            if i > 0:
                _backdate_entry(db, e["id"], 100)

        pruned = knowledge.create(entry_type="gotcha", title="Pruned", content="P")
        knowledge.prune(pruned["id"])

        report = knowledge.health(stale_days=90)

        assert report["summary"]["total_active"] == knowledge.count()
        assert report["summary"]["archived"] == 1
        assert report["summary"]["stale_flagged"] == len(report["stale_entries"])
        assert report["summary"]["near_duplicate_pairs"] == len(report["near_duplicates"])
        assert report["summary"]["contradiction_pairs"] == len(report["contradictions"])

    def test_health_is_idempotent(self, knowledge, db):
        """Running health() twice produces the same report."""
        entry = knowledge.create(
            entry_type="expertise", title="Stable", content="Stable entry",
        )
        _backdate_entry(db, entry["id"], 100)

        report1 = knowledge.health(stale_days=90)
        report2 = knowledge.health(stale_days=90)

        assert report1["summary"] == report2["summary"]
        assert len(report1["stale_entries"]) == len(report2["stale_entries"])

    def test_empty_database_health(self, knowledge):
        """Health report on empty database returns zeroed summary."""
        report = knowledge.health()
        assert report["summary"]["total_active"] == 0
        assert report["summary"]["stale_flagged"] == 0
        assert report["summary"]["archived"] == 0
        assert report["summary"]["near_duplicate_pairs"] == 0
        assert report["summary"]["contradiction_pairs"] == 0
        assert len(report["stale_entries"]) == 0
        assert len(report["near_duplicates"]) == 0
        assert len(report["contradictions"]) == 0


class TestLineageIntegration:
    """Verify create_with_lineage records provenance correctly."""

    def test_create_with_lineage_no_tracker(self, knowledge):
        """create_with_lineage works without a lineage tracker (no-op on lineage)."""
        entry = knowledge.create_with_lineage(
            entry_type="expertise",
            title="Promoted entry",
            content="From observation",
            source_observation_ids=["obs-12345678"],
            session_id="sess-test",
        )
        assert entry["id"].startswith("kn-")
        assert entry["entry_type"] == "expertise"

    def test_create_with_lineage_records_events(self, db):
        """create_with_lineage with a tracker records lineage events."""

        class MockLineageTracker:
            def __init__(self):
                self.events = []

            def record_event(self, **kwargs):
                self.events.append(kwargs)

        tracker = MockLineageTracker()
        store = KnowledgeStore(db, lineage=tracker)

        # Create observation in DB so the UPDATE doesn't fail silently
        db.execute(
            "INSERT INTO observations (id, session_id, trigger, category, title, content) "
            "VALUES (?, ?, ?, ?, ?, ?)",
            ("obs-11111111", "sess-1", "test", "test", "Test obs", "Content"),
        )

        entry = store.create_with_lineage(
            entry_type="pattern",
            title="Promoted pattern",
            content="From observation and pattern",
            source_observation_ids=["obs-11111111"],
            source_pattern_ids=["pat-22222222"],
            session_id="sess-test",
        )

        assert entry["id"].startswith("kn-")
        assert len(tracker.events) == 2

        obs_event = tracker.events[0]
        assert obs_event["source_type"] == "observation"
        assert obs_event["target_type"] == "knowledge"
        assert obs_event["event_type"] == "promoted_to_knowledge"

        pat_event = tracker.events[1]
        assert pat_event["source_type"] == "pattern"
        assert pat_event["event_type"] == "promoted_to_knowledge"

        # Verify the observation's knowledge_id FK was updated
        obs = db.execute_one(
            "SELECT knowledge_id FROM observations WHERE id = ?",
            ("obs-11111111",),
        )
        assert obs["knowledge_id"] == entry["id"]


class TestEdgeCases:
    """Edge cases and boundary conditions."""

    def test_prune_nonexistent_returns_false(self, knowledge):
        """Pruning a nonexistent entry returns False."""
        assert knowledge.prune("kn-nonexistent") is False

    def test_record_application_nonexistent_is_silent(self, knowledge):
        """Recording an application on a nonexistent entry doesn't raise."""
        knowledge.record_application("kn-nonexistent")  # should not raise

    def test_flag_stale_with_zero_days_on_old_entry(self, knowledge, db):
        """stale_days=0 flags entries that are even slightly old."""
        entry = knowledge.create(entry_type="expertise", title="A", content="A")
        # Backdate by 1 day so it's strictly older than '-0 days'
        _backdate_entry(db, entry["id"], 1)
        flagged = knowledge.flag_stale(stale_days=0)
        assert len(flagged) >= 1

    def test_search_with_limit(self, knowledge):
        """Search limit parameter restricts results."""
        for i in range(10):
            knowledge.create(entry_type="expertise", title=f"E{i}", content=f"C{i}")
        results = knowledge.search(limit=3)
        assert len(results) == 3

    def test_get_nonexistent_returns_none(self, knowledge):
        """Getting a nonexistent entry returns None."""
        assert knowledge.get("kn-doesnotexist") is None

    def test_create_all_optional_fields(self, knowledge):
        """Create with all optional fields populated."""
        entry = knowledge.create(
            entry_type="expertise",
            title="Full entry",
            content="Complete content",
            domain="testing",
            scope="domain-specific",
            valence="positive",
            tags="tag1,tag2,tag3",
        )
        assert entry["domain"] == "testing"
        assert entry["scope"] == "domain-specific"
        assert entry["tags"] == "tag1,tag2,tag3"

    def test_create_minimal_fields(self, knowledge):
        """Create with only required fields."""
        entry = knowledge.create(
            entry_type="gotcha", title="Minimal", content="Just the basics",
        )
        assert entry["domain"] is None
        assert entry["scope"] == "cross-cutting"
        assert entry["applied_count"] == 0
        assert entry["pruned_at"] is None
        assert entry["stale_flagged_at"] is None


# --- Contradiction detection on create tests ---


class TestContradictionDetector:
    """Tests for the ContradictionDetector and its integration with KnowledgeStore."""

    def test_no_false_flag_when_no_contradiction(self, knowledge):
        """Creating an entry with no contradicting entry should not flag anything."""
        e = knowledge.create(
            entry_type="expertise",
            title="coordinator runs on Mac Mini port 8000",
            content="The coordinator service is a Go binary running on the Mac Mini at port 8000",
            domain="infrastructure",
        )
        conflicts = knowledge.detect_and_flag_contradictions(e)
        assert len(conflicts) == 0

    def test_contradicting_entry_flags_old_as_stale(self, knowledge):
        """Creating a contradicting entry should flag the old entry as stale."""
        old = knowledge.create(
            entry_type="expertise",
            title="coordinator runs on Mac Mini port 8000",
            content="The coordinator service runs on the Mac Mini at port 8000",
            domain="infrastructure",
        )
        new = knowledge.create(
            entry_type="expertise",
            title="coordinator moved to agenticai2 port 8000",
            content="The coordinator service has migrated from Mac Mini, now runs on agenticai2 port 8000",
            domain="infrastructure",
        )
        conflicts = knowledge.detect_and_flag_contradictions(new)
        assert len(conflicts) >= 1
        assert any(c["old_entry_id"] == old["id"] for c in conflicts)

        # Old entry should now be flagged stale
        old_refreshed = knowledge.get(old["id"])
        assert old_refreshed["stale_flagged_at"] is not None

    def test_contradicting_entry_sets_superseded_by(self, knowledge):
        """Flagged entries should have superseded_by pointing to the new entry."""
        old = knowledge.create(
            entry_type="expertise",
            title="coordinator is Python daemon on agenticai2",
            content="The coordinator is a Python daemon deployed on agenticai2",
            domain="infrastructure",
        )
        new = knowledge.create(
            entry_type="expertise",
            title="coordinator is Go binary on Mac Mini",
            content="The coordinator was previously Python on agenticai2. It is now a Go binary on Mac Mini",
            domain="infrastructure",
        )
        knowledge.detect_and_flag_contradictions(new)
        old_refreshed = knowledge.get(old["id"])
        assert old_refreshed["stale_flagged_at"] is not None
        assert old_refreshed.get("superseded_by") == new["id"]

    def test_correction_type_flags_existing_entry(self, knowledge):
        """A correction-type entry should flag overlapping entries."""
        gotcha = knowledge.create(
            entry_type="gotcha",
            title="pagination cursors use sync_version column",
            content="Pagination cursors must use the sync_version column for ordering",
            domain="database",
        )
        correction = knowledge.create(
            entry_type="correction",
            title="pagination cursors must use unique columns, sync_version is non-unique",
            content="Pagination cursors must use unique columns. The sync_version column is non-unique and causes skipped rows.",
            domain="database",
        )
        conflicts = knowledge.detect_and_flag_contradictions(correction)
        assert len(conflicts) >= 1
        assert any(c["old_entry_id"] == gotcha["id"] for c in conflicts)

    def test_different_domain_no_cross_flag(self, knowledge):
        """Entries in different domains should not cross-flag."""
        knowledge.create(
            entry_type="expertise",
            title="coordinator runs on Mac Mini port 8000",
            content="The coordinator runs on Mac Mini at port 8000",
            domain="infrastructure",
        )
        new = knowledge.create(
            entry_type="expertise",
            title="coordinator runs on Docker port 8000",
            content="The coordinator runs on Docker at port 8000 for local dev",
            domain="security",
        )
        conflicts = knowledge.detect_and_flag_contradictions(new)
        assert len(conflicts) == 0

    def test_batch_detect_contradictions(self, knowledge):
        """batch_detect_contradictions should find known contradicting pairs."""
        knowledge.create(
            entry_type="expertise",
            title="API gateway uses nginx",
            content="The API gateway uses nginx for reverse proxying",
            domain="infrastructure",
        )
        knowledge.create(
            entry_type="expertise",
            title="API gateway uses caddy",
            content="The API gateway uses caddy for reverse proxying",
            domain="infrastructure",
        )
        report = knowledge.batch_detect_contradictions()
        assert report["total_entries_scanned"] >= 2
        assert report["contradictions_found"] >= 1
        assert len(report["conflicts"]) >= 1

    def test_explicit_negation_signal(self, knowledge):
        """Entries with 'no longer' / 'replaced' signals should flag old entries."""
        old = knowledge.create(
            entry_type="expertise",
            title="dispatch service handles routing",
            content="The dispatch service handles all message routing on agenticai2",
            domain="infrastructure",
        )
        new = knowledge.create(
            entry_type="expertise",
            title="dispatch service routing replaced",
            content="The dispatch service is no longer handling routing. The coordinator replaced dispatch for routing.",
            domain="infrastructure",
        )
        conflicts = knowledge.detect_and_flag_contradictions(new)
        assert len(conflicts) >= 1
        assert any(c["old_entry_id"] == old["id"] for c in conflicts)

    def test_short_generic_entries_not_flagged(self, knowledge):
        """Very short entries with generic words should not trigger false flags."""
        knowledge.create(
            entry_type="observation",
            title="Fixed bug",
            content="Fixed the bug",
            domain="infrastructure",
        )
        new = knowledge.create(
            entry_type="observation",
            title="Fixed bug",
            content="Fixed the bug",
            domain="infrastructure",
        )
        conflicts = knowledge.detect_and_flag_contradictions(new)
        assert len(conflicts) == 0

    def test_version_updates_not_contradictions(self, knowledge):
        """Version updates are not contradictions."""
        knowledge.create(
            entry_type="reference",
            title="GreyMatter v2.2.0 released",
            content="Version 2.2.0 of GreyMatter was released with new features",
            domain="knowledge",
        )
        new = knowledge.create(
            entry_type="reference",
            title="GreyMatter v2.3.0 released",
            content="Version 2.3.0 of GreyMatter was released with improvements",
            domain="knowledge",
        )
        conflicts = knowledge.detect_and_flag_contradictions(new)
        assert len(conflicts) == 0

    def test_already_stale_entry_not_reflagged(self, knowledge, db):
        """An entry already flagged stale should not be re-flagged."""
        old = knowledge.create(
            entry_type="expertise",
            title="coordinator runs on Mac Mini port 8000",
            content="The coordinator runs on the Mac Mini at port 8000",
            domain="infrastructure",
        )
        # Pre-flag as stale
        db.execute(
            "UPDATE knowledge_entries SET stale_flagged_at = datetime('now') WHERE id = ?",
            (old["id"],),
        )
        new = knowledge.create(
            entry_type="expertise",
            title="coordinator migrated from Mac Mini to agenticai2",
            content="The coordinator has migrated from Mac Mini and now runs on agenticai2 at port 8000",
            domain="infrastructure",
        )
        # Should not appear in candidates (already stale)
        conflicts = knowledge.detect_and_flag_contradictions(new)
        assert len(conflicts) == 0

    def test_conflicts_returned_with_confidence(self, knowledge):
        """Each conflict should include a confidence score between 0 and 1."""
        knowledge.create(
            entry_type="expertise",
            title="API gateway uses nginx for proxying",
            content="The API gateway uses nginx for reverse proxying to services",
            domain="infrastructure",
        )
        new = knowledge.create(
            entry_type="expertise",
            title="API gateway uses caddy for proxying",
            content="The API gateway uses caddy for reverse proxying to services",
            domain="infrastructure",
        )
        conflicts = knowledge.detect_and_flag_contradictions(new)
        assert len(conflicts) >= 1
        for c in conflicts:
            assert 0.0 <= c["confidence"] <= 1.0
            assert "reason" in c
            assert "old_entry_id" in c
            assert "old_entry_title" in c
