try:
    from pybase64 import b64encode
except ImportError:
    from base64 import b64encode
import certifi
from typing import Any

try:
    from lchttp.json import json_dumps, json_loads
except ImportError:
    from json import dumps as json_dumps, loads as json_loads
from urllib.parse import urlencode
import urllib3
from urllib3 import (
    HTTPResponse as UpstreamHTTPResponse,
    PoolManager as DefaultPoolManager,
    ProxyManager as DefaultProxyManager,
)
from urllib3.util import Timeout
from typing import Union, TYPE_CHECKING

from .exceptions import LCHTTPError
from .typehints import HTTP_VERBS

if TYPE_CHECKING:
    from .tokens import Token, PasetoToken
from . import __version__

RData = dict[str, Any] | None
RBody = bytes | dict[str, Any] | str | None


# ----------------------------------------------------------------------
class Response(UpstreamHTTPResponse):
    """
    Extend HTTPResponse with a few methods to be a bit more like the
    `requests` library.
    """

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        super().__init__(*args, **kwargs)
        self.custom_msg = ''
        self.status_code = self.status
        self._content = ''
        self.exception: Exception | None = None

    def json(self) -> dict[str, Any]:
        try:
            return json_loads(self.data)
        except Exception:
            if not self.ok:
                return {'message': self.data}
            raise

    @property
    def ok(self) -> bool:
        return self.status < 400

    def raise_for_status(self) -> None:
        """
        Extracted from requests library
        Raises stored :class:`HTTPError`, if one occurred.
        """
        if self.status < 400:
            return

        http_error_msg = ''
        if isinstance(self.reason, bytes):
            # We attempt to decode utf-8 first because some servers
            # choose to localize their reason strings. If the string
            # isn't utf-8, we fall back to iso-8859-1 for all other
            # encodings. (See PR #3538)
            try:
                reason = self.reason.decode('utf-8')
            except UnicodeDecodeError:
                reason = self.reason.decode('iso-8859-1')
        else:
            reason = self.reason

        if 400 <= self.status < 500:
            http_error_msg = f'{self.status} Client Error: {reason} for URL: {self._request_url}'

        elif 500 <= self.status < 600:
            http_error_msg = f'{self.status} Server Error: {reason} for URL: {self._request_url}'

        if http_error_msg:
            raise LCHTTPError(http_error_msg, response=self)

    @property
    def content(self) -> bytes:
        return self.data

    @property
    def text(self) -> str:
        """
        Return response body as text
        """
        if self._content == '':
            data = self.data
            if isinstance(data, bytes):
                self._content = data.decode('utf-8')
            elif isinstance(data, str):
                self._content = data
            else:
                self._content = str(data)

        return self._content


urllib3.response.HTTPResponse = Response  # type: ignore[misc]


# ----------------------------------------------------------------------
class PoolMixin:
    """
    Custom PoolManager mixin to set SSL verification settings and
    http connection pool classes with correct Response class
    """

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        self.verify = kwargs.pop('verify', True)
        if not self.verify:
            if 'cert_reqs' not in kwargs:
                kwargs['cert_reqs'] = 'CERT_NONE'
                urllib3.disable_warnings()
        elif self.verify and 'ca_certs' not in kwargs:
            kwargs['ca_certs'] = certifi.where()

        super().__init__(*args, **kwargs)


# ----------------------------------------------------------------------
class PoolManager(PoolMixin, DefaultPoolManager):
    pass


# ----------------------------------------------------------------------
class ProxyManager(PoolMixin, DefaultProxyManager):
    pass


# ----------------------------------------------------------------------
def prepare_data(
    body: RBody = None, data: RBody = None, content_type: str = ''
) -> RBody | bytes | str:
    """
    Encode body data or json data
    """
    if body and data:
        raise ValueError('Please provide body or json data, but not both')

    req_data = data or body or ''
    if not req_data or isinstance(req_data, (bytes, str)):
        return req_data

    content_type = 'application/json' if data and not content_type else content_type
    if '/json' in content_type:  # don't do "endswith", to support encoding declarations
        return json_dumps(req_data)

    try:
        return urlencode(req_data)
    except TypeError:
        return str(req_data)


# ----------------------------------------------------------------------
class Request:
    """
    HTTP request wrapper
    """

    user_agent = f'Console HTTP Library/{__version__}'

    def __init__(
        self,
        url: str,
        pool: PoolManager | ProxyManager | None = None,
        verify: bool = False,
        stream: bool = False,
        signed_token_class: Union['Token', 'PasetoToken', None] = None,
        **kwargs: Any,
    ):
        self.url = url
        self.pool = pool
        self.verify = verify
        self.preload_content = not stream
        self._headers: dict[str, str] = kwargs.pop('headers', {})
        self.concurrency = kwargs.pop('concurrency', 5)
        self.signed_token_class = signed_token_class
        self.trust_env = False

        # Possible auth properties
        self.cid = kwargs.pop('cid', '')
        self.email = kwargs.pop('email', '')
        self.token = kwargs.pop('token', '')
        self.user = kwargs.pop('username', '')
        self.pwd = kwargs.pop('password', '')
        self.authorization_type = kwargs.pop('authorization_type', 'ConsoleAuth')
        self.supports_auth = any([self.token, self.user, self.signed_token_class])
        self.kwargs = kwargs
        self._client = pool

        if self.user_agent and 'User-Agent' not in self._headers:
            self._headers['User-Agent'] = self.user_agent

    def authorize(self) -> None:
        """
        Call auth methods in order of preference.
        """
        if not self.supports_auth:
            return

        if self.signed_token_class:
            return self.set_signed_token_auth()

        if self.token:
            return self.set_token_auth()

        return self.set_basic_auth()

    def set_signed_token_auth(self) -> None:
        """
        Preferred authorization method, since it minimizes the number of secrets
        that need to be present on both client and server, and since it generates
        a new signed token for every request.
        """
        if self.signed_token_class:
            self._headers['Authorization'] = self.signed_token_class.auth_header()

    def set_token_auth(self) -> None:
        if 'Authorization' in self._headers:
            return
        self._headers['Authorization'] = f'{self.authorization_type} {self.token}'

    def set_basic_auth(self) -> None:
        if 'Authorization' in self._headers:
            return

        if not self.user or not self.pwd:
            return

        creds = b64encode(f'{self.user}:{self.pwd}'.encode()).strip().decode("ascii")
        self._headers['Authorization'] = f'Basic {creds}'

    @property
    def client(self) -> PoolManager | ProxyManager:
        if self._client:
            return self._client

        kwargs = self.kwargs
        pool_params = {'verify': self.verify}

        timeout = float(kwargs.get('timeout') or 0)
        if timeout:
            connect = timeout * 0.25 if timeout > 1 else timeout
            download = timeout * 0.75 if timeout > 1 else timeout
            pool_params['timeout'] = Timeout(connect=connect, read=download)  # type: ignore[assignment]

        proxy_url = kwargs.get('proxy_url')
        if proxy_url:
            pool_params['proxy_url'] = proxy_url
            pool_params['proxy_headers'] = kwargs.get('proxy_headers', {})

        PoolClass = ProxyManager if proxy_url else PoolManager
        self._client = PoolClass(**pool_params)

        return self._client

    def headers(
        self,
        fields: RData = None,
        data: RData = None,
        headers: RData = None,
        **kwargs: Any,  # noqa
    ) -> dict[str, str]:
        """
        Add Content-Type header if needed, based on presence of form fields.
        """
        if not any([data, fields, headers]):
            return self._headers

        headers = headers or {}
        all_headers = self._headers.copy()
        all_headers.update(headers)

        if 'Content-Type' in all_headers or 'content-type' in all_headers:
            return all_headers

        if data:
            all_headers['Content-Type'] = 'application/json'

        return all_headers

    def get(self, params: RData = None, data: RData = None, **kwargs: Any) -> Response:
        """
        :param params: (optional) Dictionary of query params to include with the Request.
        :param data: (optional) Data to dump to json to send in the body of the Request.
        """
        return self.request(  # noqa
            method='GET',
            url=self.url if not params else f'{self.url}?{urlencode(params)}',
            data=data,
            **kwargs,
        )

    def head(self, **kwargs: Any) -> Response:
        return self.request('HEAD', self.url, headers=self.headers(**kwargs))

    def post(
        self,
        params: RData = None,
        fields: RData = None,
        body: RData = None,
        data: RData = None,
        **kwargs: Any,
    ) -> Response:
        """
        :param fields: (optional) Data to dump to json to send as form fields in Request.
        :param params: (optional) Dictionary of query params to include with the Request.
        :param body: (optional) Dictionary, list of tuples, bytes, or file-like
            object to send in the body of the Request.
        :param data: (optional) Data to dump to json to send in the body of the Request.
        """
        return self.request(
            method='POST',
            url=self.url if not params else f'{self.url}?{urlencode(params)}',
            fields=fields,
            body=body,
            data=data,
            **kwargs,
        )

    def put(
        self,
        params: RData = None,
        fields: RData = None,
        body: RData = None,
        data: RData = None,
        **kwargs: Any,
    ) -> Response:
        """
        :param fields: (optional) Data to dump to json to send as form fields in Request.
        :param params: (optional) Dictionary of query params to include with the Request.
        :param body: (optional) Dictionary, list of tuples, bytes, or file-like
            object to send in the body of the Request.
        :param data: (optional) Data to dump to json to send in the body of the Request.
        """
        return self.request(
            method='PUT',
            url=self.url if not params else f'{self.url}?{urlencode(params)}',
            fields=fields,
            body=body,
            data=data,
            **kwargs,
        )

    def delete(self, body: RData = None, data: RData = None, **kwargs: Any) -> Response:
        """
        :param body: (optional) Dictionary, list of tuples, bytes, or file-like
            object to send in the body of the Request.
        :param data: (optional) Data to dump to json to send in the body of the Request.
        """
        return self.request(method='DELETE', url=self.url, body=body, data=data, **kwargs)

    def patch(
        self, fields: RData = None, body: RData = None, data: RData = None, **kwargs: Any
    ) -> Response:
        """
        :param fields: (optional) Data to dump to json to send as form fields in Request.
        :param body: (optional) Dictionary, list of tuples, bytes, or file-like
            object to send in the body of the Request.
        :param data: (optional) Data to dump to json to send in the body of the Request.
        """
        return self.request(
            method='PATCH',
            url=self.url,
            fields=fields,
            body=body,
            data=data,
            **kwargs,
        )

    def trace(self, body: RData = None, data: RData = None, **kwargs: Any) -> Response:
        """
        :param body: (optional) Dictionary, list of tuples, bytes, or file-like
            object to send in the body of the Request.
        :param data: (optional) Data to dump to json to send in the body of the Request.
        """
        return self.request(method='TRACE', url=self.url, body=body, data=data, **kwargs)

    def options(self, **kwargs: Any) -> Response:
        return self.request('OPTIONS', self.url, headers=self.headers(**kwargs))

    def request(  # type: ignore[no-untyped-def]
        self,
        method: HTTP_VERBS,
        url: str,
        fields: RData = None,
        body: RData = None,
        data: RData = None,
        **kwargs,
    ) -> Response:
        """
        Should not be used directly. Call one of the verb-named methods instead.

        :param method: method (GET / POST / PUT) of this Request.
        :param url: URL string of request.
        :param fields: (optional) Dictionary, list of tuples, bytes, or file-like
            object to send as form data the body of the Request.
        :param body: (optional) Dictionary, list of tuples, bytes, or file-like
            object to send in the body of the Request.
        :param data: (optional) Data to dump to json to send in the body of the Request.
        """
        self.authorize()
        headers = self.headers(fields=fields, data=data, headers=kwargs.pop('headers', None))

        kwargs.update({
            'method': method,
            'url': url,
            'fields': fields,
            'headers': headers,
            'preload_content': self.preload_content,
        })
        if method != 'HEAD' and not fields:
            content_type = headers.get('Content-Type') or headers.get('content-type') or ''
            kwargs['body'] = prepare_data(body, data, content_type)

        resp = self.client.request(**kwargs)

        if self.preload_content:
            resp.release_conn()

        return resp

    def __getattr__(self, item: str) -> Any:
        return getattr(self.client, item)

    # ------------------------------------------------------------------
    # Context manager methods for compatibility with requests.Session
    def __enter__(self) -> 'Request':
        return self

    def __exit__(self, *args: Any) -> 'Request':
        return self

    # ------------------------------------------------------------------

    def __str__(self) -> str:
        return self.url


# ----------------------------------------------------------------------
class LogCabinApiSession(Request):
    user_agent = 'Log Cabin Console/1.0'


# ----------------------------------------------------------------------
class LogCabinHttpSession(Request):
    user_agent = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36'


__all__ = (
    'LogCabinApiSession',
    'LogCabinHttpSession',
    'Request',
    'Response',
    'PoolManager',
    'ProxyManager',
)
