"""Comprehensive pytest tests for Phase 6 (Emergence) modules.

Modules under test:
  1. pulse.daemons.synthesis.learning.associative_replay  — Dreaming engine
  2. pulse.daemons.consolidation.culture_synth   — Monthly culture distillation
  3. pulse.comms.collective_decision             — Multi-agent voting
  4. pulse.brain.pattern_evolution               — Auto-generate regex from semantic misses
"""
import sys
import json
import sqlite3
import re
from pathlib import Path
from datetime import datetime, timezone, timedelta
from unittest.mock import MagicMock, patch, ANY

import pytest

# Ensure project root is on sys.path so pulse.* imports resolve
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))


# ---------------------------------------------------------------------------
# Helpers: In-memory SQLite with the schema the modules expect
# ---------------------------------------------------------------------------

def _make_inmemory_db():
    """Return an in-memory sqlite3.Connection with the tables used by Phase 6."""
    conn = sqlite3.connect(":memory:")
    conn.row_factory = sqlite3.Row
    for table in ("lessons", "failures", "patterns"):
        conn.execute(f"""
            CREATE TABLE {table} (
                id TEXT PRIMARY KEY,
                fingerprint TEXT NOT NULL DEFAULT '',
                content TEXT NOT NULL DEFAULT '',
                title TEXT NOT NULL DEFAULT '',
                domain TEXT NOT NULL DEFAULT 'general',
                salience REAL NOT NULL DEFAULT 1.0,
                confidence REAL NOT NULL DEFAULT 0.5,
                validity TEXT NOT NULL DEFAULT 'valid',
                timestamp TEXT NOT NULL DEFAULT ''
            )
        """)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS graph_edges (
            from_node TEXT NOT NULL,
            to_node TEXT NOT NULL,
            edge_type TEXT NOT NULL,
            confidence REAL NOT NULL DEFAULT 0.5,
            metadata TEXT NOT NULL DEFAULT '{}',
            UNIQUE(from_node, edge_type, to_node)
        )
    """)
    conn.commit()
    return conn


def _insert_items(conn, table, items):
    """Insert a list of dicts into *table*."""
    for item in items:
        conn.execute(
            f"INSERT INTO {table} (id, content, title, domain, salience, confidence, validity, timestamp) "
            f"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
            (
                item.get("id", f"id_{id(item)}"),
                item.get("content", ""),
                item.get("title", ""),
                item.get("domain", "general"),
                item.get("salience", 1.0),
                item.get("confidence", 0.5),
                item.get("validity", "valid"),
                item.get("timestamp", datetime.now(timezone.utc).isoformat()),
            ),
        )
    conn.commit()


# =========================================================================
# 1. associative_replay tests
# =========================================================================

class TestAssociativeReplay:
    """Tests for pulse.daemons.synthesis.learning.associative_replay."""

    # ------------------------------------------------------------------
    # _partition_episodes
    # ------------------------------------------------------------------
    def test_partition_episodes_empty(self):
        from pulse.daemons.synthesis.learning.associative_replay import _partition_episodes
        assert _partition_episodes([]) == []

    def test_partition_episodes_single_item(self):
        from pulse.daemons.synthesis.learning.associative_replay import _partition_episodes
        # Single-item episodes are filtered out (len < 2)
        now = datetime.now(timezone.utc).isoformat()
        result = _partition_episodes([{"timestamp": now}])
        assert result == []

    def test_partition_episodes_two_items_within_window(self):
        from pulse.daemons.synthesis.learning.associative_replay import _partition_episodes
        t1 = datetime(2026, 3, 12, 10, 0, 0, tzinfo=timezone.utc)
        t2 = t1 + timedelta(minutes=15)
        items = [{"timestamp": t1.isoformat()}, {"timestamp": t2.isoformat()}]
        eps = _partition_episodes(items, window_minutes=30)
        assert len(eps) == 1
        assert len(eps[0]) == 2

    def test_partition_episodes_split_across_windows(self):
        from pulse.daemons.synthesis.learning.associative_replay import _partition_episodes
        t1 = datetime(2026, 3, 12, 10, 0, 0, tzinfo=timezone.utc)
        t2 = t1 + timedelta(minutes=10)
        t3 = t1 + timedelta(minutes=65)  # > 30 min gap from t2
        t4 = t3 + timedelta(minutes=5)
        items = [
            {"timestamp": t1.isoformat()}, {"timestamp": t2.isoformat()},
            {"timestamp": t3.isoformat()}, {"timestamp": t4.isoformat()},
        ]
        eps = _partition_episodes(items, window_minutes=30)
        assert len(eps) == 2
        assert len(eps[0]) == 2
        assert len(eps[1]) == 2

    def test_partition_episodes_bad_timestamps_start_new_episode(self):
        from pulse.daemons.synthesis.learning.associative_replay import _partition_episodes
        items = [
            {"timestamp": "2026-03-12T10:00:00+00:00"},
            {"timestamp": "not-a-date"},
            {"timestamp": "2026-03-12T10:05:00+00:00"},
        ]
        # Bad timestamp causes new episode; episodes with <2 items are removed
        eps = _partition_episodes(items)
        # Depending on ordering: first episode=[item0], then bad=[item1],
        # then item2 gets its own episode since item1's ts is bad
        # All end up as size-1 episodes -> filtered
        assert all(len(ep) >= 2 for ep in eps)

    # ------------------------------------------------------------------
    # _score_juxtaposition
    # ------------------------------------------------------------------
    def test_score_juxtaposition_llm_success(self, monkeypatch):
        from pulse.daemons.synthesis import associative_replay as mod

        fake_dispatch = MagicMock(return_value="0.85")
        monkeypatch.setattr("pulse.core.llm_router.dispatch", fake_dispatch)

        item_a = {"id": "a1", "domain": "testing", "title": "Test A"}
        item_b = {"id": "b1", "domain": "deploy", "title": "Test B"}
        result = mod._score_juxtaposition(item_a, item_b)
        assert result["combined_score"] == 0.85
        assert result["reasoning"] == "llm_scored"
        assert result["item_a_id"] == "a1"
        fake_dispatch.assert_called_once()

    def test_score_juxtaposition_llm_failure_uses_heuristic(self, monkeypatch):
        from pulse.daemons.synthesis import associative_replay as mod

        monkeypatch.setattr(
            "pulse.core.llm_router.dispatch",
            MagicMock(side_effect=RuntimeError("no LLM")),
        )
        item_a = {"id": "a1", "domain": "testing", "title": "Test A"}
        item_b = {"id": "b1", "domain": "deploy", "title": "Test B"}
        result = mod._score_juxtaposition(item_a, item_b)
        assert result["combined_score"] == 0.5  # cross-domain bonus
        assert result["reasoning"] == "heuristic_fallback"

    def test_score_juxtaposition_same_domain_heuristic(self, monkeypatch):
        from pulse.daemons.synthesis import associative_replay as mod

        monkeypatch.setattr(
            "pulse.core.llm_router.dispatch",
            MagicMock(side_effect=RuntimeError("no LLM")),
        )
        item_a = {"id": "a1", "domain": "testing", "title": "Test A"}
        item_b = {"id": "b1", "domain": "testing", "title": "Test B"}
        result = mod._score_juxtaposition(item_a, item_b)
        assert result["combined_score"] == 0.3  # same domain = lower

    def test_score_juxtaposition_clamps_to_01(self, monkeypatch):
        from pulse.daemons.synthesis import associative_replay as mod

        monkeypatch.setattr("pulse.core.llm_router.dispatch", MagicMock(return_value="1.50"))
        item_a = {"id": "a1", "domain": "x", "title": "A"}
        item_b = {"id": "b1", "domain": "y", "title": "B"}
        result = mod._score_juxtaposition(item_a, item_b)
        assert result["combined_score"] <= 1.0

    # ------------------------------------------------------------------
    # run_replay — full integration (mocked DB + LLM + event_bus + fs)
    # ------------------------------------------------------------------
    def test_run_replay_insufficient_data(self, monkeypatch, tmp_path):
        from pulse.daemons.synthesis import associative_replay as mod

        conn = _make_inmemory_db()
        monkeypatch.setattr("pulse.daemons.synthesis.learning.associative_replay.get_conn", lambda: conn)
        monkeypatch.setattr(mod, "REPLAY_DIR", tmp_path / "replay")
        monkeypatch.setattr("pulse.daemons.synthesis.learning.associative_replay.event_bus", MagicMock())

        result = mod.run_replay()
        assert result["status"] == "insufficient_data"
        assert result["items_replayed"] < 4

    def test_run_replay_cross_domain_produces_edges(self, monkeypatch, tmp_path):
        from pulse.daemons.synthesis import associative_replay as mod

        conn = _make_inmemory_db()
        now = datetime.now(timezone.utc)

        # Insert items across two domains, within 30-min window so they form one episode
        items = []
        for i in range(5):
            items.append({
                "id": f"item_{i}",
                "content": f"content {i}",
                "title": f"Title {i}",
                "domain": "alpha" if i % 2 == 0 else "beta",
                "salience": 5.0 + i,
                "timestamp": (now - timedelta(hours=1, minutes=i)).isoformat(),
            })
        for table in ("lessons", "failures"):
            _insert_items(conn, table, items[:3] if table == "lessons" else items[3:])

        monkeypatch.setattr("pulse.daemons.synthesis.learning.associative_replay.get_conn", lambda: conn)
        monkeypatch.setattr(mod, "REPLAY_DIR", tmp_path / "replay")
        monkeypatch.setattr("pulse.core.llm_router.dispatch", MagicMock(return_value="0.9"))
        mock_bus = MagicMock()
        monkeypatch.setattr("pulse.daemons.synthesis.learning.associative_replay.event_bus", mock_bus)

        result = mod.run_replay()
        assert result["items_replayed"] >= 4
        assert result["new_edges_added"] >= 1
        assert result["llm_calls"] <= 3
        mock_bus.publish.assert_called_once()
        # Replay log was written
        assert (tmp_path / "replay").exists()

    def test_run_replay_llm_call_cap_at_3(self, monkeypatch, tmp_path):
        """Even with 5 juxtapositions, LLM is called at most 3 times."""
        from pulse.daemons.synthesis import associative_replay as mod

        conn = _make_inmemory_db()
        now = datetime.now(timezone.utc)

        # 10 items, 5 in each of two domains, all within a single window
        items = []
        for i in range(10):
            items.append({
                "id": f"cap_{i}",
                "content": f"content {i}",
                "title": f"Title {i}",
                "domain": "domA" if i < 5 else "domB",
                "salience": 8.0,
                "timestamp": (now - timedelta(hours=1, minutes=i)).isoformat(),
            })
        _insert_items(conn, "lessons", items)

        call_count = {"n": 0}
        def counting_dispatch(prompt, task_type="fast"):
            call_count["n"] += 1
            return "0.75"

        monkeypatch.setattr("pulse.daemons.synthesis.learning.associative_replay.get_conn", lambda: conn)
        monkeypatch.setattr(mod, "REPLAY_DIR", tmp_path / "replay")
        monkeypatch.setattr("pulse.core.llm_router.dispatch", counting_dispatch)
        monkeypatch.setattr("pulse.daemons.synthesis.learning.associative_replay.event_bus", MagicMock())

        result = mod.run_replay()
        assert result["llm_calls"] <= 3
        assert call_count["n"] <= 3

    def test_run_replay_same_domain_excluded_from_juxtapositions(self, monkeypatch, tmp_path):
        """Items in the same domain within an episode produce no juxtapositions."""
        from pulse.daemons.synthesis import associative_replay as mod

        conn = _make_inmemory_db()
        now = datetime.now(timezone.utc)

        # All items in the same domain — no cross-domain pairs possible
        items = []
        for i in range(6):
            items.append({
                "id": f"same_{i}",
                "content": f"content {i}",
                "title": f"Title {i}",
                "domain": "only_one_domain",
                "salience": 5.0,
                "timestamp": (now - timedelta(hours=1, minutes=i)).isoformat(),
            })
        _insert_items(conn, "lessons", items)

        monkeypatch.setattr("pulse.daemons.synthesis.learning.associative_replay.get_conn", lambda: conn)
        monkeypatch.setattr(mod, "REPLAY_DIR", tmp_path / "replay")
        monkeypatch.setattr("pulse.daemons.synthesis.learning.associative_replay.event_bus", MagicMock())

        result = mod.run_replay()
        assert result["juxtapositions_scored"] == 0
        assert result["new_edges_added"] == 0

    def test_run_replay_novelty_threshold(self, monkeypatch, tmp_path):
        """Only juxtapositions with combined_score > 0.6 produce graph edges."""
        from pulse.daemons.synthesis import associative_replay as mod

        conn = _make_inmemory_db()
        now = datetime.now(timezone.utc)

        items = []
        for i in range(6):
            items.append({
                "id": f"novel_{i}",
                "content": f"content {i}",
                "title": f"Title {i}",
                "domain": "alpha" if i % 2 == 0 else "beta",
                "salience": 5.0,
                "timestamp": (now - timedelta(hours=1, minutes=i)).isoformat(),
            })
        _insert_items(conn, "lessons", items)

        # LLM returns low score -> no edges
        monkeypatch.setattr("pulse.core.llm_router.dispatch", MagicMock(return_value="0.2"))
        monkeypatch.setattr("pulse.daemons.synthesis.learning.associative_replay.get_conn", lambda: conn)
        monkeypatch.setattr(mod, "REPLAY_DIR", tmp_path / "replay")
        monkeypatch.setattr("pulse.daemons.synthesis.learning.associative_replay.event_bus", MagicMock())

        result = mod.run_replay()
        assert result["new_edges_added"] == 0

    def test_save_replay_log(self, monkeypatch, tmp_path):
        from pulse.daemons.synthesis import associative_replay as mod

        monkeypatch.setattr(mod, "REPLAY_DIR", tmp_path / "replays")
        session = {"items_replayed": 10, "new_edges_added": 2}
        mod._save_replay_log(session)
        files = list((tmp_path / "replays").glob("replay_*.json"))
        assert len(files) == 1
        data = json.loads(files[0].read_text(encoding="utf-8"))
        assert data["items_replayed"] == 10


# =========================================================================
# 2. culture_synth tests
# =========================================================================

class TestCultureSynth:
    """Tests for pulse.daemons.consolidation.culture_synth."""

    # ------------------------------------------------------------------
    # run_culture_synthesis
    # ------------------------------------------------------------------
    def test_insufficient_data(self, monkeypatch, tmp_path):
        from pulse.daemons.consolidation import culture_synth as mod

        conn = _make_inmemory_db()
        monkeypatch.setattr("pulse.daemons.consolidation.culture_synth.get_conn", lambda: conn)
        monkeypatch.setattr(mod, "CULTURE_DIR", tmp_path / "culture")
        monkeypatch.setattr(mod, "PRINCIPLES_FILE", tmp_path / "culture" / "principles.json")
        monkeypatch.setattr("pulse.daemons.consolidation.culture_synth.event_bus", MagicMock())

        result = mod.run_culture_synthesis()
        assert result["status"] == "insufficient_data"

    def test_llm_distill_success(self, monkeypatch, tmp_path):
        from pulse.daemons.consolidation import culture_synth as mod

        conn = _make_inmemory_db()
        items = [
            {"id": f"l_{i}", "content": f"Always test your code {i}", "title": f"Testing rule {i}",
             "domain": "testing", "salience": 5.0, "confidence": 0.9}
            for i in range(15)
        ]
        _insert_items(conn, "lessons", items)

        llm_response = json.dumps([
            {"rank": 1, "title": "Test First", "description": "Always test before deploy", "confidence": 0.95},
            {"rank": 2, "title": "Automate", "description": "Automate everything", "confidence": 0.9},
        ])

        monkeypatch.setattr("pulse.daemons.consolidation.culture_synth.get_conn", lambda: conn)
        monkeypatch.setattr("pulse.core.llm_router.dispatch", MagicMock(return_value=llm_response))
        monkeypatch.setattr(mod, "CULTURE_DIR", tmp_path / "culture")
        monkeypatch.setattr(mod, "PRINCIPLES_FILE", tmp_path / "culture" / "principles.json")
        monkeypatch.setattr("pulse.daemons.consolidation.culture_synth.event_bus", MagicMock())

        result = mod.run_culture_synthesis()
        assert len(result["principles"]) == 2
        assert result["principles"][0]["title"] == "Test First"
        # File was written
        assert (tmp_path / "culture" / "principles.json").exists()

    def test_llm_failure_falls_back_to_frequency(self, monkeypatch, tmp_path):
        from pulse.daemons.consolidation import culture_synth as mod

        conn = _make_inmemory_db()
        items = [
            {"id": f"l_{i}", "content": f"testing automation quality {i}",
             "title": f"Lesson about testing {i}",
             "domain": "testing", "salience": 5.0, "confidence": 0.9}
            for i in range(15)
        ]
        _insert_items(conn, "lessons", items)

        # LLM dispatch fails
        monkeypatch.setattr(
            "pulse.core.llm_router.dispatch",
            MagicMock(side_effect=RuntimeError("no provider")),
        )
        monkeypatch.setattr("pulse.daemons.consolidation.culture_synth.get_conn", lambda: conn)
        monkeypatch.setattr(mod, "CULTURE_DIR", tmp_path / "culture")
        monkeypatch.setattr(mod, "PRINCIPLES_FILE", tmp_path / "culture" / "principles.json")
        monkeypatch.setattr("pulse.daemons.consolidation.culture_synth.event_bus", MagicMock())

        result = mod.run_culture_synthesis()
        # Frequency fallback produces principles
        assert len(result["principles"]) > 0
        assert len(result["principles"]) <= 5
        # Each principle has required keys
        for p in result["principles"]:
            assert "rank" in p
            assert "title" in p
            assert "confidence" in p

    def test_max_5_principles(self, monkeypatch, tmp_path):
        from pulse.daemons.consolidation import culture_synth as mod

        conn = _make_inmemory_db()
        items = [
            {"id": f"l_{i}", "content": f"word_{i} repeated content",
             "title": f"Lesson {i}", "domain": "general", "salience": 3.0, "confidence": 0.8}
            for i in range(20)
        ]
        _insert_items(conn, "lessons", items)

        # LLM returns 7 principles — should be capped to 5
        llm_response = json.dumps([
            {"rank": i, "title": f"P{i}", "description": f"Desc {i}", "confidence": 0.8}
            for i in range(1, 8)
        ])
        monkeypatch.setattr("pulse.daemons.consolidation.culture_synth.get_conn", lambda: conn)
        monkeypatch.setattr("pulse.core.llm_router.dispatch", MagicMock(return_value=llm_response))
        monkeypatch.setattr(mod, "CULTURE_DIR", tmp_path / "culture")
        monkeypatch.setattr(mod, "PRINCIPLES_FILE", tmp_path / "culture" / "principles.json")
        monkeypatch.setattr("pulse.daemons.consolidation.culture_synth.event_bus", MagicMock())

        result = mod.run_culture_synthesis()
        assert len(result["principles"]) <= 5

    def test_event_bus_published(self, monkeypatch, tmp_path):
        from pulse.daemons.consolidation import culture_synth as mod

        conn = _make_inmemory_db()
        items = [
            {"id": f"l_{i}", "content": f"testing content {i}", "title": f"T{i}",
             "domain": "general", "salience": 3.0, "confidence": 0.8}
            for i in range(12)
        ]
        _insert_items(conn, "lessons", items)

        monkeypatch.setattr("pulse.daemons.consolidation.culture_synth.get_conn", lambda: conn)
        monkeypatch.setattr("pulse.core.llm_router.dispatch", MagicMock(side_effect=RuntimeError))
        monkeypatch.setattr(mod, "CULTURE_DIR", tmp_path / "culture")
        monkeypatch.setattr(mod, "PRINCIPLES_FILE", tmp_path / "culture" / "principles.json")
        mock_bus = MagicMock()
        monkeypatch.setattr("pulse.daemons.consolidation.culture_synth.event_bus", mock_bus)

        mod.run_culture_synthesis()
        mock_bus.publish.assert_called_once_with(
            "culture.synthesized", "culture_synth", ANY,
        )

    # ------------------------------------------------------------------
    # _frequency_distill
    # ------------------------------------------------------------------
    def test_frequency_distill_counts_words(self):
        from pulse.daemons.consolidation.culture_synth import _frequency_distill

        items = [
            {"title": "Testing always", "content": "testing helps quality"},
            {"title": "Testing again", "content": "testing more quality"},
            {"title": "Deploy fast", "content": "deploy with quality"},
        ]
        result = _frequency_distill(items)
        assert len(result) <= 5
        # "testing" and "quality" should be top — both appear 3 times
        titles = [p["title"].lower() for p in result]
        assert "testing" in titles or "quality" in titles

    def test_frequency_distill_stops_excluded(self):
        from pulse.daemons.consolidation.culture_synth import _frequency_distill

        items = [
            {"title": "the the the", "content": "the the the the"},
        ]
        result = _frequency_distill(items)
        # "the" is a stop word; nothing with len>3 and not in stop set
        assert len(result) == 0

    def test_frequency_distill_short_words_excluded(self):
        from pulse.daemons.consolidation.culture_synth import _frequency_distill

        items = [
            {"title": "go up by it ok", "content": "go up by it ok"},
        ]
        result = _frequency_distill(items)
        # All words <= 3 chars, so excluded
        assert len(result) == 0

    # ------------------------------------------------------------------
    # _llm_distill
    # ------------------------------------------------------------------
    def test_llm_distill_extracts_json_from_wrapped_response(self, monkeypatch):
        from pulse.daemons.consolidation.culture_synth import _llm_distill

        response = 'Here are the principles:\n[{"rank":1,"title":"X","description":"Y","confidence":0.9}]\nDone.'
        monkeypatch.setattr("pulse.core.llm_router.dispatch", MagicMock(return_value=response))

        items = [{"domain": "d", "title": "t", "content": "c"}] * 10
        result = _llm_distill(items)
        assert len(result) == 1
        assert result[0]["title"] == "X"

    def test_llm_distill_returns_empty_on_no_json(self, monkeypatch):
        from pulse.daemons.consolidation.culture_synth import _llm_distill

        monkeypatch.setattr("pulse.core.llm_router.dispatch", MagicMock(return_value="No JSON here"))
        result = _llm_distill([{"domain": "d", "title": "t", "content": "c"}] * 10)
        assert result == []

    # ------------------------------------------------------------------
    # load_principles
    # ------------------------------------------------------------------
    def test_load_principles_no_file(self, monkeypatch, tmp_path):
        from pulse.daemons.consolidation import culture_synth as mod

        monkeypatch.setattr(mod, "PRINCIPLES_FILE", tmp_path / "nonexistent.json")
        assert mod.load_principles() == []

    def test_load_principles_valid(self, monkeypatch, tmp_path):
        from pulse.daemons.consolidation import culture_synth as mod

        f = tmp_path / "principles.json"
        f.write_text(json.dumps({"principles": [{"rank": 1, "title": "T"}]}), encoding="utf-8")
        monkeypatch.setattr(mod, "PRINCIPLES_FILE", f)
        result = mod.load_principles()
        assert len(result) == 1
        assert result[0]["title"] == "T"

    def test_load_principles_corrupt_json(self, monkeypatch, tmp_path):
        from pulse.daemons.consolidation import culture_synth as mod

        f = tmp_path / "principles.json"
        f.write_text("{corrupt", encoding="utf-8")
        monkeypatch.setattr(mod, "PRINCIPLES_FILE", f)
        assert mod.load_principles() == []


# =========================================================================
# 3. collective_decision tests
# =========================================================================

class TestCollectiveDecision:
    """Tests for pulse.comms.collective_decision."""

    @pytest.fixture(autouse=True)
    def _setup_io(self, monkeypatch, tmp_path):
        """Redirect DECISIONS_FILE to a temp path for every test."""
        from pulse.comms import collective_decision as mod

        self.decisions_file = tmp_path / "pending_decisions.json"
        monkeypatch.setattr(mod, "DECISIONS_FILE", self.decisions_file)
        monkeypatch.setattr("pulse.comms.collective_decision.event_bus", MagicMock())
        self.mod = mod

    # ------------------------------------------------------------------
    # open_decision
    # ------------------------------------------------------------------
    def test_open_decision_creates_entry(self):
        did = self.mod.open_decision("Refactor DB?", "Should we move to Postgres?")
        assert did.startswith("decision_")
        data = json.loads(self.decisions_file.read_text(encoding="utf-8"))
        assert len(data["decisions"]) == 1
        assert data["decisions"][0]["status"] == "open"
        assert data["decisions"][0]["title"] == "Refactor DB?"

    def test_open_decision_event_published(self):
        self.mod.open_decision("T", "D")
        self.mod.event_bus.publish.assert_called()

    def test_open_multiple_decisions(self):
        d1 = self.mod.open_decision("A", "desc A")
        d2 = self.mod.open_decision("B", "desc B")
        assert d1 != d2
        data = json.loads(self.decisions_file.read_text(encoding="utf-8"))
        assert len(data["decisions"]) == 2

    # ------------------------------------------------------------------
    # submit_position
    # ------------------------------------------------------------------
    def test_submit_position_success(self):
        did = self.mod.open_decision("Test", "desc")
        ok = self.mod.submit_position(did, "claude", "approve", "looks good", 0.9)
        assert ok is True

    def test_submit_position_duplicate_agent_rejected(self):
        did = self.mod.open_decision("Test", "desc")
        self.mod.submit_position(did, "claude", "approve", "r1", 0.8)
        ok = self.mod.submit_position(did, "claude", "reject", "r2", 0.7)
        assert ok is False

    def test_submit_position_invalid_decision_id(self):
        ok = self.mod.submit_position("nonexistent", "claude", "approve", "r", 0.5)
        assert ok is False

    def test_submit_position_confidence_clamped(self):
        did = self.mod.open_decision("Test", "desc")
        self.mod.submit_position(did, "claude", "approve", "r", 1.5)
        data = json.loads(self.decisions_file.read_text(encoding="utf-8"))
        pos = data["decisions"][0]["positions"][0]
        assert pos["confidence"] == 1.0

    def test_submit_position_negative_confidence_clamped(self):
        did = self.mod.open_decision("Test", "desc")
        self.mod.submit_position(did, "claude", "approve", "r", -0.5)
        data = json.loads(self.decisions_file.read_text(encoding="utf-8"))
        pos = data["decisions"][0]["positions"][0]
        assert pos["confidence"] == 0.0

    def test_submit_position_to_closed_decision_fails(self):
        did = self.mod.open_decision("T", "D")
        self.mod.submit_position(did, "a1", "yes", "r", 0.8)
        self.mod.submit_position(did, "a2", "yes", "r", 0.9)
        self.mod.synthesize(did)  # closes it
        ok = self.mod.submit_position(did, "a3", "yes", "r", 0.7)
        assert ok is False

    # ------------------------------------------------------------------
    # synthesize — voting math
    # ------------------------------------------------------------------
    def test_synthesize_consensus_above_70(self):
        """3 agents, all 'approve' with high confidence -> >70% -> synthesized."""
        did = self.mod.open_decision("T", "D")
        self.mod.submit_position(did, "claude", "approve", "r", 0.9)
        self.mod.submit_position(did, "gemini", "approve", "r", 0.8)
        self.mod.submit_position(did, "codex", "approve", "r", 0.7)
        result = self.mod.synthesize(did)
        assert result["status"] == "synthesized"
        assert result["consensus"] == "approve"
        assert result["confidence"] > 0.7

    def test_synthesize_contested_below_30(self):
        """No single position has >30% of weighted votes -> contested."""
        did = self.mod.open_decision("T", "D")
        # 4 agents with 4 different positions, similar confidence
        self.mod.submit_position(did, "a1", "pos_A", "r", 0.25)
        self.mod.submit_position(did, "a2", "pos_B", "r", 0.25)
        self.mod.submit_position(did, "a3", "pos_C", "r", 0.25)
        self.mod.submit_position(did, "a4", "pos_D", "r", 0.25)
        result = self.mod.synthesize(did)
        assert result["status"] == "contested"

    def test_synthesize_split_between_30_and_70(self):
        """Majority position is between 30% and 70% -> contested with 'split'."""
        did = self.mod.open_decision("T", "D")
        # 50% / 50% split
        self.mod.submit_position(did, "a1", "approve", "r", 0.5)
        self.mod.submit_position(did, "a2", "reject", "r", 0.5)
        result = self.mod.synthesize(did)
        assert result["status"] == "contested"
        assert result["consensus"] == "split"

    def test_synthesize_weighted_by_confidence(self):
        """One high-confidence vote outweighs multiple low-confidence votes."""
        did = self.mod.open_decision("T", "D")
        # claude: approve with 0.9 confidence
        # gemini + codex: reject with 0.1 confidence each
        self.mod.submit_position(did, "claude", "approve", "r", 0.9)
        self.mod.submit_position(did, "gemini", "reject", "r", 0.1)
        self.mod.submit_position(did, "codex", "reject", "r", 0.1)
        result = self.mod.synthesize(did)
        # approve weight = 0.9, reject weight = 0.2, total = 1.1
        # approve fraction = 0.9/1.1 ≈ 0.818 > 0.7
        assert result["status"] == "synthesized"
        assert result["consensus"] == "approve"

    def test_synthesize_not_enough_positions(self):
        did = self.mod.open_decision("T", "D")
        self.mod.submit_position(did, "a1", "approve", "r", 0.9)
        result = self.mod.synthesize(did)
        # Only 1 position, need >= 2
        assert result["status"] == "open"

    def test_synthesize_zero_total_confidence(self):
        did = self.mod.open_decision("T", "D")
        self.mod.submit_position(did, "a1", "approve", "r", 0.0)
        self.mod.submit_position(did, "a2", "reject", "r", 0.0)
        result = self.mod.synthesize(did)
        assert result["status"] == "contested"

    def test_synthesize_not_found(self):
        result = self.mod.synthesize("nonexistent_id")
        assert result["status"] == "not_found"

    def test_synthesize_event_published(self):
        did = self.mod.open_decision("T", "D")
        self.mod.submit_position(did, "a1", "yes", "r", 0.9)
        self.mod.submit_position(did, "a2", "yes", "r", 0.8)
        self.mod.synthesize(did)
        # event_bus.publish called for both open_decision and synthesize
        calls = self.mod.event_bus.publish.call_args_list
        channels = [c[0][0] for c in calls]
        assert "decision.synthesized" in channels

    # ------------------------------------------------------------------
    # get_open_decisions
    # ------------------------------------------------------------------
    def test_get_open_decisions_empty(self):
        result = self.mod.get_open_decisions()
        assert result == []

    def test_get_open_decisions_filters_closed(self):
        d1 = self.mod.open_decision("Open One", "D")
        d2 = self.mod.open_decision("Will Close", "D")
        self.mod.submit_position(d2, "a1", "yes", "r", 0.8)
        self.mod.submit_position(d2, "a2", "yes", "r", 0.9)
        self.mod.synthesize(d2)

        open_list = self.mod.get_open_decisions()
        assert len(open_list) == 1
        assert open_list[0]["id"] == d1

    # ------------------------------------------------------------------
    # _load_decisions / _save_decisions edge cases
    # ------------------------------------------------------------------
    def test_load_decisions_nonexistent_file(self):
        result = self.mod._load_decisions()
        assert result == {"decisions": []}

    def test_load_decisions_corrupt_file(self):
        self.decisions_file.parent.mkdir(parents=True, exist_ok=True)
        self.decisions_file.write_text("{bad json", encoding="utf-8")
        result = self.mod._load_decisions()
        assert result == {"decisions": []}


# =========================================================================
# 4. pattern_evolution tests
# =========================================================================

class TestPatternEvolution:
    """Tests for pulse.brain.pattern_evolution."""

    @pytest.fixture(autouse=True)
    def _setup_paths(self, monkeypatch, tmp_path):
        from pulse.brain.monitoring import pattern_evolution as mod

        self.misses_log = tmp_path / "semantic_misses.jsonl"
        self.generated = tmp_path / "generated_patterns.json"
        monkeypatch.setattr(mod, "MISSES_LOG", self.misses_log)
        monkeypatch.setattr(mod, "GENERATED_PATTERNS", self.generated)
        monkeypatch.setattr("pulse.brain.monitoring.pattern_evolution.event_bus", MagicMock())
        self.mod = mod

    # ------------------------------------------------------------------
    # log_semantic_miss
    # ------------------------------------------------------------------
    def test_log_semantic_miss_creates_file(self):
        self.mod.log_semantic_miss("Always restart after deploy", "devops")
        assert self.misses_log.exists()
        lines = self.misses_log.read_text(encoding="utf-8").strip().split("\n")
        assert len(lines) == 1
        entry = json.loads(lines[0])
        assert entry["text"] == "Always restart after deploy"
        assert entry["domain"] == "devops"

    def test_log_semantic_miss_appends(self):
        self.mod.log_semantic_miss("text one", "general")
        self.mod.log_semantic_miss("text two", "testing")
        lines = self.misses_log.read_text(encoding="utf-8").strip().split("\n")
        assert len(lines) == 2

    def test_log_semantic_miss_truncates_long_text(self):
        long_text = "A" * 300
        self.mod.log_semantic_miss(long_text)
        entry = json.loads(self.misses_log.read_text(encoding="utf-8").strip())
        assert len(entry["text"]) <= 200

    # ------------------------------------------------------------------
    # evolve_patterns
    # ------------------------------------------------------------------
    def test_evolve_no_misses_file(self):
        result = self.mod.evolve_patterns()
        assert result["status"] == "no_misses"

    def test_evolve_insufficient_misses(self):
        self.mod.log_semantic_miss("only one")
        self.mod.log_semantic_miss("only two")
        result = self.mod.evolve_patterns()
        assert result["status"] == "insufficient_misses"
        assert result["count"] == 2

    def test_evolve_generates_pattern_from_3_similar_misses(self):
        # Three misses with identical first 3 tokens -> same cluster
        self.mod.log_semantic_miss("always restart after a deploy", "devops")
        self.mod.log_semantic_miss("always restart after a failure", "devops")
        self.mod.log_semantic_miss("always restart after a crash", "devops")
        result = self.mod.evolve_patterns()
        assert result["new_patterns"] >= 1
        # Generated file written
        assert self.generated.exists()
        data = json.loads(self.generated.read_text(encoding="utf-8"))
        assert len(data["patterns"]) >= 1
        pattern = data["patterns"][0]
        assert pattern["active"] is True
        assert pattern["source_count"] == 3

    def test_evolve_cluster_key_dedup(self):
        """Same cluster key is not re-generated on subsequent runs."""
        for _ in range(3):
            self.mod.log_semantic_miss("always check the logs carefully")
        self.mod.evolve_patterns()  # First run
        result = self.mod.evolve_patterns()  # Second run — same misses
        assert result["new_patterns"] == 0  # No new patterns

    def test_evolve_mixed_clusters(self):
        """Multiple clusters: one with 3+ items, one with <3 items."""
        # Cluster 1: 3 items (qualifies)
        self.mod.log_semantic_miss("deploy requires careful staging first")
        self.mod.log_semantic_miss("deploy requires careful testing first")
        self.mod.log_semantic_miss("deploy requires careful review first")
        # Cluster 2: 2 items (does not qualify)
        self.mod.log_semantic_miss("never push to main directly")
        self.mod.log_semantic_miss("never push to main without review")
        result = self.mod.evolve_patterns()
        assert result["new_patterns"] >= 1
        assert result["clusters_found"] >= 2

    def test_evolve_event_published(self):
        for _ in range(3):
            self.mod.log_semantic_miss("monitor server load carefully always")
        self.mod.evolve_patterns()
        self.mod.event_bus.publish.assert_called_with(
            "pattern.evolved", "pattern_evolution", ANY,
        )

    def test_evolve_no_event_when_no_new_patterns(self):
        # 3 misses in different clusters (1 each) -> no patterns generated
        self.mod.log_semantic_miss("alpha bravo charlie")
        self.mod.log_semantic_miss("delta echo foxtrot")
        self.mod.log_semantic_miss("golf hotel india")
        result = self.mod.evolve_patterns()
        assert result["new_patterns"] == 0
        self.mod.event_bus.publish.assert_not_called()

    # ------------------------------------------------------------------
    # _generate_regex
    # ------------------------------------------------------------------
    def test_generate_regex_empty(self):
        from pulse.brain.monitoring.pattern_evolution import _generate_regex
        assert _generate_regex([]) == ""

    def test_generate_regex_no_common_tokens(self):
        from pulse.brain.monitoring.pattern_evolution import _generate_regex
        # Completely disjoint texts
        result = _generate_regex(["alpha", "bravo", "charlie"])
        assert result == ""

    def test_generate_regex_common_tokens_produce_pattern(self):
        from pulse.brain.monitoring.pattern_evolution import _generate_regex
        texts = [
            "always restart the server",
            "always restart the database",
            "always restart the service",
        ]
        result = _generate_regex(texts)
        assert result != ""
        # The generated regex should match most of the source texts
        hits = sum(1 for t in texts if re.search(result, t, re.IGNORECASE))
        assert hits / len(texts) >= 0.6

    def test_generate_regex_validation_threshold_60(self):
        from pulse.brain.monitoring.pattern_evolution import _generate_regex
        # First text has a unique token that makes the pattern only match 1/3 -> fail
        texts = [
            "always restart the XYZZY blorp glop",
            "always other tokens here blorp glop",
            "always different stuff blorp glop",
        ]
        # Common tokens: "always", "blorp", "glop" — but position in first text
        # determines the regex shape, which may not match others
        result = _generate_regex(texts)
        # Either empty (failed validation) or matches >= 60%
        if result:
            hits = sum(1 for t in texts if re.search(result, t, re.IGNORECASE))
            assert hits / len(texts) >= 0.6

    # ------------------------------------------------------------------
    # _load_generated / _save_generated
    # ------------------------------------------------------------------
    def test_load_generated_no_file(self):
        result = self.mod._load_generated()
        assert result == {"patterns": []}

    def test_load_generated_valid(self):
        self.generated.parent.mkdir(parents=True, exist_ok=True)
        self.generated.write_text(json.dumps({"patterns": [{"id": "gen_1"}]}), encoding="utf-8")
        result = self.mod._load_generated()
        assert len(result["patterns"]) == 1

    def test_load_generated_corrupt_json(self):
        self.generated.parent.mkdir(parents=True, exist_ok=True)
        self.generated.write_text("{bad", encoding="utf-8")
        result = self.mod._load_generated()
        assert result == {"patterns": []}

    def test_save_generated_creates_dirs(self, tmp_path):
        deep_path = tmp_path / "deep" / "nested" / "patterns.json"
        self.mod.GENERATED_PATTERNS = deep_path
        self.mod._save_generated({"patterns": [{"id": "test"}]})
        assert deep_path.exists()
        data = json.loads(deep_path.read_text(encoding="utf-8"))
        assert data["patterns"][0]["id"] == "test"


# =========================================================================
# Cross-module integration sanity
# =========================================================================

class TestCrossModuleEdgeCases:
    """Edge-case and integration tests that span module boundaries."""

    def test_replay_verbose_prints(self, monkeypatch, tmp_path, capsys):
        """Verbose flag produces terminal output."""
        from pulse.daemons.synthesis import associative_replay as mod

        conn = _make_inmemory_db()
        now = datetime.now(timezone.utc)
        items = [
            {"id": f"v_{i}", "content": f"c{i}", "title": f"T{i}",
             "domain": "a" if i % 2 == 0 else "b", "salience": 5.0,
             "timestamp": (now - timedelta(hours=1, minutes=i)).isoformat()}
            for i in range(6)
        ]
        _insert_items(conn, "lessons", items)

        monkeypatch.setattr("pulse.daemons.synthesis.learning.associative_replay.get_conn", lambda: conn)
        monkeypatch.setattr(mod, "REPLAY_DIR", tmp_path / "replay")
        monkeypatch.setattr("pulse.core.llm_router.dispatch", MagicMock(return_value="0.8"))
        monkeypatch.setattr("pulse.daemons.synthesis.learning.associative_replay.event_bus", MagicMock())

        mod.run_replay(verbose=True)
        captured = capsys.readouterr()
        assert "[REPLAY]" in captured.out

    def test_culture_verbose_prints(self, monkeypatch, tmp_path, capsys):
        from pulse.daemons.consolidation import culture_synth as mod

        conn = _make_inmemory_db()
        items = [
            {"id": f"cv_{i}", "content": f"testing content {i}",
             "title": f"T{i}", "domain": "general", "salience": 3.0, "confidence": 0.8}
            for i in range(12)
        ]
        _insert_items(conn, "lessons", items)

        monkeypatch.setattr("pulse.daemons.consolidation.culture_synth.get_conn", lambda: conn)
        monkeypatch.setattr("pulse.core.llm_router.dispatch", MagicMock(side_effect=RuntimeError))
        monkeypatch.setattr(mod, "CULTURE_DIR", tmp_path / "culture")
        monkeypatch.setattr(mod, "PRINCIPLES_FILE", tmp_path / "culture" / "principles.json")
        monkeypatch.setattr("pulse.daemons.consolidation.culture_synth.event_bus", MagicMock())

        mod.run_culture_synthesis(verbose=True)
        captured = capsys.readouterr()
        assert "[CULTURE]" in captured.out

    def test_pattern_evolve_verbose_prints(self, monkeypatch, tmp_path, capsys):
        from pulse.brain.monitoring import pattern_evolution as mod

        misses_log = tmp_path / "misses.jsonl"
        gen_file = tmp_path / "gen.json"
        monkeypatch.setattr(mod, "MISSES_LOG", misses_log)
        monkeypatch.setattr(mod, "GENERATED_PATTERNS", gen_file)
        monkeypatch.setattr("pulse.brain.monitoring.pattern_evolution.event_bus", MagicMock())

        for _ in range(3):
            mod.log_semantic_miss("always restart the server carefully")
        mod.evolve_patterns(verbose=True)
        captured = capsys.readouterr()
        assert "[EVOLVE]" in captured.out

    def test_decision_full_lifecycle(self, monkeypatch, tmp_path):
        """Open -> submit positions -> synthesize -> verify closed."""
        from pulse.comms import collective_decision as mod

        monkeypatch.setattr(mod, "DECISIONS_FILE", tmp_path / "decisions.json")
        monkeypatch.setattr("pulse.comms.collective_decision.event_bus", MagicMock())

        did = mod.open_decision("Should we refactor?", "Big refactor proposal", "architecture")
        assert mod.submit_position(did, "claude", "approve", "Code is messy", 0.95)
        assert mod.submit_position(did, "gemini", "approve", "I agree", 0.85)
        assert mod.submit_position(did, "codex", "reject", "Too risky", 0.3)

        result = mod.synthesize(did)
        # approve weight = 0.95 + 0.85 = 1.8, reject = 0.3, total = 2.1
        # approve fraction = 1.8/2.1 ≈ 0.857 > 0.7 -> synthesized
        assert result["status"] == "synthesized"
        assert result["consensus"] == "approve"

        # Decision is now closed
        assert len(mod.get_open_decisions()) == 0
