from __future__ import annotations

import json
import os
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import quote
from urllib.request import Request, urlopen

from .errors import (
    UniServerConnectionError,
    UniServerError,
    map_api_error,
)


DEFAULT_BASE_URL = "https://uniserver-hyco.onrender.com"
ENV_BASE_URL = "UNISERVER_BASE_URL"
ENV_ACCOUNT_KEY = "UNISERVER_ACCOUNT_KEY"


class UniServerClient:
    """
    Python client for the UniServer API.

    This client mirrors the server routes in `Server/main.py`.
    """

    def __init__(
        self,
        base_url: str | None = None,
        account_api_key: str | None = None,
        timeout: float = 30.0,
    ) -> None:
        resolved_base_url = base_url or os.getenv(ENV_BASE_URL) or DEFAULT_BASE_URL
        self.base_url = self._normalize_base_url(resolved_base_url)

        resolved_account_key = account_api_key
        if resolved_account_key is None:
            resolved_account_key = os.getenv(ENV_ACCOUNT_KEY)

        self.account_api_key = self._normalize_optional_key(resolved_account_key)

        self.timeout = float(timeout)
        if self.timeout <= 0:
            raise ValueError("timeout must be greater than zero.")

        self._project_api_keys: dict[str, str] = {}

    @staticmethod
    def _normalize_base_url(base_url: str) -> str:
        normalized = (base_url or "").strip().rstrip("/")
        if not normalized:
            raise ValueError("base_url cannot be empty.")
        return normalized

    @staticmethod
    def _normalize_optional_key(value: str | None) -> str | None:
        if value is None:
            return None
        key = value.strip()
        return key or None

    @staticmethod
    def _quote(segment: Any) -> str:
        return quote(str(segment), safe="")

    @staticmethod
    def _decode_json_payload(raw: bytes) -> Any:
        if not raw:
            return None

        text = raw.decode("utf-8")
        if not text.strip():
            return None

        try:
            return json.loads(text)
        except json.JSONDecodeError:
            return {"raw": text}

    @staticmethod
    def _message_from_payload(payload: Any, fallback: str) -> tuple[str, str | None]:
        if isinstance(payload, dict):
            message = payload.get("message") or payload.get("error") or fallback
            error_code = payload.get("error") if isinstance(payload.get("error"), str) else None
            return str(message), error_code
        return fallback, None

    def set_base_url(self, base_url: str) -> None:
        self.base_url = self._normalize_base_url(base_url)

    def set_account_api_key(self, account_api_key: str) -> None:
        key = self._normalize_optional_key(account_api_key)
        if key is None:
            raise ValueError("account_api_key cannot be empty.")
        self.account_api_key = key

    def clear_account_api_key(self) -> None:
        self.account_api_key = None

    def set_project_api_key(self, project_id: int | str, project_api_key: str) -> None:
        key = self._normalize_optional_key(project_api_key)
        if key is None:
            raise ValueError("project_api_key cannot be empty.")
        self._project_api_keys[str(project_id)] = key

    def get_project_api_key(self, project_id: int | str) -> str | None:
        return self._project_api_keys.get(str(project_id))

    def clear_project_api_key(self, project_id: int | str) -> None:
        self._project_api_keys.pop(str(project_id), None)

    def clear_all_project_api_keys(self) -> None:
        self._project_api_keys.clear()

    def health(self) -> dict[str, Any] | None:
        return self._request("GET", "/health")

    def create_account(
        self,
        account_id: str | None = None,
        display_name: str | None = None,
    ) -> dict[str, Any]:
        body: dict[str, Any] = {}
        if account_id is not None:
            body["accountId"] = account_id
        if display_name is not None:
            body["displayName"] = display_name

        response = self._request("POST", "/v1/accounts", body=body)

        api_key = None
        if isinstance(response, dict):
            api_key = self._normalize_optional_key(response.get("apiKey"))

        if api_key:
            self.account_api_key = api_key

        return response

    def get_account(self, account_api_key: str | None = None) -> dict[str, Any]:
        return self._request(
            "GET",
            "/v1/accounts/me",
            account_api_key=account_api_key,
            require_account_key=True,
        )

    def create_account_api_key(
        self,
        label: str | None = None,
        expires_at: str | None = None,
        account_api_key: str | None = None,
    ) -> dict[str, Any]:
        body: dict[str, Any] = {}
        if label is not None:
            body["label"] = label
        if expires_at is not None:
            body["expiresAt"] = expires_at

        return self._request(
            "POST",
            "/v1/accounts/me/api-keys",
            body=body,
            account_api_key=account_api_key,
            require_account_key=True,
        )

    def delete_account_api_key(
        self,
        api_key: str,
        account_api_key: str | None = None,
    ) -> dict[str, Any] | None:
        return self._request(
            "DELETE",
            f"/v1/accounts/me/api-keys/{self._quote(api_key)}",
            account_api_key=account_api_key,
            require_account_key=True,
        )

    def create_project(
        self,
        name: str,
        account_api_key: str | None = None,
        remember_project_key: bool = True,
    ) -> dict[str, Any]:
        response = self._request(
            "POST",
            "/v1/projects",
            body={"name": name},
            account_api_key=account_api_key,
            require_account_key=True,
        )

        if remember_project_key:
            self._remember_first_project_key(response)

        return response

    def list_projects(self, account_api_key: str | None = None) -> dict[str, Any]:
        return self._request(
            "GET",
            "/v1/projects",
            account_api_key=account_api_key,
            require_account_key=True,
        )

    def get_project(
        self,
        project_id: int | str,
        account_api_key: str | None = None,
        remember_project_key: bool = False,
    ) -> dict[str, Any]:
        response = self._request(
            "GET",
            f"/v1/projects/{self._quote(project_id)}",
            account_api_key=account_api_key,
            require_account_key=True,
        )

        if remember_project_key:
            self._remember_first_project_key(response)

        return response

    def update_project(
        self,
        project_id: int | str,
        name: str,
        account_api_key: str | None = None,
    ) -> dict[str, Any]:
        return self._request(
            "PATCH",
            f"/v1/projects/{self._quote(project_id)}",
            body={"name": name},
            account_api_key=account_api_key,
            require_account_key=True,
        )

    def delete_project(
        self,
        project_id: int | str,
        account_api_key: str | None = None,
    ) -> dict[str, Any] | None:
        self.clear_project_api_key(project_id)
        return self._request(
            "DELETE",
            f"/v1/projects/{self._quote(project_id)}",
            account_api_key=account_api_key,
            require_account_key=True,
        )

    def create_project_api_key(
        self,
        project_id: int | str,
        scopes: list[str] | tuple[str, ...] = ("read", "write"),
        expires_at: str | None = None,
        account_api_key: str | None = None,
        remember_project_key: bool = True,
    ) -> dict[str, Any]:
        normalized_scopes = [str(scope).strip().lower() for scope in scopes if str(scope).strip()]
        if not normalized_scopes:
            raise ValueError("At least one scope is required.")

        body: dict[str, Any] = {"scopes": normalized_scopes}
        if expires_at is not None:
            body["expiresAt"] = expires_at

        response = self._request(
            "POST",
            f"/v1/projects/{self._quote(project_id)}/api-keys",
            body=body,
            account_api_key=account_api_key,
            require_account_key=True,
        )

        if remember_project_key and isinstance(response, dict):
            project_key = response.get("apiKey", {}).get("key")
            project_key = self._normalize_optional_key(project_key)
            if project_key:
                self.set_project_api_key(project_id, project_key)

        return response

    def delete_project_api_key(
        self,
        project_id: int | str,
        api_key: str,
        account_api_key: str | None = None,
    ) -> dict[str, Any] | None:
        normalized_key = self._normalize_optional_key(api_key)
        if normalized_key is not None and self.get_project_api_key(project_id) == normalized_key:
            self.clear_project_api_key(project_id)

        return self._request(
            "DELETE",
            f"/v1/projects/{self._quote(project_id)}/api-keys/{self._quote(api_key)}",
            account_api_key=account_api_key,
            require_account_key=True,
        )

    def list_kv(
        self,
        project_id: int | str,
        project_api_key: str | None = None,
        account_api_key: str | None = None,
    ) -> dict[str, Any]:
        return self._request(
            "GET",
            f"/v1/projects/{self._quote(project_id)}/data/kv",
            account_api_key=account_api_key,
            project_api_key=project_api_key,
            project_id=project_id,
            require_account_key=True,
            require_project_key=True,
        )

    def get_kv(
        self,
        project_id: int | str,
        item_key: str,
        project_api_key: str | None = None,
        account_api_key: str | None = None,
    ) -> dict[str, Any]:
        return self._request(
            "GET",
            f"/v1/projects/{self._quote(project_id)}/data/kv/{self._quote(item_key)}",
            account_api_key=account_api_key,
            project_api_key=project_api_key,
            project_id=project_id,
            require_account_key=True,
            require_project_key=True,
        )

    def put_kv(
        self,
        project_id: int | str,
        item_key: str,
        value: Any,
        expected_version: int | None = None,
        project_api_key: str | None = None,
        account_api_key: str | None = None,
    ) -> dict[str, Any]:
        body: dict[str, Any] = {"value": value}
        if expected_version is not None:
            body["expectedVersion"] = expected_version

        return self._request(
            "PUT",
            f"/v1/projects/{self._quote(project_id)}/data/kv/{self._quote(item_key)}",
            body=body,
            account_api_key=account_api_key,
            project_api_key=project_api_key,
            project_id=project_id,
            require_account_key=True,
            require_project_key=True,
        )

    def delete_kv(
        self,
        project_id: int | str,
        item_key: str,
        project_api_key: str | None = None,
        account_api_key: str | None = None,
    ) -> dict[str, Any] | None:
        return self._request(
            "DELETE",
            f"/v1/projects/{self._quote(project_id)}/data/kv/{self._quote(item_key)}",
            account_api_key=account_api_key,
            project_api_key=project_api_key,
            project_id=project_id,
            require_account_key=True,
            require_project_key=True,
        )

    def list_objects(
        self,
        project_id: int | str,
        project_api_key: str | None = None,
        account_api_key: str | None = None,
    ) -> dict[str, Any]:
        return self._request(
            "GET",
            f"/v1/projects/{self._quote(project_id)}/data/objects",
            account_api_key=account_api_key,
            project_api_key=project_api_key,
            project_id=project_id,
            require_account_key=True,
            require_project_key=True,
        )

    def get_object(
        self,
        project_id: int | str,
        item_key: str,
        project_api_key: str | None = None,
        account_api_key: str | None = None,
    ) -> dict[str, Any]:
        return self._request(
            "GET",
            f"/v1/projects/{self._quote(project_id)}/data/objects/{self._quote(item_key)}",
            account_api_key=account_api_key,
            project_api_key=project_api_key,
            project_id=project_id,
            require_account_key=True,
            require_project_key=True,
        )

    def put_object(
        self,
        project_id: int | str,
        item_key: str,
        object_type: str,
        serialized: Any,
        encoding: str = "json",
        project_api_key: str | None = None,
        account_api_key: str | None = None,
    ) -> dict[str, Any]:
        body = {
            "type": object_type,
            "serialized": serialized,
            "encoding": encoding,
        }

        return self._request(
            "PUT",
            f"/v1/projects/{self._quote(project_id)}/data/objects/{self._quote(item_key)}",
            body=body,
            account_api_key=account_api_key,
            project_api_key=project_api_key,
            project_id=project_id,
            require_account_key=True,
            require_project_key=True,
        )

    def delete_object(
        self,
        project_id: int | str,
        item_key: str,
        project_api_key: str | None = None,
        account_api_key: str | None = None,
    ) -> dict[str, Any] | None:
        return self._request(
            "DELETE",
            f"/v1/projects/{self._quote(project_id)}/data/objects/{self._quote(item_key)}",
            account_api_key=account_api_key,
            project_api_key=project_api_key,
            project_id=project_id,
            require_account_key=True,
            require_project_key=True,
        )

    def _remember_first_project_key(self, project_payload: Any) -> None:
        if not isinstance(project_payload, dict):
            return

        project_id = project_payload.get("id")
        api_keys = project_payload.get("apiKeys")
        if project_id is None or not isinstance(api_keys, list) or not api_keys:
            return

        key = None
        first = api_keys[0]
        if isinstance(first, dict):
            key = first.get("key")

        key = self._normalize_optional_key(key)
        if key:
            self.set_project_api_key(project_id, key)

    def _resolve_account_api_key(self, account_api_key: str | None, *, required: bool) -> str | None:
        key = self._normalize_optional_key(account_api_key) or self.account_api_key
        if required and not key:
            raise ValueError(
                "An account API key is required. Provide `account_api_key` or call set_account_api_key(...)."
            )
        return key

    def _resolve_project_api_key(
        self,
        project_id: int | str | None,
        project_api_key: str | None,
        *,
        required: bool,
    ) -> str | None:
        key = self._normalize_optional_key(project_api_key)
        if key is None and project_id is not None:
            key = self.get_project_api_key(project_id)

        if required and not key:
            raise ValueError(
                "A project API key is required. Provide `project_api_key` or call set_project_api_key(project_id, ...)."
            )
        return key

    def _request(
        self,
        method: str,
        path: str,
        *,
        body: Any = None,
        account_api_key: str | None = None,
        project_api_key: str | None = None,
        project_id: int | str | None = None,
        require_account_key: bool = False,
        require_project_key: bool = False,
    ) -> Any:
        normalized_path = path if path.startswith("/") else f"/{path}"
        url = f"{self.base_url}{normalized_path}"

        resolved_account_key = self._resolve_account_api_key(
            account_api_key,
            required=require_account_key,
        )
        resolved_project_key = self._resolve_project_api_key(
            project_id,
            project_api_key,
            required=require_project_key,
        )

        headers = {
            "Accept": "application/json",
        }

        if resolved_account_key:
            headers["X-Uniserver-Account-Key"] = resolved_account_key

        if resolved_project_key:
            headers["X-Project-Api-Key"] = resolved_project_key

        data: bytes | None = None
        if body is not None:
            headers["Content-Type"] = "application/json"
            data = json.dumps(body, separators=(",", ":")).encode("utf-8")

        request = Request(
            url=url,
            method=method.upper(),
            headers=headers,
            data=data,
        )

        try:
            with urlopen(request, timeout=self.timeout) as response:
                payload = self._decode_json_payload(response.read())
                return payload
        except HTTPError as exc:
            payload = self._decode_json_payload(exc.read() or b"")
            fallback = f"{exc.code} {exc.reason}"
            message, error_code = self._message_from_payload(payload, fallback)
            raise map_api_error(
                exc.code,
                message=message,
                error_code=error_code,
                payload=payload,
            ) from exc
        except URLError as exc:
            reason = getattr(exc, "reason", exc)
            raise UniServerConnectionError(
                f"Unable to reach UniServer API at {self.base_url}: {reason}"
            ) from exc
        except OSError as exc:
            raise UniServerConnectionError(
                f"Unexpected connection error while reaching {self.base_url}: {exc}"
            ) from exc


def create_client(
    base_url: str | None = None,
    account_api_key: str | None = None,
    timeout: float = 30.0,
) -> UniServerClient:
    """Convenience factory for UniServerClient."""
    return UniServerClient(
        base_url=base_url,
        account_api_key=account_api_key,
        timeout=timeout,
    )
