"""
Utilities to break down :py:class:`.CachedResponse` objects into a dict of python builtin types
using `cattrs <https://cattrs.readthedocs.io>`_. This does the majority of the work needed for all
serialization formats.

.. automodsumm:: requests_cache.serializers.cattrs
   :classes-only:
   :nosignatures:

.. automodsumm:: requests_cache.serializers.cattrs
   :functions-only:
   :nosignatures:
"""

from __future__ import annotations

from collections.abc import MutableMapping
from datetime import datetime, timedelta
from decimal import Decimal
from functools import singledispatchmethod
from json import JSONDecodeError
from typing import Callable, Dict, ForwardRef, List, Optional, Union

from cattrs import Converter
from requests.cookies import RequestsCookieJar, cookiejar_from_dict
from requests.exceptions import RequestException
from requests.structures import CaseInsensitiveDict

from .._utils import is_json_content_type
from ..models import CachedResponse, DecodedContent
from .pipeline import Stage

try:
    import ujson as json
except ImportError:
    import json  # type: ignore


class CattrStage(Stage):
    """Base serializer class that does pre/post-processing with  ``cattrs``. This can be used either
    on its own, or as a stage within a :py:class:`.SerializerPipeline`.

    Args:
        factory: A callable that returns a ``cattrs`` converter to start from instead of a new
            ``Converter``. Mainly useful for preconf converters.
        decode_content: Save response body in human-readable format, if possible

    Notes on ``decode_content`` option:

    * Response body will be decoded into a human-readable format (if possible) during serialization,
      and re-encoded during deserialization to recreate the original response body.
    * Supported Content-Types are ``application/*json*`` and ``text/*``. All other types will be saved
      as-is.
    * Decoded responses are saved in a separate ``_decoded_content`` attribute, to ensure that
      ``_content`` is always binary.
    * This is the default behavior for Filesystem, DynamoDB, and MongoDB backends.
    """

    def __init__(
        self,
        factory: Optional[Callable[..., Converter]] = None,
        decode_content: bool = False,
        **kwargs,
    ):
        self.converter = init_converter(factory, **kwargs)
        self.decode_content = decode_content

    def copy(self) -> 'CattrStage':
        stage = CattrStage(decode_content=self.decode_content)
        stage.converter = self.converter
        return stage

    @singledispatchmethod
    def dumps(self, value):
        return value

    @dumps.register
    def _(self, value: CachedResponse) -> dict:
        response_dict = self.converter.unstructure(value)
        return _decode_content(value, response_dict) if self.decode_content else response_dict

    @singledispatchmethod
    def loads(self, value):
        return value

    @loads.register
    def _(self, value: MutableMapping) -> CachedResponse:
        return _encode_content(self.converter.structure(value, cl=CachedResponse))


def init_converter(
    factory: Optional[Callable[..., Converter]] = None,
    convert_datetime: bool = True,
    convert_timedelta: bool = True,
) -> Converter:
    """Make a converter to structure and unstructure nested objects within a
    :py:class:`.CachedResponse`

    Args:
        factory: An optional factory function that returns a ``cattrs`` converter
        convert_datetime: May be set to ``False`` for pre-configured converters that already have
            datetime support
    """
    factory = factory or Converter
    try:
        converter = factory(omit_if_default=True)
    # Handle previous versions of cattrs (<22.2) that don't support this argument
    except TypeError:
        converter = factory()

    # Convert datetimes to and from iso-formatted strings
    if convert_datetime:
        converter.register_unstructure_hook(datetime, lambda obj: obj.isoformat() if obj else None)
        converter.register_structure_hook(datetime, _to_datetime)

    # Convert timedeltas to and from float values in seconds
    if convert_timedelta:
        converter.register_unstructure_hook(
            timedelta, lambda obj: obj.total_seconds() if obj else None
        )
        converter.register_structure_hook(timedelta, _to_timedelta)

    # Convert dict-like objects to and from plain dicts
    converter.register_unstructure_hook(RequestsCookieJar, lambda obj: dict(obj.items()))
    converter.register_structure_hook(RequestsCookieJar, lambda obj, cls: cookiejar_from_dict(obj))
    converter.register_unstructure_hook(CaseInsensitiveDict, dict)
    converter.register_structure_hook(
        CaseInsensitiveDict, lambda obj, cls: CaseInsensitiveDict(obj)
    )

    # Tell cattrs to ignore DecodedContent; this will be handled separately in `CattrStage.loads()`
    converter.register_structure_hook(DecodedContent, lambda obj, cls: obj)
    # Same as above, but for cattrs 23.2+. In cattrs terms, this handles the "spillover" after
    # handling DecodedContent with the "union passthrough strategy," which is enabled by default
    # for its pre-configured converters (JsonConverter, etc.).
    converter.register_structure_hook(Union[Dict, List], lambda obj, cls: obj)

    def structure_fwd_ref(obj, cls):
        # python<=3.8: ForwardRef may not have been evaluated yet
        if not cls.__forward_evaluated__:  # pragma: no cover
            cls._evaluate(globals(), locals())
        return converter.structure(obj, cls.__forward_value__)

    # Resolve forward references (required for CachedResponse.history)
    converter.register_unstructure_hook_func(
        lambda cls: cls.__class__ is ForwardRef,
        lambda obj, cls=None: converter.unstructure(obj, cls.__forward_value__ if cls else None),
    )
    converter.register_structure_hook_func(
        lambda cls: cls.__class__ is ForwardRef,
        structure_fwd_ref,
    )

    return converter


def make_decimal_timedelta_converter(**kwargs) -> Converter:
    """Make a converter that uses Decimals instead of floats to represent timedelta objects"""
    converter = Converter(**kwargs)
    converter.register_unstructure_hook(
        timedelta, lambda obj: Decimal(str(obj.total_seconds())) if obj else None
    )
    converter.register_structure_hook(timedelta, _to_timedelta)
    # converter.register_unstructure_hook(float, lambda obj: Decimal(str(obj)) if obj else None)
    # converter.register_structure_hook(float, lambda obj, cls: float(obj) if obj else None)
    return converter


def _decode_content(response: CachedResponse, response_dict: Dict) -> Dict:
    """Decode response body into a human-readable format, if possible"""
    ct_header = response.headers.get('Content-Type', '')

    # Decode body as JSON
    if is_json_content_type(ct_header):
        try:
            response_dict['_decoded_content'] = response.json()
            response_dict.pop('_content', None)
        except (JSONDecodeError, RequestException):
            pass

    # Decode body as text
    if ct_header.startswith('text/'):
        response_dict['_decoded_content'] = response.text
        response_dict.pop('_content', None)

    # Otherwise, it is most likely a binary body
    return response_dict


def _encode_content(response: CachedResponse) -> CachedResponse:
    """Re-encode response body if saved as JSON or text (via ``decode_content=True``).
    This has no effect for a binary response body.
    """
    # The response may have previously been saved with `decode_content=False`
    if response._decoded_content is None:
        return response

    # Encode body as JSON
    if is_json_content_type(response.headers.get('Content-Type')):
        response._decoded_content = json.dumps(response._decoded_content)

    # Encode body back to bytes
    if isinstance(response._decoded_content, str):
        response._content = response._decoded_content.encode('utf-8')
        response._decoded_content = None
        # Set encoding explicitly so requests doesn't have to detect it
        response.encoding = 'utf-8'
        # Normalization may change Content-Length
        if 'Content-Length' in response.headers:
            response.headers['Content-Length'] = str(len(response._content))

    return response


def _convert_floats(value):
    """Workaround for DynamoDB-specific issue with decode_content=True. There doesn't seem to be
    an obvious way to do this with the current converter setup, so need to do it manually here.
    """

    def _float_to_decimal(value: DecodedContent):
        if isinstance(value, list):
            return [_float_to_decimal(v) for v in value]
        elif isinstance(value, dict):
            return {k: _float_to_decimal(v) for k, v in value.items()}
        elif isinstance(value, float):
            return Decimal(str(value))
        else:
            return value

    if isinstance(value, dict) and '_decoded_content' in value:
        value['_decoded_content'] = _float_to_decimal(value['_decoded_content'])
    return value


def _to_datetime(obj, cls) -> datetime:
    if isinstance(obj, str):
        obj = datetime.fromisoformat(obj)
    return obj


def _to_timedelta(obj, cls) -> timedelta:
    if isinstance(obj, (int, float)):
        obj = timedelta(seconds=obj)
    elif isinstance(obj, Decimal):
        obj = timedelta(seconds=float(obj))
    return obj
