# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. Check LICENSE

import time
from collections import defaultdict
from collections.abc import Callable
from contextlib import suppress
from functools import wraps
from types import NoneType

import frappe

_SITE_CACHE = defaultdict(dict)
_KWD_MARK = object()  # sentinel for separating args from kwargs


def __generate_request_cache_key(args: tuple, kwargs: dict) -> tuple:
	"""Generate a key for the cache."""

	if not kwargs:
		return args

	return (args, _KWD_MARK, frozenset(kwargs.items()))


def request_cache(func: Callable) -> Callable:
	"""
	Decorator to cache function calls mid-request.

	Cache is stored in `frappe.local.request_cache`.

	The cache only persists for the current request and is cleared when the request is over.

	The function is called just once per request with the same set of (kw)arguments.

	---
	Usage:
	```
	        from frappe.utils.caching import request_cache

	        @request_cache
	        def calculate_pi(num_terms=0):
	            import math, time

	            print(f"{num_terms = }")
	            time.sleep(10)
	            return math.pi

	        calculate_pi(10)  # will calculate value
	        calculate_pi(10)  # will return value from cache
	```
	"""

	@wraps(func)
	def wrapper(*args, **kwargs):
		_cache = getattr(frappe.local, "request_cache", None)
		if _cache is None:
			return func(*args, **kwargs)
		try:
			args_key = __generate_request_cache_key(args, kwargs)
		except Exception:
			return func(*args, **kwargs)

		try:
			return _cache[func][args_key]
		except TypeError:
			# args_key is not hashable
			return func(*args, **kwargs)
		except KeyError:
			# cache miss
			return_val = func(*args, **kwargs)
			_cache[func][args_key] = return_val
			return return_val

	return wrapper


def site_cache(ttl: int | None = None, maxsize: int | None = None) -> Callable:
	"""
	Decorator to cache method calls across requests.

	The cache is stored in `frappe.utils.caching._SITE_CACHE`.

	The cache persists on the parent process.

	It offers a light-weight cache for the current process without the additional
	overhead of serializing / deserializing Python objects.

	Note: This cache isn't shared among workers. If you need to share data across
	workers, use redis (frappe.cache API) instead.

	---
	Usage:
	```
	        from frappe.utils.caching import site_cache

	        @site_cache
	        def calculate_pi():
	            import math, time

	            precision = get_precision("Math Constant", "Pi") # depends on site data
	            return round(math.pi, precision)

	        calculate_pi(10) # will calculate value
	        calculate_pi(10) # will return value from cache
	        calculate_pi.clear_cache() # clear this function's cache for all sites
	        calculate_pi(10) # will calculate value
	```
	"""

	def time_cache_wrapper(func: Callable | None = None) -> Callable:
		def clear_cache():
			"""Clear cache for this function for all sites if not specified."""
			_SITE_CACHE[func].clear()

		func.clear_cache = clear_cache

		if ttl is not None and not callable(ttl):
			func.ttl = ttl
			func.expiration = time.monotonic() + func.ttl

		if maxsize is not None and not callable(maxsize):
			func.maxsize = maxsize

		@wraps(func)
		def site_cache_wrapper(*args, **kwargs):
			site = getattr(frappe.local, "site", None)
			if not site:
				return func(*args, **kwargs)

			arguments_key = (site, __generate_request_cache_key(args, kwargs))

			if hasattr(func, "ttl") and time.monotonic() >= func.expiration:
				func.clear_cache()
				func.expiration = time.monotonic() + func.ttl

			# NOTE: Important things to consider from thread safety POV:
			#   1. Other thread can issue clear_cache and delete entire dictionary.
			#   2. Other thread can pop the exact elemement we are reading if maxsize is hit.

			# NOTE: Keep a local reference to dictionary of interest so it doesn't get swapped
			function_cache = _SITE_CACHE[func]

			try:
				return function_cache[arguments_key]

			# not handling TypeError here, expecting arguments_key to be hashable
			except (KeyError, RuntimeError):
				# NOTE: This is just a cache miss or dictionary was modified while reading it
				pass

			if hasattr(func, "maxsize") and len(function_cache) >= func.maxsize:
				# Note: This implements FIFO eviction policy
				with suppress(RuntimeError):
					function_cache.pop(next(iter(function_cache)), None)

			result = func(*args, **kwargs)
			function_cache[arguments_key] = result

			return result

		return site_cache_wrapper

	if callable(ttl):
		return time_cache_wrapper(ttl)

	return time_cache_wrapper


def redis_cache(ttl: int | None = 3600, user: str | bool | None = None, shared: bool = False) -> Callable:
	"""Decorator to cache method calls and its return values in Redis

	args:
	        ttl: time to expiry in seconds, defaults to 1 hour
	        user: `true` should cache be specific to session user.
	        shared: `true` should cache be shared across sites
	"""

	def wrapper(func: Callable | None = None) -> Callable:
		func_key = f"{func.__module__}.{func.__qualname__}"

		def clear_cache():
			frappe.cache.delete_keys(func_key, user=user, shared=shared)

		func.clear_cache = clear_cache
		func.ttl = ttl if not callable(ttl) else 3600

		@wraps(func)
		def redis_cache_wrapper(*args, **kwargs):
			func_call_key = f"{func_key}::{hash(__generate_request_cache_key(args, kwargs))}"
			cached_val = frappe.cache.get_value(func_call_key, user=user, shared=shared)
			if cached_val is not None:
				return cached_val

			# Edge Case: None can mean two things: cache miss or the result itself is `None`
			# RedisWrapper doesn't give us any way to handle this cleanly.
			if frappe.cache.exists(func_call_key, user=user, shared=shared):
				return None

			val = func(*args, **kwargs)
			ttl = getattr(func, "ttl", 3600)
			frappe.cache.set_value(func_call_key, val, expires_in_sec=ttl, user=user, shared=shared)
			return val

		return redis_cache_wrapper

	if callable(ttl):
		return wrapper(ttl)
	return wrapper


def http_cache(
	*,
	public: bool = False,
	max_age: int | None = None,
	stale_while_revalidate: int | None = None,
) -> Callable:
	"""Decorator to send cache-control response from whitelisted endpoints.

	Reference: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control

	args:
		public: Results can be cached by proxy if set to True, otherwise only client (browser) can
				cache results.
		max_age: Cache Time-To-Live
		stale_while_revalidate: Duration for which stale response can be served while revalidation
								occurs.
	"""
	assert isinstance(stale_while_revalidate, int | NoneType)
	assert isinstance(max_age, int | NoneType)

	cache_headers = []
	if public:
		cache_headers.append("public")
	else:
		cache_headers.append("private")
	if max_age is not None:
		cache_headers.append(f"max-age={max_age}")
	if stale_while_revalidate is not None:
		cache_headers.append(f"stale-while-revalidate={stale_while_revalidate}")
	cache_headers = ",".join(cache_headers)

	def outer(func: Callable) -> Callable:
		qualified_name = f"{func.__module__}.{func.__name__}"

		@wraps(func)
		def inner(*args, **kwargs):
			ret = func(*args, **kwargs)
			if frappe.request and frappe.request.method == "GET" and qualified_name in frappe.request.path:
				frappe.local.response_headers.set("Cache-Control", cache_headers)
			return ret

		return inner

	return outer


def deprecated_local_cache(namespace, key, generator, regenerate_if_none=False):
	if namespace not in frappe.local.cache:
		frappe.local.cache[namespace] = {}

	if key not in frappe.local.cache[namespace]:
		frappe.local.cache[namespace][key] = generator()

	elif frappe.local.cache[namespace][key] is None and regenerate_if_none:
		# if key exists but the previous result was None
		frappe.local.cache[namespace][key] = generator()

	return frappe.local.cache[namespace][key]
