"""Tests for the Portfolio management system."""

from __future__ import annotations

import math

import pytest

from horizon._horizon import (
    Engine,
    Fill,
    OrderSide,
    RiskConfig,
    Side,
    SimPosition,
)
from horizon.portfolio import (
    Portfolio,
    PortfolioAsset,
    PortfolioMetrics,
    min_variance_weights,
    risk_parity_weights,
)


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture
def empty_portfolio():
    return Portfolio(name="test", capital=10000.0)


@pytest.fixture
def two_asset_portfolio():
    p = Portfolio(name="two", capital=10000.0)
    p.add_position("btc-above-50k", "yes", 20.0, 0.55, 0.62)
    p.add_position("eth-above-3k", "yes", 15.0, 0.40, 0.45)
    return p


@pytest.fixture
def engine_with_positions():
    """Create an Engine with two positions via process_fill."""
    config = RiskConfig(max_position_per_market=100.0)
    engine = Engine(risk_config=config)

    fill1 = Fill(
        fill_id="f1",
        order_id="o1",
        market_id="mkt_a",
        side=Side.Yes,
        order_side=OrderSide.Buy,
        price=0.55,
        size=10.0,
    )
    engine.process_fill(fill1)

    fill2 = Fill(
        fill_id="f2",
        order_id="o2",
        market_id="mkt_b",
        side=Side.No,
        order_side=OrderSide.Buy,
        price=0.30,
        size=20.0,
    )
    engine.process_fill(fill2)

    return engine


# ---------------------------------------------------------------------------
# PortfolioAsset dataclass
# ---------------------------------------------------------------------------


class TestPortfolioAsset:
    def test_default_weights(self):
        a = PortfolioAsset(
            market_id="m1", side="yes", size=10.0, entry_price=0.5, current_price=0.6
        )
        assert a.weight == 0.0
        assert a.target_weight == 0.0

    def test_fields(self):
        a = PortfolioAsset(
            market_id="m1",
            side="no",
            size=5.0,
            entry_price=0.3,
            current_price=0.2,
            weight=0.5,
            target_weight=0.4,
        )
        assert a.market_id == "m1"
        assert a.side == "no"
        assert a.size == 5.0
        assert a.entry_price == 0.3
        assert a.current_price == 0.2
        assert a.weight == 0.5
        assert a.target_weight == 0.4


# ---------------------------------------------------------------------------
# Portfolio creation and position management
# ---------------------------------------------------------------------------


class TestPortfolioCreation:
    def test_empty_portfolio(self, empty_portfolio):
        assert len(empty_portfolio) == 0
        assert empty_portfolio.name == "test"
        assert empty_portfolio.capital == 10000.0

    def test_add_position(self, empty_portfolio):
        empty_portfolio.add_position("m1", "yes", 10.0, 0.5, 0.6)
        assert len(empty_portfolio) == 1
        w = empty_portfolio.weights()
        assert "m1" in w
        assert abs(w["m1"] - 1.0) < 1e-10  # only position = 100%

    def test_add_multiple_positions(self, two_asset_portfolio):
        assert len(two_asset_portfolio) == 2
        w = two_asset_portfolio.weights()
        assert "btc-above-50k" in w
        assert "eth-above-3k" in w
        assert abs(sum(w.values()) - 1.0) < 1e-10

    def test_remove_position(self, two_asset_portfolio):
        two_asset_portfolio.remove_position("btc-above-50k")
        assert len(two_asset_portfolio) == 1
        w = two_asset_portfolio.weights()
        assert "btc-above-50k" not in w
        assert abs(w["eth-above-3k"] - 1.0) < 1e-10

    def test_remove_nonexistent(self, two_asset_portfolio):
        """Removing a market_id that doesn't exist should not raise."""
        two_asset_portfolio.remove_position("nonexistent")
        assert len(two_asset_portfolio) == 2

    def test_update_price(self, two_asset_portfolio):
        two_asset_portfolio.update_price("btc-above-50k", 0.80)
        w = two_asset_portfolio.weights()
        # btc value = 0.80 * 20 = 16, eth value = 0.45 * 15 = 6.75
        # total = 22.75, btc weight = 16/22.75 ~ 0.703
        expected_btc = 16.0 / 22.75
        assert abs(w["btc-above-50k"] - expected_btc) < 1e-6

    def test_update_price_nonexistent(self, two_asset_portfolio):
        """Updating price for non-existent market should be a no-op."""
        two_asset_portfolio.update_price("nonexistent", 0.99)
        assert len(two_asset_portfolio) == 2

    def test_add_replaces_existing(self, two_asset_portfolio):
        """Adding same market_id replaces the old position."""
        two_asset_portfolio.add_position("btc-above-50k", "no", 5.0, 0.60, 0.55)
        assert len(two_asset_portfolio) == 2
        w = two_asset_portfolio.weights()
        # btc value = 0.55 * 5 = 2.75, eth value = 0.45 * 15 = 6.75
        # total = 9.50
        expected_btc = 2.75 / 9.50
        assert abs(w["btc-above-50k"] - expected_btc) < 1e-6

    def test_repr(self, two_asset_portfolio):
        r = repr(two_asset_portfolio)
        assert "Portfolio" in r
        assert "two" in r
        assert "2" in r


# ---------------------------------------------------------------------------
# from_engine classmethod
# ---------------------------------------------------------------------------


class TestFromEngine:
    def test_from_engine_positions(self, engine_with_positions):
        p = Portfolio.from_engine(engine_with_positions, capital=5000.0)
        assert len(p) == 2
        assert p.capital == 5000.0
        assert p.name == "engine"

    def test_from_engine_entry_prices(self, engine_with_positions):
        """Without feeds, current_price falls back to avg_entry_price."""
        p = Portfolio.from_engine(engine_with_positions)
        w = p.weights()
        assert "mkt_a" in w
        assert "mkt_b" in w
        # mkt_a: entry 0.55, size 10 -> value 5.5
        # mkt_b: entry 0.30, size 20 -> value 6.0
        # total = 11.5
        assert abs(w["mkt_a"] - 5.5 / 11.5) < 1e-6
        assert abs(w["mkt_b"] - 6.0 / 11.5) < 1e-6

    def test_from_engine_empty(self):
        engine = Engine()
        p = Portfolio.from_engine(engine)
        assert len(p) == 0


# ---------------------------------------------------------------------------
# PnL
# ---------------------------------------------------------------------------


class TestPnL:
    def test_pnl_yes_side(self):
        p = Portfolio(capital=1000.0)
        p.add_position("m1", "yes", 10.0, 0.50, 0.60)
        # PnL = (0.60 - 0.50) * 10 = 1.0
        assert abs(p.pnl() - 1.0) < 1e-10

    def test_pnl_no_side(self):
        p = Portfolio(capital=1000.0)
        p.add_position("m1", "no", 10.0, 0.50, 0.40)
        # PnL = (0.50 - 0.40) * 10 = 1.0
        assert abs(p.pnl() - 1.0) < 1e-10

    def test_pnl_negative(self):
        p = Portfolio(capital=1000.0)
        p.add_position("m1", "yes", 10.0, 0.60, 0.50)
        # PnL = (0.50 - 0.60) * 10 = -1.0
        assert abs(p.pnl() - (-1.0)) < 1e-10

    def test_pnl_pct(self):
        p = Portfolio(capital=1000.0)
        p.add_position("m1", "yes", 10.0, 0.50, 0.60)
        # cost = 0.50 * 10 = 5.0, pnl = 1.0, pct = 1.0/5.0 = 0.20
        assert abs(p.pnl_pct() - 0.20) < 1e-10

    def test_pnl_pct_empty(self, empty_portfolio):
        assert abs(empty_portfolio.pnl_pct()) < 1e-10

    def test_pnl_multi_position(self, two_asset_portfolio):
        # btc: (0.62 - 0.55) * 20 = 1.4
        # eth: (0.45 - 0.40) * 15 = 0.75
        # total = 2.15
        assert abs(two_asset_portfolio.pnl() - 2.15) < 1e-10


# ---------------------------------------------------------------------------
# Concentration (Herfindahl)
# ---------------------------------------------------------------------------


class TestConcentration:
    def test_empty_portfolio(self, empty_portfolio):
        assert abs(empty_portfolio.concentration()) < 1e-10

    def test_single_position(self):
        p = Portfolio()
        p.add_position("m1", "yes", 10.0, 0.5, 0.5)
        # Single position -> weight = 1.0, HHI = 1.0
        assert abs(p.concentration() - 1.0) < 1e-10

    def test_equal_two_positions(self):
        p = Portfolio()
        p.add_position("m1", "yes", 10.0, 0.5, 0.5)
        p.add_position("m2", "yes", 10.0, 0.5, 0.5)
        # Equal weights: 0.5, 0.5 -> HHI = 0.25 + 0.25 = 0.5
        assert abs(p.concentration() - 0.5) < 1e-10

    def test_equal_four_positions(self):
        p = Portfolio()
        for i in range(4):
            p.add_position(f"m{i}", "yes", 10.0, 0.5, 0.5)
        # Equal weights: 0.25 each -> HHI = 4 * 0.0625 = 0.25
        assert abs(p.concentration() - 0.25) < 1e-10


# ---------------------------------------------------------------------------
# Metrics (Monte Carlo)
# ---------------------------------------------------------------------------


class TestMetrics:
    def test_empty_metrics(self, empty_portfolio):
        m = empty_portfolio.metrics()
        assert m.num_positions == 0
        assert abs(m.total_value) < 1e-10
        assert abs(m.total_pnl) < 1e-10
        assert m.concentration == 0.0
        assert m.greeks == {}

    def test_metrics_basic(self, two_asset_portfolio):
        m = two_asset_portfolio.metrics(n_simulations=5000, seed=42)
        assert m.num_positions == 2
        assert m.total_value > 0
        assert isinstance(m.var_95, float)
        assert isinstance(m.cvar_95, float)
        assert isinstance(m.win_probability, float)
        assert 0.0 <= m.win_probability <= 1.0
        assert math.isfinite(m.var_95)
        assert math.isfinite(m.cvar_95)

    def test_metrics_greeks(self, two_asset_portfolio):
        m = two_asset_portfolio.metrics(n_simulations=1000, seed=42)
        assert "btc-above-50k" in m.greeks
        assert "eth-above-3k" in m.greeks
        g = m.greeks["btc-above-50k"]
        assert math.isfinite(g.delta)
        assert math.isfinite(g.gamma)
        assert math.isfinite(g.theta)
        assert math.isfinite(g.vega)

    def test_metrics_pnl_matches(self, two_asset_portfolio):
        m = two_asset_portfolio.metrics(n_simulations=1000, seed=42)
        assert abs(m.total_pnl - two_asset_portfolio.pnl()) < 1e-10
        assert abs(m.total_pnl_pct - two_asset_portfolio.pnl_pct()) < 1e-10

    def test_metrics_concentration_matches(self, two_asset_portfolio):
        m = two_asset_portfolio.metrics(n_simulations=1000, seed=42)
        assert abs(m.concentration - two_asset_portfolio.concentration()) < 1e-10


# ---------------------------------------------------------------------------
# Kelly optimization
# ---------------------------------------------------------------------------


class TestKellyOptimization:
    def test_kelly_basic(self, two_asset_portfolio):
        probs = {"btc-above-50k": 0.70, "eth-above-3k": 0.55}
        targets = two_asset_portfolio.optimize_kelly(probs)
        assert "btc-above-50k" in targets
        assert "eth-above-3k" in targets
        for w in targets.values():
            assert w >= 0.0
            assert math.isfinite(w)

    def test_kelly_empty_probs(self, two_asset_portfolio):
        targets = two_asset_portfolio.optimize_kelly({})
        assert targets == {}

    def test_kelly_no_edge(self, two_asset_portfolio):
        """When prob == price there is no edge; Kelly should allocate 0."""
        probs = {"btc-above-50k": 0.62, "eth-above-3k": 0.45}
        targets = two_asset_portfolio.optimize_kelly(probs)
        for w in targets.values():
            assert w >= 0.0

    def test_kelly_max_total_respected(self, two_asset_portfolio):
        probs = {"btc-above-50k": 0.95, "eth-above-3k": 0.95}
        targets = two_asset_portfolio.optimize_kelly(probs, max_total=0.5)
        total = sum(targets.values())
        assert total <= 0.5 + 1e-10

    def test_kelly_new_market(self):
        """Kelly can optimise for markets not yet in portfolio."""
        p = Portfolio()
        probs = {"new-market": 0.70}
        targets = p.optimize_kelly(probs)
        assert "new-market" in targets


# ---------------------------------------------------------------------------
# Equal weight optimization
# ---------------------------------------------------------------------------


class TestEqualWeight:
    def test_equal_weight(self, two_asset_portfolio):
        targets = two_asset_portfolio.optimize_equal_weight()
        assert len(targets) == 2
        for w in targets.values():
            assert abs(w - 0.5) < 1e-10

    def test_equal_weight_empty(self, empty_portfolio):
        assert empty_portfolio.optimize_equal_weight() == {}

    def test_equal_weight_three(self):
        p = Portfolio()
        p.add_position("a", "yes", 10, 0.5, 0.5)
        p.add_position("b", "yes", 10, 0.5, 0.5)
        p.add_position("c", "yes", 10, 0.5, 0.5)
        targets = p.optimize_equal_weight()
        for w in targets.values():
            assert abs(w - 1.0 / 3.0) < 1e-10


# ---------------------------------------------------------------------------
# Risk parity optimization
# ---------------------------------------------------------------------------


class TestRiskParity:
    def test_risk_parity_with_returns(self, two_asset_portfolio):
        # 10 observations x 2 assets
        returns = [
            [0.01, 0.02],
            [-0.01, -0.03],
            [0.02, 0.01],
            [-0.005, 0.005],
            [0.015, -0.01],
            [0.005, 0.02],
            [-0.02, -0.01],
            [0.01, 0.015],
            [-0.01, 0.005],
            [0.005, -0.005],
        ]
        targets = two_asset_portfolio.optimize_risk_parity(returns)
        assert len(targets) == 2
        total = sum(targets.values())
        assert abs(total - 1.0) < 1e-6

    def test_risk_parity_no_returns(self, two_asset_portfolio):
        """Without returns, falls back to equal weight."""
        targets = two_asset_portfolio.optimize_risk_parity()
        assert len(targets) == 2
        for w in targets.values():
            assert abs(w - 0.5) < 1e-10

    def test_risk_parity_empty(self, empty_portfolio):
        assert empty_portfolio.optimize_risk_parity() == {}


# ---------------------------------------------------------------------------
# Min variance optimization
# ---------------------------------------------------------------------------


class TestMinVariance:
    def test_min_variance_basic(self, two_asset_portfolio):
        returns = [
            [0.01, 0.02],
            [-0.01, -0.03],
            [0.02, 0.01],
            [-0.005, 0.005],
            [0.015, -0.01],
            [0.005, 0.02],
            [-0.02, -0.01],
            [0.01, 0.015],
            [-0.01, 0.005],
            [0.005, -0.005],
        ]
        targets = two_asset_portfolio.optimize_min_variance(returns)
        assert len(targets) == 2
        total = sum(targets.values())
        assert abs(total - 1.0) < 1e-6
        for w in targets.values():
            assert w >= -1e-10  # non-negative

    def test_min_variance_insufficient_returns(self, two_asset_portfolio):
        """With < 2 observations, falls back to equal weight."""
        targets = two_asset_portfolio.optimize_min_variance([[0.01, 0.02]])
        for w in targets.values():
            assert abs(w - 0.5) < 1e-10

    def test_min_variance_empty(self, empty_portfolio):
        assert empty_portfolio.optimize_min_variance([[0.01]]) == {}


# ---------------------------------------------------------------------------
# Helper functions
# ---------------------------------------------------------------------------


class TestHelpers:
    def test_risk_parity_weights_equal_vol(self):
        """Two assets with equal variance => equal weight."""
        cov = [[0.04, 0.0], [0.0, 0.04]]
        w = risk_parity_weights(cov)
        assert len(w) == 2
        assert abs(w[0] - 0.5) < 1e-10
        assert abs(w[1] - 0.5) < 1e-10

    def test_risk_parity_weights_unequal_vol(self):
        """Asset with lower variance gets more weight."""
        cov = [[0.01, 0.0], [0.0, 0.04]]
        w = risk_parity_weights(cov)
        assert w[0] > w[1]  # lower vol -> more weight
        assert abs(sum(w) - 1.0) < 1e-10

    def test_risk_parity_weights_empty(self):
        assert risk_parity_weights([]) == []

    def test_min_variance_weights_single(self):
        w = min_variance_weights([[0.04]])
        assert len(w) == 1
        assert abs(w[0] - 1.0) < 1e-10

    def test_min_variance_weights_empty(self):
        assert min_variance_weights([]) == []

    def test_min_variance_weights_identity(self):
        """Two uncorrelated equal-variance assets -> equal weight."""
        cov = [[0.04, 0.0], [0.0, 0.04]]
        w = min_variance_weights(cov)
        assert abs(w[0] - 0.5) < 0.05  # approximate due to iterative method
        assert abs(w[1] - 0.5) < 0.05

    def test_min_variance_prefers_low_vol(self):
        """Min-variance should tilt towards the lower-variance asset."""
        cov = [[0.01, 0.0], [0.0, 0.09]]
        w = min_variance_weights(cov)
        assert w[0] > w[1]  # low vol asset gets more weight


# ---------------------------------------------------------------------------
# Rebalance orders
# ---------------------------------------------------------------------------


class TestRebalanceOrders:
    def test_rebalance_generates_orders(self, two_asset_portfolio):
        targets = two_asset_portfolio.optimize_equal_weight()
        orders = two_asset_portfolio.rebalance_orders(targets)
        # The portfolio is not equally weighted, so at least one order
        # Current weights: btc = 12.4/19.15, eth = 6.75/19.15
        assert isinstance(orders, list)
        for o in orders:
            assert "market_id" in o
            assert "action" in o
            assert o["action"] in ("buy", "sell")
            assert "size_delta" in o
            assert o["size_delta"] >= 0

    def test_rebalance_already_at_target(self):
        """No orders when already at target weights."""
        p = Portfolio()
        p.add_position("a", "yes", 10.0, 0.5, 0.5)
        p.add_position("b", "yes", 10.0, 0.5, 0.5)
        # Already equal-weight
        targets = {"a": 0.5, "b": 0.5}
        orders = p.rebalance_orders(targets)
        assert orders == []

    def test_rebalance_new_market(self, two_asset_portfolio):
        """Rebalancing to a market not in portfolio should generate a buy."""
        targets = {"btc-above-50k": 0.4, "eth-above-3k": 0.3, "new-mkt": 0.3}
        orders = two_asset_portfolio.rebalance_orders(targets)
        new_orders = [o for o in orders if o["market_id"] == "new-mkt"]
        assert len(new_orders) == 1
        assert new_orders[0]["action"] == "buy"


# ---------------------------------------------------------------------------
# Needs rebalance
# ---------------------------------------------------------------------------


class TestNeedsRebalance:
    def test_needs_rebalance_true(self, two_asset_portfolio):
        targets = {"btc-above-50k": 0.50, "eth-above-3k": 0.50}
        # btc current weight ~ 0.648, deviation > 0.05
        assert two_asset_portfolio.needs_rebalance(targets, threshold=0.05)

    def test_needs_rebalance_false(self):
        p = Portfolio()
        p.add_position("a", "yes", 10.0, 0.5, 0.5)
        p.add_position("b", "yes", 10.0, 0.5, 0.5)
        targets = {"a": 0.5, "b": 0.5}
        assert not p.needs_rebalance(targets, threshold=0.05)

    def test_needs_rebalance_large_threshold(self, two_asset_portfolio):
        targets = {"btc-above-50k": 0.50, "eth-above-3k": 0.50}
        # With large threshold, may not trigger
        assert not two_asset_portfolio.needs_rebalance(targets, threshold=0.99)

    def test_needs_rebalance_missing_target(self):
        """Position with weight > threshold but no target -> needs rebalance."""
        p = Portfolio()
        p.add_position("a", "yes", 10.0, 0.5, 0.5)
        p.add_position("b", "yes", 10.0, 0.5, 0.5)
        # Only target for 'a'; 'b' has weight 0.5 > threshold
        assert p.needs_rebalance({"a": 0.5}, threshold=0.05)


# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------


class TestSummary:
    def test_summary_nonempty(self, two_asset_portfolio):
        s = two_asset_portfolio.summary()
        assert isinstance(s, str)
        assert "btc-above-50k" in s
        assert "eth-above-3k" in s
        assert "PnL" in s
        assert "Capital" in s

    def test_summary_empty(self, empty_portfolio):
        s = empty_portfolio.summary()
        assert "Positions: 0" in s


# ---------------------------------------------------------------------------
# Edge cases
# ---------------------------------------------------------------------------


class TestEdgeCases:
    def test_zero_current_price(self):
        p = Portfolio()
        p.add_position("m1", "yes", 10.0, 0.50, 0.0)
        # Weight should be 0 since value is 0
        assert abs(p.weights().get("m1", 0)) < 1e-10
        # PnL = (0.0 - 0.50) * 10 = -5.0
        assert abs(p.pnl() - (-5.0)) < 1e-10

    def test_zero_entry_price(self):
        p = Portfolio()
        p.add_position("m1", "yes", 10.0, 0.0, 0.50)
        # PnL = (0.50 - 0.0) * 10 = 5.0
        assert abs(p.pnl() - 5.0) < 1e-10
        # pnl_pct: cost = 0, should return 0.0
        assert abs(p.pnl_pct()) < 1e-10

    def test_single_position_metrics(self):
        p = Portfolio()
        p.add_position("m1", "yes", 5.0, 0.50, 0.55)
        m = p.metrics(n_simulations=1000, seed=42)
        assert m.num_positions == 1
        assert m.concentration == 1.0  # single position
        assert math.isfinite(m.var_95)

    def test_large_portfolio(self):
        """10 positions should work fine."""
        p = Portfolio()
        for i in range(10):
            p.add_position(f"mkt_{i}", "yes", 10.0, 0.50, 0.50 + i * 0.01)
        assert len(p) == 10
        w = p.weights()
        assert abs(sum(w.values()) - 1.0) < 1e-10
        m = p.metrics(n_simulations=1000, seed=42)
        assert m.num_positions == 10

    def test_all_outputs_finite(self, two_asset_portfolio):
        """All metrics outputs must be finite numbers."""
        m = two_asset_portfolio.metrics(n_simulations=2000, seed=123)
        assert math.isfinite(m.total_value)
        assert math.isfinite(m.total_pnl)
        assert math.isfinite(m.total_pnl_pct)
        assert math.isfinite(m.concentration)
        assert math.isfinite(m.var_95)
        assert math.isfinite(m.cvar_95)
        assert math.isfinite(m.win_probability)
        assert math.isfinite(m.shrinkage_intensity)
