"""P&L Attribution -- break down portfolio PnL by market, time period, and factor.

Provides tools to understand where returns come from: which markets
contribute most, how PnL distributes over time, and which factors
drive performance.

Usage:
    # Standalone analysis
    report = attribute_pnl(engine)
    for mkt in report.by_market:
        print(f"{mkt.market_id}: {mkt.pnl:+.4f} ({mkt.contribution:.1%})")

    # Pipeline integration
    hz.run(
        pipeline=[
            hz.pnl_attribution_pipeline(engine),
            model,
            quoter,
        ],
        ...
    )
"""

from __future__ import annotations

import logging
import math
import time as _time
from dataclasses import dataclass, field
from typing import Any, Callable

from horizon.context import Context

logger = logging.getLogger("horizon")


@dataclass
class PnLBreakdown:
    """Per-market PnL breakdown."""

    market_id: str
    pnl: float
    pnl_pct: float
    contribution: float


@dataclass
class TimeBreakdown:
    """PnL breakdown for a time period."""

    period: str
    pnl: float
    n_trades: int
    win_rate: float


@dataclass
class FactorBreakdown:
    """Factor-based PnL attribution."""

    factor: str
    exposure: float
    pnl_contribution: float
    r_squared: float


@dataclass
class AttributionReport:
    """Full attribution report combining market, time, and factor views."""

    by_market: list[PnLBreakdown] = field(default_factory=list)
    by_time: list[TimeBreakdown] = field(default_factory=list)
    by_factor: list[FactorBreakdown] = field(default_factory=list)
    total_pnl: float = 0.0
    n_positions: int = 0


def attribute_pnl(engine: Any) -> AttributionReport:
    """Extract positions and fills from engine, compute per-market PnL breakdown.

    For each position: compute PnL, percentage, and contribution to total.
    Results are sorted by absolute PnL descending.

    Args:
        engine: Horizon Engine instance.

    Returns:
        AttributionReport with by_market populated.
    """
    from horizon._horizon import auth_require_pro
    auth_require_pro()

    positions = engine.positions()
    if not positions:
        return AttributionReport()

    # Compute per-market PnL
    market_pnl: dict[str, float] = {}
    market_cost: dict[str, float] = {}
    for pos in positions:
        mid = getattr(pos, "market_id", "")
        pnl = getattr(pos, "realized_pnl", 0.0) + getattr(pos, "unrealized_pnl", 0.0)
        cost = getattr(pos, "avg_entry_price", 0.0) * getattr(pos, "size", 0.0)
        market_pnl[mid] = market_pnl.get(mid, 0.0) + pnl
        market_cost[mid] = market_cost.get(mid, 0.0) + cost

    total_pnl = sum(market_pnl.values())

    breakdowns: list[PnLBreakdown] = []
    for mid, pnl in market_pnl.items():
        cost = market_cost.get(mid, 0.0)
        pnl_pct = (pnl / cost) if cost > 0 else 0.0
        contribution = (pnl / total_pnl) if total_pnl != 0.0 else 0.0
        breakdowns.append(PnLBreakdown(
            market_id=mid,
            pnl=pnl,
            pnl_pct=pnl_pct,
            contribution=contribution,
        ))

    # Sort by absolute PnL descending
    breakdowns.sort(key=lambda b: abs(b.pnl), reverse=True)

    return AttributionReport(
        by_market=breakdowns,
        total_pnl=total_pnl,
        n_positions=len(positions),
    )


def attribute_by_time(
    fills: list[Any],
    period: str = "daily",
) -> list[TimeBreakdown]:
    """Group fills by time period and compute PnL per period.

    Args:
        fills: List of Fill objects (must have ``timestamp`` and ``price``
               attributes, plus ``order_side`` and ``size``).
        period: One of "hourly", "daily", "weekly".

    Returns:
        List of TimeBreakdown sorted chronologically.
    """
    if not fills:
        return []

    # Group fills into buckets
    buckets: dict[str, list[Any]] = {}
    for fill in fills:
        ts = getattr(fill, "timestamp", 0.0)
        if ts <= 0:
            continue

        import datetime as _dt

        dt = _dt.datetime.fromtimestamp(ts, tz=_dt.timezone.utc)

        if period == "hourly":
            key = dt.strftime("%Y-%m-%d %H:00")
        elif period == "weekly":
            # ISO week
            iso_year, iso_week, _ = dt.isocalendar()
            key = f"{iso_year}-W{iso_week:02d}"
        else:
            # Default: daily
            key = dt.strftime("%Y-%m-%d")

        buckets.setdefault(key, []).append(fill)

    results: list[TimeBreakdown] = []
    for bucket_key in sorted(buckets.keys()):
        bucket_fills = buckets[bucket_key]
        n_trades = len(bucket_fills)

        # Compute PnL: for each fill, PnL depends on side.
        # Buy fills have negative cash flow (pay price * size),
        # sell fills have positive cash flow.
        # Net PnL for a period is approximated by summing signed cash flows.
        pnl = 0.0
        wins = 0
        for fill in bucket_fills:
            price = getattr(fill, "price", 0.0)
            size = getattr(fill, "size", 0.0)
            fill_pnl = getattr(fill, "pnl", None)

            if fill_pnl is not None:
                pnl += fill_pnl
                if fill_pnl > 0:
                    wins += 1
            else:
                # Approximate: use order_side to infer direction
                order_side = getattr(fill, "order_side", None)
                if order_side is not None:
                    # Import lazily to avoid circular import at module level
                    try:
                        from horizon._horizon import OrderSide
                        if order_side == OrderSide.Sell:
                            pnl += price * size
                            wins += 1
                        else:
                            pnl -= price * size
                    except ImportError:
                        pass

        win_rate = wins / n_trades if n_trades > 0 else 0.0

        results.append(TimeBreakdown(
            period=bucket_key,
            pnl=pnl,
            n_trades=n_trades,
            win_rate=win_rate,
        ))

    return results


def attribute_by_factor(
    positions: list[Any],
    factor_exposures: dict[str, dict[str, float]],
) -> list[FactorBreakdown]:
    """Factor-based PnL attribution.

    Args:
        positions: List of Position objects.
        factor_exposures: Mapping of factor_name -> {market_id: exposure_weight}.
            Example: {"crypto": {"btc-100k": 0.8, "eth-5k": 0.9},
                      "politics": {"trump-win": 1.0}}

    Returns:
        List of FactorBreakdown, one per factor.
    """
    if not positions or not factor_exposures:
        return []

    # Build per-market PnL lookup
    market_pnl: dict[str, float] = {}
    for pos in positions:
        mid = getattr(pos, "market_id", "")
        pnl = getattr(pos, "realized_pnl", 0.0) + getattr(pos, "unrealized_pnl", 0.0)
        market_pnl[mid] = market_pnl.get(mid, 0.0) + pnl

    total_pnl = sum(market_pnl.values())
    pnl_values = list(market_pnl.values())
    total_variance = _variance(pnl_values)

    results: list[FactorBreakdown] = []
    for factor_name, exposures in factor_exposures.items():
        # Weighted PnL contribution for this factor
        weighted_pnl = 0.0
        total_weight = 0.0
        factor_pnls: list[float] = []

        for mid, weight in exposures.items():
            pnl = market_pnl.get(mid, 0.0)
            weighted_pnl += weight * pnl
            total_weight += abs(weight)
            factor_pnls.append(weight * pnl)

        # Net exposure: average absolute weight
        exposure = total_weight / len(exposures) if exposures else 0.0

        # R-squared: fraction of total PnL variance explained by this factor
        factor_variance = _variance(factor_pnls) if factor_pnls else 0.0
        r_squared = (factor_variance / total_variance) if total_variance > 0 else 0.0
        r_squared = min(r_squared, 1.0)

        results.append(FactorBreakdown(
            factor=factor_name,
            exposure=exposure,
            pnl_contribution=weighted_pnl,
            r_squared=r_squared,
        ))

    return results


def pnl_attribution_pipeline(engine: Any = None) -> Callable[[Context], None]:
    """Pipeline function for hz.run() that adds attribution data each cycle.

    Injects into ctx.params:
        - "pnl_by_market": list of PnLBreakdown
        - "pnl_top_winner": PnLBreakdown or None
        - "pnl_top_loser": PnLBreakdown or None

    Args:
        engine: Optional engine override. If None, uses ctx.params["engine"].

    Returns:
        Pipeline function: (Context) -> None
    """

    def _attribute(ctx: Context) -> None:
        eng = engine if engine is not None else ctx.params.get("engine")
        if eng is None:
            return

        report = attribute_pnl(eng)
        ctx.params["pnl_by_market"] = report.by_market

        # Find top winner and loser
        winners = [b for b in report.by_market if b.pnl > 0]
        losers = [b for b in report.by_market if b.pnl < 0]

        ctx.params["pnl_top_winner"] = winners[0] if winners else None
        ctx.params["pnl_top_loser"] = losers[-1] if losers else None

    _attribute.__name__ = "pnl_attribution"
    return _attribute


# ---------------------------------------------------------------------------
# Greeks-based PnL attribution (logit space)
# ---------------------------------------------------------------------------

@dataclass
class GreeksAttribution:
    """Per-position PnL decomposition using logit Greeks."""

    market_id: str
    directional: float
    curvature: float
    belief_vega_pnl: float
    correlation_vega_pnl: float
    residual: float


def greeks_attribution(
    positions: list[Any],
    price_before: dict[str, float],
    price_after: dict[str, float],
    belief_vol: float = 0.2,
    vol_after: float | None = None,
) -> list[GreeksAttribution]:
    """Decompose per-position PnL using logit-space Greeks.

    Args:
        positions: List of Position objects.
        price_before: {market_id: price} at start of period.
        price_after: {market_id: price} at end of period.
        belief_vol: Belief volatility at start.
        vol_after: Belief volatility at end (defaults to belief_vol).

    Returns:
        List of GreeksAttribution, one per position.
    """
    from horizon._horizon import logit_pnl_decompose

    if vol_after is None:
        vol_after = belief_vol

    results: list[GreeksAttribution] = []
    for pos in positions:
        mid = getattr(pos, "market_id", "")
        if mid not in price_before or mid not in price_after:
            continue

        p_before = price_before[mid]
        p_after = price_after[mid]
        size = abs(getattr(pos, "size", 0.0))
        if size < 1e-15:
            continue

        net = getattr(pos, "size", 0.0)
        is_yes = net >= 0

        attr = logit_pnl_decompose(
            p_before, p_after, size, is_yes,
            belief_vol, vol_after, 0.0, 0.0,
        )
        results.append(GreeksAttribution(
            market_id=mid,
            directional=attr.directional,
            curvature=attr.curvature,
            belief_vega_pnl=attr.belief_vega_pnl,
            correlation_vega_pnl=attr.correlation_vega_pnl,
            residual=attr.residual,
        ))
    return results


# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------

def _variance(values: list[float]) -> float:
    """Compute population variance of a list of values."""
    n = len(values)
    if n < 2:
        return 0.0
    mean = sum(values) / n
    return sum((v - mean) ** 2 for v in values) / n
