"""
AltSportsData Python SDK

Alternative sports data for prediction markets, DFS platforms, and sportsbooks.

    pip install altsportsdata

    from altsportsdata import AltSportsData

    client = AltSportsData(api_key="sk_live_xxx", odds_format="probability")
    wsl = client.get_league("wsl")

    # Prediction markets — probabilities ready for contract creation
    wsl.get_market_probabilities("evt_id")

    # DFS platforms — player props + matchups
    wsl.get_player_props("evt_id")
    wsl.get_matchups("evt_id")

    # Sportsbooks — moneylines in any format
    wsl.get_moneylines("evt_id")

    # Settlement — structured grading for contract resolution
    wsl.get_settlement("evt_id")

Docs: https://api.altsportsdata.com/public/docs
API:  https://api.altsportsdata.com/public/docs/swagger
"""

from typing import List, Optional, Dict, Any, Union, Iterator
from concurrent.futures import ThreadPoolExecutor, as_completed

import logging
import random
import time
import uuid

import requests

from .models import (
    SportType, OddType, EventStatus, FutureType, ExactasType,
    OverUnderSubMarketType, SortOrder,
    RoundScore, EventParticipant,
    EventResponse, EventListing,
    JaiAlaiEvent, JaiAlaiOdds,
    FutureListing, FutureOdds,
    SportsListing,
)
from .converters import OddsFormat, convert_response, convert_odds
from .utils import ResultSet, OddsResult, _EVENT_COLUMNS, _MARKET_COLUMNS, _LEAGUE_COLUMNS
from .exceptions import (
    raise_for_status, NetworkError, ConfigurationError,
    RateLimitError, RetryExhaustedError,
)

logger = logging.getLogger("altsportsdata")

__version__ = "3.0.0"

_BASE_URL = "https://api.altsportsdata.com"
_API_PREFIX = "/api/v1/public"

# Retryable HTTP status codes
_RETRYABLE_STATUSES = {429, 500, 502, 503, 504}

# Map sportsbook-friendly status names → API values
_STATUS_ALIASES = {
    "live": "LIVE",
    "upcoming": "UPCOMING",
    "completed": "COMPLETED",
    "next": "NEXT",
    "in_window": "IN_WINDOW",
    "cancelled": "CANCELLED",
    "postponed": "POSTPONED",
}

# Map sportsbook-friendly market names → API oddType values
_MARKET_ALIASES = {
    # Sportsbook-native names
    "moneyline": "eventWinner",
    "moneylines": "eventWinner",
    "winner": "eventWinner",
    "matchup": "headToHead",
    "matchups": "headToHead",
    "h2h": "headToHead",
    "head_to_head": "headToHead",
    "props": "propBets",
    "player_props": "propBets",
    "totals": "overUnder",
    "over_under": "overUnder",
    "ou": "overUnder",
    "fastest_lap": "fastestLap",
    "heat_winner": "heatWinner",
    "dream_team": "dreamTeam",
    "exacta": "eventExacta",
    "exactas": "eventExacta",
    "heat_exacta": "heatExacta",
    "podium": "podiums",
    "top3": "raceTop3",
    "top5": "raceTop5",
    "top10": "raceTop10",
    "second": "secondPlace",
    "second_place": "secondPlace",
    "hole_shot": "holeShot",
    "multi_ou": "multiOverUnder",
    # Pass-through for API-native names
    "eventWinner": "eventWinner",
    "headToHead": "headToHead",
    "propBets": "propBets",
    "overUnder": "overUnder",
    "multiOverUnder": "multiOverUnder",
    "fastestLap": "fastestLap",
    "heatWinner": "heatWinner",
    "dreamTeam": "dreamTeam",
    "eventExacta": "eventExacta",
    "heatExacta": "heatExacta",
    "shows": "shows",
    "podiums": "podiums",
    "raceTop3": "raceTop3",
    "raceTop5": "raceTop5",
    "raceTop10": "raceTop10",
    "secondPlace": "secondPlace",
    "holeShot": "holeShot",
}

# All market types to scan in settlement / get_markets
_ALL_MARKET_TYPES = [
    "eventWinner", "secondPlace", "heatWinner", "headToHead",
    "propBets", "overUnder", "multiOverUnder", "fastestLap",
    "shows", "podiums", "dreamTeam", "eventExacta", "heatExacta",
    "raceTop3", "raceTop5", "raceTop10",
]


def _enum_val(val: Any, enum_cls: Any) -> str:
    if isinstance(val, enum_cls):
        return val.value
    return str(val)


def _resolve_status(s: Union[str, EventStatus]) -> str:
    if isinstance(s, EventStatus):
        return s.value
    return _STATUS_ALIASES.get(s.lower(), s.upper())


def _resolve_market(m: Union[str, OddType]) -> str:
    if isinstance(m, OddType):
        return m.value
    return _MARKET_ALIASES.get(m, _MARKET_ALIASES.get(m.lower(), m))


class AltSportsData:
    """
    AltSportsData client — production-grade data feed for alternative sports.

    Built for prediction markets (Kalshi, Polymarket), DFS platforms
    (PrizePicks, Underdog), and sportsbooks (DraftKings, Bet365).

    Features:
        - Automatic odds format conversion (decimal, American, probability, fractional)
        - Exponential backoff retry with jitter (429, 5xx)
        - Structured settlement for contract resolution / bet grading
        - Batch operations for bulk market data
        - Request tracing via X-Request-ID headers
        - Thread-safe session management

    Quick start::

        from altsportsdata import AltSportsData

        # Prediction market — probabilities ready for contract creation
        client = AltSportsData(api_key="key", odds_format="probability")
        wsl = client.get_league("wsl")
        probs = wsl.get_market_probabilities(event_id)

        # Sportsbook — American odds
        client = AltSportsData(api_key="key", odds_format="american")
        odds = client.get_moneylines(event_id)

        # DFS platform — matchups + props
        client = AltSportsData(api_key="key")
        matchups = client.get_matchups(event_id)
    """

    def __init__(
        self,
        api_key: Optional[str] = None,
        league: Optional[Union[SportType, str]] = None,
        *,
        base_url: str = _BASE_URL,
        timeout: int = 30,
        odds_format: Union[str, OddsFormat] = OddsFormat.DECIMAL,
        max_retries: int = 3,
        retry_backoff: float = 0.5,
    ):
        """
        Args:
            api_key: Your API key (get one at https://altsportsdata.com)
            league: Lock to a league (e.g. "wsl", "f1", "pbr"). Auto-filters all queries.
            base_url: API base URL (default: production)
            timeout: Request timeout in seconds
            odds_format: Output format for odds values:
                - ``"decimal"`` (default) — 2.50, 1.80, 4.00
                - ``"american"`` — +150, -200, +300
                - ``"probability"`` — 0.40, 0.56, 0.25 (0–1 scale)
                - ``"fractional"`` — 3/2, 4/5, 3/1
            max_retries: Max retry attempts for transient failures (429, 5xx).
                Set to 0 to disable retries.
            retry_backoff: Base delay in seconds for exponential backoff.
                Actual delay = backoff * 2^attempt + random jitter.
        """
        if not api_key:
            raise ConfigurationError(
                "API key required. Get one at https://altsportsdata.com"
            )
        self.api_key = api_key
        self.league = _enum_val(league, SportType) if league else None
        self.base_url = base_url.rstrip("/")
        self.timeout = timeout
        self.odds_format = OddsFormat(odds_format) if isinstance(odds_format, str) else odds_format
        self.max_retries = max_retries
        self.retry_backoff = retry_backoff
        self._session = requests.Session()
        self._session.headers.update({
            "X-API-KEY": api_key,
            "Content-Type": "application/json",
            "Accept": "application/json",
            "User-Agent": f"altsportsdata-python/{__version__}",
        })

    def get_league(self, league: Union[SportType, str]) -> "AltSportsData":
        """
        Get a league-scoped client that shares the same session and config.

        Args:
            league: League code (e.g. "wsl", "f1", "spr", "pbr")

        Returns:
            A new AltSportsData client locked to the given league.
            Inherits odds_format, retry settings, and HTTP session.

        Example::

            client = AltSportsData(api_key="key", odds_format="probability")
            wsl = client.get_league("wsl")
            f1  = client.get_league("f1")
            # Both use probability format, share connection pool
        """
        child = AltSportsData.__new__(AltSportsData)
        child.api_key = self.api_key
        child.league = _enum_val(league, SportType)
        child.base_url = self.base_url
        child.timeout = self.timeout
        child.odds_format = self.odds_format
        child.max_retries = self.max_retries
        child.retry_backoff = self.retry_backoff
        child._session = self._session
        return child

    # ══════════════════════════════════════════════════════════════════════
    #  LEAGUES / SPORTS
    # ══════════════════════════════════════════════════════════════════════

    def list_leagues(self) -> ResultSet:
        """List all available leagues with their markets and tours."""
        data = self._get(f"{_API_PREFIX}/sports")
        items = [SportsListing.model_validate(i) for i in data] if isinstance(data, list) else []
        return ResultSet(items, _display_columns=_LEAGUE_COLUMNS, _title="Leagues")

    # aliases
    list_sports = list_leagues
    get_leagues = list_leagues

    def get_league_info(self, league: Union[SportType, str]) -> Optional[SportsListing]:
        """
        Get details for a specific league — name, supported markets, etc.

        Args:
            league: League code (e.g. "wsl", "f1", "spr")

        Returns:
            SportsListing with name, league_key, market_types, market_count.
            None if league not found.
        """
        code = _enum_val(league, SportType)
        for lg in self.list_leagues():
            if lg.league_key == code:
                return lg
        return None

    def list_market_types(self) -> List[str]:
        """
        List all market types offered by ASD across all leagues.

        Returns:
            Sorted list of unique market type names (e.g. 'eventWinner',
            'headToHead', 'podium', 'overUnder', etc.)
        """
        all_types: set = set()
        for lg in self.list_leagues():
            for m in lg.market_types:
                all_types.add(m)
        return sorted(all_types)

    def get_market_catalog(
        self,
        league: Optional[Union[SportType, str]] = None,
    ) -> List[Dict[str, Any]]:
        """
        Complete market catalog — every league with market types and event counts.
        Built for contract/market discovery.

        Args:
            league: Filter to a specific league. If None, returns all.

        Returns:
            List of dicts: league, name, market_types, market_count,
            upcoming_events, live_events, completed_events.

        Example::

            for lg in client.get_market_catalog():
                print(f"{lg['league']:12} {lg['name']:30} "
                      f"markets={lg['market_count']}  "
                      f"upcoming={lg['upcoming_events']}")
        """
        leagues_data = self.list_leagues()
        results = []
        for lg in leagues_data:
            if league:
                target = _enum_val(league, SportType)
                if lg.league_key != target:
                    continue

            # Count events by status
            upcoming = 0
            live = 0
            completed = 0
            try:
                events = self.list_events(
                    league=lg.league_key,
                    status=["upcoming", "live", "completed"],
                )
                for e in events:
                    st = (e.eventStatus or "").upper()
                    if st == "UPCOMING":
                        upcoming += 1
                    elif st == "LIVE":
                        live += 1
                    elif st == "COMPLETED":
                        completed += 1
            except Exception:
                pass

            results.append({
                "league": lg.league_key,
                "name": lg.name,
                "market_types": lg.market_types,
                "market_count": lg.market_count,
                "upcoming_events": upcoming,
                "live_events": live,
                "completed_events": completed,
            })

            if league:
                break

        return results

    # ══════════════════════════════════════════════════════════════════════
    #  EVENTS
    # ══════════════════════════════════════════════════════════════════════

    def list_events(
        self,
        *,
        league: Optional[Union[SportType, str]] = None,
        status: Optional[Union[str, List[str], EventStatus, List[EventStatus]]] = None,
        start_date: Optional[str] = None,
        end_date: Optional[str] = None,
        sort: Optional[str] = None,
    ) -> List[EventListing]:
        """
        List events.

        Args:
            league: Override client league
            status: "upcoming", "live", "completed", or list of statuses.
                    Also accepts API values: "UPCOMING", "LIVE", "COMPLETED", etc.
            start_date: ISO datetime filter
            end_date: ISO datetime filter
            sort: "asc" or "desc"
        """
        params = self._build_event_params(
            league=league, status=status,
            start_date=start_date, end_date=end_date, sort=sort,
        )
        data = self._get(f"{_API_PREFIX}/events", params=params)
        items = [EventListing.model_validate(i) for i in data] if isinstance(data, list) else []
        return ResultSet(items, _display_columns=_EVENT_COLUMNS, _title="Events")

    # alias
    get_events = list_events

    def get_markets(
        self,
        *,
        status: Optional[Union[str, List[str]]] = "upcoming",
        limit: int = 200,
    ) -> List[Dict[str, Any]]:
        """
        Get all available markets with prices — one call, everything you need.

        Returns events that have published odds, with outcomes, matchups,
        and podium data included inline. No second fetch required.

        Works at any scope: general client returns all leagues,
        league-scoped client returns just that league.

        Odds values are automatically converted per your ``odds_format`` setting.

        Args:
            status: "upcoming" (default), "live", "completed", or a list like
                    ["live", "upcoming"]. Completed events include settlement.
            limit: Max events to scan (default 200).

        Returns:
            List of market dicts, each containing:
                - event_id, event_name, league, start_date, status
                - outcomes: list of {athlete, probability, odds, settlement?}
                - matchups: list of {player1, odds1, player2, odds2}
                - podiums: list of {athlete, probability, odds}
                - heat_winners, props, totals (when available)
                - markets_available: list of market type names
        """
        events = self.list_events(status=status)
        results = []
        scanned = 0
        for e in events:
            if scanned >= limit:
                break
            if "jaialai" in str(e.id or ""):
                continue
            scanned += 1
            try:
                odds = self._get_raw(f"{_API_PREFIX}/events/{e.id}/eventWinner")
            except (NetworkError, ConfigurationError):
                raise
            except Exception:
                continue

            # -- Build outcomes (eventWinner) --
            outcomes = []
            for o in odds.get("eventWinner", []):
                ath = o.get("athlete", {})
                entry = {
                    "athlete": f"{ath.get('firstName', '')} {ath.get('lastName', '')}".strip(),
                    "athlete_id": ath.get("id"),
                    "probability": o.get("probability"),
                    "odds": o.get("odds"),
                    "outcome_id": o.get("outcomeId") or o.get("id"),
                    "status": o.get("status"),
                }
                if o.get("settlement"):
                    entry["settlement"] = o["settlement"]
                outcomes.append(entry)

            if not outcomes:
                continue

            # -- Fetch additional markets (each is a separate endpoint) --
            available = ["eventWinner"]

            # headToHead
            matchups = []
            try:
                h2h_data = self._get_raw(f"{_API_PREFIX}/events/{e.id}/headToHead")
                for m in h2h_data.get("headToHead", []):
                    p1 = m.get("eventParticipant1", {})
                    p2 = m.get("eventParticipant2", {})
                    a1 = p1.get("athlete", {})
                    a2 = p2.get("athlete", {})
                    matchups.append({
                        "player1": f"{a1.get('firstName', '')} {a1.get('lastName', '')}".strip(),
                        "odds1": p1.get("odds"),
                        "player2": f"{a2.get('firstName', '')} {a2.get('lastName', '')}".strip(),
                        "odds2": p2.get("odds"),
                        "matchup_id": m.get("outcomeId") or m.get("id"),
                    })
                if matchups:
                    available.append("headToHead")
            except Exception:
                pass

            # podiums
            podiums = []
            try:
                pod_data = self._get_raw(f"{_API_PREFIX}/events/{e.id}/podiums")
                for o in pod_data.get("podiums", []):
                    ath = o.get("athlete", {})
                    podiums.append({
                        "athlete": f"{ath.get('firstName', '')} {ath.get('lastName', '')}".strip(),
                        "probability": o.get("probability"),
                        "odds": o.get("odds"),
                    })
                if podiums:
                    available.append("podiums")
            except Exception:
                pass

            # heatWinner
            heat_winners = []
            try:
                hw_data = self._get_raw(f"{_API_PREFIX}/events/{e.id}/heatWinner")
                for o in hw_data.get("heatWinner", []):
                    ath = o.get("athlete", {}) if isinstance(o, dict) else {}
                    heat_winners.append({
                        "athlete": f"{ath.get('firstName', '')} {ath.get('lastName', '')}".strip(),
                        "odds": o.get("odds") if isinstance(o, dict) else None,
                    })
                if heat_winners:
                    available.append("heatWinner")
            except Exception:
                pass

            result = {
                "event_id": e.id,
                "event_name": e.name,
                "league": getattr(e, "sportType", None) or (str(e.id).split(":")[0] if e.id and ":" in str(e.id) else ""),
                "start_date": e.startDate,
                "status": e.eventStatus,
                "outcomes": sorted(outcomes, key=lambda x: x.get("odds") or 999),
                "matchups": matchups,
                "podiums": sorted(podiums, key=lambda x: x.get("odds") or 999),
                "heat_winners": heat_winners,
                "markets_available": available,
                "market_count": len(available),
            }
            results.append(result)

        # Apply odds format conversion to processed results
        if self.odds_format != OddsFormat.DECIMAL:
            results = convert_response(results, self.odds_format)

        return ResultSet(results, _display_columns=_MARKET_COLUMNS, _title="Markets")

    # aliases
    get_upcoming_markets = get_markets

    def get_event(self, event_id: str) -> EventResponse:
        """Get full event details by ID."""
        return EventResponse.model_validate(self._get(f"{_API_PREFIX}/events/{event_id}"))

    def get_participants(self, event_id: str) -> List[EventParticipant]:
        """Get athletes/participants for an event."""
        data = self._get(f"{_API_PREFIX}/events/{event_id}/participants")
        return [EventParticipant.model_validate(i) for i in data] if isinstance(data, list) else []

    def get_heat_scores(self, event_id: str, heat_id: str) -> List[RoundScore]:
        """Get scores/lap times for a specific heat."""
        data = self._get(f"{_API_PREFIX}/events/{event_id}/heats/{heat_id}")
        return [RoundScore.model_validate(i) for i in data] if isinstance(data, list) else []

    # ══════════════════════════════════════════════════════════════════════
    #  ODDS — generic + sportsbook-native aliases
    # ══════════════════════════════════════════════════════════════════════

    def get_odds(
        self,
        event_id: str,
        market: Union[OddType, str],
        *,
        exactas_type: Optional[int] = None,
        sub_market: Optional[str] = None,
    ) -> Dict[str, Any]:
        """
        Get odds for any market type.

        Odds values are automatically converted per your ``odds_format`` setting.

        Args:
            event_id: Event ID
            market: Market name. Accepts sportsbook-native names:
                    "moneyline", "matchups", "h2h", "props", "totals",
                    "exacta", "top3", "top5", etc.
                    Also accepts API names: "eventWinner", "headToHead", etc.
            exactas_type: For exacta markets — 2, 3, or 4
            sub_market: For over/under — "finishingPosition", "points", "gapToLeaders"

        Returns:
            Dict with market data. Shape depends on market type.
            Odds values converted per client's odds_format setting.
        """
        market_str = _resolve_market(market)
        params: Dict[str, Any] = {}
        if exactas_type is not None:
            params["exactasType"] = exactas_type
        if sub_market is not None:
            params["overUnderSubMarketType"] = sub_market
        data = self._get(f"{_API_PREFIX}/events/{event_id}/{market_str}", params=params)
        return OddsResult(data) if isinstance(data, dict) else data

    # ── Batch operations ───────────────────────────────────────────────

    def get_odds_batch(
        self,
        event_ids: List[str],
        market: Union[OddType, str] = "eventWinner",
        *,
        max_concurrent: int = 5,
    ) -> Dict[str, Dict[str, Any]]:
        """
        Fetch odds for multiple events in parallel.

        Uses ThreadPoolExecutor for concurrent requests. Respects rate
        limits — failed requests return error dicts, not exceptions.

        Args:
            event_ids: List of event IDs to fetch
            market: Market type (default: "eventWinner")
            max_concurrent: Max parallel requests (default: 5)

        Returns:
            Dict mapping event_id → odds response (or {"error": "..."} on failure)

        Example::

            events = client.list_events(league="spr", status="upcoming")
            ids = [e.id for e in events[:10]]
            batch = client.get_odds_batch(ids, "moneyline")
            for eid, odds in batch.items():
                if "error" not in odds:
                    print(f"{eid}: {len(odds.get('eventWinner', []))} outcomes")
        """
        results: Dict[str, Dict[str, Any]] = {}
        with ThreadPoolExecutor(max_workers=min(max_concurrent, len(event_ids))) as pool:
            futures = {
                pool.submit(self.get_odds, eid, market): eid
                for eid in event_ids
            }
            for fut in as_completed(futures):
                eid = futures[fut]
                try:
                    results[eid] = fut.result()
                except Exception as e:
                    results[eid] = {"error": str(e)}
        return results

    # ── Prediction Markets (Kalshi, Polymarket) ─────────────────────────

    def get_market_probabilities(self, event_id: str) -> Dict[str, Any]:
        """
        Get win probabilities for all participants — built for prediction markets.

        Returns the same data as get_moneylines() but named for how
        Kalshi/Polymarket engineers think about it: probabilities, not odds.

        Each entry includes: athlete, probability, odds, status, outcomeId.
        When odds_format="probability", both odds and probability are 0–1 scale.
        """
        return self.get_odds(event_id, "eventWinner")

    def get_podium_probabilities(self, event_id: str) -> Dict[str, Any]:
        """Top-3 finish probabilities — prediction market friendly."""
        return self.get_odds(event_id, "podiums")

    def get_top_finish_probabilities(self, event_id: str, top_n: int = 5) -> Dict[str, Any]:
        """
        Top-N finish probabilities.

        Args:
            event_id: Event ID
            top_n: 3, 5, or 10
        """
        market_map = {3: "raceTop3", 5: "raceTop5", 10: "raceTop10"}
        market = market_map.get(top_n, f"raceTop{top_n}")
        return self.get_odds(event_id, market)

    # ── DFS Platforms (PrizePicks, Underdog) ──────────────────────────

    def get_player_props(self, event_id: str) -> Dict[str, Any]:
        """Player proposition bets — core DFS market data."""
        return self.get_odds(event_id, "props")

    def get_player_matchups(self, event_id: str) -> Dict[str, Any]:
        """Head-to-head player matchups — DFS pick'em format."""
        return self.get_odds(event_id, "matchups")

    def get_player_totals(self, event_id: str, stat: Optional[str] = None) -> Dict[str, Any]:
        """
        Player over/under totals.

        Args:
            event_id: Event ID
            stat: Stat type — 'points', 'finishingPosition', 'gapToLeaders'
        """
        return self.get_odds(event_id, "totals", sub_market=stat)

    # ── Sportsbooks (DraftKings, Bet365, Stake) ──────────────────────

    def get_moneylines(self, event_id: str) -> Dict[str, Any]:
        """Event winner / moneyline odds."""
        return self.get_odds(event_id, "moneyline")

    def get_matchups(self, event_id: str) -> Dict[str, Any]:
        """Head-to-head matchup odds."""
        return self.get_odds(event_id, "matchups")

    def get_totals(self, event_id: str, sub_market: Optional[str] = None) -> Dict[str, Any]:
        """Over/under totals. Alias for get_player_totals()."""
        return self.get_odds(event_id, "totals", sub_market=sub_market)

    def get_heat_winners(self, event_id: str) -> Dict[str, Any]:
        """Heat winner odds (WSL, SLS, racing sports)."""
        return self.get_odds(event_id, "heatWinner")

    def get_fastest_lap(self, event_id: str) -> Dict[str, Any]:
        """Fastest lap odds (racing sports)."""
        return self.get_odds(event_id, "fastestLap")

    def get_exactas(self, event_id: str, n: int = 2) -> Dict[str, Any]:
        """Exacta odds. n=2 (default), 3, or 4."""
        return self.get_odds(event_id, "exacta", exactas_type=n)

    def get_podiums(self, event_id: str) -> Dict[str, Any]:
        """Podium (top-3) finish odds."""
        return self.get_odds(event_id, "podiums")

    def get_shows(self, event_id: str) -> Dict[str, Any]:
        """Shows odds."""
        return self.get_odds(event_id, "shows")

    def get_dream_team(self, event_id: str) -> Dict[str, Any]:
        """Dream team odds."""
        return self.get_odds(event_id, "dreamTeam")

    # ══════════════════════════════════════════════════════════════════════
    #  CONVENIENCE METHODS — "done for you" computations
    # ══════════════════════════════════════════════════════════════════════

    def get_favorites(self, event_id: str, n: int = 5) -> List[Dict[str, Any]]:
        """
        Get the top N favorites for an event, sorted by lowest odds.

        Returns each entry with odds converted to all formats in one call.

        Args:
            event_id: Event ID
            n: Number of favorites to return (default 5)

        Returns:
            List of dicts: athlete, odds, probability, american, outcome_id
        """
        data = self.get_moneylines(event_id)
        outcomes = data.get("eventWinner", [])
        results = []
        for o in outcomes:
            odds = o.get("odds")
            if odds is None:
                continue
            ath = o.get("athlete", {})
            name = f"{ath.get('firstName', '')} {ath.get('lastName', '')}".strip()
            results.append({
                "athlete": name,
                "odds": odds,
                "probability": round(1.0 / odds, 4) if odds > 0 else 0,
                "american": convert_odds(odds, "decimal", "american") if odds >= 1.0 else 0,
                "outcome_id": o.get("outcomeId") or o.get("id"),
            })
        results.sort(key=lambda x: x["odds"])
        return results[:n]

    def get_settled_results(self, event_id: str) -> List[Dict[str, Any]]:
        """
        Get settled winners for a completed event.

        Returns only outcomes with settlement == "WIN".

        Args:
            event_id: Event ID

        Returns:
            List of dicts: athlete, odds, settlement, outcome_id
        """
        data = self.get_moneylines(event_id)
        outcomes = data.get("eventWinner", [])
        winners = []
        for o in outcomes:
            if o.get("settlement") != "WIN":
                continue
            ath = o.get("athlete", {})
            name = f"{ath.get('firstName', '')} {ath.get('lastName', '')}".strip()
            winners.append({
                "athlete": name,
                "odds": o.get("odds"),
                "settlement": "WIN",
                "outcome_id": o.get("outcomeId") or o.get("id"),
            })
        return winners

    def get_event_summary(self, event_id: str) -> Dict[str, Any]:
        """
        One-call snapshot: event details + top favorite + available markets.

        Args:
            event_id: Event ID

        Returns:
            Dict with event_name, league, status, start_date, favorite,
            field_size, markets_available, market_count
        """
        event = self.get_event(event_id)
        data = self.get_moneylines(event_id)
        outcomes = data.get("eventWinner", [])

        favorite = None
        valid = [o for o in outcomes if o.get("odds") is not None]
        if valid:
            best = min(valid, key=lambda o: o["odds"])
            ath = best.get("athlete", {})
            odds = best["odds"]
            favorite = {
                "athlete": f"{ath.get('firstName', '')} {ath.get('lastName', '')}".strip(),
                "odds": odds,
                "probability": round(1.0 / odds, 4) if odds > 0 else 0,
            }

        available = []
        if outcomes:
            available.append("eventWinner")
        for market in ["headToHead", "podiums", "propBets", "overUnder", "shows",
                        "fastestLap", "heatWinner", "dreamTeam"]:
            try:
                mdata = self.get_odds(event_id, market)
                if mdata.get(market):
                    available.append(market)
            except Exception:
                pass

        return {
            "event_name": event.name,
            "league": getattr(event, "sportType", None) or self.league,
            "status": getattr(event, "eventStatus", None),
            "start_date": getattr(event, "startDate", None),
            "favorite": favorite,
            "field_size": len(outcomes),
            "markets_available": available,
            "market_count": len(available),
        }

    def to_dataframe(self, items: Any) -> Any:
        """
        Convert a list of Pydantic models (or dicts) to a pandas DataFrame.

        Args:
            items: List of Pydantic models or dicts

        Returns:
            pandas.DataFrame

        Raises:
            ImportError: If pandas is not installed
        """
        try:
            import pandas as pd
        except ImportError:
            raise ImportError(
                "pandas is required for to_dataframe(). Install it: pip install pandas"
            )
        if not items:
            return pd.DataFrame()
        if hasattr(items[0], "model_dump"):
            return pd.DataFrame([i.model_dump() for i in items])
        return pd.DataFrame(items)

    def odds_to_dataframe(
        self, odds_response: Dict[str, Any], market: str = "eventWinner"
    ) -> Any:
        """
        Flatten an odds response into a clean DataFrame with computed columns.

        Adds american odds and implied probability columns automatically.

        Args:
            odds_response: Raw response from get_moneylines() / get_odds()
            market: Market key to extract (default "eventWinner")

        Returns:
            pandas.DataFrame with columns: athlete, odds, probability, american,
            outcome_id, status

        Raises:
            ImportError: If pandas is not installed
        """
        try:
            import pandas as pd
        except ImportError:
            raise ImportError(
                "pandas is required for odds_to_dataframe(). Install it: pip install pandas"
            )
        outcomes = odds_response.get(market, [])
        rows = []
        for o in outcomes:
            ath = o.get("athlete", {})
            name = f"{ath.get('firstName', '')} {ath.get('lastName', '')}".strip()
            odds = o.get("odds")
            row = {
                "athlete": name,
                "odds": odds,
                "probability": round(_to_prob(odds), 2) if odds and odds > 0 else None,
                "american": _d2a(odds) if odds and odds >= 1.0 else None,
                "outcome_id": o.get("outcomeId") or o.get("id"),
                "status": o.get("status"),
            }
            if o.get("settlement"):
                row["settlement"] = o["settlement"]
            rows.append(row)
        return pd.DataFrame(rows)

    # ══════════════════════════════════════════════════════════════════════
    #  SETTLEMENT — structured grading for contract resolution
    # ══════════════════════════════════════════════════════════════════════

    def get_settlement(
        self,
        event_id: str,
        *,
        markets: Optional[List[str]] = None,
    ) -> Dict[str, Any]:
        """
        Get structured settlement data for a completed event.

        Returns per-market grading with timestamps — built for contract
        resolution (prediction markets) and bet grading (sportsbooks).

        Args:
            event_id: Event ID (must be completed)
            markets: Specific market types to check. Default: all available.

        Returns:
            Dict with:
                - event_id, event_name, league, status
                - start_date, end_date, settled_at
                - markets: dict of market_type → settlement data
                    - eventWinner: {outcomes: [{athlete, odds, settlement, outcome_id}]}
                    - headToHead: {matchups: [{player1, player2, winner, settlement}]}
                    - podiums: {outcomes: [{athlete, position, settlement}]}
                    - etc.

        Example::

            result = client.get_settlement(event_id)
            for name, mkt in result['markets'].items():
                print(f"\\n{name}:")
                for o in mkt.get('outcomes', mkt.get('matchups', [])):
                    print(f"  {o}")

        Raises:
            ConfigurationError: If event is not in COMPLETED status.
        """
        event = self.get_event(event_id)

        market_types = markets or _ALL_MARKET_TYPES
        settled_markets: Dict[str, Any] = {}

        for mtype in market_types:
            try:
                # Use raw to avoid odds conversion on settlement data
                data = self._get_raw(f"{_API_PREFIX}/events/{event_id}/{mtype}")
            except Exception:
                continue

            settled = self._extract_settlement(data, mtype)
            if settled:
                settled_markets[mtype] = settled

        report = {
            "event_id": event_id,
            "event_name": event.name,
            "league": event.sportType,
            "status": event.eventStatus,
            "start_date": event.startDate,
            "end_date": event.endDate,
            "settled_at": event.endDate,  # best available proxy
            "markets": settled_markets,
            "markets_settled": len(settled_markets),
        }

        # Apply odds conversion if set
        if self.odds_format != OddsFormat.DECIMAL:
            report = convert_response(report, self.odds_format)

        return report

    def _extract_settlement(self, data: Dict[str, Any], market_type: str) -> Optional[Dict[str, Any]]:
        """Extract settlement info from raw odds response for a given market type."""
        if market_type in ("eventWinner", "secondPlace", "shows", "podiums",
                           "raceTop3", "raceTop5", "raceTop10", "fastestLap"):
            items = data.get(market_type, [])
            if not items:
                return None
            outcomes = []
            has_settlement = False
            for o in items:
                ath = o.get("athlete", {})
                entry = {
                    "athlete": f"{ath.get('firstName', '')} {ath.get('lastName', '')}".strip(),
                    "athlete_id": ath.get("id"),
                    "outcome_id": o.get("outcomeId") or o.get("id"),
                    "odds": o.get("odds"),
                    "probability": o.get("probability"),
                    "settlement": o.get("settlement"),
                    "status": o.get("status"),
                    "position": o.get("position"),
                    "updated_at": o.get("updatedAt"),
                }
                if o.get("settlement"):
                    has_settlement = True
                outcomes.append(entry)
            if not has_settlement:
                return None
            winners = [o for o in outcomes if o.get("settlement") == "WIN"]
            return {
                "outcomes": outcomes,
                "winners": winners,
                "total_outcomes": len(outcomes),
            }

        elif market_type == "headToHead":
            items = data.get("headToHead", [])
            if not items:
                return None
            matchups = []
            has_settlement = False
            for m in items:
                p1 = m.get("eventParticipant1", {})
                p2 = m.get("eventParticipant2", {})
                a1 = p1.get("athlete", {})
                a2 = p2.get("athlete", {})
                entry = {
                    "player1": f"{a1.get('firstName', '')} {a1.get('lastName', '')}".strip(),
                    "player1_odds": p1.get("odds"),
                    "player1_settlement": p1.get("settlement"),
                    "player2": f"{a2.get('firstName', '')} {a2.get('lastName', '')}".strip(),
                    "player2_odds": p2.get("odds"),
                    "player2_settlement": p2.get("settlement"),
                    "winner_id": m.get("winnerParticipantId"),
                    "matchup_id": m.get("outcomeId") or m.get("id"),
                }
                if p1.get("settlement") or p2.get("settlement"):
                    has_settlement = True
                matchups.append(entry)
            if not has_settlement:
                return None
            return {
                "matchups": matchups,
                "total_matchups": len(matchups),
            }

        elif market_type == "propBets":
            items = data.get("propBets", [])
            if not items:
                return None
            props = []
            has_settlement = False
            for p in items:
                ath = p.get("athlete", {})
                entry = {
                    "athlete": f"{ath.get('firstName', '')} {ath.get('lastName', '')}".strip(),
                    "proposition": p.get("proposition"),
                    "odds": p.get("odds"),
                    "settlement": p.get("settlement"),
                    "outcome_id": p.get("outcomeId") or p.get("id"),
                }
                if p.get("settlement"):
                    has_settlement = True
                props.append(entry)
            if not has_settlement:
                return None
            return {
                "props": props,
                "total_props": len(props),
            }

        elif market_type in ("overUnder", "multiOverUnder"):
            raw = data.get(market_type, [])
            if not raw:
                return None
            return {"raw": raw}

        elif market_type in ("eventExacta", "heatExacta"):
            raw = data.get(market_type, [])
            if not raw:
                return None
            items = []
            has_settlement = False
            for ex in raw:
                entry = {
                    "outcome_id": ex.get("outcomeId") or ex.get("id"),
                    "odds": ex.get("odds"),
                    "probability": ex.get("probability"),
                    "settlement": ex.get("settlement"),
                    "athletes": ex.get("athletes", []),
                }
                if ex.get("settlement"):
                    has_settlement = True
                items.append(entry)
            if not has_settlement:
                return None
            return {"exactas": items, "total": len(items)}

        return None

    # ══════════════════════════════════════════════════════════════════════
    #  LIVE POLLING — generator-based real-time feed
    # ══════════════════════════════════════════════════════════════════════

    def poll_odds(
        self,
        event_id: str,
        market: Union[OddType, str] = "eventWinner",
        *,
        interval: float = 5.0,
        max_polls: Optional[int] = None,
    ) -> Iterator[Dict[str, Any]]:
        """
        Poll odds for an event at regular intervals — generator-based live feed.

        Yields the latest odds each interval. Stops when max_polls is reached
        or the caller breaks the loop.

        Args:
            event_id: Event ID to watch
            market: Market type to poll (default: eventWinner)
            interval: Seconds between polls (default: 5)
            max_polls: Max number of polls (None = infinite)

        Yields:
            Odds dict with timestamp metadata:
                {**odds_data, "_polled_at": ISO timestamp, "_poll_number": int}

        Example::

            # Watch live moneylines, stop after 60 polls (5 min)
            for update in client.poll_odds(event_id, "moneyline", max_polls=60):
                for o in update.get("eventWinner", []):
                    print(f"  {o['athlete']['lastName']}: {o['odds']}")
        """
        import datetime
        poll_num = 0
        while max_polls is None or poll_num < max_polls:
            try:
                data = self.get_odds(event_id, market)
                data["_polled_at"] = datetime.datetime.now(
                    datetime.timezone.utc
                ).isoformat()
                data["_poll_number"] = poll_num
                yield data
            except Exception as e:
                logger.warning(f"Poll {poll_num} failed: {e}")
                yield {"_error": str(e), "_poll_number": poll_num}
            poll_num += 1
            if max_polls is None or poll_num < max_polls:
                time.sleep(interval)

    # ══════════════════════════════════════════════════════════════════════
    #  PARLAYS (SGP)
    # ══════════════════════════════════════════════════════════════════════

    def calculate_parlay(self, event_id: str, picks: List[str]) -> Dict[str, Any]:
        """
        Same Game Parlay — calculate combined odds for multiple picks.

        Args:
            event_id: Event ID
            picks: List of outcome IDs (from odds responses) to combine.
                   Must be at least 2 selections from different market types.

        Returns:
            {"decimalOdds": float, "probability": float, "viggedProbability": float}

        Note:
            SGP availability varies by league. Not all leagues support parlays.
        """
        return self._post(f"{_API_PREFIX}/events/{event_id}/sgp", body={"oddIds": picks})

    # alias
    calculate_sgp = calculate_parlay

    # ══════════════════════════════════════════════════════════════════════
    #  FUTURES
    # ══════════════════════════════════════════════════════════════════════

    def list_futures(self) -> ResultSet:
        """List all available futures across leagues."""
        data = self._get(f"{_API_PREFIX}/futures")
        items = [FutureListing.model_validate(i) for i in data] if isinstance(data, list) else []
        return ResultSet(items, _title="Futures")

    def get_futures(
        self,
        tour: str,
        type: Union[FutureType, str] = "winner",
        *,
        league: Optional[Union[SportType, str]] = None,
    ) -> List[FutureOdds]:
        """
        Get futures odds.

        Args:
            tour: Tour ID
            type: "winner", "top2", "top3", "top5", "top10", "makeCut", "makePlayOdds"
            league: Override client league
        """
        league_val = _enum_val(league, SportType) if league else self.league
        if not league_val:
            raise ConfigurationError(
                "League required for futures. Set league= on client or pass league= here."
            )
        ft = _enum_val(type, FutureType)
        data = self._get(f"{_API_PREFIX}/futures/{league_val}/tour/{tour}/odds/{ft}")
        return [FutureOdds.model_validate(i) for i in data] if isinstance(data, list) else []

    # ══════════════════════════════════════════════════════════════════════
    #  JAI ALAI (team-based match format)
    # ══════════════════════════════════════════════════════════════════════

    def get_jaialai_matches(self, event_id: str) -> List[JaiAlaiEvent]:
        """Get Jai Alai match details with set scores."""
        data = self._get(f"{_API_PREFIX}/events/jaialai/{event_id}")
        return [JaiAlaiEvent.model_validate(i) for i in data] if isinstance(data, list) else []

    def get_jaialai_odds(self, event_id: str) -> JaiAlaiOdds:
        """Get Jai Alai odds (moneyline, spread, totals per set)."""
        return JaiAlaiOdds.model_validate(
            self._get(f"{_API_PREFIX}/events/jaialai/{event_id}/odds")
        )

    # ══════════════════════════════════════════════════════════════════════
    #  CONVERSION UTILITY
    # ══════════════════════════════════════════════════════════════════════

    @staticmethod
    def convert(
        value: float,
        from_format: str = "decimal",
        to_format: str = "american",
    ) -> Union[float, str]:
        """
        Convert a single odds value between formats.

        Static method — no client instance needed.

        Args:
            value: Odds value to convert
            from_format: Source format (decimal, american, probability, fractional)
            to_format: Target format

        Returns:
            Converted value.

        Example::

            AltSportsData.convert(2.50, "decimal", "american")     # → 150.0
            AltSportsData.convert(2.50, "decimal", "probability")  # → 0.4
        """
        return convert_odds(value, from_format, to_format)

    # ══════════════════════════════════════════════════════════════════════
    #  INTERNAL — HTTP with retry, tracing, conversion
    # ══════════════════════════════════════════════════════════════════════

    def _build_event_params(
        self,
        league=None, status=None,
        start_date=None, end_date=None, sort=None,
    ) -> Dict[str, Any]:
        params: Dict[str, Any] = {}
        lv = _enum_val(league, SportType) if league else self.league
        if lv:
            params["sportType"] = lv
        if status is not None:
            if isinstance(status, (str, EventStatus)):
                status = [status]
            params["eventStatus"] = [_resolve_status(s) for s in status]
        if start_date:
            params["startDate"] = start_date
        if end_date:
            params["endDate"] = end_date
        if sort:
            params["sortOrder"] = sort.upper()
        return params

    def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
        """GET with retry, tracing, and odds format conversion."""
        data = self._request("GET", path, params=params)
        if self.odds_format != OddsFormat.DECIMAL:
            data = convert_response(data, self.odds_format)
        return data

    def _get_raw(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
        """GET without odds format conversion (internal use)."""
        return self._request("GET", path, params=params)

    def _post(self, path: str, body: Optional[Dict[str, Any]] = None) -> Any:
        data = self._request("POST", path, body=body)
        if self.odds_format != OddsFormat.DECIMAL:
            data = convert_response(data, self.odds_format)
        return data

    def _request(self, method: str, path: str, params=None, body=None) -> Any:
        """
        Execute HTTP request with automatic retry, exponential backoff,
        jitter, and request tracing.
        """
        url = f"{self.base_url}{path}"

        # Flatten list params to bracket notation
        req_params = params
        if params:
            flat = []
            for k, v in params.items():
                if isinstance(v, list):
                    for item in v:
                        flat.append((f"{k}[]", item))
                else:
                    flat.append((k, v))
            req_params = flat

        # Generate request ID for tracing
        request_id = str(uuid.uuid4())[:8]

        last_exception: Optional[Exception] = None

        for attempt in range(self.max_retries + 1):
            try:
                logger.debug(
                    "Request %s [%s] %s %s (attempt %d/%d)",
                    request_id, method, url,
                    f"params={params}" if params else "",
                    attempt + 1, self.max_retries + 1,
                )

                r = self._session.request(
                    method=method,
                    url=url,
                    params=req_params,
                    json=body,
                    timeout=self.timeout,
                    headers={"X-Request-ID": f"asd-{request_id}-{attempt}"},
                )

                if r.ok:
                    resp_body = r.json()
                    # Unwrap API envelope: {"status": 200, "data": [...]}
                    if isinstance(resp_body, dict) and "data" in resp_body and \
                            ("status" in resp_body or "statusCode" in resp_body):
                        return resp_body["data"]
                    return resp_body

                # Retryable status?
                if r.status_code in _RETRYABLE_STATUSES and attempt < self.max_retries:
                    delay = self._retry_delay(r, attempt)
                    logger.info(
                        "Request %s: HTTP %d, retrying in %.1fs (attempt %d/%d)",
                        request_id, r.status_code, delay,
                        attempt + 1, self.max_retries + 1,
                    )
                    time.sleep(delay)
                    continue

                # Non-retryable or retries exhausted — raise
                try:
                    msg = r.json().get("message", f"HTTP {r.status_code}")
                except Exception:
                    msg = f"HTTP {r.status_code}: {r.text}"

                if r.status_code in _RETRYABLE_STATUSES:
                    # Retries exhausted for a retryable error
                    raise RetryExhaustedError(
                        f"All {self.max_retries + 1} attempts failed. Last: {msg}",
                        status_code=r.status_code,
                        attempts=attempt + 1,
                    )
                raise_for_status(r.status_code, msg)

            except requests.exceptions.Timeout as e:
                last_exception = e
                if attempt < self.max_retries:
                    delay = self.retry_backoff * (2 ** attempt) + random.uniform(0, 0.5)
                    logger.info(
                        "Request %s: timeout, retrying in %.1fs (attempt %d/%d)",
                        request_id, delay, attempt + 1, self.max_retries + 1,
                    )
                    time.sleep(delay)
                    continue
                raise NetworkError(
                    f"Request timed out after {self.timeout}s "
                    f"({self.max_retries + 1} attempts)"
                )

            except requests.exceptions.ConnectionError as e:
                last_exception = e
                if attempt < self.max_retries:
                    delay = self.retry_backoff * (2 ** attempt) + random.uniform(0, 0.5)
                    logger.info(
                        "Request %s: connection error, retrying in %.1fs (attempt %d/%d)",
                        request_id, delay, attempt + 1, self.max_retries + 1,
                    )
                    time.sleep(delay)
                    continue
                raise NetworkError(
                    f"Failed to connect to AltSportsData API "
                    f"({self.max_retries + 1} attempts)"
                )

            except requests.exceptions.RequestException as e:
                raise NetworkError(f"Network error: {e}")

        # Should not reach here, but safety net
        raise NetworkError(
            f"Request failed after {self.max_retries + 1} attempts: {last_exception}"
        )

    def _retry_delay(self, response: requests.Response, attempt: int) -> float:
        """Calculate retry delay, respecting Retry-After header if present."""
        retry_after = response.headers.get("Retry-After")
        if retry_after:
            try:
                return max(float(retry_after), 0.1)
            except ValueError:
                pass
        # Exponential backoff with jitter
        return self.retry_backoff * (2 ** attempt) + random.uniform(0, 0.5)

    def __repr__(self) -> str:
        parts = [f"base_url='{self.base_url}'"]
        if self.league:
            parts.append(f"league='{self.league}'")
        if self.odds_format != OddsFormat.DECIMAL:
            parts.append(f"odds_format='{self.odds_format.value}'")
        return f"AltSportsData({', '.join(parts)})"

    def close(self) -> None:
        """Close the underlying HTTP session."""
        self._session.close()

    def __enter__(self) -> "AltSportsData":
        return self

    def __exit__(self, *args) -> None:
        self.close()
