"""
Decorators for Strategy Computed Properties and Persistent State

Provides:
- @computed: Cached indicator computations with optional MTF support
- persistent(): Descriptor for state that survives restarts
- Persistent: Descriptor class backing persistent()
- ComputedValue: Type alias for computed return values
"""

from __future__ import annotations
import functools
from typing import Any, Callable, Optional, Union
import pandas as pd

from .params import InputParam
from .series import AlignableSeries, wrap_series


# Type alias for computed return values
ComputedValue = Union[pd.Series, pd.DataFrame, float, int, bool, None]


# =============================================================================
# Persistent State Descriptor
# =============================================================================

class Persistent:
    """
    Descriptor for strategy state that persists across restarts.

    Usage:
        class MyStrategy(Strategy):
            consecutive_losses = persistent(default=0)
            last_signal_time = persistent(default=None)

    The framework automatically saves/restores these values when the bot
    stops/starts. Users don't need to implement on_save()/on_load().
    """

    def __init__(self, default: Any = None, serializer: Optional[Callable] = None):
        """
        Args:
            default: Default value when not persisted
            serializer: Optional function to serialize complex types (e.g., datetime)
        """
        self.default = default
        self.serializer = serializer
        self.name: Optional[str] = None

    def __set_name__(self, owner: type, name: str) -> None:
        """Called when the descriptor is assigned to a class attribute."""
        self.name = name
        # Track persistent fields on the class
        if not hasattr(owner, '_persistent_fields'):
            owner._persistent_fields = {}
        owner._persistent_fields[name] = self

    def __get__(self, obj: Any, objtype: type = None) -> Any:
        if obj is None:
            return self
        # Get from instance __dict__, or return default
        return obj.__dict__.get(self.name, self.default)

    def __set__(self, obj: Any, value: Any) -> None:
        obj.__dict__[self.name] = value


def persistent(default: Any = None, serializer: Optional[Callable] = None) -> Persistent:
    """
    Declare a persistent strategy attribute that survives restarts.

    Args:
        default: Default value when not restored from storage
        serializer: Optional function to serialize/deserialize complex types

    Example:
        class MyStrategy(Strategy):
            # These are saved to Redis when bot stops, restored on start
            consecutive_losses = persistent(default=0)
            last_signal_time = persistent(default=None)
            trade_history = persistent(default=[])

            def on_bar(self, df):
                # Just use self.consecutive_losses - it persists automatically
                if self.consecutive_losses >= 3:
                    return None  # Cooling off period
                ...
    """
    return Persistent(default=default, serializer=serializer)


# =============================================================================
# Computed Decorator for Cached Indicator Values
# =============================================================================

def computed(func: Callable = None, *, timeframe: Union[str, InputParam] = None, limit: int = 1000) -> Callable:
    """
    Decorator for cached indicator computations.

    Returns AlignableSeries (extends pd.Series) with .aligned() method for MTF.
    Use .iloc[-1] for current bar value.

    For MTF data, the series is sliced to current timestamp so .iloc[-1]
    always gives you the correct value for the current bar.

    Args:
        timeframe: Either a static string ('1D', '4h') or an InputParam reference
        limit: Max candles to load for MTF data (default: 1000)

    Usage:
        @computed
        def fast_sma(self, df):
            return df['close'].rolling(self.fast_period).mean()

        # Static timeframe
        @computed(timeframe='1D')
        def daily_rsi(self, df):
            return ta.momentum.rsi(df['close'], window=14)

        # Dynamic timeframe from param, with custom limit
        htf = param.timeframe(default='1D', label='Higher Timeframe')

        @computed(timeframe=htf, limit=500)
        def htf_ichimoku(self, df):
            return IchimokuIndicator(...).compute(df)

        def on_bar(self, df):
            # .iloc[-1] = current bar value (works for both normal and MTF)
            if self.daily_rsi.iloc[-1] < 30:
                return self.buy()

        def plot(self, df):
            # Use .aligned() to map HTF series to base timeframe
            return [Line(y=self.daily_rsi.aligned(), color='#3b82f6')]
    """
    # Import here to avoid circular dependency at module level
    from .indicator import IndicatorResult

    def _slice_result(result, end_idx: int, tf: str, owner):
        """Slice Series or IndicatorResult to given index for MTF."""
        if isinstance(result, pd.Series):
            sliced = result.iloc[:end_idx]
            return wrap_series(sliced, timeframe=tf, owner=owner)
        elif hasattr(result, '_fields'):  # IndicatorResult (namedtuple-like)
            sliced = {}
            for field in result._fields:
                val = getattr(result, field)
                if isinstance(val, pd.Series):
                    sliced[field] = wrap_series(val.iloc[:end_idx], timeframe=tf, owner=owner)
                else:
                    sliced[field] = val
            return type(result)(**sliced)
        else:
            raise TypeError(f"@computed must return pd.Series or IndicatorResult, got {type(result)}")

    def decorator(method: Callable) -> property:
        cache_attr = f'_c_{method.__name__}'

        @functools.wraps(method)
        def wrapper(self) -> ComputedValue:
            # Resolve timeframe
            if isinstance(timeframe, InputParam):
                tf = getattr(self, timeframe.name)
            else:
                tf = timeframe

            # MTF: compute on full data, cache, return sliced view
            if tf:
                if not hasattr(self, '_mtf_data') or not self._mtf_data or tf not in self._mtf_data:
                    return pd.Series(dtype=float)
                full_df = self._mtf_data[tf]

                if full_df is None or len(full_df) == 0:
                    return pd.Series(dtype=float)

                # Get current MTF bar index for slicing
                mtf_idx = -1
                if hasattr(self, '_context') and self._context:
                    mtf_idx = self._get_mtf_bar_index(tf)
                    # Clamp to valid range
                    if mtf_idx >= len(full_df):
                        mtf_idx = len(full_df) - 1

                # Cache key uses FULL df length (only changes when new HTF bar arrives)
                cache_key = (len(full_df), tf)
                cache = self.__dict__.get(cache_attr)
                if cache is not None and cache[0] == cache_key:
                    full_result = cache[1]
                else:
                    # Compute on FULL data, cache once
                    # Temporarily disable auto-slicing during computation to avoid
                    # nested @computed calls returning sliced results
                    old_computing = getattr(self, '_is_computing', False)
                    self._is_computing = True
                    try:
                        full_result = method(self, full_df)
                    finally:
                        self._is_computing = old_computing
                    self.__dict__[cache_attr] = (cache_key, full_result)

                # Return sliced view so .iloc[-1] gives last CLOSED bar (no lookahead)
                if mtf_idx >= 0:
                    return _slice_result(full_result, mtf_idx, tf, self)
                # Wrap full result with metadata
                if isinstance(full_result, pd.Series):
                    return wrap_series(full_result, timeframe=tf, owner=self)
                return full_result

            # Non-MTF: current behavior
            if not hasattr(self, '_current_df') or self._current_df is None:
                raise RuntimeError("Strategy._current_df must be set.")
            df = self._current_df

            if df is None or len(df) == 0:
                return None

            cache_key = (len(df), tf)
            cache = self.__dict__.get(cache_attr)
            if cache is not None and cache[0] == cache_key:
                cached_result = cache[1]
            else:
                # Temporarily disable auto-slicing during computation to avoid
                # nested @computed calls returning sliced results
                old_computing = getattr(self, '_is_computing', False)
                self._is_computing = True
                try:
                    cached_result = method(self, df)
                finally:
                    self._is_computing = old_computing
                self.__dict__[cache_attr] = (cache_key, cached_result)

            # Auto-slice if in bar loop context AND not inside a computation
            # (nested @computed calls should return full data for proper caching)
            if (hasattr(self, '_context') and self._context is not None and
                not getattr(self, '_is_computing', False)):
                bar_idx = self._context.bar_index
                if isinstance(cached_result, pd.Series):
                    sliced = cached_result.iloc[:bar_idx + 1]
                    return wrap_series(sliced, owner=self)
                elif isinstance(cached_result, pd.DataFrame):
                    return cached_result.iloc[:bar_idx + 1]
                return cached_result

            # Full series mode (plot, no context) - wrap with owner for .aligned()
            if isinstance(cached_result, pd.Series):
                return wrap_series(cached_result, owner=self)
            return cached_result

        wrapper._is_computed = True
        wrapper._timeframe = timeframe
        wrapper._mtf_limit = limit
        wrapper._original_func = method

        return property(wrapper)

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


__all__ = [
    'ComputedValue',
    'Persistent',
    'persistent',
    'computed',
]
