from collections.abc import Callable

from starlette.types import ASGIApp, Message, Receive, Scope, Send


SESSION_BODY_LIMIT = 16 * 1024
API_MUTATION_BODY_LIMIT = 64 * 1024
ISSUE_ATTACHMENT_BODY_LIMIT = 2 * 1024 * 1024 + 64 * 1024
LEGACY_JSON_ATTACHMENT_BODY_LIMIT = 3 * 1024 * 1024
UNFILED_DRAFT_SYNC_BODY_LIMIT = 17 * 1024 * 1024
MUTATION_METHODS = frozenset({"POST", "PUT", "PATCH"})


def request_body_limit(method: str, path: str) -> int | None:
    """Return the admission limit for request bodies that FastAPI will parse."""
    normalized_method = method.upper()
    if normalized_method == "POST" and path == "/api/v1/session":
        return SESSION_BODY_LIMIT
    if normalized_method == "PUT" and path == "/api/v1/unfiled-drafts":
        return UNFILED_DRAFT_SYNC_BODY_LIMIT
    if (
        normalized_method == "POST"
        and (
            (path.startswith("/api/v1/repos/") and ("/issues/" in path or "/pulls/" in path))
            or path.startswith("/api/v1/notifications/")
        )
        and path.endswith("/attachments")
    ):
        return ISSUE_ATTACHMENT_BODY_LIMIT
    if normalized_method in MUTATION_METHODS and path.startswith("/api/v1/"):
        return API_MUTATION_BODY_LIMIT
    return None


class RequestBodyLimitMiddleware:
    """Reject oversized API bodies before framework parsing or endpoint work."""

    def __init__(
        self,
        app: ASGIApp,
        limit_for: Callable[[str, str], int | None] = request_body_limit,
    ) -> None:
        self.app = app
        self.limit_for = limit_for

    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
        if scope["type"] != "http":
            await self.app(scope, receive, send)
            return

        path = scope.get("path", "")
        root_path = scope.get("root_path", "").rstrip("/")
        if root_path and (path == root_path or path.startswith(root_path + "/")):
            path = path[len(root_path) :] or "/"
        limit = self.limit_for(scope.get("method", "GET"), path)
        if limit is None:
            await self.app(scope, receive, send)
            return
        if (
            limit == ISSUE_ATTACHMENT_BODY_LIMIT
            and self._header(scope, b"content-type").startswith(b"application/json")
        ):
            limit = LEGACY_JSON_ATTACHMENT_BODY_LIMIT

        declared_length = self._content_length(scope)
        if declared_length is not None and declared_length > limit:
            await self._reject(send)
            return

        messages: list[Message] = []
        received = 0
        while True:
            message = await receive()
            messages.append(message)
            if message["type"] != "http.request":
                break
            received += len(message.get("body", b""))
            if received > limit:
                await self._reject(send)
                return
            if not message.get("more_body", False):
                break

        async def replay() -> Message:
            if messages:
                return messages.pop(0)
            return {"type": "http.request", "body": b"", "more_body": False}

        await self.app(scope, replay, send)

    @staticmethod
    def _header(scope: Scope, wanted: bytes) -> bytes:
        for name, value in scope.get("headers", []):
            if name.lower() == wanted:
                return value.lower()
        return b""

    @staticmethod
    def _content_length(scope: Scope) -> int | None:
        for name, value in scope.get("headers", []):
            if name.lower() != b"content-length":
                continue
            try:
                parsed = int(value)
            except ValueError:
                return None
            return max(0, parsed)
        return None

    @staticmethod
    async def _reject(send: Send) -> None:
        body = b'{"detail":"Request body too large"}'
        headers = [
            (b"content-type", b"application/json"),
            (b"content-length", str(len(body)).encode("ascii")),
            (b"cache-control", b"no-store"),
        ]
        await send({"type": "http.response.start", "status": 413, "headers": headers})
        await send({"type": "http.response.body", "body": body})
