"""Portfolio management for prediction market positions.

Provides portfolio construction, analytics, optimization (Kelly, equal-weight,
risk-parity, min-variance), and rebalancing order generation.

Usage:
    portfolio = hz.Portfolio(capital=10_000)
    portfolio.add_position("btc-above-50k", "yes", 20, 0.55, 0.62)
    portfolio.add_position("eth-above-3k", "yes", 15, 0.40, 0.45)
    print(portfolio.summary())

    # From an engine
    portfolio = hz.Portfolio.from_engine(engine, capital=5000)
    metrics = portfolio.metrics(n_simulations=50_000)
    print(f"VaR 95%: {metrics.var_95:.2f}")

    # Optimize and rebalance
    targets = portfolio.optimize_kelly(probs={"btc-above-50k": 0.65, "eth-above-3k": 0.50})
    orders = portfolio.rebalance_orders(targets)
"""

from __future__ import annotations

import math
from dataclasses import dataclass, field

from horizon._horizon import (
    PredictionGreeks,
    SimPosition,
    ledoit_wolf_shrinkage,
    monte_carlo,
    multi_kelly,
    prediction_greeks,
)


# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------


@dataclass
class PortfolioAsset:
    """A single position within a portfolio."""

    market_id: str
    side: str  # "yes" or "no"
    size: float
    entry_price: float
    current_price: float
    weight: float = 0.0
    target_weight: float = 0.0


@dataclass
class PortfolioMetrics:
    """Aggregated portfolio analytics."""

    total_value: float
    total_pnl: float
    total_pnl_pct: float
    num_positions: int
    concentration: float  # Herfindahl index
    var_95: float
    cvar_95: float
    win_probability: float
    greeks: dict[str, PredictionGreeks] = field(default_factory=dict)
    correlation_matrix: list[list[float]] | None = None
    shrinkage_intensity: float = 0.0


# ---------------------------------------------------------------------------
# Optimization helpers
# ---------------------------------------------------------------------------


def risk_parity_weights(cov_matrix: list[list[float]]) -> list[float]:
    """Inverse-volatility weighting (risk parity approximation).

    Each asset's weight is proportional to 1 / sigma_i. This ensures that
    each asset contributes roughly equal standalone volatility to the portfolio.
    """
    n = len(cov_matrix)
    if n == 0:
        return []

    inv_vols: list[float] = []
    for i in range(n):
        var_i = cov_matrix[i][i] if i < len(cov_matrix[i]) else 0.0
        sigma = math.sqrt(max(var_i, 0.0))
        if sigma > 1e-15:
            inv_vols.append(1.0 / sigma)
        else:
            inv_vols.append(0.0)

    total = sum(inv_vols)
    if total < 1e-15:
        # All volatilities are zero -- fall back to equal weight
        return [1.0 / n] * n

    return [v / total for v in inv_vols]


def min_variance_weights(cov_matrix: list[list[float]]) -> list[float]:
    """Minimum variance portfolio via iterative coordinate descent.

    Solves min w^T Sigma w subject to sum(w) = 1, w_i >= 0 using
    projected gradient descent.  No external dependencies required.
    """
    n = len(cov_matrix)
    if n == 0:
        return []
    if n == 1:
        return [1.0]

    # Convert to flat arrays for speed
    sigma = [[cov_matrix[i][j] for j in range(n)] for i in range(n)]

    # Start from equal weight
    w = [1.0 / n] * n

    lr = 0.01
    for iteration in range(2000):
        # gradient: 2 * Sigma @ w
        grad = [0.0] * n
        for i in range(n):
            s = 0.0
            for j in range(n):
                s += sigma[i][j] * w[j]
            grad[i] = 2.0 * s

        # Projected gradient step
        new_w = [0.0] * n
        for i in range(n):
            new_w[i] = w[i] - lr * grad[i]

        # Project onto simplex: clamp negatives, then renormalise
        for i in range(n):
            if new_w[i] < 0.0:
                new_w[i] = 0.0

        total = sum(new_w)
        if total < 1e-15:
            # Gradient pushed everything to zero -- keep previous weights
            break
        for i in range(n):
            new_w[i] /= total

        # Check convergence
        max_delta = max(abs(new_w[i] - w[i]) for i in range(n))
        w = new_w
        if max_delta < 1e-10:
            break

        # Adaptive learning rate decay
        if iteration % 200 == 0 and iteration > 0:
            lr *= 0.5

    return w


# ---------------------------------------------------------------------------
# Portfolio class
# ---------------------------------------------------------------------------


class Portfolio:
    """Portfolio of prediction market positions with analytics and optimization."""

    def __init__(self, name: str = "portfolio", capital: float = 10000.0) -> None:
        self.name = name
        self.capital = capital
        self._assets: dict[str, PortfolioAsset] = {}

    # -- Position management ------------------------------------------------

    def add_position(
        self,
        market_id: str,
        side: str,
        size: float,
        entry_price: float,
        current_price: float,
    ) -> None:
        """Add or replace a position in the portfolio."""
        self._assets[market_id] = PortfolioAsset(
            market_id=market_id,
            side=side,
            size=size,
            entry_price=entry_price,
            current_price=current_price,
        )
        self._recalc_weights()

    def remove_position(self, market_id: str) -> None:
        """Remove a position from the portfolio."""
        self._assets.pop(market_id, None)
        self._recalc_weights()

    def update_price(self, market_id: str, new_price: float) -> None:
        """Update the current price for a position."""
        if market_id in self._assets:
            self._assets[market_id].current_price = new_price
            self._recalc_weights()

    @classmethod
    def from_engine(cls, engine, capital: float = 10000.0) -> Portfolio:
        """Build a Portfolio from an Engine's live positions and feed data.

        Extracts positions, maps current prices from feeds, and constructs
        the portfolio automatically.
        """
        portfolio = cls(name="engine", capital=capital)

        # Gather current prices from feeds
        price_map: dict[str, float] = {}
        try:
            snapshots = engine.all_feed_snapshots()
            for name, snap in snapshots.items():
                if snap.bid > 0 and snap.ask > 0:
                    price_map[name] = (snap.bid + snap.ask) / 2.0
                elif snap.price > 0:
                    price_map[name] = snap.price
        except Exception:
            pass

        for pos in engine.positions():
            side_str = "yes" if str(pos.side) == "YES" else "no"
            current_price = price_map.get(pos.market_id, pos.avg_entry_price)
            portfolio.add_position(
                market_id=pos.market_id,
                side=side_str,
                size=pos.size,
                entry_price=pos.avg_entry_price,
                current_price=current_price,
            )

        return portfolio

    # -- Basic analytics ----------------------------------------------------

    def weights(self) -> dict[str, float]:
        """Current weight per market (value / total_value)."""
        return {mid: a.weight for mid, a in self._assets.items()}

    def concentration(self) -> float:
        """Herfindahl-Hirschman index (sum of squared weights). 1/N = equal."""
        if not self._assets:
            return 0.0
        return sum(a.weight ** 2 for a in self._assets.values())

    def pnl(self) -> float:
        """Total unrealised PnL across all positions."""
        total = 0.0
        for a in self._assets.values():
            if a.side == "yes":
                total += (a.current_price - a.entry_price) * a.size
            else:
                total += (a.entry_price - a.current_price) * a.size
        return total

    def pnl_pct(self) -> float:
        """Total PnL as a percentage of cost basis."""
        cost = sum(a.entry_price * a.size for a in self._assets.values())
        if cost < 1e-15:
            return 0.0
        return self.pnl() / cost

    # -- Full metrics -------------------------------------------------------

    def metrics(
        self,
        n_simulations: int = 10000,
        seed: int | None = None,
        t_hours: float = 24.0,
        vol: float = 0.2,
    ) -> PortfolioMetrics:
        """Compute portfolio metrics including Monte Carlo VaR.

        Args:
            n_simulations: Number of Monte Carlo scenarios.
            seed: PRNG seed for reproducibility.
            t_hours: Hours to expiry for greeks calculation.
            vol: Implied volatility for greeks calculation.
        """
        if not self._assets:
            return PortfolioMetrics(
                total_value=0.0,
                total_pnl=0.0,
                total_pnl_pct=0.0,
                num_positions=0,
                concentration=0.0,
                var_95=0.0,
                cvar_95=0.0,
                win_probability=0.0,
                greeks={},
                correlation_matrix=None,
                shrinkage_intensity=0.0,
            )

        # Build SimPosition list for Monte Carlo
        sim_positions = [
            SimPosition(
                market_id=a.market_id,
                side=a.side,
                size=a.size,
                entry_price=a.entry_price,
                current_price=a.current_price,
            )
            for a in self._assets.values()
        ]

        sim = monte_carlo(sim_positions, n_simulations, None, seed)

        # Prediction greeks per market
        greeks_map: dict[str, PredictionGreeks] = {}
        for a in self._assets.values():
            is_yes = a.side == "yes"
            price = max(0.01, min(0.99, a.current_price))
            try:
                g = prediction_greeks(price, a.size, is_yes, t_hours, vol)
                greeks_map[a.market_id] = g
            except Exception:
                pass

        total_val = self._total_value()

        return PortfolioMetrics(
            total_value=total_val,
            total_pnl=self.pnl(),
            total_pnl_pct=self.pnl_pct(),
            num_positions=len(self._assets),
            concentration=self.concentration(),
            var_95=sim.var_95,
            cvar_95=sim.cvar_95,
            win_probability=sim.win_probability,
            greeks=greeks_map,
            correlation_matrix=None,
            shrinkage_intensity=0.0,
        )

    # -- Optimization -------------------------------------------------------

    def optimize_kelly(
        self,
        probs: dict[str, float],
        max_total: float = 1.0,
    ) -> dict[str, float]:
        """Kelly criterion target weights for markets in *probs*.

        Args:
            probs: Estimated probabilities per market_id.
            max_total: Maximum total Kelly allocation (sum of fractions).

        Returns:
            Target weights per market_id.
        """
        if not probs:
            return {}

        market_ids = list(probs.keys())
        prob_list = [max(0.0, min(1.0, probs[m])) for m in market_ids]
        price_list = []
        for m in market_ids:
            if m in self._assets:
                price_list.append(max(0.01, min(0.99, self._assets[m].current_price)))
            else:
                # No position yet -- use probability as price proxy
                price_list.append(max(0.01, min(0.99, probs[m])))

        fractions = multi_kelly(prob_list, price_list, max_total)

        return {market_ids[i]: fractions[i] for i in range(len(market_ids))}

    def optimize_equal_weight(self) -> dict[str, float]:
        """Equal-weight target allocation across current positions."""
        if not self._assets:
            return {}
        w = 1.0 / len(self._assets)
        return {m: w for m in self._assets}

    def optimize_risk_parity(
        self,
        returns_history: list[list[float]] | None = None,
    ) -> dict[str, float]:
        """Risk parity (inverse-volatility) target weights.

        If *returns_history* is provided (T x N matrix, each row is one time
        step with N asset returns), uses Ledoit-Wolf shrinkage to estimate the
        covariance matrix. Otherwise falls back to equal weight.
        """
        if not self._assets:
            return {}

        market_ids = list(self._assets.keys())
        n = len(market_ids)

        if returns_history is not None and len(returns_history) >= 2:
            try:
                cov, _shrinkage = ledoit_wolf_shrinkage(returns_history)
                weights = risk_parity_weights(cov)
                return {market_ids[i]: weights[i] for i in range(n)}
            except Exception:
                pass

        # Fallback: equal weight
        return self.optimize_equal_weight()

    def optimize_min_variance(
        self,
        returns_history: list[list[float]],
    ) -> dict[str, float]:
        """Minimum variance target weights using Ledoit-Wolf covariance.

        Args:
            returns_history: T x N matrix of asset returns.

        Returns:
            Target weights per market_id.
        """
        if not self._assets:
            return {}

        market_ids = list(self._assets.keys())
        n = len(market_ids)

        if len(returns_history) < 2:
            return self.optimize_equal_weight()

        try:
            cov, _shrinkage = ledoit_wolf_shrinkage(returns_history)
            weights = min_variance_weights(cov)
            return {market_ids[i]: weights[i] for i in range(n)}
        except Exception:
            return self.optimize_equal_weight()

    # -- Rebalancing --------------------------------------------------------

    def rebalance_orders(
        self,
        target_weights: dict[str, float],
    ) -> list[dict]:
        """Generate rebalancing orders to move from current to target weights.

        Returns a list of order dicts with keys: market_id, side, action
        ("buy" or "sell"), size_delta, current_weight, target_weight.
        """
        orders: list[dict] = []
        total_val = self._total_value()
        if total_val < 1e-15:
            total_val = self.capital

        for market_id, target_w in target_weights.items():
            current_w = 0.0
            current_size = 0.0
            side = "yes"
            current_price = 0.50

            if market_id in self._assets:
                asset = self._assets[market_id]
                current_w = asset.weight
                current_size = asset.size
                side = asset.side
                current_price = asset.current_price

            weight_delta = target_w - current_w
            if abs(weight_delta) < 1e-10:
                continue

            # Convert weight delta to size delta
            if current_price > 1e-15:
                value_delta = weight_delta * total_val
                size_delta = abs(value_delta / current_price)
            else:
                size_delta = 0.0

            action = "buy" if weight_delta > 0 else "sell"

            # Selling more than we own -> clamp
            if action == "sell" and size_delta > current_size:
                size_delta = current_size

            if size_delta < 1e-10:
                continue

            orders.append(
                {
                    "market_id": market_id,
                    "side": side,
                    "action": action,
                    "size_delta": round(size_delta, 6),
                    "current_weight": round(current_w, 6),
                    "target_weight": round(target_w, 6),
                }
            )

        return orders

    def needs_rebalance(
        self,
        target_weights: dict[str, float],
        threshold: float = 0.05,
    ) -> bool:
        """Check whether any position deviates from target by more than *threshold*."""
        for market_id, target_w in target_weights.items():
            current_w = 0.0
            if market_id in self._assets:
                current_w = self._assets[market_id].weight
            if abs(current_w - target_w) > threshold:
                return True

        # Also check positions that have weight but no target
        for market_id, asset in self._assets.items():
            if market_id not in target_weights and asset.weight > threshold:
                return True

        return False

    # -- Reporting ----------------------------------------------------------

    def summary(self) -> str:
        """Human-readable portfolio summary."""
        lines = [
            f"Portfolio: {self.name}",
            f"Capital: {self.capital:,.2f}",
            f"Positions: {len(self._assets)}",
            f"Total Value: {self._total_value():,.2f}",
            f"PnL: {self.pnl():,.2f} ({self.pnl_pct():.2%})",
            f"Concentration (HHI): {self.concentration():.4f}",
            "",
        ]

        if self._assets:
            lines.append(f"{'Market':<30} {'Side':<5} {'Size':>8} {'Entry':>8} {'Current':>8} {'PnL':>10} {'Weight':>8}")
            lines.append("-" * 90)
            for a in self._assets.values():
                if a.side == "yes":
                    pos_pnl = (a.current_price - a.entry_price) * a.size
                else:
                    pos_pnl = (a.entry_price - a.current_price) * a.size
                lines.append(
                    f"{a.market_id:<30} {a.side:<5} {a.size:>8.2f} {a.entry_price:>8.4f} "
                    f"{a.current_price:>8.4f} {pos_pnl:>10.2f} {a.weight:>7.2%}"
                )

        return "\n".join(lines)

    # -- Internals ----------------------------------------------------------

    def _total_value(self) -> float:
        """Sum of current_price * size for all positions."""
        return sum(a.current_price * a.size for a in self._assets.values())

    def _recalc_weights(self) -> None:
        """Recompute weights after any position change."""
        total = self._total_value()
        for a in self._assets.values():
            if total > 1e-15:
                a.weight = (a.current_price * a.size) / total
            else:
                a.weight = 0.0

    def __repr__(self) -> str:
        return f"Portfolio(name={self.name!r}, positions={len(self._assets)}, capital={self.capital})"

    def __len__(self) -> int:
        return len(self._assets)
