"""Market fragility score — composite indicator for systemic risk.

Combines crowding metrics, regime state, liquidity indicators,
volatility surface shape, and correlation clustering into a single
actionable fragility score (0-100).

All functions are pure: data in, results out. No DB access, no API calls.

Usage::

    from cpz.risk.fragility import fragility_score

    result = fragility_score(
        crowding=45.0,
        regime_score=60.0,
        liquidity_score=30.0,
        vol_surface_score=55.0,
        correlation_score=40.0,
    )
"""

from __future__ import annotations

import math
from typing import Any, Dict, List, Optional

from pydantic import BaseModel, Field

import numpy as np


# ── Models ────────────────────────────────────────────────────────────


class FragilityResult(BaseModel):
    """Market fragility composite score."""
    score: float = 0.0
    level: str = "low"  # low, moderate, elevated, high, extreme
    components: Dict[str, float] = Field(default_factory=dict)
    weights: Dict[str, float] = Field(default_factory=dict)
    description: str = ""


class CorrelationClusterResult(BaseModel):
    """Are assets moving together more than usual?"""
    average_correlation: float = 0.0
    max_correlation: float = 0.0
    pct_above_threshold: float = 0.0
    cluster_score: float = 0.0  # 0-100
    regime: str = "normal"  # normal, elevated, crisis


class RegimeResult(BaseModel):
    """Market regime classification."""
    regime: str = "expansion"  # expansion, contraction, crisis, recovery
    confidence: float = 0.0
    regime_score: float = 0.0  # 0-100 (higher = more fragile)
    volatility_regime: str = "low"  # low, normal, high, extreme


# ── Default weights for fragility components ─────────────────────────

DEFAULT_WEIGHTS = {
    "crowding": 0.25,
    "regime": 0.25,
    "liquidity": 0.20,
    "vol_surface": 0.15,
    "correlation": 0.15,
}


# ── Public API ────────────────────────────────────────────────────────


def fragility_score(
    crowding: float = 0.0,
    regime_score: float = 0.0,
    liquidity_score: float = 0.0,
    vol_surface_score: float = 0.0,
    correlation_score: float = 0.0,
    weights: Optional[Dict[str, float]] = None,
) -> FragilityResult:
    """Compute composite market fragility score.

    Each input is a 0-100 score for that component (higher = more fragile).

    Args:
        crowding: Crowding metric (from 13F/options/short interest analysis).
        regime_score: Regime fragility (convergence = higher fragility).
        liquidity_score: Liquidity stress (low liquidity = higher).
        vol_surface_score: Volatility surface stress (steep skew = higher).
        correlation_score: Correlation clustering (assets moving together = higher).
        weights: Custom component weights (default: equal-ish weighting).

    Returns:
        FragilityResult with composite score, level, and component breakdown.
    """
    w = weights or DEFAULT_WEIGHTS

    components = {
        "crowding": max(0, min(100, crowding)),
        "regime": max(0, min(100, regime_score)),
        "liquidity": max(0, min(100, liquidity_score)),
        "vol_surface": max(0, min(100, vol_surface_score)),
        "correlation": max(0, min(100, correlation_score)),
    }

    score = sum(components[k] * w.get(k, 0.2) for k in components)
    score = max(0, min(100, score))

    if score >= 80:
        level = "extreme"
        desc = "Markets are extremely fragile. High risk of cascading sell-offs."
    elif score >= 60:
        level = "high"
        desc = "Elevated fragility. Monitor positions closely and consider hedging."
    elif score >= 40:
        level = "elevated"
        desc = "Moderate fragility. Some risk factors are elevated."
    elif score >= 20:
        level = "moderate"
        desc = "Normal market conditions with some caution warranted."
    else:
        level = "low"
        desc = "Markets appear stable. Normal risk conditions."

    return FragilityResult(
        score=round(score, 1),
        level=level,
        components=components,
        weights=w,
        description=desc,
    )


def correlation_clustering(
    returns_matrix: Dict[str, List[float]],
    threshold: float = 0.7,
    lookback: Optional[int] = None,
) -> CorrelationClusterResult:
    """Measure whether assets are moving together more than usual.

    High correlation clustering is a leading indicator of market fragility,
    as it suggests herding behavior and reduced diversification.

    Args:
        returns_matrix: Daily returns per asset {symbol: [returns]}.
        threshold: Correlation threshold for "high" (default 0.7).
        lookback: Number of days to use (default: all available).

    Returns:
        CorrelationClusterResult with clustering metrics.
    """
    ids = list(returns_matrix.keys())
    n = len(ids)
    if n < 2:
        return CorrelationClusterResult()

    min_len = min(len(returns_matrix[i]) for i in ids)
    if lookback:
        min_len = min(min_len, lookback)
    if min_len < 10:
        return CorrelationClusterResult()

    R = np.column_stack([
        np.array(returns_matrix[i][-min_len:], dtype=np.float64) for i in ids
    ])
    corr = np.corrcoef(R.T)

    # Extract upper triangle (excluding diagonal)
    upper = []
    for i in range(n):
        for j in range(i + 1, n):
            val = float(corr[i, j])
            if not math.isnan(val):
                upper.append(abs(val))

    if not upper:
        return CorrelationClusterResult()

    avg_corr = float(np.mean(upper))
    max_corr = float(np.max(upper))
    above_threshold = sum(1 for c in upper if c > threshold) / len(upper) * 100

    # Score: 0-100 based on average correlation and clustering
    cluster_score = min(100, avg_corr * 100 + above_threshold * 0.5)

    if cluster_score >= 70:
        regime = "crisis"
    elif cluster_score >= 40:
        regime = "elevated"
    else:
        regime = "normal"

    return CorrelationClusterResult(
        average_correlation=round(avg_corr, 4),
        max_correlation=round(max_corr, 4),
        pct_above_threshold=round(above_threshold, 1),
        cluster_score=round(cluster_score, 1),
        regime=regime,
    )


def regime_from_returns(
    daily_returns: List[float],
    window: int = 60,
) -> RegimeResult:
    """Classify market regime from return statistics.

    Uses volatility, trend, and drawdown to classify the current regime.

    Args:
        daily_returns: Market daily returns.
        window: Lookback window (default 60 days).

    Returns:
        RegimeResult with regime classification.
    """
    r = np.array(daily_returns, dtype=np.float64)
    if len(r) < window:
        return RegimeResult()

    recent = r[-window:]
    vol = float(np.std(recent, ddof=1)) * math.sqrt(252)
    mean_ret = float(np.mean(recent)) * 252
    cum = np.cumprod(1 + recent)
    peak = np.maximum.accumulate(cum)
    dd = float(np.max((peak - cum) / peak))

    # Volatility regime
    if vol > 0.40:
        vol_regime = "extreme"
    elif vol > 0.25:
        vol_regime = "high"
    elif vol > 0.12:
        vol_regime = "normal"
    else:
        vol_regime = "low"

    # Market regime
    if dd > 0.20 and vol > 0.30:
        regime = "crisis"
        confidence = min(0.95, 0.5 + dd + vol)
        regime_score = min(100, dd * 200 + vol * 100)
    elif mean_ret < -0.05 and dd > 0.10:
        regime = "contraction"
        confidence = min(0.90, 0.4 + abs(mean_ret) + dd)
        regime_score = min(100, abs(mean_ret) * 200 + dd * 150)
    elif mean_ret > 0 and dd < 0.10 and vol < 0.20:
        regime = "expansion"
        confidence = min(0.90, 0.5 + mean_ret * 2)
        regime_score = max(0, 30 - mean_ret * 100 + vol * 50)
    else:
        regime = "recovery"
        confidence = 0.50
        regime_score = 40 + vol * 50

    return RegimeResult(
        regime=regime,
        confidence=round(confidence, 2),
        regime_score=round(min(100, max(0, regime_score)), 1),
        volatility_regime=vol_regime,
    )
