########################################################################################################################
# IMPORTS

import logging
from datetime import timedelta
from email.utils import parsedate_to_datetime
from http.cookies import SimpleCookie
from typing import Any, Dict, Mapping, Optional, Sequence
from urllib.parse import urlparse

from bs4 import BeautifulSoup
from requests.cookies import RequestsCookieJar, create_cookie
from requests.exceptions import HTTPError
from rnet import Emulation, Proxy
from rnet.blocking import Client
from rnet.blocking import Response as RnetResponse
from rnet.exceptions import ConnectionError, TimeoutError, TlsError
from tenacity import (
    before_sleep_log,
    retry,
    retry_if_exception_type,
    retry_if_not_exception_type,
    stop_after_attempt,
    stop_after_delay,
    wait_exponential,
)

from datamarket.exceptions.main import IgnoredHTTPError

from ..exceptions import BadRequestError, EmptyResponseError, NotFoundError, RedirectionDetectedError
from ..interfaces.proxy import ProxyInterface
from .main import ban_sleep

########################################################################################################################
# SETUP LOGGER

logger = logging.getLogger(__name__)

########################################################################################################################
# CLASSES


class RnetRequestAdapter:
    """Adapter class for converting requests-style kwargs to rnet kwargs."""

    @staticmethod
    def _validate_supported_kwargs(requests_kwargs: Mapping[str, Any], supported: set) -> None:
        """Validate that all kwargs are in the supported set."""
        for key in requests_kwargs:
            if key not in supported:
                raise ValueError(
                    f"The parameter '{key}' exists in requests but "
                    f"is NOT supported by RNET. Remove it or add an explicit mapping."
                )

    @staticmethod
    def _map_direct_mappings(requests_kwargs: Mapping[str, Any]) -> Dict[str, Any]:
        """Map direct mappings: headers, timeout, allow_redirects."""
        rnet_kwargs = {}
        direct_map = {
            "headers": "headers",
            "timeout": "timeout",
            "allow_redirects": "allow_redirects",
        }
        for src, dst in direct_map.items():
            if src in requests_kwargs and requests_kwargs[src] is not None:
                rnet_kwargs[dst] = requests_kwargs[src]
        return rnet_kwargs

    @staticmethod
    def _map_query(requests_kwargs: Mapping[str, Any]) -> Dict[str, Any]:
        """Map params to query."""
        rnet_kwargs = {}
        if "params" in requests_kwargs and requests_kwargs["params"] is not None:
            rnet_kwargs["query"] = requests_kwargs["params"]
        return rnet_kwargs

    @staticmethod
    def _map_body_and_files(requests_kwargs: Mapping[str, Any]) -> Dict[str, Any]:
        """Map json, data, and files to appropriate rnet fields."""
        rnet_kwargs = {}
        if "json" in requests_kwargs and requests_kwargs["json"] is not None:
            rnet_kwargs["json"] = requests_kwargs["json"]

        if "data" in requests_kwargs and requests_kwargs["data"] is not None:
            data = requests_kwargs["data"]
            if isinstance(data, (Mapping, list, tuple)):
                rnet_kwargs["form"] = data
            else:
                rnet_kwargs["body"] = data

        if "files" in requests_kwargs and requests_kwargs["files"] is not None:
            rnet_kwargs["multipart"] = requests_kwargs["files"]
        return rnet_kwargs

    @staticmethod
    def _map_auth(requests_kwargs: Mapping[str, Any]) -> Dict[str, Any]:
        """Map auth to basic_auth or auth."""
        rnet_kwargs = {}
        auth = requests_kwargs.get("auth")
        if auth:
            if isinstance(auth, tuple) and len(auth) == 2:
                rnet_kwargs["basic_auth"] = auth
            else:
                rnet_kwargs["auth"] = auth
        return rnet_kwargs

    @staticmethod
    def _map_proxy(requests_kwargs: Mapping[str, Any]) -> Dict[str, Any]:
        """Map proxies to proxy."""
        rnet_kwargs = {}
        if "proxies" in requests_kwargs and requests_kwargs["proxies"] is not None:
            rnet_kwargs["proxy"] = requests_kwargs["proxies"]
        return rnet_kwargs

    @staticmethod
    def requests_to_rnet_kwargs(**requests_kwargs: Any) -> Dict[str, Any]:
        # Supported keywords
        supported = {
            "headers",
            "timeout",
            "allow_redirects",
            "params",
            "json",
            "data",
            "files",
            "auth",
            "proxies",
        }

        RnetRequestAdapter._validate_supported_kwargs(requests_kwargs, supported)

        rnet_kwargs: Dict[str, Any] = {}
        rnet_kwargs.update(RnetRequestAdapter._map_direct_mappings(requests_kwargs))
        rnet_kwargs.update(RnetRequestAdapter._map_query(requests_kwargs))
        rnet_kwargs.update(RnetRequestAdapter._map_body_and_files(requests_kwargs))
        rnet_kwargs.update(RnetRequestAdapter._map_auth(requests_kwargs))
        rnet_kwargs.update(RnetRequestAdapter._map_proxy(requests_kwargs))

        return rnet_kwargs


class RequestsCompatibleResponse:
    """
    A wrapper around rnet Response that provides backward compatibility with requests.Response API.

    This class wraps an rnet Response object and provides properties and methods that match
    the requests library API, allowing legacy code to work without modifications.
    """

    def __init__(self, rnet_response: RnetResponse):
        """
        Initialize the compatibility wrapper.

        Args:
            rnet_response: The rnet Response object to wrap
        """
        self._rnet_response = rnet_response

    # Backward-compatible properties (requests-style)
    @property
    def text(self) -> str:
        """Get response text (requests-compatible property)."""
        return self._rnet_response.text()

    @property
    def content(self) -> bytes:
        """Get response content as bytes (requests-compatible property)."""
        return self._rnet_response.bytes()

    @property
    def status_code(self) -> int:
        """Get HTTP status code (requests-compatible property)."""
        return self._rnet_response.status.as_int()

    @property
    def headers(self) -> Dict[str, str]:
        """Get response headers (requests-compatible property)."""
        headers = {}
        for key, value in self._rnet_response.headers:
            key_str = key.decode("utf-8") if isinstance(key, bytes) else str(key)
            value_str = value.decode("utf-8") if isinstance(value, bytes) else str(value)
            headers[key_str] = value_str
        return headers

    @property
    def url(self) -> str:
        """Get the final URL (requests-compatible property)."""
        return str(self._rnet_response.url)

    @property
    def ok(self) -> bool:
        """Check if response status is 2xx (requests-compatible property)."""
        return self._rnet_response.status.is_success()

    @property
    def cookies(self) -> RequestsCookieJar:
        """
        Always returns a RequestsCookieJar, for compatibility with requests.
        It tries to build it from:
        1) cookies from the rnet object (if they exist and are list/dict-like)
        2) Set-Cookie headers from the response
        """
        jar = RequestsCookieJar()

        # 1) If the rnet object exposes cookies as list or dict, dump them to the jar
        raw = getattr(self._rnet_response, "cookies", None)
        if raw is not None:
            try:
                # dict-like
                items = raw.items() if hasattr(raw, "items") else raw
                for k, v in items:
                    jar.set(k, v)
            except (TypeError, ValueError):
                # If not iterable in pairs, ignore and continue with Set-Cookie
                pass

        # 2) Parse Set-Cookie from headers
        host = urlparse(self.url).hostname
        for k, v in self._rnet_response.headers:
            key = k.decode("utf-8") if isinstance(k, bytes) else str(k)
            if key.lower() == "set-cookie":
                val = v.decode("utf-8") if isinstance(v, bytes) else str(v)
                sc = SimpleCookie()
                sc.load(val)
                for name, morsel in sc.items():
                    raw = morsel["expires"]
                    try:
                        expires = (
                            int(raw)
                            if raw and raw.isdigit()
                            else (int(parsedate_to_datetime(raw).timestamp()) if raw else None)
                        )
                    except Exception:
                        expires = None

                    ck = create_cookie(
                        name=name,
                        value=morsel.value,
                        domain=morsel["domain"] or host,
                        path=morsel["path"] or "/",
                        secure=bool(morsel["secure"]),
                        expires=expires,
                        rest={"HttpOnly": morsel["httponly"]} if morsel["httponly"] else None,
                    )
                    jar.set_cookie(ck)

        return jar

    def raise_for_status(self) -> None:
        """
        Raise HTTPError if status is not 2xx (requests-compatible method).
        The raised exception includes the response object accessible via e.response.
        """
        if not self._rnet_response.status.is_success():
            status_code = self._rnet_response.status.as_int()
            url = str(self._rnet_response.url)
            error = HTTPError(f"HTTP {status_code} error for {url}")
            error.response = self
            raise error

    # Forward-compatible properties (rnet-style)
    def bytes(self) -> bytes:
        """Get response content as bytes (rnet-style method)."""
        return self._rnet_response.bytes()

    def json(self) -> Any:
        """Parse response as JSON (compatible with both APIs)."""
        return self._rnet_response.json()

    @property
    def status(self):
        """Get rnet status object (rnet-style property)."""
        return self._rnet_response.status

    def __getattr__(self, name):
        """Forward any other attribute access to the underlying rnet response."""
        return getattr(self._rnet_response, name)


class RequestsClient:
    """A robust, proxy-enabled HTTP client with retry logic and flexible output formats."""

    def __init__(self, proxy_interface: Optional[ProxyInterface] = None):
        """
        Initializes the HTTP client.

        Args:
            proxy_interface (Optional[ProxyInterface], optional): Proxy provider. If None, no proxy is used. Defaults to None.
        """
        self.proxy_interface = proxy_interface
        # Create a base client without proxy (proxy will be set per-request)
        self.client = Client(
            emulation=Emulation.Firefox143,
            cookie_store=True,  # Enable cookie store for session continuity
            allow_redirects=True,
            max_redirects=10,
        )

    @retry(
        retry=retry_if_exception_type((TlsError, TimeoutError, ConnectionError)),
        wait=wait_exponential(exp_base=3, multiplier=3, max=60),
        stop=stop_after_delay(timedelta(minutes=10)),
        before_sleep=before_sleep_log(logger, logging.WARNING),
        reraise=True,
    )
    def _request_with_proxy_retry(self, url: str, method: str, use_auth: bool, **params):
        """
        Performs an HTTP request with proxy retry logic using rnet BlockingClient.

        Args:
            url (str): The target URL to fetch data from.
            method (str): HTTP method to use (e.g., 'GET', 'POST').
            use_auth (bool): Whether to use authenticated proxies.
            **params: Additional arguments passed to the request method.

        Returns:
            RequestsCompatibleResponse: A wrapped HTTP response object compatible with both rnet and requests APIs.
        """
        logger.info(f"Fetching data from {url} ...")

        proxy_obj = None
        if self.proxy_interface:
            host, port, user, pwd = self.proxy_interface.get_proxies(raw=True, use_auth=use_auth)
            if host and port:
                proxy_url = f"http://{host}:{port}"
                # Create Proxy object using rnet.Proxy.all() with credentials if available
                proxy_obj = Proxy.all(proxy_url, username=user, password=pwd) if user and pwd else Proxy.all(proxy_url)
                logger.info(f"Using proxy: {host}:{port}")

        request_params = params.copy()

        if proxy_obj:
            request_params["proxies"] = proxy_obj

        rnet_params = RnetRequestAdapter.requests_to_rnet_kwargs(**request_params)
        rnet_response = getattr(self.client, method.lower())(url, **rnet_params)

        return RequestsCompatibleResponse(rnet_response)

    @retry(
        retry=retry_if_not_exception_type((NotFoundError, BadRequestError, RedirectionDetectedError, IgnoredHTTPError)),
        wait=wait_exponential(exp_base=3, multiplier=3, max=60),
        stop=stop_after_attempt(5),
        before_sleep=before_sleep_log(logger, logging.WARNING),
        reraise=True,
    )
    def get_data(
        self,
        url: str,
        method: str = "GET",
        output: str = "json",
        sleep: tuple = (6, 3),
        use_auth_proxies: bool = False,
        max_proxy_delay: timedelta = timedelta(minutes=10),
        ignored_status_codes: Sequence[int] = (),
        **kwargs,
    ):
        """
        Fetches data from a given URL using HTTP requests with rnet, with support for proxy configuration, retries, and flexible output formats.

        Args:
            url (str): The target URL to fetch data from.
            method (str, optional): HTTP method to use (e.g., 'GET', 'POST'). Defaults to 'GET'.
            output (str, optional): Output format ('json', 'text', 'soup', 'response'). Defaults to 'json'.
            sleep (tuple, optional): Tuple specifying max and min sleep times (seconds) after request. Defaults to (6, 3).
            use_auth_proxies (bool, optional): Whether to use authenticated proxies. Defaults to False.
            max_proxy_delay (timedelta, optional): Maximum delay for proxy retry logic. Defaults to 10 minutes.
            ignored_status_codes (Sequence[int], optional): Status codes to ignore and return response for. Defaults to ().
            **kwargs: Additional arguments passed to the rnet request method. Supported parameters:
                - timeout (int): Total timeout in seconds (default: 30)
                - read_timeout (int): Read timeout in seconds
                - headers (Dict[str, str]): Custom headers
                - cookies (Dict[str, str]): Custom cookies
                - json (Dict[str, Any]): JSON body for POST/PUT requests
                - form (Dict[str, str] or List[Tuple[str, str]]): Form data
                - body (str or bytes): Raw request body
                - query (List[Tuple[str, str]]): Query parameters
                - auth (str): Authorization header value
                - basic_auth (Tuple[str, Optional[str]]): Basic auth credentials
                - bearer_auth (str): Bearer token
                - allow_redirects (bool): Allow redirects (default: True)
                - max_redirects (int): Max number of redirects
                - And other rnet Request parameters

        Returns:
            Depends on the 'output' argument:
            - 'json': Parsed JSON response.
            - 'text': Response text.
            - 'soup': BeautifulSoup-parsed HTML.
            - 'response': Raw rnet Response object.

        Raises:
            IgnoredHTTPError: If a response status code is in `ignored_status_codes`.
            NotFoundError: If a 404 or 410 status code is returned and not in `ignored_status_codes`.
            BadRequestError: If a 400 status code is returned and not in `ignored_status_codes`.
            EmptyResponseError: If the response has no content.
        """
        # Set default timeout if not provided
        params = kwargs.copy()

        if "timeout" not in params and "read_timeout" not in params:
            params["timeout"] = 30

        r = self._request_with_proxy_retry.retry_with(stop=stop_after_delay(max_proxy_delay))(
            self, url, method, use_auth_proxies, **params
        )

        ban_sleep(*sleep)

        # Get status code as integer (both rnet-style and requests-style work)
        status_code = r.status_code

        if status_code in ignored_status_codes:
            raise IgnoredHTTPError(message=f"Status {status_code} in ignored_status_codes for URL {url}", response=r)

        error_handlers = {
            404: lambda: NotFoundError(message=f"404 Not Found error for {url}", response=r),
            410: lambda: NotFoundError(message=f"410 Gone error for {url}", response=r),
            400: lambda: BadRequestError(message=f"400 Bad Request error for {url}", response=r),
        }

        if status_code in error_handlers:
            raise error_handlers[status_code]()

        # Use raise_for_status() which raises HTTPError with response accessible via e.response
        r.raise_for_status()

        response_content = r.content
        if not response_content:
            raise EmptyResponseError(message=f"Empty response received from {url} (status {status_code})", response=r)

        output_format = {
            "json": lambda: r.json(),
            "text": lambda: r.text,
            "soup": lambda: BeautifulSoup(response_content, "html.parser"),
            "response": lambda: r,
        }

        if output in output_format:
            return output_format[output]()

        raise ValueError(f"Unsupported output format: {output}")
