"""
MemoraLabs Python SDK — main client class.

Usage:
    from memoralabs import MemoraLabs

    client = MemoraLabs(api_key="ml_your_key")
    memory = client.add("User prefers dark mode", user_id="user_42")
    results = client.search("display preferences", user_id="user_42")
    print(results.results[0].text)
"""
from __future__ import annotations

from typing import Any, Optional

import httpx

from .exceptions import (
    AuthError,
    MemoraLabsError,
    NotFoundError,
    PlanLimitError,
    RateLimitError,
    ValidationError,
)
from .models import (
    BillingStatusResponse,
    CheckoutResponse,
    GapResponse,
    KeyRotateResponse,
    MemoryListResponse,
    MemoryResponse,
    SearchResponse,
    SignupResponse,
)

_DEFAULT_BASE_URL = "https://memoralabs-api.onrender.com"
_DEFAULT_TIMEOUT = 30.0


class MemoraLabs:
    """Synchronous client for the MemoraLabs API.

    All methods are typed and return typed dataclasses.  The client holds an
    httpx.Client internally; call close() or use as a context manager to
    release the underlying connection pool when done.

    Args:
        api_key:  Your MemoraLabs API key (starts with ``ml_``).
        base_url: Override the default API base URL (useful for testing).
    """

    def __init__(
        self,
        api_key: str,
        base_url: str = _DEFAULT_BASE_URL,
    ) -> None:
        if not isinstance(api_key, str):
            raise TypeError(f"api_key must be a str, got {type(api_key).__name__}")
        self._api_key = api_key
        self._http = httpx.Client(
            base_url=base_url,
            headers={"Authorization": f"Bearer {api_key}"},
            timeout=_DEFAULT_TIMEOUT,
        )

    # ------------------------------------------------------------------
    # Internal helpers
    # ------------------------------------------------------------------

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

        Maps API error status codes to typed SDK exceptions.

        Args:
            method:  HTTP method (GET, POST, PATCH, DELETE).
            path:    URL path relative to base_url (e.g. "/v1/memory").
            headers: Optional per-request header overrides.
            **kwargs: Passed directly to httpx.Client.request().

        Returns:
            Parsed JSON response body as a dict.

        Raises:
            AuthError:       HTTP 401
            PlanLimitError:  HTTP 402
            NotFoundError:   HTTP 404
            RateLimitError:  HTTP 429
            ValidationError: HTTP 422
            MemoraLabsError: Any other non-2xx status
        """
        request_headers = {}
        if headers is not None:
            request_headers.update(headers)

        response = self._http.request(
            method,
            path,
            headers=request_headers if request_headers else None,
            **kwargs,
        )

        if response.is_success:
            # DELETE returns 204 No Content
            if response.status_code == 204 or not response.content:
                return {}
            return response.json()

        # Parse error body
        try:
            error_body = response.json()
        except Exception:
            error_body = {}

        # Extract human-readable message
        if isinstance(error_body, dict):
            message = (
                error_body.get("detail")
                or error_body.get("message")
                or error_body.get("error")
                or response.reason_phrase
            )
            # detail can itself be a dict (FastAPI HTTPException with dict detail)
            if isinstance(message, dict):
                message = message.get("message") or response.reason_phrase
        else:
            message = response.reason_phrase

        message = str(message)
        status = response.status_code

        if status == 401:
            raise AuthError(message)
        if status == 402:
            upgrade_url: str | None = None
            if isinstance(error_body, dict):
                detail = error_body.get("detail", {})
                if isinstance(detail, dict):
                    upgrade_url = detail.get("upgrade_url")
            raise PlanLimitError(message, upgrade_url=upgrade_url)
        if status == 404:
            raise NotFoundError(message)
        if status == 422:
            raise ValidationError(message)
        if status == 429:
            raise RateLimitError(message)
        raise MemoraLabsError(message, status_code=status)

    # ------------------------------------------------------------------
    # Auth methods
    # ------------------------------------------------------------------

    def signup(
        self,
        name: str,
        email: str,
        plan: str = "free",
    ) -> SignupResponse:
        """Register a new developer account and receive an API key.

        The API key is shown exactly once — store it securely.
        This call does NOT require an existing API key.

        Args:
            name:  Developer or organisation name (1-100 characters).
            email: Developer email address (must be unique).
            plan:  Subscription plan tier — "free" (default), "pro", or "enterprise".

        Returns:
            SignupResponse with tenant_id, email, plan, api_key, key_prefix, message.
        """
        if not isinstance(name, str):
            raise TypeError(f"name must be a str, got {type(name).__name__}")
        if not isinstance(email, str):
            raise TypeError(f"email must be a str, got {type(email).__name__}")
        if not isinstance(plan, str):
            raise TypeError(f"plan must be a str, got {type(plan).__name__}")

        data = self._request(
            "POST",
            "/v1/auth/signup",
            # Override the default Authorization header — signup doesn't need auth
            headers={"Authorization": ""},
            json={"name": name, "email": email, "plan": plan},
        )
        return SignupResponse.from_dict(data)

    def rotate_key(self) -> KeyRotateResponse:
        """Rotate your API key.

        Authenticates with your current key, revokes it, and returns a new one.
        The new key is shown exactly once — store it securely.

        Returns:
            KeyRotateResponse with api_key, key_prefix, message.
        """
        data = self._request("POST", "/v1/auth/keys/rotate")
        return KeyRotateResponse.from_dict(data)

    # ------------------------------------------------------------------
    # Memory methods
    # ------------------------------------------------------------------

    def add(
        self,
        text: str,
        *,
        user_id: Optional[str] = None,
        agent_id: Optional[str] = None,
        session_id: Optional[str] = None,
        metadata: Optional[dict] = None,
    ) -> MemoryResponse:
        """Store a new memory.

        Args:
            text:       The text content to store (required).
            user_id:    Optional user ID to scope this memory to a specific user.
            agent_id:   Optional agent ID to scope this memory to a specific AI agent.
            session_id: Optional session ID to group memories within a conversation.
            metadata:   Arbitrary key-value metadata attached to the memory.

        Returns:
            MemoryResponse with the stored memory's id, text, timestamps, and status.
        """
        if not isinstance(text, str):
            raise TypeError(f"text must be a str, got {type(text).__name__}")
        if user_id is not None and not isinstance(user_id, str):
            raise TypeError(f"user_id must be a str or None, got {type(user_id).__name__}")
        if agent_id is not None and not isinstance(agent_id, str):
            raise TypeError(f"agent_id must be a str or None, got {type(agent_id).__name__}")
        if session_id is not None and not isinstance(session_id, str):
            raise TypeError(f"session_id must be a str or None, got {type(session_id).__name__}")
        if metadata is not None and not isinstance(metadata, dict):
            raise TypeError(f"metadata must be a dict or None, got {type(metadata).__name__}")

        body: dict[str, Any] = {"text": text}
        if user_id is not None:
            body["user_id"] = user_id
        if agent_id is not None:
            body["agent_id"] = agent_id
        if session_id is not None:
            body["session_id"] = session_id
        if metadata is not None:
            body["metadata"] = metadata

        data = self._request("POST", "/v1/memory", json=body)
        return MemoryResponse.from_dict(data)

    def get(self, memory_id: str) -> MemoryResponse:
        """Retrieve a single memory by ID.

        Args:
            memory_id: The UUID of the memory to retrieve.

        Returns:
            MemoryResponse for the requested memory.

        Raises:
            NotFoundError: If no memory with that ID exists.
        """
        if not isinstance(memory_id, str):
            raise TypeError(f"memory_id must be a str, got {type(memory_id).__name__}")
        data = self._request("GET", f"/v1/memory/{memory_id}")
        return MemoryResponse.from_dict(data)

    def list(
        self,
        *,
        page: int = 1,
        per_page: int = 20,
        user_id: Optional[str] = None,
        agent_id: Optional[str] = None,
        session_id: Optional[str] = None,
    ) -> MemoryListResponse:
        """List memories with optional filtering and pagination.

        Args:
            page:       Page number, 1-indexed (default 1).
            per_page:   Number of memories per page (default 20).
            user_id:    Filter to memories scoped to this user.
            agent_id:   Filter to memories scoped to this agent.
            session_id: Filter to memories belonging to this session.

        Returns:
            MemoryListResponse with memories list, total, page, page_size.
        """
        if not isinstance(page, int):
            raise TypeError(f"page must be an int, got {type(page).__name__}")
        if not isinstance(per_page, int):
            raise TypeError(f"per_page must be an int, got {type(per_page).__name__}")

        params: dict[str, Any] = {"page": page, "per_page": per_page}
        if user_id is not None:
            params["user_id"] = user_id
        if agent_id is not None:
            params["agent_id"] = agent_id
        if session_id is not None:
            params["session_id"] = session_id

        data = self._request("GET", "/v1/memory", params=params)
        return MemoryListResponse.from_dict(data)

    def update(
        self,
        memory_id: str,
        *,
        text: Optional[str] = None,
        metadata: Optional[dict] = None,
    ) -> MemoryResponse:
        """Update an existing memory's text or metadata.

        Only provided fields are updated.

        Args:
            memory_id: The UUID of the memory to update.
            text:      New text content (optional).
            metadata:  New metadata dict (optional).

        Returns:
            MemoryResponse for the updated memory.

        Raises:
            NotFoundError: If no memory with that ID exists.
        """
        if not isinstance(memory_id, str):
            raise TypeError(f"memory_id must be a str, got {type(memory_id).__name__}")
        if text is not None and not isinstance(text, str):
            raise TypeError(f"text must be a str or None, got {type(text).__name__}")
        if metadata is not None and not isinstance(metadata, dict):
            raise TypeError(f"metadata must be a dict or None, got {type(metadata).__name__}")

        body: dict[str, Any] = {}
        if text is not None:
            body["text"] = text
        if metadata is not None:
            body["metadata"] = metadata

        data = self._request("PATCH", f"/v1/memory/{memory_id}", json=body)
        return MemoryResponse.from_dict(data)

    def delete(self, memory_id: str) -> bool:
        """Delete a memory by ID.

        Args:
            memory_id: The UUID of the memory to delete.

        Returns:
            True on success.

        Raises:
            NotFoundError: If no memory with that ID exists.
        """
        if not isinstance(memory_id, str):
            raise TypeError(f"memory_id must be a str, got {type(memory_id).__name__}")
        self._request("DELETE", f"/v1/memory/{memory_id}")
        return True

    def search(
        self,
        query: str,
        *,
        user_id: Optional[str] = None,
        agent_id: Optional[str] = None,
        session_id: Optional[str] = None,
        limit: int = 10,
        min_score: Optional[float] = None,
    ) -> SearchResponse:
        """Semantic search over stored memories.

        Args:
            query:      Natural language search query (required).
            user_id:    Filter results to this user ID.
            agent_id:   Filter results to this agent ID.
            session_id: Filter results to this session ID.
            limit:      Maximum number of results to return (default 10, max 100).
            min_score:  Minimum relevance score threshold (0.0 – 1.0).

        Returns:
            SearchResponse with results list, total, memories_used, memories_limit.
        """
        if not isinstance(query, str):
            raise TypeError(f"query must be a str, got {type(query).__name__}")
        if not isinstance(limit, int):
            raise TypeError(f"limit must be an int, got {type(limit).__name__}")
        if min_score is not None and not isinstance(min_score, (int, float)):
            raise TypeError(
                f"min_score must be a float or None, got {type(min_score).__name__}"
            )

        body: dict[str, Any] = {"query": query, "limit": limit}
        if user_id is not None:
            body["user_id"] = user_id
        if agent_id is not None:
            body["agent_id"] = agent_id
        if session_id is not None:
            body["session_id"] = session_id
        if min_score is not None:
            body["min_score"] = float(min_score)

        data = self._request("POST", "/v1/memory/search", json=body)
        return SearchResponse.from_dict(data)

    def gaps(self) -> GapResponse:
        """Detect knowledge gaps in the stored memories.

        Returns:
            GapResponse with a list of detected gap descriptions.
        """
        data = self._request("POST", "/v1/memory/gaps", json={})
        return GapResponse.from_dict(data)

    # ------------------------------------------------------------------
    # Billing methods
    # ------------------------------------------------------------------

    def billing_status(self) -> BillingStatusResponse:
        """Return the current plan, usage counts, limits, and Stripe billing dates.

        Returns:
            BillingStatusResponse with plan, memory_count, memory_limit,
            search_count_today, search_limit_today, and optional Stripe fields.
        """
        data = self._request("GET", "/v1/billing/status")
        return BillingStatusResponse.from_dict(data)

    def checkout(
        self,
        *,
        success_url: Optional[str] = None,
        cancel_url: Optional[str] = None,
    ) -> CheckoutResponse:
        """Create a Stripe Checkout session for upgrading to Pro.

        Args:
            success_url: URL to redirect to after a successful checkout.
            cancel_url:  URL to redirect to if the user cancels checkout.

        Returns:
            CheckoutResponse with checkout_url (redirect here) and session_id.
        """
        if success_url is not None and not isinstance(success_url, str):
            raise TypeError(
                f"success_url must be a str or None, got {type(success_url).__name__}"
            )
        if cancel_url is not None and not isinstance(cancel_url, str):
            raise TypeError(
                f"cancel_url must be a str or None, got {type(cancel_url).__name__}"
            )

        body: dict[str, Any] = {}
        if success_url is not None:
            body["success_url"] = success_url
        if cancel_url is not None:
            body["cancel_url"] = cancel_url

        data = self._request("POST", "/v1/billing/checkout", json=body)
        return CheckoutResponse.from_dict(data)

    # ------------------------------------------------------------------
    # Lifecycle
    # ------------------------------------------------------------------

    def close(self) -> None:
        """Close the underlying HTTP connection pool."""
        self._http.close()

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

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

    def __repr__(self) -> str:
        prefix = self._api_key[:7] if len(self._api_key) >= 7 else self._api_key
        return f"MemoraLabs(api_key='{prefix}...')"
