import random
from copy import deepcopy
from datetime import datetime, timedelta, date
from typing import (Optional, Union, List, cast, TYPE_CHECKING, Any, Type,
                    Sequence)
from urllib.parse import urlencode
from uuid import uuid4

import jsonschema
from django.core.exceptions import FieldDoesNotExist
from django.core.handlers.wsgi import WSGIRequest
from django.db import models
from django.db.models import ForeignKey
from django.db.models.options import Options
from django.test.client import encode_multipart
from django.utils.datastructures import MultiValueDict
from rest_framework.response import Response
from rest_framework.reverse import reverse
from rest_framework.status import (
    HTTP_200_OK,
    HTTP_201_CREATED,
    HTTP_204_NO_CONTENT,
    HTTP_405_METHOD_NOT_ALLOWED,
)
from rest_framework.test import APITestCase
from rest_framework.utils import encoders

from rest_framework_smoke.tests import schemas

if TYPE_CHECKING:  # pragma: no cover
    MixinTarget = APITestCase
else:
    MixinTarget = object


class TestResponse(Response):
    wsgi_request: WSGIRequest

    def json(self) -> Any: ...


class APIHelpersMixin(MixinTarget):
    # basename for `SimpleRouter.register()` call for tested viewset
    basename: str

    # viewset application label
    app_label: Optional[str] = None

    # object schema in list response
    schema: dict = {}

    # object schema in details response
    details_schema: dict = {}

    # json schema definition that describes pagination data in list response
    pagination_schema: Optional[dict] = schemas.PAGINATE_SCHEMA

    # object list key for paginated response
    page_result_key = 'results'

    # url path kwargs name and corresponding model attribute name for
    # constructing detail url for object
    details_url_kwarg = details_url_field = 'pk'

    # attribute holding and object returned by tested viewset
    object_name: Optional[str] = None

    # primary key field name
    pk_field = 'id'

    # urls name format for tested viewset (see `url()` method)
    url_name_format = '{basename}-{suffix}'

    # client authentication is performed in API
    authentication: bool = True

    # list of fields that can't be set via create API
    read_only_create_fields: Sequence[str] = ()

    # list of fields that can't be updated via API
    read_only_update_fields: Sequence[str] = ()

    @property
    def obj(self) -> models.Model:
        return getattr(self, cast(str, self.object_name))

    @property
    def model(self) -> Type[models.Model]:
        return self.obj._meta.model

    @property
    def object_list(self) -> List[models.Model]:
        return [self.obj]

    def url(self, suffix: str, *, version: Optional[str] = None,
            query: Union[None, dict, MultiValueDict] = None,
            **kwargs: Any) -> str:
        """
        Constructs an url for viewset

        :param suffix: last part of viewset url (list/detail or action name)
        :param version: API version
        :param query: query parameters for a link
        :param kwargs: url kwargs for reversing
        """
        name = self.url_name_format.format(
            version=version,
            app_label=self.app_label,
            basename=self.basename,
            suffix=suffix,
        )
        url = reverse(name, kwargs=kwargs)

        # support multiple values for same param: "id=1&id=2&id=3"
        if isinstance(query, MultiValueDict):
            params = []
            for key, value in query.lists():
                if isinstance(value, list):
                    params.append('&'.join([f'{key}={x}' for x in value]))
                else:
                    params.append(f'{key}={value}')

            url += f"?{'&'.join(params)}"
        elif query:
            # simple dict query params
            url += '?%s' % urlencode(query)
        return url

    @staticmethod
    def maybe_json(response: TestResponse) -> Union[None, list, dict]:
        if response.status_code == HTTP_204_NO_CONTENT:
            return None
        return response.json()

    @staticmethod
    def clone_object(obj: models.Model, **kwargs: Any) -> models.Model:
        """ Clones a django model instance."""
        obj = deepcopy(obj)
        obj.pk = None
        for k, v in kwargs.items():
            setattr(obj, k, v)
        obj.save(force_insert=True)
        return obj

    def change_value(self, obj: models.Model, field: str) -> Any:
        opts: Options = obj._meta
        try:
            f = opts.get_field(field)
            if isinstance(f, ForeignKey):
                related = getattr(obj, field)
                return self.clone_object(related).pk
        except FieldDoesNotExist:
            pass
        value = getattr(obj, field)
        if isinstance(value, bool):
            return not value
        if isinstance(value, int):
            return value + 1
        if isinstance(value, float):
            return value + 0.1
        if isinstance(value, str):
            return value + 'N'
        if isinstance(value, datetime):
            return value + timedelta(seconds=1)
        if isinstance(value, date):
            return value + timedelta(days=1)
        raise TypeError(value, field)

    # noinspection PyShadowingBuiltins
    def perform_request(self, suffix: str, detail: bool, *,
                        method: str = 'GET',
                        headers: Optional[dict] = None,
                        status: int = HTTP_200_OK,
                        data: Optional[dict] = None,
                        format: str = 'json',
                        **kwargs: Any) -> TestResponse:
        """
        Requests viewset endpoint.

        :param suffix: suffix for endpoint url to request
        :param detail: flag for injecting details_url_kwarg to url
        :param method: HTTP method used in request
        :param headers: http headers dict
        :param status: expected response status
        :param data: request body data
        :param format: request format (json/multipart)
        :param kwargs: url reversing parameters
        """
        headers = headers or {}
        if detail and not kwargs:
            kwargs = self.get_detail_url_kwargs(self.obj)
        url = self.url(suffix, **kwargs)
        body: Union[None, str, bytes] = None
        content_type = headers.pop('content_type', 'application/octet-stream')
        if data is not None:
            if format == 'json':
                content_type = 'application/json'
                body = encoders.JSONEncoder().encode(data)
            elif format == 'multipart':
                b = uuid4().hex
                content_type = f'multipart/form-data; boundary={b}'
                body = encode_multipart(b, data)
            else:
                raise ValueError(format)

        r = cast(TestResponse, self.client.generic(
            method, url, data=body, content_type=content_type, **headers))
        self.assertEqual(r.status_code, status, self.maybe_json(r))
        return r

    def get_detail_url_kwargs(self, obj: models.Model) -> dict:
        value = getattr(obj, self.details_url_field)
        return {self.details_url_kwarg: value}

    def get_list(self, headers: Optional[dict] = None,
                 status: int = HTTP_200_OK, **kwargs: Any) -> List[dict]:
        """
        Returns list of object retrieved through api.

        If pagination_schema is set, strips pagination info from response
        """
        r = self.perform_request('list', False, headers=headers,
                                 status=status, **kwargs)
        data = r.json()
        if self.pagination_schema:
            return data.get(self.page_result_key, data)
        else:
            return data

    def get_detail(self, *, suffix: str = 'detail', status: int = HTTP_200_OK,
                   headers: Optional[dict] = None, **kwargs: Any) -> dict:
        """ Returns object details retrieved through api."""
        r = self.perform_request(suffix, True, headers=headers, status=status,
                                 **kwargs)
        return r.json()

    def perform_create(self, data: dict, status: int = HTTP_201_CREATED,
                       **kwargs: Any) -> dict:
        """ Returns object details created via api."""
        r = self.perform_request('list', False, method='POST',
                                 status=status, data=data, **kwargs)
        return r.json()

    def perform_update(self, data: dict, partial: bool = False,
                       status: int = HTTP_200_OK, **kwargs: Any) -> dict:
        """ Returns details for an object updated via api."""
        method = 'PATCH' if partial else 'PUT'
        r = self.perform_request('detail', True, method=method,
                                 status=status, data=data, **kwargs)
        return r.json()

    def perform_delete(self, *, status: int = HTTP_204_NO_CONTENT,
                       **kwargs: Any) -> Optional[dict]:
        """ Performs an object deletion via api."""
        r = self.perform_request('detail', True, method='DELETE',
                                 status=status, **kwargs)
        return cast(Optional[dict], self.maybe_json(r))

    def delete(self, status: int = HTTP_204_NO_CONTENT,
               obj: Optional[models.Model] = None
               ) -> Optional[dict]:
        """ Deletes current object via api."""
        if obj is None:
            obj = self.obj
        kwargs = self.get_detail_url_kwargs(obj)
        return self.perform_delete(status=status, **kwargs)

    def create(self, data: Optional[dict] = None,
               status: int = HTTP_201_CREATED) -> dict:
        """
        Created a new object via API.

        If kwargs not specified, deletes current object from db and uses it's
        details to create a copy via API.

        :param data: new object data
        :param status: expected response status
        :return: response data
        """
        if data is None:
            data = self.get_create_data(self.obj)
            self.obj.delete()

        return self.perform_create(data, status=status)

    def get_create_data(self, obj: models.Model) -> dict:
        """
        Returns create request data based on a details response.
        """
        kwargs = self.get_detail_url_kwargs(obj)
        data = self.get_detail(**kwargs)
        del data[self.pk_field]
        for f in self.read_only_create_fields:
            data.pop(f, None)
        return data

    def update(self,
               obj: Optional[models.Model] = None,
               data: Optional[dict] = None,
               partial: bool = False,
               status: int = HTTP_200_OK) -> dict:
        """
        Updates an object with passed data via API.
        By default, uses self.obj
        If data not passed, tries to change every field in object details
        except pk field.

        :param obj: object to be updated
        :param data: update data
        :param partial: partial update flag
        :param status: expected response status
        """
        if obj is None:
            obj = self.obj
        if data is None:
            data = self.get_update_data(obj, partial=partial)
        kwargs = self.get_detail_url_kwargs(obj)
        return self.perform_update(data, status=status, partial=partial,
                                   **kwargs)

    def get_update_data(self, obj: models.Model, partial: bool = False) -> dict:
        """
        Gets update request data based on details response for selected object.

        :param obj: object to be updated
        :param partial: PATCH request flag. If True, only one random field is
        changed.
        :return: data for update request.
        """
        kwargs = self.get_detail_url_kwargs(obj)
        data = self.get_detail(**kwargs)
        del data[self.pk_field]
        keys = [k for k in data if k not in self.read_only_update_fields]
        if partial:
            keys = [random.choice(keys)]
        for k in keys:
            data[k] = self.change_value(obj, k)
        return data

    def get_schema(self) -> dict:
        """ Returns object schema for list response.
        """
        return schemas.get_object_schema(self.schema)

    def get_details_schema(self, attr: str = 'details_schema') -> dict:
        """ Returns object schema for details response."""
        schema = getattr(self, attr, self.details_schema)
        return schemas.get_object_schema(schema)

    def get_list_schema(self, min_items: int = 1) -> dict:
        """ Returns list response schema with pagination data
        """
        result_list_schema = {
            "type": "array",
            "items": self.get_schema(),
            "minItems": min_items}
        if not self.pagination_schema:
            return result_list_schema

        schema = self.pagination_schema
        schema["properties"].update({"results": result_list_schema})
        return schema

    def assert_json_schema(self, obj: dict, schema: dict) -> None:
        """ Checks response schema."""
        try:
            jsonschema.validate(obj, schema)
        except jsonschema.ValidationError as e:  # pragma: no cover
            self.fail(e.message)

    def assert_object_list(self, objects: List[models.Model],
                           **kwargs: Any) -> None:
        """
        Requests object list and checks primary key lists with expected object
        list.
        """
        data = self.get_list(**kwargs)
        ids = [obj[self.pk_field] for obj in data]
        expected = [obj.pk for obj in objects]
        self.assertListEqual(ids, expected)


if TYPE_CHECKING:  # pragma: no cover
    APIHelpersTarget = APIHelpersMixin
else:
    APIHelpersTarget = object


class ListTestsMixin(APIHelpersTarget):

    def test_list_format(self) -> None:
        """ Checks list response format."""
        r = self.perform_request('list', False)
        self.assert_json_schema(r.json(), self.get_list_schema())

    def test_list_default_filters(self) -> None:
        """ API outputs all objects from db."""
        self.assert_object_list(list(self.model.objects.all()))

    def test_object_list_smoke(self) -> None:
        """ Check that object list API returns an object list."""
        self.assert_object_list(self.object_list)

    def test_authorization(self) -> None:
        """ Checks client authorization."""
        r = self.perform_request('list', False)
        if self.authentication:
            self.assertFalse(r.wsgi_request.user.is_anonymous)
        else:
            self.assertTrue(r.wsgi_request.user.is_anonymous)


class DetailTestsMixin(APIHelpersTarget):

    def test_detail_format(self) -> None:
        """ Checks detail response format."""
        r = self.perform_request('detail', True)
        self.assert_json_schema(r.json(), self.get_details_schema())

    def test_retrieve_object_smoke(self) -> None:
        """ Check that object detail API returns an object."""
        obj = self.get_detail()
        self.assertEqual(obj[self.pk_field], self.obj.pk)

    def test_authorization(self) -> None:
        """ Checks client authorization."""
        r = self.perform_request('detail', True)
        if self.authentication:
            self.assertFalse(r.wsgi_request.user.is_anonymous)
        else:
            self.assertTrue(r.wsgi_request.user.is_anonymous)


class ReadViewSetTestsMixin(ListTestsMixin, DetailTestsMixin, APIHelpersMixin):
    """ Tests for read API ViewSets: list + retrieve."""


class ReadOnlyViewSetTestsMixin(ReadViewSetTestsMixin):
    """ Tests for read-only API ViewSets (write not allowed)."""

    def test_create_not_allowed(self) -> None:
        """ Object creation is not allowed."""
        self.perform_request('list', False, method='POST',
                             status=HTTP_405_METHOD_NOT_ALLOWED)

    def test_update_not_allowed(self) -> None:
        """ Object update is not allowed."""
        for method in ('PUT', 'PATCH'):
            with self.subTest(method):
                self.perform_request('detail', True, method=method,
                                     status=HTTP_405_METHOD_NOT_ALLOWED)

    def test_delete_not_allowed(self) -> None:
        """ Object deletion is not allowed."""
        self.perform_request('detail', True, method='DELETE',
                             status=HTTP_405_METHOD_NOT_ALLOWED)


class CreateViewSetTestsMixin(APIHelpersMixin):
    """ Tests for create method in API ViewSet."""

    def test_create_format(self) -> None:
        """ Checks create API response format."""
        data = self.create()
        self.assert_json_schema(data, self.get_details_schema('create_schema'))

    def test_create_object_smoke(self) -> None:
        """ Checks that object is created via API."""
        data = self.create()

        pk = data[self.pk_field]
        obj = self.model.objects.filter(pk=pk).first()
        self.assertIsNotNone(obj)


class DeleteViewSetTestsMixin(APIHelpersMixin):
    """ Tests for delete method in API ViewSet."""

    def test_delete_object_smoke(self) -> None:
        """ Checks that object is deleted via API."""
        self.delete()


class FullUpdateViewSetTestsMixin(APIHelpersMixin):
    """ Tests for full update in API ViewSet."""

    def test_full_update_format(self) -> None:
        """ Checks PUT response format."""
        data = self.update()
        self.assert_json_schema(data, self.get_details_schema('update_schema'))

    def test_full_update_smoke(self) -> None:
        """ Checks that object can be updated via API."""
        previous = self.get_detail()
        data = self.update()
        for k in data:
            if k == self.pk_field or k in self.read_only_update_fields:
                continue
            self.assertNotEqual(previous[k], data[k])


class PartialUpdateViewSetTestsMixin(APIHelpersMixin):
    """ Tests for partial update in API ViewSet."""

    def test_partial_update_format(self) -> None:
        """ Checks PATCH response format."""
        previous = self.get_detail()
        read_only = [self.pk_field] + list(self.read_only_update_fields)
        key = random.choice([f for f in previous if f not in read_only])
        patch = {key: self.change_value(self.obj, key)}

        data = self.update(data=patch, partial=True)
        for k in data:
            if k == key:
                self.assertNotEqual(previous[k], data[k], k)
            else:
                self.assertEqual(previous[k], data[k], k)

    def test_partial_update_smoke(self) -> None:
        """ Checks that object can be updated via API."""
        previous = self.get_detail()
        data = self.update(partial=True)
        self.assertNotEqual(previous, data)


class UpdateViewSetTestsMixin(FullUpdateViewSetTestsMixin,
                              PartialUpdateViewSetTestsMixin):
    """ Tests for full and partial update API."""
