# SPDX-FileCopyrightText: Magenta ApS <https://magenta.dk>
# SPDX-License-Identifier: MPL-2.0
import asyncio
from contextlib import asynccontextmanager
from dataclasses import dataclass
from dataclasses import field
from typing import AsyncIterator
from typing import Generic
from typing import TypeVar
from uuid import UUID
from uuid import uuid4

import structlog
from fastapi.encoders import jsonable_encoder
from httpx import AsyncClient
from httpx import ConnectError
from httpx import HTTPStatusError
from pydantic.generics import GenericModel
from structlog.contextvars import bound_contextvars

from .autogenerated_graphql_client import GraphQLClient
from .autogenerated_graphql_client import ListenerCreateInput
from .autogenerated_graphql_client import NamespaceCreateInput
from .config import Settings
from .util import TerminateTaskGroup
from .util import terminate_task_group

T = TypeVar("T")

logger = structlog.stdlib.get_logger()

# Define globally to allow overwriting sleep duration during testing
NO_EVENT_SLEEP_DURATION = 5.0


@dataclass(frozen=True)
class Namespace:
    """Namespace to declare in OS2mo.

    Only useful for integrations which generate their own events.
    """

    name: str
    public: bool = False


@dataclass(frozen=True)
class Listener:
    """Listener to declare in OS2mo.

    You can bind listeners to namespaces you did not declare yourself.
    """

    namespace: str
    user_key: str
    routing_key: str
    path: str
    parallelism: int = 1


@dataclass(frozen=True)
class GraphQLEvents:
    """FastRAMQPI configuration for the GraphQL event system."""

    declare_namespaces: list[Namespace] = field(default_factory=list)
    declare_listeners: list[Listener] = field(default_factory=list)


class Event(GenericModel, Generic[T], frozen=True):
    """A GraphQL event for use in FastAPI HTTP handlers."""

    subject: T
    priority: int


async def fetcher(
    integration_client: AsyncClient,
    graphql_client: GraphQLClient,
    listener: UUID,
    path: str,
    rate_limit_allowed: asyncio.Event,
    fetcher_number: int,
) -> None:
    log = logger.bind(listener=listener, n=fetcher_number)
    log.info("Starting fetcher")
    while True:
        request_id = uuid4()
        with bound_contextvars(request_id=request_id):
            try:
                # Wait until the event is set. This will pause the fetcher if
                # another fetcher for the same listener received a Retry-After.
                await rate_limit_allowed.wait()

                # Fetch GraphQL event from MO
                try:
                    event = await graphql_client.fetch_event(listener)
                except ConnectError:  # pragma: no cover
                    log.warning("Unable to fetch event (ConnectError)")
                    await asyncio.sleep(5)
                    continue
                log.debug("Fetched event", graphql_event=event)
                if event is None:
                    await asyncio.sleep(NO_EVENT_SLEEP_DURATION)
                    continue

                # HTTP POST event to the integration
                try:
                    r = await integration_client.post(
                        path,
                        headers={
                            "x-request-id": str(request_id),
                        },
                        # Pass all event arguments; we let the receiver decide which
                        # are important.
                        json=jsonable_encoder(
                            Event(
                                subject=event.subject,
                                priority=event.priority,
                            )
                        ),
                    )
                except ConnectError:  # pragma: no cover
                    log.warning("Unable to pass event to integration (ConnectError)")
                    await asyncio.sleep(5)
                    continue
                # 2xx is acknowledged, anything else is not. The GraphQL event
                # system does not have negative acknowledgements.
                try:
                    r.raise_for_status()
                except HTTPStatusError as e:
                    log.warning(
                        "HTTP status error in event callback",
                        status_code=e.response.status_code,
                        response=e.response.text,
                    )
                    # Rate-limiting
                    if retry_after := e.response.headers.get("Retry-After"):
                        # Retry-After can either be a HTTP-date (not ISO 8601!) or
                        # a number of seconds to delay. We only support the latter.
                        # https://datatracker.ietf.org/doc/html/rfc7231#section-7.1.3
                        # https://datatracker.ietf.org/doc/html/rfc7231#section-7.1.1.1
                        delay = int(retry_after)
                        log.warning("Rate-limited", delay=delay)
                        rate_limit_allowed.clear()  # pause all fetchers for this listener
                        await asyncio.sleep(delay)
                        rate_limit_allowed.set()  # resume all fetchers for this listener
                    continue
                log.debug("Acknowledging event", graphql_event=event)
                await graphql_client.acknowledge_event(event.token)
            except Exception:  # pragma: no cover
                log.exception("Unexpected exception in GraphQL event fetcher")
                await asyncio.sleep(5)


@asynccontextmanager
async def lifespan(
    settings: Settings,
    mo_client: AsyncClient,
    events: GraphQLEvents,
) -> AsyncIterator[None]:
    # The regular GraphQL client available in the FastRAMQPI context is
    # specific to each integration. We don't know which version of GraphQL it
    # it using, and we cannot define the required queries in it.
    # This client should probably be moved to the global context if FastRAMQPI
    # needs to execute other GraphQL queries in the future.
    graphql_client = GraphQLClient(
        url=f"{settings.mo_url}/graphql/v25",
        http_client=mo_client,
    )
    # HTTPX client to call the integration itself
    integration_client = AsyncClient(
        # We assume the integration is listening on port 8000
        base_url="http://127.0.0.1:8000",
        # Raise the timeout from the default of 5 seconds
        timeout=300,
    )
    logger.info("Starting GraphQL event fetchers")
    try:
        async with graphql_client, asyncio.TaskGroup() as tg:
            # Declare namespaces
            for namespace in events.declare_namespaces:
                logger.info("Declaring namespace", namespace=namespace)
                await graphql_client.declare_event_namespace(
                    input=NamespaceCreateInput(
                        name=namespace.name,
                        public=namespace.public,
                    )
                )
            # Declare listeners
            for listener in events.declare_listeners:
                logger.info("Declaring listener", listener=listener)
                graphql_listener = await graphql_client.declare_event_listener(
                    input=ListenerCreateInput(
                        namespace=listener.namespace,
                        user_key=listener.user_key,
                        routing_key=listener.routing_key,
                    )
                )
                # All fetchers for a listener share the same rate-limiting
                rate_limit_allowed = asyncio.Event()
                rate_limit_allowed.set()
                for i in range(listener.parallelism):
                    tg.create_task(
                        fetcher(
                            integration_client=integration_client,
                            graphql_client=graphql_client,
                            listener=graphql_listener.uuid,
                            path=listener.path,
                            rate_limit_allowed=rate_limit_allowed,
                            fetcher_number=i,
                        )
                    )
            yield
            logger.info("Stopping GraphQL event fetchers")
            tg.create_task(terminate_task_group())
    except* TerminateTaskGroup:
        pass
