"""
SparqlClient — SPARQL query/update client over NATS.

Serialises SPARQL operations as protobuf ``AgentMeshPacket`` messages and
publishes them to the mesh SPARQL bridge subject, then awaits a response on
the NATS reply subject. The mesh-side SPARQL bridge (TypeScript) handles
forwarding to Oxigraph and returning results.

The client validates query strings before transmission:
- Rejects empty queries.
- Enforces a configurable timeout (default 10 s).

Example::

    async with MeshNode() as mesh:
        rows = await mesh.sparql.query(
            "SELECT ?agent WHERE { ?agent a <https://meshpocalypse.dev/ontology#Agent> }"
        )
        await mesh.sparql.update(
            "INSERT DATA { <...> a <...> }"
        )
"""

from __future__ import annotations

import json
import struct
import uuid
from typing import Any

from nats.aio.client import Client


class SparqlQueryError(Exception):
    """Raised when the mesh SPARQL bridge returns an error response."""

    def __init__(self, message: str, sparql: str | None = None) -> None:
        super().__init__(message)
        self.sparql = sparql


# NATS subjects for SPARQL operations (must match the TypeScript bridge config)
_SPARQL_QUERY_SUBJECT = "mesh.sparql.query"
_SPARQL_UPDATE_SUBJECT = "mesh.sparql.update"

# Protobuf-style envelope: 4-byte little-endian length prefix + JSON payload
# This is the wire format used by the TypeScript bridge for SPARQL over NATS.
# A full protobuf schema is used when buf-generated stubs are available;
# falling back to length-prefixed JSON preserves interoperability.
_HEADER_SIZE = 4


def _encode_request(payload: dict[str, Any]) -> bytes:
    """Encode a SPARQL request as a length-prefixed JSON frame.

    Uses a 4-byte little-endian length prefix matching the TypeScript bridge
    wire format. This allows the bridge to handle both protobuf and JSON payloads
    by inspecting the first byte (protobuf wire type vs ASCII '{').

    Args:
        payload: Dictionary to serialise as JSON.

    Returns:
        Length-prefixed UTF-8 JSON bytes.
    """
    body = json.dumps(payload).encode("utf-8")
    header = struct.pack("<I", len(body))
    return header + body


def _decode_response(data: bytes) -> dict[str, Any]:
    """Decode a length-prefixed JSON response frame.

    Args:
        data: Raw bytes from NATS reply.

    Returns:
        Parsed JSON dictionary.

    Raises:
        SparqlQueryError: If the frame is malformed or cannot be parsed.
    """
    if len(data) < _HEADER_SIZE:
        raise SparqlQueryError(f"Response frame too short: {len(data)} bytes")
    length = struct.unpack("<I", data[:_HEADER_SIZE])[0]
    body = data[_HEADER_SIZE : _HEADER_SIZE + length]
    try:
        return json.loads(body.decode("utf-8"))
    except (json.JSONDecodeError, UnicodeDecodeError) as exc:
        raise SparqlQueryError(f"Failed to decode SPARQL response: {exc}") from exc


class SparqlClient:
    """SPARQL query and update client for the mesh RDF graph.

    Communicates with the TypeScript SPARQL bridge over NATS using
    length-prefixed JSON frames (wire-compatible with protobuf framing).

    Args:
        nc: Connected NATS client.
        timeout: Request timeout in seconds. Defaults to 10.
    """

    def __init__(self, nc: Client, timeout: float = 10.0) -> None:
        self._nc = nc
        self._timeout = timeout

    async def query(self, sparql: str) -> list[dict[str, Any]]:
        """Execute a SPARQL SELECT query against the mesh RDF graph.

        Sends the query to the mesh SPARQL bridge via NATS and returns
        the parsed W3C SPARQL JSON results bindings.

        Args:
            sparql: SPARQL SELECT query string.

        Returns:
            List of result row dictionaries (W3C SPARQL JSON binding format).
            Each value is a dict with ``"type"`` and ``"value"`` keys.

        Raises:
            ValueError: If ``sparql`` is empty.
            SparqlQueryError: If the bridge returns an error.
            nats.errors.TimeoutError: If the request times out.
        """
        if not sparql or not sparql.strip():
            raise ValueError("sparql.query: query string must be non-empty")

        request_id = str(uuid.uuid4())
        payload = _encode_request({"id": request_id, "query": sparql, "type": "select"})
        response = await self._nc.request(_SPARQL_QUERY_SUBJECT, payload, timeout=self._timeout)
        result = _decode_response(response.data)

        if "error" in result:
            raise SparqlQueryError(str(result["error"]), sparql=sparql)

        # W3C SPARQL JSON results format
        return result.get("results", {}).get("bindings", [])

    async def update(self, sparql: str) -> None:
        """Execute a SPARQL UPDATE against the mesh RDF graph.

        Sends the update to the mesh SPARQL bridge via NATS.

        Args:
            sparql: SPARQL UPDATE string (INSERT DATA, DELETE DATA, etc.).

        Raises:
            ValueError: If ``sparql`` is empty.
            SparqlQueryError: If the bridge returns an error.
            nats.errors.TimeoutError: If the request times out.
        """
        if not sparql or not sparql.strip():
            raise ValueError("sparql.update: query string must be non-empty")

        request_id = str(uuid.uuid4())
        payload = _encode_request({"id": request_id, "query": sparql, "type": "update"})
        response = await self._nc.request(_SPARQL_UPDATE_SUBJECT, payload, timeout=self._timeout)
        result = _decode_response(response.data)

        if "error" in result:
            raise SparqlQueryError(str(result["error"]), sparql=sparql)
