"""Tests for the Engram Hub server."""

from __future__ import annotations

from unittest.mock import AsyncMock, patch

import pytest
from httpx import ASGITransport, AsyncClient
from pydantic import ValidationError

from engram.config import MAX_QUERY_LENGTH, MAX_RESPONSE_CHARS
from engram.models import SessionSearchInput
from engram.server import app


@pytest.fixture
def client():
    """Sync test client for non-async tests."""
    from fastapi.testclient import TestClient

    return TestClient(app)


@pytest.fixture
async def async_client():
    """Async test client for search endpoint tests."""
    transport = ASGITransport(app=app)
    async with AsyncClient(transport=transport, base_url="http://test") as ac:
        yield ac


# ---------------------------------------------------------------------------
# Pydantic model unit tests
# ---------------------------------------------------------------------------


class TestSessionSearchInput:
    def test_valid_minimal(self) -> None:
        m = SessionSearchInput(query="memory")
        assert m.query == "memory"

    def test_valid_with_session_id(self) -> None:
        m = SessionSearchInput(query="test", session_id="Session_42")
        assert m.session_id == "Session_42"

    def test_empty_query_rejected(self) -> None:
        with pytest.raises(ValidationError, match="At least one"):
            SessionSearchInput(query="")

    def test_overlength_query_rejected(self) -> None:
        with pytest.raises(ValidationError, match="at most"):
            SessionSearchInput(query="x" * (MAX_QUERY_LENGTH + 1))

    def test_path_traversal_rejected(self) -> None:
        with pytest.raises(ValidationError, match="session_id"):
            SessionSearchInput(query="test", session_id="../../etc/passwd")

    def test_shell_metachar_rejected(self) -> None:
        with pytest.raises(ValidationError, match="session_id"):
            SessionSearchInput(query="test", session_id="foo;rm -rf /")

    def test_spaces_rejected(self) -> None:
        with pytest.raises(ValidationError, match="session_id"):
            SessionSearchInput(query="test", session_id="foo bar")

    def test_dots_hyphens_underscores_ok(self) -> None:
        m = SessionSearchInput(query="x", session_id="Agent_DB.2026-03-28")
        assert m.session_id == "Agent_DB.2026-03-28"

    def test_tags_only_accepted(self) -> None:
        m = SessionSearchInput(query="", tags=["Testing"])
        assert m.tags == ["Testing"]

    def test_date_range_accepted(self) -> None:
        m = SessionSearchInput(query="", date_from="2026-03-01")
        assert m.date_from == "2026-03-01"

    def test_invalid_date_rejected(self) -> None:
        with pytest.raises(ValidationError, match="YYYY-MM-DD"):
            SessionSearchInput(query="test", date_from="not-a-date")

    def test_schema_generation(self) -> None:
        schema = SessionSearchInput.model_json_schema()
        assert schema["properties"]["query"]["maxLength"] == MAX_QUERY_LENGTH
        assert "session_id" in schema["properties"]
        assert "tags" in schema["properties"]


# ---------------------------------------------------------------------------
# Health endpoints (sync, no mocking needed)
# ---------------------------------------------------------------------------


class TestHealthEndpoint:
    def test_health_returns_ok(self, client) -> None:
        response = client.get("/health")
        assert response.status_code == 200
        assert response.json()["status"] == "online"

    def test_root_returns_info(self, client) -> None:
        response = client.get("/")
        assert response.status_code == 200
        assert response.json()["service"] == "Engram Hub"


# ---------------------------------------------------------------------------
# Search endpoint (async, needs subprocess mocking)
# ---------------------------------------------------------------------------


class TestSearchEndpoint:
    @pytest.mark.asyncio
    async def test_search_with_valid_query(self, async_client) -> None:
        with patch("engram.server.asyncio.create_subprocess_exec") as mock_exec:
            proc = AsyncMock()
            proc.communicate.return_value = (b"entities/foo.md:memory vault", b"")
            proc.returncode = 0
            mock_exec.return_value = proc

            response = await async_client.post("/search", json={"query": "memory"})
            assert response.status_code == 200
            assert "memory vault" in response.json()["results"]

    @pytest.mark.asyncio
    async def test_search_no_match(self, async_client) -> None:
        with patch("engram.server.asyncio.create_subprocess_exec") as mock_exec:
            proc = AsyncMock()
            proc.communicate.return_value = (b"", b"")
            proc.returncode = 1
            mock_exec.return_value = proc

            response = await async_client.post("/search", json={"query": "zzzznotfound"})
            assert response.status_code == 200
            assert "No match" in response.json()["results"]

    @pytest.mark.asyncio
    async def test_search_ripgrep_missing(self, async_client) -> None:
        with patch(
            "engram.server.asyncio.create_subprocess_exec",
            side_effect=FileNotFoundError,
        ):
            response = await async_client.post("/search", json={"query": "test"})
            assert response.status_code == 500

    @pytest.mark.asyncio
    async def test_search_output_truncation(self, async_client) -> None:
        with patch("engram.server.asyncio.create_subprocess_exec") as mock_exec:
            proc = AsyncMock()
            proc.communicate.return_value = (b"x" * (MAX_RESPONSE_CHARS + 500), b"")
            proc.returncode = 0
            mock_exec.return_value = proc

            response = await async_client.post("/search", json={"query": "x"})
            assert response.status_code == 200
            assert len(response.json()["results"]) <= MAX_RESPONSE_CHARS

    @pytest.mark.asyncio
    async def test_search_malformed_utf8(self, async_client) -> None:
        with patch("engram.server.asyncio.create_subprocess_exec") as mock_exec:
            proc = AsyncMock()
            proc.communicate.return_value = (b"valid \xff\xfe garbage", b"")
            proc.returncode = 0
            mock_exec.return_value = proc

            response = await async_client.post("/search", json={"query": "test"})
            assert response.status_code == 200
            assert "valid" in response.json()["results"]

    @pytest.mark.asyncio
    async def test_search_stderr_on_failure(self, async_client) -> None:
        with patch("engram.server.asyncio.create_subprocess_exec") as mock_exec:
            proc = AsyncMock()
            proc.communicate.return_value = (b"", b"No such directory")
            proc.returncode = 2
            mock_exec.return_value = proc

            response = await async_client.post("/search", json={"query": "test"})
            assert response.status_code == 200
            assert "No such directory" in response.json()["results"]

    @pytest.mark.asyncio
    async def test_search_with_session_id(self, async_client) -> None:
        with patch("engram.server.asyncio.create_subprocess_exec") as mock_exec:
            proc = AsyncMock()
            proc.communicate.return_value = (b"match line", b"")
            proc.returncode = 0
            mock_exec.return_value = proc

            response = await async_client.post("/search", json={"query": "test", "session_id": "Session_42"})
            assert response.status_code == 200
            call_args = mock_exec.call_args[0]
            assert "--glob" in call_args
            assert "*Session_42*" in call_args


# ---------------------------------------------------------------------------
# Input validation via HTTP
# ---------------------------------------------------------------------------


class TestInputValidation:
    @pytest.mark.asyncio
    async def test_empty_query_no_filters_rejected(self, async_client) -> None:
        response = await async_client.post("/search", json={"query": ""})
        assert response.status_code == 422

    @pytest.mark.asyncio
    async def test_overlength_query_rejected(self, async_client) -> None:
        response = await async_client.post("/search", json={"query": "x" * (MAX_QUERY_LENGTH + 1)})
        assert response.status_code == 422

    @pytest.mark.asyncio
    async def test_path_traversal_rejected(self, async_client) -> None:
        response = await async_client.post("/search", json={"query": "test", "session_id": "../../etc/passwd"})
        assert response.status_code == 422
