import time
from collections import OrderedDict
from typing import Any, Self


class Singleton:
	"""Keep single instance at all times.

	## Make sure to put the `tcr.Singleton` inheretance before all others!
	### ❌ `class A(B, tcr.Singleton)`
	### ✅ `class A(tcr.Singleton, B)`

	```py
	class SingleTuple(tcr.Singleton, tuple):
	    ...

	  tup = SingleTuple(('tup1',))
	  tup2 = SingleTuple(('tup2',)) # Those arguments are effectively ignored

	  console(tup)  # -> ('tup1',)
	  console(tup2) # -> ('tup1',)
	```
	"""

	__singleton_instance__ = None

	def __new__(cls, *args, **kwargs) -> Self:
		if cls.__singleton_instance__ is None:
			cls.__singleton_instance__ = super().__new__(cls, *args, **kwargs)
		return cls.__singleton_instance__


class NoInit:
	def __init__(self, *args, **kwargs) -> None:
		msg = "You cannot instiantiate this class"

		if hasattribute(self, "__noinit_msg"):
			msg = str(getattr(self, "__noinit_msg"))

		raise RuntimeError(msg)


def partial_class(_f=None, /, *, add_as_kwargs_to_init: bool = True) -> type:
	"""### Simillarly to functools.partial allows subclasses of decorated class to hold a meta-like state to be later used in e.g. root's init or anywhere else.

	```py
	@tcr.partial_class
	class Button:
	  def __init__(self, label: str):
	    self.label = label

	  def __repr__(self):
	    return f'Button(label={self.label!r})'

	class YesButton(Button, label='Yes'): ...
	class NoButton(Button, label='No'): ...

	y = YesButton()
	c(repr(y)) # -> Button(label='Yes')
	n = NoButton()
	c(repr(n)) # -> Button(label='No')
	n2 = NoButton(label='Nuh uh') # Overridden
	c(repr(n2)) # -> Button(label='Nuh uh')
	```

	Args:
	  add_as_kwargs_to_init: bool = True. If True, the state  will be passed as keyword arguments into `__init__`.
	"""

	def decorator(cls: type) -> type:
		def new_init_subclass(new_cls, **kwargs) -> None:
			new_cls._partial_state = kwargs

			if not add_as_kwargs_to_init:
				return

			original_init = new_cls.__init__

			def new_init(self, *args, **init_kwargs):
				merged_kwargs = {**self._partial_state, **init_kwargs}  # Merge both states such that caller can override it, like functools.partial
				original_init(self, *args, **merged_kwargs)

			new_cls.__init__ = new_init

		cls.__init_subclass__ = classmethod(new_init_subclass)
		return cls

	if _f is not None:
		return decorator(_f)
	return decorator


def hasattribute(obj: object, name: str) -> bool:
	try:
		object.__getattribute__(obj, name)
	except AttributeError:
		return False
	else:
		return True


class DefaultsGetSetItem(dict):
	def __getitem__(self, __key: Any) -> Any:
		try:
			return super().__getitem__(__key)
		except KeyError:
			if hasattribute(self, "defaults") and __key in (defaults := object.__getattribute__(self, "defaults")):
				val = defaults[__key]()
				self[__key] = val
				return val
			else:
				raise


class DefaultsGetItem(dict):
	def __getitem__(self, __key: Any) -> Any:
		try:
			return super().__getitem__(__key)
		except KeyError:
			if hasattribute(self, "defaults") and __key in (defaults := object.__getattribute__(self, "defaults")):
				return defaults[__key]()
			else:
				raise


class DefaultsGetSetAttr:
	def __getattr__(self, __name: str) -> Any:
		try:
			return getattr(super(), __name)
		except AttributeError as e:
			if hasattribute(self, "defaults") and __name in (defaults := object.__getattribute__(self, "defaults")):
				val = defaults[__name]()
				self.__setattr__(__name, val)
				return val
			else:
				raise AttributeError(f"{self.__class__.__name__!r} object has no attribute nor default value for {__name!r}") from e


class DefaultsGetAttr:
	def __getattr__(self, __name: str) -> Any:
		try:
			return getattr(super(), __name)
		except AttributeError as e:
			if hasattribute(self, "defaults") and __name in (defaults := object.__getattribute__(self, "defaults")):
				return defaults[__name]()
			else:
				raise AttributeError(f"{self.__class__.__name__!r} object has no attribute nor default value for {__name!r}") from e


class CachedInstancesMeta(type):
	"""Metaclass for caching instances based on init parameters with customizable max time and max instances."""

	def __new__(
		mcls,  # noqa: N804
		name,
		bases,
		dct,
		max_instances: int = 50,
		max_time: int = 30 * 60,
		restore_method: str | None = None,
	):
		cls = super().__new__(mcls, name, bases, dct)
		cls._cache = OrderedDict()
		cls._max_instances = max_instances
		cls._max_time = max_time
		cls._restore_method = restore_method
		return cls

	def __call__(cls, *args, **kwargs):
		key = (args, tuple(sorted(kwargs.items())))

		cls._remove_old_instances()

		if key in cls._cache:
			instance, timestamp = cls._cache.pop(key)
			cls._cache[key] = (instance, time.time())

			if instance._restore_method is not None:
				getattr(instance, instance._restore_method)()
			return instance

		instance = super().__call__(*args, **kwargs)

		cls._cache[key] = (instance, time.time())

		cls._enforce_max_cache_size()

		return instance

	def _remove_old_instances(cls):
		current_time = time.time()
		keys_to_remove = []
		for key, (_, timestamp) in cls._cache.items():
			if current_time - timestamp > cls._max_time:
				keys_to_remove.append(key)
		for key in keys_to_remove:
			del cls._cache[key]

	def _enforce_max_cache_size(cls):
		while len(cls._cache) > cls._max_instances:
			cls._cache.popitem(last=False)


class ErrDenoted:
	"""Defines an is_err() method to tell if an object is a valid result or an error."""

	@classmethod
	def is_err(cls) -> bool:
		"""Return True if this object inherits from Exception, else return False."""
		return BaseException in cls.__mro__

	def unwrap(self) -> Self:
		"""If this object is_err, raise self, else return self."""
		if self.is_err():
			raise self
		return self
