# Builtins
import asyncio
from typing import Any, Optional, Union
from json import dumps, loads
import urllib.parse
import base64
import ctypes

from .c.cffi import request, free_memory
from .cookies import cookiejar_from_dict, merge_cookies, extract_cookies_to_jar
from .exceptions.exceptions import SurfTLSClientException
from .utils.structures import CaseInsensitiveDict
from .__version__ import __version__
from .response import build_response
from .utils.session_utils import random_session_id


class Session:
    def __init__(
            self,
            ja3_string: Optional[str] = None,
            ja3_hello_id: Optional[str] = None,
            h2_settings: Optional[dict] = None,
            h2_settings_order: Optional[list] = None,
            pseudo_header_order: Optional[list] = None,
            header_order: Optional[list] = None,
            random_tls_extension_order: Optional[bool] = False,
            force_http1: Optional[bool] = False,
            force_http2: Optional[bool] = False,
            force_http3: Optional[bool] = False,
            disable_http3: Optional[bool] = False,
            insecure_skip_verify: Optional[bool] = False,
            debug: Optional[bool] = False,
            transport_options: Optional[dict] = None,
            connect_headers: Optional[dict] = None
    ) -> None:
        self._session_id = random_session_id()
        
        # Case-insensitive dictionary of headers, send on each request
        self.headers = CaseInsensitiveDict(
            {
                "User-Agent": f"surf-tls/{__version__}",
                "Accept-Encoding": "gzip, deflate, br",
                "Accept": "*/*",
                "Connection": "keep-alive",
            }
        )

        self.proxies = {}
        self.params = {}
        self.cookies = cookiejar_from_dict({})
        self.timeout_seconds = 30

        # TLS fingerprinting settings
        self.ja3_string = ja3_string
        self.ja3_hello_id = ja3_hello_id
        
        # HTTP/2 settings
        self.h2_settings = h2_settings
        self.h2_settings_order = h2_settings_order
        self.pseudo_header_order = pseudo_header_order
        self.header_order = header_order
        
        # Protocol settings
        self.random_tls_extension_order = random_tls_extension_order
        self.force_http1 = force_http1
        self.force_http2 = force_http2
        self.force_http3 = force_http3
        self.disable_http3 = disable_http3
        self.insecure_skip_verify = insecure_skip_verify
        
        # Other settings
        self.debug = debug
        self.transport_options = transport_options
        self.connect_headers = connect_headers

        self.loop = asyncio.get_event_loop()

    @property
    def timeout(self):
        return self.timeout_seconds

    @timeout.setter
    def timeout(self, seconds):
        self.timeout_seconds = seconds

    async def execute_request(
            self,
            method: str,
            url: str,
            params: Optional[dict] = None,
            data: Optional[Union[str, dict]] = None,
            headers: Optional[dict] = None,
            cookies: Optional[dict] = None,
            json: Optional[dict] = None,
            allow_redirects: Optional[bool] = True,
            timeout_seconds: Optional[int] = None,
            timeout: Optional[int] = None,
            proxy: Optional[dict] = None,
            is_byte_response: Optional[bool] = False
    ):
        # Timeout
        timeout_seconds = timeout or timeout_seconds or self.timeout_seconds
        del timeout  # deleting alias to stop further usage

        # History for redirects
        history = []

        # Prepare URL - add params to url
        if params is not None:
            url = f"{url}?{urllib.parse.urlencode(params, doseq=True)}"

        # Prepare request body
        if data is None and json is not None:
            if type(json) in [dict, list]:
                json = dumps(json)
            request_body = json
            content_type = "application/json"
        elif data is not None and type(data) not in [str, bytes]:
            request_body = urllib.parse.urlencode(data, doseq=True)
            content_type = "application/x-www-form-urlencoded"
        else:
            request_body = data
            content_type = None
        
        # Set content type if it isn't set
        if content_type is not None and "content-type" not in self.headers:
            self.headers["Content-Type"] = content_type

        # Headers
        if self.headers is None:
            headers = CaseInsensitiveDict(headers)
        elif headers is None:
            headers = self.headers
        else:
            merged_headers = CaseInsensitiveDict(self.headers)
            merged_headers.update(headers)
            none_keys = [k for (k, v) in merged_headers.items() if v is None or k is None]
            for key in none_keys:
                del merged_headers[key]
            headers = merged_headers

        # Cookies
        cookies = cookies or {}
        cookies = merge_cookies(self.cookies, cookies)
        request_cookies = [
            {'domain': c.domain, 'expires': c.expires, 'name': c.name, 'path': c.path,
             'value': c.value.replace('"', "")}
            for c in cookies
        ]

        # Proxy
        proxy = proxy or self.proxies
        if type(proxy) is dict and "http" in proxy:
            proxy = proxy["http"]
        elif type(proxy) is str:
            proxy = proxy
        else:
            proxy = ""

        while True:
            # Build request payload
            is_byte_request = isinstance(request_body, (bytes, bytearray))
            request_payload = {
                "sessionId": self._session_id,
                "followRedirects": allow_redirects,
                "forceHttp1": self.force_http1,
                "forceHttp2": self.force_http2,
                "forceHttp3": self.force_http3,
                "disableHttp3": self.disable_http3,
                "withDebug": self.debug,
                "headers": dict(headers),
                "headerOrder": self.header_order,
                "insecureSkipVerify": self.insecure_skip_verify,
                "isByteRequest": is_byte_request,
                "isByteResponse": is_byte_response,
                "proxyUrl": proxy,
                "requestUrl": url,
                "requestMethod": method,
                "requestBody": base64.b64encode(request_body).decode() if is_byte_request else (request_body or ""),
                "requestCookies": request_cookies,
                "timeoutSeconds": timeout_seconds,
                "transportOptions": self.transport_options,
                "connectHeaders": self.connect_headers,
                "ja3String": self.ja3_string or "",
                "ja3HelloID": self.ja3_hello_id or "",
                "h2Settings": self.h2_settings or {},
                "h2SettingsOrder": self.h2_settings_order or [],
                "pseudoHeaderOrder": self.pseudo_header_order or [],
                "withRandomTLSExtensionOrder": self.random_tls_extension_order
            }

            loop = asyncio.get_event_loop()
            # Call the C function
            response = await loop.run_in_executor(None, request, dumps(request_payload).encode('utf-8'))

            # Dereference the pointer to a byte array
            response_bytes = ctypes.string_at(response)
            # Convert byte array to string (surf-tls-client returns json)
            response_string = response_bytes.decode('utf-8')
            # Convert response string to json
            response_object = loads(response_string)
            # Free the memory
            await loop.run_in_executor(None, free_memory, response_object['id'].encode('utf-8'))

            # Error handling
            if response_object["status"] == 0:
                raise SurfTLSClientException(response_object["body"])
            
            # Set response cookies
            response_cookie_jar = extract_cookies_to_jar(
                request_url=url,
                request_headers=headers,
                cookie_jar=cookies,
                response_headers=response_object["headers"]
            )
            
            # Build response class
            current_response = build_response(response_object, response_cookie_jar)
            
            # Check for redirect
            if allow_redirects:
                if 'Location' in (resp_headers := current_response.headers) and current_response.status_code in (
                    300, 301, 302, 303, 307, 308
                ):
                    history.append(current_response)
                    url = resp_headers['Location']
                else:
                    break
            else:
                break

        # Assign the history to the final response
        current_response.history = history
        return current_response

    async def get(self, url: str, **kwargs: Any):
        """Sends a GET request"""
        return await self.execute_request(method="GET", url=url, **kwargs)

    async def options(self, url: str, **kwargs: Any):
        """Sends an OPTIONS request"""
        return await self.execute_request(method="OPTIONS", url=url, **kwargs)

    async def head(self, url: str, **kwargs: Any):
        """Sends a HEAD request"""
        return await self.execute_request(method="HEAD", url=url, **kwargs)

    async def post(self, url: str, data: Optional[Union[str, dict]] = None, json: Optional[dict] = None, **kwargs: Any):
        """Sends a POST request"""
        return await self.execute_request(method="POST", url=url, data=data, json=json, **kwargs)

    async def put(self, url: str, data: Optional[Union[str, dict]] = None, json: Optional[dict] = None, **kwargs: Any):
        """Sends a PUT request"""
        return await self.execute_request(method="PUT", url=url, data=data, json=json, **kwargs)

    async def patch(self, url: str, data: Optional[Union[str, dict]] = None, json: Optional[dict] = None, **kwargs: Any):
        """Sends a PATCH request"""
        return await self.execute_request(method="PATCH", url=url, data=data, json=json, **kwargs)

    async def delete(self, url: str, **kwargs: Any):
        """Sends a DELETE request"""
        return await self.execute_request(method="DELETE", url=url, **kwargs)
