from __future__ import annotations

import asyncio
import logging
from http import HTTPStatus
from typing import TYPE_CHECKING

from aiohttp import ClientSession
from aiohttp import ClientTimeout
from aiohttp import ServerConnectionError
from websockets.asyncio.client import ClientConnection
from websockets.asyncio.client import connect
from websockets.exceptions import ConnectionClosed
from websockets.protocol import State

import pysignalr.exceptions as exceptions
from pysignalr.messages import CompletionMessage
from pysignalr.messages import Message
from pysignalr.messages import PingMessage
from pysignalr.transport.abstract import ConnectionState
from pysignalr.transport.abstract import Transport
from pysignalr.utils import get_connection_url
from pysignalr.utils import get_negotiate_url
from pysignalr.utils import replace_scheme

if TYPE_CHECKING:
    import ssl
    from collections.abc import Awaitable
    from collections.abc import Callable

    from pysignalr.protocol.abstract import Protocol

DEFAULT_MAX_SIZE = 2**20  # 1 MB
DEFAULT_PING_INTERVAL = 10
DEFAULT_CONNECTION_TIMEOUT = 10

DEFAULT_RETRY_SLEEP = 1
DEFAULT_RETRY_MULTIPLIER = 1.1
DEFAULT_RETRY_COUNT = 10

_logger = logging.getLogger('pysignalr.transport')


class WebsocketTransport(Transport):
    """
    WebsocketTransport is a class that manages WebSocket connections, handles sending and receiving messages,
    and manages connection states.

    Attributes:
        url (str): The URL of the WebSocket server.
        protocol (Protocol): The protocol used for message encoding/decoding.
        callback (Callable[[Message], Awaitable[None]]): The callback function to handle incoming messages.
        headers (dict[str, str] | None): Optional HTTP headers to include in the WebSocket handshake.
        skip_negotiation (bool): Whether to skip the negotiation step.
        ping_interval (int): The interval for sending ping messages to keep the connection alive.
        connection_timeout (int): The timeout for establishing a connection.
        max_size (int | None): The maximum size for incoming messages.
        access_token_factory (Callable[[], str] | None): A factory function to provide access tokens.
    """

    def __init__(
        self,
        url: str,
        protocol: Protocol,
        callback: Callable[[Message], Awaitable[None]],
        headers: dict[str, str] | None = None,
        skip_negotiation: bool = False,
        ping_interval: int = DEFAULT_PING_INTERVAL,
        connection_timeout: int = DEFAULT_CONNECTION_TIMEOUT,
        retry_sleep: float = DEFAULT_RETRY_SLEEP,
        retry_multiplier: float = DEFAULT_RETRY_MULTIPLIER,
        retry_count: int = DEFAULT_RETRY_COUNT,
        max_size: int | None = DEFAULT_MAX_SIZE,
        access_token_factory: Callable[[], str] | None = None,
        ssl: ssl.SSLContext | None = None,
    ):
        """
        Initializes the WebSocket transport with the provided parameters.

        Args:
            url (str): The URL of the WebSocket server.
            protocol (Protocol): The protocol used for message encoding/decoding.
            callback (Callable[[Message], Awaitable[None]]): The callback function to handle incoming messages.
            headers (dict[str, str] | None): Optional HTTP headers to include in the WebSocket handshake.
            skip_negotiation (bool): Whether to skip the negotiation step.
            ping_interval (int): The interval for sending ping messages to keep the connection alive.
            connection_timeout (int): The timeout for establishing a connection.
            max_size (int | None): The maximum size for incoming messages.
            access_token_factory (Callable[[], str] | None): A factory function to provide access tokens.
        """
        super().__init__()
        self._url = url
        self._protocol = protocol
        self._callback = callback
        self._headers = headers or {}
        self._skip_negotiation = skip_negotiation
        self._ping_interval = ping_interval
        self._connection_timeout = connection_timeout
        self._max_size = max_size
        self._access_token_factory = access_token_factory
        self._retry_sleep = retry_sleep
        self._retry_multiplier = retry_multiplier
        self._retry_count = retry_count
        self._ssl = ssl

        self._state = ConnectionState.disconnected
        self._connected = asyncio.Event()
        self._ws: ClientConnection | None = None
        self._open_callback: Callable[[], Awaitable[None]] | None = None
        self._close_callback: Callable[[], Awaitable[None]] | None = None

    def on_open(self, callback: Callable[[], Awaitable[None]]) -> None:
        """
        Registers a callback function to be called when the connection is opened.

        Args:
            callback (Callable[[], Awaitable[None]]): The callback function.
        """
        self._open_callback = callback

    def on_close(self, callback: Callable[[], Awaitable[None]]) -> None:
        """
        Registers a callback function to be called when the connection is closed.

        Args:
            callback (Callable[[], Awaitable[None]]): The callback function.
        """
        self._close_callback = callback

    def on_error(self, callback: Callable[[CompletionMessage], Awaitable[None]]) -> None:
        """
        Registers a callback function to be called when an error occurs.

        Args:
            callback (Callable[[CompletionMessage], Awaitable[None]]): The callback function.
        """
        self._error_callback = callback

    async def run(self) -> None:
        """
        Runs the WebSocket transport, managing the connection lifecycle.
        """
        while True:
            try:
                await self._loop()
            except exceptions.NegotiationFailure as e:
                await self._set_state(ConnectionState.disconnected)
                self._retry_count -= 1
                if self._retry_count <= 0:
                    raise e
                self._retry_sleep *= self._retry_multiplier
                await asyncio.sleep(self._retry_sleep)
            else:
                await self._set_state(ConnectionState.disconnected)

    async def send(self, message: Message) -> None:
        """
        Sends a message over the WebSocket connection.

        Args:
            message (Message): The message to be sent.
        """
        conn = await self._get_connection()
        await conn.send(self._protocol.encode(message))

    async def _loop(self) -> None:
        """
        Manages the connection lifecycle, including reconnection logic.
        """
        await self._set_state(ConnectionState.connecting)

        if not self._skip_negotiation:
            try:
                await self._negotiate()
            except ServerConnectionError as e:
                raise exceptions.NegotiationFailure from e

        # Since websockets interprets the presence of the ssl option as something different than providing None,
        # the call needs to be made with or without ssl option to work properly
        if self._ssl is None:
            connection_loop = connect(
                self._url,
                additional_headers=self._headers,
                ping_interval=self._ping_interval,
                open_timeout=self._connection_timeout,
                max_size=self._max_size,
                logger=_logger,
            )
        else:
            connection_loop = connect(
                self._url,
                additional_headers=self._headers,
                ping_interval=self._ping_interval,
                open_timeout=self._connection_timeout,
                max_size=self._max_size,
                logger=_logger,
                ssl=self._ssl,
            )

        async for conn in connection_loop:
            try:
                await self._handshake(conn)
                self._ws = conn
                await self._set_state(ConnectionState.connected)
                await asyncio.gather(
                    self._process(conn),
                    self._keepalive(conn),
                )

            except ConnectionClosed as e:
                _logger.warning('Connection closed: %s', e)
                self._ws = None
                await self._set_state(ConnectionState.reconnecting)

    async def _set_state(self, state: ConnectionState) -> None:
        """
        Sets the connection state and triggers appropriate callbacks.

        Args:
            state (ConnectionState): The new connection state.
        """
        if state == self._state:
            return

        _logger.info('State change: %s -> %s', self._state.name, state.name)

        if state == ConnectionState.connecting:
            if self._state != ConnectionState.disconnected:
                raise RuntimeError('Cannot connect while not disconnected')

            self._connected.clear()

        elif state == ConnectionState.connected:
            if self._state not in (ConnectionState.connecting, ConnectionState.reconnecting):
                raise RuntimeError('Cannot connect while not connecting or reconnecting')

            self._connected.set()

            if self._open_callback:
                await self._open_callback()

        elif state in (ConnectionState.reconnecting, ConnectionState.disconnected):
            self._connected.clear()

            if self._close_callback:
                await self._close_callback()

        else:
            raise NotImplementedError

        self._state = state

    async def _get_connection(self) -> ClientConnection:
        """
        Gets the active WebSocket connection, ensuring it is open.

        Returns:
            ClientConnection: The active WebSocket connection.

        Raises:
            RuntimeError: If the connection is closed or was never run.
        """
        try:
            await asyncio.wait_for(self._connected.wait(), self._connection_timeout)
        except TimeoutError as e:
            raise RuntimeError('The socket was never run') from e
        if not self._ws or self._ws.state != State.OPEN:
            raise RuntimeError('Connection is closed')
        return self._ws

    async def _process(self, conn: ClientConnection) -> None:
        """
        Processes incoming messages from the WebSocket connection.

        Args:
            conn (ClientConnection): The WebSocket connection.
        """
        while True:
            raw_message = await conn.recv()
            await self._on_raw_message(raw_message)

    async def _keepalive(self, conn: ClientConnection) -> None:
        """
        Sends periodic ping messages to keep the connection alive.

        Args:
            conn (ClientConnection): The WebSocket connection.
        """
        while True:
            await asyncio.sleep(10)
            await conn.send(self._protocol.encode(PingMessage()))

    async def _handshake(self, conn: ClientConnection) -> None:
        """
        Performs the WebSocket handshake with the server.

        Args:
            conn (ClientConnection): The WebSocket connection.
        """
        _logger.info('Sending handshake to server')
        token = self._access_token_factory() if self._access_token_factory else None
        if token:
            self._headers['Authorization'] = f'Bearer {token}'
        our_handshake = self._protocol.handshake_message()
        await conn.send(self._protocol.encode(our_handshake))

        _logger.info('Awaiting handshake from server')
        raw_message = await conn.recv()
        handshake, messages = self._protocol.decode_handshake(raw_message)
        if handshake.error:
            raise ValueError(f'Handshake error: {handshake.error}')
        for message in messages:
            await self._on_message(message)

    async def _negotiate(self) -> None:
        """
        Performs the negotiation step to establish the connection.
        """
        negotiate_url = get_negotiate_url(self._url)
        _logger.info('Performing negotiation, URL: `%s`', negotiate_url)

        session = ClientSession(
            timeout=ClientTimeout(connect=self._connection_timeout),
        )
        async with session:
            async with session.post(negotiate_url, headers=self._headers) as response:
                if response.status == HTTPStatus.OK:
                    data = await response.json()
                elif response.status == HTTPStatus.UNAUTHORIZED:
                    raise exceptions.AuthorizationError
                else:
                    raise exceptions.ConnectionError(response.status)

        connection_id = data.get('connectionId')
        url = data.get('url')
        access_token = data.get('accessToken')

        if connection_id:
            _logger.info('Negotiation completed')
            self._url = get_connection_url(self._url, connection_id)
        elif url and access_token:
            _logger.info('Negotiation completed (Azure)')
            self._url = replace_scheme(url, ws=True)
            self._headers['Authorization'] = f'Bearer {access_token}'
        else:
            raise exceptions.ServerError(str(data))

    async def _on_raw_message(self, raw_message: str | bytes) -> None:
        """
        Handles raw incoming messages, decoding them into protocol-specific messages.

        Args:
            raw_message (str | bytes): The raw incoming message.
        """
        for message in self._protocol.decode(raw_message):
            await self._on_message(message)

    async def _on_message(self, message: Message) -> None:
        """
        Handles decoded messages, passing them to the registered callback.

        Args:
            message (Message): The decoded message.
        """
        await self._callback(message)


class BaseWebsocketTransport(WebsocketTransport):
    """
    BaseWebsocketTransport is a subclass of WebsocketTransport that disables keepalive and handshake
    for simplified use cases.
    """

    async def _keepalive(self, conn: ClientConnection) -> None:
        """
        Disabled keepalive method.

        Args:
            conn (ClientConnection): The WebSocket connection.
        """
        return

    async def _handshake(self, conn: ClientConnection) -> None:
        """
        Disabled handshake method.

        Args:
            conn (ClientConnection): The WebSocket connection.
        """
        return
