from __future__ import annotations

import io
from collections.abc import Sequence
from types import GeneratorType
from typing import Any, cast
from urllib.parse import unquote

import anyio
import httpx

from lilya.testclient._internal.types import ASGI3App, PortalFactoryType
from lilya.testclient._internal.websockets import WebSocketTestSession
from lilya.testclient.exceptions import ASGISpecViolation, UpgradeException
from lilya.types import Message

_AUTH_USER_KEY = "__lilya_testclient_authenticated_user__"


class TestClientTransport(httpx.BaseTransport):
    """
    A synchronous transport adapter for `httpx` that routes requests directly to an ASGI
    application.

    This transport allows the `TestClient` to execute requests against an ASGI app
    in a synchronous context by utilizing a threading portal to bridge the gap
    between the synchronous test code and the asynchronous ASGI application.
    """

    encoding: str = "ascii"

    def __init__(
        self,
        app: ASGI3App,
        portal_factory: PortalFactoryType,
        raise_server_exceptions: bool = True,
        root_path: str = "",
        *,
        check_asgi_conformance: bool = True,
        app_state: dict[str, Any],
    ) -> None:
        """
        Initializes the TestClientTransport.

        Args:
            app (ASGI3App): The ASGI3 application instance to which requests will be dispatched.
            portal_factory (PortalFactoryType): A factory function that yields a blocking portal,
                allowing synchronous code to drive the asynchronous ASGI app.
            raise_server_exceptions (bool, optional): If True, exceptions occurring within the
                application will be raised in the test client. Defaults to True.
            root_path (str, optional): The root path to be prepended to request paths, useful
                for testing mounted applications. Defaults to "".
            check_asgi_conformance (bool, optional): If True, enforces strict checks against
                the ASGI specification (e.g., header types, body types) and raises errors on violations.
                Defaults to True.
            app_state (dict[str, Any]): A shared dictionary representing the application's
                state, used for persistence across requests (e.g., authentication).
        """
        self.app = app
        self.raise_server_exceptions = raise_server_exceptions
        self.check_asgi_conformance = check_asgi_conformance
        self.root_path = root_path
        self.portal_factory = portal_factory
        self.app_state = app_state

    def _inject_authenticated_user(self, scope: dict[str, Any]) -> None:
        """
        Injects the currently authenticated user into the ASGI scope.

        This helper method checks the `app_state` for a specific authentication key.
        If a user is found, it is added to the `scope["user"]` and `scope["state"]["user"]`
        to simulate an authenticated session during the request.

        Args:
            scope (dict[str, Any]): The ASGI scope dictionary being prepared for the request.
        """
        user = self.app_state.get(_AUTH_USER_KEY)
        if user is None:
            return

        scope["user"] = user
        state = scope.get("state")
        if not isinstance(state, dict):
            state = {}
            scope["state"] = state
        state.setdefault("user", user)

    def handle_request(self, request: httpx.Request) -> httpx.Response:
        """
        Intercepts the standard HTTP request and processes it through the ASGI application.

        This method parses the URL, builds the necessary headers and ASGI scope,
        and determines whether to handle the request as a standard HTTP request or
        initiate a WebSocket session.

        Args:
            request (httpx.Request): The incoming HTTP request object from the client.

        Returns:
            httpx.Response: The response generated by the ASGI application.

        Raises:
            UpgradeException: If the request is a WebSocket upgrade request.
        """
        scheme, netloc, path, raw_path, query = self._parse_url(request.url)
        host, port, default_port = self._parse_host_and_port(netloc, scheme)
        headers = self._build_headers(request.headers, host, port, default_port)

        if scheme in {"ws", "wss"}:
            session = self._handle_websocket_request(
                request, scheme, path, cast(str, raw_path), query, headers, host, port
            )
            raise UpgradeException(session)

        scope = self._build_http_scope(
            request, scheme, path, cast(str, raw_path), query, headers, host, port
        )
        response = self._process_http_request(scope, request)
        return response

    def _parse_url(
        self, url: httpx.URL
    ) -> tuple[str, str, str, str, str] | tuple[str, str, str, bytes, str]:
        """
        Deconstructs the URL into its constituent components.

        Args:
            url (httpx.URL): The URL object to parse.

        Returns:
            tuple: A tuple containing the scheme, netloc, path, raw_path, and query string.
        """
        scheme = url.scheme
        netloc = url.netloc.decode(encoding=self.encoding)
        path = url.path
        raw_path = url.raw_path
        query = url.query.decode(encoding=self.encoding)
        return scheme, netloc, path, raw_path, query

    def _parse_host_and_port(self, netloc: str, scheme: str) -> tuple[str, int, int]:
        """
        Extracts the host and port from the network location string.

        Args:
            netloc (str): The network location string (e.g., "localhost:8000").
            scheme (str): The URL scheme (e.g., "http", "https"), used to determine defaults.

        Returns:
            tuple[str, int, int]: A tuple containing the host, the port, and the default
            port for the scheme.
        """
        default_port = {"http": 80, "ws": 80, "https": 443, "wss": 443}[scheme]
        if ":" in netloc:
            host, port_string = netloc.split(":", 1)
            port = int(port_string)
        else:
            host = netloc
            port = default_port
        return host, port, default_port

    def _build_headers(
        self, request_headers: httpx.Headers, host: str, port: int, default_port: int
    ) -> list[tuple[bytes, bytes]]:
        """
        Constructs the list of byte-encoded headers required for the ASGI scope.

        This ensures that the 'host' header is correctly set based on the parsed
        host and port, and converts all other headers from the request into the
        format expected by ASGI (list of byte tuples).

        Args:
            request_headers (httpx.Headers): The headers from the original request.
            host (str): The target host.
            port (int): The target port.
            default_port (int): The default port for the scheme.

        Returns:
            list[tuple[bytes, bytes]]: The list of headers suitable for the ASGI scope.
        """
        headers: list[Any] = []
        if "host" in request_headers:
            headers = []
        elif port == default_port:
            headers = [(b"host", host.encode())]
        else:
            headers = [(b"host", (f"{host}:{port}").encode())]
        headers += [
            (key.lower().encode(), value.encode()) for key, value in request_headers.multi_items()
        ]
        return headers

    def _handle_websocket_request(
        self,
        request: httpx.Request,
        scheme: str,
        path: str,
        raw_path: str,
        query: str,
        headers: list[tuple[bytes, bytes]],
        host: str,
        port: int,
    ) -> WebSocketTestSession:
        """
        Prepares and initiates a WebSocket test session.

        This method constructs the ASGI scope for a WebSocket connection and initializes
        a `WebSocketTestSession` instance.

        Args:
            request (httpx.Request): The original upgrade request.
            scheme (str): The URL scheme ("ws" or "wss").
            path (str): The URL path.
            raw_path (str): The raw URL path.
            query (str): The query string.
            headers (list[tuple[bytes, bytes]]): The prepared list of headers.
            host (str): The server host.
            port (int): The server port.

        Returns:
            WebSocketTestSession: An object representing the WebSocket connection.
        """
        subprotocol = request.headers.get("sec-websocket-protocol", None)
        if subprotocol is None:
            subprotocols: Sequence[str] = []
        else:
            subprotocols = [value.strip() for value in subprotocol.split(",")]

        scope: dict[str, Any] = {
            "type": "websocket",
            "path": unquote(path),
            "raw_path": raw_path,
            "root_path": self.root_path,
            "scheme": scheme,
            "query_string": query.encode(),
            "headers": headers,
            "client": None,
            "server": [host, port],
            "subprotocols": subprotocols,
            "state": self.app_state.copy(),
            "extensions": {"websocket.http.response": {}},
        }
        self._inject_authenticated_user(scope)
        session = WebSocketTestSession(self.app, scope, self.portal_factory)
        return session

    def _build_http_scope(
        self,
        request: httpx.Request,
        scheme: str,
        path: str,
        raw_path: str,
        query: str,
        headers: list[tuple[bytes, bytes]],
        host: str,
        port: int,
    ) -> dict[str, Any]:
        """
        Constructs the ASGI scope dictionary for a standard HTTP request.

        Args:
            request (httpx.Request): The HTTP request object.
            scheme (str): The URL scheme.
            path (str): The URL path.
            raw_path (str): The raw URL path.
            query (str): The query string.
            headers (list[tuple[bytes, bytes]]): The list of byte headers.
            host (str): The server host.
            port (int): The server port.

        Returns:
            dict[str, Any]: The ASGI scope dictionary.
        """
        scope: dict[str, Any] = {
            "type": "http",
            "http_version": "1.1",
            "method": request.method,
            "path": unquote(path),
            "raw_path": raw_path,
            "root_path": self.root_path,
            "scheme": scheme,
            "query_string": query.encode(),
            "headers": headers,
            "client": None,
            "server": [host, port],
            "extensions": {"http.response.debug": {}},
            "state": self.app_state.copy(),
        }
        self._inject_authenticated_user(scope)
        return scope

    def _process_http_request(
        self, scope: dict[str, Any], request: httpx.Request
    ) -> httpx.Response:
        """
        Orchestrates the HTTP request-response cycle within the ASGI application.

        This method defines the `receive` and `send` callables required by the ASGI spec,
        manages the request body streaming, and captures the response from the application.
        It uses a portal to run the async application code synchronously.

        Args:
            scope (dict[str, Any]): The ASGI scope for the request.
            request (httpx.Request): The original HTTP request object.

        Returns:
            httpx.Response: The constructed HTTP response object.

        Raises:
            ASGISpecViolation: If the application violates the ASGI specification (e.g.,
                yielding non-byte data, malformed headers) and `check_asgi_conformance` is True.
            Exception: Re-raises any exception occurring within the application if
                `raise_server_exceptions` is True.
        """
        request_complete = False
        response_started = False
        response_complete: anyio.Event
        raw_kwargs: dict[str, Any] = {"stream": io.BytesIO()}
        template = None
        context = None

        async def receive() -> Message:
            nonlocal request_complete

            if request_complete:
                if not response_complete.is_set():
                    await response_complete.wait()
                return {"type": "http.disconnect"}

            body = request.read()
            if isinstance(cast(str, body), str):
                if self.check_asgi_conformance:
                    raise ASGISpecViolation("ASGI Spec violation: body must be a bytes string")
                body_bytes: bytes = body.encode("utf-8")
            elif body is None:
                body_bytes = b""
            elif isinstance(cast(str, body), GeneratorType):
                try:
                    chunk = body.send(None)
                    if isinstance(chunk, str):
                        if self.check_asgi_conformance:
                            raise ASGISpecViolation(
                                "ASGI Spec violation: chunk must be a bytes string"
                            )
                        chunk = chunk.encode("utf-8")
                    return {"type": "http.request", "body": chunk, "more_body": True}
                except StopIteration:
                    request_complete = True
                    return {"type": "http.request", "body": b""}
            else:
                body_bytes = body

            request_complete = True
            return {"type": "http.request", "body": body_bytes}

        async def send(message: Message) -> None:
            nonlocal raw_kwargs, response_started, template, context

            if message["type"] == "http.response.start":
                assert not response_started, 'Received multiple "http.response.start" messages.'
                raw_kwargs["status_code"] = message["status"]
                raw_kwargs["headers"] = list(message.get("headers", []))
                response_started = True
            elif message["type"] == "http.response.body":
                assert response_started, (
                    'Received "http.response.body" without "http.response.start".'
                )
                assert not response_complete.is_set(), (
                    'Received "http.response.body" after response completed.'
                )
                body = message.get("body", b"")
                if self.check_asgi_conformance and not isinstance(
                    body, (bytes, memoryview, bytearray)
                ):
                    raise ASGISpecViolation("ASGI Spec violation: body must be a bytes string")
                more_body = message.get("more_body", False)
                if request.method != "HEAD":
                    raw_kwargs["stream"].write(body)
                if not more_body:
                    raw_kwargs["stream"].seek(0)
                    response_complete.set()
            elif message["type"] == "http.response.debug":
                template = message["info"]["template"]
                context = message["info"]["context"]

        try:
            with self.portal_factory() as portal:
                response_complete = portal.call(anyio.Event)
                portal.call(self.app, scope, receive, send)
        except BaseException as exc:
            if self.raise_server_exceptions:
                raise exc

        if not response_started:
            if self.raise_server_exceptions or self.check_asgi_conformance:
                raise ASGISpecViolation("TestClient did not receive any response.")
            raw_kwargs = {"status_code": 500, "headers": [], "stream": io.BytesIO()}

        # Convert the stream content to an httpx.ByteStream for the final response
        raw_kwargs["stream"] = httpx.ByteStream(raw_kwargs["stream"].read())
        raw_kwargs["headers"] = list(raw_kwargs["headers"])

        if self.check_asgi_conformance:
            for header_key, header_value in raw_kwargs["headers"]:
                if not isinstance(header_key, bytes):
                    raise ASGISpecViolation(
                        f'Response header key "{header_key!r}" is not a bytes string.'
                    )
                if b"\n" in header_key:
                    raise ASGISpecViolation(
                        f'Response header key "{header_key!r}" contains a newline.'
                    )
                if not isinstance(header_value, bytes):
                    raise ASGISpecViolation(
                        f'Response header key "{header_key!r}" value ("{header_value!r}") is not a bytes string.'
                    )
                if b"\n" in header_value:
                    raise ASGISpecViolation(
                        f'Response header "{header_key!r}" value ("{header_value!r}") contains a newline.'
                    )

        response = httpx.Response(**raw_kwargs, request=request)
        if template is not None:
            response.template = template
            response.context = context
        return response


class AsyncTestClientTransport(httpx.AsyncBaseTransport):
    """
    An asynchronous transport adapter for `httpx` that routes requests directly to an ASGI
    application.

    Unlike `TestClientTransport`, this class does not use a threading portal. Instead,
    it awaits the ASGI application directly, making it suitable for use within an existing
    async event loop (e.g., when running tests with `pytest-asyncio` or `trio`).
    """

    encoding: str = "ascii"

    def __init__(
        self,
        app: ASGI3App,
        *,
        raise_server_exceptions: bool = True,
        root_path: str = "",
        check_asgi_conformance: bool = True,
        app_state: dict[str, Any],
    ) -> None:
        """
        Initializes the AsyncTestClientTransport.

        Args:
            app (ASGI3App): The ASGI3 application instance.
            raise_server_exceptions (bool, optional): If True, application exceptions are raised.
                Defaults to True.
            root_path (str, optional): The root path to prepend to requests. Defaults to "".
            check_asgi_conformance (bool, optional): If True, enforces strict ASGI compliance.
                Defaults to True.
            app_state (dict[str, Any]): The shared application state.
        """
        self.app = app
        self.raise_server_exceptions = raise_server_exceptions
        self.check_asgi_conformance = check_asgi_conformance
        self.root_path = root_path
        self.app_state = app_state

    def _inject_authenticated_user(self, scope: dict[str, Any]) -> None:
        """
        Injects the currently authenticated user into the ASGI scope.

        Checks the shared `app_state` and populates `scope["user"]` if a user is present.

        Args:
            scope (dict[str, Any]): The ASGI scope dictionary.
        """
        user = self.app_state.get(_AUTH_USER_KEY)
        if user is None:
            return

        scope["user"] = user
        state = scope.get("state")
        if not isinstance(state, dict):
            state = {}
            scope["state"] = state
        state.setdefault("user", user)

    async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
        """
        Asynchronously handles the HTTP request by passing it through the ASGI app.

        Args:
            request (httpx.Request): The incoming request.

        Returns:
            httpx.Response: The response from the ASGI app.

        Raises:
            UpgradeException: If the request is a WebSocket upgrade request.
        """
        scheme, netloc, path, raw_path, query = self._parse_url(request.url)
        host, port, default_port = self._parse_host_and_port(netloc, scheme)
        headers = self._build_headers(request.headers, host, port, default_port)

        if scheme in {"ws", "wss"}:
            session = await self._handle_websocket_request(
                request, scheme, path, cast(str, raw_path), query, headers, host, port
            )
            raise UpgradeException(session)

        scope = self._build_http_scope(
            request, scheme, path, cast(str, raw_path), query, headers, host, port
        )

        return await self._process_http_request(scope, request)

    def _parse_url(
        self, url: httpx.URL
    ) -> tuple[str, str, str, str, str] | tuple[str, str, str, bytes, str]:
        """
        Parses the URL into components.

        Args:
            url (httpx.URL): The URL to parse.

        Returns:
            tuple: Scheme, netloc, path, raw_path, and query.
        """
        scheme = url.scheme
        netloc = url.netloc.decode(encoding=self.encoding)
        path = url.path
        raw_path = url.raw_path
        query = url.query.decode(encoding=self.encoding)
        return scheme, netloc, path, raw_path, query

    def _parse_host_and_port(self, netloc: str, scheme: str) -> tuple[str, int, int]:
        """
        Extracts host and port from the network location.


        Args:
            netloc (str): Network location string.
            scheme (str): URL scheme.

        Returns:
            tuple[str, int, int]: Host, port, and default port.
        """
        default_port = {"http": 80, "ws": 80, "https": 443, "wss": 443}[scheme]
        if ":" in netloc:
            host, port_str = netloc.split(":", 1)
            port = int(port_str)
        else:
            host = netloc
            port = default_port
        return host, port, default_port

    def _build_headers(
        self, request_headers: httpx.Headers, host: str, port: int, default_port: int
    ) -> list[tuple[bytes, bytes]]:
        """
        Builds the ASGI-compliant headers list.

        Args:
            request_headers (httpx.Headers): Original headers.
            host (str): Target host.
            port (int): Target port.
            default_port (int): Default port for the scheme.

        Returns:
            list[tuple[bytes, bytes]]: List of header tuples.
        """
        if "host" in request_headers:
            headers: list[Any] = []
        elif port == default_port:
            headers = [(b"host", host.encode())]
        else:
            headers = [(b"host", f"{host}:{port}".encode())]
        headers += [
            (key.lower().encode(), value.encode()) for key, value in request_headers.multi_items()
        ]
        return headers

    async def _handle_websocket_request(
        self,
        request: httpx.Request,
        scheme: str,
        path: str,
        raw_path: str,
        query: str,
        headers: list[tuple[bytes, bytes]],
        host: str,
        port: int,
    ) -> WebSocketTestSession:
        """
        Prepares and initiates an asynchronous WebSocket test session.

        Args:
            request (httpx.Request): The upgrade request.
            scheme (str): "ws" or "wss".
            path (str): URL path.
            raw_path (str): Raw URL path.
            query (str): Query string.
            headers (list[tuple[bytes, bytes]]): Request headers.
            host (str): Server host.
            port (int): Server port.

        Returns:
            WebSocketTestSession: The WebSocket session instance.
        """
        subprotocol = request.headers.get("sec-websocket-protocol", None)
        if subprotocol is None:
            subprotocols: Sequence[str] = []
        else:
            subprotocols = [value.strip() for value in subprotocol.split(",")]

        scope: dict[str, Any] = {
            "type": "websocket",
            "path": unquote(path),
            "raw_path": raw_path,
            "root_path": self.root_path,
            "scheme": scheme,
            "query_string": query.encode(),
            "headers": headers,
            "client": None,
            "server": [host, port],
            "subprotocols": subprotocols,
            "state": self.app_state.copy(),
            "extensions": {"websocket.http.response": {}},
        }
        self._inject_authenticated_user(scope)
        return WebSocketTestSession(self.app, scope, portal_factory=None)

    def _build_http_scope(
        self,
        request: httpx.Request,
        scheme: str,
        path: str,
        raw_path: str,
        query: str,
        headers: list[tuple[bytes, bytes]],
        host: str,
        port: int,
    ) -> dict[str, Any]:
        """
        Constructs the ASGI scope for an asynchronous HTTP request.

        Args:
            request (httpx.Request): The request object.
            scheme (str): URL scheme.
            path (str): URL path.
            raw_path (str): Raw URL path.
            query (str): Query string.
            headers (list[tuple[bytes, bytes]]): Request headers.
            host (str): Host.
            port (int): Port.

        Returns:
            dict[str, Any]: The ASGI scope.
        """
        scope: dict[str, Any] = {
            "type": "http",
            "http_version": "1.1",
            "method": request.method,
            "path": unquote(path),
            "raw_path": raw_path,
            "root_path": self.root_path,
            "scheme": scheme,
            "query_string": query.encode(),
            "headers": headers,
            "client": None,
            "server": [host, port],
            "extensions": {"http.response.debug": {}},
            "state": self.app_state.copy(),
        }
        self._inject_authenticated_user(scope)
        return scope

    async def _process_http_request(
        self, scope: dict[str, Any], request: httpx.Request
    ) -> httpx.Response:
        """
        Orchestrates the asynchronous HTTP request-response cycle.

        Args:
            scope (dict[str, Any]): The ASGI scope.
            request (httpx.Request): The HTTP request.

        Returns:
            httpx.Response: The HTTP response.

        Raises:
            ASGISpecViolation: On ASGI spec violations.
            Exception: On application errors if configured to raise them.
        """
        request_complete = False
        response_started = False
        response_complete = anyio.Event()
        raw_kwargs: dict[str, Any] = {"stream": io.BytesIO()}
        template = None
        context = None

        async def receive() -> Message:
            nonlocal request_complete

            if request_complete:
                if not response_complete.is_set():
                    await response_complete.wait()
                return {"type": "http.disconnect"}

            body = request.read()
            if isinstance(body, str):  # type: ignore[unreachable]
                if self.check_asgi_conformance:  # type: ignore[unreachable]
                    raise ASGISpecViolation("ASGI Spec violation: body must be bytes")
                body_bytes = body.encode("utf-8")
            elif body is None:
                body_bytes = b""
            elif isinstance(body, GeneratorType):  # type: ignore[unreachable]
                try:  # type: ignore[unreachable]
                    chunk = next(body)
                    if isinstance(chunk, str):
                        if self.check_asgi_conformance:
                            raise ASGISpecViolation("ASGI Spec violation: chunk must be bytes")
                        chunk = chunk.encode("utf-8")
                    return {"type": "http.request", "body": chunk, "more_body": True}
                except StopIteration:
                    request_complete = True
                    return {"type": "http.request", "body": b""}
            else:
                body_bytes = body

            request_complete = True
            return {"type": "http.request", "body": body_bytes}

        async def send(message: Message) -> None:
            nonlocal raw_kwargs, response_started, template, context

            if message["type"] == "http.response.start":
                assert not response_started
                raw_kwargs["status_code"] = message["status"]
                raw_kwargs["headers"] = list(message.get("headers", []))
                response_started = True
            elif message["type"] == "http.response.body":
                assert response_started
                body = message.get("body", b"")
                if self.check_asgi_conformance and not isinstance(
                    body, (bytes, memoryview, bytearray)
                ):
                    raise ASGISpecViolation("ASGI Spec violation: body must be bytes")
                if request.method != "HEAD":
                    raw_kwargs["stream"].write(body)
                if not message.get("more_body", False):
                    raw_kwargs["stream"].seek(0)
                    response_complete.set()
            elif message["type"] == "http.response.debug":
                template = message["info"]["template"]
                context = message["info"]["context"]

        try:
            await self.app(scope, receive, send)
        except BaseException:
            if self.raise_server_exceptions:
                raise
        finally:
            response_complete.set()

        if not response_started:
            if self.raise_server_exceptions or self.check_asgi_conformance:
                raise ASGISpecViolation("TestClient did not receive any response.")
            raw_kwargs.update(
                status_code=500, headers=[], stream=io.BytesIO(b"Internal Server Error")
            )

        raw_kwargs["stream"] = httpx.ByteStream(raw_kwargs["stream"].read())
        raw_kwargs["headers"] = list(raw_kwargs["headers"])

        if self.check_asgi_conformance:
            for header_key, header_value in raw_kwargs["headers"]:
                if not isinstance(header_key, bytes):
                    raise ASGISpecViolation(
                        f'Response header key "{header_key!r}" is not a bytes string.'
                    )
                if b"\n" in header_key:
                    raise ASGISpecViolation(
                        f'Response header key "{header_key!r}" contains a newline.'
                    )
                if not isinstance(header_value, bytes):
                    raise ASGISpecViolation(
                        f'Response header key "{header_key!r}" value ("{header_value!r}") is not a bytes string.'
                    )
                if b"\n" in header_value:
                    raise ASGISpecViolation(
                        f'Response header "{header_key!r}" value ("{header_value!r}") contains a newline.'
                    )

        response = httpx.Response(**raw_kwargs, request=request)
        if template is not None:
            response.template = template
            response.context = context
        return response
