"""Tests for undo/redo/checkpoint/history functionality."""

from __future__ import annotations

import tempfile
from pathlib import Path

import pytest
from fastapi.testclient import TestClient

from carve.server import CarveServer, create_http_app

# =============================================================================
# Sample code fixtures
# =============================================================================

SAMPLE_PYTHON = '''
class Calculator:
    """A simple calculator."""

    def add(self, a: int, b: int) -> int:
        """Add two numbers."""
        return a + b

    def subtract(self, a: int, b: int) -> int:
        """Subtract two numbers."""
        return a - b
'''


@pytest.fixture
def _empty_cwd(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    """Change to an empty directory so CarveServer() creates no store."""
    monkeypatch.chdir(tmp_path)
    monkeypatch.delenv("CARVE_WORKSPACE", raising=False)


@pytest.fixture
def temp_python_file() -> Path:
    """Create a temporary Python file with sample code."""
    with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
        f.write(SAMPLE_PYTHON)
        return Path(f.name)


@pytest.fixture
def server(temp_python_file: Path) -> CarveServer:
    """Create a CarveServer with the sample file loaded."""
    return CarveServer(file_path=temp_python_file)


# =============================================================================
# TestUndo
# =============================================================================


class TestUndo:
    """Tests for undo functionality."""

    def test_undo_insert(self, server: CarveServer) -> None:
        """Undo an insert removes the inserted node."""
        calc = server.search(pattern="Calculator")[0]
        snippet = "def multiply(self, a, b):\n    return a * b"
        result = server.insert(calc["node_id"], "end", snippet)
        assert "error" not in result

        # Verify it exists
        found = server.search(pattern="multiply")
        assert len(found) > 0

        # Undo
        undo_result = server.undo()
        assert undo_result["success"] is True
        assert undo_result["undone_count"] == 1

        # Verify it's gone
        found = server.search(pattern="multiply")
        assert not any("multiply" in r["node_id"] for r in found)

    def test_undo_update(self, server: CarveServer) -> None:
        """Undo an update restores the original source."""
        add_nodes = [
            n for n in server.search(pattern="add") if "add" in n["node_id"].split("::")[-1]
        ]
        add_id = add_nodes[0]["node_id"]

        # Update
        new_snippet = "def add(self, a: int, b: int) -> int:\n    return a + b + 1"
        server.update(add_id, new_snippet)

        # Verify update took effect
        updated = server.inspect(add_id)["source"]
        assert "a + b + 1" in updated

        # Undo
        undo_result = server.undo()
        assert undo_result["success"] is True

        # Verify original restored
        restored = server.inspect(add_id)["source"]
        assert "a + b + 1" not in restored
        assert "return a + b" in restored

    def test_undo_delete(self, server: CarveServer) -> None:
        """Undo a delete re-inserts the node."""
        sub_nodes = [
            n
            for n in server.search(pattern="subtract")
            if "subtract" in n["node_id"].split("::")[-1]
        ]
        sub_id = sub_nodes[0]["node_id"]

        # Delete
        result = server.delete(sub_id)
        assert result["success"] is True

        # Verify gone
        found = server.search(pattern="subtract")
        assert not any("subtract" in r["node_id"].split("::")[-1] for r in found)

        # Undo
        undo_result = server.undo()
        assert undo_result["success"] is True

        # Verify re-inserted
        found = server.search(pattern="subtract")
        assert any("subtract" in r["node_id"].split("::")[-1] for r in found)

    def test_undo_move(self, server: CarveServer) -> None:
        """Undo a move returns the node to its original position."""
        # Insert a second class to move into
        root = server.search(node_type="module")
        assert len(root) > 0
        root_id = root[0]["node_id"]
        result = server.insert(root_id, "end", "class Other:\n    pass")
        assert "error" not in result

        # Find add method and move it
        add_nodes = [
            n for n in server.search(pattern="add") if "add" in n["node_id"].split("::")[-1]
        ]
        add_id = add_nodes[0]["node_id"]

        other = server.search(pattern="Other")
        assert len(other) > 0
        other_id = other[0]["node_id"]

        # Clear undo stack from the insert above
        server._undo_entries.clear()

        move_result = server.move(add_id, other_id, "end")
        assert "error" not in move_result

        # Verify moved — add should now be under Other
        found_in_other = server.search(pattern="add", scope=other_id)
        assert any("add" in r["node_id"].split("::")[-1] for r in found_in_other)

        # Undo
        undo_result = server.undo()
        assert undo_result["success"] is True

        # Verify add is back in Calculator (search scoped to Calculator)
        calc = server.search(pattern="Calculator")[0]
        found_in_calc = server.search(pattern="add", scope=calc["node_id"])
        assert any("add" in r["node_id"].split("::")[-1] for r in found_in_calc)

    def test_undo_empty_stack(self, server: CarveServer) -> None:
        """Undo on empty stack returns an error."""
        result = server.undo()
        assert "error" in result
        assert "Nothing to undo" in result["error"]


# =============================================================================
# TestRedo
# =============================================================================


class TestRedo:
    """Tests for redo functionality."""

    def test_redo_after_undo(self, server: CarveServer) -> None:
        """Redo re-applies the undone operation."""
        calc = server.search(pattern="Calculator")[0]
        snippet = "def multiply(self, a, b):\n    return a * b"
        result = server.insert(calc["node_id"], "end", snippet)
        assert "error" not in result

        # Undo
        server.undo()
        found = server.search(pattern="multiply")
        assert not any("multiply" in r["node_id"] for r in found)

        # Redo
        redo_result = server.redo()
        assert redo_result["success"] is True

        # Verify node is back
        found = server.search(pattern="multiply")
        assert any("multiply" in r["node_id"] for r in found)

    def test_redo_empty_stack(self, server: CarveServer) -> None:
        """Redo on empty stack returns an error."""
        result = server.redo()
        assert "error" in result
        assert "Nothing to redo" in result["error"]

    def test_new_mutation_clears_redo(self, server: CarveServer) -> None:
        """A new mutation after undo clears the redo stack."""
        add_nodes = [
            n for n in server.search(pattern="add") if "add" in n["node_id"].split("::")[-1]
        ]
        add_id = add_nodes[0]["node_id"]

        # Update then undo
        server.update(add_id, "def add(self, a: int, b: int) -> int:\n    return a + b + 1")
        server.undo()

        # Verify redo is available
        history = server.history()
        assert len(history["redo"]) > 0

        # New mutation clears redo
        server.update(add_id, "def add(self, a: int, b: int) -> int:\n    return a + b + 2")
        history = server.history()
        assert len(history["redo"]) == 0


# =============================================================================
# TestCheckpoint
# =============================================================================


class TestCheckpoint:
    """Tests for checkpoint functionality."""

    def test_checkpoint_and_undo_to(self, server: CarveServer) -> None:
        """Create checkpoint, make changes, undo back to checkpoint."""
        # Create checkpoint
        cp_result = server.checkpoint("before_changes")
        assert cp_result["success"] is True

        # Make multiple changes
        add_nodes = [
            n for n in server.search(pattern="add") if "add" in n["node_id"].split("::")[-1]
        ]
        add_id = add_nodes[0]["node_id"]
        server.update(add_id, "def add(self, a: int, b: int) -> int:\n    return a + b + 1")

        calc = server.search(pattern="Calculator")[0]
        server.insert(calc["node_id"], "end", "def multiply(self, a, b):\n    return a * b")

        # Undo to checkpoint
        undo_result = server.undo(to="before_changes")
        assert undo_result["success"] is True
        assert undo_result["undone_count"] == 2

        # Verify both changes are undone
        found = server.search(pattern="multiply")
        assert not any("multiply" in r["node_id"] for r in found)

        restored = server.inspect(add_id)["source"]
        assert "a + b + 1" not in restored

    def test_checkpoint_not_found(self, server: CarveServer) -> None:
        """Undo to non-existent checkpoint returns error."""
        result = server.undo(to="nonexistent")
        assert "error" in result
        assert "not found" in result["error"].lower() or "Nothing to undo" in result["error"]

    def test_duplicate_checkpoint_name(self, server: CarveServer) -> None:
        """Duplicate checkpoint names are rejected."""
        server.checkpoint("mypoint")
        result = server.checkpoint("mypoint")
        assert "error" in result
        assert "already exists" in result["error"]


# =============================================================================
# TestHistory
# =============================================================================


class TestHistory:
    """Tests for history functionality."""

    def test_empty_history(self, server: CarveServer) -> None:
        """Empty server has empty history."""
        history = server.history()
        assert history["undo"] == []
        assert history["redo"] == []

    def test_history_after_mutations(self, server: CarveServer) -> None:
        """History shows mutations."""
        add_nodes = [
            n for n in server.search(pattern="add") if "add" in n["node_id"].split("::")[-1]
        ]
        add_id = add_nodes[0]["node_id"]
        server.update(add_id, "def add(self, a: int, b: int) -> int:\n    return a + b + 1")

        history = server.history()
        assert len(history["undo"]) == 1
        assert "update" in history["undo"][0]["description"]
        assert "timestamp" in history["undo"][0]

    def test_history_shows_checkpoints(self, server: CarveServer) -> None:
        """History includes checkpoint entries."""
        server.checkpoint("v1")

        add_nodes = [
            n for n in server.search(pattern="add") if "add" in n["node_id"].split("::")[-1]
        ]
        add_id = add_nodes[0]["node_id"]
        server.update(add_id, "def add(self, a: int, b: int) -> int:\n    return a + b + 1")

        history = server.history()
        assert len(history["undo"]) == 2
        assert history["undo"][0]["checkpoint"] == "v1"

    def test_history_after_undo(self, server: CarveServer) -> None:
        """Undo moves entries from undo to redo stack."""
        add_nodes = [
            n for n in server.search(pattern="add") if "add" in n["node_id"].split("::")[-1]
        ]
        add_id = add_nodes[0]["node_id"]
        server.update(add_id, "def add(self, a: int, b: int) -> int:\n    return a + b + 1")

        server.undo()

        history = server.history()
        assert len(history["undo"]) == 0
        assert len(history["redo"]) == 1


# =============================================================================
# TestUndoMultiFile
# =============================================================================


class TestUndoMultiFile:
    """Tests for undo with multi-file project stores."""

    @pytest.fixture
    def project_server(self, tmp_path: Path) -> CarveServer:
        """Create a project with multiple files."""
        proj = tmp_path / "proj"
        proj.mkdir()
        (proj / "main.py").write_text("def main():\n    pass\n")
        (proj / "utils.py").write_text("def helper():\n    pass\n")
        return CarveServer(file_path=proj)

    def test_undo_file_creation(self, project_server: CarveServer) -> None:
        """Undo creating a new file removes it."""
        dirs = project_server.search(node_type="directory")
        assert len(dirs) > 0
        dir_id = dirs[0]["node_id"]

        result = project_server.insert(
            dir_id,
            "end",
            "x = 1\n",
            file_name="new_module.py",
        )
        assert "error" not in result

        # Verify new file exists
        files = project_server.search(node_type="file")
        file_names = [f["node_id"] for f in files]
        assert any("new_module" in fn for fn in file_names)

        # Undo
        undo_result = project_server.undo()
        assert undo_result["success"] is True

        # Verify file is gone
        files = project_server.search(node_type="file")
        file_names = [f["node_id"] for f in files]
        assert not any("new_module" in fn for fn in file_names)

    def test_undo_cross_file_update(self, project_server: CarveServer) -> None:
        """Undo works across different files."""
        # Update in main.py
        main_nodes = project_server.search(pattern="main")
        main_fn = [n for n in main_nodes if n.get("type") == "function_definition"]
        assert len(main_fn) > 0
        main_id = main_fn[0]["node_id"]

        project_server.update(main_id, "def main():\n    print('hello')\n")

        # Update in utils.py
        helper_nodes = project_server.search(pattern="helper")
        helper_fn = [n for n in helper_nodes if n.get("type") == "function_definition"]
        assert len(helper_fn) > 0
        helper_id = helper_fn[0]["node_id"]

        project_server.update(helper_id, "def helper():\n    return 42\n")

        # Undo utils update
        project_server.undo()
        restored = project_server.inspect(helper_id)["source"]
        assert "return 42" not in restored

        # Undo main update
        project_server.undo()
        restored = project_server.inspect(main_id)["source"]
        assert "print" not in restored


# =============================================================================
# TestUndoHTTP
# =============================================================================


class TestUndoHTTP:
    """Tests for undo/redo via HTTP endpoints."""

    @pytest.fixture
    def http_client(self, server: CarveServer) -> TestClient:
        app = create_http_app(server)
        return TestClient(app)

    def test_undo_via_http(self, http_client: TestClient, server: CarveServer) -> None:
        """POST /tree/undo works."""
        # Make a change first
        calc = server.search(pattern="Calculator")[0]
        server.insert(calc["node_id"], "end", "def multiply(self, a, b):\n    return a * b")

        response = http_client.post("/tree/undo", json={})
        assert response.status_code == 200
        data = response.json()
        assert data["success"] is True

    def test_redo_via_http(self, http_client: TestClient, server: CarveServer) -> None:
        """POST /tree/redo works."""
        calc = server.search(pattern="Calculator")[0]
        server.insert(calc["node_id"], "end", "def multiply(self, a, b):\n    return a * b")
        server.undo()

        response = http_client.post("/tree/redo")
        assert response.status_code == 200
        data = response.json()
        assert data["success"] is True

    def test_checkpoint_via_http(self, http_client: TestClient) -> None:
        """POST /tree/checkpoint works."""
        response = http_client.post("/tree/checkpoint", json={"name": "v1"})
        assert response.status_code == 200
        data = response.json()
        assert data["success"] is True
        assert data["name"] == "v1"

    def test_history_via_http(self, http_client: TestClient, server: CarveServer) -> None:
        """GET /tree/history works."""
        # Make a change
        add_nodes = [
            n for n in server.search(pattern="add") if "add" in n["node_id"].split("::")[-1]
        ]
        add_id = add_nodes[0]["node_id"]
        server.update(add_id, "def add(self, a: int, b: int) -> int:\n    return a + b + 1")

        response = http_client.get("/tree/history")
        assert response.status_code == 200
        data = response.json()
        assert len(data["undo"]) == 1
        assert len(data["redo"]) == 0

    def test_undo_to_checkpoint_via_http(
        self,
        http_client: TestClient,
        server: CarveServer,
    ) -> None:
        """POST /tree/undo with 'to' parameter works."""
        server.checkpoint("start")

        add_nodes = [
            n for n in server.search(pattern="add") if "add" in n["node_id"].split("::")[-1]
        ]
        add_id = add_nodes[0]["node_id"]
        server.update(add_id, "def add(self, a: int, b: int) -> int:\n    return a + b + 1")

        response = http_client.post("/tree/undo", json={"to": "start"})
        assert response.status_code == 200
        data = response.json()
        assert data["success"] is True
        assert data["undone_count"] == 1


# =============================================================================
# TestUndoEdgeCases
# =============================================================================


class TestUndoEdgeCases:
    """Tests for edge cases in undo/redo."""

    def test_max_undo_depth(self, server: CarveServer) -> None:
        """Undo stack is bounded by _max_undo."""
        server._max_undo = 3

        add_nodes = [
            n for n in server.search(pattern="add") if "add" in n["node_id"].split("::")[-1]
        ]
        add_id = add_nodes[0]["node_id"]

        # Make 5 updates
        for i in range(5):
            server.update(add_id, f"def add(self, a, b):\n    return a + b + {i}")

        # Only 3 should be in the stack
        history = server.history()
        assert len(history["undo"]) == 3

    def test_undo_redo_undo(self, server: CarveServer) -> None:
        """Undo, redo, undo sequence works."""
        add_nodes = [
            n for n in server.search(pattern="add") if "add" in n["node_id"].split("::")[-1]
        ]
        add_id = add_nodes[0]["node_id"]

        server.update(add_id, "def add(self, a: int, b: int) -> int:\n    return a + b + 1")

        # Undo
        server.undo()
        src = server.inspect(add_id)["source"]
        assert "a + b + 1" not in src

        # Redo
        server.redo()
        src = server.inspect(add_id)["source"]
        assert "a + b + 1" in src

        # Undo again
        server.undo()
        src = server.inspect(add_id)["source"]
        assert "a + b + 1" not in src

    def test_read_only_blocks_undo(self, temp_python_file: Path) -> None:
        """Undo is blocked in read-only mode."""
        ro_server = CarveServer(file_path=temp_python_file, read_only=True)
        with pytest.raises(PermissionError, match="read-only"):
            ro_server.undo()

    def test_read_only_blocks_redo(self, temp_python_file: Path) -> None:
        """Redo is blocked in read-only mode."""
        ro_server = CarveServer(file_path=temp_python_file, read_only=True)
        with pytest.raises(PermissionError, match="read-only"):
            ro_server.redo()

    def test_checkpoint_does_not_clear_redo(self, server: CarveServer) -> None:
        """Creating a checkpoint preserves the redo stack."""
        add_nodes = [
            n for n in server.search(pattern="add") if "add" in n["node_id"].split("::")[-1]
        ]
        add_id = add_nodes[0]["node_id"]

        server.update(add_id, "def add(self, a: int, b: int) -> int:\n    return a + b + 1")
        server.undo()

        # Redo stack has 1 entry
        assert len(server.history()["redo"]) == 1

        # Checkpoint should not clear redo
        server.checkpoint("mid")
        assert len(server.history()["redo"]) == 1


# =============================================================================
# TestUndoCrossFileMove (Bug 1 regression)
# =============================================================================


MATH_UTILS_SOURCE = """\
def greet():
    print("hello")


def add(a, b):
    return a + b


def subtract(a, b):
    return a - b


def multiply(a, b):
    return a * b
"""

HELPERS_SOURCE = """\
def log(msg):
    print(msg)
"""


class TestUndoCrossFileMove:
    """Regression tests for cross-file move undo (Bug 1)."""

    @pytest.fixture
    def cross_file_server(self, tmp_path: Path) -> CarveServer:
        """Create a project with math_utils.py and helpers.py."""
        proj = tmp_path / "proj"
        proj.mkdir()
        (proj / "math_utils.py").write_text(MATH_UTILS_SOURCE)
        (proj / "helpers.py").write_text(HELPERS_SOURCE)
        return CarveServer(file_path=proj)

    def test_undo_cross_file_move(self, cross_file_server: CarveServer) -> None:
        """Move multiply from math_utils to helpers, then undo.

        Verify multiply returns to math_utils and source is clean.
        """
        server = cross_file_server

        # Find multiply and helpers module
        multiply_nodes = server.search(pattern="multiply")
        multiply_id = [n for n in multiply_nodes if n.get("type") == "function_definition"][0][
            "node_id"
        ]
        helpers_nodes = server.search(pattern="helpers")
        helpers_id = [n for n in helpers_nodes if n.get("type") in ("module", "file")][0]["node_id"]

        # Move multiply to helpers
        move_result = server.move(multiply_id, helpers_id, "end")
        assert "error" not in move_result

        # Verify multiply is now in helpers
        found_in_helpers = server.search(pattern="multiply", scope=helpers_id)
        assert any("multiply" in r["node_id"] for r in found_in_helpers)

        # Undo the move
        undo_result = server.undo()
        assert undo_result["success"] is True

        # Verify multiply is back in math_utils
        math_nodes = server.search(pattern="math_utils")
        math_id = [n for n in math_nodes if n.get("type") in ("module", "file")][0]["node_id"]
        found_in_math = server.search(pattern="multiply", scope=math_id)
        assert any("multiply" in r["node_id"] for r in found_in_math)

        # Verify multiply is NOT in helpers anymore
        found_in_helpers = server.search(pattern="multiply", scope=helpers_id)
        assert not any("multiply" in r["node_id"] for r in found_in_helpers)

    def test_redo_cross_file_move(self, cross_file_server: CarveServer) -> None:
        """Move multiply, undo, then redo. Verify multiply ends back in helpers."""
        server = cross_file_server

        multiply_nodes = server.search(pattern="multiply")
        multiply_id = [n for n in multiply_nodes if n.get("type") == "function_definition"][0][
            "node_id"
        ]
        helpers_nodes = server.search(pattern="helpers")
        helpers_id = [n for n in helpers_nodes if n.get("type") in ("module", "file")][0]["node_id"]

        # Move, undo, redo
        server.move(multiply_id, helpers_id, "end")
        server.undo()
        redo_result = server.redo()
        assert redo_result["success"] is True

        # Verify multiply is back in helpers
        found_in_helpers = server.search(pattern="multiply", scope=helpers_id)
        assert any("multiply" in r["node_id"] for r in found_in_helpers)

    def test_undo_cross_file_move_preserves_source(
        self,
        cross_file_server: CarveServer,
        tmp_path: Path,
    ) -> None:
        """After undo, verify math_utils.py on disk is valid with all 4 functions."""
        server = cross_file_server

        multiply_nodes = server.search(pattern="multiply")
        multiply_id = [n for n in multiply_nodes if n.get("type") == "function_definition"][0][
            "node_id"
        ]
        helpers_nodes = server.search(pattern="helpers")
        helpers_id = [n for n in helpers_nodes if n.get("type") in ("module", "file")][0]["node_id"]

        # Move then undo
        server.move(multiply_id, helpers_id, "end")
        undo_result = server.undo()
        assert undo_result["success"] is True

        # Read math_utils.py from disk
        math_utils_path = tmp_path / "proj" / "math_utils.py"
        content = math_utils_path.read_text()

        # All 4 functions should be present
        assert "def greet" in content
        assert "def add" in content
        assert "def subtract" in content
        assert "def multiply" in content

        # Source should be valid Python (no syntax errors)
        compile(content, str(math_utils_path), "exec")


# =============================================================================
# TestUndoDeleteSpacing (Bug 2 regression)
# =============================================================================


MULTI_FUNC_SOURCE = """\
def foo():
    pass


def bar():
    pass


def baz():
    pass
"""


class TestUndoDeleteSpacing:
    """Regression tests for undo-delete blank-line spacing (Bug 2)."""

    @pytest.fixture
    def func_server(self, tmp_path: Path) -> CarveServer:
        """Create a server with a multi-function file."""
        py_file = tmp_path / "funcs.py"
        py_file.write_text(MULTI_FUNC_SOURCE)
        return CarveServer(file_path=py_file)

    def test_undo_delete_preserves_blank_lines(self, func_server: CarveServer) -> None:
        """Delete a function, undo, verify blank lines between functions."""
        server = func_server

        # Find bar function
        bar_nodes = [
            n for n in server.search(pattern="bar") if n.get("type") == "function_definition"
        ]
        assert len(bar_nodes) > 0
        bar_id = bar_nodes[0]["node_id"]

        # Delete bar
        result = server.delete(bar_id)
        assert result["success"] is True

        # Undo
        undo_result = server.undo()
        assert undo_result["success"] is True

        # Verify bar is back
        bar_found = [
            n for n in server.search(pattern="bar") if n.get("type") == "function_definition"
        ]
        assert len(bar_found) > 0

        # Verify the source has proper blank-line separation
        # Read via inspect on the module/root to get the full source
        root_nodes = server.search(node_type="module")
        if not root_nodes:
            root_nodes = server.search(node_type="file")
        root_id = root_nodes[0]["node_id"]
        root_info = server.inspect(root_id)
        source = root_info["source"]

        # Functions should be separated by blank lines (no "pass\ndef" without gap)
        assert "pass\ndef" not in source, (
            f"Functions jammed together without blank lines:\n{source}"
        )
