#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# Copyright 2020 Confluent Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
import abc
import asyncio
import asyncio as _locks
import json
import logging
import os
import ssl
import time
import urllib
from typing import Any, Awaitable, Callable, Dict, List, Literal, Optional, Type, Union
from urllib.parse import unquote, urlparse

import certifi
import httpx
from authlib.integrations.httpx_client import AsyncOAuth2Client
from cachetools import Cache, LRUCache, TTLCache
from httpx import Response

from confluent_kafka import version
from confluent_kafka.schema_registry.common._oauthbearer import (
    _AbstractCustomOAuthBearerFieldProviderBuilder,
    _AbstractOAuthBearerOIDCAzureIMDSFieldProviderBuilder,
    _AbstractOAuthBearerOIDCFieldProviderBuilder,
    _AsyncBearerFieldProvider,
    _StaticOAuthBearerFieldProviderBuilder,
)
from confluent_kafka.schema_registry.common.schema_registry_client import (
    RegisteredSchema,
    Schema,
    SchemaVersion,
    ServerConfig,
    _SchemaCache,
    full_jitter,
    is_retriable,
    is_success,
)
from confluent_kafka.schema_registry.error import OAuthTokenError, SchemaRegistryError

__all__ = [
    '_urlencode',
    '_AsyncCustomOAuthClient',
    '_AsyncOAuthClient',
    '_AsyncBaseRestClient',
    '_AsyncRestClient',
    'AsyncSchemaRegistryClient',
]

# TODO: consider adding `six` dependency or employing a compat file
# Python 2.7 is officially EOL so compatibility issue will be come more the norm.
# We need a better way to handle these issues.
# Six is one possibility but the compat file pattern used by requests
# is also quite nice.
#
# six: https://pypi.org/project/six/
# compat file : https://github.com/psf/requests/blob/master/requests/compat.py
try:
    string_type = basestring  # type: ignore[name-defined]  # noqa

    def _urlencode(value: str) -> str:
        return urllib.quote(value, safe='')  # type: ignore[attr-defined]

except NameError:
    string_type = str

    def _urlencode(value: str) -> str:
        return urllib.parse.quote(value, safe='')


log = logging.getLogger(__name__)


class _AsyncCustomOAuthClient(_AsyncBearerFieldProvider):
    def __init__(self, custom_function: Callable[[Dict], Awaitable[Dict]], custom_config: dict):
        self.custom_function = custom_function
        self.custom_config = custom_config

    async def get_bearer_fields(self) -> dict:
        return await self.custom_function(self.custom_config)


class _AsyncAbstractOAuthClient(_AsyncBearerFieldProvider):
    def __init__(
        self, logical_cluster: str, identity_pool: str, max_retries: int, retries_wait_ms: int, retries_max_wait_ms: int
    ):
        self.logical_cluster: str = logical_cluster
        self.identity_pool: str = identity_pool
        self.max_retries: int = max_retries
        self.retries_wait_ms: int = retries_wait_ms
        self.retries_max_wait_ms: int = retries_max_wait_ms
        self.token: str = ""

    async def get_bearer_fields(self) -> dict:
        return {
            'bearer.auth.token': await self.get_access_token(),
            'bearer.auth.logical.cluster': self.logical_cluster,
            'bearer.auth.identity.pool.id': self.identity_pool,
        }

    async def get_access_token(self) -> str:
        if not self.token or self.token_expired():
            await self.generate_access_token()

        return self.token

    @abc.abstractmethod
    def token_expired(self) -> bool:
        raise NotImplementedError

    @abc.abstractmethod
    async def fetch_token(self) -> str:
        raise NotImplementedError

    async def generate_access_token(self) -> None:
        for i in range(self.max_retries + 1):
            try:
                self.token = await self.fetch_token()
                return
            except Exception as e:
                if i >= self.max_retries:
                    raise OAuthTokenError(
                        f"Failed to retrieve token after {self.max_retries} " f"attempts due to error: {str(e)}"
                    )
                await asyncio.sleep(full_jitter(self.retries_wait_ms, self.retries_max_wait_ms, i) / 1000)


class _AsyncOAuthClient(_AsyncAbstractOAuthClient):
    def __init__(
        self,
        client_id: str,
        client_secret: str,
        scope: str,
        token_endpoint: str,
        logical_cluster: str,
        identity_pool: str,
        max_retries: int,
        retries_wait_ms: int,
        retries_max_wait_ms: int,
    ):
        super().__init__(logical_cluster, identity_pool, max_retries, retries_wait_ms, retries_max_wait_ms)
        self.client = AsyncOAuth2Client(client_id=client_id, client_secret=client_secret, scope=scope)
        self.token_endpoint: str = token_endpoint
        self.token_object: dict = {}
        self.token_expiry_threshold: float = 0.8

    def token_expired(self) -> bool:
        expiry_window = self.token_object['expires_in'] * (1 - self.token_expiry_threshold)
        return self.token_object['expires_at'] < time.time() + expiry_window

    async def fetch_token(self) -> str:
        self.token_object = await self.client.fetch_token(url=self.token_endpoint, grant_type='client_credentials')
        return self.token_object['access_token']


class _AsyncOAuthAzureIMDSClient(_AsyncAbstractOAuthClient):
    def __init__(
        self,
        token_endpoint: str,
        logical_cluster: str,
        identity_pool: str,
        max_retries: int,
        retries_wait_ms: int,
        retries_max_wait_ms: int,
    ):
        super().__init__(logical_cluster, identity_pool, max_retries, retries_wait_ms, retries_max_wait_ms)
        self.client = httpx.AsyncClient()
        self.token_endpoint: str = token_endpoint
        self.token_object: dict = {}
        self.token_expiry_threshold: float = 0.8

    def token_expired(self) -> bool:
        expiry_window = int(self.token_object['expires_in']) * (1 - self.token_expiry_threshold)
        return int(self.token_object['expires_on']) < time.time() + expiry_window

    async def fetch_token(self) -> str:
        self.token_object = (await self.client.get(self.token_endpoint, headers=[('Metadata', 'true')])).json()
        return self.token_object['access_token']


class _AsyncOAuthBearerOIDCFieldProviderBuilder(_AbstractOAuthBearerOIDCFieldProviderBuilder):

    def build(self, max_retries: int, retries_wait_ms: int, retries_max_wait_ms: int):
        self._validate()
        return _AsyncOAuthClient(
            self.client_id,
            self.client_secret,
            self.scope,
            self.token_endpoint,
            self.logical_cluster,
            self.identity_pool,
            max_retries,
            retries_wait_ms,
            retries_max_wait_ms,
        )


class _AsyncOAuthBearerOIDCAzureIMDSFieldProviderBuilder(_AbstractOAuthBearerOIDCAzureIMDSFieldProviderBuilder):

    def build(self, max_retries: int, retries_wait_ms: int, retries_max_wait_ms: int):
        self._validate()
        return _AsyncOAuthAzureIMDSClient(
            self.token_endpoint,
            self.logical_cluster,
            self.identity_pool,
            max_retries,
            retries_wait_ms,
            retries_max_wait_ms,
        )


class _AsyncCustomOAuthBearerFieldProviderBuilder(_AbstractCustomOAuthBearerFieldProviderBuilder):

    def build(self, max_retries: int, retries_wait_ms: int, retries_max_wait_ms: int):
        self._validate()
        assert self.custom_function is not None
        assert self.custom_config is not None
        return _AsyncCustomOAuthClient(self.custom_function, self.custom_config)


class _AsyncFieldProviderBuilder:

    __builders: Dict[str, Type[Any]] = {
        "OAUTHBEARER": _AsyncOAuthBearerOIDCFieldProviderBuilder,
        "OAUTHBEARER_AZURE_IMDS": _AsyncOAuthBearerOIDCAzureIMDSFieldProviderBuilder,
        "STATIC_TOKEN": _StaticOAuthBearerFieldProviderBuilder,
        "CUSTOM": _AsyncCustomOAuthBearerFieldProviderBuilder,
    }

    @staticmethod
    def build(conf, max_retries: int, retries_wait_ms: int, retries_max_wait_ms: int):
        bearer_auth_credentials_source = conf.pop('bearer.auth.credentials.source', None)
        if bearer_auth_credentials_source is None:
            return [None, None]

        if bearer_auth_credentials_source not in _AsyncFieldProviderBuilder.__builders:
            raise ValueError('Unrecognized bearer.auth.credentials.source')
        bearer_field_provider_builder = _AsyncFieldProviderBuilder.__builders[bearer_auth_credentials_source](conf)
        return (
            bearer_auth_credentials_source,
            bearer_field_provider_builder.build(max_retries, retries_wait_ms, retries_max_wait_ms),
        )


class _AsyncBaseRestClient(object):

    def __init__(self, conf: dict):
        # copy dict to avoid mutating the original
        conf_copy = conf.copy()

        base_url = conf_copy.pop('url', None)
        if base_url is None:
            raise ValueError("Missing required configuration property url")
        if not isinstance(base_url, string_type):
            raise TypeError("url must be a str, not " + str(type(base_url)))
        base_urls = []
        for url in base_url.split(','):
            url = url.strip().rstrip('/')
            if not url.startswith('http') and not url.startswith('mock'):
                raise ValueError("Invalid url {}".format(url))
            base_urls.append(url)
        if not base_urls:
            raise ValueError("Missing required configuration property url")
        self.base_urls = base_urls

        ca: Union[str, bool, None] = conf_copy.pop('ssl.ca.location', None)
        key: Optional[str] = conf_copy.pop('ssl.key.location', None)
        key_password: Optional[str] = conf_copy.pop('ssl.key.password', None)
        client_cert: Optional[str] = conf_copy.pop('ssl.certificate.location', None)

        # this mimicks legacy, deprecated behaviour of httpx
        # self.verify is always set to an ssl.SSLContext in case we need to load_cert_chain
        if ca is False:
            self.verify = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
            self.verify.check_hostname = False
            self.verify.verify_mode = ssl.CERT_NONE
        elif isinstance(ca, str):
            if os.path.isdir(ca):
                self.verify = ssl.create_default_context(capath=ca)
            else:
                self.verify = ssl.create_default_context(cafile=ca)
        else:
            if os.environ.get("SSL_CERT_FILE"):
                self.verify = ssl.create_default_context(cafile=os.environ["SSL_CERT_FILE"])
            elif os.environ.get("SSL_CERT_DIR"):
                self.verify = ssl.create_default_context(capath=os.environ["SSL_CERT_DIR"])
            else:
                self.verify = ssl.create_default_context(cafile=certifi.where())

        if client_cert is not None:
            if key is not None and key_password is not None:
                self.verify.load_cert_chain(certfile=client_cert, keyfile=key, password=key_password)
            elif key is not None:
                self.verify.load_cert_chain(certfile=client_cert, keyfile=key)
            elif key_password is not None:
                self.verify.load_cert_chain(certfile=client_cert, password=key_password)
            else:
                self.verify.load_cert_chain(certfile=client_cert)

        if (key is not None or key_password is not None) and client_cert is None:
            raise ValueError(
                "ssl.certificate.location required when" " configuring ssl.key.location or ssl.key.password"
            )

        parsed = urlparse(self.base_urls[0])
        try:
            userinfo = (unquote(parsed.username), unquote(parsed.password))
        except (AttributeError, TypeError):
            userinfo = ("", "")
        if 'basic.auth.user.info' in conf_copy:
            if userinfo != ('', ''):
                raise ValueError(
                    "basic.auth.user.info configured with"
                    " userinfo credentials in the URL."
                    " Remove userinfo credentials from the url or"
                    " remove basic.auth.user.info from the"
                    " configuration"
                )

            userinfo = tuple(conf_copy.pop('basic.auth.user.info', '').split(':', 1))

            if len(userinfo) != 2:
                raise ValueError("basic.auth.user.info must be in the form" " of {username}:{password}")

        self.auth = userinfo if userinfo != ('', '') else None

        # The following adds support for proxy config
        # If specified: it uses the specified proxy details when making requests
        self.proxy = None
        proxy = conf_copy.pop('proxy', None)
        if proxy is not None:
            self.proxy = proxy

        self.timeout = None
        timeout = conf_copy.pop('timeout', None)
        if timeout is not None:
            self.timeout = timeout

        self.cache_capacity = 1000
        cache_capacity = conf_copy.pop('cache.capacity', None)
        if cache_capacity is not None:
            if not isinstance(cache_capacity, (int, float)):
                raise TypeError("cache.capacity must be a number, not " + str(type(cache_capacity)))
            self.cache_capacity = int(cache_capacity)

        self.cache_latest_ttl_sec = None
        cache_latest_ttl_sec = conf_copy.pop('cache.latest.ttl.sec', None)
        if cache_latest_ttl_sec is not None:
            if not isinstance(cache_latest_ttl_sec, (int, float)):
                raise TypeError("cache.latest.ttl.sec must be a number, not " + str(type(cache_latest_ttl_sec)))
            self.cache_latest_ttl_sec = cache_latest_ttl_sec

        self.max_retries = 3
        max_retries = conf_copy.pop('max.retries', None)
        if max_retries is not None:
            if not isinstance(max_retries, (int, float)):
                raise TypeError("max.retries must be a number, not " + str(type(max_retries)))
            self.max_retries = int(max_retries)

        self.retries_wait_ms = 1000
        retries_wait_ms = conf_copy.pop('retries.wait.ms', None)
        if retries_wait_ms is not None:
            if not isinstance(retries_wait_ms, (int, float)):
                raise TypeError("retries.wait.ms must be a number, not " + str(type(retries_wait_ms)))
            self.retries_wait_ms = int(retries_wait_ms)

        self.retries_max_wait_ms = 20000
        retries_max_wait_ms = conf_copy.pop('retries.max.wait.ms', None)
        if retries_max_wait_ms is not None:
            if not isinstance(retries_max_wait_ms, (int, float)):
                raise TypeError("retries.max.wait.ms must be a number, not " + str(type(retries_max_wait_ms)))
            self.retries_max_wait_ms = int(retries_max_wait_ms)

        [self.bearer_auth_credentials_source, self.bearer_field_provider] = _AsyncFieldProviderBuilder.build(
            conf_copy, self.max_retries, self.retries_wait_ms, self.retries_max_wait_ms
        )

        # Any leftover keys are unknown to _RestClient
        if len(conf_copy) > 0:
            raise ValueError("Unrecognized properties: {}".format(", ".join(conf_copy.keys())))

    async def get(self, url: str, query: Optional[dict] = None) -> Any:
        raise NotImplementedError()

    async def post(self, url: str, body: Optional[dict], **kwargs) -> Any:
        raise NotImplementedError()

    async def delete(self, url: str) -> Any:
        raise NotImplementedError()

    async def put(self, url: str, body: Optional[dict] = None) -> Any:
        raise NotImplementedError()


class _AsyncRestClient(_AsyncBaseRestClient):
    """
    HTTP client for Confluent Schema Registry.

    See SchemaRegistryClient for configuration details.

    Args:
        conf (dict): Dictionary containing _RestClient configuration
    """

    def __init__(self, conf: dict):
        super().__init__(conf)

        self.session = httpx.AsyncClient(verify=self.verify, auth=self.auth, proxy=self.proxy, timeout=self.timeout)

    async def handle_bearer_auth(self, headers: dict) -> None:
        if self.bearer_field_provider is None:
            raise ValueError("Bearer field provider is not set")
        bearer_fields = await self.bearer_field_provider.get_bearer_fields()
        required_fields = ['bearer.auth.token', 'bearer.auth.identity.pool.id', 'bearer.auth.logical.cluster']

        missing_fields = []
        for field in required_fields:
            if field not in bearer_fields:
                missing_fields.append(field)

        if missing_fields:
            raise ValueError(
                "Missing required bearer auth fields, needs to be set in config or custom function: {}".format(
                    ", ".join(missing_fields)
                )
            )

        headers["Authorization"] = "Bearer {}".format(bearer_fields['bearer.auth.token'])
        headers['Confluent-Identity-Pool-Id'] = bearer_fields['bearer.auth.identity.pool.id']
        headers['target-sr-cluster'] = bearer_fields['bearer.auth.logical.cluster']

    async def get(self, url: str, query: Optional[dict] = None) -> Any:
        return await self.send_request(url, method='GET', query=query)

    async def post(self, url: str, body: Optional[dict], **kwargs) -> Any:
        return await self.send_request(url, method='POST', body=body)

    async def delete(self, url: str) -> Any:
        return await self.send_request(url, method='DELETE')

    async def put(self, url: str, body: Optional[dict] = None) -> Any:
        return await self.send_request(url, method='PUT', body=body)

    async def send_request(
        self, url: str, method: str, body: Optional[dict] = None, query: Optional[dict] = None
    ) -> Any:
        """
        Sends HTTP request to the SchemaRegistry, trying each base URL in turn.

        All unsuccessful attempts will raise a SchemaRegistryError with the
        response contents. In most cases this will be accompanied by a
        Schema Registry supplied error code.

        In the event the response is malformed an error_code of -1 will be used.

        Args:
            url (str): Request path

            method (str): HTTP method

            body (str): Request content

            query (dict): Query params to attach to the URL

        Returns:
            dict: Schema Registry response content.
        """

        headers = {
            'Accept': "application/vnd.schemaregistry.v1+json,"
            " application/vnd.schemaregistry+json,"
            " application/json"
        }

        body_str: Optional[str] = None
        if body is not None:
            body_str = json.dumps(body)
            headers = {
                'Content-Length': str(len(body_str)),
                'Content-Type': "application/vnd.schemaregistry.v1+json",
                'Confluent-Accept-Unknown-Properties': "true",
                'Confluent-Client-Version': f"python/{version()}",
            }

        headers['Confluent-Client-Version'] = f"python/{version()}"

        if self.bearer_auth_credentials_source:
            await self.handle_bearer_auth(headers)

        response = None
        for i, base_url in enumerate(self.base_urls):
            try:
                response = await self.send_http_request(base_url, url, method, headers, body_str, query)

                if is_success(response.status_code):
                    return response.json()

                if not is_retriable(response.status_code) or i == len(self.base_urls) - 1:
                    break
            except Exception as e:
                if i == len(self.base_urls) - 1:
                    # Raise the exception since we have no more urls to try
                    raise e

        if isinstance(response, Response):
            try:
                raise SchemaRegistryError(
                    response.status_code, response.json().get('error_code'), response.json().get('message')
                )
            except (ValueError, KeyError, AttributeError):
                raise SchemaRegistryError(
                    response.status_code, -1, "Unknown Schema Registry Error: " + str(response.content)
                )
        else:
            raise TypeError("Unexpected response of unsupported type: " + str(type(response)))

    async def send_http_request(
        self,
        base_url: str,
        url: str,
        method: str,
        headers: Optional[dict],
        body: Optional[str] = None,
        query: Optional[dict] = None,
    ) -> Response:
        """
        Sends HTTP request to the SchemaRegistry.

        All unsuccessful attempts will raise a SchemaRegistryError with the
        response contents. In most cases this will be accompanied by a
        Schema Registry supplied error code.

        In the event the response is malformed an error_code of -1 will be used.

        Args:
            base_url (str): Schema Registry base URL

            url (str): Request path

            method (str): HTTP method

            headers (dict): Headers

            body (str): Request content

            query (dict): Query params to attach to the URL

        Returns:
            Response: Schema Registry response content.
        """
        response = None
        for i in range(self.max_retries + 1):
            response = await self.session.request(
                method, url="/".join([base_url, url]), headers=headers, content=body, params=query
            )

            if is_success(response.status_code):
                return response

            if not is_retriable(response.status_code) or i >= self.max_retries:
                return response

            await asyncio.sleep(full_jitter(self.retries_wait_ms, self.retries_max_wait_ms, i) / 1000)
        return response  # type: ignore[return-value]


class AsyncSchemaRegistryClient(object):
    """
    A Confluent Schema Registry client.

    Configuration properties (* indicates a required field):

    +------------------------------+------+-------------------------------------------------+
    | Property name                | type | Description                                     |
    +==============================+======+=================================================+
    | ``url`` *                    | str  | Comma-separated list of Schema Registry URLs.   |
    +------------------------------+------+-------------------------------------------------+
    |                              |      | Path to CA certificate file used                |
    | ``ssl.ca.location``          | str  | to verify the Schema Registry's                 |
    |                              |      | private key.                                    |
    +------------------------------+------+-------------------------------------------------+
    |                              |      | Path to client's private key                    |
    |                              |      | (PEM) used for authentication.                  |
    | ``ssl.key.location``         | str  |                                                 |
    |                              |      | ``ssl.certificate.location`` must also be set.  |
    +------------------------------+------+-------------------------------------------------+
    |                              |      | Password to use to decrypt the client's private |
    |                              |      | key.                                            |
    |                              |      |                                                 |
    | ``ssl.key.password``         | str  | The private key may be provided using           |
    |                              |      | ``ssl.key.location``, or bundled with the       |
    |                              |      | certificate in ``ssl.certificate.location``.    |
    |                              |      | Password is optional (key may be unencrypted).  |
    +------------------------------+------+-------------------------------------------------+
    |                              |      | Path to client's certificate (PEM) used for     |
    |                              |      | authentication.                                 |
    | ``ssl.certificate.location`` | str  |                                                 |
    |                              |      | May be set without ``ssl.key.location`` if the  |
    |                              |      | private key is stored within the PEM as well.   |
    +------------------------------+------+-------------------------------------------------+
    |                              |      | Client HTTP credentials in the form of          |
    |                              |      | ``username:password``.                          |
    | ``basic.auth.user.info``     | str  |                                                 |
    |                              |      | By default userinfo is extracted from           |
    |                              |      | the URL if present.                             |
    +------------------------------+------+-------------------------------------------------+
    |                              |      |                                                 |
    | ``proxy``                    | str  | Proxy such as http://localhost:8030.            |
    |                              |      |                                                 |
    +------------------------------+------+-------------------------------------------------+
    |                              |      |                                                 |
    | ``timeout``                  | int  | Request timeout.                                |
    |                              |      |                                                 |
    +------------------------------+------+-------------------------------------------------+
    |                              |      |                                                 |
    | ``cache.capacity``           | int  | Cache capacity.  Defaults to 1000.              |
    |                              |      |                                                 |
    +------------------------------+------+-------------------------------------------------+
    |                              |      |                                                 |
    | ``cache.latest.ttl.sec``     | int  | TTL in seconds for caching the latest schema.   |
    |                              |      |                                                 |
    +------------------------------+------+-------------------------------------------------+
    |                              |      |                                                 |
    | ``max.retries``              | int  | Maximum retries for a request.  Defaults to 2.  |
    |                              |      |                                                 |
    +------------------------------+------+-------------------------------------------------+
    |                              |      | Maximum time to wait for the first retry.       |
    |                              |      | When jitter is applied, the actual wait may     |
    | ``retries.wait.ms``          | int  | be less.                                        |
    |                              |      |                                                 |
    |                              |      | Defaults to 1000.                               |
    +------------------------------+------+-------------------------------------------------+

    Args:
        conf (dict): Schema Registry client configuration.

    See Also:
        `Confluent Schema Registry documentation <http://confluent.io/docs/current/schema-registry/docs/intro.html>`_
    """  # noqa: E501

    def __init__(self, conf: dict):
        self._conf = conf
        self._rest_client = _AsyncRestClient(conf)
        self._cache = _SchemaCache()
        self._latest_lock = _locks.Lock()
        cache_capacity = self._rest_client.cache_capacity
        cache_ttl = self._rest_client.cache_latest_ttl_sec
        self._latest_version_cache: Cache[Any, Any]
        self._latest_with_metadata_cache: Cache[Any, Any]
        if cache_ttl is not None:
            self._latest_version_cache = TTLCache(cache_capacity, cache_ttl)
            self._latest_with_metadata_cache = TTLCache(cache_capacity, cache_ttl)
        else:
            self._latest_version_cache = LRUCache(cache_capacity)
            self._latest_with_metadata_cache = LRUCache(cache_capacity)

    async def __aenter__(self):
        return self

    async def __aexit__(self, *args):
        if self._rest_client is not None:
            await self._rest_client.session.aclose()

    def config(self):
        return self._conf

    async def register_schema(self, subject_name: str, schema: 'Schema', normalize_schemas: bool = False) -> int:
        """
        Registers a schema under ``subject_name``.

        Args:
            subject_name (str): subject to register a schema under
            schema (Schema): Schema instance to register
            normalize_schemas (bool): Normalize schema before registering

        Returns:
            int: Schema id

        Raises:
            SchemaRegistryError: if Schema violates this subject's
                Compatibility policy or is otherwise invalid.

        See Also:
            `POST Subject Version API Reference <https://docs.confluent.io/current/schema-registry/develop/api.html#post--subjects-(string-%20subject)-versions>`_
        """  # noqa: E501

        registered_schema = await self.register_schema_full_response(
            subject_name, schema, normalize_schemas=normalize_schemas
        )
        return registered_schema.schema_id  # type: ignore[return-value]

    async def register_schema_full_response(
        self, subject_name: str, schema: 'Schema', normalize_schemas: bool = False
    ) -> 'RegisteredSchema':
        """
        Registers a schema under ``subject_name``.

        Args:
            subject_name (str): subject to register a schema under
            schema (Schema): Schema instance to register
            normalize_schemas (bool): Normalize schema before registering

        Returns:
            int: Schema id

        Raises:
            SchemaRegistryError: if Schema violates this subject's
                Compatibility policy or is otherwise invalid.

        See Also:
            `POST Subject Version API Reference <https://docs.confluent.io/current/schema-registry/develop/api.html#post--subjects-(string-%20subject)-versions>`_
        """  # noqa: E501

        schema_id = self._cache.get_id_by_schema(subject_name, schema)
        if schema_id is not None:
            result = self._cache.get_schema_by_id(subject_name, schema_id)
            if result is not None:
                return RegisteredSchema(
                    schema_id=schema_id, guid=result[0], subject=subject_name, version=None, schema=result[1]
                )

        request = schema.to_dict()

        response = await self._rest_client.post(
            'subjects/{}/versions?normalize={}'.format(_urlencode(subject_name), normalize_schemas), body=request
        )

        response_schema = RegisteredSchema.from_dict(response)

        registered_schema = RegisteredSchema(
            schema_id=response_schema.schema_id,
            guid=response_schema.guid,
            subject=response_schema.subject or subject_name,
            version=response_schema.version,
            schema=response_schema.schema,
        )

        # The registered schema may not be fully populated
        s = registered_schema.schema if registered_schema.schema.schema_str is not None else schema
        self._cache.set_schema(subject_name, registered_schema.schema_id, registered_schema.guid, s)

        return registered_schema

    async def get_schema(
        self,
        schema_id: int,
        subject_name: Optional[str] = None,
        fmt: Optional[str] = None,
        reference_format: Optional[str] = None,
    ) -> 'Schema':
        """
        Fetches the schema associated with ``schema_id`` from the
        Schema Registry. The result is cached so subsequent attempts will not
        require an additional round-trip to the Schema Registry.

        Args:
            schema_id (int): Schema id.
            subject_name (str): Subject name the schema is registered under.
            fmt (str): Desired output format, dependent on schema type.
            reference_format (str): Desired output format for references.

        Returns:
            Schema: Schema instance identified by the ``schema_id``

        Raises:
            SchemaRegistryError: If schema can't be found.

        See Also:
            `GET Schema API Reference <https://docs.confluent.io/current/schema-registry/develop/api.html#get--schemas-ids-int-%20id>`_
        """  # noqa: E501

        result = self._cache.get_schema_by_id(subject_name, schema_id)
        if result is not None:
            return result[1]

        query = {}
        if subject_name is not None:
            query['subject'] = subject_name
        if fmt is not None:
            query['format'] = fmt
        if reference_format is not None:
            query['reference_format'] = reference_format
        response = await self._rest_client.get('schemas/ids/{}'.format(schema_id), query)

        registered_schema = RegisteredSchema.from_dict(response)

        self._cache.set_schema(subject_name, schema_id, registered_schema.guid, registered_schema.schema)

        return registered_schema.schema

    async def get_schema_by_guid(self, guid: str, fmt: Optional[str] = None) -> 'Schema':
        """
        Fetches the schema associated with ``guid`` from the
        Schema Registry. The result is cached so subsequent attempts will not
        require an additional round-trip to the Schema Registry.

        Args:
            guid (str): Schema guid
            fmt (str): Format of the schema

        Returns:
            Schema: Schema instance identified by the ``guid``

        Raises:
            SchemaRegistryError: If schema can't be found.

        See Also:
         `GET Schema API Reference <https://docs.confluent.io/current/schema-registry/develop/api.html#get--schemas-ids-int-%20id>`_
        """  # noqa: E501

        schema = self._cache.get_schema_by_guid(guid)
        if schema is not None:
            return schema

        query = {}
        if fmt is not None:
            query['format'] = fmt
        response = await self._rest_client.get('schemas/guids/{}'.format(guid), query)

        registered_schema = RegisteredSchema.from_dict(response)

        self._cache.set_schema(None, registered_schema.schema_id, registered_schema.guid, registered_schema.schema)

        return registered_schema.schema

    async def get_schema_types(self) -> List[str]:
        """
        Lists all supported schema types in the Schema Registry.

        Returns:
            list(str): List of supported schema types (e.g., ['AVRO', 'JSON', 'PROTOBUF'])

        Raises:
            SchemaRegistryError: if schema types can't be retrieved

        See Also:
            `GET Schema Types API Reference <https://docs.confluent.io/current/schema-registry/develop/api.html#get--schemas-types>`_
        """  # noqa: E501

        return await self._rest_client.get('schemas/types')

    async def get_subjects_by_schema_id(
        self,
        schema_id: int,
        subject_name: Optional[str] = None,
        deleted: bool = False,
        offset: int = 0,
        limit: int = -1,
    ) -> List[str]:
        """
        Retrieves all the subjects associated with ``schema_id``.

        Args:
            schema_id (int): Schema ID.
            subject_name (str): Subject name that results can be filtered by.
            deleted (bool): Whether to include subjects where the schema was deleted.
            offset (int): Pagination offset for results.
            limit (int): Pagination size for results. Ignored if negative.

        Returns:
            list(str): List of subjects matching the specified parameters.

        Raises:
            SchemaRegistryError: if subjects can't be found
        """
        query: dict[str, Any] = {'offset': offset, 'limit': limit}
        if subject_name is not None:
            query['subject'] = subject_name
        if deleted:
            query['deleted'] = deleted
        return await self._rest_client.get('schemas/ids/{}/subjects'.format(schema_id), query)

    async def get_schema_versions(
        self,
        schema_id: int,
        subject_name: Optional[str] = None,
        deleted: bool = False,
        offset: int = 0,
        limit: int = -1,
    ) -> List[SchemaVersion]:
        """
        Gets all subject-version pairs of a schema by its ID.

        Args:
            schema_id (int): Schema ID.
            subject_name (str): Subject name that results can be filtered by.
            deleted (bool): Whether to include subject versions where the schema was deleted.
            offset (int): Pagination offset for results.
            limit (int): Pagination size for results. Ignored if negative.

        Returns:
            list(SchemaVersion): List of subject-version pairs. Each pair contains:
                - subject (str): Subject name.
                - version (int): Version number.

        Raises:
            SchemaRegistryError: if schema versions can't be found.

        See Also:
            `GET Schema Versions API Reference <https://docs.confluent.io/current/schema-registry/develop/api.html#get--schemas-ids-int-%20id-versions>`_
        """  # noqa: E501

        query: dict[str, Any] = {'offset': offset, 'limit': limit}
        if subject_name is not None:
            query['subject'] = subject_name
        if deleted:
            query['deleted'] = deleted
        response = await self._rest_client.get('schemas/ids/{}/versions'.format(schema_id), query)
        return [SchemaVersion.from_dict(item) for item in response]

    async def lookup_schema(
        self,
        subject_name: str,
        schema: 'Schema',
        normalize_schemas: bool = False,
        fmt: Optional[str] = None,
        deleted: bool = False,
    ) -> 'RegisteredSchema':
        """
        Returns ``schema`` registration information for ``subject``.

        Args:
            subject_name (str): Subject name the schema is registered under.
            schema (Schema): Schema instance.
            normalize_schemas (bool): Normalize schema before registering.
            fmt (str): Desired output format, dependent on schema type.
            deleted (bool): Whether to include deleted schemas.

        Returns:
            RegisteredSchema: Subject registration information for this schema.

        Raises:
            SchemaRegistryError: If schema or subject can't be found.

        See Also:
            `POST Subject API Reference <https://docs.confluent.io/current/schema-registry/develop/api.html#post--subjects-(string-%20subject)>`_
        """  # noqa: E501

        registered_schema = self._cache.get_registered_by_subject_schema(subject_name, schema)
        if registered_schema is not None:
            return registered_schema

        request = schema.to_dict()

        query_params: dict[str, Any] = {'normalize': normalize_schemas, 'deleted': deleted}
        if fmt is not None:
            query_params['format'] = fmt

        query_string = '&'.join(f"{key}={value}" for key, value in query_params.items())

        response = await self._rest_client.post(
            'subjects/{}?{}'.format(_urlencode(subject_name), query_string), body=request
        )

        result = RegisteredSchema.from_dict(response)

        # Ensure the schema matches the input
        registered_schema = RegisteredSchema(
            schema_id=result.schema_id,
            guid=result.guid,
            subject=result.subject,
            version=result.version,
            schema=schema,
        )

        self._cache.set_registered_schema(schema, registered_schema)

        return registered_schema

    async def get_subjects(
        self,
        subject_prefix: Optional[str] = None,
        deleted: bool = False,
        deleted_only: bool = False,
        offset: int = 0,
        limit: int = -1,
    ) -> List[str]:
        """
        Lists all subjects registered with the Schema Registry.

        Args:
            subject_prefix (str): Subject name prefix that results can be filtered by.
            deleted (bool): Whether to include deleted subjects.
            deleted_only (bool): Whether to return deleted subjects only. If both deleted and deleted_only are True, deleted_only takes precedence.
            offset (int): Pagination offset for results.
            limit (int): Pagination size for results. Ignored if negative.

        Returns:
            list(str): Registered subject names

        Raises:
            SchemaRegistryError: if subjects can't be found

        See Also:
            `GET subjects API Reference <https://docs.confluent.io/current/schema-registry/develop/api.html#get--subjects>`_
        """  # noqa: E501

        query: dict[str, Any] = {'deleted': deleted, 'deleted_only': deleted_only, 'offset': offset, 'limit': limit}
        if subject_prefix is not None:
            query['subject'] = subject_prefix
        return await self._rest_client.get('subjects', query)

    async def delete_subject(self, subject_name: str, permanent: bool = False) -> List[int]:
        """
        Deletes the specified subject and its associated compatibility level if
        registered. It is recommended to use this API only when a topic needs
        to be recycled or in development environments.

        Args:
            subject_name (str): subject name
            permanent (bool): True for a hard delete, False (default) for a soft delete

        Returns:
            list(int): Versions deleted under this subject

        Raises:
            SchemaRegistryError: if the request was unsuccessful.

        See Also:
            `DELETE Subject API Reference <https://docs.confluent.io/current/schema-registry/develop/api.html#delete--subjects-(string-%20subject)>`_
        """  # noqa: E501

        if permanent:
            versions = await self._rest_client.delete('subjects/{}?permanent=true'.format(_urlencode(subject_name)))
            self._cache.remove_by_subject(subject_name)
        else:
            versions = await self._rest_client.delete('subjects/{}'.format(_urlencode(subject_name)))

        return versions

    async def get_latest_version(self, subject_name: str, fmt: Optional[str] = None) -> 'RegisteredSchema':
        """
        Retrieves latest registered version for subject

        Args:
            subject_name (str): Subject name.
            fmt (str): Format of the schema

        Returns:
            RegisteredSchema: Registration information for this version.

        Raises:
            SchemaRegistryError: if the version can't be found or is invalid.

        See Also:
            `GET Subject Version API Reference <https://docs.confluent.io/current/schema-registry/develop/api.html#get--subjects-(string-%20subject)-versions-(versionId-%20version)>`_
        """  # noqa: E501

        async with self._latest_lock:
            registered_schema = self._latest_version_cache.get(subject_name, None)
        if registered_schema is not None:
            return registered_schema

        query = {'format': fmt} if fmt is not None else None
        response = await self._rest_client.get(
            'subjects/{}/versions/{}'.format(_urlencode(subject_name), 'latest'), query
        )

        registered_schema = RegisteredSchema.from_dict(response)

        async with self._latest_lock:
            self._latest_version_cache[subject_name] = registered_schema

        return registered_schema

    async def get_latest_with_metadata(
        self, subject_name: str, metadata: Dict[str, str], deleted: bool = False, fmt: Optional[str] = None
    ) -> 'RegisteredSchema':
        """
        Retrieves latest registered version for subject with the given metadata

        Args:
            subject_name (str): Subject name.
            metadata (dict): The key-value pairs for the metadata.
            deleted (bool): Whether to include deleted schemas.
            fmt (str): Format of the schema

        Returns:
            RegisteredSchema: Registration information for this version.

        Raises:
            SchemaRegistryError: if the version can't be found or is invalid.
        """  # noqa: E501

        cache_key = (subject_name, frozenset(metadata.items()), deleted)
        async with self._latest_lock:
            registered_schema = self._latest_with_metadata_cache.get(cache_key, None)
        if registered_schema is not None:
            return registered_schema

        query: dict[str, Any] = {'deleted': deleted}
        if fmt is not None:
            query['format'] = fmt
        keys = metadata.keys()
        if keys:
            query['key'] = [_urlencode(key) for key in keys]
            query['value'] = [_urlencode(metadata[key]) for key in keys]

        response = await self._rest_client.get('subjects/{}/metadata'.format(_urlencode(subject_name)), query)

        registered_schema = RegisteredSchema.from_dict(response)

        async with self._latest_lock:
            self._latest_with_metadata_cache[cache_key] = registered_schema

        return registered_schema

    async def get_version(
        self,
        subject_name: str,
        version: Union[int, Literal["latest"]] = "latest",
        deleted: bool = False,
        fmt: Optional[str] = None,
    ) -> 'RegisteredSchema':
        """
        Retrieves a specific schema registered under `subject_name` and `version`.

        Args:
            subject_name (str): Subject name.
            version (Union[int, Literal["latest"]]): Version of the schema or string "latest". Defaults to latest version.
            deleted (bool): Whether to include deleted schemas.
            fmt (str): Format of the schema.

        Returns:
            RegisteredSchema: Registration information for this version.

        Raises:
            SchemaRegistryError: if the version can't be found or is invalid.

        See Also:
            `GET Subject Versions API Reference <https://docs.confluent.io/current/schema-registry/develop/api.html#get--subjects-(string-%20subject)-versions-(versionId-%20version)>`_
        """  # noqa: E501

        if version != "latest":  # Skip cache lookup for reading the latest version
            registered_schema = self._cache.get_registered_by_subject_version(subject_name, version)
            if registered_schema is not None:
                return registered_schema

        query: dict[str, Any] = {'deleted': deleted, 'format': fmt} if fmt is not None else {'deleted': deleted}
        response = await self._rest_client.get(
            'subjects/{}/versions/{}'.format(_urlencode(subject_name), version), query
        )

        registered_schema = RegisteredSchema.from_dict(response)

        self._cache.set_registered_schema(registered_schema.schema, registered_schema)

        return registered_schema

    async def get_referenced_by(
        self, subject_name: str, version: Union[int, Literal["latest"]] = "latest", offset: int = 0, limit: int = -1
    ) -> List[int]:
        """
        Get a list of IDs of schemas that reference the schema with the given `subject_name` and `version`.

        Args:
            subject_name (str): Subject name
            version (Union[int, Literal["latest"]]): Version number or "latest"
            offset (int): Pagination offset for results.
            limit (int): Pagination size for results. Ignored if negative.

        Returns:
            list(int): List of schema IDs that reference the specified schema.

        Raises:
            SchemaRegistryError: if the schema version can't be found or referenced schemas can't be retrieved

        See Also:
            `GET Subject Versions (ReferenceBy) API Reference <https://docs.confluent.io/current/schema-registry/develop/api.html#get--subjects-(string-%20subject)-versions-versionId-%20version-referencedby>`_
        """  # noqa: E501

        query: dict[str, Any] = {'offset': offset, 'limit': limit}
        return await self._rest_client.get(
            'subjects/{}/versions/{}/referencedby'.format(_urlencode(subject_name), version), query
        )

    async def get_versions(
        self, subject_name: str, deleted: bool = False, deleted_only: bool = False, offset: int = 0, limit: int = -1
    ) -> List[int]:
        """
        Get a list of all versions registered with this subject.

        Args:
            subject_name (str): Subject name.
            deleted (bool): Whether to include deleted schemas.
            deleted_only (bool): Whether to return deleted versions only. If both deleted and deleted_only are True, deleted_only takes precedence.
            offset (int): Pagination offset for results.
            limit (int): Pagination size for results. Ignored if negative.

        Returns:
            list(int): Registered versions

        Raises:
            SchemaRegistryError: If subject can't be found

        See Also:
            `GET Subject All Versions API Reference <https://docs.confluent.io/platform/current/schema-registry/develop/api.html#get--subjects-(string-%20subject)-versions>`_
        """  # noqa: E501

        query: dict[str, Any] = {'deleted': deleted, 'deleted_only': deleted_only, 'offset': offset, 'limit': limit}
        return await self._rest_client.get('subjects/{}/versions'.format(_urlencode(subject_name)), query)

    async def delete_version(self, subject_name: str, version: int, permanent: bool = False) -> int:
        """
        Deletes a specific version registered to ``subject_name``.

        Args:
            subject_name (str) Subject name

            version (int): Version number

            permanent (bool): True for a hard delete, False (default) for a soft delete

        Returns:
            int: Version number which was deleted

        Raises:
            SchemaRegistryError: if the subject or version cannot be found.

        See Also:
            `Delete Subject Version API Reference <https://docs.confluent.io/current/schema-registry/develop/api.html#delete--subjects-(string-%20subject)-versions-(versionId-%20version)>`_
        """  # noqa: E501

        if permanent:
            response = await self._rest_client.delete(
                'subjects/{}/versions/{}?permanent=true'.format(_urlencode(subject_name), version)
            )
        else:
            response = await self._rest_client.delete(
                'subjects/{}/versions/{}'.format(_urlencode(subject_name), version)
            )

        # Clear cache for both soft and hard deletes to maintain consistency
        self._cache.remove_by_subject_version(subject_name, version)

        return response

    async def set_compatibility(self, subject_name: Optional[str] = None, level: Optional[str] = None) -> str:
        """
        Update global or subject level compatibility level.

        Args:
            level (str): Compatibility level. See API reference for a list of
                valid values.

            subject_name (str, optional): Subject to update. Sets global compatibility
                level policy if not set.

        Returns:
            str: The newly configured compatibility level.

        Raises:
            SchemaRegistryError: If the compatibility level is invalid.

        See Also:
            `PUT Subject Compatibility API Reference <https://docs.confluent.io/current/schema-registry/develop/api.html#put--config-(string-%20subject)>`_
        """  # noqa: E501

        if level is None:
            raise ValueError("level must be set")

        if subject_name is None:
            return await self._rest_client.put('config', body={'compatibility': level.upper()})

        return await self._rest_client.put(
            'config/{}'.format(_urlencode(subject_name)), body={'compatibility': level.upper()}
        )

    async def get_compatibility(self, subject_name: Optional[str] = None) -> str:
        """
        Get the current compatibility level.

        Args:
            subject_name (str, optional): Subject name. Returns global policy
                if left unset.

        Returns:
            str: Compatibility level for the subject if set, otherwise the global compatibility level.

        Raises:
            SchemaRegistryError: if the request was unsuccessful.

        See Also:
            `GET Subject Compatibility API Reference <https://docs.confluent.io/current/schema-registry/develop/api.html#get--config-(string-%20subject)>`_
        """  # noqa: E501

        if subject_name is not None:
            url = 'config/{}'.format(_urlencode(subject_name))
        else:
            url = 'config'

        result = await self._rest_client.get(url)
        return result['compatibilityLevel']

    async def test_compatibility(
        self,
        subject_name: str,
        schema: 'Schema',
        version: Union[int, str] = "latest",
        normalize: bool = False,
        verbose: bool = False,
    ) -> bool:
        """
        Test the compatibility of a candidate schema for a given subject and version

        Args:
            subject_name (str): Subject name the schema is registered under
            schema (Schema): Schema instance.
            version (int or str, optional): Version number, or the string "latest". Defaults to "latest".
            normalize (bool): Whether to normalize the input schema.
            verbose (bool): Whether to return detailed error messages.

        Returns:
            bool: True if the schema is compatible with the specified version

        Raises:
            SchemaRegistryError: if the request was unsuccessful.

        See Also:
            `POST Test Compatibility API Reference <https://docs.confluent.io/current/schema-registry/develop/api.html#post--compatibility-subjects-(string-%20subject)-versions-(versionId-%20version)>`_
        """  # noqa: E501

        request = schema.to_dict()
        response = await self._rest_client.post(
            'compatibility/subjects/{}/versions/{}?normalize={}&verbose={}'.format(
                _urlencode(subject_name), version, normalize, verbose
            ),
            body=request,
        )
        return response['is_compatible']

    async def test_compatibility_all_versions(
        self, subject_name: str, schema: 'Schema', normalize: bool = False, verbose: bool = False
    ) -> bool:
        """
        Test the input schema against all schema versions under the subject (depending on the compatibility level set).

        Args:
            subject_name (str): Subject of the schema versions against which compatibility is to be tested.
            schema (Schema): Schema instance.
            normalize (bool): Whether to normalize the input schema.
            verbose (bool): Whether to return detailed error messages.

        Returns:
            bool: True if the schema is compatible with all of the subject's schemas versions.
        See Also:
            `POST Test Compatibility Against All API Reference <https://docs.confluent.io/current/schema-registry/develop/api.html#post--compatibility-subjects-(string-%20subject)-versions>`_
        """  # noqa: E501

        request = schema.to_dict()
        response = await self._rest_client.post(
            'compatibility/subjects/{}/versions?normalize={}&verbose={}'.format(
                _urlencode(subject_name), normalize, verbose
            ),
            body=request,
        )
        return response['is_compatible']

    async def set_config(
        self, subject_name: Optional[str] = None, config: Optional['ServerConfig'] = None
    ) -> 'ServerConfig':
        """
        Update global or subject config.

        Args:
            config (ServerConfig): Config. See API reference for a list of
                valid values.

            subject_name (str, optional): Subject to update. Sets global config
                if not set.

        Returns:
            str: The newly configured config.

        Raises:
            SchemaRegistryError: If the config is invalid.

        See Also:
            `PUT Subject Config API Reference <https://docs.confluent.io/current/schema-registry/develop/api.html#put--config-(string-%20subject)>`_
        """  # noqa: E501

        if config is None:
            raise ValueError("config must be set")

        if subject_name is None:
            return await self._rest_client.put('config', body=config.to_dict())

        return await self._rest_client.put('config/{}'.format(_urlencode(subject_name)), body=config.to_dict())

    async def delete_config(self, subject_name: Optional[str] = None) -> 'ServerConfig':
        """
        Delete the specified subject-level compatibility level config and revert to the global default.

        Args:
            subject_name (str, optional): Subject name. Deletes global config
                if left unset.

        Returns:
            ServerConfig: The old deleted config

        Raises:
            SchemaRegistryError: if the request was unsuccessful.

        See Also:
            `DELETE Subject Config API Reference <https://docs.confluent.io/current/schema-registry/develop/api.html#delete--config-(string- subject)>`_
        """  # noqa: E501
        if subject_name is not None:
            url = 'config/{}'.format(_urlencode(subject_name))
        else:
            url = 'config'
        result = await self._rest_client.delete(url)
        return ServerConfig.from_dict(result)

    async def get_config(self, subject_name: Optional[str] = None) -> 'ServerConfig':
        """
        Get the current config.

        Args:
            subject_name (str, optional): Subject name. Returns global config
                if left unset.

        Returns:
            ServerConfig: Config for the subject if set, otherwise the global config.

        Raises:
            SchemaRegistryError: if the request was unsuccessful.

        See Also:
            `GET Subject Config API Reference <https://docs.confluent.io/current/schema-registry/develop/api.html#get--config-(string-%20subject)>`_
        """  # noqa: E501

        if subject_name is not None:
            url = 'config/{}'.format(_urlencode(subject_name))
        else:
            url = 'config'

        result = await self._rest_client.get(url)
        return ServerConfig.from_dict(result)

    async def get_mode(self, subject_name: str) -> str:
        """
        Get the mode for a subject.

        Args:
            subject_name (str): Subject name.

        Returns:
            str: Mode for the subject. Returns one of IMPORT, READONLY, READWRITE (default).

        Raises:
            SchemaRegistryError: if the request was unsuccessful.

        See Also:
            `GET Subject Mode API Reference <https://docs.confluent.io/current/schema-registry/develop/api.html#get--mode-(string-%20subject)>`_
        """  # noqa: E501
        result = await self._rest_client.get('mode/{}'.format(_urlencode(subject_name)))
        return result['mode']

    async def update_mode(self, subject_name: str, mode: str, force: bool = False) -> str:
        """
        Update the mode for a subject.

        Args:
            subject_name (str): Subject name.
            mode (str): Mode to update.
            force (bool): Whether to force a mode change even if the Schema Registry has existing schemas.

        Returns:
            str: New mode for the subject. Must be one of IMPORT, READONLY, READWRITE (default).

        Raises:
            SchemaRegistryError: if the request was unsuccessful.

        See Also:
            `PUT Subject Mode API Reference <https://docs.confluent.io/current/schema-registry/develop/api.html#put--mode-(string-%20subject)>`_
        """  # noqa: E501
        result = await self._rest_client.put(
            'mode/{}?force={}'.format(_urlencode(subject_name), force),
            body={'mode': mode},
        )
        return result['mode']

    async def delete_mode(self, subject_name: str) -> str:
        """
        Delete the mode for a subject and revert to the global default

        Args:
            subject_name (str): Subject name.

        Returns:
            str: New mode for the subject. Must be one of IMPORT, READONLY, READWRITE (default).

        Raises:
            SchemaRegistryError: if the request was unsuccessful.

        See Also:
            `DELETE Subject Mode API Reference <https://docs.confluent.io/current/schema-registry/develop/api.html#delete--mode-(string-%20subject)>`_
        """  # noqa: E501
        result = await self._rest_client.delete('mode/{}'.format(_urlencode(subject_name)))
        return result['mode']

    async def get_global_mode(self) -> str:
        """
        Get the current mode for Schema Registry at a global level.

        Returns:
            str: Schema Registry mode. Must be one of IMPORT, READONLY, READWRITE (default).

        Raises:
            SchemaRegistryError: if the request was unsuccessful.

        See Also:
            `GET Global Mode API Reference <https://docs.confluent.io/current/schema-registry/develop/api.html#get--mode>`_
        """  # noqa: E501
        result = await self._rest_client.get('mode')
        return result['mode']

    async def update_global_mode(self, mode: str, force: bool = False) -> str:
        """
        Update the mode for the Schema Registry at a global level.

        Args:
            mode (str): Mode to update.
            force (bool): Whether to force a mode change even if the Schema Registry has existing schemas.

        Returns:
            str: New mode for the Schema Registry. Must be one of IMPORT, READONLY, READWRITE (default).

        Raises:
            SchemaRegistryError: if the request was unsuccessful.

        See Also:
            `PUT Global Mode API Reference <https://docs.confluent.io/current/schema-registry/develop/api.html#put--mode>`_
        """  # noqa: E501
        result = await self._rest_client.put('mode?force={}'.format(force), body={'mode': mode})
        return result['mode']

    async def get_contexts(self, offset: int = 0, limit: int = -1) -> List[str]:
        """
        Retrieves a list of contexts.

        Args:
            offset (int): Pagination offset for results.
            limit (int): Pagination size for results. Ignored if negative.

        Returns:
            List[str]: List of contexts.

        Raises:
            SchemaRegistryError: if the request was unsuccessful.
        """  # noqa: E501

        result = await self._rest_client.get('contexts', query={'offset': offset, 'limit': limit})
        return result

    async def clear_latest_caches(self):
        async with self._latest_lock:
            self._latest_version_cache.clear()
            self._latest_with_metadata_cache.clear()

    async def clear_caches(self):
        async with self._latest_lock:
            self._latest_version_cache.clear()
            self._latest_with_metadata_cache.clear()
        self._cache.clear()

    @staticmethod
    def new_client(conf: dict) -> 'AsyncSchemaRegistryClient':
        from .mock_schema_registry_client import AsyncMockSchemaRegistryClient

        url = conf.get("url")
        if url and isinstance(url, str) and url.startswith("mock://"):
            return AsyncMockSchemaRegistryClient(conf)
        return AsyncSchemaRegistryClient(conf)
