"""Tests for EmergentDetector — Phase 20 (AD-240)."""

from __future__ import annotations

import json
import time
from io import StringIO
from typing import Any
from unittest.mock import MagicMock

import pytest
from rich.console import Console

from probos.agents.introspect import IntrospectionAgent
from probos.cognitive.emergent_detector import (
    EmergentDetector,
    EmergentPattern,
    SystemDynamicsSnapshot,
)
from probos.cognitive.llm_client import MockLLMClient
from probos.consensus.trust import TrustNetwork
from probos.experience.panels import render_anomalies_panel
from probos.experience.shell import ProbOSShell
from probos.mesh.routing import HebbianRouter, REL_INTENT, REL_SOCIAL
from probos.runtime import ProbOSRuntime
from probos.types import DreamReport, IntentMessage, LLMRequest


# ---------------------------------------------------------------------------
# Stubs / helpers
# ---------------------------------------------------------------------------


class _FakeTrustNetwork:
    """Minimal trust network stub."""

    def __init__(self, records: dict[str, dict] | None = None) -> None:
        self._data = records or {}

    def raw_scores(self) -> dict[str, dict[str, float]]:
        return dict(self._data)

    def all_scores(self) -> dict[str, float]:
        return {
            aid: r["alpha"] / (r["alpha"] + r["beta"])
            for aid, r in self._data.items()
        }

    def get_score(self, agent_id: str) -> float:
        r = self._data.get(agent_id)
        if r is None:
            return 0.5
        return r["alpha"] / (r["alpha"] + r["beta"])

    def get_events_for_agent(self, agent_id: str, n: int = 20) -> list:
        return []


class _FakeRouter:
    """Minimal Hebbian router stub."""

    def __init__(self, weights: dict[tuple, float] | None = None) -> None:
        self._weights = weights or {}

    def all_weights_typed(self) -> dict[tuple, float]:
        return dict(self._weights)

    @property
    def weight_count(self) -> int:
        return len(self._weights)

    def get_weight(self, source: str, target: str, rel_type: str | None = None) -> float:
        return self._weights.get((source, target), 0.0)


def _make_detector(
    weights: dict | None = None,
    trust_records: dict | None = None,
    episodic_memory: Any = None,
    max_history: int = 100,
    **kwargs: Any,
) -> EmergentDetector:
    """Create an EmergentDetector with fake dependencies.

    Defaults trust_anomaly_min_count=1 so legacy tests that call
    detect_trust_anomalies() once still get results. New BF-089 tests
    set this explicitly to exercise the temporal buffer.
    """
    router = _FakeRouter(weights)
    trust = _FakeTrustNetwork(trust_records)
    kwargs.setdefault("trust_anomaly_min_count", 1)
    detector = EmergentDetector(
        hebbian_router=router,  # type: ignore[arg-type]
        trust_network=trust,  # type: ignore[arg-type]
        episodic_memory=episodic_memory,
        max_history=max_history,
        **kwargs,
    )
    # BF-165: Pre-existing tests assume active state — record activity
    # so the activity gate doesn't suppress cluster detection.
    detector.record_activity()
    return detector


# ===========================================================================
# Dataclass tests
# ===========================================================================


class TestEmergentPatternDataclass:
    def test_fields_roundtrip(self) -> None:
        p = EmergentPattern(
            pattern_type="trust_anomaly",
            description="Agent X high trust",
            confidence=0.85,
            evidence={"agent": "X"},
            timestamp=1000.0,
            severity="notable",
        )
        assert p.pattern_type == "trust_anomaly"
        assert p.confidence == 0.85
        assert p.severity == "notable"
        assert p.evidence == {"agent": "X"}

    def test_severity_values(self) -> None:
        for sev in ("info", "notable", "significant"):
            p = EmergentPattern(
                pattern_type="test",
                description="test",
                confidence=0.5,
                severity=sev,
            )
            assert p.severity == sev


class TestSystemDynamicsSnapshotDataclass:
    def test_fields_roundtrip(self) -> None:
        s = SystemDynamicsSnapshot(
            timestamp=123.0,
            tc_n=0.5,
            cooperation_clusters=[{"size": 3}],
            trust_distribution={"mean": 0.5},
            routing_entropy=1.2,
            capability_count=5,
            dream_consolidation_rate=10.0,
        )
        assert s.tc_n == 0.5
        assert s.routing_entropy == 1.2
        assert s.capability_count == 5
        assert len(s.cooperation_clusters) == 1


# ===========================================================================
# TC_N computation
# ===========================================================================


class TestComputeTcN:
    def test_no_episodic_memory_returns_zero(self) -> None:
        d = _make_detector()
        assert d.compute_tc_n() == 0.0

    def test_no_weights_returns_zero(self) -> None:
        d = _make_detector(episodic_memory=object())
        assert d.compute_tc_n() == 0.0

    def test_single_pool_dags_return_zero(self) -> None:
        """All intents route to same pool → tc_n = 0."""
        weights = {
            ("read_file", "file_reader_filesystem_0_abc", REL_INTENT): 0.5,
            ("stat_file", "file_reader_filesystem_1_def", REL_INTENT): 0.3,
        }
        d = _make_detector(weights=weights, episodic_memory=object())
        tc = d.compute_tc_n()
        assert tc == 0.0

    def test_multi_pool_dags_return_one(self) -> None:
        """Each intent routes to a different pool → tc_n = 1.0."""
        weights = {
            ("read_file", "file_reader_filesystem_0_abc", REL_INTENT): 0.5,
            ("read_file", "shell_command_shell_0_xyz", REL_INTENT): 0.4,
            ("run_cmd", "runner_commands_0_qqq", REL_INTENT): 0.3,
            ("run_cmd", "writer_output_0_www", REL_INTENT): 0.2,
        }
        d = _make_detector(weights=weights, episodic_memory=object())
        tc = d.compute_tc_n()
        assert tc == 1.0

    def test_mixed_single_multi_returns_fraction(self) -> None:
        """One single-pool intent, one multi-pool intent → tc_n = 0.5."""
        weights = {
            # read_file → single pool (filesystem)
            ("read_file", "file_reader_filesystem_0_abc", REL_INTENT): 0.5,
            # run_command → two pools
            ("run_command", "shell_cmd_shell_0_xyz", REL_INTENT): 0.4,
            ("run_command", "alt_cmd_alt_pool_1_qqq", REL_INTENT): 0.3,
        }
        d = _make_detector(weights=weights, episodic_memory=object())
        tc = d.compute_tc_n()
        assert 0.0 < tc < 1.0

    def test_pool_extraction_from_agent_id(self) -> None:
        pool = EmergentDetector._extract_pool("file_reader_filesystem_0_abc123")
        assert pool != ""

    def test_malformed_agent_id_graceful(self) -> None:
        pool = EmergentDetector._extract_pool("x")
        assert pool == ""

    def test_social_cross_pool_contributes_to_tc_n(self) -> None:
        """REL_SOCIAL weights between agents in different pools increase TC_N."""
        weights = {
            # Social: agent in pool_a talks to agent in pool_b → cross-pool
            ("agent_a_pool_a_0_abc", "agent_b_pool_b_0_def", REL_SOCIAL): 0.5,
        }
        d = _make_detector(weights=weights, episodic_memory=object())
        tc = d.compute_tc_n()
        assert tc > 0.0

    def test_social_same_pool_does_not_contribute(self) -> None:
        """REL_SOCIAL weights between agents in the same pool → TC_N = 0."""
        weights = {
            # Both agents share pool prefix "mypool" (fallback: parts[:-2])
            ("mypool_0_abc", "mypool_1_def", REL_SOCIAL): 0.5,
        }
        d = _make_detector(weights=weights, episodic_memory=object())
        tc = d.compute_tc_n()
        assert tc == 0.0

    def test_social_and_intent_combined(self) -> None:
        """Both REL_INTENT and REL_SOCIAL signals combine in TC_N."""
        weights = {
            # Intent: single pool → no multi-pool (contributes 0/1)
            ("read_file", "reader_filesystem_0_abc", REL_INTENT): 0.5,
            # Social: cross-pool → contributes 1/1
            ("agent_a_eng_0_abc", "agent_b_sci_0_def", REL_SOCIAL): 0.5,
        }
        d = _make_detector(weights=weights, episodic_memory=object())
        tc = d.compute_tc_n()
        # 1 intent (single-pool) + 1 social (cross-pool) → 1/2 = 0.5
        assert tc == 0.5


# ===========================================================================
# Cooperation cluster detection
# ===========================================================================


class TestCooperationClusters:
    def test_empty_weights_no_clusters(self) -> None:
        d = _make_detector()
        assert d.detect_cooperation_clusters() == []

    def test_single_strong_connection_one_cluster(self) -> None:
        weights = {
            ("read_file", "agent_pool_0_abc", REL_INTENT): 0.5,
        }
        # BF-124: Use min_size=1 to preserve legacy test (default is now 3)
        d = _make_detector(weights=weights, cluster_min_size=1, cluster_edge_threshold=0.1)
        clusters = d.detect_cooperation_clusters()
        assert len(clusters) == 1
        assert clusters[0]["size"] == 2

    def test_two_disconnected_groups(self) -> None:
        weights = {
            ("read_file", "reader_pool_0_abc", REL_INTENT): 0.5,
            ("write_file", "writer_pool_0_def", REL_INTENT): 0.4,
        }
        # BF-124: Use min_size=1 to preserve legacy test
        d = _make_detector(weights=weights, cluster_min_size=1, cluster_edge_threshold=0.1)
        clusters = d.detect_cooperation_clusters()
        assert len(clusters) == 2

    def test_weights_below_threshold_filtered(self) -> None:
        weights = {
            ("read_file", "agent_pool_0_abc", REL_INTENT): 0.05,  # below threshold
        }
        d = _make_detector(weights=weights)
        clusters = d.detect_cooperation_clusters()
        assert clusters == []

    def test_cluster_contains_expected_members(self) -> None:
        weights = {
            ("intent_a", "agent_pool_0_abc", REL_INTENT): 0.5,
            ("intent_a", "agent_pool_1_def", REL_INTENT): 0.3,
        }
        # BF-124: Use min_size=1 and edge_threshold=0.1 to preserve legacy test
        d = _make_detector(weights=weights, cluster_min_size=1, cluster_edge_threshold=0.1)
        clusters = d.detect_cooperation_clusters()
        assert len(clusters) == 1
        assert clusters[0]["size"] == 3  # intent_a + 2 agents


# ===========================================================================
# Trust anomaly detection
# ===========================================================================


class TestTrustAnomalies:
    def test_all_similar_no_anomalies(self) -> None:
        records = {
            f"agent_{i}": {"alpha": 2.0, "beta": 2.0, "observations": 0.0}
            for i in range(5)
        }
        d = _make_detector(trust_records=records)
        patterns = d.detect_trust_anomalies()
        assert len(patterns) == 0

    def test_one_very_low_trust(self) -> None:
        records = {
            "agent_0": {"alpha": 2.0, "beta": 2.0, "observations": 0.0},
            "agent_1": {"alpha": 2.0, "beta": 2.0, "observations": 0.0},
            "agent_2": {"alpha": 2.0, "beta": 2.0, "observations": 0.0},
            "agent_3": {"alpha": 2.0, "beta": 2.0, "observations": 0.0},
            "agent_4": {"alpha": 2.0, "beta": 2.0, "observations": 0.0},
            "agent_5": {"alpha": 2.0, "beta": 2.0, "observations": 0.0},
            "agent_6": {"alpha": 2.0, "beta": 2.0, "observations": 0.0},
            "outlier": {"alpha": 0.5, "beta": 20.0, "observations": 19.0},
        }
        d = _make_detector(trust_records=records)
        patterns = d.detect_trust_anomalies()
        trust_patterns = [p for p in patterns if "outlier" in p.description]
        assert len(trust_patterns) >= 1
        assert trust_patterns[0].evidence["direction"] == "low"

    def test_one_very_high_trust(self) -> None:
        records = {
            "agent_0": {"alpha": 2.0, "beta": 2.0, "observations": 0.0},
            "agent_1": {"alpha": 2.0, "beta": 2.0, "observations": 0.0},
            "agent_2": {"alpha": 2.0, "beta": 2.0, "observations": 0.0},
            "agent_3": {"alpha": 2.0, "beta": 2.0, "observations": 0.0},
            "agent_4": {"alpha": 2.0, "beta": 2.0, "observations": 0.0},
            "agent_5": {"alpha": 2.0, "beta": 2.0, "observations": 0.0},
            "agent_6": {"alpha": 2.0, "beta": 2.0, "observations": 0.0},
            "star": {"alpha": 20.0, "beta": 0.5, "observations": 19.0},
        }
        d = _make_detector(trust_records=records)
        patterns = d.detect_trust_anomalies()
        trust_patterns = [p for p in patterns if "star" in p.description]
        assert len(trust_patterns) >= 1
        assert trust_patterns[0].evidence["direction"] == "high"

    def test_change_point_detection(self) -> None:
        records = {
            "agent_0": {"alpha": 5.0, "beta": 2.0, "observations": 3.0},
            "agent_1": {"alpha": 2.0, "beta": 2.0, "observations": 0.0},
        }
        d = _make_detector(trust_records=records)
        # Create a previous snapshot with different trust for agent_0
        prev_snapshot = SystemDynamicsSnapshot(
            trust_distribution={
                "mean": 0.5,
                "std": 0.1,
                "per_agent": {
                    "agent_0": 0.4,  # was 0.4, now ~0.71 → delta > 0.15
                    "agent_1": 0.5,
                },
            },
        )
        d._history.append(prev_snapshot)
        patterns = d.detect_trust_anomalies()
        change_points = [p for p in patterns if "change point" in p.description]
        assert len(change_points) >= 1

    def test_hyperactive_agent(self) -> None:
        records = {
            "agent_0": {"alpha": 2.0, "beta": 2.0, "observations": 1.0},
            "agent_1": {"alpha": 2.0, "beta": 2.0, "observations": 1.0},
            "agent_2": {"alpha": 2.0, "beta": 2.0, "observations": 1.0},
            "agent_3": {"alpha": 2.0, "beta": 2.0, "observations": 1.0},
            "agent_4": {"alpha": 2.0, "beta": 2.0, "observations": 1.0},
            "agent_5": {"alpha": 2.0, "beta": 2.0, "observations": 1.0},
            "agent_6": {"alpha": 2.0, "beta": 2.0, "observations": 1.0},
            "hyperact": {"alpha": 2.0, "beta": 2.0, "observations": 500.0},
        }
        d = _make_detector(trust_records=records)
        patterns = d.detect_trust_anomalies()
        hyper = [p for p in patterns if "hyperact" in p.description]
        assert len(hyper) >= 1

    def test_single_agent_no_anomaly(self) -> None:
        records = {"only_one": {"alpha": 2.0, "beta": 2.0, "observations": 0.0}}
        d = _make_detector(trust_records=records)
        patterns = d.detect_trust_anomalies()
        assert len(patterns) == 0


# ===========================================================================
# Routing shift detection
# ===========================================================================


class TestRoutingShifts:
    def test_no_previous_snapshot_no_shifts(self) -> None:
        weights = {("read_file", "agent_pool_0_abc", REL_INTENT): 0.5}
        d = _make_detector(weights=weights)
        patterns = d.detect_routing_shifts()
        assert len(patterns) == 0

    def test_new_connection_detected(self) -> None:
        weights = {
            ("read_file", "agent_pool_0_abc", REL_INTENT): 0.5,
            ("write_file", "writer_pool_0_def", REL_INTENT): 0.3,
        }
        d = _make_detector(weights=weights)
        # Set previous map with only read_file
        d._prev_intent_agent_map = {"read_file": {"agent_pool_0_abc"}}
        patterns = d.detect_routing_shifts()
        # write_file + writer should be detected as new
        assert len(patterns) >= 1

    def test_stable_routing_no_shifts(self) -> None:
        weights = {("read_file", "agent_pool_0_abc", REL_INTENT): 0.5}
        d = _make_detector(weights=weights)
        d._prev_intent_agent_map = {"read_file": {"agent_pool_0_abc"}}
        patterns = d.detect_routing_shifts()
        assert len(patterns) == 0

    def test_entropy_uniform_high(self) -> None:
        """Uniform weight distribution → high entropy."""
        weights = {
            ("i1", "agent_poolA_0_abc", REL_INTENT): 0.5,
            ("i2", "agent_poolB_0_def", REL_INTENT): 0.5,
            ("i3", "agent_poolC_0_ghi", REL_INTENT): 0.5,
        }
        d = _make_detector(weights=weights)
        entropy = d.compute_routing_entropy()
        # With 3 equal pools, entropy should be log2(3) ≈ 1.585
        assert entropy > 1.0

    def test_entropy_concentrated_low(self) -> None:
        """All weight on one pool → entropy = 0."""
        weights = {
            ("i1", "agent_pool_0_abc", REL_INTENT): 0.5,
            ("i2", "agent_pool_1_def", REL_INTENT): 0.5,
        }
        d = _make_detector(weights=weights)
        entropy = d.compute_routing_entropy()
        # Both agents in same pool → entropy = 0
        assert entropy == 0.0

    def test_entropy_includes_social_weights(self) -> None:
        """Parallel to BF-176: REL_SOCIAL cross-pool collaboration must
        contribute to routing entropy. Without this, an active crew that
        is primarily DMing/Ward-Roomming (REL_SOCIAL) but rarely receiving
        intent-bus broadcasts (REL_INTENT) shows entropy=0 even though
        cross-pool routing is healthy.
        """
        weights = {
            # No REL_INTENT activity — pure social crew.
            ("agentA", "agent_poolA_0_aaa", REL_SOCIAL): 0.5,
            ("agentB", "agent_poolB_0_bbb", REL_SOCIAL): 0.5,
            ("agentC", "agent_poolC_0_ccc", REL_SOCIAL): 0.5,
        }
        d = _make_detector(weights=weights)
        entropy = d.compute_routing_entropy()
        # 3 distinct target pools with equal social weight → entropy > 1.0
        assert entropy > 1.0

    def test_entropy_combines_intent_and_social(self) -> None:
        """Intent + social weights aggregate into the same pool distribution."""
        weights = {
            ("i1", "agent_poolA_0_aaa", REL_INTENT): 0.3,
            ("agentX", "agent_poolB_0_bbb", REL_SOCIAL): 0.3,
        }
        d = _make_detector(weights=weights)
        entropy = d.compute_routing_entropy()
        # Two equally-weighted pools → entropy = 1.0
        assert entropy == pytest.approx(1.0)


# ===========================================================================
# Consolidation anomaly detection
# ===========================================================================


class TestConsolidationAnomalies:
    def test_no_dream_report_no_anomalies(self) -> None:
        d = _make_detector()
        patterns = d.detect_consolidation_anomalies(None)
        assert patterns == []

    def test_normal_dream_report_no_anomalies(self) -> None:
        # BF-166: With < 5 reports, the min history gate returns early.
        # This test still passes (no anomaly) — but now because of the gate,
        # not because the values are normal.
        d = _make_detector()
        report1 = DreamReport(weights_strengthened=5, weights_pruned=3)
        d.detect_consolidation_anomalies(report1)
        report2 = DreamReport(weights_strengthened=5, weights_pruned=3)
        patterns = d.detect_consolidation_anomalies(report2)
        assert len(patterns) == 0

    def test_high_strengthened_anomaly(self) -> None:
        d = _make_detector()
        # Build baseline — BF-166: need 5 reports for meaningful baseline
        for _ in range(5):
            d.detect_consolidation_anomalies(
                DreamReport(weights_strengthened=5, weights_pruned=3)
            )
        # Now a spike: > 2x average
        patterns = d.detect_consolidation_anomalies(
            DreamReport(weights_strengthened=50, weights_pruned=3)
        )
        strengthened = [p for p in patterns if "strengthening" in p.description]
        assert len(strengthened) == 1

    def test_high_pruned_anomaly(self) -> None:
        d = _make_detector()
        # BF-166: need 5 reports for meaningful baseline
        for _ in range(5):
            d.detect_consolidation_anomalies(
                DreamReport(weights_strengthened=5, weights_pruned=3)
            )
        patterns = d.detect_consolidation_anomalies(
            DreamReport(weights_strengthened=5, weights_pruned=30)
        )
        pruned = [p for p in patterns if "pruning" in p.description]
        assert len(pruned) == 1

    def test_prewarm_intents_not_yet_checked(self) -> None:
        """Pre-warm intents are stored but don't cause anomalies without handler check."""
        d = _make_detector()
        d.detect_consolidation_anomalies(
            DreamReport(weights_strengthened=1, weights_pruned=1, pre_warm_intents=["foo"])
        )
        patterns = d.detect_consolidation_anomalies(
            DreamReport(weights_strengthened=1, weights_pruned=1, pre_warm_intents=["bar"])
        )
        # No anomaly — just baseline
        assert len(patterns) == 0

    def test_bf175_trust_adj_below_floor_no_anomaly(self) -> None:
        """BF-175: Trust adjustments >2x average but below absolute floor are NOT anomalies."""
        d = _make_detector()
        # Build baseline with avg trust_adjustments = 4
        for _ in range(5):
            d.detect_consolidation_anomalies(
                DreamReport(weights_strengthened=5, weights_pruned=3, trust_adjustments=4)
            )
        # Spike to 9 — >2x average (4) but below floor (10)
        patterns = d.detect_consolidation_anomalies(
            DreamReport(weights_strengthened=5, weights_pruned=3, trust_adjustments=9)
        )
        trust = [p for p in patterns if "trust" in p.description]
        assert len(trust) == 0

    def test_bf175_trust_adj_above_floor_fires(self) -> None:
        """BF-175: Trust adjustments >2x average AND above absolute floor ARE anomalies."""
        d = _make_detector()
        for _ in range(5):
            d.detect_consolidation_anomalies(
                DreamReport(weights_strengthened=5, weights_pruned=3, trust_adjustments=4)
            )
        # Spike to 12 — >2x average (4) AND above floor (10)
        patterns = d.detect_consolidation_anomalies(
            DreamReport(weights_strengthened=5, weights_pruned=3, trust_adjustments=12)
        )
        trust = [p for p in patterns if "trust" in p.description]
        assert len(trust) == 1

    def test_bf175_pruned_below_floor_no_anomaly(self) -> None:
        """BF-175: Pruned >2x average but below absolute floor (5) suppressed."""
        d = _make_detector()
        for _ in range(5):
            d.detect_consolidation_anomalies(
                DreamReport(weights_strengthened=5, weights_pruned=1)
            )
        # Spike to 3 — >2x average (1) but below floor (5)
        patterns = d.detect_consolidation_anomalies(
            DreamReport(weights_strengthened=5, weights_pruned=3)
        )
        pruned = [p for p in patterns if "pruning" in p.description]
        assert len(pruned) == 0

    def test_bf175_strengthened_below_floor_no_anomaly(self) -> None:
        """BF-175: Strengthened >2x average but below absolute floor (10) suppressed."""
        d = _make_detector()
        for _ in range(5):
            d.detect_consolidation_anomalies(
                DreamReport(weights_strengthened=3, weights_pruned=1)
            )
        # Spike to 8 — >2x average (3) but below floor (10)
        patterns = d.detect_consolidation_anomalies(
            DreamReport(weights_strengthened=8, weights_pruned=1)
        )
        strengthened = [p for p in patterns if "strengthening" in p.description]
        assert len(strengthened) == 0


# ===========================================================================
# analyze() integration
# ===========================================================================


class TestAnalyzeIntegration:
    def test_returns_list_of_patterns(self) -> None:
        d = _make_detector()
        result = d.analyze()
        assert isinstance(result, list)

    def test_stores_snapshot_in_history(self) -> None:
        d = _make_detector()
        assert len(d._history) == 0
        d.analyze()
        assert len(d._history) == 1

    def test_history_respects_max_history(self) -> None:
        d = _make_detector(max_history=3)
        for _ in range(5):
            d.analyze()
        assert len(d._history) == 3

    def test_multiple_analyze_builds_trend(self) -> None:
        d = _make_detector()
        d.analyze()
        d.analyze()
        d.analyze()
        assert len(d._history) == 3


# ===========================================================================
# summary() and get_snapshot()
# ===========================================================================


class TestSummaryAndSnapshot:
    def test_summary_json_serializable(self) -> None:
        d = _make_detector()
        d.analyze()
        s = d.summary()
        # Should not raise
        json.dumps(s)
        assert "tc_n" in s
        assert "routing_entropy" in s
        assert "patterns_detected" in s

    def test_get_snapshot_returns_dynamics(self) -> None:
        d = _make_detector()
        snap = d.get_snapshot()
        assert isinstance(snap, SystemDynamicsSnapshot)
        assert snap.timestamp > 0

    def test_summary_latest_patterns_capped(self) -> None:
        d = _make_detector()
        # Manually add 10 patterns
        for i in range(10):
            d._all_patterns.append(EmergentPattern(
                pattern_type="test",
                description=f"pattern {i}",
                confidence=0.5,
            ))
        s = d.summary()
        assert len(s["latest_patterns"]) == 5


# ===========================================================================
# Runtime integration
# ===========================================================================


class TestRuntimeIntegration:
    @pytest.fixture
    def runtime(self, tmp_path):
        from probos.runtime import ProbOSRuntime
        from probos.config import load_config

        config_path = tmp_path / "system.yaml"
        config_path.write_text(
            "system:\n  name: ProbOS\n  version: 0.1.0\n"
            "pools:\n  default_size: 2\n  min_size: 1\n  max_size: 5\n"
            "mesh:\n  hebbian_decay_rate: 0.99\n  hebbian_reward: 0.05\n"
            "  gossip_interval_ms: 5000\n  signal_ttl_seconds: 30\n"
            "  semantic_matching: false\n"
            "consensus:\n  min_votes: 2\n  approval_threshold: 0.6\n"
            "  use_confidence_weights: true\n  trust_prior_alpha: 2.0\n"
            "  trust_prior_beta: 2.0\n  trust_decay_rate: 0.999\n"
            "  red_team_pool_size: 2\n"
            "cognitive:\n  llm_base_url: 'http://localhost:8080/v1'\n"
            "  llm_api_key: ''\n  llm_model_fast: 'mock'\n"
            "  llm_model_standard: 'mock'\n  llm_model_deep: 'mock'\n"
            "  llm_timeout_seconds: 5\n  working_memory_token_budget: 2000\n"
            "  decomposition_timeout_seconds: 5\n  dag_execution_timeout_seconds: 10\n"
            "  max_concurrent_tasks: 5\n  attention_decay_rate: 0.95\n"
            "  focus_history_size: 5\n  background_demotion_factor: 0.5\n"
            "scaling:\n  enabled: false\n"
            "federation:\n  enabled: false\n"
            "self_mod:\n  enabled: false\n"
            "qa:\n  enabled: false\n"
            "knowledge:\n  enabled: false\n"
            "dreaming:\n  idle_threshold_seconds: 300\n  dream_interval_seconds: 600\n"
            "  replay_episode_count: 10\n  pathway_strengthening_factor: 0.02\n"
            "  pathway_weakening_factor: 0.01\n  prune_threshold: 0.005\n"
            "  trust_boost: 0.1\n  trust_penalty: 0.05\n  pre_warm_top_k: 5\n"
        )
        config = load_config(str(config_path))
        rt = ProbOSRuntime(config=config, data_dir=str(tmp_path / "data"))
        return rt

    @pytest.mark.asyncio
    async def test_runtime_creates_detector(self, runtime, tmp_path) -> None:
        await runtime.start()
        try:
            assert runtime._emergent_detector is not None
            assert isinstance(runtime._emergent_detector, EmergentDetector)
        finally:
            await runtime.stop()

    @pytest.mark.asyncio
    async def test_status_includes_emergent(self, runtime, tmp_path) -> None:
        await runtime.start()
        try:
            status = runtime.status()
            assert "emergent" in status
            assert "tc_n" in status["emergent"]
        finally:
            await runtime.stop()

    @pytest.mark.asyncio
    async def test_detector_without_episodic_tc_n_zero(self, runtime, tmp_path) -> None:
        await runtime.start()
        try:
            tc = runtime._emergent_detector.compute_tc_n()
            assert tc == 0.0  # No episodic memory
        finally:
            await runtime.stop()

    @pytest.mark.asyncio
    async def test_post_dream_analysis_wired(self, runtime, tmp_path) -> None:
        """If dream scheduler exists, post_dream_fn should be set."""
        from probos.cognitive.episodic_mock import MockEpisodicMemory

        runtime.episodic_memory = MockEpisodicMemory()
        await runtime.start()
        try:
            if runtime.dream_scheduler:
                assert runtime.dream_scheduler._post_dream_fn is not None
        finally:
            await runtime.stop()


# ===========================================================================
# Introspection integration
# ===========================================================================


class TestIntrospectionIntegration:
    def _mock_runtime_with_detector(self) -> MagicMock:
        rt = MagicMock(spec=ProbOSRuntime)
        router = _FakeRouter()
        trust = _FakeTrustNetwork({"a": {"alpha": 2, "beta": 2, "observations": 0}})
        detector = EmergentDetector(
            hebbian_router=router,  # type: ignore[arg-type]
            trust_network=trust,  # type: ignore[arg-type]
        )
        rt._emergent_detector = detector
        return rt

    @pytest.mark.asyncio
    async def test_system_anomalies_intent(self) -> None:
        agent = IntrospectionAgent(pool="introspect", agent_id="test_introspect_0")
        agent._runtime = self._mock_runtime_with_detector()

        intent = IntentMessage(intent="system_anomalies", params={})
        result = await agent.handle_intent(intent)
        assert result is not None
        assert result.success is True
        assert "anomaly_count" in result.result

    @pytest.mark.asyncio
    async def test_emergent_patterns_intent(self) -> None:
        agent = IntrospectionAgent(pool="introspect", agent_id="test_introspect_0")
        agent._runtime = self._mock_runtime_with_detector()

        intent = IntentMessage(intent="emergent_patterns", params={})
        result = await agent.handle_intent(intent)
        assert result is not None
        assert result.success is True
        assert "snapshot" in result.result
        assert "summary" in result.result

    @pytest.mark.asyncio
    async def test_mock_llm_routes_anomalies(self) -> None:
        client = MockLLMClient()
        request = LLMRequest(prompt="are there any anomalies in the system?")
        response = await client.complete(request)
        data = json.loads(response.content)
        intents = data.get("intents", [])
        assert len(intents) == 1
        assert intents[0]["intent"] == "system_anomalies"

    @pytest.mark.asyncio
    async def test_mock_llm_routes_emergent(self) -> None:
        client = MockLLMClient()
        request = LLMRequest(prompt="show emergent patterns")
        response = await client.complete(request)
        data = json.loads(response.content)
        intents = data.get("intents", [])
        assert len(intents) == 1
        assert intents[0]["intent"] == "emergent_patterns"


# ===========================================================================
# Shell and panel tests
# ===========================================================================


class TestShellAndPanel:
    @pytest.mark.asyncio
    async def test_anomalies_command(self, tmp_path) -> None:
        from probos.config import load_config
        from probos.runtime import ProbOSRuntime

        config_path = tmp_path / "system.yaml"
        config_path.write_text(
            "system:\n  name: ProbOS\n  version: 0.1.0\n"
            "pools:\n  default_size: 2\n  min_size: 1\n  max_size: 5\n"
            "mesh:\n  hebbian_decay_rate: 0.99\n  hebbian_reward: 0.05\n"
            "  gossip_interval_ms: 5000\n  signal_ttl_seconds: 30\n"
            "  semantic_matching: false\n"
            "consensus:\n  min_votes: 2\n  approval_threshold: 0.6\n"
            "  use_confidence_weights: true\n  trust_prior_alpha: 2.0\n"
            "  trust_prior_beta: 2.0\n  trust_decay_rate: 0.999\n"
            "  red_team_pool_size: 2\n"
            "cognitive:\n  llm_base_url: 'http://localhost:8080/v1'\n"
            "  llm_api_key: ''\n  llm_model_fast: 'mock'\n"
            "  llm_model_standard: 'mock'\n  llm_model_deep: 'mock'\n"
            "  llm_timeout_seconds: 5\n  working_memory_token_budget: 2000\n"
            "  decomposition_timeout_seconds: 5\n  dag_execution_timeout_seconds: 10\n"
            "  max_concurrent_tasks: 5\n  attention_decay_rate: 0.95\n"
            "  focus_history_size: 5\n  background_demotion_factor: 0.5\n"
            "scaling:\n  enabled: false\n"
            "federation:\n  enabled: false\n"
            "self_mod:\n  enabled: false\n"
            "qa:\n  enabled: false\n"
            "knowledge:\n  enabled: false\n"
            "dreaming:\n  idle_threshold_seconds: 300\n  dream_interval_seconds: 600\n"
            "  replay_episode_count: 10\n  pathway_strengthening_factor: 0.02\n"
            "  pathway_weakening_factor: 0.01\n  prune_threshold: 0.005\n"
            "  trust_boost: 0.1\n  trust_penalty: 0.05\n  pre_warm_top_k: 5\n"
        )
        config = load_config(str(config_path))
        rt = ProbOSRuntime(config=config, data_dir=str(tmp_path / "data"))
        await rt.start()
        try:
            buf = StringIO()
            console = Console(file=buf, width=120, force_terminal=True)
            shell = ProbOSShell(rt, console=console)
            await shell.execute_command("/anomalies")
            output = buf.getvalue()
            assert "Emergent Behavior" in output or "operating normally" in output
        finally:
            await rt.stop()

    def test_help_includes_anomalies(self) -> None:
        assert "/anomalies" in ProbOSShell.COMMANDS

    def test_render_panel_with_patterns(self) -> None:
        summary = {
            "tc_n": 0.3,
            "routing_entropy": 1.2,
            "cooperation_clusters": 2,
            "snapshots_recorded": 5,
            "patterns_detected": 3,
        }
        patterns = [
            {
                "pattern_type": "trust_anomaly",
                "description": "Agent X has low trust",
                "confidence": 0.9,
                "severity": "notable",
            },
        ]
        panel = render_anomalies_panel(summary, patterns)
        # Render to string to verify it works
        buf = StringIO()
        console = Console(file=buf, width=120, force_terminal=True)
        console.print(panel)
        output = buf.getvalue()
        assert "TC_N" in output
        assert "trust_anomaly" in output

    def test_render_panel_empty_normal(self) -> None:
        summary = {
            "tc_n": 0.0,
            "routing_entropy": 0.0,
            "cooperation_clusters": 0,
            "snapshots_recorded": 0,
            "patterns_detected": 0,
        }
        panel = render_anomalies_panel(summary, [])
        buf = StringIO()
        console = Console(file=buf, width=120, force_terminal=True)
        console.print(panel)
        output = buf.getvalue()
        assert "operating normally" in output

    def test_render_panel_severity_coloring(self) -> None:
        summary = {"tc_n": 0.0, "routing_entropy": 0.0, "cooperation_clusters": 0,
                    "snapshots_recorded": 0, "patterns_detected": 2}
        patterns = [
            {"pattern_type": "test", "description": "info", "confidence": 0.5, "severity": "info"},
            {"pattern_type": "test", "description": "sig", "confidence": 0.9, "severity": "significant"},
        ]
        panel = render_anomalies_panel(summary, patterns)
        buf = StringIO()
        console = Console(file=buf, width=120, force_terminal=True)
        console.print(panel)
        output = buf.getvalue()
        # Both rows should render without error
        assert "info" in output
        assert "sig" in output


class TestPatternDeduplication:
    """AD-411: EmergentDetector pattern deduplication."""

    def test_duplicate_suppressed_within_cooldown(self):
        """Same pattern within cooldown window is suppressed."""
        router = HebbianRouter()
        trust = TrustNetwork()
        detector = EmergentDetector(router, trust)
        detector.set_pattern_cooldown(60.0)

        # First call — not a duplicate
        assert not detector._is_duplicate_pattern("trust_anomaly", "agent1:high")
        # Second call — duplicate (within cooldown)
        assert detector._is_duplicate_pattern("trust_anomaly", "agent1:high")

    def test_different_keys_not_suppressed(self):
        """Different dedup keys are independent."""
        router = HebbianRouter()
        trust = TrustNetwork()
        detector = EmergentDetector(router, trust)
        detector.set_pattern_cooldown(60.0)

        assert not detector._is_duplicate_pattern("trust_anomaly", "agent1:high")
        assert not detector._is_duplicate_pattern("trust_anomaly", "agent2:high")

    def test_different_types_not_suppressed(self):
        """Same key with different pattern types are independent."""
        router = HebbianRouter()
        trust = TrustNetwork()
        detector = EmergentDetector(router, trust)
        detector.set_pattern_cooldown(60.0)

        assert not detector._is_duplicate_pattern("trust_anomaly", "agent1")
        assert not detector._is_duplicate_pattern("routing_shift", "agent1")

    def test_cooldown_expiry(self):
        """Pattern is allowed after cooldown expires."""
        router = HebbianRouter()
        trust = TrustNetwork()
        detector = EmergentDetector(router, trust)
        detector.set_pattern_cooldown(0.0)  # Zero cooldown = no suppression

        assert not detector._is_duplicate_pattern("trust_anomaly", "agent1:high")
        assert not detector._is_duplicate_pattern("trust_anomaly", "agent1:high")

    def test_prune_stale_entries(self):
        """Stale dedup entries are cleaned up."""
        router = HebbianRouter()
        trust = TrustNetwork()
        detector = EmergentDetector(router, trust, cluster_cooldown_seconds=1.0)
        detector.set_pattern_cooldown(1.0)

        detector._is_duplicate_pattern("trust_anomaly", "agent1:high")
        assert len(detector._last_pattern_fired) == 1

        # Manually age the entry
        key = ("trust_anomaly", "agent1:high")
        detector._last_pattern_fired[key] = time.monotonic() - 100
        detector._prune_stale_dedup_entries()
        assert len(detector._last_pattern_fired) == 0

    def test_set_pattern_cooldown(self):
        """Cooldown window is configurable."""
        router = HebbianRouter()
        trust = TrustNetwork()
        detector = EmergentDetector(router, trust)
        detector.set_pattern_cooldown(120.0)
        assert detector._pattern_cooldown_seconds == 120.0

    def test_negative_cooldown_clamped(self):
        """Negative cooldown values are clamped to 0."""
        router = HebbianRouter()
        trust = TrustNetwork()
        detector = EmergentDetector(router, trust)
        detector.set_pattern_cooldown(-10.0)
        assert detector._pattern_cooldown_seconds == 0.0

    def test_trust_anomaly_dedup_in_analyze(self):
        """Trust anomalies for same agent are suppressed across analyze() calls."""
        router = HebbianRouter()
        trust = TrustNetwork()
        detector = EmergentDetector(router, trust, trust_anomaly_min_count=1)
        detector.set_pattern_cooldown(600.0)

        # Create agents with divergent trust to trigger anomalies
        for i in range(5):
            trust.get_or_create(f"normal_{i}")
        outlier_id = "outlier_agent"
        trust.get_or_create(outlier_id)
        # Pump the outlier's trust high
        for _ in range(50):
            trust.record_outcome(outlier_id, success=True, weight=1.0)

        detector.set_live_agents({f"normal_{i}" for i in range(5)} | {outlier_id})

        # First analyze — should find the anomaly
        patterns1 = detector.analyze()
        trust_anomalies1 = [p for p in patterns1 if p.pattern_type == "trust_anomaly"]

        # Second analyze — same state, anomalies should be suppressed
        patterns2 = detector.analyze()
        trust_anomalies2 = [p for p in patterns2 if p.pattern_type == "trust_anomaly"]

        # Second run should have fewer (or zero) trust anomalies
        assert len(trust_anomalies2) <= len(trust_anomalies1)


class TestPoolDuplicateGuard:
    """AD-411: create_pool() duplicate name guard."""

    @pytest.mark.asyncio
    async def test_duplicate_pool_returns_existing(self):
        """Creating a pool with an existing name returns the existing pool."""
        from probos.runtime import ProbOSRuntime
        from probos.config import SystemConfig

        config = SystemConfig()
        rt = ProbOSRuntime(config)

        # Simulate an existing pool by inserting a sentinel object
        sentinel_pool = MagicMock()
        rt.pools["test_pool"] = sentinel_pool

        result = await rt.create_pool("test_pool", "some_type")
        assert result is sentinel_pool


# ===========================================================================
# BF-036: Trust flooding fix
# ===========================================================================


class TestBF036TrustFloodingFix:
    """BF-036: Verify guards against trust anomaly flooding after cold-start."""

    def test_narrow_std_suppresses_sigma_check(self) -> None:
        """Population with tiny std (<0.05) should produce no sigma anomalies."""
        # 10 agents at 0.50, one at 0.55 — std ≈ 0.015
        records: dict[str, dict] = {}
        for i in range(10):
            records[f"agent_{i}"] = {"alpha": 10.0, "beta": 10.0, "observations": 16.0}
        records["slightly_high"] = {"alpha": 11.0, "beta": 9.0, "observations": 16.0}
        d = _make_detector(trust_records=records)
        patterns = d.detect_trust_anomalies()
        sigma_patterns = [
            p for p in patterns
            if p.pattern_type == "trust_anomaly" and "σ" in p.description
        ]
        assert len(sigma_patterns) == 0

    def test_wide_std_allows_sigma_check(self) -> None:
        """With genuine population divergence, outliers ARE flagged."""
        records: dict[str, dict] = {}
        for i in range(7):
            records[f"agent_{i}"] = {"alpha": 5.0, "beta": 5.0, "observations": 6.0}
        # Outlier with high trust and sufficient observations
        records["star"] = {"alpha": 18.0, "beta": 2.0, "observations": 16.0}
        d = _make_detector(trust_records=records)
        patterns = d.detect_trust_anomalies()
        trust_patterns = [p for p in patterns if "star" in p.description]
        assert len(trust_patterns) >= 1

    def test_low_observations_suppresses_anomaly(self) -> None:
        """Agent with <8 total (alpha+beta) should not be flagged even with high std."""
        records: dict[str, dict] = {}
        for i in range(7):
            records[f"agent_{i}"] = {"alpha": 5.0, "beta": 5.0, "observations": 6.0}
        # High trust but only 4.5 total observations (alpha+beta) — below threshold
        records["new_agent"] = {"alpha": 2.5, "beta": 2.0, "observations": 0.5}
        # Also add a genuine outlier so std > 0.05
        records["outlier"] = {"alpha": 18.0, "beta": 2.0, "observations": 16.0}
        d = _make_detector(trust_records=records)
        patterns = d.detect_trust_anomalies()
        new_agent_patterns = [p for p in patterns if "new_agen" in p.description]
        assert len(new_agent_patterns) == 0

    def test_sufficient_observations_allows_anomaly(self) -> None:
        """Agent with >=8 total observations should be flagged if deviation is large."""
        records: dict[str, dict] = {}
        for i in range(7):
            records[f"agent_{i}"] = {"alpha": 5.0, "beta": 5.0, "observations": 6.0}
        # High trust and total=8.0 — meets threshold
        records["veteran"] = {"alpha": 6.0, "beta": 2.0, "observations": 4.0}
        # Need std > 0.05 and abs_deviation > 0.10 — add a low outlier too
        records["low_out"] = {"alpha": 1.0, "beta": 19.0, "observations": 16.0}
        d = _make_detector(trust_records=records)
        patterns = d.detect_trust_anomalies()
        # low_out (score ≈ 0.05) is the big anomaly — should fire
        low_patterns = [p for p in patterns if "low_out" in p.description]
        assert len(low_patterns) >= 1

    def test_small_absolute_deviation_suppresses(self) -> None:
        """Agent only 0.08 from mean should NOT fire even with std just above 0.05."""
        # Craft population: mean ≈ 0.50, std ≈ 0.06
        records: dict[str, dict] = {}
        for i in range(8):
            records[f"agent_{i}"] = {"alpha": 5.0, "beta": 5.0, "observations": 6.0}
        # Push std above 0.05 with a low agent
        records["low_agent"] = {"alpha": 2.0, "beta": 8.0, "observations": 6.0}
        # Agent barely above mean — abs deviation < 0.10
        records["near_mean"] = {"alpha": 6.0, "beta": 5.0, "observations": 6.0}
        d = _make_detector(trust_records=records)
        patterns = d.detect_trust_anomalies()
        # Filter for sigma-based trust anomalies only (contain "σ" in description)
        near_sigma = [
            p for p in patterns
            if "near_mea" in p.description and "σ" in p.description
        ]
        assert len(near_sigma) == 0

    def test_large_absolute_deviation_fires(self) -> None:
        """Agent 0.15+ from mean with sufficient observations and std fires."""
        records: dict[str, dict] = {}
        for i in range(7):
            records[f"agent_{i}"] = {"alpha": 5.0, "beta": 5.0, "observations": 6.0}
        # Outlier > 0.10 absolute from mean, total > 8, std should be > 0.05
        records["outlier"] = {"alpha": 16.0, "beta": 2.0, "observations": 14.0}
        d = _make_detector(trust_records=records)
        patterns = d.detect_trust_anomalies()
        outlier_patterns = [p for p in patterns if "outlier" in p.description]
        assert len(outlier_patterns) >= 1

    def test_cold_start_transition_no_flooding(self) -> None:
        """Simulate post-cold-start: 20 agents at prior, a few with 1-3 successes. No flooding."""
        records: dict[str, dict] = {}
        for i in range(20):
            records[f"agent_{i}"] = {"alpha": 2.0, "beta": 2.0, "observations": 0.0}
        # A few agents got 1-3 successes (alpha incremented)
        records["agent_0"]["alpha"] = 3.0  # score 0.6, total 5.0
        records["agent_0"]["observations"] = 1.0
        records["agent_1"]["alpha"] = 4.0  # score 0.67, total 6.0
        records["agent_1"]["observations"] = 2.0
        records["agent_2"]["alpha"] = 5.0  # score 0.71, total 7.0
        records["agent_2"]["observations"] = 3.0
        d = _make_detector(trust_records=records)
        patterns = d.detect_trust_anomalies()
        # All three guards collectively prevent any sigma anomalies
        sigma_patterns = [
            p for p in patterns
            if p.pattern_type == "trust_anomaly" and "σ" in p.description
        ]
        assert len(sigma_patterns) == 0


# ===========================================================================
# BF-089: Emergent detector false positive reduction
# ===========================================================================


def _outlier_records() -> dict[str, dict]:
    """Build trust records with a clear outlier (score ≈ 0.90) vs population (score ≈ 0.50)."""
    records: dict[str, dict] = {}
    for i in range(7):
        records[f"agent_{i}"] = {"alpha": 5.0, "beta": 5.0, "observations": 6.0}
    records["outlier"] = {"alpha": 18.0, "beta": 2.0, "observations": 16.0}
    return records


class TestBF089TemporalBuffer:
    """BF-089: Temporal buffer requires sustained anomaly before emitting."""

    def test_temporal_buffer_suppresses_single_occurrence(self) -> None:
        """A single trust anomaly trigger does NOT produce an EmergentPattern."""
        records = _outlier_records()
        d = _make_detector(trust_records=records, trust_anomaly_min_count=3)
        patterns = d.detect_trust_anomalies()
        sigma_patterns = [p for p in patterns if "σ" in p.description]
        assert len(sigma_patterns) == 0

    def test_temporal_buffer_promotes_sustained_anomaly(self) -> None:
        """An agent triggering 3+ times within the window DOES produce a pattern."""
        records = _outlier_records()
        d = _make_detector(
            trust_records=records,
            trust_anomaly_min_count=3,
            trust_anomaly_window=600.0,
        )
        # Call three times to accumulate observations
        d.detect_trust_anomalies()
        d.detect_trust_anomalies()
        patterns = d.detect_trust_anomalies()
        sigma_patterns = [p for p in patterns if "σ" in p.description]
        assert len(sigma_patterns) >= 1

    def test_temporal_buffer_window_expiry(self) -> None:
        """Old anomaly counts expire after the window elapses."""
        records = _outlier_records()
        d = _make_detector(
            trust_records=records,
            trust_anomaly_min_count=3,
            trust_anomaly_window=600.0,
        )
        # Simulate two old observations then prune
        now = time.monotonic()
        d._trust_anomaly_counts["sigma:outlier:high"] = [now - 700, now - 650]
        d._prune_stale_anomaly_counts()
        # Both should have been pruned (older than 600s window)
        assert "sigma:outlier:high" not in d._trust_anomaly_counts


class TestBF089AdaptiveBaselines:
    """BF-089: EMA smoothing for sigma detection."""

    def test_adaptive_baseline_smooths_noise(self) -> None:
        """EMA mean/std are updated across multiple analyze() calls."""
        records = _outlier_records()
        d = _make_detector(trust_records=records, trust_anomaly_min_count=1)

        # First call initializes EMA
        d.detect_trust_anomalies()
        first_mean = d._ema_trust_mean
        assert first_mean is not None

        # Second call — same data — EMA updates (alpha blend)
        d.detect_trust_anomalies()
        second_mean = d._ema_trust_mean
        # With identical data, EMA should converge toward same value
        assert second_mean is not None
        assert abs(second_mean - first_mean) < 0.01  # Stable convergence


class TestBF089BatchLimiting:
    """BF-089: Per-analysis batch limiting."""

    def test_batch_limiting(self) -> None:
        """When >max anomalies detected, only top N by confidence are emitted."""
        # Create many agents with extreme trust to trigger multiple anomalies
        records: dict[str, dict] = {}
        for i in range(7):
            records[f"normal_{i}"] = {"alpha": 5.0, "beta": 5.0, "observations": 6.0}
        # Four outliers with different deviations
        records["out_a"] = {"alpha": 18.0, "beta": 2.0, "observations": 16.0}
        records["out_b"] = {"alpha": 19.0, "beta": 1.0, "observations": 16.0}
        records["out_c"] = {"alpha": 1.0, "beta": 19.0, "observations": 16.0}
        records["out_d"] = {"alpha": 0.5, "beta": 20.0, "observations": 19.0}

        d = _make_detector(
            trust_records=records,
            trust_anomaly_min_count=1,
            max_trust_anomalies_per_pass=2,
        )
        patterns = d.detect_trust_anomalies()
        assert len(patterns) <= 2


class TestBF089DutyCorrelation:
    """BF-089: Duty cycle correlation awareness."""

    def test_duty_correlation_suppression(self) -> None:
        """Trust change coinciding with duty completion is suppressed for change-point."""
        records = {
            "agent_0": {"alpha": 5.0, "beta": 2.0, "observations": 3.0},
            "agent_1": {"alpha": 2.0, "beta": 2.0, "observations": 0.0},
        }
        d = _make_detector(trust_records=records, trust_anomaly_min_count=1)
        # Create previous snapshot
        prev_snapshot = SystemDynamicsSnapshot(
            trust_distribution={
                "mean": 0.5,
                "std": 0.1,
                "per_agent": {
                    "agent_0": 0.4,  # delta ≈ 0.31 → normally triggers change-point
                    "agent_1": 0.5,
                },
            },
        )
        d._history.append(prev_snapshot)

        # With duty completion for agent_0 — change-point should be suppressed
        now = time.monotonic()
        patterns = d.detect_trust_anomalies(duty_completions=[("agent_0", now - 30.0)])
        change_points = [p for p in patterns if "change point" in p.description and "agent_0" in p.description]
        assert len(change_points) == 0

    def test_duty_correlation_sigma_elevation(self) -> None:
        """Trust sigma during duty cycle requires 3.0σ (significant) instead of 2.0σ (notable)."""
        records = _outlier_records()
        d = _make_detector(trust_records=records, trust_anomaly_min_count=1)
        now = time.monotonic()

        # First: without duty — should fire at 2.0σ
        patterns_no_duty = d.detect_trust_anomalies(duty_completions=[])
        sigma_no_duty = [p for p in patterns_no_duty if "σ" in p.description]

        # Reset dedup cache for second call
        d._last_pattern_fired.clear()

        # With duty completion for outlier — threshold elevated to 3.0σ
        patterns_with_duty = d.detect_trust_anomalies(
            duty_completions=[("outlier", now - 30.0)]
        )
        sigma_with_duty = [
            p for p in patterns_with_duty
            if "σ" in p.description and "outlier" in p.description
        ]
        # Either fewer alerts, or the ones that remain are at higher sigma
        # The key assertion: with duty correlation, the outlier is held to a stricter standard
        if sigma_no_duty:
            # If it fired without duty, it should fire less (or not at all, or only at significant level) with duty
            for p in sigma_with_duty:
                assert p.evidence.get("deviation_sigma", 0) > 3.0 or len(sigma_with_duty) < len(sigma_no_duty)


class TestBF089ConfigurableThresholds:
    """BF-089: Constructor parameters override defaults."""

    def test_configurable_thresholds(self) -> None:
        """Custom thresholds are stored and used."""
        d = _make_detector(
            trust_sigma_threshold=4.0,
            trust_change_threshold=0.25,
            trust_min_std=0.10,
            trust_min_observations=12.0,
            trust_min_deviation=0.20,
            trust_ema_alpha=0.5,
            trust_anomaly_window=300.0,
            trust_anomaly_min_count=5,
            max_trust_anomalies_per_pass=1,
            duty_correlation_window=60.0,
        )
        assert d._trust_sigma_threshold == 4.0
        assert d._trust_change_threshold == 0.25
        assert d._trust_min_std == 0.10
        assert d._trust_min_observations == 12.0
        assert d._trust_min_deviation == 0.20
        assert d._trust_ema_alpha == 0.5
        assert d._trust_anomaly_window == 300.0
        assert d._trust_anomaly_min_count == 5
        assert d._max_trust_anomalies_per_pass == 1
        assert d._duty_correlation_window == 60.0


class TestBF089RegressionGuards:
    """BF-089: Ensure existing behavior is not broken."""

    def test_cold_start_suppression_still_works(self) -> None:
        """BF-034 cold-start suppression is unaffected by the temporal buffer."""
        records = _outlier_records()
        d = _make_detector(trust_records=records, trust_anomaly_min_count=1)
        d.set_cold_start_suppression(9999.0)  # Suppress for a long time
        patterns = d.detect_trust_anomalies()
        assert len(patterns) == 0

    def test_cooperation_clusters_unaffected(self) -> None:
        """Cooperation cluster detection is unchanged by BF-089."""
        weights = {
            ("intent_a", "agent_pool_0_abc", REL_INTENT): 0.5,
            ("intent_a", "agent_pool_1_def", REL_INTENT): 0.3,
        }
        d = _make_detector(weights=weights, trust_anomaly_min_count=5)
        clusters = d.detect_cooperation_clusters()
        # Cooperation clusters are independent of trust anomaly settings
        assert len(clusters) == 1
        assert clusters[0]["size"] == 3

    def test_cold_start_suppresses_cooperation_clusters(self) -> None:
        """BF-126: Cold-start suppression also suppresses cooperation clusters."""
        weights = {
            ("intent_a", "agent_pool_0_abc", REL_INTENT): 0.5,
            ("intent_a", "agent_pool_1_def", REL_INTENT): 0.3,
        }
        d = _make_detector(weights=weights)
        # Without suppression — clusters detected
        clusters = d.detect_cooperation_clusters()
        assert len(clusters) == 1

        # With suppression — clusters suppressed
        d.set_cold_start_suppression(9999.0)
        clusters = d.detect_cooperation_clusters()
        assert len(clusters) == 0

    def test_cluster_suppression_expires(self) -> None:
        """BF-126: Cluster suppression expires after the window."""
        weights = {
            ("intent_a", "agent_pool_0_abc", REL_INTENT): 0.5,
            ("intent_a", "agent_pool_1_def", REL_INTENT): 0.3,
        }
        d = _make_detector(weights=weights)
        # Set suppression to already-expired time
        d._suppress_clusters_until = time.monotonic() - 1.0
        clusters = d.detect_cooperation_clusters()
        assert len(clusters) == 1


# ===========================================================================
# BF-124: Cooperation cluster calibration
# ===========================================================================


class TestBF124ThresholdCalibration:
    """BF-124: Configurable cluster thresholds and quality filtering."""

    def test_default_cluster_threshold_is_030(self) -> None:
        """Default cluster_edge_threshold is 0.3, not the old 0.1."""
        d = _make_detector()
        assert d._cluster_edge_threshold == 0.3

    def test_cluster_threshold_configurable(self) -> None:
        """Constructor accepts custom cluster_edge_threshold; weights below threshold excluded."""
        weights = {
            ("intent_a", "agent_pool_0_abc", REL_INTENT): 0.25,  # below 0.4
            ("intent_b", "agent_pool_1_def", REL_INTENT): 0.5,   # above 0.4
        }
        d = _make_detector(
            weights=weights,
            cluster_edge_threshold=0.4,
            cluster_min_size=1,
        )
        clusters = d.detect_cooperation_clusters()
        # Only intent_b + agent_pool_1_def should form a cluster
        assert len(clusters) == 1
        assert clusters[0]["size"] == 2

    def test_cluster_min_size_filters_small(self) -> None:
        """Clusters with 2 nodes filtered out when cluster_min_size=3."""
        weights = {
            ("intent_a", "agent_pool_0_abc", REL_INTENT): 0.5,
        }
        # min_size=3 → the 2-node cluster (intent_a + agent) is filtered
        d = _make_detector(
            weights=weights,
            cluster_edge_threshold=0.1,
            cluster_min_size=3,
        )
        clusters = d.detect_cooperation_clusters()
        assert clusters == []

    def test_cluster_min_avg_weight_filters_weak(self) -> None:
        """Cluster with avg_weight below threshold is filtered."""
        weights = {
            ("intent_a", "agent_pool_0_abc", REL_INTENT): 0.15,
            ("intent_a", "agent_pool_1_def", REL_INTENT): 0.12,
        }
        # edge_threshold=0.1 includes these, but avg ≈ 0.135 < min_avg=0.25
        d = _make_detector(
            weights=weights,
            cluster_edge_threshold=0.1,
            cluster_min_size=1,
            cluster_min_avg_weight=0.25,
        )
        clusters = d.detect_cooperation_clusters()
        assert clusters == []

    def test_cluster_quality_combined(self) -> None:
        """Cluster passes edge threshold but fails min_avg_weight → filtered."""
        weights = {
            ("intent_a", "agent_pool_0_abc", REL_INTENT): 0.35,
            ("intent_a", "agent_pool_1_def", REL_INTENT): 0.12,
        }
        # edge_threshold=0.1 → both edges included → 3-node cluster
        # avg_weight ≈ (0.35 + 0.12) / 2 = 0.235 < min_avg_weight=0.30
        d = _make_detector(
            weights=weights,
            cluster_edge_threshold=0.1,
            cluster_min_size=1,
            cluster_min_avg_weight=0.30,
        )
        clusters = d.detect_cooperation_clusters()
        assert clusters == []

    def test_genuine_cluster_detected(self) -> None:
        """Strong cluster (multiple nodes, high avg_weight) detected correctly."""
        weights = {
            ("intent_a", "agent_pool_0_abc", REL_INTENT): 0.8,
            ("intent_a", "agent_pool_1_def", REL_INTENT): 0.7,
            ("intent_b", "agent_pool_0_abc", REL_INTENT): 0.6,
            ("intent_b", "agent_pool_1_def", REL_INTENT): 0.9,
        }
        d = _make_detector(
            weights=weights,
            cluster_edge_threshold=0.3,
            cluster_min_size=3,
            cluster_min_avg_weight=0.25,
        )
        clusters = d.detect_cooperation_clusters()
        # All four nodes connected with strong weights → 1 cluster, size 4
        assert len(clusters) >= 1
        assert clusters[0]["size"] >= 3
        assert clusters[0]["avg_weight"] >= 0.25

    def test_cluster_cooldown_longer_than_default(self) -> None:
        """Cooperation cluster dedup cooldown is 1800s, not the default 600s."""
        d = _make_detector()
        assert d._cluster_cooldown_seconds == 1800.0
        assert d._pattern_cooldown_seconds == 600.0
        assert d._cluster_cooldown_seconds > d._pattern_cooldown_seconds

    def test_cluster_cooldown_per_type(self) -> None:
        """Trust anomaly uses 600s cooldown, cooperation_cluster uses 1800s."""
        d = _make_detector()
        d.set_pattern_cooldown(600.0)

        # Trust anomaly — first fire allowed
        assert not d._is_duplicate_pattern("trust_anomaly", "agent1:high")
        # Trust anomaly — second fire suppressed (within 600s)
        assert d._is_duplicate_pattern("trust_anomaly", "agent1:high")

        # Cooperation cluster — first fire allowed
        assert not d._is_duplicate_pattern("cooperation_cluster", "cluster1")
        # Cooperation cluster — second fire suppressed (within 1800s)
        assert d._is_duplicate_pattern("cooperation_cluster", "cluster1")

        # Now manually expire the trust anomaly entry (advance past 600s)
        key_trust = ("trust_anomaly", "agent1:high")
        d._last_pattern_fired[key_trust] = time.monotonic() - 650
        # Trust anomaly allowed again (past 600s cooldown)
        assert not d._is_duplicate_pattern("trust_anomaly", "agent1:high")

        # But cluster entry still suppressed (only 650s elapsed < 1800s)
        key_cluster = ("cooperation_cluster", "cluster1")
        d._last_pattern_fired[key_cluster] = time.monotonic() - 650
        assert d._is_duplicate_pattern("cooperation_cluster", "cluster1")


class TestBF124Config:
    """BF-124: EmergentDetectorConfig exists and wires to SystemConfig."""

    def test_emergent_detector_config_exists(self) -> None:
        """EmergentDetectorConfig is importable from probos.config."""
        from probos.config import EmergentDetectorConfig
        cfg = EmergentDetectorConfig()
        assert cfg.cluster_edge_threshold == 0.3
        assert cfg.cluster_min_size == 3
        assert cfg.cluster_min_avg_weight == 0.25
        assert cfg.cluster_cooldown_seconds == 1800.0

    def test_system_config_has_emergent_detector(self) -> None:
        """SystemConfig().emergent_detector returns EmergentDetectorConfig with defaults."""
        from probos.config import SystemConfig, EmergentDetectorConfig
        sc = SystemConfig()
        assert isinstance(sc.emergent_detector, EmergentDetectorConfig)
        assert sc.emergent_detector.cluster_edge_threshold == 0.3

    def test_config_values_override(self) -> None:
        """Custom values override defaults."""
        from probos.config import EmergentDetectorConfig
        cfg = EmergentDetectorConfig(
            cluster_edge_threshold=0.5,
            cluster_min_size=5,
            cluster_min_avg_weight=0.4,
            cluster_cooldown_seconds=3600.0,
        )
        assert cfg.cluster_edge_threshold == 0.5
        assert cfg.cluster_min_size == 5
        assert cfg.cluster_min_avg_weight == 0.4
        assert cfg.cluster_cooldown_seconds == 3600.0


class TestBF124DivergenceDedup:
    """BF-124: Divergence alert dedup is agent-pair + topic aware."""

    def test_divergence_same_pair_same_topic_deduped(self) -> None:
        """Same agent pair + same topic → suppressed within cooldown."""
        from probos.bridge_alerts import BridgeAlertService
        svc = BridgeAlertService(cooldown_seconds=600.0)

        data = {
            "divergence_detected": True,
            "divergence_topic": "warp core efficiency",
            "divergence_agents": ["LaForge", "Scotty"],
            "divergence_departments": ["Engineering"],
            "divergence_similarity": 0.3,
        }
        # First call should emit
        alerts1 = svc.check_divergence(data)
        assert len(alerts1) == 1

        # Second call same pair + same topic — suppressed
        alerts2 = svc.check_divergence(data)
        assert len(alerts2) == 0

    def test_divergence_same_pair_new_topic_fires(self) -> None:
        """Same agent pair + different topic → new alert fires."""
        from probos.bridge_alerts import BridgeAlertService
        svc = BridgeAlertService(cooldown_seconds=600.0)

        data1 = {
            "divergence_detected": True,
            "divergence_topic": "warp core efficiency",
            "divergence_agents": ["LaForge", "Scotty"],
            "divergence_departments": ["Engineering"],
            "divergence_similarity": 0.3,
        }
        data2 = dict(data1, divergence_topic="shield modulation")

        alerts1 = svc.check_divergence(data1)
        assert len(alerts1) == 1

        alerts2 = svc.check_divergence(data2)
        assert len(alerts2) == 1  # New topic fires

    def test_divergence_different_pair_same_topic_fires(self) -> None:
        """Different agent pair + same topic → new alert fires."""
        from probos.bridge_alerts import BridgeAlertService
        svc = BridgeAlertService(cooldown_seconds=600.0)

        data1 = {
            "divergence_detected": True,
            "divergence_topic": "warp core efficiency",
            "divergence_agents": ["LaForge", "Scotty"],
            "divergence_departments": ["Engineering"],
            "divergence_similarity": 0.3,
        }
        data2 = dict(data1, divergence_agents=["Chapel", "Bones"])

        alerts1 = svc.check_divergence(data1)
        assert len(alerts1) == 1

        alerts2 = svc.check_divergence(data2)
        assert len(alerts2) == 1  # Different pair fires


class TestBF124Regression:
    """BF-124: Existing behavior is preserved."""

    def test_bf126_suppression_still_works(self) -> None:
        """Post-stasis suppression window still suppresses with new thresholds."""
        weights = {
            ("intent_a", "agent_pool_0_abc", REL_INTENT): 0.8,
            ("intent_a", "agent_pool_1_def", REL_INTENT): 0.7,
            ("intent_b", "agent_pool_0_abc", REL_INTENT): 0.6,
        }
        d = _make_detector(
            weights=weights,
            cluster_edge_threshold=0.3,
            cluster_min_size=1,
        )
        # Without suppression — clusters detected
        clusters = d.detect_cooperation_clusters()
        assert len(clusters) >= 1

        # With suppression — clusters suppressed
        d.set_cold_start_suppression(9999.0)
        clusters = d.detect_cooperation_clusters()
        assert len(clusters) == 0

    def test_existing_trust_anomaly_detection_unchanged(self) -> None:
        """Trust anomaly detection params/behavior unchanged by BF-124."""
        records = _outlier_records()
        d = _make_detector(trust_records=records, trust_anomaly_min_count=1)
        # BF-124 cluster params should not affect trust detection
        assert d._trust_sigma_threshold == 2.0
        assert d._trust_change_threshold == 0.15
        patterns = d.detect_trust_anomalies()
        trust_patterns = [p for p in patterns if p.pattern_type == "trust_anomaly"]
        assert len(trust_patterns) >= 1
