"""Whale discovery, ranking, clustering, and alerting for Polymarket (Polymarket-only -- requires on-chain wallet data).

Scans top markets, collects wallet holders, scores them, detects
coordinated clusters, and generates real-time alerts when high-scoring
wallets make significant moves.  Provides both one-shot analysis
(``scan_galaxy``, ``detect_clusters``, ``auto_target``) and continuous
modes (``galaxy_tracker`` pipeline, ``galaxy_hunt`` standalone loop).

Usage:
    snapshot = hz.scan_galaxy(top_n=20)
    clusters = hz.detect_clusters(wallet_trades, window_secs=3600)
    target = hz.auto_target(snapshot)
    hz.run(pipeline=[hz.galaxy_tracker(scan_interval_cycles=100)])
    hz.galaxy_hunt(top_n=10, size_scale=0.5, exchange="paper")
"""

from __future__ import annotations

import logging
import math
import os
import signal
import time
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Any, Callable

from horizon._horizon import (
    Engine,
    OrderRequest,
    OrderSide,
    RiskConfig,
    Side,
    auth_require_ultra,
)
from horizon.context import Context
from horizon.discovery import top_markets
from horizon.flow import Holder, Trade, get_top_holders, get_wallet_trades, get_market_trades
from horizon.wallet_intel import WalletScore, score_wallet

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


# ---------------------------------------------------------------------------
# Result dataclasses
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class RankedWallet:
    """A wallet ranked by composite score with performance metrics."""

    address: str
    score: float
    win_rate: float
    pnl: float
    sharpe: float
    edge_category: str  # "smart_money", "neutral", "weak_hand", "bot"
    trade_count: int
    position_count: int
    last_active: float


@dataclass(frozen=True)
class WalletCluster:
    """A group of wallets that trade in a correlated manner."""

    wallets: list[str] = field(default_factory=list)
    co_trade_count: int = 0
    avg_delay_secs: float = 0.0
    cluster_type: str = "coincidence"  # "coordinated", "copy_chain", "coincidence"
    shared_markets: list[str] = field(default_factory=list)


@dataclass(frozen=True)
class WhaleAlert:
    """Alert when a high-scoring wallet makes a significant move."""

    wallet: str
    pseudonym: str
    market_id: str
    market_title: str
    action: str  # "entered_long", "entered_short", "increased", "exited"
    size_usdc: float
    significance: float
    timestamp: float


@dataclass(frozen=True)
class GalaxySnapshot:
    """Complete snapshot of a galaxy scan."""

    top_wallets: list[RankedWallet]
    bottom_wallets: list[RankedWallet]
    clusters: list[WalletCluster]
    alerts: list[WhaleAlert]
    scan_time: float
    market_count: int
    wallet_count: int


@dataclass
class GalaxyConfig:
    """Configuration for galaxy scans (mutable)."""

    top_n: int = 20
    min_trades: int = 10
    cluster_window_secs: float = 3600.0
    cluster_min_overlap: int = 3
    alert_min_score: float = 0.3
    alert_min_size_usdc: float = 500.0


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

_score_cache: dict[str, tuple[float, WalletScore]] = {}  # address -> (expiry_ts, score)
_CACHE_TTL = 300.0  # 5 minutes


def _cached_score(address: str, limit: int = 200) -> WalletScore | None:
    """Score a wallet with caching (5-min TTL). Returns None on error."""
    now = time.time()
    if address in _score_cache:
        expiry, cached = _score_cache[address]
        if now < expiry:
            return cached
    try:
        ws = score_wallet(address, limit=limit)
        _score_cache[address] = (now + _CACHE_TTL, ws)
        return ws
    except Exception as exc:
        logger.debug("Failed to score wallet %s: %s", address[:10], exc)
        return None


# ---------------------------------------------------------------------------
# Private helpers
# ---------------------------------------------------------------------------


def _categorize_wallet(score: WalletScore) -> str:
    """Classify wallet edge: smart_money / neutral / weak_hand / bot."""
    cs = score.composite_score
    if cs > 0.5:
        return "smart_money"
    elif cs > 0.0:
        return "neutral"
    elif cs > -0.3:
        return "weak_hand"
    else:
        return "bot"


def _classify_action(trade: Trade, prior_markets: dict[str, str]) -> str:
    """Classify trade action based on prior activity in the market."""
    cid = trade.condition_id
    side = trade.side.upper()
    if cid not in prior_markets:
        return "entered_long" if side == "BUY" else "entered_short"
    prev_side = prior_markets[cid]
    return "increased" if side == prev_side else "exited"


def _generate_alerts(
    wallet_trades: dict[str, list[Trade]],
    scores: dict[str, WalletScore],
    config: GalaxyConfig,
) -> list[WhaleAlert]:
    """Generate whale alerts from recent trades of scored wallets.

    Filters by score >= alert_min_score and size >= alert_min_size_usdc.
    Significance = score * log(1 + size_usdc).  Capped at 50 alerts.
    """
    alerts: list[WhaleAlert] = []

    for wallet, trades in wallet_trades.items():
        ws = scores.get(wallet)
        if ws is None or ws.composite_score < config.alert_min_score:
            continue

        sorted_trades = sorted(trades, key=lambda t: t.timestamp, reverse=True)

        # Build prior-activity map (earliest first)
        prior_markets: dict[str, str] = {}
        for t in reversed(sorted_trades):
            if t.condition_id and t.side:
                prior_markets[t.condition_id] = t.side.upper()

        seen_markets: dict[str, str] = {}
        for t in sorted_trades:
            if t.usdc_size < config.alert_min_size_usdc or not t.condition_id:
                continue

            action = _classify_action(t, seen_markets)
            if t.condition_id and t.side:
                seen_markets[t.condition_id] = t.side.upper()

            significance = round(
                ws.composite_score * math.log(1.0 + t.usdc_size), 4,
            )
            alerts.append(WhaleAlert(
                wallet=wallet,
                pseudonym=t.pseudonym or "",
                market_id=t.condition_id,
                market_title=t.market_title or "",
                action=action,
                size_usdc=round(t.usdc_size, 2),
                significance=significance,
                timestamp=float(t.timestamp),
            ))

    alerts.sort(key=lambda a: a.significance, reverse=True)
    return alerts[:50]


def _collect_unique_wallets(market_ids: list[str]) -> dict[str, list[Holder]]:
    """Collect unique wallet holders across multiple markets."""
    wallet_holders: dict[str, list[Holder]] = defaultdict(list)
    for cid in market_ids:
        try:
            holders = get_top_holders(cid, limit=20)
            for h in holders:
                if h.wallet:
                    wallet_holders[h.wallet].append(h)
        except Exception as exc:
            logger.debug("Failed to fetch holders for %s: %s", cid[:12], exc)
    return wallet_holders


def _build_ranked_wallet(
    address: str, ws: WalletScore, last_active: float,
) -> RankedWallet:
    """Build a RankedWallet from a WalletScore."""
    return RankedWallet(
        address=address,
        score=round(ws.composite_score, 4),
        win_rate=round(ws.win_rate, 4),
        pnl=round(ws.total_pnl, 2),
        sharpe=round(ws.sharpe, 4),
        edge_category=_categorize_wallet(ws),
        trade_count=ws.trade_count,
        position_count=ws.position_count,
        last_active=last_active,
    )


def _get_market_condition_ids(
    markets: list[Any] | None, max_markets: int,
) -> list[str]:
    """Resolve market condition IDs from Market objects, strings, or discovery."""
    if markets is not None:
        result: list[str] = []
        for m in markets:
            if isinstance(m, str):
                result.append(m)
            elif hasattr(m, "condition_id") and m.condition_id:
                result.append(str(m.condition_id))
            elif hasattr(m, "id"):
                result.append(str(m.id))
        return result[:max_markets]

    try:
        discovered = top_markets(limit=max_markets)
        ids = [str(m.condition_id) for m in discovered if getattr(m, "condition_id", None)]
        if ids:
            return ids
    except Exception as exc:
        logger.warning("Market discovery failed: %s", exc)
    return []


# ---------------------------------------------------------------------------
# Public functions
# ---------------------------------------------------------------------------


def scan_galaxy(
    markets: list[Any] | None = None,
    top_n: int = 20,
    min_trades: int = 10,
    max_markets: int = 50,
) -> GalaxySnapshot:
    """Scan the galaxy of Polymarket wallets and rank them.

    Discovers top markets (or uses the provided list), collects unique
    wallet holders, batch-scores them, detects clusters, and generates
    alerts.

    Args:
        markets: Market objects or condition ID strings. Discovers top
            markets by volume if None.
        top_n: Number of top/bottom wallets to return.
        min_trades: Minimum trade count for wallet inclusion.
        max_markets: Maximum markets to scan when discovering.

    Returns:
        GalaxySnapshot with ranked wallets, clusters, and alerts.
    """
    auth_require_ultra()
    start_time = time.time()
    config = GalaxyConfig(top_n=top_n, min_trades=min_trades)

    market_ids = _get_market_condition_ids(markets, max_markets)
    if not market_ids:
        logger.warning("No markets found for galaxy scan")
        return GalaxySnapshot(
            top_wallets=[], bottom_wallets=[], clusters=[], alerts=[],
            scan_time=round(time.time() - start_time, 2),
            market_count=0, wallet_count=0,
        )

    logger.info("Galaxy scan: scanning %d markets...", len(market_ids))

    # Collect and score wallets
    wallet_holders = _collect_unique_wallets(market_ids)
    logger.info("Galaxy scan: found %d unique wallets", len(wallet_holders))

    scored: dict[str, WalletScore] = {}
    for address in wallet_holders:
        ws = _cached_score(address, limit=200)
        if ws is not None and ws.trade_count >= min_trades:
            scored[address] = ws

    logger.info("Galaxy scan: scored %d wallets (min_trades=%d)", len(scored), min_trades)

    if not scored:
        return GalaxySnapshot(
            top_wallets=[], bottom_wallets=[], clusters=[], alerts=[],
            scan_time=round(time.time() - start_time, 2),
            market_count=len(market_ids), wallet_count=len(wallet_holders),
        )

    # Build and rank wallets
    ranked = [
        _build_ranked_wallet(addr, ws, time.time())
        for addr, ws in scored.items()
    ]
    ranked.sort(key=lambda w: w.score, reverse=True)
    top_wallets = ranked[:top_n]
    bottom_wallets = sorted(ranked, key=lambda w: w.score)[:top_n]

    # Collect trades for clustering and alerts
    target_addrs = {w.address for w in top_wallets} | {w.address for w in bottom_wallets}
    wallet_trades: dict[str, list[Trade]] = {}
    for address in target_addrs:
        try:
            trades = get_wallet_trades(address, limit=100)
            if trades:
                wallet_trades[address] = trades
        except Exception as exc:
            logger.debug("Failed to fetch trades for %s: %s", address[:10], exc)

    # Detect clusters among top wallets
    top_wt = {a: t for a, t in wallet_trades.items() if a in {w.address for w in top_wallets}}
    try:
        clusters = detect_clusters(top_wt, window_secs=config.cluster_window_secs,
                                   min_overlap=config.cluster_min_overlap)
    except Exception as exc:
        logger.warning("Cluster detection failed: %s", exc)
        clusters = []

    # Generate alerts
    try:
        alerts = _generate_alerts(wallet_trades, scored, config)
    except Exception as exc:
        logger.warning("Alert generation failed: %s", exc)
        alerts = []

    scan_time = round(time.time() - start_time, 2)
    logger.info("Galaxy scan complete: %d wallets, %d clusters, %d alerts in %.1fs",
                len(scored), len(clusters), len(alerts), scan_time)

    return GalaxySnapshot(
        top_wallets=top_wallets, bottom_wallets=bottom_wallets,
        clusters=clusters, alerts=alerts,
        scan_time=scan_time, market_count=len(market_ids), wallet_count=len(scored),
    )


def detect_clusters(
    wallet_trades: dict[str, list[Trade]],
    window_secs: float = 3600.0,
    min_overlap: int = 3,
) -> list[WalletCluster]:
    """Detect clusters of wallets that trade in coordinated patterns.

    For each pair of wallets, finds shared markets.  If the overlap
    meets ``min_overlap``, computes average trade-time delay and
    classifies: <60s "copy_chain", <300s "coordinated", else
    "coincidence".  Returns up to 50 clusters sorted by co_trade_count.

    Args:
        wallet_trades: Wallet address -> list of Trade.
        window_secs: Max time window for co-trade matching.
        min_overlap: Minimum shared markets for a cluster.

    Returns:
        List of WalletCluster sorted by co_trade_count descending.
    """
    auth_require_ultra()

    if len(wallet_trades) < 2:
        return []

    # Per-wallet: market -> earliest timestamp
    wallet_mkt_ts: dict[str, dict[str, int]] = {}
    wallet_mkt_set: dict[str, set[str]] = {}

    for address, trades in wallet_trades.items():
        mkt_ts: dict[str, int] = {}
        for t in trades:
            if not t.condition_id or t.timestamp <= 0:
                continue
            cid = t.condition_id
            if cid not in mkt_ts or t.timestamp < mkt_ts[cid]:
                mkt_ts[cid] = t.timestamp
        wallet_mkt_ts[address] = mkt_ts
        wallet_mkt_set[address] = set(mkt_ts.keys())

    # Compare all pairs
    addresses = list(wallet_trades.keys())
    clusters: list[WalletCluster] = []

    for i in range(len(addresses)):
        for j in range(i + 1, len(addresses)):
            addr_a, addr_b = addresses[i], addresses[j]
            shared = wallet_mkt_set.get(addr_a, set()) & wallet_mkt_set.get(addr_b, set())
            if len(shared) < min_overlap:
                continue

            delays: list[float] = []
            for cid in shared:
                ts_a = wallet_mkt_ts.get(addr_a, {}).get(cid)
                ts_b = wallet_mkt_ts.get(addr_b, {}).get(cid)
                if ts_a is not None and ts_b is not None:
                    delay = abs(ts_a - ts_b)
                    if delay <= window_secs:
                        delays.append(float(delay))

            if not delays:
                continue

            avg_delay = sum(delays) / len(delays)
            if avg_delay < 60.0:
                cluster_type = "copy_chain"
            elif avg_delay < 300.0:
                cluster_type = "coordinated"
            else:
                cluster_type = "coincidence"

            clusters.append(WalletCluster(
                wallets=[addr_a, addr_b],
                co_trade_count=len(shared),
                avg_delay_secs=round(avg_delay, 2),
                cluster_type=cluster_type,
                shared_markets=sorted(shared),
            ))

    clusters.sort(key=lambda c: c.co_trade_count, reverse=True)
    return clusters[:50]


def auto_target(
    snapshot: GalaxySnapshot,
    strategy: str = "auto",
) -> dict:
    """Select target wallets and mode from a galaxy snapshot.

    Strategies:
        "copy": top wallets with score > 0.5.
        "fade": bottom wallets with score < -0.3.
        "auto": picks the stronger set.

    Args:
        snapshot: A GalaxySnapshot from scan_galaxy().
        strategy: One of "auto", "copy", "fade".

    Returns:
        Dict with mode, wallets, reasoning, and config suggestions.
    """
    auth_require_ultra()

    copy_targets: list[str] = []
    fade_targets: list[str] = []

    if strategy in ("auto", "copy"):
        copy_targets = [w.address for w in snapshot.top_wallets if w.score > 0.5]
    if strategy in ("auto", "fade"):
        fade_targets = [w.address for w in snapshot.bottom_wallets if w.score < -0.3]

    if strategy == "copy":
        mode, wallets = "copy", copy_targets
        reasoning = f"Copying {len(wallets)} smart-money wallets with score > 0.5."
    elif strategy == "fade":
        mode, wallets = "fade", fade_targets
        reasoning = f"Fading {len(wallets)} weak-hand wallets with score < -0.3."
    else:
        # Auto-select
        if not copy_targets and not fade_targets:
            mode, wallets = "copy", []
            reasoning = "No suitable targets found in either direction."
        elif not fade_targets:
            mode, wallets = "copy", copy_targets
            reasoning = f"Auto-selected copy: {len(wallets)} smart-money wallets, no fade targets."
        elif not copy_targets:
            mode, wallets = "fade", fade_targets
            reasoning = f"Auto-selected fade: {len(wallets)} weak-hand wallets, no copy targets."
        else:
            copy_avg = sum(w.score for w in snapshot.top_wallets if w.score > 0.5) / max(1, len(copy_targets))
            fade_avg = sum(abs(w.score) for w in snapshot.bottom_wallets if w.score < -0.3) / max(1, len(fade_targets))

            if len(copy_targets) > len(fade_targets):
                mode, wallets = "copy", copy_targets
                reasoning = (f"Auto-selected copy: {len(copy_targets)} targets "
                             f"(avg {copy_avg:.2f}) vs {len(fade_targets)} fade targets.")
            elif len(fade_targets) > len(copy_targets):
                mode, wallets = "fade", fade_targets
                reasoning = (f"Auto-selected fade: {len(fade_targets)} targets "
                             f"(avg |score| {fade_avg:.2f}) vs {len(copy_targets)} copy targets.")
            elif copy_avg >= fade_avg:
                mode, wallets = "copy", copy_targets
                reasoning = f"Auto-selected copy: stronger avg score ({copy_avg:.2f}) than fade ({fade_avg:.2f})."
            else:
                mode, wallets = "fade", fade_targets
                reasoning = f"Auto-selected fade: stronger avg |score| ({fade_avg:.2f}) than copy ({copy_avg:.2f})."

    if mode == "copy":
        config_suggestion = {"size_scale": 0.5, "max_position": 1000.0, "poll_interval": 30.0, "inverse": False}
    else:
        config_suggestion = {"size_scale": 0.3, "max_position": 500.0, "poll_interval": 30.0, "inverse": True}

    return {"mode": mode, "wallets": wallets, "reasoning": reasoning, "config": config_suggestion}


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


def galaxy_tracker(
    config: GalaxyConfig | None = None,
    scan_interval_cycles: int = 100,
) -> Callable[[Context], None]:
    """Create a pipeline function that periodically scans the whale galaxy.

    Performs a full scan_galaxy() every ``scan_interval_cycles`` cycles.
    Injects ``galaxy_snapshot`` and ``galaxy_alerts`` into ctx.params.

    Args:
        config: Optional GalaxyConfig (uses defaults if None).
        scan_interval_cycles: Run a full scan every N pipeline cycles.

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

    config = config or GalaxyConfig()
    last_snapshot: GalaxySnapshot | None = None
    cycle_count: int = 0
    tracked_wallets: set[str] = set()

    def _inner(ctx: Context) -> None:
        nonlocal last_snapshot, cycle_count, tracked_wallets

        cycle_count += 1
        engine = ctx.params.get("engine")
        if engine is None:
            return

        if cycle_count % scan_interval_cycles == 0:
            try:
                snapshot = scan_galaxy(top_n=config.top_n, min_trades=config.min_trades)
                last_snapshot = snapshot
                tracked_wallets = {w.address for w in snapshot.top_wallets}
                ctx.params["galaxy_snapshot"] = snapshot
                ctx.params["galaxy_alerts"] = snapshot.alerts
                logger.info("Galaxy scan: %d wallets, %d clusters, %d alerts",
                            snapshot.wallet_count, len(snapshot.clusters), len(snapshot.alerts))
            except Exception as exc:
                logger.warning("Galaxy scan failed: %s", exc)
        elif last_snapshot is not None:
            ctx.params["galaxy_snapshot"] = last_snapshot
            ctx.params["galaxy_alerts"] = last_snapshot.alerts

    _inner.__name__ = "galaxy_tracker"
    return _inner


# ---------------------------------------------------------------------------
# Standalone blocking function
# ---------------------------------------------------------------------------


def galaxy_hunt(
    markets: list[Any] | None = None,
    top_n: int = 10,
    size_scale: float = 0.5,
    max_position: float = 1000.0,
    exchange: str = "paper",
    dry_run: bool = False,
    api_key: str | None = None,
) -> None:
    """Scan the galaxy, auto-target, and launch copy/fade trading.

    Blocking function that scans the galaxy for top/bottom wallets,
    auto-selects the best target mode, and launches copy_trades().
    Handles SIGINT/SIGTERM for clean shutdown.

    Args:
        markets: Market objects or condition ID strings (None = discover).
        top_n: Number of top wallets to target.
        size_scale: Trade size multiplier.
        max_position: Maximum USDC exposure per market.
        exchange: Exchange backend ("paper" or "polymarket").
        dry_run: Log trades without submitting orders.
        api_key: Horizon API key (falls back to HORIZON_API_KEY env var).
    """
    auth_require_ultra()

    if not logging.root.handlers:
        logging.basicConfig(level=logging.INFO, format="%(message)s")

    logger.info("Galaxy hunt starting -- scanning for targets...")

    # Build engine
    engine_kwargs: dict[str, Any] = {"exchange_type": exchange}
    resolved_key = api_key or os.environ.get("HORIZON_API_KEY")
    if resolved_key:
        engine_kwargs["api_key"] = resolved_key

    try:
        engine = Engine(**engine_kwargs)
    except Exception as exc:
        logger.error("Failed to create engine: %s", exc)
        return

    # Signal handling
    shutdown = False

    def _handle_signal(signum: int, frame: Any) -> None:
        nonlocal shutdown
        shutdown = True
        logger.info("Shutdown signal received")

    signal.signal(signal.SIGINT, _handle_signal)
    signal.signal(signal.SIGTERM, _handle_signal)

    # Initial scan
    try:
        snapshot = scan_galaxy(markets=markets, top_n=top_n)
    except Exception as exc:
        logger.error("Galaxy scan failed: %s", exc)
        return

    logger.info("Scan complete: %d wallets, %d clusters, %d alerts in %.1fs",
                snapshot.wallet_count, len(snapshot.clusters), len(snapshot.alerts), snapshot.scan_time)

    if shutdown:
        logger.info("Shutdown before targeting -- exiting")
        return

    # Auto-target
    try:
        target = auto_target(snapshot)
    except Exception as exc:
        logger.error("Auto-target failed: %s", exc)
        return

    logger.info("Target mode: %s, wallets: %d", target["mode"], len(target["wallets"]))
    logger.info("Reasoning: %s", target["reasoning"])

    if not target["wallets"]:
        logger.warning("No suitable targets found")
        return

    if dry_run:
        logger.info("[DRY RUN] Would %s wallets: %s",
                    target["mode"], [w[:10] + "..." for w in target["wallets"]])
        logger.info("[DRY RUN] Suggested config: %s", target["config"])
        return

    if shutdown:
        logger.info("Shutdown before trading -- exiting")
        return

    # Launch copy_trades
    from horizon.copy_trader import copy_trades

    inverse = target["mode"] == "fade"
    suggested = target["config"]

    logger.info("Launching %s for %d wallets (inverse=%s, size_scale=%.2f)",
                "fade" if inverse else "copy", len(target["wallets"]),
                inverse, suggested.get("size_scale", size_scale))

    try:
        copy_trades(
            wallet=target["wallets"],
            size_scale=suggested.get("size_scale", size_scale),
            max_position=suggested.get("max_position", max_position),
            poll_interval=suggested.get("poll_interval", 30.0),
            exchange=exchange,
            inverse=inverse,
            api_key=api_key,
        )
    except Exception as exc:
        logger.error("Copy trades failed: %s", exc)
