#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements.  See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership.  The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License.  You may obtain a copy of the License at
#
#   https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied.  See the License for the
# specific language governing permissions and limitations
# under the License.
#

"""Veculo API client."""

from __future__ import annotations

import os
from typing import TYPE_CHECKING, Any, Optional

import httpx

if TYPE_CHECKING:
    from veculo.embeddings import EmbeddingProvider

from veculo.errors import AuthenticationError, NotFoundError, ValidationError, VeculoError

_DEFAULT_ENDPOINT = "https://api.veculo.com"
_DEFAULT_TIMEOUT = 30.0
_API_VERSION = "v1"


class VeculoClient:
    """Client for the Veculo managed graph+vector database API.

    The client reads configuration from explicit arguments, then falls back to
    environment variables:

    * ``VECULO_ENDPOINT``  -- API base URL (default ``https://api.veculo.com``)
    * ``VECULO_API_KEY``   -- API key for authentication
    * ``VECULO_CLUSTER_ID`` -- Target cluster identifier

    Example::

        from veculo import VeculoClient

        client = VeculoClient(api_key="vk-...", cluster_id="cl-a7f3b2")
        client.put_vertex(id="alice", label="person", properties={"name": "Alice"})
        result = client.get_vertex(id="alice")
    """

    def __init__(
        self,
        endpoint: str | None = None,
        api_key: str | None = None,
        cluster_id: str | None = None,
        cluster_name: str | None = None,
        *,
        timeout: float = _DEFAULT_TIMEOUT,
    ) -> None:
        """Initialize the Veculo client.

        Provide either ``cluster_id`` or ``cluster_name``. If
        ``cluster_name`` is given, the client resolves it to an ID via
        the API on first use.

        Args:
            endpoint: API endpoint URL. Defaults to ``VECULO_ENDPOINT`` env var
                or ``https://api.veculo.com``.
            api_key: Veculo API key. Defaults to ``VECULO_API_KEY`` env var.
            cluster_id: Cluster ID (e.g., ``cl-a7f3b2``). Defaults to
                ``VECULO_CLUSTER_ID`` env var.
            cluster_name: Cluster display name (e.g., ``"production"``).
                Defaults to ``VECULO_CLUSTER_NAME`` env var. Resolved to
                cluster_id automatically.
            timeout: HTTP request timeout in seconds.

        Raises:
            VeculoError: If ``api_key`` or cluster cannot be resolved.
        """
        self.endpoint = (
            endpoint or os.environ.get("VECULO_ENDPOINT") or _DEFAULT_ENDPOINT
        ).rstrip("/")
        self.api_key = api_key or os.environ.get("VECULO_API_KEY")
        self._timeout = timeout

        if not self.api_key:
            raise VeculoError(
                "API key is required. Pass api_key= or set VECULO_API_KEY."
            )

        # Resolve cluster: ID takes priority, then name lookup
        self.cluster_id = cluster_id or os.environ.get("VECULO_CLUSTER_ID")
        cluster_name = cluster_name or os.environ.get("VECULO_CLUSTER_NAME")

        if not self.cluster_id and cluster_name:
            self.cluster_id = self._resolve_cluster_name(cluster_name)
        elif not self.cluster_id:
            raise VeculoError(
                "Cluster is required. Pass cluster_id=, cluster_name=, "
                "or set VECULO_CLUSTER_ID / VECULO_CLUSTER_NAME."
            )

        self._embedder: EmbeddingProvider | None = None

        self._client = httpx.Client(
            base_url=f"{self.endpoint}/{_API_VERSION}/{self.cluster_id}",
            headers={
                "Authorization": f"Bearer {self.api_key}",
                "Content-Type": "application/json",
                "User-Agent": "veculo-python/0.1.0",
            },
            timeout=timeout,
        )

    def _resolve_cluster_name(self, name: str) -> str:
        """Look up a cluster ID by display name via the API."""
        response = httpx.get(
            f"{self.endpoint}/tenants",
            headers={"Authorization": f"Bearer {self.api_key}"},
            timeout=self._timeout,
        )
        if not response.is_success:
            raise VeculoError(
                f"Failed to list clusters: HTTP {response.status_code}"
            )
        clusters = response.json()
        for cluster in clusters:
            if cluster.get("name") == name:
                return cluster["tenant_id"]
        raise NotFoundError(f"No cluster found with name '{name}'")

    # ------------------------------------------------------------------
    # Vertex operations
    # ------------------------------------------------------------------

    def put_vertex(
        self,
        id: str,
        label: str = "default",
        properties: dict[str, Any] | None = None,
        embedding: list[float] | None = None,
        visibility: str | None = None,
    ) -> dict:
        """Insert or update a vertex, optionally with a vector embedding.

        Args:
            id: Unique vertex identifier.
            label: Vertex label / type.
            properties: Key-value properties to store.
            embedding: Optional vector embedding for similarity search.
            visibility: Accumulo-style visibility expression.

        Returns:
            API response body as a dictionary.
        """
        body: dict[str, Any] = {"id": id, "label": label}
        if properties is not None:
            body["properties"] = properties
        if visibility is not None:
            body["visibility"] = visibility
        if embedding is not None:
            body["embedding"] = embedding
            return self._request("POST", "/vertices/embedding", json=body)
        return self._request("POST", "/vertices", json=body)

    def put_vertex_with_text(
        self,
        id: str,
        text: str,
        label: str = "default",
        properties: dict[str, Any] | None = None,
        visibility: str | None = None,
        embed_server_side: bool = False,
    ) -> dict:
        """Insert a vertex, generating an embedding from text.

        Args:
            id: Unique vertex identifier.
            text: Text to generate embedding from.
            label: Vertex label.
            properties: Additional properties.
            visibility: Visibility expression.
            embed_server_side: If True, sends text to Veculo API for
                server-side embedding (billed). If False, requires
                an embedding provider to be configured via set_embedder().

        Returns:
            API response.

        Raises:
            VeculoError: If no embedder configured and embed_server_side=False.
        """
        if embed_server_side:
            body: dict[str, Any] = {"id": id, "label": label, "text": text}
            if properties:
                body["properties"] = properties
            if visibility:
                body["visibility"] = visibility
            return self._request("POST", "/vertices/embed", json=body)

        if self._embedder is None:
            raise VeculoError(
                "No embedding provider configured. Call client.set_embedder() "
                "or use embed_server_side=True."
            )
        embedding = self._embedder.embed(text)
        props = dict(properties or {})
        props["_text"] = text
        return self.put_vertex(
            id=id, label=label, properties=props,
            embedding=embedding, visibility=visibility,
        )

    def set_embedder(self, provider: "EmbeddingProvider") -> None:
        """Configure a client-side embedding provider.

        Example::
            from veculo.embeddings import OpenAIEmbeddings
            client.set_embedder(OpenAIEmbeddings(api_key="sk-..."))
        """
        self._embedder = provider

    def get_vertex(
        self,
        id: str,
        authorizations: str | None = None,
    ) -> dict:
        """Retrieve a vertex by ID.

        Args:
            id: Vertex identifier.
            authorizations: Comma-separated visibility authorizations.

        Returns:
            Vertex data as a dictionary.
        """
        params: dict[str, str] | None = None
        if authorizations is not None:
            params = {"authorizations": authorizations}
        return self._request("GET", f"/vertices/{id}", params=params)

    # ------------------------------------------------------------------
    # Edge operations
    # ------------------------------------------------------------------

    def put_edge(
        self,
        source: str,
        target: str,
        edge_type: str,
        properties: dict[str, Any] | None = None,
        visibility: str | None = None,
    ) -> dict:
        """Create or update a directed edge between two vertices.

        Args:
            source: Source vertex ID.
            target: Target vertex ID.
            edge_type: Relationship type (e.g., ``"knows"``).
            properties: Key-value properties to store on the edge.
            visibility: Accumulo-style visibility expression.

        Returns:
            API response body as a dictionary.
        """
        body: dict[str, Any] = {
            "source": source,
            "target": target,
            "edge_type": edge_type,
        }
        if properties is not None:
            body["properties"] = properties
        if visibility is not None:
            body["visibility"] = visibility
        return self._request("POST", "/edges", json=body)

    # ------------------------------------------------------------------
    # Query operations
    # ------------------------------------------------------------------

    def get_neighbors(
        self,
        vertex_id: str,
        edge_type: str,
        depth: int = 1,
        authorizations: str | None = None,
    ) -> dict:
        """Traverse the graph to find neighbors of a vertex.

        Args:
            vertex_id: Starting vertex ID.
            edge_type: Filter traversal to this edge type.
            depth: Maximum traversal depth (default 1).
            authorizations: Comma-separated visibility authorizations.

        Returns:
            Neighboring vertices and edges as a dictionary.
        """
        body: dict[str, Any] = {
            "vertex_id": vertex_id,
            "edge_type": edge_type,
        }
        if authorizations is not None:
            body["authorizations"] = authorizations
        return self._request("POST", "/neighbors", json=body)

    def query(
        self,
        embedding: list[float] | None = None,
        top_k: int = 10,
        edge_type: str | None = None,
        depth: int = 1,
        authorizations: str | None = None,
    ) -> dict:
        """Execute a hybrid vector similarity + graph traversal query.

        Finds the ``top_k`` most similar vertices by embedding, then
        optionally expands the result set via graph traversal.

        Args:
            embedding: Query vector for similarity search.
            top_k: Number of nearest neighbors to return (default 10).
            edge_type: Optional edge type filter for graph expansion.
            depth: Graph traversal depth (default 1).
            authorizations: Comma-separated visibility authorizations.

        Returns:
            Combined vector and graph results as a dictionary.
        """
        body: dict[str, Any] = {"top_k": top_k, "depth": depth}
        if embedding is not None:
            body["query_vector"] = embedding
        if edge_type is not None:
            body["edge_type"] = edge_type
        if authorizations is not None:
            body["authorizations"] = authorizations
        return self._request("POST", "/query/vector", json=body)

    def get_metrics(self) -> dict:
        """Retrieve cluster health and performance metrics.

        Returns:
            Cluster metrics as a dictionary.
        """
        return self._request("GET", "/metrics")

    # ------------------------------------------------------------------
    # Bulk operations
    # ------------------------------------------------------------------

    def put_vertices_bulk(
        self,
        vertices: list[dict[str, Any]],
    ) -> dict:
        """Insert multiple vertices in a single batch.

        Args:
            vertices: List of vertex dicts, each with 'id', 'label',
                and optional 'properties'.

        Returns:
            API response with insert count.
        """
        return self._request("POST", "/vertices/bulk", json={"vertices": vertices})

    def put_edges_bulk(
        self,
        edges: list[dict[str, Any]],
    ) -> dict:
        """Insert multiple edges in a single batch.

        Args:
            edges: List of edge dicts, each with 'source', 'target',
                'edge_type', and optional 'properties'.

        Returns:
            API response with insert count.
        """
        return self._request("POST", "/edges/bulk", json={"edges": edges})

    # ------------------------------------------------------------------
    # AI-native queries
    # ------------------------------------------------------------------

    def nl_query(
        self,
        question: str,
        authorizations: str | None = None,
        schema_hints: dict | None = None,
    ) -> dict:
        """Execute a natural language query against the graph.

        Translates the question into a structured query plan via LLM,
        executes it, and returns results with the generated plan.

        Args:
            question: Natural language question.
            authorizations: Comma-separated visibility authorizations.
            schema_hints: Optional schema metadata for better translation.

        Returns:
            Dict with 'question', 'query_plan', and 'results'.
        """
        body: dict[str, Any] = {"question": question}
        if authorizations is not None:
            body["authorizations"] = authorizations
        if schema_hints is not None:
            body["schema_hints"] = schema_hints
        return self._request("POST", "/query/natural", json=body)

    def rag_query(
        self,
        question: str,
        context_hops: int = 1,
        model: str | None = None,
        authorizations: str | None = None,
        top_k: int = 10,
    ) -> dict:
        """Execute a Graph-Augmented RAG query.

        Performs vector search, expands graph neighborhoods, and
        synthesizes an answer via LLM with source citations.

        Args:
            question: Natural language question.
            context_hops: Graph traversal depth for context (default 1).
            model: LLM model for synthesis.
            authorizations: Visibility authorizations for ABAC.
            top_k: Number of vector search results.

        Returns:
            Dict with 'answer', 'sources', 'context', and 'model'.
        """
        body: dict[str, Any] = {
            "question": question,
            "context_hops": context_hops,
            "top_k": top_k,
        }
        if model is not None:
            body["model"] = model
        if authorizations is not None:
            body["authorizations"] = authorizations
        return self._request("POST", "/rag", json=body)

    # ------------------------------------------------------------------
    # Configuration
    # ------------------------------------------------------------------

    def configure_auto_embed(
        self,
        model: str = "text-embedding-005",
        provider: str = "vertex-ai",
        text_properties: list[str] | None = None,
        min_text_length: int = 10,
    ) -> dict:
        """Configure automatic embedding generation for text vertices.

        When enabled, new vertices with text properties will automatically
        get embeddings generated during minor compaction.

        Args:
            model: Embedding model name.
            provider: Embedding provider (vertex-ai, openai).
            text_properties: Property names to treat as text sources.
            min_text_length: Minimum text length to trigger embedding.

        Returns:
            API response confirming configuration.
        """
        body: dict[str, Any] = {
            "model": model,
            "provider": provider,
            "text_properties": text_properties or [],
            "min_text_length": min_text_length,
        }
        return self._request("POST", "/configure/auto-embed", json=body)

    def configure_semantic_edges(
        self,
        similarity_threshold: float = 0.85,
        max_edges_per_vertex: int = 10,
    ) -> dict:
        """Configure automatic semantic edge creation.

        During major compaction, vertices with similar embeddings will
        automatically get SIMILAR_TO edges.

        Args:
            similarity_threshold: Minimum cosine similarity (default 0.85).
            max_edges_per_vertex: Maximum edges per vertex (default 10).

        Returns:
            API response confirming configuration.
        """
        return self._request("POST", "/configure/semantic-edges", json={
            "similarity_threshold": similarity_threshold,
            "max_edges_per_vertex": max_edges_per_vertex,
        })

    # ------------------------------------------------------------------
    # Insights
    # ------------------------------------------------------------------

    def get_anomalies(
        self,
        authorizations: str | None = None,
    ) -> dict:
        """Get vertices flagged as anomalous by embedding analysis.

        Returns:
            Dict with 'anomalies' list of flagged vertices and scores.
        """
        params: dict[str, str] | None = None
        if authorizations is not None:
            params = {"authorizations": authorizations}
        return self._request("GET", "/insights/anomalies", params=params)

    def get_top_ranked(
        self,
        authorizations: str | None = None,
    ) -> dict:
        """Get top-ranked vertices by PageRank score.

        Returns:
            Dict with 'ranks' list of vertices and their scores.
        """
        params: dict[str, str] | None = None
        if authorizations is not None:
            params = {"authorizations": authorizations}
        return self._request("GET", "/insights/ranks", params=params)

    def get_processing_status(self) -> dict:
        """Get the status of pending marker processing queue.

        Returns:
            Dict with queue sizes for each marker type.
        """
        return self._request("GET", "/insights/processing-queue")

    # ------------------------------------------------------------------
    # Internals
    # ------------------------------------------------------------------

    def _request(
        self,
        method: str,
        path: str,
        *,
        params: dict[str, str] | None = None,
        json: dict[str, Any] | None = None,
    ) -> dict:
        """Send an HTTP request and return the parsed JSON response.

        Raises:
            AuthenticationError: On 401 or 403 responses.
            NotFoundError: On 404 responses.
            ValidationError: On 400 or 422 responses.
            VeculoError: On any other non-2xx response.
        """
        response = self._client.request(method, path, params=params, json=json)

        if response.is_success:
            if response.status_code == 204:
                return {}
            return response.json()

        # Parse error body
        try:
            error_body = response.json()
            message = error_body.get("error", {}).get("message", response.text)
        except Exception:
            error_body = None
            message = response.text or f"HTTP {response.status_code}"

        status = response.status_code
        if status in (401, 403):
            raise AuthenticationError(message, status_code=status, body=error_body)
        if status == 404:
            raise NotFoundError(message, status_code=status, body=error_body)
        if status in (400, 422):
            raise ValidationError(message, status_code=status, body=error_body)
        raise VeculoError(message, status_code=status, body=error_body)

    def close(self) -> None:
        """Close the underlying HTTP client."""
        self._client.close()

    def __enter__(self) -> "VeculoClient":
        return self

    def __exit__(self, *args: Any) -> None:
        self.close()

    def __repr__(self) -> str:
        return (
            f"VeculoClient(endpoint={self.endpoint!r}, "
            f"cluster_id={self.cluster_id!r})"
        )
