"""Call hosted Petrophysics.io functions from ordinary Python 3.9+.

Save this file beside your script. No third-party packages are required and no
hosted library source is downloaded. HTTPS is required outside local testing.
"""

import json
import math
import re
from urllib import error, parse, request

__version__ = "24.0.0"
__all__ = ["Client", "RemoteError"]

_SLUG = re.compile(r"[a-z][a-z0-9-]{0,63}\Z")
_FUNCTION = re.compile(r"[A-Za-z][A-Za-z0-9_]{0,127}\Z")
_MAX_RESPONSE_BYTES = 16 * 1024 * 1024


class RemoteError(Exception):
    """Remote failure, with HTTP status and safe backend error information.

    Network/protocol failures have status=None. Requests are never retried:
    a timeout does not prove that the server failed to execute the function.
    """

    def __init__(self, message, *, error_code="remote_error", status=None,
                 details=None):
        super().__init__(message)
        self.error_code = error_code
        self.status = status
        self.details = details


class _NoRedirect(request.HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        # Do not send API credentials or submitted input to a redirected host.
        return None


def _identifier(value, pattern, label):
    if not isinstance(value, str) or not pattern.fullmatch(value):
        raise ValueError("Invalid {}".format(label))
    return value


def _version(value):
    if value is not None and (isinstance(value, bool) or
                              not isinstance(value, int) or value < 1):
        raise ValueError("version must be a positive integer or None")
    return value


def _json_value(value, active=None, depth=0):
    """Convert numpy-like arrays/scalars without importing numpy."""
    if depth > 100:
        raise ValueError("Inputs are nested too deeply")
    if value is None or isinstance(value, (str, bool, int)):
        return value
    if isinstance(value, float):
        if not math.isfinite(value):
            raise ValueError("NaN and infinity are not valid API inputs; use None for missing values")
        return value
    if active is None:
        active = set()
    identity = id(value)
    if identity in active:
        raise ValueError("Circular input structures are not valid JSON")
    active.add(identity)
    try:
        if isinstance(value, dict):
            if any(not isinstance(key, str) for key in value):
                raise TypeError("JSON dictionary keys must be strings")
            return {key: _json_value(item, active, depth + 1)
                    for key, item in value.items()}
        if isinstance(value, (list, tuple)):
            return [_json_value(item, active, depth + 1) for item in value]
        for method_name in ("tolist", "item"):
            method = getattr(value, method_name, None)
            if callable(method):
                converted = method()
                if converted is not value:
                    return _json_value(converted, active, depth + 1)
        raise TypeError("Unsupported API input type: {}".format(type(value).__name__))
    finally:
        active.remove(identity)


def _safe_error(payload, status):
    """Preserve documented error fields without embedding a raw HTML page."""
    details = None
    error_code = "http_error"
    message = "Petrophysics.io returned HTTP {}".format(status)
    if isinstance(payload, dict):
        body = payload.get("error", payload.get("detail"))
        if isinstance(body, dict):
            if isinstance(body.get("code"), str):
                error_code = body["code"]
            elif isinstance(body.get("error_code"), str):
                error_code = body["error_code"]
            if isinstance(body.get("message"), str):
                message = body["message"][:1000]
            details = body.get("details")
        elif isinstance(body, str):
            message = body[:1000]
        elif isinstance(body, list):
            error_code = "validation_error"
            details = body
        elif isinstance(payload.get("message"), str):
            message = payload["message"][:1000]
            error_code = payload.get("code", error_code)
            details = payload.get("details")
    return RemoteError(message, error_code=error_code, status=status, details=details)


class Client:
    """Small JSON API client.

    Example:
        api = Client("https://api.petrophysics.io")
        vsh = api.library("demo").vsh([20, 60, 100], gr_min=20, gr_max=100)

    base_url must be the deployed backend URL, not the static website URL.
    api_key is a consumer key; never give callers the owner admin password.
    """

    def __init__(self, base_url="https://api.petrophysics.io", api_key=None,
                 timeout=30):
        if not isinstance(base_url, str):
            raise ValueError("base_url must be a URL string")
        parts = parse.urlsplit(base_url)
        if (not parts.hostname or parts.username or parts.password or
                parts.query or parts.fragment):
            raise ValueError("base_url must be an absolute URL without credentials, query or fragment")
        try:
            parts.port
        except ValueError as exc:
            raise ValueError("Invalid base_url port") from exc
        if parts.scheme != "https" and not (
                parts.scheme == "http" and
                parts.hostname.lower() in ("localhost", "127.0.0.1", "::1")):
            raise ValueError("HTTPS is required except for localhost/127.0.0.1/::1 testing")
        if any(character.isspace() or ord(character) < 32 for character in base_url):
            raise ValueError("base_url cannot contain whitespace or control characters")
        if parts.path and any(segment in (".", "..") for segment in parts.path.split("/")):
            raise ValueError("base_url cannot contain relative path segments")
        if api_key is not None and (not isinstance(api_key, str) or not api_key or
                                    any(ord(char) < 33 or ord(char) > 126 for char in api_key)):
            raise ValueError("api_key must be a nonempty ASCII string without whitespace")
        if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or not math.isfinite(timeout) or timeout <= 0:
            raise ValueError("timeout must be a positive finite number of seconds")
        self.base_url = base_url.rstrip("/")
        self.api_key = api_key
        self.timeout = timeout
        self._opener = request.build_opener(_NoRedirect())

    def _request(self, method, path, payload=None):
        data = None
        headers = {"Accept": "application/json", "User-Agent": "petrophysics-client/" + __version__}
        if payload is not None:
            data = json.dumps(_json_value(payload), allow_nan=False,
                              separators=(",", ":")).encode("utf-8")
            headers["Content-Type"] = "application/json"
        if self.api_key:
            headers["X-API-Key"] = self.api_key
        req = request.Request(self.base_url + path, data=data, headers=headers,
                              method=method)
        try:
            with self._opener.open(req, timeout=self.timeout) as response:
                raw = response.read(_MAX_RESPONSE_BYTES + 1)
                if len(raw) > _MAX_RESPONSE_BYTES:
                    raise RemoteError("API response exceeds client size limit", error_code="response_too_large")
                result = json.loads(raw.decode("utf-8"),
                                    parse_constant=lambda _: (_ for _ in ()).throw(ValueError("Nonfinite JSON")))
                if not isinstance(result, dict):
                    raise ValueError("API response must be a JSON object")
                return result
        except error.HTTPError as exc:
            try:
                raw = exc.read(_MAX_RESPONSE_BYTES + 1)
                payload = json.loads(raw.decode("utf-8")) if len(raw) <= _MAX_RESPONSE_BYTES else None
            except (ValueError, UnicodeError, OSError):
                payload = None
            raise _safe_error(payload, exc.code) from None
        except (error.URLError, TimeoutError, OSError) as exc:
            raise RemoteError("Could not reach the library service; check its URL and connection",
                              error_code="connection_error") from exc
        except (ValueError, UnicodeError) as exc:
            raise RemoteError("The library service did not return valid JSON",
                              error_code="invalid_response") from exc

    def catalog(self):
        """Return published metadata; key access protects calls, not metadata."""
        data = self._request("GET", "/api/v1/libraries")
        libraries = data.get("libraries")
        if not isinstance(libraries, list):
            raise RemoteError("Catalogue response is missing its libraries list", error_code="invalid_response")
        return libraries

    def _call_result(self, slug, function, args, kwargs, version):
        _identifier(slug, _SLUG, "library slug")
        _identifier(function, _FUNCTION, "function name")
        payload = {"args": args, "kwargs": kwargs}
        if _version(version) is not None:
            payload["version"] = version
        path = "/api/v1/libraries/{}/functions/{}/call".format(slug, function)
        response = self._request("POST", path, payload)
        if "result" not in response:
            raise RemoteError("Call response is missing result", error_code="invalid_response")
        return response["result"]

    def call(self, slug, function, *args, version=None, **kwargs):
        """Call a function once and return its JSON result.

        version pins an immutable published version. Omit it for latest.
        Arguments must be JSON-compatible, numpy-like arrays or scalars.
        """
        return self._call_result(slug, function, args, kwargs, version)

    def library(self, slug, version=None):
        """Return a proxy such as api.library('demo', version=1).vsh(...)."""
        _identifier(slug, _SLUG, "library slug")
        _version(version)
        return _Library(self, slug, version)


class _Library:
    def __init__(self, client, slug, version):
        self._client = client
        self._slug = slug
        self._version = version

    def __getattr__(self, function):
        _identifier(function, _FUNCTION, "function name")

        def remote_function(*args, **kwargs):
            return self._client._call_result(self._slug, function, args, kwargs, self._version)

        remote_function.__name__ = function
        remote_function.__doc__ = "Call hosted function {}.{}".format(self._slug, function)
        return remote_function
