"""
Tests for SparqlClient — SPARQL query/update over NATS.

All NATS request() calls are mocked. No live NATS server or Oxigraph required.
"""

from __future__ import annotations

import json
import struct
from typing import Any
from unittest.mock import AsyncMock, MagicMock

import pytest

from meshpocalypse_semantic.sparql import SparqlClient, SparqlQueryError, _encode_request, _decode_response


def _make_nats_response(payload: dict[str, Any]) -> MagicMock:
    """Build a mock NATS message with a length-prefixed JSON payload."""
    msg = MagicMock()
    msg.data = _encode_request(payload)
    return msg


class TestEncodeDecodeRoundtrip:
    """Wire format encode/decode symmetry tests."""

    def test_encode_decode_roundtrip(self) -> None:
        """Encoded payload should decode back to the original dict."""
        original = {"id": "abc", "query": "SELECT ?s WHERE { ?s a ?t }", "type": "select"}
        encoded = _encode_request(original)
        decoded = _decode_response(encoded)
        assert decoded == original

    def test_decode_raises_on_short_frame(self) -> None:
        """Frames shorter than 4 bytes should raise SparqlQueryError."""
        with pytest.raises(SparqlQueryError, match="too short"):
            _decode_response(b"\x00\x01")

    def test_decode_raises_on_invalid_json(self) -> None:
        """Frames with invalid JSON body should raise SparqlQueryError."""
        body = b"not-json"
        data = struct.pack("<I", len(body)) + body
        with pytest.raises(SparqlQueryError, match="decode"):
            _decode_response(data)


class TestSparqlClientQuery:
    """Tests for SparqlClient.query()."""

    @pytest.mark.asyncio
    async def test_query_returns_bindings_on_success(self) -> None:
        """query() should return the 'results.bindings' array from the response."""
        mock_nc = AsyncMock()
        expected_bindings = [{"s": {"type": "uri", "value": "https://example.com/agent"}}]
        mock_nc.request.return_value = _make_nats_response({
            "results": {"bindings": expected_bindings}
        })

        client = SparqlClient(mock_nc)
        result = await client.query("SELECT ?s WHERE { ?s a <https://meshpocalypse.dev/ontology#Agent> }")

        assert result == expected_bindings
        mock_nc.request.assert_called_once()
        # Verify the subject
        call_args = mock_nc.request.call_args
        assert call_args[0][0] == "mesh.sparql.query"

    @pytest.mark.asyncio
    async def test_query_raises_sparql_error_on_error_response(self) -> None:
        """query() should raise SparqlQueryError when the bridge returns 'error'."""
        mock_nc = AsyncMock()
        mock_nc.request.return_value = _make_nats_response({"error": "Syntax error in SPARQL"})

        client = SparqlClient(mock_nc)
        with pytest.raises(SparqlQueryError, match="Syntax error"):
            await client.query("SELECT ?s WHERE { INVALID }")

    @pytest.mark.asyncio
    async def test_query_raises_value_error_for_empty_string(self) -> None:
        """query() with empty string should raise ValueError before sending."""
        mock_nc = AsyncMock()
        client = SparqlClient(mock_nc)
        with pytest.raises(ValueError, match="non-empty"):
            await client.query("")
        mock_nc.request.assert_not_called()

    @pytest.mark.asyncio
    async def test_query_raises_value_error_for_whitespace_only(self) -> None:
        """query() with whitespace-only string should raise ValueError."""
        mock_nc = AsyncMock()
        client = SparqlClient(mock_nc)
        with pytest.raises(ValueError, match="non-empty"):
            await client.query("   ")
        mock_nc.request.assert_not_called()

    @pytest.mark.asyncio
    async def test_query_returns_empty_list_when_no_bindings(self) -> None:
        """query() should return an empty list when results.bindings is empty."""
        mock_nc = AsyncMock()
        mock_nc.request.return_value = _make_nats_response({
            "results": {"bindings": []}
        })

        client = SparqlClient(mock_nc)
        result = await client.query("SELECT ?s WHERE { ?s a <...> }")
        assert result == []

    @pytest.mark.asyncio
    async def test_query_encodes_sparql_in_payload(self) -> None:
        """query() should include the SPARQL string in the request payload."""
        mock_nc = AsyncMock()
        mock_nc.request.return_value = _make_nats_response({"results": {"bindings": []}})

        sparql_str = "SELECT ?agent WHERE { ?agent a <https://meshpocalypse.dev/ontology#Agent> }"
        client = SparqlClient(mock_nc)
        await client.query(sparql_str)

        # Decode the sent payload and verify it contains the SPARQL
        call_args = mock_nc.request.call_args
        payload_bytes = call_args[0][1]
        decoded = _decode_response(payload_bytes)
        assert decoded["query"] == sparql_str
        assert decoded["type"] == "select"


class TestSparqlClientUpdate:
    """Tests for SparqlClient.update()."""

    @pytest.mark.asyncio
    async def test_update_succeeds_on_ok_response(self) -> None:
        """update() should complete without raising on a successful response."""
        mock_nc = AsyncMock()
        mock_nc.request.return_value = _make_nats_response({"status": "ok"})

        client = SparqlClient(mock_nc)
        await client.update("INSERT DATA { <https://example.com/agent> a <https://meshpocalypse.dev/ontology#Agent> }")
        mock_nc.request.assert_called_once()
        assert mock_nc.request.call_args[0][0] == "mesh.sparql.update"

    @pytest.mark.asyncio
    async def test_update_raises_sparql_error_on_error_response(self) -> None:
        """update() should raise SparqlQueryError when bridge returns an error."""
        mock_nc = AsyncMock()
        mock_nc.request.return_value = _make_nats_response({"error": "Write conflict"})

        client = SparqlClient(mock_nc)
        with pytest.raises(SparqlQueryError, match="Write conflict"):
            await client.update("DELETE DATA { <...> a <...> }")

    @pytest.mark.asyncio
    async def test_update_raises_value_error_for_empty_string(self) -> None:
        """update() with empty string should raise ValueError."""
        mock_nc = AsyncMock()
        client = SparqlClient(mock_nc)
        with pytest.raises(ValueError, match="non-empty"):
            await client.update("")
        mock_nc.request.assert_not_called()
