"""
test_couche7_integration.py — Phase C : tests d'intégration Couche 7
======================================================================
Vérifie que:
  1. Le module market_context s'importe correctement depuis market_spy et ai_predictor
  2. Le singleton MarketContextDetector est thread-safe et retourne des résultats cohérents
  3. En shadow mode, la décision du bot n'est jamais modifiée (block_long_entries ignoré)
  4. log_couche7_decision écrit bien un JSONL lisible
  5. get_combined_signal retourne des valeurs valides pour toutes les combinaisons
  6. _get_token_context_c7 dans AIPredictor ne lève jamais d'exception

Usage :
    python test_couche7_integration.py
"""

import json
import logging
import os
import sys
import threading
import time
import tempfile
from typing import Optional
from unittest.mock import patch, MagicMock

# ── Logger ──────────────────────────────────────────────────────────────────
logging.basicConfig(
    level=logging.INFO,
    format='%(levelname)s | %(name)s | %(message)s',
)
logger = logging.getLogger('TestC7Integration')

PASS = '✅'
FAIL = '❌'
_failures = []


def _ok(name: str, msg: str = '') -> None:
    logger.info(f"{PASS} {name}" + (f" — {msg}" if msg else ''))


def _fail(name: str, msg: str) -> None:
    logger.error(f"{FAIL} {name} — {msg}")
    _failures.append((name, msg))


# ════════════════════════════════════════════════════════════════════════════
# Helpers — fixture klines
# ════════════════════════════════════════════════════════════════════════════

def _make_phoenix_klines(n: int = 90) -> list:
    """Klines synthétiques : Phoenix (rebond depuis ATL récent)."""
    import time as _t
    rng_seed = 42
    import random
    r = random.Random(rng_seed)
    now_ms = int(_t.time() * 1000)
    day_ms = 86_400_000
    klines = []
    base = 0.010  # départ bas (ATL)
    for i in range(n):
        ts = now_ms - (n - i) * day_ms
        price = base * (1 + 0.02 * i + r.gauss(0, 0.01))  # trend +2%/j avec bruit
        vol = 1_000_000 * (1 + 0.005 * i + r.gauss(0, 0.05))
        open_p = price * (1 + r.gauss(0, 0.005))
        high_p = price * (1 + abs(r.gauss(0, 0.01)))
        low_p  = price * (1 - abs(r.gauss(0, 0.01)))
        close_p = price
        klines.append([ts, str(open_p), str(high_p), str(low_p), str(close_p),
                        str(vol), ts + day_ms - 1, str(vol * price),
                        500, str(vol * 0.6), str(vol * 0.6 * price), '0'])
    return klines


# ════════════════════════════════════════════════════════════════════════════
# Test 1 — Imports Couche 7
# ════════════════════════════════════════════════════════════════════════════

def test_imports():
    name = 'Imports market_context'
    try:
        from market_context import (
            get_market_context_detector,
            get_combined_signal,
            log_couche7_decision,
            COUCHE7_ENABLED,
            COUCHE7_SHADOW_MODE,
            MACRO_CONTEXT_MATRIX,
            MarketContextDetector,
        )
        assert not COUCHE7_ENABLED, 'COUCHE7_ENABLED doit être False par défaut'
        assert COUCHE7_SHADOW_MODE, 'COUCHE7_SHADOW_MODE doit être True par défaut'
        assert isinstance(MACRO_CONTEXT_MATRIX, dict), 'MACRO_CONTEXT_MATRIX doit être un dict'
        assert len(MACRO_CONTEXT_MATRIX) == 6, f'6 macro regimes attendus, got {len(MACRO_CONTEXT_MATRIX)}'
        _ok(name, f'COUCHE7_ENABLED={COUCHE7_ENABLED}, SHADOW={COUCHE7_SHADOW_MODE}')
    except Exception as e:
        _fail(name, str(e))


# ════════════════════════════════════════════════════════════════════════════
# Test 2 — Singleton thread-safe
# ════════════════════════════════════════════════════════════════════════════

def test_singleton_thread_safe():
    name = 'Singleton thread-safe'
    try:
        from market_context import get_market_context_detector, MarketContextDetector
        instances = []
        errors = []

        def get_instance():
            try:
                instances.append(id(get_market_context_detector()))
            except Exception as e:
                errors.append(str(e))

        threads = [threading.Thread(target=get_instance) for _ in range(20)]
        for t in threads: t.start()
        for t in threads: t.join()

        if errors:
            _fail(name, f'Erreurs dans threads: {errors}')
            return
        unique_ids = set(instances)
        if len(unique_ids) != 1:
            _fail(name, f'{len(unique_ids)} instances différentes créées (attendu: 1)')
        else:
            _ok(name, f'20 threads → 1 seule instance (id={next(iter(unique_ids))})')
    except Exception as e:
        _fail(name, str(e))


# ════════════════════════════════════════════════════════════════════════════
# Test 3 — get_context avec mock fetch (cache hit puis miss)
# ════════════════════════════════════════════════════════════════════════════

def test_get_context_with_mock():
    name = 'get_context avec mock fetch'
    try:
        from market_context import MarketContextDetector, detect_market_context

        klines = _make_phoenix_klines(90)

        with patch('market_context.fetch_daily_klines', return_value=klines):
            det = MarketContextDetector()  # instance fraîche (pas le singleton global)
            result = det.get_context('TESTUSDT')

        if result is None:
            _fail(name, 'get_context a retourné None')
            return

        assert result.regime in ('PHOENIX', 'BULL_RUN', 'PARABOLIC_EXTENSION',
                                  'PUMP_TERMINAL', 'CONSOLIDATION', 'BEAR_RANGE'), \
            f'Régime inattendu: {result.regime}'
        assert 0.0 <= result.confidence <= 1.0, f'Confidence hors range: {result.confidence}'
        _ok(name, f'régime={result.regime} conf={result.confidence:.2f}')
    except Exception as e:
        _fail(name, str(e))


# ════════════════════════════════════════════════════════════════════════════
# Test 4 — Shadow mode : block_long_entries jamais appliqué
# ════════════════════════════════════════════════════════════════════════════

def test_shadow_mode_no_block():
    name = 'Shadow mode — block_long_entries ignoré'
    try:
        import market_context as mc
        original_enabled = mc.COUCHE7_ENABLED
        original_shadow = mc.COUCHE7_SHADOW_MODE

        # Forcer shadow mode (état par défaut)
        mc.COUCHE7_ENABLED = False
        mc.COUCHE7_SHADOW_MODE = True

        # Simuler une ContextResult avec block_long_entries=True
        from market_context import ContextResult, ContextMetrics

        # Un régime PUMP_TERMINAL a block_long_entries=True dans ses implications
        mock_ctx = MagicMock()
        mock_ctx.regime = 'PUMP_TERMINAL'
        mock_ctx.confidence = 0.85
        mock_ctx.implications = {'block_long_entries': True, 'rsi_overbought_threshold': 65}
        mock_ctx.notes = []

        # En shadow mode, même si le signal C7 dit AVOID, la décision bot ne change pas
        # On vérifie juste que le flag n'est pas lu pour bloquer
        confirmed_before = True  # simuler un signal confirmé par le bot
        
        if mc.COUCHE7_SHADOW_MODE:
            # Le shadow mode ne bloque jamais
            confirmed_after = confirmed_before
        else:
            # Déploiement réel (futur) : lirait block_long_entries
            confirmed_after = not mock_ctx.implications.get('block_long_entries', False)

        assert confirmed_after == True, f'Shadow mode a bloqué une entrée (confirmed={confirmed_after})'
        _ok(name, 'PUMP_TERMINAL en shadow → confirmed non modifié')

        # Restaurer
        mc.COUCHE7_ENABLED = original_enabled
        mc.COUCHE7_SHADOW_MODE = original_shadow
    except Exception as e:
        _fail(name, str(e))


# ════════════════════════════════════════════════════════════════════════════
# Test 5 — log_couche7_decision écrit un JSONL valide
# ════════════════════════════════════════════════════════════════════════════

def test_jsonl_logger():
    name = 'log_couche7_decision JSONL'
    try:
        from market_context import log_couche7_decision, COUCHE7_LOG_FILE
        import market_context as mc

        with tempfile.NamedTemporaryFile(mode='w', suffix='.log', delete=False) as f:
            tmp_path = f.name

        original_log_file = mc.COUCHE7_LOG_FILE
        original_log_enabled = mc.COUCHE7_LOG_ENABLED
        mc.COUCHE7_LOG_FILE = tmp_path
        mc.COUCHE7_LOG_ENABLED = True

        try:
            # Créer un contexte mock
            mock_ctx = MagicMock()
            mock_ctx.regime = 'PHOENIX'
            mock_ctx.confidence = 0.95
            mock_ctx.implications = {'rsi_overbought_threshold': 80, 'block_long_entries': False}
            mock_ctx.notes = ['Phoenix confirmé']

            # On doit patcher asdict car mock_ctx.metrics n'est pas un dataclass
            with patch('market_context.asdict', return_value={'price': 0.045, 'trend_30d': 0.38}):
                log_couche7_decision(
                    symbol='SAGAUSDT',
                    context=mock_ctx,
                    bot_decision='BUY',
                    reason='surge_confirmed',
                    extra={'surge_type': 'FLASH', 'surge_strength': 3.5},
                )

            # Lire et valider
            with open(tmp_path, 'r', encoding='utf-8') as f:
                lines = f.readlines()

            assert len(lines) == 1, f'Attendu 1 ligne JSONL, got {len(lines)}'
            entry = json.loads(lines[0])
            assert entry['symbol'] == 'SAGAUSDT'
            assert entry['bot_decision'] == 'BUY'
            assert entry['couche7_regime'] == 'PHOENIX'
            assert entry['couche7_confidence'] == 0.95
            assert entry['shadow_mode'] is True
            _ok(name, f'JSONL écrit et valide ({len(lines)} ligne(s))')
        finally:
            mc.COUCHE7_LOG_FILE = original_log_file
            mc.COUCHE7_LOG_ENABLED = original_log_enabled
            os.unlink(tmp_path)
    except Exception as e:
        _fail(name, str(e))


# ════════════════════════════════════════════════════════════════════════════
# Test 6 — get_combined_signal couvre toutes les combinaisons
# ════════════════════════════════════════════════════════════════════════════

def test_combined_signal_matrix():
    name = 'MACRO_CONTEXT_MATRIX — couverture complète'
    try:
        from market_context import get_combined_signal, MACRO_CONTEXT_MATRIX

        MACRO_REGIMES = list(MACRO_CONTEXT_MATRIX.keys())
        TOKEN_REGIMES = ['PHOENIX', 'BULL_RUN', 'PARABOLIC_EXTENSION', 'PUMP_TERMINAL',
                         'CONSOLIDATION', 'BEAR_RANGE']
        VALID_SIGNALS = {'LONG_PLUS', 'LONG', 'WATCH', 'AVOID'}

        errors = []
        for macro in MACRO_REGIMES:
            for token in TOKEN_REGIMES:
                sig = get_combined_signal(macro, token)
                if sig not in VALID_SIGNALS:
                    errors.append(f'{macro}×{token}→{sig}')

        # Test fail-safe avec régime inconnu
        sig_unknown = get_combined_signal('UNKNOWN_REGIME', 'PHOENIX')
        assert sig_unknown == 'LONG', f'Fail-safe attendu LONG, got {sig_unknown}'

        if errors:
            _fail(name, f'{len(errors)} combinaisons invalides: {errors[:3]}')
        else:
            n = len(MACRO_REGIMES) * len(TOKEN_REGIMES)
            _ok(name, f'{n} combinaisons testées, toutes dans {VALID_SIGNALS}; fail-safe OK')
    except Exception as e:
        _fail(name, str(e))


# ════════════════════════════════════════════════════════════════════════════
# Test 7 — _get_token_context_c7 dans AIPredictor (ne lève jamais)
# ════════════════════════════════════════════════════════════════════════════

def test_ai_predictor_c7_helper():
    name = 'AIPredictor._get_token_context_c7 fail-safe'
    try:
        import ast
        with open('ai_predictor.py', 'r') as f:
            src = f.read()
        # Vérifier la présence du helper
        assert '_get_token_context_c7' in src, 'Helper _get_token_context_c7 manquant dans ai_predictor.py'
        assert 'COUCHE7_AVAILABLE' in src, 'COUCHE7_AVAILABLE manquant dans ai_predictor.py'
        assert 'c7_regime' in src, "Stockage 'c7_regime' dans features manquant"
        _ok(name, 'Helper et intégration features présents dans ai_predictor.py')
    except Exception as e:
        _fail(name, str(e))


# ════════════════════════════════════════════════════════════════════════════
# Test 8 — market_spy.py intégration Couche 7
# ════════════════════════════════════════════════════════════════════════════

def test_market_spy_c7_integration():
    name = 'market_spy.py — intégration Couche 7'
    try:
        with open('market_spy.py', 'r') as f:
            src = f.read()
        checks = [
            ('_COUCHE7_AVAILABLE', 'flag _COUCHE7_AVAILABLE'),
            ('from market_context import', 'import market_context'),
            ('couche7_detector', 'clé couche7_detector dans ctx'),
            ('log_couche7_decision', 'appel log_couche7_decision'),
            ('COUCHE7_SHADOW_MODE', 'référence COUCHE7_SHADOW_MODE'),
            ('get_combined_signal', 'appel get_combined_signal'),
            ('[C7] shadow error', 'bloc try/except shadow error'),
        ]
        missing = []
        for token, desc in checks:
            if token not in src:
                missing.append(desc)
        if missing:
            _fail(name, f'Éléments manquants: {missing}')
        else:
            _ok(name, f'{len(checks)} vérifications OK')
    except Exception as e:
        _fail(name, str(e))


# ════════════════════════════════════════════════════════════════════════════
# Test 9 — Cache TTL 1h : résultat frais servi depuis cache
# ════════════════════════════════════════════════════════════════════════════

def test_cache_ttl():
    name = 'Cache TTL — résultat servi depuis cache'
    try:
        from market_context import MarketContextDetector

        klines = _make_phoenix_klines(90)
        call_count = {'n': 0}

        def mock_fetch(symbol, limit=180):
            call_count['n'] += 1
            return klines

        with patch('market_context.fetch_daily_klines', side_effect=mock_fetch):
            det = MarketContextDetector()
            r1 = det.get_context('CACHETEST')
            r2 = det.get_context('CACHETEST')  # doit venir du cache
            r3 = det.get_context('CACHETEST', force_refresh=True)  # force API call

        assert call_count['n'] == 4, (
            f'Attendu 4 appels API (2 pour initial: resolve+fetch, 0 cache, 2 force_refresh), '
            f'got {call_count["n"]}'
        )
        assert r1 is not None and r2 is not None and r3 is not None
        assert r1.regime == r2.regime == r3.regime
        _ok(name, f'{call_count["n"]} appels API pour 3 get_context (cache fonctionnel)')
    except Exception as e:
        _fail(name, str(e))


# ════════════════════════════════════════════════════════════════════════════
# Test 10 — get_context avec données insuffisantes retourne None (fail-safe)
# ════════════════════════════════════════════════════════════════════════════

def test_get_context_insufficient_data():
    name = 'get_context — données insuffisantes → None'
    try:
        from market_context import MarketContextDetector

        with patch('market_context.fetch_daily_klines', return_value=[]):
            det = MarketContextDetector()
            result = det.get_context('EMPTYSYM')

        assert result is None, f'Attendu None, got {result}'
        _ok(name, 'None retourné sans exception quand données insuffisantes')
    except Exception as e:
        _fail(name, str(e))


# ════════════════════════════════════════════════════════════════════════════
# Runner
# ════════════════════════════════════════════════════════════════════════════

if __name__ == '__main__':
    logger.info('=' * 70)
    logger.info('COUCHE 7 — TESTS D\'INTÉGRATION (Phase C)')
    logger.info('=' * 70)

    tests = [
        test_imports,
        test_singleton_thread_safe,
        test_get_context_with_mock,
        test_shadow_mode_no_block,
        test_jsonl_logger,
        test_combined_signal_matrix,
        test_ai_predictor_c7_helper,
        test_market_spy_c7_integration,
        test_cache_ttl,
        test_get_context_insufficient_data,
    ]

    for t in tests:
        try:
            t()
        except Exception as e:
            _fail(t.__name__, f'Exception non gérée: {e}')

    logger.info('')
    if _failures:
        logger.error(f'❌ {len(_failures)}/{len(tests)} tests échoués:')
        for name, msg in _failures:
            logger.error(f'   • {name}: {msg}')
        sys.exit(1)
    else:
        logger.info(f'✅ All {len(tests)} integration tests passed')
        sys.exit(0)
