# Validates REQ-p00060-D, REQ-p00060-E
# Validates REQ-o00062-A, REQ-o00062-B, REQ-o00062-C, REQ-o00062-D
# Validates REQ-o00062-E, REQ-o00062-F, REQ-o00062-G
# Validates REQ-o00063-A
# Validates REQ-d00065-A, REQ-d00065-B, REQ-d00065-C, REQ-d00065-D, REQ-d00065-E
"""Tests for MCP mutation tools.

Tests REQ-o00062: MCP Graph Mutation Tools
Tests REQ-o00063: File Mutation Tools
Tests REQ-d00065: Mutation Tool Delegation

All mutation tools must:
- Delegate to TraceGraph mutation methods (REQ-o00062-D, REQ-d00065-D)
- Return MutationEntry for audit (REQ-o00062-E)
- Require confirm=True for destructive operations (REQ-o00062-F)
"""

from pathlib import Path

import pytest

from elspais.graph import GraphNode, NodeKind
from elspais.graph.builder import TraceGraph
from elspais.graph.mutations import BrokenReference
from elspais.graph.relations import EdgeKind

# ─────────────────────────────────────────────────────────────────────────────
# Fixtures
# ─────────────────────────────────────────────────────────────────────────────


@pytest.fixture
def mutation_graph():
    """Create a TraceGraph with mutation support for testing."""
    graph = TraceGraph(repo_root=Path("/test/repo"))

    # Create PRD requirement with assertions
    prd_node = GraphNode(
        id="REQ-p00001",
        kind=NodeKind.REQUIREMENT,
        label="Platform Security",
    )
    prd_node._content = {
        "level": "PRD",
        "status": "Active",
        "hash": "abc12345",
        "body": "The platform shall be secure.",
    }

    # Add assertions
    assertion_a = GraphNode(
        id="REQ-p00001-A",
        kind=NodeKind.ASSERTION,
        label="SHALL encrypt all data at rest",
    )
    assertion_a._content = {"label": "A", "text": "SHALL encrypt all data at rest"}
    prd_node.link(assertion_a, EdgeKind.STRUCTURES)

    assertion_b = GraphNode(
        id="REQ-p00001-B",
        kind=NodeKind.ASSERTION,
        label="SHALL use TLS 1.3 for transit",
    )
    assertion_b._content = {"label": "B", "text": "SHALL use TLS 1.3 for transit"}
    prd_node.link(assertion_b, EdgeKind.STRUCTURES)

    # Create OPS requirement that implements PRD
    ops_node = GraphNode(
        id="REQ-o00001",
        kind=NodeKind.REQUIREMENT,
        label="Database Encryption",
    )
    ops_node._content = {
        "level": "OPS",
        "status": "Active",
        "hash": "def67890",
        "body": "Database encryption operations.",
    }

    # Link PRD -> OPS
    prd_node.link(ops_node, EdgeKind.IMPLEMENTS)

    # Build graph
    graph._roots = [prd_node]
    graph._index = {
        "REQ-p00001": prd_node,
        "REQ-p00001-A": assertion_a,
        "REQ-p00001-B": assertion_b,
        "REQ-o00001": ops_node,
    }

    return graph


# ─────────────────────────────────────────────────────────────────────────────
# Test: Node Mutations - REQ-o00062-A
# ─────────────────────────────────────────────────────────────────────────────


class TestMutateRenameNode:
    """Tests for mutate_rename_node() tool."""

    def test_REQ_d00065_A_delegates_to_graph_rename_node(self, mutation_graph):
        """REQ-d00065-A: Delegates to graph.rename_node()."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import _mutate_rename_node

        result = _mutate_rename_node(mutation_graph, "REQ-o00001", "REQ-o00099")

        assert result["success"] is True
        # Verify node was renamed in graph
        assert mutation_graph.find_by_id("REQ-o00099") is not None
        assert mutation_graph.find_by_id("REQ-o00001") is None

    def test_REQ_o00062_E_returns_mutation_entry(self, mutation_graph):
        """REQ-o00062-E: Returns MutationEntry for audit."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import _mutate_rename_node

        result = _mutate_rename_node(mutation_graph, "REQ-o00001", "REQ-o00099")

        assert "mutation" in result
        mutation = result["mutation"]
        assert mutation["operation"] == "rename_node"
        assert mutation["target_id"] == "REQ-o00001"
        assert "before_state" in mutation
        assert "after_state" in mutation

    # Implements: REQ-o00062-A
    def test_rename_nonexistent_node_returns_error(self, mutation_graph):
        """Renaming non-existent node returns error."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import _mutate_rename_node

        result = _mutate_rename_node(mutation_graph, "REQ-NONEXISTENT", "REQ-NEW")

        assert result["success"] is False
        assert "error" in result


class TestMutateUpdateTitle:
    """Tests for mutate_update_title() tool."""

    # Implements: REQ-o00062-A
    def test_delegates_to_graph_update_title(self, mutation_graph):
        """Delegates to graph.update_title()."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import _mutate_update_title

        result = _mutate_update_title(mutation_graph, "REQ-p00001", "Updated Platform Security")

        assert result["success"] is True
        node = mutation_graph.find_by_id("REQ-p00001")
        assert node.get_label() == "Updated Platform Security"

    # Implements: REQ-o00062-E
    def test_returns_mutation_entry(self, mutation_graph):
        """Returns MutationEntry for audit."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import _mutate_update_title

        result = _mutate_update_title(mutation_graph, "REQ-p00001", "New Title")

        assert "mutation" in result
        mutation = result["mutation"]
        assert mutation["operation"] == "update_title"


class TestMutateChangeStatus:
    """Tests for mutate_change_status() tool."""

    # Implements: REQ-o00062-A
    def test_delegates_to_graph_change_status(self, mutation_graph):
        """Delegates to graph.change_status()."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import _mutate_change_status

        result = _mutate_change_status(mutation_graph, "REQ-p00001", "Deprecated")

        assert result["success"] is True
        node = mutation_graph.find_by_id("REQ-p00001")
        assert node.status == "Deprecated"

    # Implements: REQ-o00062-E
    def test_returns_mutation_entry(self, mutation_graph):
        """Returns MutationEntry for audit."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import _mutate_change_status

        result = _mutate_change_status(mutation_graph, "REQ-p00001", "Draft")

        assert "mutation" in result
        assert result["mutation"]["operation"] == "change_status"


class TestMutateAddRequirement:
    """Tests for mutate_add_requirement() tool."""

    def test_REQ_d00065_B_delegates_to_graph_add_requirement(self, mutation_graph):
        """REQ-d00065-B: Delegates to graph.add_requirement()."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import _mutate_add_requirement

        result = _mutate_add_requirement(
            mutation_graph,
            req_id="REQ-d00001",
            title="New DEV Requirement",
            level="DEV",
            status="Draft",
            parent_id="REQ-o00001",
            edge_kind="IMPLEMENTS",
        )

        assert result["success"] is True
        node = mutation_graph.find_by_id("REQ-d00001")
        assert node is not None
        assert node.get_label() == "New DEV Requirement"

    # Implements: REQ-o00062-E
    def test_returns_mutation_entry(self, mutation_graph):
        """Returns MutationEntry for audit."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import _mutate_add_requirement

        result = _mutate_add_requirement(
            mutation_graph,
            req_id="REQ-d00002",
            title="Another Requirement",
            level="DEV",
            status="Draft",
        )

        assert "mutation" in result
        assert result["mutation"]["operation"] == "add_requirement"


class TestMutateDeleteRequirement:
    """Tests for mutate_delete_requirement() tool."""

    def test_REQ_o00062_F_requires_confirm_true(self, mutation_graph):
        """REQ-o00062-F: Requires confirm=True for destructive operations."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import _mutate_delete_requirement

        # Without confirm=True, should NOT delete
        result = _mutate_delete_requirement(mutation_graph, "REQ-o00001", confirm=False)

        assert result["success"] is False
        assert "confirm" in result["error"].lower()
        # Node should still exist
        assert mutation_graph.find_by_id("REQ-o00001") is not None

    def test_REQ_d00065_C_deletes_when_confirmed(self, mutation_graph):
        """REQ-d00065-C: Calls graph.delete_requirement() only if confirm=True."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import _mutate_delete_requirement

        result = _mutate_delete_requirement(mutation_graph, "REQ-o00001", confirm=True)

        assert result["success"] is True
        assert mutation_graph.find_by_id("REQ-o00001") is None

    # Implements: REQ-o00062-E
    def test_returns_mutation_entry(self, mutation_graph):
        """Returns MutationEntry for audit."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import _mutate_delete_requirement

        result = _mutate_delete_requirement(mutation_graph, "REQ-o00001", confirm=True)

        assert "mutation" in result
        assert result["mutation"]["operation"] == "delete_requirement"


# ─────────────────────────────────────────────────────────────────────────────
# Test: Assertion Mutations - REQ-o00062-B
# ─────────────────────────────────────────────────────────────────────────────


class TestMutateAddAssertion:
    """Tests for mutate_add_assertion() tool."""

    # Implements: REQ-o00062-B
    def test_delegates_to_graph_add_assertion(self, mutation_graph):
        """Delegates to graph.add_assertion()."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import _mutate_add_assertion

        result = _mutate_add_assertion(
            mutation_graph,
            req_id="REQ-p00001",
            label="C",
            text="SHALL log all access attempts",
        )

        assert result["success"] is True
        assertion = mutation_graph.find_by_id("REQ-p00001-C")
        assert assertion is not None
        assert assertion.get_label() == "SHALL log all access attempts"

    # Implements: REQ-o00062-E
    def test_returns_mutation_entry(self, mutation_graph):
        """Returns MutationEntry for audit."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import _mutate_add_assertion

        result = _mutate_add_assertion(mutation_graph, "REQ-p00001", "C", "New assertion text")

        assert "mutation" in result
        assert result["mutation"]["operation"] == "add_assertion"


class TestMutateUpdateAssertion:
    """Tests for mutate_update_assertion() tool."""

    # Implements: REQ-o00062-B
    def test_delegates_to_graph_update_assertion(self, mutation_graph):
        """Delegates to graph.update_assertion()."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import _mutate_update_assertion

        result = _mutate_update_assertion(
            mutation_graph, "REQ-p00001-A", "SHALL encrypt ALL data at rest using AES-256"
        )

        assert result["success"] is True
        assertion = mutation_graph.find_by_id("REQ-p00001-A")
        assert "AES-256" in assertion.get_label()

    # Implements: REQ-o00062-E
    def test_returns_mutation_entry(self, mutation_graph):
        """Returns MutationEntry for audit."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import _mutate_update_assertion

        result = _mutate_update_assertion(mutation_graph, "REQ-p00001-A", "Updated text")

        assert "mutation" in result
        assert result["mutation"]["operation"] == "update_assertion"


class TestMutateDeleteAssertion:
    """Tests for mutate_delete_assertion() tool."""

    # Implements: REQ-o00062-F
    def test_requires_confirm_true(self, mutation_graph):
        """Requires confirm=True for destructive operations."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import _mutate_delete_assertion

        result = _mutate_delete_assertion(mutation_graph, "REQ-p00001-A", confirm=False)

        assert result["success"] is False
        assert mutation_graph.find_by_id("REQ-p00001-A") is not None

    # Implements: REQ-o00062-B
    def test_deletes_when_confirmed(self, mutation_graph):
        """Deletes assertion when confirmed."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import _mutate_delete_assertion

        # Store original assertion ID before deletion
        original_text = mutation_graph.find_by_id("REQ-p00001-A").get_label()

        result = _mutate_delete_assertion(mutation_graph, "REQ-p00001-A", confirm=True)

        assert result["success"] is True
        # After deletion with compact=True, REQ-p00001-A now has what was REQ-p00001-B
        # Check that the original assertion text is no longer at A
        if mutation_graph.find_by_id("REQ-p00001-A"):
            # A still exists but has different content (was B, now compacted to A)
            assert mutation_graph.find_by_id("REQ-p00001-A").get_label() != original_text

    # Implements: REQ-o00062-E
    def test_returns_mutation_entry(self, mutation_graph):
        """Returns MutationEntry for audit."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import _mutate_delete_assertion

        result = _mutate_delete_assertion(mutation_graph, "REQ-p00001-A", confirm=True)

        assert "mutation" in result
        assert result["mutation"]["operation"] == "delete_assertion"


class TestMutateRenameAssertion:
    """Tests for mutate_rename_assertion() tool."""

    # Implements: REQ-o00062-B
    def test_delegates_to_graph_rename_assertion(self, mutation_graph):
        """Delegates to graph.rename_assertion()."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import _mutate_rename_assertion

        result = _mutate_rename_assertion(mutation_graph, "REQ-p00001-A", "X")

        assert result["success"] is True
        assert mutation_graph.find_by_id("REQ-p00001-X") is not None
        assert mutation_graph.find_by_id("REQ-p00001-A") is None

    # Implements: REQ-o00062-E
    def test_returns_mutation_entry(self, mutation_graph):
        """Returns MutationEntry for audit."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import _mutate_rename_assertion

        result = _mutate_rename_assertion(mutation_graph, "REQ-p00001-A", "X")

        assert "mutation" in result
        assert result["mutation"]["operation"] == "rename_assertion"


# ─────────────────────────────────────────────────────────────────────────────
# Test: Edge Mutations - REQ-o00062-C
# ─────────────────────────────────────────────────────────────────────────────


class TestMutateAddEdge:
    """Tests for mutate_add_edge() tool."""

    # Implements: REQ-o00062-C
    def test_delegates_to_graph_add_edge(self, mutation_graph):
        """Delegates to graph.add_edge()."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import _mutate_add_edge

        # Add a new DEV requirement first
        dev_node = GraphNode(
            id="REQ-d00001",
            kind=NodeKind.REQUIREMENT,
            label="DEV Requirement",
        )
        dev_node._content = {"level": "DEV", "status": "Draft"}
        mutation_graph._index["REQ-d00001"] = dev_node

        result = _mutate_add_edge(
            mutation_graph,
            source_id="REQ-d00001",
            target_id="REQ-o00001",
            edge_kind="IMPLEMENTS",
        )

        assert result["success"] is True

    # Implements: REQ-o00062-E
    def test_returns_mutation_entry(self, mutation_graph):
        """Returns MutationEntry for audit."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import _mutate_add_edge

        # Setup node
        dev_node = GraphNode(
            id="REQ-d00002",
            kind=NodeKind.REQUIREMENT,
            label="Another DEV",
        )
        dev_node._content = {"level": "DEV", "status": "Draft"}
        mutation_graph._index["REQ-d00002"] = dev_node

        result = _mutate_add_edge(mutation_graph, "REQ-d00002", "REQ-o00001", "IMPLEMENTS")

        assert "mutation" in result
        assert result["mutation"]["operation"] == "add_edge"

    # Implements: REQ-o00062-C
    def test_normalizes_full_assertion_ids_to_bare_labels(self, mutation_graph):
        """Full assertion IDs like REQ-o00001-A are normalized to bare labels."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import _mutate_add_edge

        # Setup: add assertion to target so the ID is valid
        target = mutation_graph.find_by_id("REQ-o00001")
        target.set_field("assertions", {"A": "Test assertion"})

        dev_node = GraphNode(
            id="REQ-d00003",
            kind=NodeKind.REQUIREMENT,
            label="DEV with assertion ref",
        )
        dev_node._content = {"level": "DEV", "status": "Draft"}
        mutation_graph._index["REQ-d00003"] = dev_node

        # Config matching the default REQ-{type}{component} pattern
        config = {
            "project": {"namespace": "REQ"},
            "id-patterns": {
                "canonical": "{namespace}-{type}{component}",
                "types": {
                    "p": {"level": 1},
                    "o": {"level": 2},
                    "d": {"level": 3},
                },
                "component": {"style": "numeric", "digits": 5, "leading_zeros": True},
                "assertions": {"label_style": "uppercase"},
            },
        }

        result = _mutate_add_edge(
            mutation_graph,
            source_id="REQ-d00003",
            target_id="REQ-o00001",
            edge_kind="IMPLEMENTS",
            assertion_targets=["REQ-o00001-A"],
            config=config,
        )

        assert result["success"] is True
        # Verify the edge has bare label "A", not the full "REQ-o00001-A"
        edges = [
            e
            for e in target.iter_outgoing_edges()
            if e.kind == EdgeKind.IMPLEMENTS and e.target.id == "REQ-d00003"
        ]
        assert len(edges) == 1
        assert edges[0].assertion_targets == ["A"]


class TestMutateChangeEdgeKind:
    """Tests for mutate_change_edge_kind() tool."""

    # Implements: REQ-o00062-C
    def test_delegates_to_graph_change_edge_kind(self, mutation_graph):
        """Delegates to graph.change_edge_kind()."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import _mutate_change_edge_kind

        # Change IMPLEMENTS to REFINES
        result = _mutate_change_edge_kind(mutation_graph, "REQ-o00001", "REQ-p00001", "REFINES")

        assert result["success"] is True

    # Implements: REQ-o00062-E
    def test_returns_mutation_entry(self, mutation_graph):
        """Returns MutationEntry for audit."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import _mutate_change_edge_kind

        result = _mutate_change_edge_kind(mutation_graph, "REQ-o00001", "REQ-p00001", "REFINES")

        assert "mutation" in result
        assert result["mutation"]["operation"] == "change_edge_kind"


class TestMutateChangeEdgeTargets:
    """Tests for mutate_change_edge_targets() tool.

    Validates REQ-o00062-C: Edge mutation tools include change_targets action.
    """

    def test_REQ_o00062_C_delegates_to_graph_change_edge_targets(self, mutation_graph):
        """REQ-o00062-C: Delegates to graph.change_edge_targets()."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import _mutate_change_edge_targets

        # REQ-o00001 implements REQ-p00001 (edge exists from fixture)
        # Change assertion targets to just ["A"]
        result = _mutate_change_edge_targets(mutation_graph, "REQ-o00001", "REQ-p00001", ["A"])

        assert result["success"] is True
        # Verify the edge's assertion_targets is ["A"]
        parent = mutation_graph.find_by_id("REQ-p00001")
        edges = [
            e
            for e in parent.iter_outgoing_edges()
            if e.kind == EdgeKind.IMPLEMENTS and e.target.id == "REQ-o00001"
        ]
        assert len(edges) == 1
        assert edges[0].assertion_targets == ["A"]

    def test_REQ_o00062_E_returns_mutation_entry(self, mutation_graph):
        """REQ-o00062-E: Returns MutationEntry for audit."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import _mutate_change_edge_targets

        result = _mutate_change_edge_targets(mutation_graph, "REQ-o00001", "REQ-p00001", ["B"])

        assert "mutation" in result
        mutation = result["mutation"]
        assert mutation["operation"] == "change_edge_targets"

    # Implements: REQ-o00062-C
    def test_change_edge_targets_error_no_edge(self, mutation_graph):
        """Returns error when no edge exists between nodes."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import _mutate_change_edge_targets

        # REQ-p00001-A is an assertion, no edge from it to REQ-o00001
        result = _mutate_change_edge_targets(mutation_graph, "REQ-p00001-A", "REQ-o00001", ["A"])

        assert result["success"] is False
        assert "error" in result


class TestMutateDeleteEdge:
    """Tests for mutate_delete_edge() tool."""

    # Implements: REQ-o00062-F
    def test_requires_confirm_true(self, mutation_graph):
        """Requires confirm=True for destructive operations."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import _mutate_delete_edge

        result = _mutate_delete_edge(mutation_graph, "REQ-o00001", "REQ-p00001", confirm=False)

        assert result["success"] is False

    # Implements: REQ-o00062-C
    def test_deletes_when_confirmed(self, mutation_graph):
        """Deletes edge when confirmed."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import _mutate_delete_edge

        result = _mutate_delete_edge(mutation_graph, "REQ-o00001", "REQ-p00001", confirm=True)

        assert result["success"] is True

    # Implements: REQ-o00062-E
    def test_returns_mutation_entry(self, mutation_graph):
        """Returns MutationEntry for audit."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import _mutate_delete_edge

        result = _mutate_delete_edge(mutation_graph, "REQ-o00001", "REQ-p00001", confirm=True)

        assert "mutation" in result
        assert result["mutation"]["operation"] == "delete_edge"


class TestMutateFixBrokenReference:
    """Tests for mutate_fix_broken_reference() tool."""

    # Implements: REQ-o00062-C
    def test_delegates_to_graph_fix_broken_reference(self, mutation_graph):
        """Delegates to graph.fix_broken_reference()."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import _mutate_fix_broken_reference

        # Create a broken reference scenario first
        mutation_graph._broken_references.append(
            BrokenReference(
                source_id="REQ-o00001",
                target_id="REQ-MISSING",
                edge_kind=EdgeKind.IMPLEMENTS,
            )
        )

        result = _mutate_fix_broken_reference(
            mutation_graph, "REQ-o00001", "REQ-MISSING", "REQ-p00001"
        )

        assert result["success"] is True

    # Implements: REQ-o00062-E
    def test_returns_mutation_entry(self, mutation_graph):
        """Returns MutationEntry for audit."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import _mutate_fix_broken_reference

        mutation_graph._broken_references.append(
            BrokenReference(
                source_id="REQ-o00001",
                target_id="REQ-BAD",
                edge_kind=EdgeKind.IMPLEMENTS,
            )
        )

        result = _mutate_fix_broken_reference(mutation_graph, "REQ-o00001", "REQ-BAD", "REQ-p00001")

        assert "mutation" in result
        assert result["mutation"]["operation"] == "fix_broken_reference"


# ─────────────────────────────────────────────────────────────────────────────
# Test: File Mutations - REQ-o00063
# ─────────────────────────────────────────────────────────────────────────────


class TestMutateMoveNodeToFile:
    """Tests for mutate_move_node_to_file() tool.

    Validates REQ-o00063: File mutation tools include move_node_to_file action.
    """

    @pytest.fixture
    def file_graph(self, mutation_graph):
        """Extend mutation_graph with FILE nodes and CONTAINS wiring."""
        graph = mutation_graph

        file1 = GraphNode("file:spec/main.md", NodeKind.FILE, label="main.md")
        file1.set_field("relative_path", "spec/main.md")
        graph._index["file:spec/main.md"] = file1
        graph._roots.append(file1)

        req = graph.find_by_id("REQ-p00001")
        edge = file1.link(req, EdgeKind.CONTAINS)
        edge.metadata["render_order"] = 0.0

        file2 = GraphNode("file:spec/other.md", NodeKind.FILE, label="other.md")
        file2.set_field("relative_path", "spec/other.md")
        graph._index["file:spec/other.md"] = file2
        graph._roots.append(file2)

        return graph

    def test_REQ_o00063_A_delegates_to_graph_move_node_to_file(self, file_graph):
        """REQ-o00063-A: Delegates to graph.move_node_to_file()."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import _mutate_move_node_to_file

        result = _mutate_move_node_to_file(file_graph, "REQ-p00001", "file:spec/other.md")

        assert result["success"] is True
        assert "mutation" in result
        # Verify req is now under the target file
        req = file_graph.find_by_id("REQ-p00001")
        assert req.file_node().id == "file:spec/other.md"

    def test_REQ_o00063_A_move_error_no_file_parent(self, file_graph):
        """REQ-o00063-A: Moving a node without a FILE parent returns error."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import _mutate_move_node_to_file

        # Create a standalone node with no FILE ancestor
        orphan = GraphNode("REQ-d00099", NodeKind.REQUIREMENT, label="Orphan")
        orphan._content = {"level": "DEV", "status": "Draft"}
        file_graph._index["REQ-d00099"] = orphan

        result = _mutate_move_node_to_file(file_graph, "REQ-d00099", "file:spec/other.md")

        assert result["success"] is False
        assert "error" in result


class TestMutateRenameFile:
    """Tests for mutate_rename_file() tool.

    Validates REQ-o00063: File mutation tools include rename_file action.
    """

    @pytest.fixture
    def file_graph(self, mutation_graph):
        """Extend mutation_graph with a FILE node."""
        graph = mutation_graph

        file1 = GraphNode("file:spec/main.md", NodeKind.FILE, label="main.md")
        file1.set_field("relative_path", "spec/main.md")
        graph._index["file:spec/main.md"] = file1
        graph._roots.append(file1)

        req = graph.find_by_id("REQ-p00001")
        edge = file1.link(req, EdgeKind.CONTAINS)
        edge.metadata["render_order"] = 0.0

        return graph

    def test_REQ_o00063_A_delegates_to_graph_rename_file(self, file_graph):
        """REQ-o00063-A: Delegates to graph.rename_file()."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import _mutate_rename_file

        result = _mutate_rename_file(file_graph, "file:spec/main.md", "spec/renamed.md")

        assert result["success"] is True
        assert "mutation" in result
        # Verify the new ID is findable
        assert file_graph.find_by_id("file:spec/renamed.md") is not None
        # Old ID should be gone
        assert file_graph.find_by_id("file:spec/main.md") is None

    def test_REQ_o00063_A_rename_error_not_found(self, file_graph):
        """REQ-o00063-A: Renaming a nonexistent file returns error."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import _mutate_rename_file

        result = _mutate_rename_file(file_graph, "file:spec/nonexistent.md", "spec/new.md")

        assert result["success"] is False
        assert "error" in result


# ─────────────────────────────────────────────────────────────────────────────
# Test: Undo Operations - REQ-o00062-G
# ─────────────────────────────────────────────────────────────────────────────


class TestUndoLastMutation:
    """Tests for undo_last_mutation() tool."""

    def test_REQ_o00062_G_delegates_to_graph_undo_last(self, mutation_graph):
        """REQ-o00062-G: Reverses mutations using graph.undo_last()."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import _mutate_update_title, _undo_last_mutation

        # Make a mutation
        _mutate_update_title(mutation_graph, "REQ-p00001", "Changed Title")
        assert mutation_graph.find_by_id("REQ-p00001").get_label() == "Changed Title"

        # Undo it
        result = _undo_last_mutation(mutation_graph)

        assert result["success"] is True
        assert mutation_graph.find_by_id("REQ-p00001").get_label() == "Platform Security"

    # Implements: REQ-o00062-G
    def test_returns_undone_mutation_entry(self, mutation_graph):
        """Returns the mutation that was undone."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import _mutate_update_title, _undo_last_mutation

        _mutate_update_title(mutation_graph, "REQ-p00001", "New Title")
        result = _undo_last_mutation(mutation_graph)

        assert "mutation" in result
        assert result["mutation"]["operation"] == "update_title"


class TestUndoToMutation:
    """Tests for undo_to_mutation() tool."""

    def test_REQ_o00062_G_delegates_to_graph_undo_to(self, mutation_graph):
        """REQ-o00062-G: Reverses mutations using graph.undo_to()."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import (
            _mutate_change_status,
            _mutate_update_title,
            _undo_to_mutation,
        )

        # Make multiple mutations
        _mutate_update_title(mutation_graph, "REQ-p00001", "Title 1")
        result2 = _mutate_update_title(mutation_graph, "REQ-p00001", "Title 2")
        mutation_id = result2["mutation"]["id"]  # We'll undo back to (and including) this
        _mutate_change_status(mutation_graph, "REQ-p00001", "Deprecated")

        # Undo back to (and including) second mutation
        # This undoes mutations 3 and 2, leaving mutation 1
        result = _undo_to_mutation(mutation_graph, mutation_id)

        assert result["success"] is True
        assert result["mutations_undone"] == 2
        # Should have the state after first mutation (Title 1, Active)
        node = mutation_graph.find_by_id("REQ-p00001")
        assert node.get_label() == "Title 1"
        assert node.status == "Active"


class TestGetMutationLog:
    """Tests for get_mutation_log() tool."""

    # Implements: REQ-o00062-E
    def test_returns_mutation_history(self, mutation_graph):
        """Returns list of mutation entries."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import (
            _get_mutation_log,
            _mutate_change_status,
            _mutate_update_title,
        )

        _mutate_update_title(mutation_graph, "REQ-p00001", "Title Change")
        _mutate_change_status(mutation_graph, "REQ-p00001", "Draft")

        result = _get_mutation_log(mutation_graph)

        assert "mutations" in result
        assert len(result["mutations"]) == 2

    # Implements: REQ-o00062-E
    def test_respects_limit_parameter(self, mutation_graph):
        """Respects limit parameter."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import (
            _get_mutation_log,
            _mutate_change_status,
            _mutate_update_title,
        )

        _mutate_update_title(mutation_graph, "REQ-p00001", "Title 1")
        _mutate_update_title(mutation_graph, "REQ-p00001", "Title 2")
        _mutate_change_status(mutation_graph, "REQ-p00001", "Draft")

        result = _get_mutation_log(mutation_graph, limit=2)

        assert len(result["mutations"]) == 2


# ─────────────────────────────────────────────────────────────────────────────
# Test: Inspection Tools
# ─────────────────────────────────────────────────────────────────────────────


class TestGetOrphanedNodes:
    """Tests for get_orphaned_nodes() tool."""

    # Implements: REQ-o00060-A
    def test_returns_orphaned_node_list(self, mutation_graph):
        """Returns list of orphaned nodes."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import _get_orphaned_nodes

        # Delete edge to create orphan
        mutation_graph._orphaned_ids.add("REQ-o00001")

        result = _get_orphaned_nodes(mutation_graph)

        assert "orphans" in result
        assert "REQ-o00001" in [o["id"] for o in result["orphans"]]


class TestGetBrokenReferences:
    """Tests for get_broken_references() tool."""

    # Implements: REQ-o00060-A
    def test_returns_broken_reference_list(self, mutation_graph):
        """Returns list of broken references."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import _get_broken_references

        mutation_graph._broken_references.append(
            BrokenReference(
                source_id="REQ-o00001",
                target_id="REQ-MISSING",
                edge_kind=EdgeKind.IMPLEMENTS,
            )
        )

        result = _get_broken_references(mutation_graph)

        assert "broken_references" in result
        assert len(result["broken_references"]) == 1
        assert result["broken_references"][0]["source_id"] == "REQ-o00001"
        assert result["broken_references"][0]["target_id"] == "REQ-MISSING"


# ─────────────────────────────────────────────────────────────────────────────
# Test: Serialization - REQ-d00064
# ─────────────────────────────────────────────────────────────────────────────


class TestSerializeMutationEntry:
    """Tests for serialize_mutation_entry() function."""

    # Implements: REQ-d00064-B
    def test_serializes_all_fields(self, mutation_graph):
        """Serializes all MutationEntry fields."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import _mutate_update_title

        result = _mutate_update_title(mutation_graph, "REQ-p00001", "New Title")

        mutation = result["mutation"]
        assert "id" in mutation
        assert "operation" in mutation
        assert "target_id" in mutation
        assert "before_state" in mutation
        assert "after_state" in mutation
        assert "timestamp" in mutation

    # Implements: REQ-d00064-B
    def test_handles_affects_hash_flag(self, mutation_graph):
        """Includes affects_hash flag for assertion mutations."""
        pytest.importorskip("mcp")
        from elspais.mcp.server import _mutate_update_assertion

        result = _mutate_update_assertion(mutation_graph, "REQ-p00001-A", "New assertion text")

        mutation = result["mutation"]
        assert "affects_hash" in mutation
        assert mutation["affects_hash"] is True
