import itertools
import logging
import traceback
from typing import TypeVar, Type, Dict, Any, Union, List, Mapping, Optional

from django.conf import settings
from django.contrib.postgres.fields import JSONField
from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned
from django.db import transaction
from django.db.models import Model
from django.db.models.constants import LOOKUP_SEP
from django.http import HttpRequest, HttpResponse, Http404, HttpResponseNotFound, QueryDict
from django.utils.cache import patch_vary_headers, patch_cache_control
from django.utils.encoding import force_text
from django.views.decorators.csrf import csrf_exempt
from tastypie.bundle import Bundle
from tastypie.exceptions import (InvalidSortError, ApiFieldError, BadRequest as TastyPieBadRequest,
                                 ImmediateHttpResponse)
from tastypie.fields import CharField as TastypieApiCharField
from tastypie.http import HttpMultipleChoices
from tastypie.resources import ModelResource, Resource

from idm.api.base import RequiredAuthentication
from idm.api.exceptions import (RestApiError, UnhandledException, Forbidden as ApiForbidden, BadRequest,
                                CannotWriteInReadOnlyState)
from idm.api.frontend.apifields import ApiJSONField, ForRelated
from idm.api.frontend.paginator import OptimizedPaginator
from idm.api.frontend.serializer import TimeZoneAwareSerializer
from idm.api.frontend.utils import OrderingField
from idm.core.workflow.exceptions import Forbidden
from idm.framework.requester import Requester
from idm.utils.model_fields import JSONField as OldJSONField

log = logging.getLogger(__name__)

TResponse = TypeVar('TResponse', bound=HttpResponse)


class ResponseModifierMixin(Resource):
    """
    Mixin для правильного указания content-type хедера в ответе API
    """
    class Meta:
        abstract = True

    def create_response(
            self: 'Resource',
            request: HttpRequest,
            data: Optional[Union[Dict[str, Any], List[Dict[str, Any]]]],
            response_class: Type[TResponse] = HttpResponse,
            **response_kwargs,
    ) -> TResponse:
        """
        Форсим utf-8 в content-type ответа
        """
        desired_format = self.determine_format(request)
        serialized = self.serialize(request, data, desired_format)
        if response_kwargs.get('status') == 204:
            serialized = b''
        return response_class(content=serialized, content_type='application/json; charset=utf-8', **response_kwargs)


class FilterPermissionsMixin(Resource):
    """
    Копия метода из FrontendApiResource, но используется для ресурсов,
    у которых build_filters возвращает еще и permission_params
    Нужно удалить, когда во всех ручках поддержим такую возможность
    """
    class Meta:
        abstract = True

    def obj_get_list(self: 'FrontendApiResource', bundle: Bundle, **kwargs):
        filters = {}
        self.permission_params = {}

        if hasattr(bundle.request, 'GET'):
            # Grab a mutable copy.
            filters = bundle.request.GET.copy()

        # Update with the provided kwargs.
        filters.update(kwargs)
        applicable_filters, self.permission_params = self.build_filters(bundle.request, filters=filters)

        try:
            objects = self.apply_filters(bundle.request, applicable_filters, permission_params=self.permission_params)
            return self.authorized_read_list(objects, bundle)
        except ValueError:
            raise BadRequest("Invalid resource lookup data provided (mismatched type).")


class FrontendApiResource(ResponseModifierMixin, ModelResource):
    """
    Базовый класс ресурса API фронтента - позволяет работать с полями пользователя и системы
    """

    class Meta:
        abstract = True
        authentication = RequiredAuthentication()
        paginator_class = OptimizedPaginator
        serializer = TimeZoneAwareSerializer()
        sorting_aliases = {}
        include_resource_uri = False

    def base_urls(self):
        urls = super(FrontendApiResource, self).base_urls()
        urls = [url for url in urls if url.name != 'api_get_multiple']
        return urls

    @classmethod
    def api_field_from_django_field(cls, f, default=TastypieApiCharField):
        """
        Подключаем возможность использовать кастомные классы для представления ForeignKey полей моделей
        и распознавание JSON Field'а
        """
        if isinstance(f, (JSONField, OldJSONField)):
            return ApiJSONField

        return super(FrontendApiResource, cls).api_field_from_django_field(f, default)

    def build_filters(self, request: HttpRequest, filters: Mapping[str, Any] = None) -> Mapping[str, Any]:
        """
        Позволяем фильтровать по кастомным полям вроде объектов системы, пользователя
        """
        result = super(FrontendApiResource, self).build_filters(filters)
        for field_name in filters.keys():
            field = self.fields.get(field_name)
            if field and hasattr(field, 'to_field'):
                to_field = self.fields[field_name].to_field
                ending = field_name + LOOKUP_SEP + 'exact'
                for key, value in result.items():
                    if key.endswith(ending):
                        new_key = key.replace(ending, field_name + LOOKUP_SEP + to_field)
                        del result[key]
                        result[new_key] = value
        return result

    def get_requester(self, request: HttpRequest, data: Dict[str, Any] = None) -> Requester:
        """Возвращает текущего реквестора, который делает запрос"""
        return Requester(impersonated=request.user, impersonator=None, allowed_systems=None)

    def method_check(self, request: HttpRequest, allowed: bool = None) -> str:
        """Проверка метода, которая отвечает 500, если метод изменяет данные, а IDM находится в read-only"""

        method = super(FrontendApiResource, self).method_check(request, allowed=allowed)
        if method not in ('get', 'head', 'options') and getattr(request, 'service_is_readonly', False):
            response = self.error_response(request, {
                'error_code': CannotWriteInReadOnlyState.error_code,
                'message': CannotWriteInReadOnlyState.message,
            }, response_class=CannotWriteInReadOnlyState.response_class)
            raise ImmediateHttpResponse(response=response)
        return method

    def wrap_view(self, view: str):
        """
        Копия базового метода, но не перехватывает ValidationError и логгирует BadRequest/ApiFieldsError
        """
        @csrf_exempt
        def wrapper(request: HttpRequest, *args, **kwargs) -> HttpResponse:
            try:
                query_fields = request.GET.get('fields', '')
                request.META['fields'] = query_fields and set(query_fields.split(',')) or set()

                callback = getattr(self, view)
                response = callback(request, *args, **kwargs)

                # Our response can vary based on a number of factors, use
                # the cache class to determine what we should ``Vary`` on so
                # caches won't return the wrong (cached) version.
                varies = getattr(self._meta.cache, "varies", [])

                if varies:
                    patch_vary_headers(response, varies)

                if self._meta.cache.cacheable(request, response):
                    if self._meta.cache.cache_control():
                        # If the request is cacheable and we have a
                        # ``Cache-Control`` available then patch the header.
                        patch_cache_control(response, **self._meta.cache.cache_control())

                if request.is_ajax() and not response.has_header("Cache-Control"):
                    # IE excessively caches XMLHttpRequests, so we're disabling
                    # the browser cache here.
                    # See http://www.enhanceie.com/ie/bugs.asp for details.
                    patch_cache_control(response, no_cache=True)
                return response
            except (TastyPieBadRequest, ApiFieldError) as e:
                log.warning('Intercepted tastypie exception')
                if hasattr(e, 'message'):
                    error = e.message
                elif hasattr(e, 'args'):
                    error = e.args[0]
                return self.error_response(request, {"error": error}, response_class=BadRequest.response_class)
            except Exception as e:
                if hasattr(e, 'response'):
                    return e.response

                # A real, non-expected exception.
                # Handle the case where the full traceback is more helpful
                # than the serialized error.
                if settings.DEBUG and getattr(settings, 'TASTYPIE_FULL_DEBUG', False):
                    raise

                # Re-raise the error to get a proper traceback when the error
                # happend during a test case
                if request.META.get('SERVER_NAME') == 'testserver':
                    raise

                # Rather than re-raising, we're going to things similar to
                # what Django does. The difference is returning a serialized
                # error message.
                return self._handle_500(request, e)

        return wrapper

    def _handle_500(self, request: HttpRequest, exception: Exception) -> HttpResponse:
        if isinstance(exception, (ObjectDoesNotExist, Http404)):
            response_class = HttpResponseNotFound
            data = {
                'message': force_text(exception),
                'error_code': 'NOT_FOUND',
            }
        elif isinstance(exception, MultipleObjectsReturned):
            response_class = HttpMultipleChoices
            data = {
                'message': force_text(exception),
                'error_code': HttpMultipleChoices.status_code
            }
        elif isinstance(exception, RestApiError) and type(exception) is not RestApiError:
            # Штатная ошибка
            response_class = exception.response_class
            data = exception.extra.copy() if exception.extra else {}
            data.update({
                'message': force_text(exception),
                'error_code': exception.error_code,
            })
            if exception.errors is not None:
                data['errors'] = exception.errors
        elif isinstance(exception, Forbidden):
            response_class = ApiForbidden.response_class
            data = {
                'message': force_text(exception),
                'error_code': ApiForbidden.error_code,
            }
            if exception.data:
                data['data'] = exception.data
        else:
            # Какая-то 500
            response_class = UnhandledException.response_class
            tb = traceback.format_exc()

            data = {
                'error_code': UnhandledException.error_code,
                'message': '%s - %s' % (UnhandledException.message, force_text(tb.splitlines()[-1])),
                'traceback': tb,
            }
            log.exception('Error happened while handling request')

        # По умолчанию django открывает транзакцию в мастер на любое чтение.
        # Чтобы избежать этого, django_replicated с включенной настройкой REPLICATED_MANAGE_ATOMIC_REQUESTS
        # позволяет не открывать транзакцию в мастер на чтение.
        # В случае возникновения ошибки при записи транзакцию нужно откатить: она не будет откачена
        # автоматически, так как мы перехватили исключение чуть выше.
        # Поэтому нужно выставить признак, с помощью которого транзакция будет откачена,
        # но сделать это нужно только для тех запросов, которые транзакцию всё же открыли
        if transaction.get_connection().in_atomic_block:
            transaction.set_rollback(True)
        log.debug('Responded to frontend with "%s"', repr(data))
        return self.error_response(request, data, response_class=response_class)

    def full_dehydrate(self, bundle: Bundle, for_list: Union[bool, ForRelated] = False) -> Bundle:
        """Гранулярно дегидрируем объект в случае detail, list, related
        """
        bundle.for_related = isinstance(for_list, ForRelated)
        fields = bundle.request.META.pop('fields', set())

        bundle = super(FrontendApiResource, self).full_dehydrate(bundle, for_list)

        if isinstance(for_list, ForRelated):
            bundle = self.dehydrate_for_related(bundle)
        elif for_list:
            bundle = self.dehydrate_for_list(bundle)
        else:
            bundle = self.dehydrate_for_detail(bundle)

        if fields:
            invalid_fields = fields - set(bundle.data)
            if invalid_fields:
                log.debug(f'Unknown fields passed in query: {",".join(invalid_fields)}')
                raise BadRequest(f'Unknown fields passed in query: {",".join(invalid_fields)}')
            bundle.data = {field_name: value for field_name, value in bundle.data.items() if field_name in fields}

        # Для списка объектов надо вернуть после обработки каждого объекта
        if for_list is True:
            bundle.request.META['fields'] = fields

        return bundle

    def apply_sorting(self, obj_list, options=None):
        """Переписанная копипаста из BaseModelResource.apply_sorting
        """
        if options is None:
            options = {}

        parameter_name = 'order_by'
        if parameter_name not in options:
            return obj_list

        order_by_args = []

        if hasattr(options, 'getlist'):
            order_bits = options.getlist(parameter_name)
        else:
            order_bits = options.get(parameter_name)

            if not isinstance(order_bits, (list, tuple)):
                order_bits = [order_bits]

        # здесь заканчивается копипаста и начинается разворачивание алиасов
        order_fields = []
        for order_by in order_bits:
            field = OrderingField.from_string(order_by)
            if field.name in getattr(self._meta, 'ordering_aliases', ()):
                alias = self._meta.ordering_aliases[field.name]
                if field.descending:
                    alias = ~alias
                order_fields.extend(alias.fields)
            else:
                if field.name not in self.fields:
                    raise InvalidSortError("No matching '%s' field for ordering on." % field.name)

                if field.name not in self._meta.ordering:
                    raise InvalidSortError("The '%s' field does not allow ordering." % field.name)

                order_fields.append(field)

        for field in order_fields:
            order_by_args.append(field.as_lookup())

        return obj_list.order_by(*order_by_args)

    def dehydrate_for_related(self, bundle: Bundle) -> Bundle:
        """Дегидрация related ресурса
        """
        return bundle

    def dehydrate_for_list(self, bundle: Bundle) -> Bundle:
        """Дегидрация объекта для списка
        """
        return bundle

    def dehydrate_for_detail(self, bundle: Bundle) -> Bundle:
        """Дегидрация объекта для detail вида
        """
        return bundle

    def get_object_list_for_detail(self, request: HttpRequest, **kwargs):
        """Получение выборки объектов для detail вида
        """
        return self.get_object_list(request)

    def get_detail(self, request: HttpRequest, **kwargs) -> HttpResponse:
        """Оригинальный get_detail перехватывает ObjectDoesNotExist и возвращает HttpNotFound с пустым телом.
        Этот метод - копия оригинального, но он не перехватывает ошибки, которые должны перехватиться в _handler_500.
        """
        basic_bundle = self.build_bundle(request=request)

        obj = self.cached_obj_get(bundle=basic_bundle, **self.remove_api_resource_names(kwargs))

        bundle = self.build_bundle(obj=obj, request=request)
        bundle = self.full_dehydrate(bundle)
        bundle = self.alter_detail_data_to_serialize(request, bundle)
        return self.create_response(request, bundle)

    def get_list(self, request: HttpRequest, **kwargs) -> HttpResponse:
        """
        Оригинальный get_list, но ответ может не содержать "results"
        """
        base_bundle = self.build_bundle(request=request)
        objects = self.obj_get_list(bundle=base_bundle, **self.remove_api_resource_names(kwargs))
        sorted_objects = self.apply_sorting(objects, options=request.GET)

        paginator = self._meta.paginator_class(request.GET, sorted_objects, resource_uri=self.get_resource_uri(),
                                               limit=self._meta.limit, max_limit=self._meta.max_limit,
                                               collection_name=self._meta.collection_name)
        to_be_serialized = paginator.page()

        if self._meta.collection_name in to_be_serialized:
            # Dehydrate the bundles in preparation for serialization.
            bundles = [
                self.full_dehydrate(self.build_bundle(obj=obj, request=request), for_list=True)
                for obj in to_be_serialized[self._meta.collection_name]
            ]

            to_be_serialized[self._meta.collection_name] = bundles
            to_be_serialized = self.alter_list_data_to_serialize(request, to_be_serialized)

        return self.create_response(request, to_be_serialized)

    def obj_get_list(self, bundle: Bundle, **kwargs):
        """Почти оригинальный obj_get_list, только прокидывает request в build_filters
        """
        filters = {}

        if hasattr(bundle.request, 'GET'):
            # Grab a mutable copy.
            filters = bundle.request.GET.copy()

        # Update with the provided kwargs.
        filters.update(kwargs)
        applicable_filters = self.build_filters(bundle.request, filters=filters)

        try:
            objects = self.apply_filters(bundle.request, applicable_filters)
            return self.authorized_read_list(objects, bundle)
        except ValueError:
            raise BadRequest("Invalid resource lookup data provided (mismatched type).")

    def obj_get(self, bundle: Bundle, bypass_permissions: bool = False, **kwargs):
        """Почти оригинальный obj_get, только использует get_object_list_for_detail
        для получения выборки объектов
        """
        # prevents FieldError when looking up nested resources containing extra data
        field_names = set(itertools.chain.from_iterable(
            (field.name, field.attname) if hasattr(field, 'attname') else (field.name,)
            for field in self._meta.object_class._meta.get_fields()
            if not (field.many_to_one and field.related_model is None)
        ))
        field_names.add('pk')
        insensitive_keys = getattr(self._meta, 'insensitive_keys', ())
        kwargs = {
            k: v.lower() if k in insensitive_keys else v
            for k, v in kwargs.items() if k in field_names
        }

        try:
            object_list = self.get_object_list_for_detail(
                bundle.request, bypass_permissions=bypass_permissions, pk=kwargs.get('pk', None)
            ).filter(**kwargs)
        except ValueError:
            raise Http404('Invalid resource lookup data provided (mismatched type).')

        stringified_kwargs = ', '.join(["%s=%s" % (k, v) for k, v in kwargs.items()])

        if len(object_list) <= 0:
            raise self._meta.object_class.DoesNotExist('Couldn\'t find an instance of "%s" which matched "%s".' %
                                                       (self._meta.object_class.__name__, stringified_kwargs))
        elif len(object_list) > 1:
            raise MultipleObjectsReturned('More than "%s" matched "%s".' %
                                          (self._meta.object_class.__name__, stringified_kwargs))

        bundle.obj = object_list[0]
        self.authorized_read_detail(object_list, bundle)
        return bundle.obj

    def validate(self, bundle: Bundle):
        bundle_is_valid = self.is_valid(bundle)
        if not bundle_is_valid:
            raise BadRequest(errors=bundle.errors[self._meta.resource_name])

    def deserialize(
            self,
            request: HttpRequest,
            data: Dict[str, Any],
            format: str = 'application/json'
    ) -> Mapping[str, Any]:
        deserialized = super(FrontendApiResource, self).deserialize(request, data, format)
        query = QueryDict('', mutable=True)
        if not isinstance(deserialized, dict):
            raise BadRequest(f'Expect JSON objects in body, got {type(deserialized).__name__}')
        for k, v in deserialized.items():
            query.appendlist(k, v)
        return query

    def get_resource_type(
            self,
            bundle_or_obj: Union[Bundle, Model] = None,
            url_name: str ='api_dispatch_list',
    ) -> Optional[str]:
        uri = self.get_resource_uri(bundle_or_obj, url_name).strip('/').split('/', 2)  # /api/<frontend|v1>/...
        if len(uri) >= 2:
            return uri[1]
        return None
