import inspect
import warnings
from typing import (
    TYPE_CHECKING,
    Any,
    Callable,
    Coroutine,
    Dict,
    Iterable,
    List,
    Optional,
    Sequence,
    Type,
    Union,
    cast,
)

import pydantic
from asgiref.sync import async_to_sync, sync_to_async
from django.http import (
    HttpRequest,
    HttpResponse,
    HttpResponseNotAllowed,
    StreamingHttpResponse,
)
from django.http.response import HttpResponseBase
from pydantic import BaseModel

from ninja.compatibility.files import FIX_MIDDLEWARE_PATH, need_to_fix_request_files
from ninja.compatibility.streaming import create_streaming_response
from ninja.constants import NOT_SET, NOT_SET_TYPE
from ninja.errors import (
    AuthenticationError,
    ConfigError,
    Throttled,
    ValidationErrorContext,
)
from ninja.params.models import TModels
from ninja.responses import Status
from ninja.schema import Schema, pydantic_version
from ninja.signature import ViewSignature, is_async
from ninja.streaming import StreamFormat, _serialize_item, _StreamAlias
from ninja.throttling import BaseThrottle
from ninja.types import DictStrAny
from ninja.utils import is_async_callable

if TYPE_CHECKING:
    from ninja import NinjaAPI  # pragma: no cover

__all__ = ["Operation", "PathView", "ResponseObject"]


class Operation:
    def __init__(
        self,
        path: str,
        methods: List[str],
        view_func: Callable,
        *,
        auth: Optional[Union[Sequence[Callable], Callable, NOT_SET_TYPE]] = NOT_SET,
        throttle: Union[BaseThrottle, List[BaseThrottle], NOT_SET_TYPE] = NOT_SET,
        response: Any = NOT_SET,
        operation_id: Optional[str] = None,
        summary: Optional[str] = None,
        description: Optional[str] = None,
        tags: Optional[List[str]] = None,
        deprecated: Optional[bool] = None,
        by_alias: Optional[bool] = None,
        exclude_unset: Optional[bool] = None,
        exclude_defaults: Optional[bool] = None,
        exclude_none: Optional[bool] = None,
        include_in_schema: bool = True,
        url_name: Optional[str] = None,
        openapi_extra: Optional[Dict[str, Any]] = None,
    ) -> None:
        self.is_async = False
        self.path: str = path
        self.methods: List[str] = methods
        self.view_func: Callable = view_func
        self.api: NinjaAPI = cast("NinjaAPI", None)
        self.csrf_exempt: bool = getattr(view_func, "csrf_exempt", False)
        if url_name is not None:
            self.url_name = url_name

        self.auth_param: Optional[Union[Sequence[Callable], Callable, object]] = auth
        self.auth_callbacks: Sequence[Callable] = []
        self._set_auth(auth)

        if isinstance(throttle, BaseThrottle):
            throttle = [throttle]
        self.throttle_param = throttle
        self.throttle_objects: List[BaseThrottle] = []
        if throttle is not NOT_SET:
            for th in throttle:  # type: ignore
                assert isinstance(
                    th, BaseThrottle
                ), "Throttle should be an instance of BaseThrottle"
                self.throttle_objects.append(th)

        self.signature = ViewSignature(self.path, self.view_func)
        self.models: TModels = self.signature.models

        self.stream_format: Optional[Type[StreamFormat]] = None
        self.stream_item_model: Optional[Type[Schema]] = None
        self.response_models: Dict[Any, Any]
        if isinstance(response, _StreamAlias):
            self.stream_format = response.format_cls
            self.stream_item_model = self._create_response_model(response.item_type)
            self.response_models = {200: self.stream_item_model}
        elif response is NOT_SET:
            self.response_models = {200: NOT_SET}
        elif isinstance(response, dict):
            self.response_models = self._create_response_model_multiple(response)
        else:
            self.response_models = {200: self._create_response_model(response)}

        if need_to_fix_request_files(methods, self.models):
            raise ConfigError(
                f"Router '{path}' has method(s) {methods}  that require fixing request.FILES. "
                f"Please add '{FIX_MIDDLEWARE_PATH}' to settings.MIDDLEWARE"
            )

        self.operation_id = operation_id
        self.summary = summary or self.view_func.__name__.title().replace("_", " ")
        self.description = description or self.signature.docstring
        self.tags = tags
        self.deprecated = deprecated
        self.include_in_schema = include_in_schema
        self.openapi_extra = openapi_extra

        # Exporting models params
        self.by_alias = by_alias or False
        self.exclude_unset = exclude_unset or False
        self.exclude_defaults = exclude_defaults or False
        self.exclude_none = exclude_none or False

        if hasattr(view_func, "_ninja_contribute_to_operation"):
            # Allow 3rd party code to contribute to the operation behavior
            callbacks: List[Callable] = view_func._ninja_contribute_to_operation
            for callback in callbacks:
                callback(self)

    def clone(self) -> "Operation":
        """
        Create a fresh copy of this operation for binding to an API.

        This method is used when mounting the same router multiple times
        to ensure each mount has independent operation instances.
        """
        # Create instance without calling __init__ to avoid expensive processing
        cloned = object.__new__(self.__class__)

        # Copy all essential attributes
        cloned.is_async = self.is_async
        cloned.path = self.path
        cloned.methods = list(self.methods)
        cloned.view_func = self.view_func
        cloned.api = cast("NinjaAPI", None)  # Will be set during binding
        cloned.csrf_exempt = self.csrf_exempt

        # Copy url_name if it exists
        if hasattr(self, "url_name"):
            cloned.url_name = self.url_name

        # Copy auth settings
        cloned.auth_param = self.auth_param
        cloned.auth_callbacks = list(self.auth_callbacks)

        # Copy throttle settings
        cloned.throttle_param = self.throttle_param
        cloned.throttle_objects = list(self.throttle_objects)

        # Copy signature and models (immutable after creation, safe to share)
        cloned.signature = self.signature
        cloned.models = self.models

        # Copy streaming attributes
        cloned.stream_format = self.stream_format
        cloned.stream_item_model = self.stream_item_model

        # Copy response models (dict copy for isolation)
        cloned.response_models = dict(self.response_models)

        # Copy metadata
        cloned.operation_id = self.operation_id
        cloned.summary = self.summary
        cloned.description = self.description
        cloned.tags = list(self.tags) if self.tags else None
        cloned.deprecated = self.deprecated
        cloned.include_in_schema = self.include_in_schema
        cloned.openapi_extra = dict(self.openapi_extra) if self.openapi_extra else None

        # Copy export model params
        cloned.by_alias = self.by_alias
        cloned.exclude_unset = self.exclude_unset
        cloned.exclude_defaults = self.exclude_defaults
        cloned.exclude_none = self.exclude_none

        # Re-apply run decorators (from decorate_view) to the clone's run method
        # We can't just copy the decorated run because it's bound to the original instance
        if hasattr(self, "_run_decorators") and self._run_decorators:
            cloned._run_decorators = []  # type: ignore[attr-defined]
            for deco in self._run_decorators:
                cloned.run = deco(cloned.run)  # type: ignore
                cloned._run_decorators.append(deco)  # type: ignore[attr-defined]

        return cloned

    def run(self, request: HttpRequest, **kw: Any) -> HttpResponseBase:
        error = self._run_checks(request)
        if error:
            return error
        try:
            temporal_response = self.api.create_temporal_response(request)
            values = self._get_values(request, kw, temporal_response)
            result = self.view_func(request, **values)
            if self.stream_format:
                return self._stream_response(request, result, temporal_response)
            return self._result_to_response(request, result, temporal_response)
        except Exception as e:
            if isinstance(e, TypeError) and "required positional argument" in str(e):
                msg = "Did you fail to use functools.wraps() in a decorator?"
                msg = f"{e.args[0]}: {msg}" if e.args else msg
                e.args = (msg,) + e.args[1:]
            return self.api.on_exception(request, e)

    def _validate_stream_item(self, item: Any, request: HttpRequest) -> str:
        """Validate a single stream item and return serialized JSON string."""
        assert self.stream_item_model is not None
        resp_object = ResponseObject(item)
        validated = self.stream_item_model.model_validate(
            resp_object, context={"request": request, "response_status": 200}
        )

        model_dump_kwargs: Dict[str, Any] = {}
        if pydantic_version >= [2, 7]:  # pragma: no branch
            # pydantic added support for serialization context at 2.7
            model_dump_kwargs.update(
                context={"request": request, "response_status": 200}
            )

        result = validated.model_dump(
            by_alias=self.by_alias,
            exclude_unset=self.exclude_unset,
            exclude_defaults=self.exclude_defaults,
            exclude_none=self.exclude_none,
            **model_dump_kwargs,
        )["response"]
        return _serialize_item(result)

    def _stream_response(
        self,
        request: HttpRequest,
        generator: Any,
        temporal_response: HttpResponse,
    ) -> StreamingHttpResponse:
        """Create a StreamingHttpResponse from a sync generator."""
        assert self.stream_format is not None
        fmt = self.stream_format

        def content_iter() -> Any:
            for item in generator:
                data = self._validate_stream_item(item, request)
                yield fmt.format_chunk(data)
            # Copy headers/cookies after generator completes (user may set them inside)
            for key, value in temporal_response.items():
                if key.lower() != "content-type":
                    response[key] = value
            for cookie_name, cookie in temporal_response.cookies.items():
                response.cookies[cookie_name] = cookie

        response = StreamingHttpResponse(
            content_iter(),
            content_type=fmt.media_type,
            status=temporal_response.status_code,
        )
        # Add format-specific headers
        for key, value in fmt.response_headers().items():
            response[key] = value
        return response

    def _set_auth(
        self, auth: Optional[Union[Sequence[Callable], Callable, object]]
    ) -> None:
        if auth is not None and auth is not NOT_SET:
            self.auth_callbacks = isinstance(auth, Sequence) and auth or [auth]

    def _run_checks(self, request: HttpRequest) -> Optional[HttpResponse]:
        "Runs security/throttle checks for each operation"
        # NOTE: if you change anything in this function - do this also in AsyncOperation

        # Set CSRF exempt status on request so auth handlers can check it
        if self.csrf_exempt:
            # _ninja_csrf_exempt is a special flag that tells auth handler to skip CSRF checks
            request._ninja_csrf_exempt = True  # type: ignore

        # auth:
        if self.auth_callbacks:
            error = self._run_authentication(request)
            if error:
                return error

        # Throttling:
        if self.throttle_objects:
            error = self._check_throttles(request)
            if error:
                return error

        return None

    def _run_authentication(self, request: HttpRequest) -> Optional[HttpResponse]:
        for callback in self.auth_callbacks:
            try:
                if is_async_callable(callback) or getattr(callback, "is_async", False):
                    result = callback(request)
                    if inspect.iscoroutine(result):
                        result = async_to_sync(callback)(request)
                else:
                    result = callback(request)
            except Exception as exc:
                return self.api.on_exception(request, exc)

            if result:
                request.auth = result  # type: ignore
                return None
        return self.api.on_exception(request, AuthenticationError())

    def _check_throttles(self, request: HttpRequest) -> Optional[HttpResponse]:
        throttle_durations = []
        for throttle in self.throttle_objects:
            if not throttle.allow_request(request):
                throttle_durations.append(throttle.wait())

        if throttle_durations:
            # Filter out `None` values which may happen in case of config / rate
            durations = [
                duration for duration in throttle_durations if duration is not None
            ]

            duration = max(durations, default=None)
            return self.api.on_exception(request, Throttled(wait=duration))  # type: ignore
        return None

    def _model_dump_kwargs(self, request: HttpRequest, status: int) -> Dict[str, Any]:
        kwargs: Dict[str, Any] = {}
        if pydantic_version >= [2, 7]:
            kwargs["context"] = {"request": request, "response_status": status}
        return kwargs

    def _result_to_response(
        self, request: HttpRequest, result: Any, temporal_response: HttpResponse
    ) -> HttpResponseBase:
        """
        The protocol for results
         - if HttpResponse - returns as is
         - if Status object - uses status code + body
         - if tuple with 2 elements - means http_code + body (deprecated)
         - otherwise it's a body
        """
        if isinstance(result, HttpResponseBase):
            return result

        status: int = 200
        if len(self.response_models) == 1:
            status = next(iter(self.response_models))

        if isinstance(result, Status):
            status = result.status_code
            result = result.value
        elif isinstance(result, tuple) and len(result) == 2:
            warnings.warn(
                "Returning tuple (status_code, response) is deprecated. "
                "Use Status(status_code, response) instead.",
                DeprecationWarning,
                stacklevel=2,
            )
            status, result = result

        if status in self.response_models:
            response_model = self.response_models[status]
        elif Ellipsis in self.response_models:
            response_model = self.response_models[Ellipsis]
        else:
            raise ConfigError(
                f"Schema for status {status} is not set in response"
                f" {self.response_models.keys()}"
            )

        temporal_response.status_code = status

        if response_model is NOT_SET:
            return self.api.create_response(
                request, result, temporal_response=temporal_response
            )

        if response_model is None:
            # Empty response.
            return temporal_response

        model_dump_kwargs = self._model_dump_kwargs(request, status)

        # Skip re-validation for pydantic model instances matching the response type
        resp_annotation = response_model.model_fields["response"].annotation
        if (
            isinstance(resp_annotation, type)
            and isinstance(result, BaseModel)
            and isinstance(result, resp_annotation)
        ):
            result = cast(BaseModel, result).model_dump(
                by_alias=self.by_alias,
                exclude_unset=self.exclude_unset,
                exclude_defaults=self.exclude_defaults,
                exclude_none=self.exclude_none,
                **model_dump_kwargs,
            )
            return self.api.create_response(
                request, result, temporal_response=temporal_response
            )

        resp_object = ResponseObject(result)
        # ^ we need object because getter_dict seems work only with model_validate
        validated_object = response_model.model_validate(
            resp_object, context={"request": request, "response_status": status}
        )

        result = validated_object.model_dump(
            by_alias=self.by_alias,
            exclude_unset=self.exclude_unset,
            exclude_defaults=self.exclude_defaults,
            exclude_none=self.exclude_none,
            **model_dump_kwargs,
        )["response"]
        return self.api.create_response(
            request, result, temporal_response=temporal_response
        )

    def _get_values(
        self, request: HttpRequest, path_params: Any, temporal_response: HttpResponse
    ) -> DictStrAny:
        values = {}
        error_contexts: List[ValidationErrorContext] = []
        for model in self.models:
            try:
                data = model.resolve(request, self.api, path_params)
                values.update(data)
            except pydantic.ValidationError as e:
                error_contexts.append(
                    ValidationErrorContext(pydantic_validation_error=e, model=model)
                )
        if error_contexts:
            validation_error = self.api.validation_error_from_error_contexts(
                error_contexts
            )
            raise validation_error
        if self.signature.response_arg:
            values[self.signature.response_arg] = temporal_response
        return values

    def _create_response_model_multiple(
        self, response_param: DictStrAny
    ) -> Dict[str, Optional[Type[Schema]]]:
        result = {}
        for key, model in response_param.items():
            status_codes = isinstance(key, Iterable) and key or [key]
            for code in status_codes:
                result[code] = self._create_response_model(model)
        return result

    def _create_response_model(self, response_param: Any) -> Optional[Type[Schema]]:
        if response_param is None:
            return None
        attrs = {"__annotations__": {"response": response_param}}
        return type("NinjaResponseSchema", (Schema,), attrs)


class AsyncOperation(Operation):
    def __init__(self, *args: Any, **kwargs: Any) -> None:
        super().__init__(*args, **kwargs)
        self.is_async = True

    async def run(self, request: HttpRequest, **kw: Any) -> HttpResponseBase:  # type: ignore
        error = await self._run_checks(request)
        if error:
            return error
        try:
            temporal_response = self.api.create_temporal_response(request)
            values = self._get_values(request, kw, temporal_response)
            if self.stream_format:
                result = self.view_func(request, **values)
                return await self._async_stream_response(
                    request, result, temporal_response
                )
            result = await self.view_func(request, **values)
            return self._result_to_response(request, result, temporal_response)
        except Exception as e:
            return self.api.on_exception(request, e)

    async def _async_stream_response(
        self,
        request: HttpRequest,
        generator: Any,
        temporal_response: HttpResponse,
    ) -> StreamingHttpResponse:
        """Create a StreamingHttpResponse from an async generator."""
        assert self.stream_format is not None
        fmt = self.stream_format

        async def content_gen() -> Any:
            async for item in generator:
                data = self._validate_stream_item(item, request)
                yield fmt.format_chunk(data)

        return await create_streaming_response(
            content_gen(),
            content_type=fmt.media_type,
            status=temporal_response.status_code,
            temporal_response=temporal_response,
            extra_headers=fmt.response_headers(),
        )

    async def _run_checks(self, request: HttpRequest) -> Optional[HttpResponse]:  # type: ignore
        "Runs security/throttle checks for each operation"
        # NOTE: if you change anything in this function - do this also in Sync Operation

        # Set CSRF exempt status on request so auth handlers can check it
        if self.csrf_exempt:
            request._ninja_csrf_exempt = True  # type: ignore

        # auth:
        if self.auth_callbacks:
            error = await self._run_authentication(request)
            if error:
                return error

        # Throttling:
        if self.throttle_objects:
            error = self._check_throttles(request)
            if error:
                return error

        return None

    async def _run_authentication(self, request: HttpRequest) -> Optional[HttpResponse]:  # type: ignore
        for callback in self.auth_callbacks:
            try:
                if is_async_callable(callback) or getattr(callback, "is_async", False):
                    cor: Optional[Coroutine] = callback(request)
                    if cor is None:
                        result = None
                    else:
                        result = await cor
                else:
                    result = await sync_to_async(callback)(request)
            except Exception as exc:
                return self.api.on_exception(request, exc)

            if result:
                request.auth = result  # type: ignore
                return None
        return self.api.on_exception(request, AuthenticationError())


class PathView:
    def __init__(self) -> None:
        self.operations: List[Operation] = []
        self.is_async = False  # if at least one operation is async - will become True
        self.url_name: Optional[str] = None

    def add_operation(
        self,
        path: str,
        methods: List[str],
        view_func: Callable,
        *,
        auth: Optional[Union[Sequence[Callable], Callable, NOT_SET_TYPE]] = NOT_SET,
        throttle: Union[BaseThrottle, List[BaseThrottle], NOT_SET_TYPE] = NOT_SET,
        response: Any = NOT_SET,
        operation_id: Optional[str] = None,
        summary: Optional[str] = None,
        description: Optional[str] = None,
        tags: Optional[List[str]] = None,
        deprecated: Optional[bool] = None,
        by_alias: Optional[bool] = None,
        exclude_unset: Optional[bool] = None,
        exclude_defaults: Optional[bool] = None,
        exclude_none: Optional[bool] = None,
        url_name: Optional[str] = None,
        include_in_schema: bool = True,
        openapi_extra: Optional[Dict[str, Any]] = None,
    ) -> Operation:
        if url_name:
            self.url_name = url_name

        OperationClass = Operation
        is_streaming = isinstance(response, _StreamAlias)
        if is_async(view_func) or (
            is_streaming and inspect.isasyncgenfunction(view_func)
        ):
            self.is_async = True
            OperationClass = AsyncOperation

        operation = OperationClass(
            path,
            methods,
            view_func,
            auth=auth,
            throttle=throttle,
            response=response,
            operation_id=operation_id,
            summary=summary,
            description=description,
            tags=tags,
            deprecated=deprecated,
            by_alias=by_alias,
            exclude_unset=exclude_unset,
            exclude_defaults=exclude_defaults,
            exclude_none=exclude_none,
            include_in_schema=include_in_schema,
            url_name=url_name,
            openapi_extra=openapi_extra,
        )

        self.operations.append(operation)
        view_func._ninja_operation = operation  # type: ignore

        return operation

    def clone(self) -> "PathView":
        """
        Create a fresh copy of this PathView with cloned operations.

        This method is used when mounting the same router multiple times
        to ensure each mount has independent PathView and Operation instances.
        """
        cloned = PathView()
        cloned.is_async = self.is_async
        cloned.url_name = self.url_name
        cloned.operations = [op.clone() for op in self.operations]
        return cloned

    def get_view(self) -> Callable:
        # Create a unique view function for this PathView

        if self.is_async:
            # Create a wrapper for async view
            async def async_view_wrapper(
                request: HttpRequest, *args: Any, **kwargs: Any
            ) -> HttpResponseBase:
                return await self._async_view(request, *args, **kwargs)

            # All django-ninja views are CSRF exempt at Django middleware level
            # Cookie-based auth (APIKeyCookie) handles CSRF checking separately
            async_view_wrapper.csrf_exempt = True  # type: ignore

            return async_view_wrapper
        else:
            # Create a wrapper for sync view
            def sync_view_wrapper(
                request: HttpRequest, *args: Any, **kwargs: Any
            ) -> HttpResponseBase:
                return self._sync_view(request, *args, **kwargs)

            # All django-ninja views are CSRF exempt at Django middleware level
            # Cookie-based auth (APIKeyCookie) handles CSRF checking separately
            sync_view_wrapper.csrf_exempt = True  # type: ignore

            return sync_view_wrapper

    def _sync_view(self, request: HttpRequest, *a: Any, **kw: Any) -> HttpResponseBase:
        operation = self._find_operation(request)
        if operation is None:
            return self._not_allowed()
        return operation.run(request, *a, **kw)

    async def _async_view(
        self, request: HttpRequest, *a: Any, **kw: Any
    ) -> HttpResponseBase:
        operation = self._find_operation(request)
        if operation is None:
            return self._not_allowed()
        if operation.is_async:
            return await cast(AsyncOperation, operation).run(request, *a, **kw)
        return await sync_to_async(operation.run)(request, *a, **kw)

    def _find_operation(self, request: HttpRequest) -> Optional[Operation]:
        for op in self.operations:
            if request.method in op.methods:
                return op
        return None

    def _not_allowed(self) -> HttpResponse:
        allowed_methods = set()
        for op in self.operations:
            allowed_methods.update(op.methods)
        return HttpResponseNotAllowed(allowed_methods, content=b"Method not allowed")


class ResponseObject:
    "Basically this is just a helper to be able to pass response to pydantic's model_validate"

    def __init__(self, response: HttpResponse) -> None:
        self.response = response
