"""
Strategy Base Class and Decorators

Provides the complete foundation for building trading strategies with:
- @strategy decorator for metadata and auto-registration
- @computed decorator for cached indicator computations
- Strategy base class with trading signals, position tracking, state persistence
- persistent() descriptor for state that survives restarts

Usage:
    from sixtysix import Strategy, strategy, computed, param, persistent, Line

    @strategy(
        name='sma_crossover',
        display_name='SMA Crossover',
        category='Trend Following'
    )
    class SMACrossoverStrategy:
        fast_period = param.number(default=20, min=5, max=100)
        slow_period = param.number(default=50, min=10, max=200)
        consecutive_losses = persistent(default=0)

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

        @computed
        def slow_sma(self, df):
            return df['close'].rolling(self.slow_period).mean()

        def plot(self, df):
            return [
                Line(y=self.fast_sma, color='#3b82f6', legend='Fast SMA'),
                Line(y=self.slow_sma, color='#f97316', legend='Slow SMA'),
            ]

        def on_bar(self, df):
            if self.ta.crossover(self.fast_sma, self.slow_sma):
                return self.buy(reason='Golden cross')
"""

from __future__ import annotations
from typing import Any, Callable, Dict, List, Literal, Optional, Protocol, Set, Type, Union, runtime_checkable
from typing_extensions import TypedDict
import pandas as pd
import numpy as np
from datetime import datetime

from .params import InputParam, ParamValue, SelectOption, StrategyConfig
from .plot import PlotComponent, extract_plot_data_with_panels
from .ta import TradingHelpers
from .decorators import ComputedValue, Persistent, persistent, computed
from .indicator import IndicatorRef
from .domain import Signal, SignalType, TradingContext, PositionInfo, SizingMode


# Protocol for Signal-like objects returned by on_bar
@runtime_checkable
class SignalLike(Protocol):
    """Protocol for signal objects that can be returned from on_bar()."""
    timestamp: datetime
    signal_type: str  # 'buy', 'sell', etc.
    price: float


# Type alias for on_bar return value
OnBarResult = Optional[Union[SignalLike, List[SignalLike]]]


class StrategyInfoDict(TypedDict):
    """Type for strategy info response"""
    name: str
    display_name: str
    category: str
    description: Optional[str]


class StrategyConfigParamDict(TypedDict, total=False):
    """Type for configurable parameter in API response"""
    name: str
    label: str
    type: Literal['number', 'color', 'boolean', 'select']
    value: ParamValue
    min_value: Optional[float]
    max_value: Optional[float]
    step: Optional[float]
    options: Optional[List[SelectOption]]


class StrategyResponseDict(TypedDict):
    """Type for strategy API response"""
    name: str
    display_name: str
    description: str
    category: str
    data: List[Dict[str, Union[int, float, str, bool, None]]]
    render_components: List[Dict[str, Union[str, Dict[str, str], Dict[str, Union[str, int, float, bool, None]], None]]]
    configurable_params: List[StrategyConfigParamDict]


# Registry for auto-registration
STRATEGY_REGISTRY: Dict[str, Type["Strategy"]] = {}


# =============================================================================
# Multi-Timeframe (MTF) Utilities (from former mtf.py)
# =============================================================================

def compute_mtf_bar_indices(
    base_timestamps: np.ndarray,
    mtf_timestamps: np.ndarray
) -> np.ndarray:
    """
    Compute which MTF bar each base bar belongs to.

    For each base timestamp, finds the last MTF bar that started
    at or before that timestamp.

    Args:
        base_timestamps: Array of timestamps from base timeframe
        mtf_timestamps: Array of timestamps from MTF timeframe

    Returns:
        Array of indices into mtf_timestamps for each base bar

    Example:
        Base hourly: [0, 1, 2, ..., 23, 24, 25, ...]
        MTF daily:   [0, 24, 48, ...]

        Result: [0, 0, 0, ..., 0, 1, 1, ...]
        (Base bars 0-23 map to daily bar 0, 24-47 to bar 1, etc.)
    """
    indices = np.searchsorted(mtf_timestamps, base_timestamps, side='right') - 1
    return np.clip(indices, 0, len(mtf_timestamps) - 1)


# =============================================================================
# Strategy Class Decorator
# =============================================================================

def _fix_super_cells(ns: dict, old_cls: type, new_cls: type) -> None:
    """After type() recreates a class, patch __class__ cells so super() works."""
    for attr in ns.values():
        fn = getattr(attr, '__func__', attr) if isinstance(attr, (classmethod, staticmethod)) else attr
        if not (callable(fn) and hasattr(fn, '__code__') and '__class__' in fn.__code__.co_freevars):
            continue
        for cell in (fn.__closure__ or ()):
            try:
                if cell.cell_contents is old_cls:
                    cell.cell_contents = new_cls
            except ValueError:
                pass


def strategy(
    name: str,
    display_name: str,
    category: str = 'General',
    description: Optional[str] = None
) -> Callable:
    """
    Class decorator for defining strategies.

    Args:
        name: Unique identifier for the strategy (e.g., 'sma_crossover')
        display_name: Human-readable name for UI display
        category: Category for grouping in UI (e.g., 'Trend Following')
        description: Optional description text

    The decorator automatically injects Strategy as a base class if the class
    doesn't already inherit from it, so explicit inheritance is optional:

        @strategy(name='sma_crossover', display_name='SMA Crossover')
        class SMACrossover:       # decorator injects Strategy base
            ...

        @strategy(name='sma_crossover', display_name='SMA Crossover')
        class SMACrossover(Strategy):  # also works (backward compatible)
            ...
    """
    def decorator(cls: type) -> type:
        # Inject Strategy base class if not already present
        if not issubclass(cls, Strategy):
            original_cls = cls
            new_bases = (Strategy,) if cls.__bases__ == (object,) else (*cls.__bases__, Strategy)
            ns = {k: v for k, v in cls.__dict__.items() if k not in ('__dict__', '__weakref__')}
            cls = type(cls.__name__, new_bases, ns)
            _fix_super_cells(ns, original_cls, cls)

        cls._strategy_name = name
        cls._strategy_display_name = display_name
        cls._strategy_category = category
        cls._strategy_description = description

        # Auto-register
        STRATEGY_REGISTRY[name] = cls

        return cls

    return decorator


# =============================================================================
# Base Strategy Class
# =============================================================================

class Strategy:
    """
    Base class for all trading strategies.

    Core Methods (to be overridden):
        plot(df)    - Return PlotComponent objects for visualization
        on_bar(df)  - Generate signals per bar (access current bar via self.bar)

    Core Properties:
        self.bar       - Current bar OHLCV data (pd.Series)
        self.bar_index - Current bar index (0, 1, 2, ...) - USE THIS, NOT len(df)!
        self.ta        - Trading helper functions (crossover, crossunder, etc.)
        self.ctx       - Strategy context (bar_index, timestamp, position, etc.)

    Signal Methods:
        buy(...)       - Create buy signal (open/add to long)
        sell(...)      - Create sell signal (open short or explicit sell)
        close(...)     - Close position (full or partial)

    Trading Properties:
        position       - Current position (is_flat, is_long, quantity, etc.)
        equity         - Current account equity
        cash           - Available cash
        can_buy        - Can open/add to long?
        can_sell       - Can open/add to short?

    WARNING: In on_bar(df), do NOT use len(df) - 1 to get bar index!
    The df may be sliced or full depending on the caller. Always use self.bar_index.

    Lifecycle Hooks (optional):
        on_start()  - Called when strategy starts
        on_stop()   - Called when strategy stops
        on_reset()  - Reset strategy state (clears @computed caches)

    Use @computed decorator for cached indicator computations:
        @computed
        def sma(self, df):
            return df['close'].rolling(self.period).mean()

    Example:
        @strategy(name='sma_crossover', display_name='SMA Crossover')
        class SMACrossover:  # @strategy injects Strategy base automatically
            fast_period = param.number(default=20, min=5, max=100)
            slow_period = param.number(default=50, min=10, max=200)

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

            @computed
            def slow_sma(self, df):
                return df['close'].rolling(self.slow_period).mean()

            def plot(self, df):
                return [
                    Line(y=self.fast_sma, color='#3b82f6'),
                    Line(y=self.slow_sma, color='#f97316'),
                ]

            def on_bar(self, df):
                if self.bar_index < 50:  # Warmup period
                    return None
                if self.ta.crossover(self.fast_sma, self.slow_sma):
                    return self.buy(reason='Golden cross')
    """

    # Set by @strategy decorator
    _strategy_name: str = ''
    _strategy_display_name: str = ''
    _strategy_category: str = 'General'
    _strategy_description: Optional[str] = None

    # MTF data (injected by engine before running)
    _mtf_data: Dict[str, pd.DataFrame] = None
    _current_df: pd.DataFrame = None
    _mtf_bar_indices: Dict[str, np.ndarray] = None  # Pre-computed MTF bar indices

    # Context injection (set by engine before on_bar)
    _context: Optional[TradingContext] = None

    @property
    def bar(self) -> pd.Series:
        """
        Get the current bar's OHLCV data.

        Returns a pandas Series with: open, high, low, close, volume, timestamp.

        Example:
            def on_bar(self, df):
                if self.bar['close'] > self.bar['open']:
                    # Bullish candle
                    pass
                price = self.bar['close']
        """
        if not hasattr(self, '_context') or self._context is None:
            raise RuntimeError("Cannot access self.bar outside of on_bar() context")
        return self._current_df.iloc[self._context.bar_index]

    @property
    def bar_index(self) -> int:
        """
        Get the current bar index.

        IMPORTANT: Always use self.bar_index instead of len(df) - 1.
        The df parameter in on_bar() may be sliced or full depending on
        the caller (SignalGenerator vs BacktestEngine), so len(df) is unreliable.

        Example:
            def on_bar(self, df):
                if self.bar_index < 10:
                    return None  # Wait for warmup
                # Use self.bar_index for any bar-relative logic
        """
        if not hasattr(self, '_context') or self._context is None:
            raise RuntimeError("Cannot access self.bar_index outside of on_bar() context")
        return self._context.bar_index

    @property
    def idx(self) -> int:
        """Shorthand for self.bar_index."""
        return self.bar_index

    @property
    def ta(self) -> TradingHelpers:
        """
        Access trading helper functions.

        Provides PineScript-like technical analysis functions:
            - self.ta.crossover(a, b)  - Check if 'a' crosses above 'b'
            - self.ta.crossunder(a, b) - Check if 'a' crosses below 'b'
            - self.ta.crossed(a, b)    - Check if 'a' crossed 'b' either direction
            - self.ta.pivothigh(source, left, right) - Detect pivot high
            - self.ta.pivotlow(source, left, right)  - Detect pivot low

        Example:
            def on_bar(self, df):
                if self.ta.crossover(self.fast_sma, self.slow_sma):
                    return self.buy(reason='Golden cross')
        """
        # Return fresh TradingHelpers with current context
        # (context changes each bar, so don't cache)
        return TradingHelpers(self.ctx)

    # =========================================================================
    # Context Access & Trading Properties
    # =========================================================================

    @property
    def ctx(self) -> TradingContext:
        """Access current trading context. Available in on_bar() when called by engine."""
        if self._context is None:
            raise RuntimeError("Context not available. Strategy must be run by engine.")
        return self._context

    @property
    def position(self) -> PositionInfo:
        """Shortcut to current position info."""
        return self.ctx.position

    @property
    def equity(self) -> float:
        """Shortcut to current equity."""
        return self.ctx.equity

    @property
    def cash(self) -> float:
        """Shortcut to available cash."""
        return self.ctx.cash

    @property
    def can_buy(self) -> bool:
        """Can we open/add to a long position?"""
        return (
            self.position.is_flat or
            (self.position.is_long and self.ctx.can_add_entry)
        )

    @property
    def can_sell(self) -> bool:
        """Can we open/add to a short position?"""
        return (
            self.position.is_flat or
            (self.position.is_short and self.ctx.can_add_entry)
        )

    def __init__(self, **kwargs):
        """Initialize strategy with optional parameter overrides."""
        for attr_name in dir(self.__class__):
            attr = getattr(self.__class__, attr_name, None)
            if isinstance(attr, InputParam):
                value = kwargs.get(attr_name, attr.default)
                setattr(self, attr_name, value)

    # =========================================================================
    # Lifecycle Hooks
    # =========================================================================

    def on_start(self) -> None:
        """Called when strategy starts. Override for initialization."""
        pass

    def on_stop(self) -> None:
        """Called when strategy stops. Override for cleanup."""
        pass

    def on_reset(self) -> None:
        """
        Reset strategy state for a new run.

        Automatically clears @computed caches.
        Override to clear additional state (call super().on_reset() first).
        """
        self._clear_computed_caches()

    def _clear_computed_caches(self) -> None:
        """Clear all @computed and use_indicator() caches."""
        to_delete = [
            attr for attr in self.__dict__
            if attr.startswith('_c_') or attr.startswith('_ui_')
        ]
        for attr in to_delete:
            del self.__dict__[attr]

    # =========================================================================
    # State Persistence
    # =========================================================================

    def _get_persistent_state(self) -> Dict[str, Any]:
        """Collect all persistent field values for saving."""
        state = {}
        persistent_fields = getattr(self.__class__, '_persistent_fields', {})
        for name, descriptor in persistent_fields.items():
            value = getattr(self, name)
            if descriptor.serializer and value is not None:
                try:
                    value = descriptor.serializer(value)
                except Exception:
                    pass
            state[name] = value
        return state

    def _restore_persistent_state(self, state: Dict[str, Any]) -> None:
        """Restore persistent field values from saved state."""
        if not state:
            return
        persistent_fields = getattr(self.__class__, '_persistent_fields', {})
        for name, descriptor in persistent_fields.items():
            if name in state:
                setattr(self, name, state[name])

    def _has_persistent_state(self) -> bool:
        """Check if this strategy has any persistent fields."""
        persistent_fields = getattr(self.__class__, '_persistent_fields', {})
        return len(persistent_fields) > 0

    # =========================================================================
    # Position Sizing Helpers
    # =========================================================================

    def size(
        self,
        percent: Optional[float] = None,
        value: Optional[float] = None,
        quantity: Optional[float] = None
    ) -> float:
        """
        Calculate position size with flexible inputs.

        Args:
            percent: Percentage of equity (0.1 = 10%)
            value: Fixed dollar value
            quantity: Absolute quantity

        Returns:
            Quantity to trade
        """
        if quantity is not None:
            return quantity
        if value is not None:
            return self.ctx.size_for_value(value)
        if percent is not None:
            return self.ctx.size_for_percent(percent)
        return self.ctx.size_for_percent(self.ctx.default_size)

    def risk_size(self, stop_distance: float, risk_percent: float = 0.02) -> float:
        """
        Calculate size based on risk per trade.

        Args:
            stop_distance: Distance to stop loss in price units
            risk_percent: Max risk per trade (default 2%)

        Returns:
            Quantity that risks the specified amount
        """
        return self.ctx.size_for_risk(risk_percent, stop_distance)

    # =========================================================================
    # Signal Generation
    # =========================================================================

    def buy(
        self,
        *,
        percent: Optional[float] = None,
        quantity: Optional[float] = None,
        stop_loss: Optional[float] = None,
        take_profit: Optional[float] = None,
        reason: str = "",
        extra: Optional[Dict[str, Any]] = None,
    ) -> Signal:
        """
        Create a buy signal at current price.

        Args:
            percent: Position size as % of equity (0.1 = 10%)
            quantity: Absolute quantity to buy
            stop_loss: Stop loss price
            take_profit: Take profit price
            reason: Reason for the signal
            extra: Dict of feature values to capture at entry

        Example:
            return self.buy(percent=0.25, reason='RSI oversold')
        """
        return Signal(
            timestamp=self.ctx.timestamp,
            signal_type=SignalType.BUY,
            price=self.ctx.current_price,
            reason=reason,
            quantity=quantity,
            percent_equity=percent,
            stop_loss=stop_loss,
            take_profit=take_profit,
            extra=extra,
        )

    def sell(
        self,
        *,
        percent: Optional[float] = None,
        quantity: Optional[float] = None,
        stop_loss: Optional[float] = None,
        take_profit: Optional[float] = None,
        reason: str = "",
        extra: Optional[Dict[str, Any]] = None,
    ) -> Signal:
        """
        Create a sell signal at current price.

        For opening short positions or explicit selling.

        Args:
            percent: Position size as % of equity (0.1 = 10%)
            quantity: Absolute quantity to sell
            stop_loss: Stop loss price
            take_profit: Take profit price
            reason: Reason for the signal
            extra: Dict of feature values to capture at exit
        """
        return Signal(
            timestamp=self.ctx.timestamp,
            signal_type=SignalType.SELL,
            price=self.ctx.current_price,
            reason=reason,
            quantity=quantity,
            percent_equity=percent,
            stop_loss=stop_loss,
            take_profit=take_profit,
            extra=extra,
        )

    def close(
        self,
        percent: float = 1.0,
        *,
        reason: str = "",
        extra: Optional[Dict[str, Any]] = None,
    ) -> Signal:
        """
        Close position at current price.

        Args:
            percent: Portion of position to close (1.0 = 100%, 0.5 = 50%)
            reason: Reason for closing
            extra: Dict of feature values to capture at exit

        Example:
            return self.close(reason='Take profit')
            return self.close(percent=0.5, reason='Partial exit')
        """
        return Signal(
            timestamp=self.ctx.timestamp,
            signal_type=SignalType.SELL,
            price=self.ctx.current_price,
            reason=reason,
            percent_position=percent,
            extra=extra,
        )

    # =========================================================================
    # Multi-Timeframe (MTF) Support
    # =========================================================================

    @classmethod
    def get_required_timeframes(cls) -> Set[str]:
        """
        Discover required timeframes from @computed decorators (static only).

        Returns:
            Set of static timeframe strings (e.g., {'1D', '1W'})
        """
        timeframes = set()
        for name in dir(cls):
            try:
                attr = getattr(cls, name, None)
                if isinstance(attr, property) and hasattr(attr.fget, '_timeframe'):
                    tf = attr.fget._timeframe
                    if tf:
                        timeframes.add(tf)
            except Exception:
                continue
        return timeframes

    def get_instance_timeframes(self) -> Set[str]:
        """
        Get all timeframes required by this instance.

        Resolves both:
        - Static timeframes: @computed(timeframe='1D')
        - Dynamic timeframes: @computed(timeframe=htf) where htf is InputParam
        - use_indicator() with timeframe parameter

        Returns:
            Set of timeframe strings needed for this instance
        """
        timeframes = set()
        for name in dir(self.__class__):
            try:
                attr = getattr(self.__class__, name, None)
                # @computed with timeframe
                if isinstance(attr, property) and hasattr(attr.fget, '_timeframe'):
                    tf_spec = attr.fget._timeframe
                    if tf_spec:
                        if isinstance(tf_spec, InputParam):
                            tf = getattr(self, tf_spec.name)
                        else:
                            tf = tf_spec
                        if tf:
                            timeframes.add(tf)
                # use_indicator() with timeframe
                elif isinstance(attr, IndicatorRef) and attr.timeframe:
                    tf_spec = attr.timeframe
                    if isinstance(tf_spec, InputParam):
                        tf = getattr(self, tf_spec.name)
                    else:
                        tf = tf_spec
                    if tf:
                        timeframes.add(tf)
            except Exception:
                continue
        return timeframes

    def get_mtf_limits(self) -> Dict[str, int]:
        """
        Get MTF candle limits for each required timeframe.

        Returns:
            Dict mapping timeframe to limit (e.g., {'1D': 1000, '4h': 500})
        """
        limits = {}
        for name in dir(self.__class__):
            try:
                attr = getattr(self.__class__, name, None)
                # @computed with timeframe
                if isinstance(attr, property) and hasattr(attr.fget, '_timeframe'):
                    tf_spec = attr.fget._timeframe
                    mtf_limit = getattr(attr.fget, '_mtf_limit', 1000)
                    if tf_spec:
                        if isinstance(tf_spec, InputParam):
                            tf = getattr(self, tf_spec.name)
                        else:
                            tf = tf_spec
                        if tf:
                            limits[tf] = max(limits.get(tf, 0), mtf_limit)
                # use_indicator() with timeframe (default limit 1000)
                elif isinstance(attr, IndicatorRef) and attr.timeframe:
                    tf_spec = attr.timeframe
                    if isinstance(tf_spec, InputParam):
                        tf = getattr(self, tf_spec.name)
                    else:
                        tf = tf_spec
                    if tf:
                        limits[tf] = max(limits.get(tf, 0), 1000)
            except Exception:
                continue
        return limits

    def _get_mtf_bar_index(self, timeframe: str) -> int:
        """Find which MTF bar corresponds to the current bar."""
        if self._mtf_bar_indices is not None and timeframe in self._mtf_bar_indices:
            bar_idx = self.ctx.bar_index
            if bar_idx < len(self._mtf_bar_indices[timeframe]):
                return int(self._mtf_bar_indices[timeframe][bar_idx])
            return -1

        if self._mtf_data is None or timeframe not in self._mtf_data:
            return -1

        current_ts = self.ctx.timestamp
        mtf_df = self._mtf_data[timeframe]
        mask = mtf_df['timestamp'] <= current_ts
        if not mask.any():
            return -1
        return int(mask.sum()) - 1

    def _precompute_mtf_indices(self, base_df: pd.DataFrame) -> None:
        """Pre-compute MTF bar indices for all bars in base DataFrame."""
        if self._mtf_data is None:
            return

        self._mtf_bar_indices = {}
        base_timestamps = base_df['timestamp'].values

        if len(base_timestamps) >= 2:
            bar_duration = base_timestamps[1] - base_timestamps[0]
        else:
            bar_duration = 0

        close_timestamps = base_timestamps + bar_duration

        for tf, mtf_df in self._mtf_data.items():
            mtf_timestamps = mtf_df['timestamp'].values
            indices = np.searchsorted(mtf_timestamps, close_timestamps, side='right') - 1
            indices = np.clip(indices, 0, len(mtf_timestamps) - 1)
            self._mtf_bar_indices[tf] = indices

    def get_mtf_indices(self, timeframe: str) -> Optional[np.ndarray]:
        """Get precomputed MTF bar indices for a timeframe, or None if not available."""
        return self._mtf_bar_indices.get(timeframe) if self._mtf_bar_indices else None

    def align(self, series: pd.Series, timeframe: str) -> pd.Series:
        """
        Align HTF series to base timeframe.

        Maps each base bar to the corresponding HTF value that was
        active at that time (fill-forward).

        Args:
            series: HTF indicator values (from @computed)
            timeframe: Which timeframe this series is from

        Returns:
            Series aligned to base timeframe length
        """
        if series is None or len(series) == 0:
            return pd.Series(dtype=float)

        indices = self.get_mtf_indices(timeframe)
        if indices is not None:
            clamped = np.clip(indices, 0, len(series) - 1)
            return pd.Series(series.iloc[clamped].values)

        if self._current_df is not None:
            return pd.Series([series.iloc[-1]] * len(self._current_df))
        return pd.Series([series.iloc[-1]])

    # =========================================================================
    # Strategy Methods (to be overridden)
    # =========================================================================

    def plot(self, df: pd.DataFrame) -> List[PlotComponent]:
        """
        Define the strategy's overlay visualization.

        Override to add visual overlays on the chart (e.g., moving averages).
        Returns empty list by default.

        Args:
            df: DataFrame with OHLCV data

        Returns:
            List of PlotComponent objects (Line, Scatter, etc.)
        """
        return []

    def on_bar(self, df: pd.DataFrame) -> OnBarResult:
        """
        Generate signals for the current bar.

        Called for each bar during backtesting. Access current bar via self.bar.

        Args:
            df: Market data (DO NOT use len(df) for bar index - see warning below)

        Returns:
            Signal, list of signals, or None

        WARNING: Do NOT use len(df) - 1 to get the current bar index!
        The df may be sliced or full depending on the caller. Always use:
            - self.bar_index  - Current bar index (0, 1, 2, ...)
            - self.bar        - Current bar OHLCV data

        Example:
            def on_bar(self, df):
                if self.bar_index < 10:
                    return None  # Warmup period

                if self.bar['close'] > self.bar['open']:
                    return self.buy(reason='Bullish candle')
        """
        raise NotImplementedError("Subclasses must implement on_bar()")

    # =========================================================================
    # Configuration & Metadata
    # =========================================================================

    @classmethod
    def _get_input_params(cls) -> List[InputParam]:
        """Get all InputParam descriptors from the class"""
        params = []
        for attr_name in dir(cls):
            attr = getattr(cls, attr_name, None)
            if isinstance(attr, InputParam):
                params.append(attr)
        return params

    @property
    def config(self) -> StrategyConfig:
        """Build StrategyConfig from class metadata"""
        configurable_params = []
        for p in self._get_input_params():
            config_param = p.to_config_param()
            config_param.value = getattr(self, p.name)
            configurable_params.append(config_param)

        description = self._strategy_description
        if description is None and self.__class__.__doc__:
            description = self.__class__.__doc__.strip().split('\n')[0]

        return StrategyConfig(
            name=self._strategy_name,
            display_name=self._strategy_display_name,
            description=description or '',
            category=self._strategy_category,
            render_components=[],
            configurable_params=configurable_params
        )

    def to_dict(self, df: pd.DataFrame, signals_df: Optional[pd.DataFrame] = None) -> Dict[str, Any]:
        """
        Convert strategy to dictionary format for API response.

        Args:
            df: Market data DataFrame
            signals_df: Optional signals DataFrame (from backtest engine or signal generator)
        """
        # Set _current_df so @computed properties work in plot()
        # Clear _context so @computed returns full data (not sliced from on_bar)
        self._current_df = df
        self._context = None
        plot_result = self.plot(df)

        timestamps = [int(t) for t in df['timestamp'].tolist()] if 'timestamp' in df.columns else []

        plot_data = extract_plot_data_with_panels(plot_result, timestamps)
        overlay_data = plot_data['overlay_data']
        render_components = plot_data['overlay_components']
        panels = plot_data['panels']

        # Convert signals DataFrame to list of dicts
        signals_data = []
        if signals_df is not None and isinstance(signals_df, pd.DataFrame) and not signals_df.empty:
            for _, row in signals_df.iterrows():
                if pd.isna(row['timestamp']):
                    continue
                signals_data.append({
                    'timestamp': int(row['timestamp']),
                    'signal_type': row['signal_type'],
                    'price': float(row['price']) if pd.notna(row['price']) else None,
                    'reason': row.get('reason', ''),
                    'quantity': row.get('quantity'),
                    'percent_equity': row.get('percent_equity'),
                })

        # Auto-add signal render component if we have signals
        if signals_data:
            buy_color = getattr(self, 'buy_color', '#2862ff')
            sell_color = getattr(self, 'sell_color', '#d522f9')

            render_components.append({
                'type': 'signal',
                'data_fields': {
                    'y': 'price',
                    'signal_type': 'signal_type',
                },
                'style': {
                    'buy_color': buy_color,
                    'sell_color': sell_color,
                    'size': 12,
                },
                'legend': None,
            })

        configurable_params = []
        for p in self.config.configurable_params:
            param_dict = {
                'name': p.name,
                'label': p.label,
                'type': p.type,
                'value': p.value
            }
            if p.min_value is not None:
                param_dict['min_value'] = p.min_value
            if p.max_value is not None:
                param_dict['max_value'] = p.max_value
            if p.step is not None:
                param_dict['step'] = p.step
            if p.options is not None:
                param_dict['options'] = p.options
            if p.purpose is not None:
                param_dict['purpose'] = p.purpose
            if p.category is not None:
                param_dict['category'] = p.category
            configurable_params.append(param_dict)

        return {
            'name': self.config.name,
            'display_name': self.config.display_name,
            'description': self.config.description,
            'category': self.config.category,
            'data': overlay_data,
            'signals': signals_data,
            'render_components': render_components,
            'panels': panels,
            'configurable_params': configurable_params
        }


# =============================================================================
# Helper Functions
# =============================================================================

def get_strategy(name: str, **kwargs) -> Strategy:
    """Get a strategy instance by name."""
    if name not in STRATEGY_REGISTRY:
        raise ValueError(f"Unknown strategy: {name}")

    cls = STRATEGY_REGISTRY[name]
    return cls(**kwargs)


def get_available_strategies() -> Dict[str, Type[Strategy]]:
    """Get all registered strategies"""
    return STRATEGY_REGISTRY.copy()


def get_strategy_info(name: str) -> StrategyInfoDict:
    """Get strategy info without instantiating"""
    if name not in STRATEGY_REGISTRY:
        raise ValueError(f"Unknown strategy: {name}")

    cls = STRATEGY_REGISTRY[name]
    return {
        'name': cls._strategy_name,
        'display_name': cls._strategy_display_name,
        'category': cls._strategy_category,
        'description': cls._strategy_description
    }


def get_all_strategies() -> list:
    """Get info for all registered strategies including configurable params."""
    result = []
    for name, cls in STRATEGY_REGISTRY.items():
        description = cls._strategy_description
        if description is None and cls.__doc__:
            description = cls.__doc__.strip().split('\n')[0]

        result.append({
            'name': cls._strategy_name,
            'display_name': cls._strategy_display_name,
            'category': cls._strategy_category,
            'description': description or '',
            'configurable_params': [
                {
                    'name': p.name,
                    'label': p.label,
                    'type': p.param_type,
                    'value': p.default,
                    'min_value': p.min_value,
                    'max_value': p.max_value,
                    'step': p.step,
                    'options': p.options
                }
                for p in cls._get_input_params()
            ]
        })
    return result


__all__ = [
    # Types
    'SignalLike',
    'OnBarResult',
    'ComputedValue',
    'StrategyInfoDict',
    'StrategyConfigParamDict',
    'StrategyResponseDict',
    # Persistent state (re-exported from decorators)
    'Persistent',
    'persistent',
    # MTF utility (from former mtf.py)
    'compute_mtf_bar_indices',
    # Registry
    'STRATEGY_REGISTRY',
    # Decorators
    'computed',
    'strategy',
    # Base class
    'Strategy',
    # Helper functions
    'get_strategy',
    'get_available_strategies',
    'get_strategy_info',
    'get_all_strategies',
]
