"""Tests for agentic fund modules: alerts, ledger, promotion, recovery,
adaptive thresholds, backtest runner, explainability, multi-fund."""

from __future__ import annotations

import math
import tempfile
import time
import types
from pathlib import Path

import pytest


# -----------------------------------------------------------------------
# Helpers
# -----------------------------------------------------------------------

def _tmp_db(tmp_path: Path, name: str = "test.db") -> str:
    return str(tmp_path / name)


def _mock_status(
    name="test",
    pnl=100.0,
    drawdown_pct=5.0,
    uptime_secs=86400 * 15,
    state_val="running",
    open_orders=5,
    active_positions=3,
    allocated_capital=10_000,
    error=None,
    total_fills=100,
):
    from horizon.fund._types import StrategyState
    return types.SimpleNamespace(
        name=name,
        pnl=pnl,
        drawdown_pct=drawdown_pct,
        uptime_secs=uptime_secs,
        state=StrategyState(state_val),
        open_orders=open_orders,
        active_positions=active_positions,
        allocated_capital=allocated_capital,
        current_nav=allocated_capital + pnl,
        error=error,
        total_fills=total_fills,
    )


# =======================================================================
# AlertManager
# =======================================================================

class TestAlerts:
    def test_alert_creation(self):
        from horizon.fund._alerts import Alert, AlertLevel, AlertCategory
        a = Alert(
            level=AlertLevel.WARNING,
            category=AlertCategory.DRAWDOWN_BREACH,
            message="Drawdown 12%",
            timestamp=time.time(),
        )
        assert a.level == AlertLevel.WARNING
        assert a.strategy_name is None

    def test_alert_to_dict(self):
        from horizon.fund._alerts import Alert, AlertLevel, AlertCategory
        a = Alert(
            level=AlertLevel.CRITICAL,
            category=AlertCategory.KILL_SWITCH,
            message="Kill switch",
            timestamp=1000.0,
            strategy_name="mm",
            details={"nav": 95000},
        )
        d = a.to_dict()
        assert d["level"] == "critical"
        assert d["category"] == "kill_switch"
        assert d["strategy_name"] == "mm"
        assert d["details"]["nav"] == 95000

    def test_fire_records_history(self):
        from horizon.fund._alerts import Alert, AlertLevel, AlertCategory, AlertManager
        mgr = AlertManager()
        a = Alert(AlertLevel.INFO, AlertCategory.SETTLEMENT, "Settled", time.time())
        assert mgr.fire(a) is True
        assert len(mgr.recent_alerts()) == 1

    def test_rate_limiting(self):
        from horizon.fund._alerts import Alert, AlertLevel, AlertCategory, AlertManager
        mgr = AlertManager(max_per_category_per_min=3, rate_window_secs=60.0)
        now = time.time()
        fired = 0
        for i in range(10):
            a = Alert(AlertLevel.WARNING, AlertCategory.DRAWDOWN_BREACH, f"alert {i}", now)
            if mgr.fire(a):
                fired += 1
        # Should have rate-limited after 3
        assert fired == 3
        # But all 10 are in history (rate-limited ones too)
        assert len(mgr.recent_alerts()) == 10

    def test_callback_channel(self):
        from horizon.fund._alerts import Alert, AlertLevel, AlertCategory, AlertManager, CallbackChannel
        received = []
        mgr = AlertManager()
        mgr.register_channel(CallbackChannel(lambda a: received.append(a)))
        a = Alert(AlertLevel.INFO, AlertCategory.SETTLEMENT, "test", time.time())
        mgr.fire(a)
        assert len(received) == 1
        assert received[0].message == "test"

    def test_recent_alerts_filter(self):
        from horizon.fund._alerts import Alert, AlertLevel, AlertCategory, AlertManager
        mgr = AlertManager()
        mgr.fire(Alert(AlertLevel.INFO, AlertCategory.SETTLEMENT, "s1", time.time()))
        mgr.fire(Alert(AlertLevel.WARNING, AlertCategory.DRAWDOWN_BREACH, "d1", time.time()))
        mgr.fire(Alert(AlertLevel.INFO, AlertCategory.SETTLEMENT, "s2", time.time()))
        all_alerts = mgr.recent_alerts()
        assert len(all_alerts) == 3
        settlements = mgr.recent_alerts(category=AlertCategory.SETTLEMENT)
        assert len(settlements) == 2

    def test_alert_counts(self):
        from horizon.fund._alerts import Alert, AlertLevel, AlertCategory, AlertManager
        mgr = AlertManager()
        mgr.fire(Alert(AlertLevel.INFO, AlertCategory.SETTLEMENT, "s1", time.time()))
        mgr.fire(Alert(AlertLevel.INFO, AlertCategory.SETTLEMENT, "s2", time.time()))
        mgr.fire(Alert(AlertLevel.WARNING, AlertCategory.KILL_SWITCH, "k1", time.time()))
        counts = mgr.alert_counts()
        assert counts["settlement"] == 2
        assert counts["kill_switch"] == 1

    def test_webhook_channel_rejects_bad_url(self):
        from horizon.fund._alerts import WebhookChannel
        with pytest.raises(ValueError, match="http"):
            WebhookChannel("ftp://bad")


# =======================================================================
# FundLedger
# =======================================================================

class TestLedger:
    def test_deposit(self, tmp_path):
        from horizon.fund._ledger import FundLedger, Account
        ledger = FundLedger(db_path=_tmp_db(tmp_path))
        entry = ledger.deposit(10_000, "initial")
        assert entry.debit_account == Account.CASH
        assert entry.credit_account == Account.REALIZED_PNL
        assert entry.amount == 10_000
        ledger.close()

    def test_withdraw(self, tmp_path):
        from horizon.fund._ledger import FundLedger, Account
        ledger = FundLedger(db_path=_tmp_db(tmp_path))
        entry = ledger.withdraw(5_000, "withdrawal")
        assert entry.debit_account == Account.REALIZED_PNL
        assert entry.credit_account == Account.CASH
        ledger.close()

    def test_trade_pnl_positive(self, tmp_path):
        from horizon.fund._ledger import FundLedger, Account
        ledger = FundLedger(db_path=_tmp_db(tmp_path))
        entry = ledger.record_trade_pnl("mm", 500.0, "fill_1")
        assert entry.debit_account == Account.POSITIONS
        assert entry.credit_account == Account.REALIZED_PNL
        assert entry.amount == 500.0
        ledger.close()

    def test_trade_pnl_negative(self, tmp_path):
        from horizon.fund._ledger import FundLedger, Account
        ledger = FundLedger(db_path=_tmp_db(tmp_path))
        entry = ledger.record_trade_pnl("mm", -200.0, "fill_2")
        assert entry.debit_account == Account.REALIZED_PNL
        assert entry.credit_account == Account.POSITIONS
        assert entry.amount == 200.0
        ledger.close()

    def test_accrue_fee(self, tmp_path):
        from horizon.fund._ledger import FundLedger, Account
        ledger = FundLedger(db_path=_tmp_db(tmp_path))
        e1 = ledger.accrue_fee("management", 50.0, "tick_1")
        assert e1.credit_account == Account.MANAGEMENT_FEES
        e2 = ledger.accrue_fee("performance", 100.0, "tick_2")
        assert e2.credit_account == Account.PERFORMANCE_FEES
        ledger.close()

    def test_balance(self, tmp_path):
        from horizon.fund._ledger import FundLedger, Account
        ledger = FundLedger(db_path=_tmp_db(tmp_path))
        ledger.deposit(10_000, "init")
        # CASH: debit 10k, credit 0 -> balance 10k
        assert ledger.balance(Account.CASH) == 10_000
        # REALIZED_PNL: debit 0, credit 10k -> balance -10k (equity proxy)
        assert ledger.balance(Account.REALIZED_PNL) == -10_000
        ledger.close()

    def test_balance_sheet(self, tmp_path):
        from horizon.fund._ledger import FundLedger
        ledger = FundLedger(db_path=_tmp_db(tmp_path))
        ledger.deposit(10_000, "init")
        ledger.record_trade_pnl("mm", 500, "fill")
        bs = ledger.balance_sheet()
        assert "cash" in bs
        assert "positions" in bs
        assert "realized_pnl" in bs
        ledger.close()

    def test_income_statement(self, tmp_path):
        from horizon.fund._ledger import FundLedger
        ledger = FundLedger(db_path=_tmp_db(tmp_path))
        ledger.record_trade_pnl("mm", 1_000, "fill")
        ledger.accrue_fee("management", 50, "tick")
        stmt = ledger.income_statement()
        assert stmt["realized_pnl"] == 1_000
        assert stmt["management_fees"] == 50
        assert stmt["net_income"] == 950
        ledger.close()

    def test_recent_entries(self, tmp_path):
        from horizon.fund._ledger import FundLedger
        ledger = FundLedger(db_path=_tmp_db(tmp_path))
        ledger.deposit(1_000, "a")
        ledger.deposit(2_000, "b")
        entries = ledger.recent_entries(limit=10)
        assert len(entries) == 2
        assert entries[0].amount == 1_000  # first
        assert entries[1].amount == 2_000  # second
        ledger.close()

    def test_validation_negative_amount(self, tmp_path):
        from horizon.fund._ledger import FundLedger
        ledger = FundLedger(db_path=_tmp_db(tmp_path))
        with pytest.raises(ValueError, match="positive"):
            ledger.deposit(-100, "bad")
        ledger.close()

    def test_validation_zero_amount(self, tmp_path):
        from horizon.fund._ledger import FundLedger
        ledger = FundLedger(db_path=_tmp_db(tmp_path))
        with pytest.raises(ValueError, match="positive"):
            ledger.deposit(0, "bad")
        ledger.close()

    def test_validation_nan(self, tmp_path):
        from horizon.fund._ledger import FundLedger
        ledger = FundLedger(db_path=_tmp_db(tmp_path))
        with pytest.raises(ValueError):
            ledger.deposit(float("nan"), "bad")
        ledger.close()

    def test_entry_count(self, tmp_path):
        from horizon.fund._ledger import FundLedger
        ledger = FundLedger(db_path=_tmp_db(tmp_path))
        assert ledger.entry_count() == 0
        ledger.deposit(1000, "a")
        assert ledger.entry_count() == 1
        ledger.close()

    def test_trade_pnl_too_small(self, tmp_path):
        from horizon.fund._ledger import FundLedger
        ledger = FundLedger(db_path=_tmp_db(tmp_path))
        with pytest.raises(ValueError, match="too small"):
            ledger.record_trade_pnl("mm", 0.0, "tiny")
        ledger.close()


# =======================================================================
# PromotionManager
# =======================================================================

class TestPromotion:
    def test_track_default_paper(self, tmp_path):
        from horizon.fund._promotion import PromotionManager, PromotionStage
        mgr = PromotionManager(db_path=_tmp_db(tmp_path))
        mgr.track("mm")
        assert mgr.stage("mm") == PromotionStage.PAPER
        mgr.close()

    def test_untracked_returns_paper(self, tmp_path):
        from horizon.fund._promotion import PromotionManager, PromotionStage
        mgr = PromotionManager(db_path=_tmp_db(tmp_path))
        assert mgr.stage("unknown") == PromotionStage.PAPER
        mgr.close()

    def test_check_promotion_not_met(self, tmp_path):
        from horizon.fund._promotion import PromotionManager
        mgr = PromotionManager(db_path=_tmp_db(tmp_path))
        mgr.track("mm")
        # Uptime too short (1 day < 14 days)
        status = _mock_status(uptime_secs=86400)
        assert mgr.check_promotion("mm", status) is None
        mgr.close()

    def test_check_promotion_paper_to_shadow(self, tmp_path):
        import time as _time
        from horizon.fund._promotion import PromotionManager, PromotionStage
        mgr = PromotionManager(db_path=_tmp_db(tmp_path))
        mgr.track("mm")
        # Simulate 15 days in the PAPER stage by backdating the stage timestamp
        mgr._stage_timestamps["mm"] = _time.time() - 86400 * 15
        status = _mock_status(
            uptime_secs=86400 * 15,  # 15 days
            pnl=500.0,
            drawdown_pct=10.0,
        )
        result = mgr.check_promotion("mm", status, sharpe=1.5)
        assert result == PromotionStage.SHADOW
        mgr.close()

    def test_promote_records_history(self, tmp_path):
        from horizon.fund._promotion import PromotionManager, PromotionStage
        mgr = PromotionManager(db_path=_tmp_db(tmp_path))
        mgr.track("mm")
        record = mgr.promote("mm", PromotionStage.SHADOW)
        assert record.from_stage == "paper"
        assert record.to_stage == "shadow"
        assert mgr.stage("mm") == PromotionStage.SHADOW
        history = mgr.history("mm")
        assert len(history) == 1
        mgr.close()

    def test_all_stages(self, tmp_path):
        from horizon.fund._promotion import PromotionManager
        mgr = PromotionManager(db_path=_tmp_db(tmp_path))
        mgr.track("a")
        mgr.track("b")
        stages = mgr.all_stages()
        assert stages["a"] == "paper"
        assert stages["b"] == "paper"
        mgr.close()

    def test_already_live_no_promotion(self, tmp_path):
        from horizon.fund._promotion import PromotionManager, PromotionStage
        mgr = PromotionManager(db_path=_tmp_db(tmp_path))
        mgr.track("mm")
        mgr.promote("mm", PromotionStage.LIVE)
        status = _mock_status(uptime_secs=86400 * 30, pnl=5000)
        assert mgr.check_promotion("mm", status, sharpe=2.0) is None
        mgr.close()


# =======================================================================
# RecoveryManager
# =======================================================================

class TestRecovery:
    def test_no_failed_strategies(self):
        from horizon.fund._recovery import RecoveryManager
        mgr = RecoveryManager()
        controller = types.SimpleNamespace(
            failed_strategies=lambda: [],
        )
        events = mgr.check_strategies(controller)
        assert events == []

    def test_circuit_breaker(self):
        from horizon.fund._recovery import RecoveryManager
        mgr = RecoveryManager(max_retries=2, cooldown_secs=0.0)

        # Mock controller with persistent failure
        call_count = 0
        def mock_failed():
            return ["bad_strat"]

        controller = types.SimpleNamespace(
            failed_strategies=mock_failed,
            _strategies={"bad_strat": types.SimpleNamespace(
                config=types.SimpleNamespace(name="bad_strat"),
                allocated_capital=1000,
            )},
            remove=lambda name: None,
            deploy=lambda config, capital: (_ for _ in ()).throw(RuntimeError("still broken")),
        )

        # First attempt
        events = mgr.check_strategies(controller)
        assert len(events) == 1

        # Second attempt
        events = mgr.check_strategies(controller)
        assert len(events) == 1

        # Third attempt: circuit breaker triggers
        events = mgr.check_strategies(controller)
        assert len(events) == 1
        assert mgr.is_permanently_failed("bad_strat")

        # No more attempts
        events = mgr.check_strategies(controller)
        assert len(events) == 0

    def test_reset(self):
        from horizon.fund._recovery import RecoveryManager
        mgr = RecoveryManager(max_retries=1, cooldown_secs=0.0)
        controller = types.SimpleNamespace(
            failed_strategies=lambda: ["s"],
            _strategies={"s": types.SimpleNamespace(
                config=types.SimpleNamespace(name="s"),
                allocated_capital=1000,
            )},
            remove=lambda name: None,
            deploy=lambda config, capital: (_ for _ in ()).throw(RuntimeError("fail")),
        )
        mgr.check_strategies(controller)  # attempt 1
        mgr.check_strategies(controller)  # circuit break
        assert mgr.is_permanently_failed("s")
        mgr.reset("s")
        assert not mgr.is_permanently_failed("s")

    def test_event_history(self):
        from horizon.fund._recovery import RecoveryManager
        mgr = RecoveryManager()
        assert len(mgr.event_history()) == 0

    def test_stats(self):
        from horizon.fund._recovery import RecoveryManager
        mgr = RecoveryManager()
        stats = mgr.stats()
        assert stats["total_attempts"] == 0
        assert stats["permanently_failed"] == []


# =======================================================================
# ThresholdTuner
# =======================================================================

class TestAdaptiveThresholds:
    def test_initial_defaults(self):
        from horizon.fund._adaptive_thresholds import ThresholdTuner
        tuner = ThresholdTuner()
        t = tuner.get_thresholds()
        assert t.deploy_staging == 0.5
        assert t.promote_live == 0.7

    def test_record_outcome(self):
        from horizon.fund._adaptive_thresholds import ThresholdTuner
        tuner = ThresholdTuner()
        tuner.record_outcome("d1", "deploy_staging", 0.6, True)
        stats = tuner.stats()
        assert stats["deploy_staging"]["sample_count"] == 1

    def test_tune_insufficient_samples(self):
        from horizon.fund._adaptive_thresholds import ThresholdTuner
        tuner = ThresholdTuner(min_samples=10)
        for i in range(5):
            tuner.record_outcome(f"d{i}", "deploy_staging", 0.6, True)
        t_before = tuner.get_thresholds()
        t_after = tuner.tune()
        # No change with insufficient samples
        assert t_before.deploy_staging == t_after.deploy_staging

    def test_tune_low_precision_raises_threshold(self):
        from horizon.fund._adaptive_thresholds import ThresholdTuner
        tuner = ThresholdTuner(min_samples=5, target_precision=0.6, step=0.05)
        # Record mostly bad outcomes
        for i in range(10):
            tuner.record_outcome(f"d{i}", "deploy_staging", 0.5, i < 3)  # 30% precision
        t = tuner.tune()
        assert t.deploy_staging > 0.5  # Should have gone up

    def test_tune_high_precision_lowers_threshold(self):
        from horizon.fund._adaptive_thresholds import ThresholdTuner
        tuner = ThresholdTuner(min_samples=5, step=0.05)
        # Record mostly good outcomes
        for i in range(10):
            tuner.record_outcome(f"d{i}", "deploy_staging", 0.5, True)  # 100% precision
        t = tuner.tune()
        assert t.deploy_staging < 0.5  # Should have gone down

    def test_bounds_enforcement(self):
        from horizon.fund._adaptive_thresholds import ThresholdTuner
        tuner = ThresholdTuner(min_samples=5, step=0.5, min_threshold=0.2, max_threshold=0.95)
        # Push threshold up with bad outcomes
        for i in range(20):
            tuner.record_outcome(f"d{i}", "deploy_staging", 0.5, False)
        t = tuner.tune()
        assert t.deploy_staging <= 0.95

    def test_stats_per_action(self):
        from horizon.fund._adaptive_thresholds import ThresholdTuner
        tuner = ThresholdTuner()
        tuner.record_outcome("d1", "deploy_staging", 0.5, True)
        tuner.record_outcome("d2", "scale_up", 0.8, False)
        stats = tuner.stats()
        assert stats["deploy_staging"]["profitable"] == 1
        assert stats["scale_up"]["profitable"] == 0


# =======================================================================
# BacktestRunner
# =======================================================================

class TestBacktestRunner:
    def test_generate_price_series_length(self):
        from horizon.fund._backtest_runner import BacktestRunner
        ticks = BacktestRunner._generate_price_series(edge=0.05, n_ticks=100)
        assert len(ticks) == 100

    def test_price_bounds(self):
        from horizon.fund._backtest_runner import BacktestRunner
        ticks = BacktestRunner._generate_price_series(edge=0.1, n_ticks=1000)
        for t in ticks:
            assert 0.01 <= t["price"] <= 0.99
            assert 0.00 <= t["bid"] <= 1.0
            assert 0.00 <= t["ask"] <= 1.0

    def test_tick_keys(self):
        from horizon.fund._backtest_runner import BacktestRunner
        ticks = BacktestRunner._generate_price_series(edge=0.0, n_ticks=10)
        for t in ticks:
            assert "timestamp" in t
            assert "price" in t
            assert "bid" in t
            assert "ask" in t
            assert "volume" in t

    def test_as_backtest_fn_callable(self):
        from horizon.fund._backtest_runner import BacktestRunner
        runner = BacktestRunner()
        fn = runner.as_backtest_fn()
        assert callable(fn)

    def test_as_robustness_fn_callable(self):
        from horizon.fund._backtest_runner import BacktestRunner
        runner = BacktestRunner()
        fn = runner.as_robustness_fn()
        assert callable(fn)

    def test_run_no_pipeline_raises(self):
        from horizon.fund._backtest_runner import BacktestRunner
        runner = BacktestRunner()
        candidate = types.SimpleNamespace(
            pipeline=None,
            market_id="test",
            edge_estimate=0.05,
            strategy_type="mm",
        )
        with pytest.raises(ValueError, match="no pipeline"):
            runner.run(candidate)

    def test_bid_less_than_ask(self):
        from horizon.fund._backtest_runner import BacktestRunner
        ticks = BacktestRunner._generate_price_series(edge=0.0, n_ticks=500)
        for t in ticks:
            assert t["bid"] <= t["ask"]


# =======================================================================
# Explainer
# =======================================================================

class TestExplainability:
    def _mock_fund(self):
        """Create a minimal mock fund manager for Explainer."""
        mock = types.SimpleNamespace(
            status=lambda: {
                "nav": 100_000,
                "drawdown_pct": 2.0,
                "running_strategies": 3,
                "kill_switch": False,
            },
            regime_detector=types.SimpleNamespace(
                current_regime=lambda: types.SimpleNamespace(
                    regime="quiet",
                    confidence=0.8,
                    volatility=0.01,
                    trend_strength=0.1,
                    mean_reversion_score=0.3,
                ),
                regime_history=lambda limit=5: [],
            ),
            risk_dashboard=lambda: {"nav": 100_000, "var": 5000},
            controller=types.SimpleNamespace(
                all_statuses=lambda: [_mock_status()],
                status=lambda name: _mock_status(name=name),
            ),
            agent=None,
            alert_manager=types.SimpleNamespace(
                recent_alerts=lambda limit=10: [],
            ),
            hypothesis_manager=types.SimpleNamespace(
                active_hypotheses=lambda: [],
            ),
            alpha_model=types.SimpleNamespace(
                factor_report=lambda: [],
            ),
            exec_quality=types.SimpleNamespace(
                strategy_metrics=lambda name: {},
            ),
            alpha_decay=types.SimpleNamespace(
                fit_decay=lambda name: None,
            ),
            promotion_manager=None,
            risk_analytics=types.SimpleNamespace(
                tail_risk=lambda: None,
                dynamic_limits=lambda regime: types.SimpleNamespace(
                    position_scale=1.0,
                    max_position_pct=0.05,
                    var_limit=0.10,
                    spread_multiplier=1.0,
                    regime=regime,
                ),
                _greeks_cache=None,
            ),
            correlation_monitor=types.SimpleNamespace(
                correlation_matrix=lambda: {},
            ),
            var_budget=types.SimpleNamespace(
                snapshot=lambda: {},
            ),
            stress_test=lambda: types.SimpleNamespace(
                summary=lambda: {},
            ),
            perf_attribution=types.SimpleNamespace(
                recent_snapshots=lambda limit=1: [],
            ),
        )
        return mock

    def test_explain_fund_state(self):
        from horizon.fund._explainability import Explainer
        explainer = Explainer()
        result = explainer.explain_fund_state(self._mock_fund())
        assert "overview" in result
        assert "regime" in result
        assert "risk" in result
        assert "strategies" in result
        assert "timestamp" in result

    def test_explain_strategy(self):
        from horizon.fund._explainability import Explainer
        explainer = Explainer()
        result = explainer.explain_strategy(self._mock_fund(), "test")
        assert "performance" in result
        assert "strategy_name" in result
        assert result["strategy_name"] == "test"

    def test_explain_decision(self):
        from horizon.fund._explainability import Explainer
        from horizon.fund._decisions import ActionType
        explainer = Explainer()
        decision = types.SimpleNamespace(
            decision_id="d-abc123",
            timestamp=time.time(),
            action=ActionType.DEPLOY_STAGING,
            parameters={"name": "mm"},
            reasoning="High edge detected",
            confidence=0.75,
            outcome="executed",
            error=None,
            execution_time_ms=12.5,
        )
        result = explainer.explain_decision(decision)
        assert result["what"]["action"] == "deploy_staging"
        assert result["why"]["confidence"] == 0.75
        assert result["outcome"]["status"] == "executed"

    def test_explain_risk(self):
        from horizon.fund._explainability import Explainer
        explainer = Explainer()
        result = explainer.explain_risk(self._mock_fund())
        assert "tail_risk" in result
        assert "greeks" in result
        assert "correlations" in result

    def test_handles_exceptions_gracefully(self):
        from horizon.fund._explainability import Explainer
        # Fund where everything raises
        broken_fund = types.SimpleNamespace(
            status=lambda: (_ for _ in ()).throw(RuntimeError("broken")),
            regime_detector=types.SimpleNamespace(
                current_regime=lambda: (_ for _ in ()).throw(RuntimeError("broken")),
            ),
            risk_dashboard=lambda: (_ for _ in ()).throw(RuntimeError("broken")),
            controller=types.SimpleNamespace(
                all_statuses=lambda: (_ for _ in ()).throw(RuntimeError("broken")),
            ),
            agent=None,
            alert_manager=types.SimpleNamespace(
                recent_alerts=lambda limit=10: (_ for _ in ()).throw(RuntimeError("broken")),
            ),
            hypothesis_manager=types.SimpleNamespace(
                active_hypotheses=lambda: (_ for _ in ()).throw(RuntimeError("broken")),
            ),
            alpha_model=types.SimpleNamespace(
                factor_report=lambda: (_ for _ in ()).throw(RuntimeError("broken")),
            ),
        )
        explainer = Explainer()
        # Should not raise
        result = explainer.explain_fund_state(broken_fund)
        assert "timestamp" in result


# =======================================================================
# FundCluster
# =======================================================================

class TestMultiFund:
    def _mock_fund_manager(self, nav=100_000, drawdown=2.0, strategies=3):
        return types.SimpleNamespace(
            start=lambda: None,
            stop=lambda: None,
            close=lambda: None,
            status=lambda: {
                "nav": nav,
                "drawdown_pct": drawdown,
                "running_strategies": strategies,
                "kill_switch": False,
            },
        )

    def test_add_and_list(self):
        from horizon.fund._multi_fund import FundCluster
        cluster = FundCluster()
        cluster.add_fund("alpha", self._mock_fund_manager())
        cluster.add_fund("beta", self._mock_fund_manager())
        assert cluster.fund_count == 2
        assert set(cluster.fund_names) == {"alpha", "beta"}

    def test_add_duplicate_raises(self):
        from horizon.fund._multi_fund import FundCluster
        cluster = FundCluster()
        cluster.add_fund("alpha", self._mock_fund_manager())
        with pytest.raises(ValueError, match="already exists"):
            cluster.add_fund("alpha", self._mock_fund_manager())

    def test_remove_fund(self):
        from horizon.fund._multi_fund import FundCluster
        cluster = FundCluster()
        cluster.add_fund("alpha", self._mock_fund_manager())
        cluster.remove_fund("alpha")
        assert cluster.fund_count == 0

    def test_remove_nonexistent_raises(self):
        from horizon.fund._multi_fund import FundCluster
        cluster = FundCluster()
        with pytest.raises(ValueError):
            cluster.remove_fund("nope")

    def test_aggregate_status(self):
        from horizon.fund._multi_fund import FundCluster
        cluster = FundCluster()
        cluster.add_fund("a", self._mock_fund_manager(nav=50_000))
        cluster.add_fund("b", self._mock_fund_manager(nav=75_000))
        status = cluster.aggregate_status()
        assert status["total_nav"] == 125_000
        assert status["fund_count"] == 2

    def test_aggregate_risk(self):
        from horizon.fund._multi_fund import FundCluster
        cluster = FundCluster()
        cluster.add_fund("a", self._mock_fund_manager(drawdown=5.0))
        cluster.add_fund("b", self._mock_fund_manager(drawdown=10.0))
        risk = cluster.aggregate_risk()
        assert risk["max_single_fund_drawdown_pct"] == 10.0

    def test_start_all(self):
        from horizon.fund._multi_fund import FundCluster
        started = []
        fm = types.SimpleNamespace(
            start=lambda: started.append(True),
            stop=lambda: None,
            status=lambda: {"nav": 100_000, "drawdown_pct": 0, "running_strategies": 0, "kill_switch": False},
        )
        cluster = FundCluster()
        cluster.add_fund("a", fm)
        results = cluster.start_all()
        assert results["a"] is True
        assert len(started) == 1

    def test_fund_accessor(self):
        from horizon.fund._multi_fund import FundCluster
        fm = self._mock_fund_manager()
        cluster = FundCluster()
        cluster.add_fund("x", fm)
        assert cluster.fund("x") is fm

    def test_fund_not_found(self):
        from horizon.fund._multi_fund import FundCluster
        cluster = FundCluster()
        with pytest.raises(ValueError):
            cluster.fund("missing")

    def test_rebalance_bad_weights(self):
        from horizon.fund._multi_fund import FundCluster
        cluster = FundCluster()
        cluster.add_fund("a", self._mock_fund_manager())
        with pytest.raises(ValueError, match="sum to"):
            cluster.rebalance_across_funds({"a": 0.5})


# =======================================================================
# _types.py new fields
# =======================================================================

class TestTypesNewFields:
    def test_default_values(self):
        from horizon.fund._types import FundConfig
        c = FundConfig()
        assert c.alert_webhook_url is None
        assert c.promotion_enabled is False
        assert c.ledger_enabled is False
        assert c.self_healing_enabled is False
        assert c.adaptive_thresholds_enabled is False

    def test_custom_values(self):
        from horizon.fund._types import FundConfig
        c = FundConfig(
            alert_webhook_url="https://hooks.example.com/fund",
            promotion_enabled=True,
            ledger_enabled=True,
        )
        assert c.alert_webhook_url == "https://hooks.example.com/fund"
        assert c.promotion_enabled is True


# =======================================================================
# _decisions.py set_thresholds
# =======================================================================

class TestDecisionsSetThresholds:
    def test_set_thresholds(self, tmp_path):
        from horizon.fund._decisions import DecisionFramework, ConfidenceThresholds
        fw = DecisionFramework(db_path=_tmp_db(tmp_path))
        new_t = ConfidenceThresholds(deploy_staging=0.9)
        fw.set_thresholds(new_t)
        assert fw._thresholds.deploy_staging == 0.9
        fw.close()


# =======================================================================
# _controller.py restart
# =======================================================================

class TestControllerRestart:
    def test_restart_not_found(self):
        from horizon.fund._controller import StrategyController
        from horizon.fund._types import FundConfig
        ctrl = StrategyController(FundConfig())
        with pytest.raises(ValueError, match="not found"):
            ctrl.restart("nonexistent")


# =======================================================================
# Integration: imports from horizon.fund work
# =======================================================================

class TestIntegration:
    def test_all_new_types_importable(self):
        from horizon.fund import (
            AlertManager, Alert, AlertLevel, AlertCategory,
            CallbackChannel, WebhookChannel,
            FundLedger, JournalEntry, Account,
            PromotionManager, PromotionStage, PromotionCriteria, PromotionRecord,
            RecoveryManager, RecoveryAction, RecoveryEvent,
            ThresholdTuner, OutcomeRecord,
            BacktestRunner,
            Explainer,
            FundCluster,
        )
        # Just verify they imported
        assert AlertManager is not None
        assert FundLedger is not None
        assert PromotionManager is not None
        assert RecoveryManager is not None
        assert ThresholdTuner is not None
        assert BacktestRunner is not None
        assert Explainer is not None
        assert FundCluster is not None

    def test_mcp_fund_tools_exist(self):
        """Verify the new MCP tools are registered."""
        from horizon import tools
        assert hasattr(tools, "fund_explain")
        assert hasattr(tools, "fund_explain_strategy")
        assert hasattr(tools, "fund_explain_risk")
        assert hasattr(tools, "fund_hypotheses")
        assert hasattr(tools, "fund_regime")
        assert hasattr(tools, "fund_alpha_model")
        assert hasattr(tools, "fund_decisions")
        assert hasattr(tools, "fund_decay_report")
        assert hasattr(tools, "fund_alerts")
        assert hasattr(tools, "fund_ledger_entries")
        assert hasattr(tools, "fund_promotion_status")
        assert hasattr(tools, "fund_full_snapshot")
