#  !/usr/bin/env python
#
#  __init__.py
#
r"""
JSON encoder utilising functools.singledispatch to support custom encoders
for both Python's built-in classes and user-created classes, without as much legwork.


Creating and registering a custom encoder is as easy as:

.. code-block:: python

	>>> import sdjson
	>>>
	>>> @sdjson.register_encoder(MyClass)
	>>> def encode_myclass(obj):
	...     return dict(obj)
	>>>

In this case, ``MyClass`` can be made JSON-serializable simply by calling
:class:`dict` on it. If your class requires more complicated logic
to make it JSON-serializable, do that here.

Then, to dump the object to a string:

.. code-block:: python

	>>> class_instance = MyClass()
	>>> print(sdjson.dumps(class_instance))
	'{"menu": ["egg and bacon", "egg sausage and bacon", "egg and spam", "egg bacon and spam"],
	"today\'s special": "Lobster Thermidor au Crevette with a Mornay sauce served in a Provencale
	manner with shallots and aubergines garnished with truffle pate, brandy and with a fried egg
	on top and spam."}'
	>>>

Or to dump to a file:

.. code-block:: python

	>>> with open("spam.json", "w") as fp:
	...     sdjson.dumps(class_instance, fp)
	...
	>>>

``sdjson`` also provides access to :func:`~json.load`, :func:`~json.loads`, :class:`~json.JSONDecoder`,
:class:`~json.JSONDecodeError`, and :class:`~json.JSONEncoder` from the :mod:`json` module,
allowing you to use ``sdjson`` as a drop-in replacement for :mod:`json`.

If you wish to dump an object without using the custom encoders, you can pass a different
:class:`~json.JSONEncoder` subclass, or indeed :class:`~json.JSONEncoder`
itself to get the stock functionality.

.. code-block:: python

	>>> sdjson.dumps(class_instance, cls=sdjson.JSONEncoder)
	>>>

-----------

.. latex:clearpage::

When you've finished, if you want to unregister the encoder you can run:

.. code-block:: python

	>>> sdjson.unregister_encoder(MyClass)
	>>>

to remove the encoder for ``MyClass``. If you want to replace the encoder with a
different one it is not necessary to call this function: the
:func:`@sdjson.register_encoder <sdjson.register_encoder>`
decorator will replace any existing decorator for the given class.


.. TODO:: This module does not currently support custom decoders, but might in the future.
"""  # noqa: D400
#
#  Copyright © 2020-2021 Dominic Davis-Foster <dominic@davis-foster.co.uk>
#
#  Permission is hereby granted, free of charge, to any person obtaining a copy
#  of this software and associated documentation files (the "Software"), to deal
#  in the Software without restriction, including without limitation the rights
#  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
#  copies of the Software, and to permit persons to whom the Software is
#  furnished to do so, subject to the following conditions:
#
#  The above copyright notice and this permission notice shall be included in all
#  copies or substantial portions of the Software.
#
#  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
#  EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
#  MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
#  IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
#  DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
#  OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
#  OR OTHER DEALINGS IN THE SOFTWARE.
#
#  Based on https://treyhunner.com/2013/09/singledispatch-json-serializer/
#  Copyright © 2013 Trey Hunner
#  He said "Feel free to use it however you like." So I have.
#
#  Also based on the `json` module (version 2.0.9) by Bob Ippolito from Python 3.7
#  Licensed under the Python Software Foundation License Version 2.
#  Copyright © 2001-2020 Python Software Foundation. All rights reserved.
#  Copyright © 2000 BeOpen.com . All rights reserved.
#  Copyright © 1995-2000 Corporation for National Research Initiatives . All rights reserved.
#  Copyright © 1991-1995 Stichting Mathematisch Centrum . All rights reserved.
#
#  Type annotations from Typeshed
#  https://github.com/python/typeshed
#  Apache 2.0 Licensed
#

# stdlib
import json
import sys
from functools import singledispatch
from typing import IO, Any, Callable, Iterator, Optional, Tuple, Type, Union

# 3rd party
from domdf_python_tools.doctools import append_docstring_from, is_documented_by, make_sphinx_links

if sys.version_info < (3, 8):  # pragma: no cover (py38+)
	# 3rd party
	from typing_extensions import _ProtocolMeta  # type: ignore
else:  # pragma: no cover (<py38)
	# stdlib
	from typing import _ProtocolMeta  # type: ignore

__all__ = [
		"load",
		"loads",
		"JSONDecoder",
		"JSONDecodeError",
		"dump",
		"dumps",
		"JSONEncoder",
		"encoders",
		"register_encoder",
		"unregister_encoder",
		]

__author__ = "Dominic Davis-Foster"
__copyright__ = "2020-2021 Dominic Davis-Foster"
__license__ = "MIT+"
__version__ = "0.3.1"
__email__ = "dominic@davis-foster.co.uk"

# TODO: perhaps add a limit on number of decimal places for floats etc, like with pandas' jsons

json.decoder.JSONDecoder.__module__ = "json"
json.encoder.JSONEncoder.__module__ = "json"


def allow_unregister(func) -> Callable:
	"""
	Decorator to allow removal of custom encoders with ``<sdjson.encoders.unregister(<type>)``,
	where <type> is the custom type you wish to remove the encoder for.
	"""  # noqa: D400

	# From https://stackoverflow.com/a/25951784/3092681
	# Copyright © 2014 Martijn Pieters
	# https://stackoverflow.com/users/100297/martijn-pieters
	# Licensed under CC BY-SA 4.0

	# build a dictionary mapping names to closure cells
	closure = dict(zip(func.register.__code__.co_freevars, func.register.__closure__))
	registry = closure["registry"].cell_contents
	dispatch_cache = closure["dispatch_cache"].cell_contents

	def unregister(cls):
		del registry[cls]
		dispatch_cache.clear()

	func.unregister = unregister
	return func


def sphinxify_json_docstring() -> Callable:
	"""
	Turn references in the docstring to :class:`~json.JSONEncoder` into proper links.
	"""

	def wrapper(target):
		# To save having the `sphinxify_docstring` decorator too
		target.__doc__ = make_sphinx_links(target.__doc__)

		target.__doc__ = target.__doc__.replace("``JSONEncoder``", ":class:`~json.JSONEncoder`")
		target.__doc__ = target.__doc__.replace("``.default()``", ":meth:`~json.JSONEncoder.default`")

		return target

	return wrapper


class _Encoders:

	def __init__(self):
		self._registry = allow_unregister(singledispatch(lambda x: None))
		self._protocol_registry = {}
		self.registry = self._registry.registry

	def register(self, cls: Type, func: Optional[Callable] = None) -> Callable:
		"""
		Registers a new handler for the given type.

		Can be used as a decorator or a regular function:

		.. code-block:: python

			@register_encoder(bytes)
			def bytes_encoder(obj):
				return obj.decode("UTF-8")

			def int_encoder(obj):
				return int(obj)

			register_encoder(int, int_encoder)


		:param cls:
		:param func:
		"""

		if func is None:
			return lambda f: self.register(cls, f)

		if isinstance(cls, _ProtocolMeta):
			if getattr(cls, "_is_runtime_protocol", False):
				self._protocol_registry[cls] = func
			else:
				raise TypeError("Protocols must be @runtime_checkable")
			return func
		else:
			return self._registry.register(cls, func)

	def dispatch(self, cls: object) -> Optional[Callable]:
		"""
		Returns the best available implementation for the given object.

		:param cls:
		"""

		if object in self.registry:
			self.unregister(object)

		handler = self._registry.dispatch(type(cls))
		if handler is not None:
			return handler
		else:
			for protocol, handler in self._protocol_registry.items():
				if isinstance(cls, protocol):
					return handler

		return None

	def unregister(self, cls: Type):
		"""
		Unregister the handler for the given type.

		.. code-block:: python

			unregister_encoder(int)

		:param cls:

		:raise KeyError: if no handler is found.
		"""

		if cls in self.registry:
			self._registry.unregister(cls)
		elif cls in self._protocol_registry:
			del self._protocol_registry[cls]
		else:
			raise KeyError


encoders = _Encoders()
register_encoder = encoders.register  # type: ignore
unregister_encoder = encoders.unregister  # type: ignore


@sphinxify_json_docstring()
@append_docstring_from(json.dump)
def dump(obj: Any, fp: IO, **kwargs: Any):  # TODO
	"""
	Serialize custom Python classes to JSON.
	Custom classes can be registered using the ``@encoders.register(<type>)`` decorator.
	"""

	iterable = dumps(obj, **kwargs)

	for chunk in iterable:
		fp.write(chunk)


@sphinxify_json_docstring()
@append_docstring_from(json.dumps)
def dumps(
		obj: Any,
		*,
		skipkeys: bool = False,
		ensure_ascii: bool = True,
		check_circular: bool = True,
		allow_nan: bool = True,
		cls: Optional[Type[json.JSONEncoder]] = None,
		indent: Union[None, int, str] = None,
		separators: Optional[Tuple[str, str]] = None,
		default: Optional[Callable[[Any], Any]] = None,
		sort_keys: bool = False,
		**kwargs: Any,
		):
	"""
	Serialize custom Python classes to JSON.
	Custom classes can be registered using the ``@encoders.register(<type>)`` decorator.
	"""

	if (
			not skipkeys and ensure_ascii and check_circular and allow_nan and cls is None and indent is None
			and separators is None and default is None and not sort_keys and not kwargs
			):
		return _default_encoder.encode(obj)
	if cls is None:  # pragma: no cover (!CPython)   # TODO
		cls = _CustomEncoder
	return cls(
			skipkeys=skipkeys,
			ensure_ascii=ensure_ascii,
			check_circular=check_circular,
			allow_nan=allow_nan,
			indent=indent,
			separators=separators,
			default=default,
			sort_keys=sort_keys,
			**kwargs
			).encode(obj)


# Provide access to remaining objects from json module.
# We have to do it this way to sort out the docstrings for sphinx without
#  modifying the original docstrings.
@sphinxify_json_docstring()
@append_docstring_from(json.load)
def load(*args, **kwargs):  # pragma: no cover (!CPython)  # TODO
	"""
	Alias of :func:`json.load`.
	"""

	return json.load(*args, **kwargs)


@sphinxify_json_docstring()
@append_docstring_from(json.loads)
def loads(*args, **kwargs):  # pragma: no cover (!CPython)  # TODO
	"""
	Alias of :func:`json.loads`.
	"""

	return json.loads(*args, **kwargs)


@sphinxify_json_docstring()
@append_docstring_from(json.JSONEncoder)
class JSONEncoder(json.JSONEncoder):
	"""
	Alias of :class:`json.JSONEncoder`.

	.. autosummary-widths:: 33/100
	"""

	def __init__(self, *args, **kwargs):
		super().__init__(*args, **kwargs)

	@sphinxify_json_docstring()
	@is_documented_by(json.JSONEncoder.default)
	def default(self, o: Any) -> Any:  # noqa: D102
		return super().default(o)

	@sphinxify_json_docstring()
	@is_documented_by(json.JSONEncoder.encode)
	def encode(self, o: Any) -> Any:  # noqa: D102
		return super().encode(o)

	@sphinxify_json_docstring()
	@is_documented_by(json.JSONEncoder.iterencode)
	def iterencode(  # noqa: D102
			self,
			o: Any,
			_one_shot: bool = False,
			) -> Iterator[str]:  # pragma: no cover (!CPython)
		return super().iterencode(o, _one_shot)


@sphinxify_json_docstring()
@append_docstring_from(json.JSONDecoder)
class JSONDecoder(json.JSONDecoder):  # pragma: no cover (!CPython)  # TODO
	"""
	Alias of :class:`json.JSONDecoder`.

	.. autosummary-widths:: 35/100
	"""

	def __init__(self, *args, **kwargs):
		super().__init__(*args, **kwargs)

	@sphinxify_json_docstring()
	@is_documented_by(json.JSONDecoder.decode)
	def decode(self, *args, **kwargs):  # noqa: D102
		return super().decode(*args, **kwargs)

	@sphinxify_json_docstring()
	@is_documented_by(json.JSONDecoder.raw_decode)
	def raw_decode(self, *args, **kwargs):  # noqa: D102
		return super().raw_decode(*args, **kwargs)


JSONDecodeError = json.JSONDecodeError


# Custom encoder for sdjson
class _CustomEncoder(JSONEncoder):

	def default(self, obj):
		handler = encoders.dispatch(obj)
		if handler is not None:
			return handler(obj)

		return super().default(obj)


_default_encoder = _CustomEncoder(
		skipkeys=False,
		ensure_ascii=True,
		check_circular=True,
		allow_nan=True,
		indent=None,
		separators=None,
		default=None,
		)
