"""Minimal reference client for the Elastio REST API.

This is a copy-paste/downloadable reference client published by the Elastio
Developer Portal (https://developer.elastio.com/tools/clients/python/). It is
not an official package and is not published on PyPI. It depends only on
``requests``.

Example::

    from elastio_client import ElastioClient

    client = ElastioClient("acme")              # token from $ELASTIO_TOKEN
    page = client.get("/gateway/api/v2/incidents", params={"status": "open"})
    for incident in page["data"]:
        print(incident["handle"], incident["status"])
"""

from __future__ import annotations

import os
import random
import time
from typing import Any, Dict, Iterator, Mapping, Optional

import requests

__all__ = ["ElastioClient", "ElastioAPIError"]
__version__ = "0.1.0"

JSON = Any
_IDEMPOTENT = frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"})


class ElastioAPIError(Exception):
    """Raised for any non-2xx response from the Elastio API.

    The gateway returns errors as ``{"status": 404, "error": "Not Found",
    "message": "..."}``. When the body is not in that shape, ``error`` falls
    back to the HTTP reason phrase and ``message`` to the raw response text.
    """

    def __init__(self, status: int, error: str, message: str, response: Optional[requests.Response] = None) -> None:
        self.status = status
        self.error = error
        self.message = message
        self.response = response
        super().__init__(f"{status} {error}: {message}" if message else f"{status} {error}")

    @classmethod
    def from_response(cls, resp: requests.Response) -> "ElastioAPIError":
        status, error, message = resp.status_code, resp.reason or "", resp.text or ""
        try:
            body = resp.json()
        except ValueError:
            body = None
        if isinstance(body, dict):
            status = int(body.get("status") or status)
            error = str(body.get("error") or error)
            message = str(body.get("message") or message)
        return cls(status, error, message, resp)


class ElastioClient:
    """Thin wrapper around ``requests`` for one Elastio tenant.

    Args:
        tenant: Your console subdomain, e.g. ``"acme"`` for
            ``https://acme.app.elastio.com``.
        token: Personal access token. Defaults to the ``ELASTIO_TOKEN``
            environment variable.
        base_url: Override the base URL (defaults to
            ``https://{tenant}.app.elastio.com``).
        timeout: Per-request timeout in seconds.
        max_retries: How many times to retry idempotent requests
            (GET, PUT, DELETE) after a 5xx response or a connection error.
        backoff: Base delay in seconds for exponential backoff between retries.
        session: Optional pre-configured ``requests.Session``.
    """

    def __init__(
        self,
        tenant: Optional[str] = None,
        token: Optional[str] = None,
        *,
        base_url: Optional[str] = None,
        timeout: float = 30.0,
        max_retries: int = 3,
        backoff: float = 0.5,
        session: Optional[requests.Session] = None,
    ) -> None:
        if not base_url and not tenant:
            raise ValueError("Pass a tenant (console subdomain) or a base_url")
        token = token or os.environ.get("ELASTIO_TOKEN")
        if not token:
            raise ValueError("No token: pass token= or set the ELASTIO_TOKEN environment variable")
        self.base_url = (base_url or f"https://{tenant}.app.elastio.com").rstrip("/")
        self.timeout = timeout
        self.max_retries = max_retries
        self.backoff = backoff
        self.session = session or requests.Session()
        self.session.headers.update(
            {
                "Authorization": f"Bearer {token}",
                "Accept": "application/json",
                "User-Agent": f"elastio-reference-client-python/{__version__}",
            }
        )

    # ------------------------------------------------------------------ core

    def request(
        self,
        method: str,
        path: str,
        *,
        params: Optional[Mapping[str, Any]] = None,
        json: JSON = None,
        headers: Optional[Mapping[str, str]] = None,
    ) -> JSON:
        """Send a request and return the parsed JSON body (``None`` if empty).

        ``path`` is relative to the base URL, e.g. ``/gateway/api/v2/incidents``.
        Raises :class:`ElastioAPIError` for non-2xx responses.
        """
        method = method.upper()
        url = path if path.startswith("http") else f"{self.base_url}/{path.lstrip('/')}"
        clean = {k: v for k, v in (params or {}).items() if v is not None}
        retries = self.max_retries if method in _IDEMPOTENT else 0

        attempt = 0
        while True:
            try:
                resp = self.session.request(
                    method, url, params=clean, json=json, headers=headers, timeout=self.timeout
                )
            except (requests.ConnectionError, requests.Timeout):
                if attempt >= retries:
                    raise
            else:
                if resp.status_code < 500 or attempt >= retries:
                    break
            attempt += 1
            time.sleep(self.backoff * (2 ** (attempt - 1)) + random.uniform(0, self.backoff))

        if not resp.ok:
            raise ElastioAPIError.from_response(resp)
        if resp.status_code == 204 or not resp.content:
            return None
        try:
            return resp.json()
        except ValueError:
            return resp.text

    def get(self, path: str, params: Optional[Mapping[str, Any]] = None, **kw: Any) -> JSON:
        """``GET path`` and return parsed JSON."""
        return self.request("GET", path, params=params, **kw)

    def post(self, path: str, json: JSON = None, params: Optional[Mapping[str, Any]] = None, **kw: Any) -> JSON:
        """``POST path`` with a JSON body. Not retried."""
        return self.request("POST", path, params=params, json=json, **kw)

    def put(self, path: str, json: JSON = None, params: Optional[Mapping[str, Any]] = None, **kw: Any) -> JSON:
        """``PUT path`` with a JSON body."""
        return self.request("PUT", path, params=params, json=json, **kw)

    def patch(self, path: str, json: JSON = None, params: Optional[Mapping[str, Any]] = None, **kw: Any) -> JSON:
        """``PATCH path`` with a JSON body. Not retried."""
        return self.request("PATCH", path, params=params, json=json, **kw)

    def delete(self, path: str, params: Optional[Mapping[str, Any]] = None, **kw: Any) -> JSON:
        """``DELETE path``. Returns ``None`` for ``204 No Content``."""
        return self.request("DELETE", path, params=params, **kw)

    # ------------------------------------------------------------ pagination

    @staticmethod
    def _dig(body: JSON, dotted: str) -> Any:
        for part in dotted.split("."):
            if not isinstance(body, dict):
                return None
            body = body.get(part)
        return body

    def paginate_token(
        self,
        path: str,
        params: Optional[Mapping[str, Any]] = None,
        page_size: int = 100,
        items_key: str = "data",
        *,
        token_param: str = "next_page_token",
        size_param: str = "page_size",
    ) -> Iterator[Any]:
        """Yield every item from a token-paginated list operation.

        Sends ``page_size`` and, after the first page, the token from
        ``pagination.next_page_token`` (also looked up under
        ``data.pagination``). Stops when no token is returned.

        Args:
            items_key: Dotted path to the item array, e.g. ``"data"`` or
                ``"data.assets"``.
            token_param: Query parameter that carries the token. Most
                operations use ``next_page_token``; some use ``page_token``
                or ``pageToken``. Check the operation in the spec.
            size_param: Query parameter for the page size (``page_size`` or
                ``pageSize``).
        """
        query: Dict[str, Any] = dict(params or {})
        query[size_param] = page_size
        while True:
            body = self.get(path, params=query)
            yield from self._dig(body, items_key) or []
            pagination = self._dig(body, "pagination") or self._dig(body, "data.pagination") or {}
            token = pagination.get("next_page_token") if isinstance(pagination, dict) else None
            if not token:
                return
            query[token_param] = token

    def paginate_offset(
        self,
        path: str,
        params: Optional[Mapping[str, Any]] = None,
        limit: int = 100,
        items_key: str = "data",
    ) -> Iterator[Any]:
        """Yield every item from a ``limit``/``offset`` list operation.

        Expects a response with ``total`` and ``data``. Stops when ``offset``
        reaches ``total`` or a page comes back short.
        """
        query: Dict[str, Any] = dict(params or {})
        offset = int(query.pop("offset", 0) or 0)
        while True:
            query.update(limit=limit, offset=offset)
            body = self.get(path, params=query)
            items = self._dig(body, items_key) or []
            yield from items
            offset += len(items)
            total = body.get("total") if isinstance(body, dict) else None
            if not items or len(items) < limit or (total is not None and offset >= int(total)):
                return

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

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


if __name__ == "__main__":  # pragma: no cover - smoke test
    import json as _json
    import sys

    if len(sys.argv) < 3:
        print("usage: python elastio_client.py <tenant> <path>   (token from $ELASTIO_TOKEN)", file=sys.stderr)
        sys.exit(2)
    try:
        print(_json.dumps(ElastioClient(sys.argv[1]).get(sys.argv[2]), indent=2))
    except ElastioAPIError as err:
        print(f"error: {err}", file=sys.stderr)
        sys.exit(1)
