"""Bias detection and prevention for autonomous fund decisions.

Catches the 5 ways an LLM can overfit when managing a fund:
1. Backtest overfitting (deflated Sharpe, PBO gate)
2. Recency bias (memory confidence decay)
3. Confirmation bias (repeated similar decisions)
4. Performance chasing (mean-reversion detection)
5. Small-sample learning (minimum observation requirements)
"""

from __future__ import annotations

import logging
import math
import threading
import time
from collections import deque
from dataclasses import dataclass, field
from typing import Any

logger = logging.getLogger("horizon.fund.bias_guard")


@dataclass(frozen=True)
class BiasAlert:
    """A detected bias in decision-making."""
    bias_type: str
    severity: str  # "info", "warning", "block"
    message: str
    details: dict[str, Any] = field(default_factory=dict)
    timestamp: float = 0.0


@dataclass
class BiasGuardConfig:
    """Configuration for bias detection."""
    # Backtest overfitting
    max_pbo: float = 0.50  # Max probability of backtest overfitting
    min_deflated_sharpe: float = 1.0  # Deflated Sharpe accounting for multiple trials
    min_oos_sharpe: float = 0.5  # Minimum out-of-sample Sharpe ratio
    require_walk_forward: bool = True

    # Recency bias
    memory_half_life_days: float = 90.0  # Confidence halves every N days
    min_memory_age_days: float = 7.0  # Ignore memories younger than this for pattern conclusions

    # Confirmation bias
    max_similar_deploys: int = 3  # Max strategies of same type in a window
    similarity_window_secs: float = 86400.0  # 24h window for similarity check
    similarity_threshold: float = 0.8  # How similar two decisions need to be to count

    # Performance chasing
    min_performance_history_days: float = 14.0  # Need 2 weeks before scaling up
    max_recent_return_for_scaleup: float = 0.10  # If >10% in last 3 days, suspect chasing

    # Small-sample learning
    min_trades_for_pattern: int = 30  # Min trades before concluding a pattern
    min_strategies_for_regime: int = 3  # Min strategy deployments before regime conclusions
    min_observations_for_memory: int = 5  # Min data points before recording a pattern


class BiasGuard:
    """Detects and prevents cognitive biases in autonomous fund decisions.

    Sits between the AutonomousAgent and the DecisionFramework. Every
    proposed action is screened for bias before the decision framework
    evaluates confidence and rate limits.

    Usage::

        guard = BiasGuard()

        # Before deploying, check for overfitting
        alerts = guard.check_deploy(candidate, backtest_result, cpcv_result)
        if guard.should_block(alerts):
            # Reject the deployment
            ...

        # Before scaling up, check for chasing
        alerts = guard.check_scale_up(strategy_name, recent_returns)
        ...

        # Decay memory confidence over time
        decayed = guard.decay_confidence(original_confidence, age_days)
    """

    def __init__(self, config: BiasGuardConfig | None = None) -> None:
        self._config = config or BiasGuardConfig()
        self._deploy_history: deque[dict[str, Any]] = deque(maxlen=500)
        self._alerts: deque[BiasAlert] = deque(maxlen=10_000)
        self._lock = threading.Lock()

    @property
    def config(self) -> BiasGuardConfig:
        return self._config

    # ------------------------------------------------------------------
    # 1. Backtest overfitting detection
    # ------------------------------------------------------------------

    def check_deploy(
        self,
        strategy_type: str,
        market_id: str,
        backtest_sharpe: float,
        n_trades: int,
        pbo: float | None = None,
        deflated_sharpe: float | None = None,
        oos_sharpe: float | None = None,
        n_trials: int = 1,
    ) -> list[BiasAlert]:
        """Check a deployment candidate for overfitting signals.

        Args:
            strategy_type: Type of strategy being deployed.
            market_id: Target market.
            backtest_sharpe: In-sample Sharpe ratio.
            n_trades: Number of trades in backtest.
            pbo: Probability of backtest overfitting (from CPCV).
            deflated_sharpe: Sharpe adjusted for multiple testing.
            oos_sharpe: Out-of-sample Sharpe ratio.
            n_trials: Number of strategy variations tested (for multiple testing correction).

        Returns:
            List of BiasAlerts (empty = no bias detected).
        """
        now = time.time()
        alerts: list[BiasAlert] = []

        # PBO check
        if pbo is not None and pbo > self._config.max_pbo:
            alerts.append(BiasAlert(
                bias_type="backtest_overfitting",
                severity="block",
                message=f"Probability of overfitting {pbo:.2f} exceeds limit {self._config.max_pbo:.2f}",
                details={"pbo": pbo, "limit": self._config.max_pbo},
                timestamp=now,
            ))

        # Deflated Sharpe check
        if deflated_sharpe is not None and deflated_sharpe < self._config.min_deflated_sharpe:
            alerts.append(BiasAlert(
                bias_type="backtest_overfitting",
                severity="block",
                message=(
                    f"Deflated Sharpe {deflated_sharpe:.2f} below minimum "
                    f"{self._config.min_deflated_sharpe:.2f} after adjusting for {n_trials} trials"
                ),
                details={
                    "deflated_sharpe": deflated_sharpe,
                    "raw_sharpe": backtest_sharpe,
                    "n_trials": n_trials,
                },
                timestamp=now,
            ))

        # In-sample vs out-of-sample divergence
        if oos_sharpe is not None:
            if oos_sharpe < self._config.min_oos_sharpe:
                alerts.append(BiasAlert(
                    bias_type="backtest_overfitting",
                    severity="block",
                    message=f"Out-of-sample Sharpe {oos_sharpe:.2f} below minimum {self._config.min_oos_sharpe:.2f}",
                    details={"oos_sharpe": oos_sharpe, "is_sharpe": backtest_sharpe},
                    timestamp=now,
                ))
            if backtest_sharpe > 0 and oos_sharpe / backtest_sharpe < 0.5:
                alerts.append(BiasAlert(
                    bias_type="sharpe_degradation",
                    severity="warning",
                    message=(
                        f"OOS Sharpe ({oos_sharpe:.2f}) is less than half of "
                        f"IS Sharpe ({backtest_sharpe:.2f}) - likely overfit"
                    ),
                    details={
                        "ratio": oos_sharpe / backtest_sharpe,
                        "is_sharpe": backtest_sharpe,
                        "oos_sharpe": oos_sharpe,
                    },
                    timestamp=now,
                ))

        # Small sample
        if n_trades < self._config.min_trades_for_pattern:
            alerts.append(BiasAlert(
                bias_type="small_sample",
                severity="warning",
                message=f"Only {n_trades} trades in backtest (minimum {self._config.min_trades_for_pattern})",
                details={"n_trades": n_trades, "minimum": self._config.min_trades_for_pattern},
                timestamp=now,
            ))

        # Confirmation bias: too many similar deploys recently
        with self._lock:
            total_deployed = len([
                d for d in self._deploy_history if not d.get("blocked", False)
            ])
        conf_alerts = self._check_confirmation_bias(
            strategy_type, market_id, now, total_strategies=total_deployed,
        )
        alerts.extend(conf_alerts)

        # Record this deploy attempt
        with self._lock:
            self._deploy_history.append({
                "strategy_type": strategy_type,
                "market_id": market_id,
                "timestamp": now,
                "sharpe": backtest_sharpe,
                "blocked": any(a.severity == "block" for a in alerts),
            })

        self._alerts.extend(alerts)
        return alerts

    # ------------------------------------------------------------------
    # 2. Recency bias: memory confidence decay
    # ------------------------------------------------------------------

    def decay_confidence(self, original_confidence: float, age_days: float) -> float:
        """Apply exponential decay to memory confidence based on age.

        Memories get less reliable over time. A 90-day half-life means
        a confidence of 0.8 becomes 0.4 after 90 days, 0.2 after 180 days.

        Args:
            original_confidence: The confidence when the memory was recorded.
            age_days: How old the memory is in days.

        Returns:
            Decayed confidence value.
        """
        if age_days <= 0:
            return original_confidence
        half_life = self._config.memory_half_life_days
        if half_life <= 0:
            return original_confidence
        decay_factor = math.pow(0.5, age_days / half_life)
        return original_confidence * decay_factor

    def is_memory_mature(self, age_days: float) -> bool:
        """Check if a memory is old enough to draw conclusions from.

        Prevents acting on patterns observed just hours ago.
        """
        return age_days >= self._config.min_memory_age_days

    # ------------------------------------------------------------------
    # 3. Confirmation bias: repeated similar decisions
    # ------------------------------------------------------------------

    def _check_confirmation_bias(
        self,
        strategy_type: str,
        market_id: str,
        now: float,
        total_strategies: int = 0,
    ) -> list[BiasAlert]:
        """Detect if the LLM keeps deploying the same thing."""
        alerts: list[BiasAlert] = []
        window = self._config.similarity_window_secs

        with self._lock:
            recent = [
                d for d in self._deploy_history
                if now - d["timestamp"] < window and not d.get("blocked", False)
            ]

        # Scale max_similar_deploys by fund size
        effective_max = max(
            self._config.max_similar_deploys,
            total_strategies // 10 + 1,
        )

        # Count same strategy type
        same_type = [d for d in recent if d["strategy_type"] == strategy_type]
        if len(same_type) >= effective_max:
            alerts.append(BiasAlert(
                bias_type="confirmation_bias",
                severity="block",
                message=(
                    f"Already deployed {len(same_type)} '{strategy_type}' strategies "
                    f"in the last {window / 3600:.0f}h (limit: {effective_max})"
                ),
                details={
                    "strategy_type": strategy_type,
                    "count": len(same_type),
                    "limit": effective_max,
                    "base_limit": self._config.max_similar_deploys,
                    "total_strategies": total_strategies,
                    "window_hours": window / 3600,
                },
                timestamp=now,
            ))

        # Count same market
        same_market = [d for d in recent if d["market_id"] == market_id]
        if len(same_market) >= 2:
            alerts.append(BiasAlert(
                bias_type="confirmation_bias",
                severity="warning",
                message=f"Already deployed {len(same_market)} strategies on market '{market_id}' recently",
                details={"market_id": market_id, "count": len(same_market)},
                timestamp=now,
            ))

        return alerts

    # ------------------------------------------------------------------
    # 4. Performance chasing detection
    # ------------------------------------------------------------------

    def check_scale_up(
        self,
        strategy_name: str,
        daily_returns: list[float],
        age_days: float,
    ) -> list[BiasAlert]:
        """Check if a scale-up decision is chasing recent performance.

        Args:
            strategy_name: Name of the strategy being scaled.
            daily_returns: Recent daily return series.
            age_days: How long the strategy has been running.

        Returns:
            List of BiasAlerts.
        """
        now = time.time()
        alerts: list[BiasAlert] = []

        # Too young to scale
        if age_days < self._config.min_performance_history_days:
            alerts.append(BiasAlert(
                bias_type="performance_chasing",
                severity="block",
                message=(
                    f"Strategy '{strategy_name}' is only {age_days:.0f} days old "
                    f"(minimum {self._config.min_performance_history_days:.0f} days before scaling)"
                ),
                details={
                    "age_days": age_days,
                    "minimum_days": self._config.min_performance_history_days,
                },
                timestamp=now,
            ))

        # Check for hot-hand bias: recent returns too good (likely mean-reverting)
        if len(daily_returns) >= 3:
            recent_3d = sum(daily_returns[-3:])
            if recent_3d > self._config.max_recent_return_for_scaleup:
                alerts.append(BiasAlert(
                    bias_type="performance_chasing",
                    severity="warning",
                    message=(
                        f"Strategy '{strategy_name}' gained {recent_3d:.1%} in last 3 days - "
                        f"likely mean-reverting, not a signal to scale up"
                    ),
                    details={
                        "recent_3d_return": recent_3d,
                        "threshold": self._config.max_recent_return_for_scaleup,
                    },
                    timestamp=now,
                ))

        # Check for insufficient sample: not enough daily returns
        if len(daily_returns) < 10:
            alerts.append(BiasAlert(
                bias_type="small_sample",
                severity="warning",
                message=f"Only {len(daily_returns)} daily returns for '{strategy_name}' - insufficient for scaling decision",
                details={"n_returns": len(daily_returns)},
                timestamp=now,
            ))

        self._alerts.extend(alerts)
        return alerts

    # ------------------------------------------------------------------
    # 5. Small-sample learning prevention
    # ------------------------------------------------------------------

    def check_memory_record(
        self,
        category: str,
        n_observations: int,
        confidence: float,
    ) -> list[BiasAlert]:
        """Check if there's enough data to record a pattern.

        Prevents the LLM from recording "political markets are great"
        from 2 trades.

        Args:
            category: Memory category being recorded.
            n_observations: Number of data points behind this conclusion.
            confidence: Proposed confidence level.

        Returns:
            List of BiasAlerts.
        """
        now = time.time()
        alerts: list[BiasAlert] = []

        min_obs = self._config.min_observations_for_memory
        if category == "strategy_outcome":
            min_obs = self._config.min_trades_for_pattern
        elif category == "regime_mapping":
            min_obs = self._config.min_strategies_for_regime

        if n_observations < min_obs:
            # Cap confidence proportionally to how far below the threshold
            capped = confidence * (n_observations / min_obs)
            alerts.append(BiasAlert(
                bias_type="small_sample",
                severity="warning",
                message=(
                    f"Only {n_observations}/{min_obs} observations for '{category}' - "
                    f"confidence capped from {confidence:.2f} to {capped:.2f}"
                ),
                details={
                    "n_observations": n_observations,
                    "minimum": min_obs,
                    "original_confidence": confidence,
                    "capped_confidence": capped,
                },
                timestamp=now,
            ))

        self._alerts.extend(alerts)
        return alerts

    def cap_confidence_by_sample(
        self,
        confidence: float,
        n_observations: int,
        category: str = "general",
    ) -> float:
        """Cap confidence based on sample size.

        Returns the lower of the proposed confidence and what the
        sample size supports.
        """
        min_obs = self._config.min_observations_for_memory
        if category == "strategy_outcome":
            min_obs = self._config.min_trades_for_pattern
        elif category == "regime_mapping":
            min_obs = self._config.min_strategies_for_regime

        if n_observations >= min_obs:
            return confidence

        return confidence * (n_observations / max(min_obs, 1))

    # ------------------------------------------------------------------
    # Utility
    # ------------------------------------------------------------------

    @staticmethod
    def should_block(alerts: list[BiasAlert]) -> bool:
        """Check if any alerts are blocking severity."""
        return any(a.severity == "block" for a in alerts)

    def recent_alerts(self, limit: int = 50) -> list[BiasAlert]:
        return list(self._alerts)[-limit:]

    def alerts_by_type(self) -> dict[str, int]:
        """Count alerts by bias type."""
        counts: dict[str, int] = {}
        for a in self._alerts:
            counts[a.bias_type] = counts.get(a.bias_type, 0) + 1
        return counts

    def stats(self) -> dict[str, Any]:
        """Return bias guard statistics."""
        with self._lock:
            total_deploys = len(self._deploy_history)
            blocked = sum(1 for d in self._deploy_history if d.get("blocked", False))
        return {
            "total_deploy_attempts": total_deploys,
            "blocked_deploys": blocked,
            "block_rate": blocked / max(total_deploys, 1),
            "total_alerts": len(self._alerts),
            "alerts_by_type": self.alerts_by_type(),
        }

    def reset(self) -> None:
        """Reset all tracking state."""
        with self._lock:
            self._deploy_history.clear()
        self._alerts.clear()
