"""
Унифицированный пайплайн подготовки DataFrame для обучения/инференса.

Заменяет 8 локальных копий _prepare_df / _prepare_ticker_df,
разбросанных по main.py, models/moe.py, monitor.py, cli/handlers.py,
run_walkforward.py, models/moe_regression.py.

Использование:
    from features.pipeline import prepare_df
    df = prepare_df('SBER', 'H1', with_mtf=True, limit=2000)
"""

from typing import Optional
import pandas as pd
import numpy as np

from data.loader import load_dataframe
from features.technical import engineer_features
from features.directional import add_directional_signals, add_short_specific_features
from features.context import add_curated_context
from features.targets import compute_dual_outcomes, compute_swing_dual_outcomes
from config import get_target_config, get_market


def has_mtf_context(df: pd.DataFrame) -> bool:
    """Проверяет, есть ли в df колонки MTF контекста."""
    from features.mtf_context import MTF_CONTEXT_COLS
    return any(c in df.columns for c in MTF_CONTEXT_COLS)


def clean_numeric_columns(df: pd.DataFrame, log_warnings: bool = False,
                          include_str_dtype: bool = False) -> pd.DataFrame:
    """
    Конвертирует object-колонки (и опционально 'str' dtype) в числовой тип.

    Централизованная версия — заменяет 8 копий конвертации по всему проекту.

    Args:
        df: DataFrame с возможными object-колонками.
        log_warnings: Если True — логирует появление NaN после конвертации.
        include_str_dtype: Если True — включает колонки с dtype 'str'
                          (необходимо для Dask/Modin, используется в moe.py).

    Returns:
        DataFrame с числовыми колонками.
    """
    import logging
    logger = logging.getLogger('AI_Strategy')

    dtype_include = ['object', 'str'] if include_str_dtype else ['object']
    for col in df.select_dtypes(include=dtype_include).columns:
        if col not in ['Date', 'Time']:
            if log_warnings:
                n_nan_before = df[col].isna().sum()
                df[col] = pd.to_numeric(df[col], errors='coerce')
                n_nan_after = df[col].isna().sum()
                if n_nan_after > n_nan_before:
                    logger.warning(f"{col}: {n_nan_after - n_nan_before} NaN values after conversion")
            else:
                df[col] = pd.to_numeric(df[col], errors='coerce')
    # OHLC-колонки могут отсутствовать (например, в тестах); если есть — конвертируем во float
    for col in ['Open', 'High', 'Low', 'Close']:
        if col not in df.columns:
            continue
        if log_warnings:
            n_nan_before = df[col].isna().sum()
            df[col] = df[col].astype(float)
            n_nan_after = df[col].isna().sum()
            if n_nan_after > n_nan_before:
                logger.warning(f"{col}: {n_nan_after - n_nan_before} NaN values after conversion")
        else:
            df[col] = df[col].astype(float)
    return df


def _add_crypto_features_if_needed(df: pd.DataFrame, ticker: str) -> pd.DataFrame:
    """Добавляет crypto-специфичные признаки, если тикер криптовалютный.
    
    Вызывает _add_crypto_features (технические индикаторы) — может быть
    вызван как до, так и после compute_dual_outcomes.
    Rolling SR добавляется только если outcome_long/outcome_short уже вычислены.
    
    Аналог блоков из monitor.py (crypto features + rolling base rate).
    """
    market = get_market(ticker)
    if market != 'crypto':
        return df
    
    # Crypto technical features (не зависят от targets)
    try:
        from models.crypto_moe import _add_crypto_features
        df = _add_crypto_features(df)
    except ImportError:
        pass
    
    # Rolling base rate (требует outcome_long/outcome_short)
    if 'outcome_long' in df.columns and 'outcome_short' in df.columns:
        ROLL_WIN = 1000
        df['rolling_long_sr'] = df['outcome_long'].rolling(ROLL_WIN, min_periods=100).mean().fillna(0.5)
        df['rolling_short_sr'] = df['outcome_short'].rolling(ROLL_WIN, min_periods=100).mean().fillna(0.5)
    return df


def prepare_df(
    ticker: str,
    timeframe: str = 'H1',
    *,
    with_mtf: bool = True,
    with_short_specific: bool = True,
    with_targets: bool = True,
    with_crypto_features: bool = False,
    limit: Optional[int] = None,
    min_rows: int = 100,
    shift_mtf: Optional[bool] = None,
    winsor_bounds: Optional[dict] = None,
    log_nan_conversion: bool = False,
    include_str_dtype: bool = False,
    reset_index: bool = False,
) -> Optional[pd.DataFrame]:
    """
    Загружает и подготавливает DataFrame для одного тикера × timeframe.

    Универсальный пайплайн, заменяющий 8 копий по проекту.
    Все параметры опциональны и настраиваются под сценарий.

    Pipeline:
        1. load_dataframe → сырые свечи
        2. engineer_features → технические индикаторы
        3. clean_numeric_columns → object → numeric
        4. add_directional_signals → directional сигналы
        5. (если with_short_specific) add_short_specific_features
        6. add_curated_context → свечные паттерны, streaks
        7. (если with_targets) compute_dual_outcomes → SL/TP исходы
        8. (если with_crypto_features) crypto-specific фичи (после targets)
        9. (если with_mtf) add_mtf_context → мультитаймфрейм контекст
        10. (если winsor_bounds) winsorize_features
        11. dropna → финальная очистка

    Args:
        ticker: Тикер инструмента.
        timeframe: Таймфрейм ('H1', 'D1', 'W1').
        with_mtf: Добавлять MTF контекст (D1+W1). По умолчанию True.
                  Параметр shift_mtf передаётся в add_mtf_context если задан.
        with_short_specific: Добавлять short-специфичные признаки.
                             True для MoE/Monitor, False для main.py legacy.
        with_targets: Вычислять outcome_long/outcome_short.
        with_crypto_features: Добавлять crypto-специфичные признаки + rolling SR.
                              True для monitor.py, False для основного MoE.
        limit: Ограничение количества строк.
        min_rows: Минимальное число строк (иначе None). По умолчанию 100.
        shift_mtf: Сдвиг D1/W1 на 1 период назад (защита от lookahead).
                   True — тренировка, False — инференс, None — значение по умолчанию.
        winsor_bounds: Границы winsorization из train-фазы.
                       Если переданы — применяет winsorize_features (как monitor.py).
                       Если None — не применяет (winsorization после split'а).
        log_nan_conversion: Логировать появление NaN при конвертации типов
                           (как в monitor.py).
        include_str_dtype: Включать колонки с dtype 'str' в конвертацию.
                           Нужно для moe.py/moe_regression.py (Dask/Modin).
        reset_index: Сбросить индекс DataFrame. Нужен для run_walkforward.py.

    Returns:
        DataFrame с признаками или None при ошибке/нехватке данных.
    """
    try:
        df = load_dataframe(ticker, timeframe, limit)
    except Exception as e:
        print(f'    [WARN] Не удалось загрузить {ticker}_{timeframe}: {e}')
        return None

    if df is None or len(df) < min_rows:
        return None

    # 1. Технические индикаторы
    df = engineer_features(df)

    # 2. Конвертация object-колонок (централизованная)
    df = clean_numeric_columns(df, log_warnings=log_nan_conversion,
                               include_str_dtype=include_str_dtype)

    # 3-5. Сигналы
    df = add_directional_signals(df)
    if with_short_specific:
        df = add_short_specific_features(df)
    df = add_curated_context(df)

    # 6. Таргеты (SL/TP outcome) — per-market config
    # v12.8: swing-based SL (рыночные уровни) вместо чисто ATR-расстояния
    # MOEX-only-long: outcome_short не вычисляется (short-selling недоступен)
    if with_targets:
        target_cfg = get_target_config(ticker)
        from config import USE_SWING_TARGETS, should_disable_short_training
        disable_short = should_disable_short_training(ticker)
        # BUG-FIX (2026-07-31): отфильтровать per-market поля, не относящиеся
        # к target-функциям (например `max_atr_ratio` — это для live execution).
        target_only_keys = ('atr_mult_sl', 'atr_mult_tp', 'max_bars', 'swing_lookback')
        target_kwargs = {k: v for k, v in target_cfg.items() if k in target_only_keys}
        if USE_SWING_TARGETS:
            df = compute_swing_dual_outcomes(df, **target_kwargs, disable_short=disable_short)
        else:
            # Старый compute_dual_outcomes не знает swing_lookback
            atr_only_keys = ('atr_mult_sl', 'atr_mult_tp', 'max_bars')
            atr_only_cfg = {k: v for k, v in target_kwargs.items() if k in atr_only_keys}
            df = compute_dual_outcomes(df, **atr_only_cfg, disable_short=disable_short)

    # 7. Crypto-specific features (после targets — rolling SR нужны outcome)
    if with_crypto_features:
        df = _add_crypto_features_if_needed(df, ticker)

    # 8. MTF контекст
    if with_mtf:
        try:
            from features.mtf_context import add_mtf_context
            kwargs = {}
            if shift_mtf is not None:
                kwargs['shift_mtf'] = shift_mtf
            df = add_mtf_context(ticker, df, **kwargs)
        except Exception as e:
            print(f'    [WARN] MTF контекст недоступен: {e}')

    # 9. Winsorization с сохранёнными bounds из train (как в monitor.py)
    if winsor_bounds is not None:
        try:
            from data.cleaner import winsorize_features
            df, _ = winsorize_features(df, bounds=winsor_bounds)
        except ImportError:
            pass

    # 10. Финальная очистка
    # Заполняем SMA200/EMA200 из первого валидного значения
    # (нужны ~200 строк до первого валидного — это вспомогательные колонки
    # для режимного фильтра, удаление строк из-за них выкидывает ~40% данных).
    for _col in ['sma_200', 'ema_200']:
        if _col in df.columns and df[_col].isna().any():
            df[_col] = df[_col].bfill().ffill()
    # dropna ТОЛЬКО по core-колонкам (исключая заполненные sma_200/ema_200)
    core_cols = [c for c in df.columns if c not in ('sma_200', 'ema_200')]
    df = df.dropna(subset=core_cols)
    if len(df) < min_rows:
        return None

    if reset_index:
        df = df.reset_index(drop=True)

    return df
