"""Synchronous Influxion client."""

from __future__ import annotations

import time
from typing import Any

import httpx

from ._base import (
    MAX_RETRIES,
    InfluxionConfig,
    _logger,
    auth_headers,
    backoff_seconds,
    build_config,
    map_http_error,
    resolve_project_and_agent,
    should_retry_status,
)
from ._exceptions import AuthenticationError, InfluxionError, NetworkError
from ._types import AgentConfig


class InfluxionClient:
    """Synchronous Influxion SDK client.

    Example::

        influxion.init(api_key="sk-...", project_id="my-project", agent_id="report-agent")
        client = InfluxionClient()
        client.create_session(
            session_id="my-session-123",
            name="weekly-report-run",
        )
        client.create_session_artifact(
            session_id="my-session-123",
            name="report",
            artifact="The quarterly results show...",
        )
        client.close()

    Use as a context manager to ensure the underlying HTTP client is closed.
    Example::

        with InfluxionClient() as client:
            ...

    """

    def __init__(
        self,
        *,
        api_key: str | None = None,
        base_url: str | None = None,
        fail_silently: bool = True,
    ) -> None:
        """
        Args:
            api_key: API key for authentication. Falls back to the
                ``INFLUXION_API_KEY`` environment variable. Raises
                :exc:`~influxion.AuthenticationError` immediately if neither
                is set.
            base_url: Base URL for the Influxion API. Falls back to the
                ``INFLUXION_BASE_URL`` environment variable, then
                ``https://api.influxion.io/v1``.
            fail_silently: If ``True`` (default), call-time errors are logged
                and methods return ``None`` instead of raising. Set to
                ``False`` during development to surface errors immediately.
                :exc:`~influxion.AuthenticationError` at construction always
                raises regardless of this setting.
        """
        self._config: InfluxionConfig = build_config(api_key, base_url, fail_silently)
        self._http = httpx.Client(
            headers=auth_headers(self._config.api_key),
            timeout=30.0,
        )

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

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

    def __enter__(self) -> InfluxionClient:
        return self

    def __exit__(self, *args: Any) -> bool:
        self.close()
        return False

    # ------------------------------------------------------------------
    # Internal request machinery
    # ------------------------------------------------------------------

    def _request(self, method: str, path: str, **kwargs: Any) -> Any:
        """Execute an HTTP request with retry/backoff logic.

        Returns the parsed JSON body on success, or ``None`` for empty
        responses (e.g. 204). Raises on unrecoverable errors.
        """
        url = f"{self._config.base_url}{path}"
        last_exc: Exception | None = None

        for attempt in range(MAX_RETRIES):
            try:
                response = self._http.request(method, url, **kwargs)

                if response.is_success:
                    return response.json() if response.content else None

                try:
                    detail = response.json().get("detail", response.text)
                except Exception:
                    detail = response.text or str(response.status_code)

                if response.status_code == 401:
                    raise AuthenticationError(f"Authentication failed: {detail}")

                error = map_http_error(response.status_code, str(detail))
                if (
                    should_retry_status(response.status_code)
                    and attempt < MAX_RETRIES - 1
                ):
                    last_exc = error
                    time.sleep(backoff_seconds(attempt))
                    continue
                raise error

            except (AuthenticationError, InfluxionError):
                raise
            except httpx.TransportError as exc:
                last_exc = NetworkError(f"Network error: {exc}")
                if attempt < MAX_RETRIES - 1:
                    time.sleep(backoff_seconds(attempt))
                    continue
                raise last_exc from exc

        # Reached only when all retries consumed via `continue` on the last attempt,
        # which cannot happen given the loop structure — but satisfies type checkers.
        raise last_exc  # type: ignore[misc]

    def _call(self, method: str, path: str, **kwargs: Any) -> Any:
        """Wrap :meth:`_request` applying ``fail_silently`` behaviour."""
        try:
            return self._request(method, path, **kwargs)
        except AuthenticationError:
            raise  # always propagates
        except Exception as exc:
            if not self._config.fail_silently:
                raise
            _logger.error("Influxion SDK error (%s %s): %s", method, path, exc)
            return

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def create_session(
        self,
        *,
        session_id: str,
        name: str,
        tags: list[str] | None = None,
        metadata: dict[str, Any] | None = None,
        project_id: str | None = None,
        agent_id: str | None = None,
    ) -> None:
        """Create a new agent session.

        Args:
            session_id: Your own identifier for this session (e.g. from your
                job or run ID). Required; used to reference the session in
                other methods via ``session_id``.
            name: A human-readable label for this session.
            tags: Optional list of tags. Deduplicated server-side.
            metadata: Optional JSON-serializable dict attached to the session.
            project_id: The Influxion project ID. Falls back to
                the value set by :func:`influxion.init`.
            agent_id: An identifier for your agent (e.g. ``"email-agent"``).
                Falls back to the value set by :func:`influxion.init`.

        Returns:
            ``None``. The session is identified by ``session_id`` in later
            calls. On error when ``fail_silently=True``, logs and returns
            ``None``; otherwise raises.
        """
        resolved_project_id, resolved_agent_id = resolve_project_and_agent(
            project_id, agent_id
        )
        body: dict[str, Any] = {
            "session_id": session_id,
            "name": name,
            "project_id": resolved_project_id,
            "agent_id": resolved_agent_id,
        }
        if tags is not None:
            body["tags"] = tags
        if metadata is not None:
            body["metadata"] = metadata
        self._call("POST", "/agent_sessions", json=body)

    def get_config(
        self,
        *,
        config_id: str | None = None,
        session_id: str | None = None,
        project_id: str | None = None,
        agent_id: str | None = None,
    ) -> AgentConfig | None:
        """Fetch agent configuration from Influxion.

        Returns the default configuration, or a specific config when
        ``config_id`` is provided. When A/B testing is in effect, pass
        ``session_id`` for consistent variant selection.

        Args:
            config_id: Config slug to fetch. If not set, the default config is returned.
            session_id: Optional. The session to get a configuration for.
            project_id: Influxion project ID. Falls back to :func:`influxion.init` value.
            agent_id: Agent identifier. Falls back to :func:`influxion.init` value.

        Returns:
            The agent config, or ``None`` on error when ``fail_silently=True``.
        """
        has_config_id = config_id is not None and bool((config_id or "").strip())
        has_session_id = session_id is not None and bool((session_id or "").strip())
        if has_config_id and has_session_id:
            raise ValueError(
                "config_id and session_id are mutually exclusive; provide at most one"
            )
        resolved_project, resolved_agent = resolve_project_and_agent(
            project_id, agent_id
        )
        params: dict[str, str] = {
            "project_id": resolved_project,
            "agent_id": resolved_agent,
        }
        if config_id is not None and (config_id or "").strip():
            params["config_id"] = (config_id or "").strip()
        elif session_id is not None and (session_id or "").strip():
            params["session_id"] = (session_id or "").strip()
        else:
            params["default"] = "true"
        data = self._call("GET", "/agent_configs", params=params)
        if data is None:
            return None
        configs = data.get("configs") or []
        if not configs:
            return None
        return AgentConfig.model_validate(configs[0])

    def create_session_artifact(
        self,
        *,
        session_id: str,
        name: str,
        artifact: str,
        type: str = "text",
        tags: list[str] | None = None,
        metadata: dict[str, Any] | None = None,
        project_id: str | None = None,
        agent_id: str | None = None,
    ) -> None:
        """Upload an artifact to a session.

        Args:
            session_id: Your identifier for the session (same as
                ``session_id`` used in :meth:`create_session`).
            name: A name for the artifact (e.g. ``"executive-summary"``).
            artifact: The artifact payload — the content to store and evaluate.
            type: Content type. Currently ``"text"``.
            tags: Optional list of tags. Deduplicated server-side.
            metadata: Optional JSON-serializable dict attached to the artifact.
            project_id: Optional. Falls back to :func:`influxion.init` value.
            agent_id: Optional. Falls back to :func:`influxion.init` value.

        Returns:
            ``None``. On error when ``fail_silently=True``, logs and returns
            ``None``; otherwise raises.
        """
        resolved_project, resolved_agent = resolve_project_and_agent(
            project_id, agent_id
        )
        body: dict[str, Any] = {
            "session_id": session_id,
            "project_id": resolved_project,
            "agent_id": resolved_agent,
            "name": name,
            "artifact": artifact,
            "type": type,
        }
        if tags is not None:
            body["tags"] = tags
        if metadata is not None:
            body["metadata"] = metadata
        self._call("POST", "/agent_sessions/artifacts", json=body)

    def create_session_tool_call(
        self,
        *,
        session_id: str,
        name: str,
        input: dict[str, Any] | None = None,
        output: dict[str, Any] | None = None,
        tags: list[str] | None = None,
        metadata: dict[str, Any] | None = None,
        project_id: str | None = None,
        agent_id: str | None = None,
    ) -> None:
        """Report a tool call for a session.

        Args:
            session_id: Your identifier for the session (same as
                ``session_id`` used in :meth:`create_session`).
            name: Name of the tool that was invoked.
            input: Optional JSON-serializable input passed to the tool.
            output: Optional JSON-serializable output from the tool.
            tags: Optional list of tags. Deduplicated server-side.
            metadata: Optional JSON-serializable dict attached to the tool call.
            project_id: Optional. Falls back to :func:`influxion.init` value.
            agent_id: Optional. Falls back to :func:`influxion.init` value.

        Returns:
            ``None``. On error when ``fail_silently=True``, logs and returns
            ``None``; otherwise raises.
        """
        resolved_project, resolved_agent = resolve_project_and_agent(
            project_id, agent_id
        )
        body: dict[str, Any] = {
            "session_id": session_id,
            "project_id": resolved_project,
            "agent_id": resolved_agent,
            "name": name,
        }
        if input is not None:
            body["input"] = input
        if output is not None:
            body["output"] = output
        if tags is not None:
            body["tags"] = tags
        if metadata is not None:
            body["metadata"] = metadata
        self._call("POST", "/agent_sessions/tool_calls", json=body)

    def update_session(
        self,
        *,
        session_id: str,
        success: bool | None = None,
        failure_message: str | None = None,
        tags: list[str] | None = None,
        metadata: dict[str, Any] | None = None,
        project_id: str | None = None,
        agent_id: str | None = None,
    ) -> None:
        """Update mutable fields on an existing session.

        Args:
            session_id: Your identifier for the session (same as
                ``session_id`` used in :meth:`create_session`).
            success: Whether the session completed successfully. When provided,
                the server records the session's end time.
            failure_message: Optional message when ``success=False``.
            tags: Optional list of tags. Replaces session tags on the server.
            metadata: Optional JSON-serializable dict. Merges with or replaces
                session metadata on the server.
            project_id: Optional. Falls back to :func:`influxion.init` value.
            agent_id: Optional. Falls back to :func:`influxion.init` value.

        Returns:
            ``None``. On error when ``fail_silently=True``, logs and returns
            ``None``; otherwise raises.

        If no mutable fields are provided, no request is made. Use
        :meth:`end_session` as a convenience wrapper when recording the
        session's final outcome.
        """
        body: dict[str, Any] = {}
        if success is not None:
            body["success"] = success
        if failure_message is not None:
            body["failure_message"] = failure_message
        if tags is not None:
            body["tags"] = tags
        if metadata is not None:
            body["metadata"] = metadata
        if not body:
            return
        resolved_project, resolved_agent = resolve_project_and_agent(
            project_id, agent_id
        )
        body["session_id"] = session_id
        body["project_id"] = resolved_project
        body["agent_id"] = resolved_agent
        self._call("PATCH", "/agent_sessions", json=body)

    def end_session(
        self,
        *,
        session_id: str,
        success: bool = True,
        failure_message: str | None = None,
        project_id: str | None = None,
        agent_id: str | None = None,
    ) -> None:
        """Record the outcome of a session and mark it as ended.

        Args:
            session_id: Your identifier for the session (same as
                ``session_id`` used in :meth:`create_session`).
            success: Whether the session completed successfully. Defaults to
                ``True``.
            failure_message: Optional description when ``success=False``.
            project_id: Optional. Falls back to :func:`influxion.init` value.
            agent_id: Optional. Falls back to :func:`influxion.init` value.

        Returns:
            ``None``. On error when ``fail_silently=True``, logs and returns
            ``None``; otherwise raises.
        """
        self.update_session(
            session_id=session_id,
            project_id=project_id,
            agent_id=agent_id,
            success=success,
            failure_message=failure_message,
        )
