"""
General-purpose decorators used by several modules
"""

from __future__ import annotations

import logging
from contextvars import ContextVar
from functools import wraps
from typing import Callable, Optional, TypeVar, ParamSpec


P = ParamSpec("P")
R = TypeVar("R")

# Context variable that holds the current logical function scope
_log_scope: ContextVar[Optional[str]] = ContextVar("_log_scope", default=None)


class ContextVarFilter(logging.Filter):
    """
    Logging filter that injects the current log scope (from a ContextVar)
    into each LogRecord as attribute `func_scope`, so formatters can use %(func_scope)s.
    """
    def filter(self, record: logging.LogRecord) -> bool:
        scope = _log_scope.get()
        # Always set attribute so formatters don't fail if empty
        setattr(record, "func_scope", scope or "")
        return True


def with_log_scope(name: Optional[str] = None) -> Callable[[Callable[P, R]], Callable[P, R]]:
    """
    Decorator that sets a per-call logging 'scope' for the duration of a function call.
    The scope is exposed to logging via ContextVar + Filter and is available
    in the LogRecord as `record.func_scope`.

    Usage
    -----
    1) Install the ContextVarFilter on your handler(s):
        handler.addFilter(ContextVarFilter())
        handler.setFormatter(logging.Formatter(
            "%(asctime)s %(levelname)s [%(func_scope)s] %(name)s: %(message)s"
        ))

    2) Decorate functions:
        @with_log_scope()
        def compute(...): ...
        # or a custom label:
        @with_log_scope("startup.phase1")
        def initialize(...): ...

    Result
    ------
    Every log emitted during the function's execution (including calls to
    helper functions deeper in the stack) will include the `func_scope` value,
    without changing log levels or requiring any changes to logging calls.
    
    Params:
        name(str): Optional. If provided, this string is used as the scope value. If None, the function's qualified name (`__qualname__`) is used.

    Returns:
        decorator which returns the wrapped function and its Result operations.
    
    """
    def deco(fn: Callable[P, R]) -> Callable[P, R]:
        scope_label = name or fn.__qualname__

        @wraps(fn)
        def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
            token = _log_scope.set(scope_label)
            try:
                return fn(*args, **kwargs)
            finally:
                # Ensure scope is restored even on exceptions
                _log_scope.reset(token)

        return wrapper
    return deco



def wrap_with_name(
    exc_type: Type[BaseException] = Exception,
    *,
    preserve_type: bool = False,
    formatter: Optional[Callable[[str, BaseException], str]] = None,
) -> Callable[[Callable[P, R]], Callable[P, R]]:
    """
    Decorator that adds a function-qualified name to any exception raised inside the function,
    and re-raises with explicit exception chaining (`raise ... from e`).


    Notes
    -----
    - Uses `functools.wraps` to preserve `__name__`, `__qualname__`, `__doc__`, etc.
    - Always chains exceptions with `from e` to keep the original traceback intact.
    - Setting `preserve_type=True` can fail for exceptions with non-standard `__init__`
      signatures (rare); in that case we gracefully fall back to `exc_type`.

    Examples
    --------
    >>> @wrap_with_name()
    ... def trim_lower(v: str) -> str:
    ...     return v.strip().lower()
    ...
    >>> @wrap_with_name(ValueError, preserve_type=False)
    ... def must_be_positive(x: int) -> int:
    ...     if x <= 0:
    ...         raise ValueError("not positive")
    ...     return x
    ...
    >>> @wrap_with_name(preserve_type=True)
    ... def divide(a: int, b: int) -> float:
    ...     return a / b  # ZeroDivisionError will be re-raised with function context

    You can also customize the error message:
    >>> def fmt(name, e): return f"[{name}] crashed: {e!r}"
    >>> @wrap_with_name(formatter=fmt)
    ... def do_work():
    ...     raise RuntimeError("bad state")
    
    
    Params:
        - exc_type: Type[BaseException], default=Exception
            The exception class to use when re-raising. Ignored when `preserve_type=True`,
            unless preserving the original type fails (then this is used as a fallback).
        - preserve_type : bool, default=False
            If True, attempts to re-raise using the *original* exception class with an augmented
            message. This is useful when you want callers' `except SomeError` blocks to continue
            working. If constructing the original exception with a custom message fails (e.g.,
            non-standard constructor), the decorator falls back to `exc_type`.
        - formatter : Optional[Callable[[str, BaseException], str]], default=None
            Optional callable that builds the final message. Receives the function's qualified
            name and the original exception. If not provided, the message defaults to:
            `"{qualname} failed: {original_exception}"`.

    Returns:
        Callable: A wrapped function with identical signature that adds context and preserves traceback.
    
    
    """
    def decorator(fn: Callable[P, R]) -> Callable[P, R]:
        qualname = fn.__qualname__

        @wraps(fn)
        def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
            try:
                return fn(*args, **kwargs)
            except Exception as e:  # Keep this broad; we re-raise with chaining
                message = formatter(qualname, e) if formatter else f"{qualname} failed: {e}"
                if preserve_type:
                    # Try to re-raise with the original exception class
                    try:
                        raise e.__class__(message) from e
                    except Exception:
                        # Fallback to the provided exc_type if the original cannot be constructed cleanly
                        raise exc_type(message) from e
                else:
                    raise exc_type(message) from e

        return wrapper

    return decorator


def frame_by_symbols(sym: str = "#", times: int = 30):
    """
    Decorator to graphically surround the function func video printed output by a number of symbols (by default hashes #).
    The inner function gets the function and has a nested wrapper function to deal with the decorated function arguments

    Examples
    --------
    >>> @frame_by_symbols(sym="*", times=55)
    ... def welcome(phrase: str) -> str:
    ...     print(prhase)
    

    Params:
        sym(str): symbol to print a number of times to surround the output of the nested function (by default #)
        times(int): number of times the hash must be repeated (by default 30)
    """
    def deco(func):
        def frame(*args):
            print(sym*times)
            func(*args)
            print(sym*times)
        return frame
    return deco

