"""Tests for the Carve server module."""

from __future__ import annotations

import tempfile
from pathlib import Path

import pytest
from fastapi.testclient import TestClient

from carve.server import AGENT_GUIDE, 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
'''

SAMPLE_PYTHON_SIMPLE = '''
def greet(name: str) -> str:
    """Greet someone."""
    return f"Hello, {name}!"
'''


@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 loaded_server(temp_python_file: Path) -> CarveServer:
    """Create a CarveServer with the sample file loaded."""
    server = CarveServer(file_path=temp_python_file)
    return server


# =============================================================================
# CarveServer tests
# =============================================================================


class TestCarveServer:
    """Tests for CarveServer class."""

    @pytest.mark.usefixtures("_empty_cwd")
    def test_init_empty(self) -> None:
        """Test creating an empty server."""
        server = CarveServer()
        assert server.store is None

    def test_init_with_file(self, temp_python_file: Path) -> None:
        """Test creating a server with a file."""
        server = CarveServer(file_path=temp_python_file)
        assert server.store is not None
        assert server.store.language == "python"

    @pytest.mark.usefixtures("_empty_cwd")
    def test_load_source(self) -> None:
        """Test loading source directly."""
        server = CarveServer()
        result = server.load_source(SAMPLE_PYTHON, "python")
        assert result["success"] is True
        assert result["language"] == "python"

    @pytest.mark.usefixtures("_empty_cwd")
    def test_status_empty(self) -> None:
        """Test status on empty server."""
        server = CarveServer()
        status = server.status()
        assert status["loaded"] is False

    def test_status_loaded(self, loaded_server: CarveServer) -> None:
        """Test status on loaded server."""
        status = loaded_server.status()
        assert status["loaded"] is True
        assert status["language"] == "python"
        assert status["node_count"] > 0

    def test_subtree(self, loaded_server: CarveServer) -> None:
        """Test subtree navigation."""
        # Find the Calculator class
        search_result = loaded_server.search(pattern="Calculator")
        assert len(search_result) > 0

        calc_id = search_result[0]["node_id"]
        result = loaded_server.subtree(calc_id, depth=1)
        assert "error" not in result
        assert result["node_id"] == calc_id
        assert "children" in result

    def test_subtree_not_found(self, loaded_server: CarveServer) -> None:
        """Test subtree with invalid node."""
        result = loaded_server.subtree("nonexistent")
        assert "error" in result

    def test_ancestors(self, loaded_server: CarveServer) -> None:
        """Test ancestors navigation."""
        # Find the add method
        search_result = loaded_server.search(pattern="add")
        assert len(search_result) > 0

        add_id = search_result[0]["node_id"]
        result = loaded_server.ancestors(add_id)
        assert len(result) > 0
        assert result[0]["node_id"] == add_id

    def test_inspect(self, loaded_server: CarveServer) -> None:
        """Test inspect a node."""
        # Find the Calculator class
        search_result = loaded_server.search(pattern="Calculator")
        calc_id = search_result[0]["node_id"]

        result = loaded_server.inspect(calc_id)
        assert "error" not in result
        assert result["node_id"] == calc_id
        assert "source" in result

    def test_inspect_with_metadata(self, loaded_server: CarveServer) -> None:
        """Test inspect with metadata."""
        search_result = loaded_server.search(pattern="Calculator")
        calc_id = search_result[0]["node_id"]

        result = loaded_server.inspect(calc_id, include_metadata=True)
        assert "metadata" in result

    def test_search_by_pattern(self, loaded_server: CarveServer) -> None:
        """Test search by pattern."""
        result = loaded_server.search(pattern="add|subtract")
        assert len(result) >= 2

    def test_search_by_type(self, loaded_server: CarveServer) -> None:
        """Test search by node type."""
        result = loaded_server.search(node_type="function_definition")
        assert len(result) >= 2  # add and subtract

    @pytest.mark.usefixtures("_empty_cwd")
    def test_search_no_store(self) -> None:
        """Test search without a loaded store."""
        server = CarveServer()
        with pytest.raises(ValueError, match="No workspace loaded"):
            server.search(pattern="test")

    def test_validate_snippet_valid(self, loaded_server: CarveServer) -> None:
        """Test validating a valid snippet."""
        snippet = "def new_func():\n    pass"
        result = loaded_server.validate_snippet(snippet)
        assert result["valid"] is True
        assert len(result["errors"]) == 0

    def test_validate_snippet_invalid(self, loaded_server: CarveServer) -> None:
        """Test validating an invalid snippet."""
        snippet = "def new_func(\n    pass"  # Missing closing paren
        result = loaded_server.validate_snippet(snippet)
        assert result["valid"] is False
        assert len(result["errors"]) > 0

    @pytest.mark.usefixtures("_empty_cwd")
    def test_validate_snippet_no_language(self) -> None:
        """Test validate without language."""
        server = CarveServer()
        result = server.validate_snippet("def test(): pass")
        assert "error" in result

    def test_update(self, loaded_server: CarveServer) -> None:
        """Test updating a node."""
        # Find the add method
        search_result = loaded_server.search(pattern="add")
        add_nodes = [n for n in search_result if "add" in n["node_id"].split("::")[-1]]
        assert len(add_nodes) > 0
        add_id = add_nodes[0]["node_id"]

        new_snippet = '''def add(self, a: int, b: int) -> int:
    """Add two numbers with logging."""
    result = a + b
    print(f"Adding {a} + {b} = {result}")
    return result'''

        result = loaded_server.update(add_id, new_snippet, prompt="Add logging")
        assert result.get("success") is True or "error" not in result

    def test_delete(self, loaded_server: CarveServer) -> None:
        """Test deleting a node."""
        # Find the subtract method
        search_result = loaded_server.search(pattern="subtract")
        sub_nodes = [n for n in search_result if "subtract" in n["node_id"].split("::")[-1]]
        assert len(sub_nodes) > 0
        sub_id = sub_nodes[0]["node_id"]

        result = loaded_server.delete(sub_id)
        assert result.get("success") is True

        # Verify it's gone
        search_result = loaded_server.search(pattern="subtract")
        sub_nodes = [n for n in search_result if "subtract" in n["node_id"].split("::")[-1]]
        # May still find it in other contexts, but the main one should be gone


# =============================================================================
# HTTP API tests
# =============================================================================


@pytest.fixture
def http_client(loaded_server: CarveServer) -> TestClient:
    """Create a test client for the HTTP API."""
    app = create_http_app(loaded_server)
    return TestClient(app)


class TestHTTPAPI:
    """Tests for the HTTP API endpoints."""

    def test_status(self, http_client: TestClient) -> None:
        """Test GET /project/status."""
        response = http_client.get("/project/status")
        assert response.status_code == 200
        data = response.json()
        assert data["loaded"] is True
        assert data["language"] == "python"

    def test_subtree(self, http_client: TestClient, loaded_server: CarveServer) -> None:
        """Test POST /tree/subtree."""
        # First get a valid node_id
        search_result = loaded_server.search(pattern="Calculator")
        calc_id = search_result[0]["node_id"]

        response = http_client.post("/tree/subtree", json={"node_id": calc_id, "depth": 1})
        assert response.status_code == 200
        data = response.json()
        assert data["node_id"] == calc_id

    def test_ancestors(self, http_client: TestClient, loaded_server: CarveServer) -> None:
        """Test POST /tree/ancestors."""
        search_result = loaded_server.search(pattern="add")
        add_id = search_result[0]["node_id"]

        response = http_client.post("/tree/ancestors", json={"node_id": add_id})
        assert response.status_code == 200
        data = response.json()
        assert len(data) > 0

    def test_inspect(self, http_client: TestClient, loaded_server: CarveServer) -> None:
        """Test POST /tree/inspect."""
        search_result = loaded_server.search(pattern="Calculator")
        calc_id = search_result[0]["node_id"]

        response = http_client.post("/tree/inspect", json={"node_id": calc_id})
        assert response.status_code == 200
        data = response.json()
        assert "source" in data

    def test_search(self, http_client: TestClient) -> None:
        """Test POST /tree/search."""
        response = http_client.post("/tree/search", json={"pattern": "add"})
        assert response.status_code == 200
        data = response.json()
        assert len(data) > 0

    def test_validate(self, http_client: TestClient) -> None:
        """Test POST /tree/validate."""
        response = http_client.post(
            "/tree/validate", json={"snippet": "def test(): pass", "language": "python"}
        )
        assert response.status_code == 200
        data = response.json()
        assert data["valid"] is True

    def test_validate_invalid(self, http_client: TestClient) -> None:
        """Test POST /tree/validate with invalid code."""
        response = http_client.post(
            "/tree/validate", json={"snippet": "def test(: pass", "language": "python"}
        )
        assert response.status_code == 200
        data = response.json()
        assert data["valid"] is False

    def test_update(self, http_client: TestClient, loaded_server: CarveServer) -> None:
        """Test POST /tree/update."""
        search_result = loaded_server.search(pattern="add")
        add_nodes = [n for n in search_result if "add" in n["node_id"].split("::")[-1]]
        add_id = add_nodes[0]["node_id"]

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

        response = http_client.post(
            "/tree/update",
            json={"node_id": add_id, "snippet": new_snippet},
        )
        assert response.status_code == 200

    def test_delete(self, http_client: TestClient, loaded_server: CarveServer) -> None:
        """Test POST /tree/delete."""
        search_result = loaded_server.search(pattern="subtract")
        sub_nodes = [n for n in search_result if "subtract" in n["node_id"].split("::")[-1]]
        sub_id = sub_nodes[0]["node_id"]

        response = http_client.post("/tree/delete", json={"node_id": sub_id})
        assert response.status_code == 200
        data = response.json()
        assert data.get("success") is True

    def test_guide(self, http_client: TestClient) -> None:
        """Test GET /guide returns the agent guide."""
        response = http_client.get("/guide")
        assert response.status_code == 200
        data = response.json()
        assert "content" in data
        assert data["content"] == AGENT_GUIDE
        assert "Sidecar Metadata" in data["content"]
        assert "prompt" in data["content"]
        assert "model" in data["content"]
        assert "notes" in data["content"]


@pytest.mark.usefixtures("_empty_cwd")
class TestHTTPAPINoStore:
    """Tests for HTTP API when no store is loaded."""

    @pytest.fixture
    def empty_client(self) -> TestClient:
        """Create a test client with empty server."""
        app = create_http_app(CarveServer())
        return TestClient(app)

    def test_status_empty(self, empty_client: TestClient) -> None:
        """Test status when no file is loaded."""
        response = empty_client.get("/project/status")
        assert response.status_code == 200
        data = response.json()
        assert data["loaded"] is False

    def test_subtree_no_store(self, empty_client: TestClient) -> None:
        """Test subtree when no file is loaded."""
        response = empty_client.post("/tree/subtree", json={"node_id": "test"})
        assert response.status_code == 400

    def test_search_no_store(self, empty_client: TestClient) -> None:
        """Test search when no file is loaded."""
        response = empty_client.post("/tree/search", json={"pattern": "test"})
        assert response.status_code == 400


# =============================================================================
# Error handling tests
# =============================================================================


class TestErrorHandling:
    """Tests for error handling."""

    def test_subtree_invalid_node(self, loaded_server: CarveServer) -> None:
        """Test subtree with invalid node ID."""
        result = loaded_server.subtree("invalid::node::id")
        assert "error" in result

    def test_inspect_invalid_node(self, loaded_server: CarveServer) -> None:
        """Test inspect with invalid node ID."""
        result = loaded_server.inspect("invalid::node::id")
        assert "error" in result

    def test_update_invalid_node(self, loaded_server: CarveServer) -> None:
        """Test update with invalid node ID."""
        result = loaded_server.update("invalid::node::id", "def test(): pass")
        assert "error" in result

    def test_delete_invalid_node(self, loaded_server: CarveServer) -> None:
        """Test delete with invalid node ID."""
        result = loaded_server.delete("invalid::node::id")
        assert "error" in result

    def test_insert_invalid_parent(self, loaded_server: CarveServer) -> None:
        """Test insert with invalid parent ID."""
        result = loaded_server.insert("invalid::parent", "end", "def test(): pass")
        assert "error" in result

    def test_insert_invalid_snippet(self, loaded_server: CarveServer) -> None:
        """Test insert with invalid snippet."""
        search_result = loaded_server.search(pattern="Calculator")
        calc_id = search_result[0]["node_id"]

        result = loaded_server.insert(calc_id, "end", "def broken(: pass")
        assert "error" in result

    def test_update_invalid_snippet(self, loaded_server: CarveServer) -> None:
        """Test update with invalid snippet."""
        search_result = loaded_server.search(pattern="add")
        add_nodes = [n for n in search_result if "add" in n["node_id"].split("::")[-1]]
        add_id = add_nodes[0]["node_id"]

        result = loaded_server.update(add_id, "def broken(: pass")
        assert "error" in result


# =============================================================================
# Permission control tests
# =============================================================================


class TestReadOnly:
    """Tests for --read-only mode."""

    @pytest.fixture
    def ro_server(self, temp_python_file: Path) -> CarveServer:
        return CarveServer(file_path=temp_python_file, read_only=True)

    def test_navigation_allowed(self, ro_server: CarveServer) -> None:
        """Read-only allows navigation tools."""
        results = ro_server.search(pattern="add")
        assert len(results) > 0
        node_id = results[0]["node_id"]
        assert "error" not in ro_server.subtree(node_id)
        assert "error" not in ro_server.inspect(node_id)

    def test_insert_blocked(self, ro_server: CarveServer) -> None:
        results = ro_server.search(pattern="Calculator")
        calc_id = results[0]["node_id"]
        with pytest.raises(PermissionError, match="read-only"):
            ro_server.insert(calc_id, "end", "def new_func(): pass")

    def test_update_blocked(self, ro_server: CarveServer) -> None:
        results = ro_server.search(pattern="add")
        add_id = [n for n in results if "add" in n["node_id"].split("::")[-1]][0]["node_id"]
        with pytest.raises(PermissionError, match="read-only"):
            ro_server.update(add_id, "def add(self, a, b): return a + b")

    def test_delete_blocked(self, ro_server: CarveServer) -> None:
        results = ro_server.search(pattern="subtract")
        sub_id = [n for n in results if "subtract" in n["node_id"].split("::")[-1]][0]["node_id"]
        with pytest.raises(PermissionError, match="read-only"):
            ro_server.delete(sub_id)

    def test_move_blocked(self, ro_server: CarveServer) -> None:
        results = ro_server.search(pattern="add")
        add_id = [n for n in results if "add" in n["node_id"].split("::")[-1]][0]["node_id"]
        calc_id = ro_server.search(pattern="Calculator")[0]["node_id"]
        with pytest.raises(PermissionError, match="read-only"):
            ro_server.move(add_id, calc_id, "end")

    def test_validate_allowed(self, ro_server: CarveServer) -> None:
        """Validate is read-only, should still work."""
        result = ro_server.validate_snippet("def test(): pass")
        assert result["valid"] is True


class TestNoCreate:
    """Tests for --no-create mode."""

    @pytest.fixture
    def nc_server(self, tmp_path: Path) -> CarveServer:
        proj = tmp_path / "proj"
        proj.mkdir()
        (proj / "main.py").write_text("x = 1\n")
        return CarveServer(file_path=proj, no_create=True)

    def test_update_allowed(self, nc_server: CarveServer) -> None:
        """Edits to existing code are allowed."""
        results = nc_server.search(pattern="x")
        assert len(results) > 0

    def test_create_file_blocked(self, nc_server: CarveServer) -> None:
        root_id = nc_server.search(node_type="directory")[0]["node_id"]
        with pytest.raises(PermissionError, match="no-create"):
            nc_server.insert(root_id, "end", "y = 2\n", file_name="new.py")


class TestNoDelete:
    """Tests for --no-delete mode."""

    @pytest.fixture
    def nd_server(self, tmp_path: Path) -> CarveServer:
        proj = tmp_path / "proj"
        proj.mkdir()
        (proj / "main.py").write_text("def greet():\n    pass\n")
        return CarveServer(file_path=proj, no_delete=True)

    def test_delete_code_node_allowed(self, nd_server: CarveServer) -> None:
        """Deleting code nodes (functions, etc.) is still allowed."""
        results = nd_server.search(pattern="greet", node_type="function_definition")
        assert len(results) > 0
        greet_id = results[0]["node_id"]
        result = nd_server.delete(greet_id)
        assert result.get("success") is True

    def test_delete_file_node_blocked(self, nd_server: CarveServer) -> None:
        """Deleting file nodes is blocked."""
        results = nd_server.search(node_type="file")
        file_id = results[0]["node_id"]
        with pytest.raises(PermissionError, match="no-delete"):
            nd_server.delete(file_id)


# =============================================================================
# Custom project tools tests
# =============================================================================


class TestCustomTools:
    """Tests for custom project tools."""

    @pytest.fixture
    def tool_project(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
        """Create a project dir with carvesettings.toml defining tools."""
        proj = tmp_path / "proj"
        proj.mkdir()
        (proj / "main.py").write_text("x = 1\n")
        (proj / "carvesettings.toml").write_text(
            '[tools]\ngreet = "echo hello"\nsay = "echo {message}"\n'
        )
        monkeypatch.chdir(proj)
        monkeypatch.delenv("CARVE_WORKSPACE", raising=False)
        return proj

    def test_run_tool_stdout(self, tool_project: Path) -> None:
        """Custom tool returns correct stdout."""
        server = CarveServer(file_path=tool_project, allow_custom_tools=True)
        result = server.run_custom_tool("greet", {})
        assert result["success"] is True
        assert result["stdout"].strip() == "hello"
        assert result["returncode"] == 0

    def test_placeholder_substitution(self, tool_project: Path) -> None:
        """Placeholders are substituted from arguments."""
        server = CarveServer(file_path=tool_project, allow_custom_tools=True)
        result = server.run_custom_tool("say", {"message": "world"})
        assert result["success"] is True
        assert result["stdout"].strip() == "world"

    def test_missing_placeholder_error(self, tool_project: Path) -> None:
        """Missing placeholder arguments return an error."""
        server = CarveServer(file_path=tool_project, allow_custom_tools=True)
        result = server.run_custom_tool("say", {})
        assert "error" in result
        assert "message" in result["error"]

    def test_disabled_by_default(self, tool_project: Path) -> None:
        """Custom tools raise PermissionError when not enabled."""
        server = CarveServer(file_path=tool_project)
        with pytest.raises(PermissionError, match="allow-custom-tools"):
            server.run_custom_tool("greet", {})

    def test_unknown_tool_name(self, tool_project: Path) -> None:
        """Unknown tool name returns an error dict."""
        server = CarveServer(file_path=tool_project, allow_custom_tools=True)
        result = server.run_custom_tool("nonexistent", {})
        assert "error" in result
        assert "nonexistent" in result["error"]

    def test_list_custom_tools(self, tool_project: Path) -> None:
        """list_custom_tools returns tool definitions."""
        server = CarveServer(file_path=tool_project, allow_custom_tools=True)
        tools = server.list_custom_tools()
        names = {t["name"] for t in tools}
        assert "greet" in names
        assert "say" in names
        say_tool = next(t for t in tools if t["name"] == "say")
        assert "message" in say_tool["parameters"]

    def test_tool_description_contains_command(self, tool_project: Path) -> None:
        """Tool description includes the command template for auditability."""
        server = CarveServer(file_path=tool_project, allow_custom_tools=True)
        tools = server.list_custom_tools()
        greet = next(t for t in tools if t["name"] == "greet")
        assert "echo hello" in greet["description"]
        say = next(t for t in tools if t["name"] == "say")
        assert "echo {message}" in say["description"]

    def test_stderr_and_nonzero_exit(self, tool_project: Path) -> None:
        """Tool captures stderr and non-zero exit codes."""
        # Override settings with a failing command
        (tool_project / "carvesettings.toml").write_text(
            '[tools]\nfail = "echo oops >&2 && exit 1"\n'
        )
        server = CarveServer(file_path=tool_project, allow_custom_tools=True)
        result = server.run_custom_tool("fail", {})
        assert result["success"] is False
        assert result["returncode"] == 1
        assert "oops" in result["stderr"]

    def test_http_endpoint(self, tool_project: Path) -> None:
        """Custom tool is callable via the HTTP endpoint."""
        server = CarveServer(file_path=tool_project, allow_custom_tools=True)
        app = create_http_app(server)
        client = TestClient(app)
        resp = client.post("/tools/greet", json={"arguments": {}})
        assert resp.status_code == 200
        data = resp.json()
        assert data["success"] is True
        assert data["stdout"].strip() == "hello"

    def test_http_endpoint_disabled(self, tool_project: Path) -> None:
        """HTTP endpoint returns 403 when custom tools are disabled."""
        server = CarveServer(file_path=tool_project)
        app = create_http_app(server)
        client = TestClient(app)
        resp = client.post("/tools/greet", json={"arguments": {}})
        assert resp.status_code == 403
