import copy
import logging
import socket
import time
from dataclasses import dataclass
from datetime import timedelta
from enum import IntEnum
from types import TracebackType
from typing import Callable, Iterator, Optional, Type

import requests
from urllib3.exceptions import ReadTimeoutError
from urllib3.util import Url, parse_url

__all__ = [
    "ReadyState",
    "EventSource",
    "MessageEvent",
    "InvalidStatusCodeError",
    "InvalidContentTypeError",
]

DEFAULT_RECONNECTION_TIME = timedelta(seconds=5)
DEFAULT_MAX_CONNECT_RETRY = 5
_CONTENT_TYPE_EVENT_STREAM = "text/event-stream"
_LOGGER = logging.getLogger(__name__)


def _iter_exception_chain(exc: BaseException) -> Iterator[BaseException]:
    """Yield exceptions reachable from args/cause/context without looping."""
    stack = [exc]
    seen = set()

    while stack:
        current = stack.pop()
        current_id = id(current)
        if current_id in seen:
            continue
        seen.add(current_id)
        yield current

        if current.__cause__ is not None:
            stack.append(current.__cause__)
        if current.__context__ is not None:
            stack.append(current.__context__)
        for arg in current.args:
            if isinstance(arg, BaseException):
                stack.append(arg)


def _is_read_timeout_error(exc: BaseException) -> bool:
    """Detect read timeouts across requests/urllib3 exception wrappers."""
    timeout_types = (
        requests.exceptions.ReadTimeout,
        requests.exceptions.Timeout,
        ReadTimeoutError,
        socket.timeout,
    )
    return any(isinstance(current, timeout_types) for current in _iter_exception_chain(exc))


class InvalidStatusCodeError(requests.RequestException):
    """Invalid status code error."""

    def __init__(self, status_code: int, *args, **kwargs):
        self.status_code = status_code
        requests.RequestException.__init__(self, *args, **kwargs)


class InvalidContentTypeError(requests.RequestException):
    """Invalid content type error."""

    def __init__(self, content_type: Optional[str], *args, **kwargs):
        self.content_type = content_type
        requests.RequestException.__init__(self, *args, **kwargs)


class ReadyState(IntEnum):
    """Represents the state of the connection.

    See `MDN - EventSource: readyState property <https://developer.mozilla.org/en-US/docs/Web/API/EventSource/readyState>`__ for more information.
    """

    CONNECTING = 0
    OPEN = 1
    CLOSED = 2


@dataclass
class MessageEvent:
    """Represents MessageEvent Interface.

    See `MDN - EventSource: instance properties <https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent#instance_properties>`__ for more information.
    See `Event types <https://javascript.info/server-sent-events#event-types>` for more information.
    """

    type: str
    """A string representing the type of event."""
    data: str
    """The data sent by the message emitter."""
    origin: str
    """A string representing the message emitter."""
    last_event_id: str
    """A string representing a unique ID for the event."""


class EventSource:
    """Represents EventSource Interface as a context manager.

    An example::

        import requests
        from requests_sse import EventSource, InvalidStatusCodeError, InvalidContentTypeError

        with EventSource("https://stream.wikimedia.org/v2/stream/recentchange", timeout=30) as event_source:
            try:
                for event in event_source:
                    print(event)
            except InvalidStatusCodeError:
                pass
            except InvalidContentTypeError:
                pass
            except requests.RequestException:
                pass

    See `MDN - EventSource <https://developer.mozilla.org/en-US/docs/Web/API/EventSource>`__ for more information.

    :param url: specifies the URL to which to connect
    :param method: specifies the HTTP method with which connection should be established
    :param reconnection_time: wait time before try to reconnect in case
        connection broken
    :param max_connect_retry: maximum number of retries to connect
    :param timeout: how long to wait for the server to send the next bytes of
        the stream before raising a timeout to the caller. ``None`` disables
        the timeout. For long idle streams, prefer no timeout or periodic
        heartbeat comments from the server. After a timeout, the current
        stream is closed and the caller may reconnect explicitly. See
        https://docs.python-requests.org/en/latest/user/quickstart/#timeouts
        for more information
    :param session: specifies a requests.Session, if not, create
        a default requests.Session
    :param on_open: event handler for open event
    :param on_message: event handler for message event, only called when
        the event type is "message"
    :param on_error: event handler for error event
    :param latest_event_id: specifies the last event ID value of the connection
    :param kwargs: keyword arguments will pass to underlying requests.request() method.

    :raises InvalidStatusCodeError: if status code is not 200
    :raises InvalidContentTypeError: if content type is not 'text/event-stream'
    :raises requests.RequestException: if connection failed, or if reading the
        event stream timed out
    """

    def __init__(
        self,
        url: str,
        method: str = "GET",
        reconnection_time: timedelta = DEFAULT_RECONNECTION_TIME,
        max_connect_retry: int = DEFAULT_MAX_CONNECT_RETRY,
        timeout: Optional[float] = None,
        session: Optional[requests.Session] = None,
        on_open: Optional[Callable[[], None]] = None,
        on_message: Optional[Callable[[MessageEvent], None]] = None,
        on_error: Optional[Callable[[], None]] = None,
        latest_event_id: Optional[str] = None,
        **kwargs,
    ):
        self._url = url
        self._ready_state = ReadyState.CONNECTING

        if session is not None:
            self._session = session
            self._need_close_session = False
        else:
            self._session = requests.Session()
            self._need_close_session = True

        self._on_open = on_open
        self._on_message = on_message
        self._on_error = on_error

        self._reconnection_time = reconnection_time
        self._max_connect_retry = max_connect_retry
        self._timeout = timeout
        self._last_event_id = "" if latest_event_id is None else latest_event_id
        self._kwargs = copy.deepcopy(kwargs)

        if "headers" not in self._kwargs:
            self._kwargs["headers"] = dict()
        self._kwargs["headers"]["Accept"] = _CONTENT_TYPE_EVENT_STREAM
        self._kwargs["headers"]["Cache-Control"] = "no-cache"

        self._event_id = ""
        self._event_type = None
        self._event_data = None

        self._origin: Optional[str] = None
        self._response: Optional[requests.Response] = None
        self._data_generator: Optional[Iterator] = None
        self._first_line = True

        self._method = method

    def __enter__(self) -> "EventSource":
        """Connect and listen Server-Sent Event."""
        self.connect(self._max_connect_retry)
        return self

    def __exit__(
        self,
        exc_type: Optional[Type[BaseException]],
        exc_value: Optional[BaseException],
        traceback: Optional[TracebackType],
    ) -> None:
        """Call closing methods."""
        self.close()

    @property
    def url(self) -> str:
        """Return URL to which to connect."""
        return self._url

    @property
    def ready_state(self) -> ReadyState:
        """Return ready state."""
        return self._ready_state

    def __iter__(self):
        return self

    def _reset_connection(self) -> None:
        """Close the active response and clear stream iteration state."""
        if self._response is not None:
            self._response.close()
            self._response = None
            self._data_generator = None

    def __next__(self) -> MessageEvent:
        """Process events"""
        if self._ready_state == ReadyState.CLOSED:
            raise StopIteration

        if self._response is None:
            raise ValueError("response is None")

        if self._data_generator is None:
            raise ValueError("data_generator is None")

        while True:
            while True:
                try:
                    line_in_bytes = next(self._data_generator)
                except StopIteration:
                    self._event_type = None
                    self._event_data = None
                    break
                except requests.RequestException as e:
                    if _is_read_timeout_error(e):
                        _LOGGER.debug(
                            "Read timeout while waiting for event data at %s; "
                            "propagating to caller",
                            self._url,
                            exc_info=e,
                        )
                        self._event_type = None
                        self._event_data = None
                        self._ready_state = ReadyState.CONNECTING
                        self._reset_connection()
                        raise

                    _LOGGER.debug(
                        "Failed to read next line from event stream at %s: %s",
                        self._url,
                        str(e),
                        exc_info=e,
                    )
                    self._event_type = None
                    self._event_data = None
                    break

                line: str = line_in_bytes.decode("utf8")
                if self._first_line:
                    if line.startswith("\ufeff"):
                        line = line[1:]
                    self._first_line = False
                line = line.rstrip("\n").rstrip("\r")

                if line == "":
                    # empty line
                    event = self._dispatch_event()
                    if event is not None:
                        return event
                    continue

                if line[0] == ":":
                    # comment line, ignore
                    continue

                if ":" in line:
                    # contains ':'
                    fields = line.split(":", 1)
                    field_name = fields[0]
                    field_value = fields[1][1:] if fields[1].startswith(" ") else fields[1]
                    self._process_field(field_name, field_value)
                else:
                    self._process_field(line, "")
            if self._ready_state == ReadyState.CLOSED:
                raise StopIteration

            self._ready_state = ReadyState.CONNECTING
            if self._on_error:
                self._on_error()
            if self._ready_state == ReadyState.CLOSED:
                raise StopIteration
            _LOGGER.debug(
                "wait %s seconds for reconnect", self._reconnection_time.total_seconds()
            )
            time.sleep(self._reconnection_time.total_seconds())
            if self._ready_state == ReadyState.CLOSED:
                raise StopIteration
            self.connect(self._max_connect_retry)

    def connect(self, retry: int = 0) -> None:
        """Connect to resource."""
        remaining_retries = retry

        while True:
            _LOGGER.debug(f"connect, retry={remaining_retries}")

            if self._last_event_id != "":
                self._kwargs["headers"]["Last-Event-Id"] = self._last_event_id

            try:
                response = self._session.request(
                    method=self._method,
                    url=self.url,
                    stream=True,
                    timeout=self._timeout,
                    **self._kwargs,
                )
            except requests.RequestException:
                if remaining_retries <= 0 or self._ready_state == ReadyState.CLOSED:
                    self._fail_connect()
                    raise

                self._ready_state = ReadyState.CONNECTING
                if self._on_error:
                    self._on_error()
                if self._ready_state == ReadyState.CLOSED:
                    self._fail_connect()
                    raise

                _LOGGER.debug(
                    "wait %s seconds for retry",
                    self._reconnection_time.total_seconds(),
                )
                time.sleep(self._reconnection_time.total_seconds())
                if self._ready_state == ReadyState.CLOSED:
                    self._fail_connect()
                    raise
                remaining_retries -= 1
                continue

            if response.status_code != 200:
                error_message = "fetch {} failed with wrong response status: {}".format(
                    self._url, response.status_code
                )
                self._fail_connect()
                raise InvalidStatusCodeError(
                    response.status_code, error_message, response=response
                )

            content_type = response.headers.get("Content-Type")
            # Per the WHATWG spec, we only check the MIME type essence (type/subtype),
            # ignoring parameters like charset.
            # See https://html.spec.whatwg.org/multipage/server-sent-events.html#sse-processing-model
            if (
                not content_type
                or content_type.lower().split(";")[0].strip()
                != _CONTENT_TYPE_EVENT_STREAM
            ):
                error_message = "fetch {} failed with wrong Content-Type: {}".format(
                    self._url, response.headers.get("Content-Type")
                )
                self._fail_connect()
                raise InvalidContentTypeError(
                    content_type, error_message, response=response
                )
            self._connected()
            self._response = response
            self._data_generator = response.iter_lines()
            self._origin = self._get_origin(response)
            self._first_line = True
            return

    def close(self) -> None:
        """Close connection and session if needed.

        Must be called if EventSource is not called as context manager.
        This also enables EventSource to be used with contextlib.closing
        context manager.
        """
        _LOGGER.debug("close")
        self._ready_state = ReadyState.CLOSED
        self._reset_connection()
        if self._need_close_session:
            self._session.close()

    def _connected(self):
        """Announce the connection is made."""
        if self._ready_state != ReadyState.CLOSED:
            self._ready_state = ReadyState.OPEN
            if self._on_open:
                self._on_open()

    def _fail_connect(self):
        """Announce the connection is failed."""
        if self._ready_state != ReadyState.CLOSED:
            self._ready_state = ReadyState.CLOSED
            if self._on_error:
                self._on_error()

    def _dispatch_event(self):
        """Dispatch event."""
        self._last_event_id = self._event_id

        if self._event_data is None:
            self._event_type = None
            return

        self._event_data = self._event_data.rstrip("\n")
        if self._event_type is None:
            self._event_type = "message"

        message = MessageEvent(
            type=self._event_type,
            data=self._event_data,
            origin=self._origin,
            last_event_id=self._last_event_id,
        )
        _LOGGER.debug(f"message: {message}")
        # Match the EventSource onmessage callback behavior.
        if self._on_message and self._event_type == "message":
            self._on_message(message)

        self._event_type = None
        self._event_data = None
        return message

    def _process_field(self, field_name, field_value):
        """Process field."""
        if field_name == "event":
            self._event_type = field_value

        elif field_name == "data":
            if self._event_data is None:
                self._event_data = field_value
            else:
                self._event_data += field_value
            self._event_data += "\n"

        elif field_name == "id" and "\u0000" not in field_value:
            self._event_id = field_value

        elif field_name == "retry":
            if field_value.isascii() and field_value.isdigit():
                self._reconnection_time = timedelta(milliseconds=int(field_value))
            else:
                _LOGGER.debug("Received invalid retry value %s, ignore it", field_value)

    @staticmethod
    def _get_origin(response: requests.Response) -> str:
        """Get origin from response."""
        url = response.history[0].url if response.history else response.url
        parsed_url = parse_url(url)
        return Url(
            scheme=parsed_url.scheme, host=parsed_url.host, port=parsed_url.port
        ).url
