"""Tests for DAG Editor: session, proposals, scoring, export, API integration."""

from __future__ import annotations

import json
from unittest.mock import MagicMock

from orxaq.experiences.dag_editor.editor import DAGEditorSession, EditAction
from orxaq.experiences.dag_editor.export import DAGExporter
from orxaq.experiences.dag_editor.proposals import CREDIT_PRIORS, EdgeProposer
from orxaq.experiences.dag_editor.scoring import SolitonScorer
from orxaq.experiences.editor import DAGEditorAPI
from orxaq.kernel.types import CausalEdge, CausalRole, Entity, VariableType
from orxaq.kernel.world_dag import WorldDAG
from orxaq.orchestration.integration import OrxaqEngine


def _make_dag() -> WorldDAG:
    """Create a small test DAG."""
    dag = WorldDAG()
    dag.add_entity(Entity(entity_id="A", name="Var A"))
    dag.add_entity(Entity(entity_id="B", name="Var B"))
    dag.add_entity(Entity(entity_id="C", name="Var C"))
    dag.add_edge(CausalEdge(source="A", target="B", mechanism="causes"))
    dag.add_edge(CausalEdge(source="B", target="C", mechanism="leads_to"))
    return dag


# ── DAGEditorSession ────────────────────────────────────────


class TestDAGEditorSession:
    def test_add_node(self):
        session = DAGEditorSession()
        result = session.add_node("X", name="Var X")
        assert result["status"] == "created"
        assert session.dag.node_count() == 1

    def test_add_duplicate_node(self):
        session = DAGEditorSession()
        session.add_node("X")
        result = session.add_node("X")
        assert result["status"] == "conflict"

    def test_remove_node(self):
        session = DAGEditorSession(_make_dag())
        result = session.remove_node("B")
        assert result["status"] == "deleted"
        assert session.dag.node_count() == 2
        assert session.dag.edge_count() == 0  # both edges removed

    def test_remove_nonexistent_node(self):
        session = DAGEditorSession()
        result = session.remove_node("Z")
        assert result["status"] == "not_found"

    def test_add_edge(self):
        session = DAGEditorSession()
        session.add_node("X")
        session.add_node("Y")
        result = session.add_edge("X", "Y")
        assert result["status"] == "created"
        assert session.dag.edge_count() == 1

    def test_add_edge_cycle(self):
        dag = _make_dag()
        session = DAGEditorSession(dag)
        result = session.add_edge("C", "A")
        assert result["status"] == "cycle"

    def test_add_edge_invalid_node(self):
        session = DAGEditorSession()
        session.add_node("X")
        result = session.add_edge("X", "Z")
        assert result["status"] == "invalid"

    def test_remove_edge(self):
        session = DAGEditorSession(_make_dag())
        result = session.remove_edge("A", "B")
        assert result["status"] == "deleted"
        assert session.dag.edge_count() == 1

    def test_remove_nonexistent_edge(self):
        session = DAGEditorSession(_make_dag())
        result = session.remove_edge("A", "C")
        assert result["status"] == "not_found"

    def test_undo_add_node(self):
        session = DAGEditorSession()
        session.add_node("X")
        assert session.dag.node_count() == 1
        result = session.undo()
        assert result["status"] == "undone"
        assert session.dag.node_count() == 0

    def test_undo_remove_node(self):
        session = DAGEditorSession(_make_dag())
        session.remove_node("B")
        assert session.dag.node_count() == 2
        session.undo()
        assert session.dag.node_count() == 3

    def test_undo_add_edge(self):
        session = DAGEditorSession()
        session.add_node("X")
        session.add_node("Y")
        session.add_edge("X", "Y")
        session.undo()
        assert session.dag.edge_count() == 0

    def test_undo_remove_edge(self):
        session = DAGEditorSession(_make_dag())
        session.remove_edge("A", "B")
        session.undo()
        assert session.dag.edge_count() == 2

    def test_redo(self):
        session = DAGEditorSession()
        session.add_node("X")
        session.undo()
        assert session.dag.node_count() == 0
        session.redo()
        assert session.dag.node_count() == 1

    def test_undo_empty(self):
        session = DAGEditorSession()
        result = session.undo()
        assert result["status"] == "empty"

    def test_redo_empty(self):
        session = DAGEditorSession()
        result = session.redo()
        assert result["status"] == "empty"

    def test_can_undo_redo(self):
        session = DAGEditorSession()
        assert not session.can_undo
        assert not session.can_redo
        session.add_node("X")
        assert session.can_undo
        session.undo()
        assert session.can_redo

    def test_snapshot(self):
        session = DAGEditorSession(_make_dag())
        snap = session.snapshot()
        assert snap["node_count"] == 3
        assert snap["edge_count"] == 2
        assert "can_undo" in snap
        assert "can_redo" in snap

    def test_load_snapshot(self):
        session = DAGEditorSession(_make_dag())
        snap = session.snapshot()

        new_session = DAGEditorSession()
        new_session.load_snapshot(snap)
        assert new_session.dag.node_count() == 3
        assert new_session.dag.edge_count() == 2

    def test_history_length(self):
        session = DAGEditorSession()
        assert session.history_length == 0
        session.add_node("X")
        assert session.history_length == 1

    def test_add_node_invalid_causal_role(self):
        session = DAGEditorSession()
        result = session.add_node("X", causal_role="INVALID_ROLE")
        assert result["status"] == "created"
        entity = session.dag.get_entity("X")
        assert entity.causal_role == CausalRole.EXOGENOUS

    def test_add_node_invalid_variable_type(self):
        session = DAGEditorSession()
        result = session.add_node("X", variable_type="INVALID_TYPE")
        assert result["status"] == "created"
        entity = session.dag.get_entity("X")
        assert entity.variable_type == VariableType.STATIC

    def test_redo_remove_node(self):
        session = DAGEditorSession(_make_dag())
        session.remove_node("B")
        session.undo()
        result = session.redo()
        assert result["status"] == "redone"
        assert result["action"] == "remove_node"
        assert session.dag.node_count() == 2

    def test_redo_add_edge(self):
        session = DAGEditorSession()
        session.add_node("X")
        session.add_node("Y")
        session.add_edge("X", "Y")
        session.undo()
        result = session.redo()
        assert result["status"] == "redone"
        assert result["action"] == "add_edge"
        assert session.dag.edge_count() == 1

    def test_redo_remove_edge(self):
        session = DAGEditorSession(_make_dag())
        session.remove_edge("A", "B")
        session.undo()
        result = session.redo()
        assert result["status"] == "redone"
        assert result["action"] == "remove_edge"
        assert session.dag.edge_count() == 1

    def test_undo_unknown_action(self):
        session = DAGEditorSession()
        session._undo_stack.append(EditAction(action_type="unknown_action", data={}))
        result = session.undo()
        assert "error" in result

    def test_redo_unknown_action(self):
        session = DAGEditorSession()
        session._redo_stack.append(EditAction(action_type="unknown_action", data={}))
        result = session.redo()
        assert "error" in result

    def test_history_overflow(self):
        session = DAGEditorSession()
        session._max_history = 5
        for i in range(10):
            session.add_node(f"N{i}")
        assert len(session._undo_stack) == 5

    def test_load_snapshot_with_invalid_edge(self):
        snap = {
            "nodes": [
                {"entity_id": "A", "name": "A"},
                {"entity_id": "B", "name": "B"},
            ],
            "edges": [
                {"source": "A", "target": "B"},
                {"source": "B", "target": "A"},
            ],
        }
        session = DAGEditorSession()
        session.load_snapshot(snap)
        assert session.dag.node_count() == 2
        assert session.dag.edge_count() == 1

    def test_dict_to_entity_invalid_role(self):
        from orxaq.experiences.dag_editor.editor import _dict_to_entity

        entity = _dict_to_entity(
            {"entity_id": "X", "causal_role": "BOGUS", "variable_type": "BOGUS"}
        )
        assert entity.causal_role == CausalRole.EXOGENOUS
        assert entity.variable_type == VariableType.STATIC


# ── EdgeProposer ────────────────────────────────────────────


class TestEdgeProposer:
    def test_prior_proposals_outgoing(self):
        dag = WorldDAG()
        dag.add_entity(Entity(entity_id="gdp_growth", name="GDP Growth"))
        dag.add_entity(Entity(entity_id="unemployment_rate", name="Unemployment"))
        proposer = EdgeProposer(dag)
        proposals = proposer.propose_for_node("gdp_growth")
        sources = [p["source"] for p in proposals]
        targets = [p["target"] for p in proposals]
        assert "gdp_growth" in sources
        assert "unemployment_rate" in targets

    def test_prior_proposals_incoming(self):
        dag = WorldDAG()
        dag.add_entity(Entity(entity_id="gdp_growth", name="GDP"))
        dag.add_entity(Entity(entity_id="default_rate", name="Default Rate"))
        proposer = EdgeProposer(dag)
        proposals = proposer.propose_for_node("default_rate")
        # default_rate should have incoming edge from gdp_growth
        edges = [(p["source"], p["target"]) for p in proposals]
        assert ("gdp_growth", "default_rate") in edges

    def test_no_duplicate_proposals(self):
        dag = WorldDAG()
        dag.add_entity(Entity(entity_id="gdp_growth", name="GDP"))
        dag.add_entity(Entity(entity_id="unemployment_rate", name="Unemp"))
        dag.add_edge(CausalEdge(source="gdp_growth", target="unemployment_rate"))
        proposer = EdgeProposer(dag)
        proposals = proposer.propose_for_node("gdp_growth")
        # Should not propose already-existing edge
        existing = [
            p
            for p in proposals
            if p["source"] == "gdp_growth" and p["target"] == "unemployment_rate"
        ]
        assert len(existing) == 0

    def test_propose_all(self):
        dag = WorldDAG()
        dag.add_entity(Entity(entity_id="gdp_growth", name="GDP"))
        dag.add_entity(Entity(entity_id="unemployment_rate", name="Unemp"))
        dag.add_entity(Entity(entity_id="default_rate", name="Default"))
        proposer = EdgeProposer(dag)
        proposals = proposer.propose_all()
        assert len(proposals) > 0

    def test_max_proposals_limit(self):
        dag = WorldDAG()
        for var in CREDIT_PRIORS:
            dag.add_entity(Entity(entity_id=var, name=var))
        proposer = EdgeProposer(dag)
        proposals = proposer.propose_for_node("gdp_growth", max_proposals=2)
        assert len(proposals) <= 2

    def test_llm_proposals_with_mock(self):
        dag = WorldDAG()
        dag.add_entity(Entity(entity_id="A", name="A"))
        dag.add_entity(Entity(entity_id="B", name="B"))

        mock_provider = MagicMock()
        mock_provider.generate.return_value = MagicMock(
            text=json.dumps([{"source": "A", "target": "B", "confidence": 0.8, "reason": "test"}]),
            metadata={},
        )

        proposer = EdgeProposer(dag, provider=mock_provider)
        proposals = proposer.propose_for_node("A")
        assert any(p["method"] == "llm" for p in proposals)

    def test_llm_proposals_error_handling(self):
        dag = WorldDAG()
        dag.add_entity(Entity(entity_id="A", name="A"))

        mock_provider = MagicMock()
        mock_provider.generate.return_value = MagicMock(
            text="not json",
            metadata={},
        )

        proposer = EdgeProposer(dag, provider=mock_provider)
        proposals = proposer._llm_proposals("A")
        assert proposals == []

    def test_llm_proposals_api_error(self):
        dag = WorldDAG()
        dag.add_entity(Entity(entity_id="A", name="A"))

        mock_provider = MagicMock()
        mock_provider.generate.return_value = MagicMock(
            text="",
            metadata={"error": "connection refused"},
        )

        proposer = EdgeProposer(dag, provider=mock_provider)
        proposals = proposer._llm_proposals("A")
        assert proposals == []

    def test_llm_code_block_stripped(self):
        """Cover line 136: LLM response with code block markers."""
        dag = WorldDAG()
        dag.add_entity(Entity(entity_id="A", name="A"))
        dag.add_entity(Entity(entity_id="B", name="B"))

        mock_provider = MagicMock()
        text = '```json\n[{"source": "A", "target": "B"}]\n```'
        mock_provider.generate.return_value = MagicMock(
            text=text,
            metadata={},
        )

        proposer = EdgeProposer(dag, provider=mock_provider)
        proposals = proposer._llm_proposals("A")
        assert len(proposals) >= 1

    def test_llm_non_list_response(self):
        """Cover line 139: LLM returns valid JSON but not a list."""
        dag = WorldDAG()
        dag.add_entity(Entity(entity_id="A", name="A"))

        mock_provider = MagicMock()
        mock_provider.generate.return_value = MagicMock(
            text='{"source": "A", "target": "B"}',
            metadata={},
        )

        proposer = EdgeProposer(dag, provider=mock_provider)
        proposals = proposer._llm_proposals("A")
        assert proposals == []


# ── SolitonScorer ───────────────────────────────────────────


class TestSolitonScorer:
    def test_empty_dag(self):
        scorer = SolitonScorer(WorldDAG())
        assert scorer.overall_score() == 0.0

    def test_single_node(self):
        dag = WorldDAG()
        dag.add_entity(Entity(entity_id="A", name="A"))
        scorer = SolitonScorer(dag)
        score = scorer.overall_score()
        assert 0.0 <= score <= 1.0

    def test_connected_dag_scores_higher(self):
        isolated = WorldDAG()
        isolated.add_entity(Entity(entity_id="A", name="A"))
        isolated.add_entity(Entity(entity_id="B", name="B"))

        connected = _make_dag()

        s1 = SolitonScorer(isolated).overall_score()
        s2 = SolitonScorer(connected).overall_score()
        assert s2 > s1

    def test_component_scores(self):
        scorer = SolitonScorer(_make_dag())
        comps = scorer.component_scores()
        assert "connectivity" in comps
        assert "balance" in comps
        assert "depth" in comps
        assert "density" in comps
        for v in comps.values():
            assert 0.0 <= v <= 1.0

    def test_node_scores(self):
        scorer = SolitonScorer(_make_dag())
        ns = scorer.node_scores()
        assert "A" in ns
        assert "B" in ns
        assert "C" in ns
        for v in ns.values():
            assert 0.0 <= v <= 1.0

    def test_edge_impact(self):
        scorer = SolitonScorer(_make_dag())
        impact = scorer.edge_impact("A", "B")
        assert impact["edge_exists"] is True
        assert "current_score" in impact

    def test_edge_impact_nonexistent(self):
        scorer = SolitonScorer(_make_dag())
        impact = scorer.edge_impact("A", "C")
        assert impact["edge_exists"] is False

    def test_empty_dag_component_scores(self):
        """Cover line 41: component_scores on empty DAG."""
        scorer = SolitonScorer(WorldDAG())
        comps = scorer.component_scores()
        assert comps["connectivity"] == 0.0
        assert comps["balance"] == 0.0

    def test_no_roots_connectivity(self):
        """Cover line 84: all nodes have parents (no roots)."""
        from unittest.mock import patch

        dag = WorldDAG()
        dag.add_entity(Entity(entity_id="A", name="A"))
        dag.add_entity(Entity(entity_id="B", name="B"))
        dag.add_edge(CausalEdge(source="A", target="B"))
        scorer = SolitonScorer(dag)
        # Mock parents to always return non-empty (simulates no roots)
        with patch.object(dag, "parents", return_value=["fake_parent"]):
            conn = scorer._connectivity_score()
        assert conn == 0.5

    def test_empty_topo_depth(self):
        """Cover line 122: empty topological sort in depth score."""
        from unittest.mock import patch

        dag = WorldDAG()
        dag.add_entity(Entity(entity_id="A", name="A"))
        dag.add_entity(Entity(entity_id="B", name="B"))
        dag.add_edge(CausalEdge(source="A", target="B"))
        scorer = SolitonScorer(dag)
        # Mock topological_sort to return empty list
        with patch.object(dag, "topological_sort", return_value=[]):
            depth = scorer._depth_score()
        assert depth == 0.0


# ── DAGExporter ─────────────────────────────────────────────


class TestDAGExporter:
    def test_to_json(self):
        exporter = DAGExporter(_make_dag())
        result = exporter.to_json()
        data = json.loads(result)
        assert len(data["nodes"]) == 3
        assert len(data["edges"]) == 2

    def test_to_dot(self):
        exporter = DAGExporter(_make_dag())
        dot = exporter.to_dot()
        assert "digraph" in dot
        assert '"A"' in dot
        assert '"B"' in dot
        assert '"A" -> "B"' in dot

    def test_to_mermaid(self):
        exporter = DAGExporter(_make_dag())
        mermaid = exporter.to_mermaid()
        assert "graph TD" in mermaid
        assert "A" in mermaid
        assert "-->" in mermaid

    def test_to_dict(self):
        exporter = DAGExporter(_make_dag())
        d = exporter.to_dict()
        assert isinstance(d, dict)
        assert "nodes" in d
        assert "edges" in d

    def test_empty_dag(self):
        exporter = DAGExporter(WorldDAG())
        data = json.loads(exporter.to_json())
        assert data["nodes"] == []
        assert data["edges"] == []

    def test_dot_title(self):
        exporter = DAGExporter(_make_dag())
        dot = exporter.to_dot(title="TestDAG")
        assert '"TestDAG"' in dot

    def test_mermaid_mechanism_label(self):
        dag = WorldDAG()
        dag.add_entity(Entity(entity_id="X", name="X"))
        dag.add_entity(Entity(entity_id="Y", name="Y"))
        dag.add_edge(CausalEdge(source="X", target="Y", mechanism="causal_link"))
        exporter = DAGExporter(dag)
        mermaid = exporter.to_mermaid()
        assert "causal_link" in mermaid


# ── DAGEditorAPI (legacy editor.py) ─────────────────────────


class TestDAGEditorAPI:
    def _api(self):
        engine = OrxaqEngine()
        engine.load_credit_ontology()
        return DAGEditorAPI(engine)

    def test_list_nodes(self):
        api = self._api()
        resp, status = api.handle("/api/dag/nodes", "GET")
        assert status == 200
        assert resp["count"] > 0

    def test_list_edges(self):
        api = self._api()
        resp, status = api.handle("/api/dag/edges", "GET")
        assert status == 200

    def test_export(self):
        api = self._api()
        resp, status = api.handle("/api/dag/export", "GET")
        assert status == 200
        assert "nodes" in resp
        assert "edges" in resp

    def test_add_node(self):
        api = self._api()
        body = json.dumps({"id": "test_node", "name": "Test"})
        resp, status = api.handle("/api/dag/nodes", "POST", body)
        assert status == 201

    def test_add_duplicate_node(self):
        api = self._api()
        body = json.dumps({"id": "test_node", "name": "Test"})
        api.handle("/api/dag/nodes", "POST", body)
        resp, status = api.handle("/api/dag/nodes", "POST", body)
        assert status == 409

    def test_add_edge(self):
        api = self._api()
        # Use existing ontology nodes
        nodes, _ = api.handle("/api/dag/nodes", "GET")
        if nodes["count"] >= 2:
            n1 = nodes["nodes"][0]["id"]
            n2 = nodes["nodes"][1]["id"]
            body = json.dumps({"source": n1, "target": n2, "mechanism": "test"})
            resp, status = api.handle("/api/dag/edges", "POST", body)
            assert status in (201, 409)  # 409 if edge already exists or cycle

    def test_remove_node(self):
        api = self._api()
        body = json.dumps({"id": "temp_node", "name": "Temp"})
        api.handle("/api/dag/nodes", "POST", body)
        resp, status = api.handle("/api/dag/nodes", "DELETE", json.dumps({"id": "temp_node"}))
        assert status == 200

    def test_not_found_route(self):
        api = self._api()
        resp, status = api.handle("/api/dag/unknown", "GET")
        assert status == 404

    def test_invalid_json(self):
        api = self._api()
        resp, status = api.handle("/api/dag/nodes", "POST", "not-json")
        assert status == 400

    def test_missing_body(self):
        api = self._api()
        resp, status = api.handle("/api/dag/nodes", "POST")
        assert status == 400


# ── CLI integration ─────────────────────────────────────────


class TestDAGEditorCLI:
    def test_edit_command_exists(self):
        from orxaq.experiences.cli import build_parser

        parser = build_parser()
        # Verify 'edit' is a recognized subcommand
        args = parser.parse_args(["edit", "--port", "9999"])
        assert args.command == "edit"
        assert args.port == 9999


# ── Targeted coverage (line 86: mermaid edge without mechanism) ──


class TestUndoRedoCycleErrors:
    """Cover lines 165-166, 182-183, 211-212: undo/redo with cycle errors."""

    def test_redo_add_edge_cycle(self):
        """Redo an edge add that would now create a cycle (lines 211-212)."""
        session = DAGEditorSession()
        session.add_node("A")
        session.add_node("B")
        session.add_node("C")
        session.add_edge("A", "B")
        session.add_edge("B", "C")
        # Undo B→C — puts it in redo stack
        session.undo()
        assert session.dag.edge_count() == 1
        # Manually add C→A without clearing redo stack
        session.dag.add_edge(CausalEdge(source="C", target="A"))
        # Now redo B→C — creates cycle A→B→C→A, CycleError caught
        result = session.redo()
        assert result["status"] == "redone"

    def test_undo_remove_edge_cycle(self):
        """Undo edge removal when re-adding would create a cycle (lines 182-183)."""
        session = DAGEditorSession()
        session.add_node("A")
        session.add_node("B")
        session.add_edge("A", "B")
        # Remove A→B
        session.remove_edge("A", "B")
        assert session.dag.edge_count() == 0
        # Manually add B→A without clearing undo stack
        session.dag.add_edge(CausalEdge(source="B", target="A"))
        # Undo remove A→B — tries to re-add A→B but B→A exists → cycle
        result = session.undo()
        assert result["status"] == "undone"

    def test_undo_remove_node_with_cycle_edges(self):
        """Undo node removal when reconnecting edges would create cycle (lines 165-166)."""
        session = DAGEditorSession()
        session.add_node("A")
        session.add_node("B")
        session.add_node("C")
        session.add_edge("A", "B")
        session.add_edge("B", "C")
        # Remove node B (records entity B and connected edges A→B, B→C)
        session.remove_node("B")
        assert session.dag.node_count() == 2
        # Manually add C→A to create cycle potential
        session.dag.add_edge(CausalEdge(source="C", target="A"))
        # Undo remove_node B — re-adds B, tries edges. B→C would form cycle A→B→C→A
        result = session.undo()
        assert result["status"] == "undone"
        assert session.dag.node_count() == 3


class TestMermaidNoMechanism:
    """Cover line 86: edge without mechanism label in mermaid export."""

    def test_mermaid_edge_no_mechanism(self):
        dag = WorldDAG()
        dag.add_entity(Entity(entity_id="X", name="Var X"))
        dag.add_entity(Entity(entity_id="Y", name="Var Y"))
        dag.add_edge(CausalEdge(source="X", target="Y", mechanism=""))
        exporter = DAGExporter(dag)
        mermaid = exporter.to_mermaid()
        assert "X --> Y" in mermaid
        # No label pipe syntax
        assert "-->|" not in mermaid


# ── Targeted coverage (editor.py lines 130-131, 202) ────


class TestEditorAPIEdgeCases:
    """Cover editor.py lines 130-131 (invalid vtype) and 202 (remove edge missing)."""

    def _api(self):
        engine = OrxaqEngine()
        engine.load_credit_ontology()
        return DAGEditorAPI(engine)

    def test_add_node_invalid_variable_type(self):
        """Cover lines 130-131: invalid variable_type defaults to STATIC."""
        api = self._api()
        body = json.dumps(
            {
                "id": "vtype_test",
                "name": "VType Test",
                "variable_type": "INVALID_TYPE",
            }
        )
        resp, status = api.handle("/api/dag/nodes", "POST", body)
        assert status == 201

    def test_remove_edge_missing_params(self):
        """Cover line 202: remove edge with empty source/target."""
        api = self._api()
        body = json.dumps({"source": "", "target": ""})
        resp, status = api.handle("/api/dag/edges", "DELETE", body)
        assert status == 400
        assert "required" in resp["error"]
