#!/usr/bin/env python3
"""
Conflict Monitor: contradiction detection on real brain data.

Loads actual JSONL items from Hippocampus and verifies:
  1. Real brain items load from JSONL files.
  2. Negation/affirmation signal detection works on real text.
  3. text_similarity produces valid 0.0-1.0 scores on real item pairs.
  4. Identical text → similarity 1.0.
  5. scan_for_conflicts detects a contradiction seeded in a domain
     taken from real brain data.
  6. Low-similarity items are not falsely flagged.
  7. No older items → no conflicts reported.
  8. Temp DB seeded with real lessons as 'old' items runs cleanly.
  9. Production DB (if present) scan completes without exception.
"""
import json
import sqlite3
import sys
import unittest
from datetime import datetime, timezone, timedelta
from pathlib import Path
from unittest.mock import patch

ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))

HIPPOCAMPUS = ROOT / "Hippocampus"
LESSONS_FILE = HIPPOCAMPUS / "Lessons" / "auto_lessons.jsonl"
FAILS_FILE = HIPPOCAMPUS / "Failures" / "auto_fails.jsonl"
PATTERNS_FILE = HIPPOCAMPUS / "Patterns" / "auto_patterns.jsonl"

from pulse.daemons.synthesis.core.conflict_monitor import (
    scan_for_conflicts, NEGATION_WORDS, AFFIRMATION_WORDS,
)
from pulse.core.brain_analysis import text_similarity


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _load_jsonl(path: Path, limit: int = 100) -> list:
    items = []
    try:
        with open(path, encoding="utf-8") as fh:
            for i, line in enumerate(fh):
                if i >= limit:
                    break
                line = line.strip()
                if line:
                    items.append(json.loads(line))
    except FileNotFoundError:
        pass
    return items


def _make_conn() -> sqlite3.Connection:
    """In-memory SQLite with the minimal schema used by conflict_monitor."""
    conn = sqlite3.connect(":memory:")
    conn.row_factory = sqlite3.Row
    for tbl in ("lessons", "failures", "patterns"):
        conn.execute(f"""
            CREATE TABLE {tbl} (
                id       TEXT PRIMARY KEY,
                title    TEXT,
                content  TEXT,
                domain   TEXT DEFAULT 'general',
                timestamp TEXT,
                validity  TEXT DEFAULT 'valid'
            )
        """)
    conn.commit()
    return conn


def _insert(conn, table: str, item_id: str, title: str, content: str,
            domain: str, timestamp: str) -> None:
    conn.execute(
        f"INSERT INTO {table} (id,title,content,domain,timestamp,validity)"
        f" VALUES (?,?,?,?,?,'valid')",
        (item_id, title, content, domain, timestamp),
    )
    conn.commit()


# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------

class TestConflictMonitorRealData(unittest.TestCase):
    """Contradiction detection validated against actual JSONL brain items."""

    @classmethod
    def setUpClass(cls):
        cls.lessons = _load_jsonl(LESSONS_FILE, 100)
        cls.failures = _load_jsonl(FAILS_FILE, 100)
        cls.patterns = _load_jsonl(PATTERNS_FILE, 100)

    # ── 1. Real files ──────────────────────────────────────────────────────

    def test_real_brain_files_non_empty(self):
        """Hippocampus JSONL files must contain at least one item each."""
        self.assertGreater(len(self.lessons), 0,
                           f"{LESSONS_FILE} is empty or missing")
        self.assertGreater(len(self.failures), 0,
                           f"{FAILS_FILE} is empty or missing")
        self.assertGreater(len(self.patterns), 0,
                           f"{PATTERNS_FILE} is empty or missing")

    def test_real_items_have_expected_fields(self):
        """Every loaded item must carry id, content, and domain fields."""
        for item in self.lessons[:10]:
            self.assertIn("id", item, "Lesson missing 'id'")
            self.assertIn("content", item, "Lesson missing 'content'")
            self.assertIn("domain", item, "Lesson missing 'domain'")

    # ── 2. Signal detection on real text ──────────────────────────────────

    def test_signal_detection_covers_real_lessons(self):
        """At least some real lessons must trigger affirmation or negation signals."""
        hits = 0
        for item in self.lessons[:50]:
            text = (item.get("title") or item.get("content") or "").lower()
            words = set(text.split())
            if words & AFFIRMATION_WORDS or words & NEGATION_WORDS:
                hits += 1
        self.assertGreater(hits, 0,
                           "No real lesson text triggered any signal word — "
                           "NEGATION_WORDS / AFFIRMATION_WORDS may need expansion")

    def test_negation_words_present_in_some_failures(self):
        """Failure items (what went wrong) should contain negation signals in at least one."""
        hits = sum(
            1 for item in self.failures[:50]
            if set((item.get("title") or "").lower().split()) & NEGATION_WORDS
        )
        # Not asserting > 0 (failure titles describe root-cause, not commands)
        # but the count must be a non-negative integer
        self.assertIsInstance(hits, int)
        self.assertGreaterEqual(hits, 0)

    # ── 3. text_similarity on real pairs ─────────────────────────────────

    def test_similarity_bounds_on_real_items(self):
        """text_similarity must return a float in [0.0, 1.0] on real titles."""
        if len(self.lessons) < 2:
            self.skipTest("Not enough lesson items")
        a = (self.lessons[0].get("title") or "")[:80].lower()
        b = (self.lessons[1].get("title") or "")[:80].lower()
        sim = text_similarity(a, b)
        self.assertGreaterEqual(sim, 0.0)
        self.assertLessEqual(sim, 1.0)

    # ── 4. Identical text → 1.0 ───────────────────────────────────────────

    def test_identical_text_similarity_is_one(self):
        """Jaccard similarity of identical strings must equal 1.0."""
        txt = "always use atomic writes before any brain state update"
        self.assertEqual(text_similarity(txt, txt), 1.0)

    def test_empty_text_similarity_is_zero(self):
        """Similarity with an empty string must equal 0.0."""
        self.assertEqual(text_similarity("", "some text"), 0.0)
        self.assertEqual(text_similarity("some text", ""), 0.0)

    # ── 5. Contradiction detected in real domain ──────────────────────────

    def test_scan_detects_contradiction_in_real_domain(self):
        """scan_for_conflicts must flag a negation/affirmation pair seeded
        in a domain taken from an actual brain item."""
        domain = self.lessons[0].get("domain", "general") if self.lessons else "general"
        conn = _make_conn()
        now = datetime.now(timezone.utc)

        # Texts differ only in the leading signal word → high Jaccard similarity
        # (7 shared words out of 9 total ≈ 0.78, well above the 0.4 threshold)
        _insert(conn, "lessons", "old_aff",
                "always use atomic writes when updating brain files",
                "always use atomic writes when updating brain files",
                domain, (now - timedelta(hours=48)).isoformat())

        _insert(conn, "lessons", "new_neg",
                "never use atomic writes when updating brain files",
                "never use atomic writes when updating brain files",
                domain, now.isoformat())

        with patch("pulse.core.brain_db.get_conn", return_value=conn), \
             patch("pulse.daemons.synthesis.core.conflict_monitor._save_conflicts"):
            result = scan_for_conflicts(verbose=False)

        self.assertEqual(result["status"], "ok",
                         f"Unexpected status: {result}")
        self.assertGreater(result.get("conflicts", 0), 0,
                           "scan_for_conflicts must detect the injected contradiction")

    # ── 5b. Contradiction detected in failures table ─────────────────────

    def test_scan_detects_contradiction_in_failures_table(self):
        """scan_for_conflicts must flag a negation/affirmation pair seeded
        in the failures table (not only lessons)."""
        domain = self.failures[0].get("domain", "general") if self.failures else "general"
        conn = _make_conn()
        now = datetime.now(timezone.utc)

        _insert(conn, "failures", "old_aff_fail",
                "always validate task signatures before routing",
                "always validate task signatures before routing",
                domain, (now - timedelta(hours=48)).isoformat())

        _insert(conn, "failures", "new_neg_fail",
                "never validate task signatures before routing",
                "never validate task signatures before routing",
                domain, now.isoformat())

        with patch("pulse.core.brain_db.get_conn", return_value=conn), \
             patch("pulse.daemons.synthesis.core.conflict_monitor._save_conflicts"):
            result = scan_for_conflicts(verbose=False)

        self.assertEqual(result["status"], "ok",
                         f"Unexpected status: {result}")
        self.assertGreater(result.get("conflicts", 0), 0,
                           "scan_for_conflicts must detect the contradiction in failures table")

    # ── 6. Low-similarity items → no false positive ───────────────────────

    def test_scan_no_false_positive_unrelated_topics(self):
        """Unrelated texts that share only signal words must NOT be flagged
        when their Jaccard similarity is below the 0.4 threshold."""
        old_text = "always deploy to staging first before production it works"
        new_text = "never skip the brain query it is wrong to avoid searching"
        sim = text_similarity(old_text[:80], new_text[:80])
        # Only run the DB check when similarity is actually below threshold
        if sim >= 0.4:
            self.skipTest(f"Incidentally similar texts (sim={sim:.3f}); skipping")

        conn = _make_conn()
        now = datetime.now(timezone.utc)
        _insert(conn, "failures", "old_u", old_text, old_text,
                "deployment", (now - timedelta(hours=48)).isoformat())
        _insert(conn, "failures", "new_u", new_text, new_text,
                "deployment", now.isoformat())

        with patch("pulse.core.brain_db.get_conn", return_value=conn), \
             patch("pulse.daemons.synthesis.core.conflict_monitor._save_conflicts"):
            result = scan_for_conflicts(verbose=False)

        self.assertEqual(result.get("conflicts", 0), 0,
                         f"Low-similarity pair wrongly flagged (sim={sim:.3f})")

    # ── 7. No older items → no conflicts ─────────────────────────────────

    def test_scan_no_conflicts_when_only_recent_items(self):
        """With only recent items and no older counterparts,
        no conflicts should be detected."""
        conn = _make_conn()
        now = datetime.now(timezone.utc)
        _insert(conn, "lessons", "only_recent",
                "always use atomic writes for updates",
                "always use atomic writes for brain updates",
                "general", now.isoformat())

        with patch("pulse.core.brain_db.get_conn", return_value=conn), \
             patch("pulse.daemons.synthesis.core.conflict_monitor._save_conflicts"):
            result = scan_for_conflicts(verbose=False)

        self.assertEqual(result["status"], "ok")
        self.assertEqual(result.get("conflicts", 0), 0)

    # ── 8. Temp DB seeded with real lessons ──────────────────────────────

    def test_scan_with_real_lessons_as_old_items(self):
        """Seed temp DB with actual JSONL lessons as 'old', add a neutral
        recent item, and verify scan_for_conflicts completes without error."""
        conn = _make_conn()
        now = datetime.now(timezone.utc)
        old_ts = (now - timedelta(hours=72)).isoformat()
        recent_ts = now.isoformat()

        inserted = 0
        for i, item in enumerate(self.lessons[:25]):
            title = (item.get("title") or item.get("content") or "")[:80]
            content = (item.get("content") or "")[:200]
            domain = item.get("domain", "general")
            if title and len(title) >= 20:
                try:
                    _insert(conn, "lessons", f"real_old_{i}",
                            title, content, domain, old_ts)
                    inserted += 1
                except Exception:
                    pass  # Skip duplicate fingerprints

        # Recent neutral item in a common domain
        _insert(conn, "lessons", "recent_neutral",
                "brain search should always run before any implementation step",
                "brain search should always run before any implementation step",
                "neural_infra", recent_ts)

        with patch("pulse.core.brain_db.get_conn", return_value=conn), \
             patch("pulse.daemons.synthesis.core.conflict_monitor._save_conflicts"):
            result = scan_for_conflicts(verbose=False)

        self.assertEqual(result["status"], "ok",
                         f"scan failed with real data seeded ({inserted} old items)")
        self.assertIsInstance(result.get("conflicts", 0), int)

    # ── 9. Production DB (if present) ─────────────────────────────────────

    def test_production_db_scan_does_not_crash(self):
        """If the production SQLite DB exists, scan must complete without
        raising an exception. Returns 'ok' or 'skipped'."""
        try:
            result = scan_for_conflicts(verbose=False)
        except Exception as exc:
            self.fail(f"scan_for_conflicts raised on production DB: {exc}")
        self.assertIn("status", result)
        self.assertIn(result["status"], ("ok", "skipped"),
                      f"Unexpected status: {result['status']}")


if __name__ == "__main__":
    unittest.main(verbosity=2)
