"""Resolution sniping for prediction markets.

Detects when a prediction market's underlying event has resolved (or is about
to resolve) by monitoring news sources and using LLM analysis. When high
confidence resolution is detected, generates aggressive quotes to capture the
resolution payout.

Typical usage::

    # Standalone scan
    signals = hz.scan_resolutions(markets)

    # Pipeline integration
    hz.run(pipeline=[hz.resolution_sniper()])
"""

from __future__ import annotations

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

from horizon._horizon import Quote
from horizon.context import Context

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


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


@dataclass(frozen=True)
class ResolutionSignal:
    """Signal indicating a market has potentially resolved.

    Attributes:
        market_id: Market identifier.
        market_title: Human-readable market question.
        resolved: Whether the LLM believes the event has resolved.
        confidence: Confidence in the resolution detection [0, 1].
        resolution_side: Expected resolution side ("yes" or "no").
        evidence: Headlines or facts triggering the detection.
        reasoning: LLM reasoning text.
        timestamp: Unix timestamp of detection.
    """

    market_id: str
    market_title: str
    resolved: bool
    confidence: float
    resolution_side: str
    evidence: list[str]
    reasoning: str
    timestamp: float


@dataclass
class SniperConfig:
    """Configuration for resolution sniping.

    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 monitor for resolution events.
        exa_query: Optional Exa.ai semantic search query. Env: ``EXA_API_KEY``.
        tavily_query: Optional Tavily real-time search query. Env: ``TAVILY_API_KEY``.
        confidence_threshold: Minimum confidence to trigger a snipe.
        max_position_size: Maximum position size in USDC.
        price_offset: Offset from 1.0 for aggressive pricing.
        scan_interval_cycles: Cycles between resolution scans.
        cache_ttl: Cache time-to-live in seconds.
    """

    provider: str = "anthropic"
    model: str = ""
    news_sources: list[str] = field(default_factory=list)
    exa_query: str = ""
    tavily_query: str = ""
    confidence_threshold: float = 0.85
    max_position_size: float = 50.0
    price_offset: float = 0.02
    scan_interval_cycles: int = 5
    cache_ttl: float = 60.0


# ---------------------------------------------------------------------------
# Module-level cache
# ---------------------------------------------------------------------------

_signal_cache: dict[str, tuple[float, ResolutionSignal]] = {}
_seen_evidence: set[str] = set()


# ---------------------------------------------------------------------------
# LLM prompt + parsing
# ---------------------------------------------------------------------------


def _build_sniper_prompt(title: str, news_items: list[Any]) -> str:
    """Ask the LLM whether this event has resolved."""
    news_text = ""
    if news_items:
        lines = []
        for i, item in enumerate(news_items, 1):
            lines.append(f"  {i}. {item.title}")
            if item.summary:
                lines.append(f"     {item.summary[:200]}")
        news_text = "\n".join(lines)
    else:
        news_text = "  (No recent news available)"

    return (
        "You are a prediction market resolution detector. Determine if this event has been resolved.\n"
        "\n"
        f'Market question: "{title}"\n'
        "\n"
        "Recent news:\n"
        f"{news_text}\n"
        "\n"
        "Has this event definitively resolved? Return ONLY valid JSON:\n"
        "{\n"
        '  "resolved": true/false,\n'
        '  "confidence": 0.XX,\n'
        '  "resolution_side": "yes" or "no",\n'
        '  "evidence": ["key fact 1", "key fact 2"],\n'
        '  "reasoning": "1-2 sentence explanation"\n'
        "}\n"
    )


def _parse_sniper_response(
    text: str, market_id: str, market_title: str,
) -> ResolutionSignal:
    """Parse LLM response into a ResolutionSignal."""
    now = time.time()

    defaults = ResolutionSignal(
        market_id=market_id,
        market_title=market_title,
        resolved=False,
        confidence=0.0,
        resolution_side="yes",
        evidence=[],
        reasoning="Unable to parse response",
        timestamp=now,
    )

    try:
        json_match = re.search(r'\{[^{}]*\}', text, re.DOTALL)
        if not json_match:
            return defaults

        data = json.loads(json_match.group())

        resolved = bool(data.get("resolved", False))
        confidence = float(data.get("confidence", 0.0))
        confidence = max(0.0, min(1.0, confidence))

        resolution_side = str(data.get("resolution_side", "yes")).lower()
        if resolution_side not in ("yes", "no"):
            resolution_side = "yes"

        evidence = [str(e) for e in data.get("evidence", [])]
        reasoning = str(data.get("reasoning", ""))

        return ResolutionSignal(
            market_id=market_id,
            market_title=market_title,
            resolved=resolved,
            confidence=round(confidence, 4),
            resolution_side=resolution_side,
            evidence=evidence,
            reasoning=reasoning,
            timestamp=now,
        )
    except (json.JSONDecodeError, ValueError, TypeError) as exc:
        logger.debug("Sniper response parse failed: %s", exc)
        return defaults


def _call_sniper_llm(prompt: str, config: SniperConfig) -> str:
    """Call LLM for resolution detection. Returns raw text."""
    from horizon.llm import _call_llm_raw

    return _call_llm_raw(
        prompt, config.provider, config.model,
        max_tokens=512, temperature=0.1,
    )


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


def scan_resolutions(
    markets: list[dict[str, Any]],
    config: SniperConfig | None = None,
) -> list[ResolutionSignal]:
    """Scan markets for potential resolution events.

    Fetches news and uses LLM to determine if any markets have resolved.

    Args:
        markets: List of dicts with ``"id"`` and ``"title"`` keys.
        config: Sniper configuration.

    Returns:
        List of :class:`ResolutionSignal` for markets where resolution was detected.
    """
    config = config or SniperConfig()
    results: list[ResolutionSignal] = []

    # Fetch news once for all markets
    from horizon.llm import _fetch_news_rss, _fetch_news_exa, _fetch_news_tavily

    news_items = _fetch_news_rss(config.news_sources, max_items=10)
    if config.exa_query:
        news_items.extend(_fetch_news_exa(config.exa_query, max_items=10))
    if config.tavily_query:
        news_items.extend(_fetch_news_tavily(config.tavily_query, max_items=10))

    for mkt in markets:
        mkt_id = str(mkt.get("id", ""))
        mkt_title = str(mkt.get("title", ""))
        if not mkt_id:
            continue

        # Bound _signal_cache: clear when exceeding 1000 entries
        if len(_signal_cache) > 1000:
            _signal_cache.clear()

        # Check cache
        now = time.time()
        if mkt_id in _signal_cache:
            ts, cached = _signal_cache[mkt_id]
            if now - ts < config.cache_ttl:
                if cached.resolved:
                    results.append(cached)
                continue

        try:
            prompt = _build_sniper_prompt(mkt_title, news_items)
            response_text = _call_sniper_llm(prompt, config)

            if not response_text:
                continue

            signal = _parse_sniper_response(response_text, mkt_id, mkt_title)
            _signal_cache[mkt_id] = (now, signal)

            if signal.resolved and signal.confidence >= config.confidence_threshold:
                results.append(signal)
                logger.info(
                    "Resolution detected for %s: side=%s confidence=%.2f",
                    mkt_id[:12], signal.resolution_side, signal.confidence,
                )
        except Exception as exc:
            logger.warning("scan_resolutions: failed for %s: %s", mkt_id[:12], exc)

    return results


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


def resolution_sniper(
    config: SniperConfig | None = None,
) -> Callable[[Context], list[Quote] | None]:
    """Create a pipeline function that snipes market resolutions.

    Periodically scans news to detect if the current market has resolved.
    When resolution is detected with sufficient confidence, generates an
    aggressive quote to buy the resolving side at near-parity.

    Args:
        config: Sniper configuration.

    Returns:
        Pipeline function that returns ``list[Quote]`` on resolution
        detection, or ``None`` otherwise.
    """
    config = config or SniperConfig()

    cycle_count = 0
    triggered: dict[str, bool] = {}

    def _inner(ctx: Context) -> list[Quote] | None:
        nonlocal cycle_count
        cycle_count += 1

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

        # Don't re-trigger for same market
        if triggered.get(market_id, False):
            return None

        # Only scan at interval
        if cycle_count % config.scan_interval_cycles != 0:
            return None

        # Fetch news and check resolution
        from horizon.llm import _fetch_news_rss, _fetch_news_exa, _fetch_news_tavily

        news_items = _fetch_news_rss(config.news_sources, max_items=5)
        if config.exa_query:
            news_items.extend(_fetch_news_exa(config.exa_query, max_items=5))
        if config.tavily_query:
            news_items.extend(_fetch_news_tavily(config.tavily_query, max_items=5))
        prompt = _build_sniper_prompt(market.name, news_items)
        response_text = _call_sniper_llm(prompt, config)

        if not response_text:
            return None

        signal = _parse_sniper_response(response_text, market_id, market.name)

        # Inject signal into context
        ctx.params["sniper_signal"] = signal

        if not signal.resolved or signal.confidence < config.confidence_threshold:
            return None

        # Check for new evidence (avoid re-triggering on same news)
        evidence_key = "|".join(sorted(signal.evidence))
        if evidence_key in _seen_evidence:
            return None
        # Bound _seen_evidence: clear oldest half when exceeding 10000
        if len(_seen_evidence) > 10000:
            to_remove = list(_seen_evidence)[:len(_seen_evidence) // 2]
            for item in to_remove:
                _seen_evidence.discard(item)
        _seen_evidence.add(evidence_key)

        # Generate aggressive quote
        triggered[market_id] = True

        if signal.resolution_side == "yes":
            # Buy YES at aggressive price (near 1.0)
            bid = 1.0 - config.price_offset
            ask = 0.99
        else:
            # Buy NO = sell YES at aggressive price (near 0.0)
            bid = 0.01
            ask = config.price_offset

        size = min(config.max_position_size, 50.0)

        logger.info(
            "SNIPE: %s resolved %s (confidence=%.2f), quoting bid=%.2f ask=%.2f size=%.1f",
            market_id[:12], signal.resolution_side, signal.confidence,
            bid, ask, size,
        )

        return [Quote(bid=bid, ask=ask, size=size)]

    _inner.__name__ = "resolution_sniper"
    return _inner
