"""LLM-powered probability forecasting for prediction markets.

Combines news fetching (RSS, NewsAPI, Exa.ai, Tavily) with LLM-based
probability estimation to generate probability forecasts and detect edge
opportunities. Uses litellm for provider-agnostic LLM calls (100+ providers
including OpenRouter), with fallback to direct Anthropic/OpenAI SDK calls.

Typical usage::

    # One-shot forecast
    fc = hz.llm_forecast("market-123", "Will BTC hit 100k?", 0.65)

    # Scan for edges
    edges = hz.llm_scan(markets, min_edge_bps=300)

    # Pipeline integration
    hz.run(pipeline=[hz.llm_signal(forecast_interval_cycles=10), my_quoter])
"""

from __future__ import annotations

import json
import logging
import os
import re
import time
from dataclasses import dataclass, field
from typing import Any, Callable

from horizon.context import Context

logger = logging.getLogger("horizon.llm")


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


@dataclass(frozen=True)
class NewsItem:
    """A single news item from an RSS feed or NewsAPI.

    Attributes:
        title: Headline text.
        summary: Brief summary or description.
        url: Source URL.
        published: Unix timestamp of publication.
        source: Origin identifier (``"rss"``, ``"newsapi"``, ``"exa"``,
            ``"tavily"``, ``"user"``).
    """

    title: str
    summary: str
    url: str
    published: float
    source: str  # "rss", "newsapi", "exa", "tavily", "user"


@dataclass(frozen=True)
class LLMForecast:
    """LLM-generated probability forecast for a prediction market.

    Attributes:
        market_id: Market identifier.
        market_title: Human-readable market question.
        llm_prob: LLM-estimated probability, clamped to [0.01, 0.99].
        reasoning: LLM's reasoning text.
        confidence: Self-reported confidence [0, 1].
        edge_bps: ``(llm_prob - market_price) * 10000``.
        market_price: Market mid price at forecast time.
        model: Model identifier used for the forecast.
        news_sources: URLs of news items used.
        timestamp: Unix timestamp of forecast generation.
    """

    market_id: str
    market_title: str
    llm_prob: float
    reasoning: str
    confidence: float
    edge_bps: float
    market_price: float
    model: str
    news_sources: list[str]
    timestamp: float


@dataclass
class LLMConfig:
    """Configuration for LLM-based forecasting.

    Attributes:
        provider: LLM provider (``"anthropic"``, ``"openai"``, or any
            litellm-supported provider).
        model: Model name. Empty string uses provider default. Accepts
            litellm-qualified strings like ``"openrouter/meta-llama/..."``.
        news_sources: RSS feed URLs to fetch news from.
        newsapi_query: Optional NewsAPI search query.
        exa_query: Optional Exa.ai semantic search query. Env: ``EXA_API_KEY``.
        tavily_query: Optional Tavily real-time search query. Env: ``TAVILY_API_KEY``.
        max_news_items: Max news items to include in prompt.
        cache_ttl: Cache time-to-live in seconds.
        rate_limit_per_minute: Max LLM calls per minute.
        max_tokens: Max response tokens for LLM call.
        temperature: LLM sampling temperature.
    """

    provider: str = "anthropic"
    model: str = ""
    news_sources: list[str] = field(default_factory=list)
    newsapi_query: str = ""
    exa_query: str = ""
    tavily_query: str = ""
    max_news_items: int = 5
    cache_ttl: float = 300.0
    rate_limit_per_minute: int = 10
    max_tokens: int = 1024
    temperature: float = 0.2


# ---------------------------------------------------------------------------
# Module-level caches
# ---------------------------------------------------------------------------

_forecast_cache: dict[str, tuple[float, LLMForecast]] = {}
_call_timestamps: list[float] = []


# ---------------------------------------------------------------------------
# News fetching
# ---------------------------------------------------------------------------


def _fetch_news_rss(urls: list[str], max_items: int = 5) -> list[NewsItem]:
    """Parse RSS feeds and return news items."""
    if not urls:
        return []

    try:
        import feedparser
    except ImportError:
        logger.warning("feedparser not installed. Install with: pip install feedparser")
        return []

    items: list[NewsItem] = []
    for url in urls:
        try:
            feed = feedparser.parse(url)
            for entry in feed.entries[:max_items]:
                published = 0.0
                if hasattr(entry, "published_parsed") and entry.published_parsed:
                    import calendar
                    published = float(calendar.timegm(entry.published_parsed))

                items.append(NewsItem(
                    title=getattr(entry, "title", ""),
                    summary=getattr(entry, "summary", "")[:500],
                    url=getattr(entry, "link", ""),
                    published=published,
                    source="rss",
                ))
        except Exception as exc:
            logger.debug("RSS fetch failed for %s: %s", url, exc)

    # Sort by recency, limit
    items.sort(key=lambda x: x.published, reverse=True)
    return items[:max_items]


def _fetch_news_api(query: str, max_items: int = 5) -> list[NewsItem]:
    """Fetch news from NewsAPI.org."""
    api_key = os.environ.get("NEWSAPI_KEY")
    if not api_key or not query:
        return []

    try:
        import requests
    except ImportError:
        logger.warning("requests not installed")
        return []

    try:
        resp = requests.get(
            "https://newsapi.org/v2/everything",
            params={"q": query, "pageSize": max_items, "sortBy": "publishedAt"},
            headers={"X-Api-Key": api_key},
            timeout=10,
        )
        if not resp.ok:
            logger.debug("NewsAPI returned %d", resp.status_code)
            return []

        data = resp.json()
        items: list[NewsItem] = []
        for article in data.get("articles", [])[:max_items]:
            published = 0.0
            pub_str = article.get("publishedAt", "")
            if pub_str:
                try:
                    from datetime import datetime, timezone
                    dt = datetime.fromisoformat(pub_str.replace("Z", "+00:00"))
                    published = dt.timestamp()
                except (ValueError, TypeError):
                    pass

            items.append(NewsItem(
                title=article.get("title", ""),
                summary=(article.get("description") or "")[:500],
                url=article.get("url", ""),
                published=published,
                source="newsapi",
            ))
        return items
    except Exception as exc:
        logger.debug("NewsAPI fetch failed: %s", exc)
        return []


def _fetch_news_exa(query: str, max_items: int = 5) -> list[NewsItem]:
    """Fetch news via Exa.ai semantic search. Env: ``EXA_API_KEY``."""
    api_key = os.environ.get("EXA_API_KEY")
    if not api_key or not query:
        return []

    try:
        from exa_py import Exa
    except ImportError:
        logger.warning("exa_py not installed. Install with: pip install exa-py")
        return []

    try:
        client = Exa(api_key=api_key)
        results = client.search_and_contents(
            query,
            num_results=max_items,
            use_autoprompt=True,
            text=True,
        )

        items: list[NewsItem] = []
        for r in results.results[:max_items]:
            published = 0.0
            if hasattr(r, "published_date") and r.published_date:
                try:
                    from datetime import datetime, timezone

                    dt = datetime.fromisoformat(
                        str(r.published_date).replace("Z", "+00:00")
                    )
                    published = dt.timestamp()
                except (ValueError, TypeError):
                    pass

            items.append(NewsItem(
                title=getattr(r, "title", "") or "",
                summary=(getattr(r, "text", "") or "")[:500],
                url=getattr(r, "url", "") or "",
                published=published,
                source="exa",
            ))
        return items
    except Exception as exc:
        logger.debug("Exa.ai fetch failed: %s", exc)
        return []


def _fetch_news_tavily(query: str, max_items: int = 5) -> list[NewsItem]:
    """Fetch news via Tavily real-time search. Env: ``TAVILY_API_KEY``."""
    api_key = os.environ.get("TAVILY_API_KEY")
    if not api_key or not query:
        return []

    try:
        from tavily import TavilyClient
    except ImportError:
        logger.warning("tavily not installed. Install with: pip install tavily-python")
        return []

    try:
        client = TavilyClient(api_key=api_key)
        response = client.search(query, max_results=max_items)

        items: list[NewsItem] = []
        for r in response.get("results", [])[:max_items]:
            published = 0.0
            pub_str = r.get("published_date", "")
            if pub_str:
                try:
                    from datetime import datetime, timezone

                    dt = datetime.fromisoformat(
                        str(pub_str).replace("Z", "+00:00")
                    )
                    published = dt.timestamp()
                except (ValueError, TypeError):
                    pass

            items.append(NewsItem(
                title=r.get("title", ""),
                summary=(r.get("content", "") or "")[:500],
                url=r.get("url", ""),
                published=published,
                source="tavily",
            ))
        return items
    except Exception as exc:
        logger.debug("Tavily fetch failed: %s", exc)
        return []


# ---------------------------------------------------------------------------
# LLM prompt + calling
# ---------------------------------------------------------------------------


def _build_forecast_prompt(title: str, price: float, news: list[NewsItem]) -> str:
    """Build a structured prompt for LLM probability estimation."""
    news_text = ""
    if news:
        news_lines = []
        for i, item in enumerate(news, 1):
            news_lines.append(f"  {i}. {item.title}")
            if item.summary:
                news_lines.append(f"     {item.summary[:200]}")
        news_text = "\n".join(news_lines)
    else:
        news_text = "  (No recent news available)"

    return (
        "You are a prediction market analyst. Estimate the probability this market resolves YES.\n"
        "\n"
        f'Market: "{title}"\n'
        f"Current price: {price:.4f} ({price * 100:.0f}% implied)\n"
        "\n"
        "Recent news:\n"
        f"{news_text}\n"
        "\n"
        'Return ONLY valid JSON: {"probability": 0.XX, "reasoning": "...", "confidence": 0.XX}\n'
        "- probability: your estimate between 0.01 and 0.99\n"
        "- reasoning: 1-3 sentence explanation\n"
        "- confidence: how confident you are in your estimate (0.0 to 1.0)\n"
    )


def _resolve_model(provider: str, model: str) -> str:
    """Map (provider, model) to a litellm-compatible model string.

    If *model* already contains ``/`` it is returned as-is (e.g.
    ``"openrouter/meta-llama/llama-3-70b"``).  Otherwise, a provider
    prefix is prepended so litellm routes to the correct backend.
    """
    if model and "/" in model:
        return model  # already qualified
    defaults = {"anthropic": "claude-sonnet-4-20250514", "openai": "gpt-4o-mini"}
    base = model or defaults.get(provider, model)
    prefix = {"anthropic": "anthropic/", "openai": "openai/"}
    return prefix.get(provider, "") + base


def _call_llm_raw(
    prompt: str,
    provider: str = "anthropic",
    model: str = "",
    max_tokens: int = 1024,
    temperature: float = 0.2,
) -> str:
    """Call an LLM via litellm and return the raw response text.

    Falls back to direct Anthropic / OpenAI SDK calls when litellm is
    not installed, preserving backward compatibility.
    """
    resolved = _resolve_model(provider, model)

    # --- Try litellm first ---------------------------------------------------
    try:
        import litellm  # noqa: F811

        response = litellm.completion(
            model=resolved,
            messages=[{"role": "user", "content": prompt}],
            max_tokens=max_tokens,
            temperature=temperature,
        )
        return response.choices[0].message.content or ""
    except ImportError:
        pass  # litellm not installed -- fall through to direct SDK
    except Exception as exc:
        logger.warning("litellm call failed: %s", exc)
        return ""

    # --- Fallback: direct SDK -------------------------------------------------
    if provider == "anthropic":
        api_key = os.environ.get("ANTHROPIC_API_KEY")
        if not api_key:
            logger.warning("ANTHROPIC_API_KEY not set")
            return ""
        try:
            import anthropic

            client = anthropic.Anthropic(api_key=api_key)
            bare_model = model or "claude-sonnet-4-20250514"
            response = client.messages.create(
                model=bare_model,
                max_tokens=max_tokens,
                temperature=temperature,
                messages=[{"role": "user", "content": prompt}],
            )
            return response.content[0].text
        except ImportError:
            logger.warning("anthropic package not installed. Install with: pip install anthropic")
            return ""
        except Exception as exc:
            logger.warning("Anthropic API call failed: %s", exc)
            return ""
    elif provider == "openai":
        api_key = os.environ.get("OPENAI_API_KEY")
        if not api_key:
            logger.warning("OPENAI_API_KEY not set")
            return ""
        try:
            import openai

            client = openai.OpenAI(api_key=api_key)
            bare_model = model or "gpt-4o-mini"
            response = client.chat.completions.create(
                model=bare_model,
                max_tokens=max_tokens,
                temperature=temperature,
                messages=[{"role": "user", "content": prompt}],
            )
            return response.choices[0].message.content
        except ImportError:
            logger.warning("openai package not installed. Install with: pip install openai")
            return ""
        except Exception as exc:
            logger.warning("OpenAI API call failed: %s", exc)
            return ""

    return ""


def _call_llm(
    prompt: str, config: LLMConfig,
) -> tuple[float, str, float]:
    """Call LLM and return (prob, reasoning, confidence)."""
    text = _call_llm_raw(
        prompt,
        provider=config.provider,
        model=config.model,
        max_tokens=config.max_tokens,
        temperature=config.temperature,
    )
    if not text:
        return 0.5, "LLM unavailable", 0.0
    return _parse_llm_response(text)


def _parse_llm_response(text: str) -> tuple[float, str, float]:
    """Parse LLM response into (probability, reasoning, confidence).

    Tries JSON parsing first, then regex fallback.
    """
    prob = 0.5
    reasoning = ""
    confidence = 0.5

    # Try JSON parse
    try:
        # Extract JSON from possible markdown code blocks
        json_match = re.search(r'\{[^{}]*\}', text, re.DOTALL)
        if json_match:
            data = json.loads(json_match.group())
            prob = float(data.get("probability", 0.5))
            reasoning = str(data.get("reasoning", ""))
            confidence = float(data.get("confidence", 0.5))
            # Clamp
            prob = max(0.01, min(0.99, prob))
            confidence = max(0.0, min(1.0, confidence))
            return prob, reasoning, confidence
    except (json.JSONDecodeError, ValueError, TypeError):
        pass

    # Regex fallback
    prob_match = re.search(r'"?probability"?\s*[:=]\s*(0\.\d+)', text)
    if prob_match:
        prob = max(0.01, min(0.99, float(prob_match.group(1))))

    reason_match = re.search(r'"?reasoning"?\s*[:=]\s*"([^"]*)"', text)
    if reason_match:
        reasoning = reason_match.group(1)

    conf_match = re.search(r'"?confidence"?\s*[:=]\s*(0\.\d+|1\.0)', text)
    if conf_match:
        confidence = max(0.0, min(1.0, float(conf_match.group(1))))

    return prob, reasoning, confidence


# ---------------------------------------------------------------------------
# Rate limiting
# ---------------------------------------------------------------------------


def _check_rate_limit(config: LLMConfig) -> bool:
    """Return True if we're within rate limits."""
    global _call_timestamps
    now = time.time()
    # Prune old timestamps
    _call_timestamps = [t for t in _call_timestamps if now - t < 60.0]
    if len(_call_timestamps) >= config.rate_limit_per_minute:
        return False
    _call_timestamps.append(now)
    return True


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------


def llm_forecast(
    market_id: str,
    market_title: str,
    market_price: float,
    news_context: list[NewsItem] | None = None,
    config: LLMConfig | None = None,
) -> LLMForecast:
    """Generate an LLM-based probability forecast for a single market.

    Fetches news, calls the configured LLM provider, and returns a
    :class:`LLMForecast` with probability estimate, reasoning, and edge.

    Args:
        market_id: Market identifier.
        market_title: Human-readable market question.
        market_price: Current market mid price (0-1).
        news_context: Pre-fetched news items. If None, fetches automatically.
        config: LLM configuration. Defaults to ``LLMConfig()``.

    Returns:
        :class:`LLMForecast` with LLM probability and edge estimate.
    """
    config = config or LLMConfig()

    # Check cache
    now = time.time()
    if market_id in _forecast_cache:
        ts, cached = _forecast_cache[market_id]
        if now - ts < config.cache_ttl:
            return cached

    # Rate limit check
    if not _check_rate_limit(config):
        # Return cached if available, else neutral
        if market_id in _forecast_cache:
            return _forecast_cache[market_id][1]
        logger.warning("LLM rate limit exceeded, returning neutral forecast")
        return LLMForecast(
            market_id=market_id,
            market_title=market_title,
            llm_prob=0.5,
            reasoning="Rate limit exceeded",
            confidence=0.0,
            edge_bps=0.0,
            market_price=market_price,
            model=config.model or config.provider,
            news_sources=[],
            timestamp=now,
        )

    # Fetch news if not provided
    if news_context is None:
        news_context = []
        if config.news_sources:
            news_context.extend(_fetch_news_rss(config.news_sources, config.max_news_items))
        if config.newsapi_query:
            news_context.extend(_fetch_news_api(config.newsapi_query, config.max_news_items))
        if config.exa_query:
            news_context.extend(_fetch_news_exa(config.exa_query, config.max_news_items))
        if config.tavily_query:
            news_context.extend(_fetch_news_tavily(config.tavily_query, config.max_news_items))
        # Trim to max
        news_context = news_context[:config.max_news_items]

    # Build prompt and call LLM
    prompt = _build_forecast_prompt(market_title, market_price, news_context)
    prob, reasoning, confidence = _call_llm(prompt, config)

    # Compute edge
    edge_bps = round((prob - market_price) * 10000, 4) if market_price > 0 else 0.0

    model_name = _resolve_model(config.provider, config.model)

    forecast = LLMForecast(
        market_id=market_id,
        market_title=market_title,
        llm_prob=round(prob, 4),
        reasoning=reasoning,
        confidence=round(confidence, 4),
        edge_bps=round(edge_bps, 4),
        market_price=round(market_price, 4),
        model=model_name,
        news_sources=[n.url for n in news_context if n.url],
        timestamp=now,
    )

    # Cache
    _forecast_cache[market_id] = (now, forecast)

    logger.debug(
        "llm_forecast(%s): prob=%.4f edge=%.0fbps confidence=%.2f",
        market_id[:12], prob, edge_bps, confidence,
    )

    return forecast


def llm_scan(
    markets: list[dict[str, Any]],
    min_edge_bps: float = 200.0,
    max_markets: int = 20,
    config: LLMConfig | None = None,
) -> list[LLMForecast]:
    """Scan multiple markets for LLM-detected edges.

    Args:
        markets: List of dicts with ``"id"``, ``"title"``, and ``"price"`` keys.
        min_edge_bps: Minimum absolute edge in basis points to include.
        max_markets: Maximum number of markets to scan.
        config: LLM configuration.

    Returns:
        List of :class:`LLMForecast` with |edge| >= min_edge_bps, sorted by |edge|.
    """
    config = config or LLMConfig()
    results: list[LLMForecast] = []

    for mkt in markets[:max_markets]:
        mkt_id = str(mkt.get("id", ""))
        mkt_title = str(mkt.get("title", ""))
        mkt_price = float(mkt.get("price", 0.5) or 0.5)

        if not mkt_id:
            continue

        try:
            fc = llm_forecast(mkt_id, mkt_title, mkt_price, config=config)
            if abs(fc.edge_bps) >= min_edge_bps:
                results.append(fc)
        except Exception as exc:
            logger.warning("llm_scan: forecast failed for %s: %s", mkt_id[:12], exc)

    results.sort(key=lambda f: abs(f.edge_bps), reverse=True)
    return results


# ---------------------------------------------------------------------------
# Pipeline factory
# ---------------------------------------------------------------------------


def llm_signal(
    config: LLMConfig | None = None,
    forecast_interval_cycles: int = 20,
) -> Callable[[Context], None]:
    """Create a pipeline function that forecasts the current market via LLM.

    The returned callable refreshes the LLM forecast every
    *forecast_interval_cycles* cycles and injects it into ``ctx.params``.

    Injected context parameters:
        ``llm_forecast`` -- the :class:`LLMForecast` object.
        ``llm_edge_bps`` -- ``float``, signed edge in basis points.

    Args:
        config: LLM configuration.
        forecast_interval_cycles: Cycles between forecast refreshes.

    Returns:
        Pipeline function with signature ``(Context) -> None``.
    """
    config = config or LLMConfig()

    forecasts: dict[str, LLMForecast] = {}
    cycle_count = 0

    def _inner(ctx: Context) -> None:
        nonlocal forecasts, cycle_count
        cycle_count += 1

        market = ctx.market
        if market is None:
            return
        market_id = market.id
        if not market_id:
            return

        if cycle_count % forecast_interval_cycles == 0:
            try:
                price = ctx.feed.price if ctx.feed else 0.5
                fc = llm_forecast(
                    market_id,
                    market_title=market.name,
                    market_price=price,
                    config=config,
                )
                forecasts[market_id] = fc
                ctx.params["llm_forecast"] = fc
                ctx.params["llm_edge_bps"] = fc.edge_bps
                logger.info(
                    "LLM forecast for %s: prob=%.4f edge=%.0fbps",
                    market_id[:12], fc.llm_prob, fc.edge_bps,
                )
            except Exception as exc:
                logger.warning("LLM forecast failed for %s: %s", market_id[:12], exc)
        elif market_id in forecasts:
            ctx.params["llm_forecast"] = forecasts[market_id]
            ctx.params["llm_edge_bps"] = forecasts[market_id].edge_bps

    _inner.__name__ = "llm_signal"
    return _inner
