"""
General utilities for store and other components.

This module provides dependency-free utilities that can be used in any context
(webapp, recipe, params, tests, CLI tools) without requiring Flask app config
or workload folder access.
"""

import time
from functools import wraps
from threading import RLock
from typing import Any, Callable

from flask import g, has_request_context

__all__ = ["request_cached", "invalidate_request_cache", "time_cached", "_has_unpublished_changes"]

from backend.utils.metrics import GLOBAL_REGISTRY


def _freeze(obj):
    if isinstance(obj, (str, int, float, bool, type(None))):
        return obj
    if isinstance(obj, dict):
        return frozenset((k, _freeze(v)) for k, v in obj.items())
    if isinstance(obj, (list, tuple)):
        return tuple(_freeze(x) for x in obj)
    try:
        hash(obj)
        return obj
    except TypeError:
        return str(obj)


def request_cached(fn):
    @wraps(fn)
    def wrapper(*args, **kwargs):
        if not has_request_context():
            return fn(*args, **kwargs)
        cache = getattr(g, "_store_cache", None)
        if cache is None:
            cache = {}
            g._store_cache = cache
        key = (fn.__name__, _freeze(args), _freeze(kwargs))
        if key in cache:
            return cache[key]
        result = fn(*args, **kwargs)
        cache[key] = result
        return result

    return wrapper


def time_cached(ttl_seconds: float) -> Callable:
    """
    Cache function results for ``expiry_s`` seconds.

    The cache key is built from all positional and keyword arguments.
    """
    if ttl_seconds <= 0:
        raise ValueError("expiry_s must be > 0")

    def decorator(fn: Callable) -> Callable:
        cache: dict[Any, tuple[Any, float]] = {}
        lock = RLock()

        name = getattr(fn, "__qualname__", getattr(fn, "__name__", type(fn).__name__))
        metric_prefix = f"cache.time_cached.{name}"
        hit_meter = GLOBAL_REGISTRY.meter(f"{metric_prefix}.hit")
        expired_miss_meter = GLOBAL_REGISTRY.meter(f"{metric_prefix}.expiredMiss")
        full_miss_meter = GLOBAL_REGISTRY.meter(f"{metric_prefix}.fullMiss")

        @wraps(fn)
        def wrapper(*args, **kwargs):
            now = time.monotonic()
            key = (_freeze(args), _freeze(kwargs))
            with lock:
                cached = cache.get(key)
                if cached and now < cached[1]:
                    hit_meter.mark()
                    return cached[0]
                elif cached:
                    expired_miss_meter.mark()
                else:
                    full_miss_meter.mark()

            result = fn(*args, **kwargs)

            with lock:
                cache[key] = (result, now + ttl_seconds)
            return result

        def cache_clear():
            with lock:
                cache.clear()

        wrapper.cache_clear = cache_clear
        return wrapper

    return decorator


def invalidate_request_cache(*method_names: str):
    def decorator(fn):
        @wraps(fn)
        def wrapper(self, *args, **kwargs):
            if has_request_context() and hasattr(g, "_store_cache"):
                cache = g._store_cache
                for k in list(cache):
                    if k[0] in method_names:
                        del cache[k]

            result = fn(self, *args, **kwargs)

            return result

        return wrapper

    return decorator


def _has_unpublished_changes(agent: dict) -> bool:
    pv = agent.get("published_version")
    if not pv:
        return True

    keys = (
        "name",
        "description",
        "system_prompt",
        "kb_description",
        "sample_questions",
        "llmid",
        "tools",
        "documents",
        "sem_models_llm",
        "datasources",
    )
    return any(agent.get(k) != pv.get(k) for k in keys)
