"""
tests/test_phase7_kaskad_dynamism.py — Phase 7: Kaskad Dynamism

WATERFALL_PLAN §11 — Dynamic edge activation and content-modulated
cascade predictions.  Addresses BS-19 (Static DAG weights) and
BS-20 (No cascade learning).

Test IDs: 7.01 – 7.07

Key principles:
  - AX23: DAG topology is axiomatic — NEVER changes
  - DynamicEdge: activation ∈ [0.3, 1.0] modulated by content evidence
  - activate_edges: maps frozen CascadeEdge → DynamicEdge
  - predict_neighbors_dynamic: T8 predictions adjusted by lens_scores
"""

from __future__ import annotations

import unittest
from typing import Dict, Tuple

from kaskad import (
    build_framework_graph,
    CascadeDAG,
    CascadeEdge,
    CascadeNode,
    DynamicEdge,
    EdgeType,
    ChainId,
    NeighborhoodPrediction,
    MIN_EDGE_ACTIVATION,
    activate_edges,
    predict_neighbors_dynamic,
)


# ────────────────────────────────────────────────────────────────────
# Helpers
# ────────────────────────────────────────────────────────────────────

def _build_dag() -> CascadeDAG:
    """Build the canonical framework graph for testing."""
    return build_framework_graph()


def _empty_lens_scores() -> Dict[str, float]:
    """No lens evidence at all."""
    return {}


def _high_c2_lens_scores() -> Dict[str, float]:
    """Scores where C2's associated lens (kavram_sozlugu) is high."""
    return {"kavram_sozlugu": 0.85, "fol_formalizasyon": 0.1}


def _all_lens_scores_high() -> Dict[str, float]:
    """All lenses at high scores."""
    return {
        "kavram_sozlugu": 0.80,
        "fol_formalizasyon": 0.75,
        "holografik": 0.70,
        "mereoloji": 0.65,
        "bayes_analiz": 0.60,
        "kategori_teorisi": 0.70,
        "oyun_teorisi": 0.55,
    }


# ════════════════════════════════════════════════════════════════════
# 7.01 — DAG topology unchanged by activation
# ════════════════════════════════════════════════════════════════════

class TestDagTopologyUnchanged(unittest.TestCase):
    """Activation NEVER adds or removes edges from the DAG.
    AX23: DAG topology is axiomatic — it is a structural commitment."""

    def test_edge_count_preserved(self):
        """activate_edges returns exactly as many DynamicEdges as the DAG has CascadeEdges."""
        dag = _build_dag()
        original_edges = dag.get_edges()
        dynamic = activate_edges(dag, set(), _empty_lens_scores())
        self.assertEqual(
            len(dynamic), len(original_edges),
            "DynamicEdge count must match CascadeEdge count — topology is immutable",
        )

    def test_edge_endpoints_preserved(self):
        """Every (source, target, type) triple is preserved."""
        dag = _build_dag()
        original_keys = {
            (e.source, e.target, e.edge_type.value) for e in dag.get_edges()
        }
        dynamic = activate_edges(dag, set(), _high_c2_lens_scores())
        dynamic_keys = set(dynamic.keys())
        self.assertEqual(original_keys, dynamic_keys)

    def test_no_topology_mutation(self):
        """DAG node and edge count unchanged after activation."""
        dag = _build_dag()
        nodes_before = len(list(dag._nodes.keys()))
        edges_before = len(dag.get_edges())
        _ = activate_edges(dag, {"beauty", "cascade"}, _all_lens_scores_high())
        nodes_after = len(list(dag._nodes.keys()))
        edges_after = len(dag.get_edges())
        self.assertEqual(nodes_before, nodes_after)
        self.assertEqual(edges_before, edges_after)


# ════════════════════════════════════════════════════════════════════
# 7.02 — Activation modulates strength based on vocabulary
# ════════════════════════════════════════════════════════════════════

class TestActivationModulatesStrength(unittest.TestCase):
    """Relevant vocabulary in text → high activation on corresponding edges."""

    def test_vocab_hit_gives_full_activation(self):
        """C2 vocabulary ('beauty', 'creation') → C2 edges get activation 1.0."""
        dag = _build_dag()
        dynamic = activate_edges(dag, {"beauty", "creation"}, _empty_lens_scores())
        # AX24→AX25 is a C2 edge
        key = ("AX24", "AX25", EdgeType.GROUNDS.value)
        self.assertIn(key, dynamic)
        self.assertAlmostEqual(dynamic[key].activation, 1.0)

    def test_lens_score_gives_partial_activation(self):
        """No vocab but high lens score → activation equals lens score."""
        dag = _build_dag()
        scores = {"kavram_sozlugu": 0.75}
        dynamic = activate_edges(dag, set(), scores)
        # C2 edges should use kavram_sozlugu score (0.75) since > 0.5
        key = ("AX24", "AX25", EdgeType.GROUNDS.value)
        self.assertIn(key, dynamic)
        self.assertGreaterEqual(dynamic[key].activation, 0.75)

    def test_irrelevant_vocab_no_boost(self):
        """Vocabulary from C1 chain does NOT boost C2 edges."""
        dag = _build_dag()
        # "fakr" and "acz" are C1 vocabulary, not C2
        dynamic = activate_edges(dag, {"fakr", "acz"}, _empty_lens_scores())
        key = ("AX24", "AX25", EdgeType.GROUNDS.value)
        self.assertIn(key, dynamic)
        # C2 edge should NOT be boosted by C1 vocab
        # (it gets MIN because no C2 evidence)
        self.assertLessEqual(dynamic[key].activation, MIN_EDGE_ACTIVATION + 0.01)


# ════════════════════════════════════════════════════════════════════
# 7.03 — Activation minimum bound
# ════════════════════════════════════════════════════════════════════

class TestActivationMinimum(unittest.TestCase):
    """With no vocabulary and no lens scores, activation must be ≥ MIN_EDGE_ACTIVATION.
    A structural edge is always 'real' even absent content evidence."""

    def test_no_evidence_gives_minimum(self):
        """Empty vocab + empty scores → all edges at MIN_EDGE_ACTIVATION."""
        dag = _build_dag()
        dynamic = activate_edges(dag, set(), _empty_lens_scores())
        for key, de in dynamic.items():
            self.assertGreaterEqual(
                de.activation, MIN_EDGE_ACTIVATION,
                f"Edge {key} activation {de.activation} < MIN {MIN_EDGE_ACTIVATION}",
            )

    def test_effective_strength_equals_base_times_activation(self):
        """DynamicEdge.effective_strength == base_strength × activation."""
        dag = _build_dag()
        dynamic = activate_edges(dag, set(), _empty_lens_scores())
        for key, de in dynamic.items():
            expected = de.base_strength * de.activation
            self.assertAlmostEqual(
                de.effective_strength, expected,
                msg=f"Edge {key}: effective_strength mismatch",
            )

    def test_min_activation_constant_matches_class(self):
        """MIN_EDGE_ACTIVATION constant matches DynamicEdge.MIN_ACTIVATION default."""
        de = DynamicEdge(source="a", target="b", edge_type=EdgeType.GROUNDS)
        self.assertEqual(de.MIN_ACTIVATION, MIN_EDGE_ACTIVATION)


# ════════════════════════════════════════════════════════════════════
# 7.04 — T8 confidence modulated by lens scores
# ════════════════════════════════════════════════════════════════════

class TestT8ConfidenceModulated(unittest.TestCase):
    """predict_neighbors_dynamic adjusts T8 confidence using lens_scores."""

    def test_high_lens_score_boosts_confidence(self):
        """High lens score for predicted node's chain → higher confidence."""
        dag = _build_dag()
        seeds = frozenset({"AX24"})
        # Base prediction (no lens modulation)
        base = dag.predict_neighbors(seeds, max_hops=2)
        # Dynamic prediction with high C2 lens score
        dynamic = predict_neighbors_dynamic(
            dag, seeds, {"kavram_sozlugu": 0.9}, max_hops=2,
        )
        # Dynamic confidence should be ≥ base confidence (C2 lens high → boost)
        self.assertGreaterEqual(dynamic.confidence, base.confidence)

    def test_low_lens_score_dampens_confidence(self):
        """Low/zero lens score for predicted node's chain → lower confidence."""
        dag = _build_dag()
        seeds = frozenset({"AX24"})
        base = dag.predict_neighbors(seeds, max_hops=2)
        dynamic = predict_neighbors_dynamic(
            dag, seeds, {"kavram_sozlugu": 0.1}, max_hops=2,
        )
        # Dynamic confidence with low lens score → dampened below base
        self.assertLessEqual(dynamic.confidence, base.confidence)

    def test_no_lens_scores_returns_base(self):
        """When lens_scores is empty, result matches base prediction exactly."""
        dag = _build_dag()
        seeds = frozenset({"AX24"})
        base = dag.predict_neighbors(seeds, max_hops=2)
        dynamic = predict_neighbors_dynamic(dag, seeds, {}, max_hops=2)
        self.assertEqual(base.detected, dynamic.detected)
        self.assertEqual(base.predicted, dynamic.predicted)
        self.assertAlmostEqual(base.confidence, dynamic.confidence)

    def test_predicted_nodes_unchanged(self):
        """The SET of predicted nodes doesn't change — only confidence does."""
        dag = _build_dag()
        seeds = frozenset({"AX24"})
        base = dag.predict_neighbors(seeds, max_hops=2)
        dynamic = predict_neighbors_dynamic(
            dag, seeds, _all_lens_scores_high(), max_hops=2,
        )
        self.assertEqual(base.predicted, dynamic.predicted)
        self.assertEqual(base.detected, dynamic.detected)


# ════════════════════════════════════════════════════════════════════
# 7.05 — DynamicEdge from_edge preserves edge data
# ════════════════════════════════════════════════════════════════════

class TestDynamicEdgeFromEdge(unittest.TestCase):
    """DynamicEdge.from_edge() correctly wraps a frozen CascadeEdge."""

    def test_from_edge_preserves_fields(self):
        """source, target, edge_type copied from CascadeEdge."""
        edge = CascadeEdge(
            source="AX24", target="AX25",
            edge_type=EdgeType.GROUNDS, label="test",
        )
        de = DynamicEdge.from_edge(edge, activation=0.75)
        self.assertEqual(de.source, "AX24")
        self.assertEqual(de.target, "AX25")
        self.assertEqual(de.edge_type, EdgeType.GROUNDS)
        self.assertAlmostEqual(de.activation, 0.75)
        self.assertAlmostEqual(de.base_strength, 1.0)

    def test_from_edge_default_activation(self):
        """Default activation is 1.0."""
        edge = CascadeEdge(
            source="AX30", target="AX31",
            edge_type=EdgeType.PROVES,
        )
        de = DynamicEdge.from_edge(edge)
        self.assertAlmostEqual(de.activation, 1.0)


# ════════════════════════════════════════════════════════════════════
# 7.06 — dusun() feeds kaskad with lens_scores
# ════════════════════════════════════════════════════════════════════

class TestDusunFeedsKaskad(unittest.TestCase):
    """dusun() result includes kaskad_activation summary (Phase 7 integration)."""

    def test_dusun_result_has_kaskad_activation(self):
        """dusun() returns a dict containing kaskad_activation key."""
        from fw_server.dusun import dusun
        result = dusun("Beauty and creation form a cascade structure")
        self.assertIn("kaskad_activation", result)

    def test_kaskad_activation_has_expected_keys(self):
        """kaskad_activation contains total_edges, active_edges, activation_ratio."""
        from fw_server.dusun import dusun
        result = dusun("Beauty and creation form a cascade structure")
        ka = result.get("kaskad_activation", {})
        if ka:  # Non-empty means kaskad integration succeeded
            self.assertIn("total_edges", ka)
            self.assertIn("active_edges", ka)
            self.assertIn("activation_ratio", ka)
            self.assertGreater(ka["total_edges"], 0)
            self.assertGreaterEqual(ka["active_edges"], 0)
            self.assertGreaterEqual(ka["activation_ratio"], 0.0)
            self.assertLessEqual(ka["activation_ratio"], 1.0)


# ════════════════════════════════════════════════════════════════════
# 7.07 — Regression baseline
# ════════════════════════════════════════════════════════════════════

class TestRegressionBaseline(unittest.TestCase):
    """Verify Phase 7 additions don't break existing kaskad functionality."""

    def test_build_framework_graph_still_works(self):
        """build_framework_graph() returns a valid CascadeDAG."""
        dag = _build_dag()
        self.assertIsInstance(dag, CascadeDAG)
        self.assertGreater(len(dag.get_edges()), 0)

    def test_predict_neighbors_still_works(self):
        """Original predict_neighbors is unaffected."""
        dag = _build_dag()
        pred = dag.predict_neighbors(frozenset({"AX24", "AX25"}), max_hops=2)
        self.assertIsInstance(pred, NeighborhoodPrediction)
        self.assertTrue(len(pred.predicted) > 0)
        self.assertGreater(pred.confidence, 0.0)

    def test_generate_report_still_works(self):
        """generate_report() unbroken."""
        dag = _build_dag()
        report = dag.generate_report()
        self.assertGreater(report.total_nodes, 0)
        self.assertGreater(report.total_edges, 0)

    def test_kaskad_exports_complete(self):
        """All Phase 7 exports are importable from kaskad."""
        from kaskad import (
            DynamicEdge,
            activate_edges,
            predict_neighbors_dynamic,
            MIN_EDGE_ACTIVATION,
        )
        self.assertTrue(callable(activate_edges))
        self.assertTrue(callable(predict_neighbors_dynamic))
        self.assertIsInstance(MIN_EDGE_ACTIVATION, float)
        self.assertEqual(MIN_EDGE_ACTIVATION, 0.3)


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