#!/usr/bin/env python3
"""Spike test: Compare combined WebSocket stream configurations.

Tests 3 configs against live Binance aggTrade streams:
  1. 1x18: Single connection, all 18 symbols
  2. 2x9 even: Two connections, 9 symbols each (alphabetical split)
  3. 2x9 tiered: Two connections, high-volume (BTC/ETH + 7) vs low-volume (9)

Measures: messages/sec, max inter-message latency per symbol, reconnect time.
Requires: Python 3.13, websockets library.

Usage:
    uv run --python 3.13 scripts/spike_combined_ws.py --duration 300 --config all
"""

from __future__ import annotations

import argparse
import asyncio
import json
import ssl
import time
from dataclasses import dataclass, field

import websockets

# All 18 registered symbols (from symbols.toml)
ALL_SYMBOLS = [
    "BTCUSDT", "ETHUSDT", "BNBUSDT", "SOLUSDT", "XRPUSDT",
    "DOGEUSDT", "ADAUSDT", "AVAXUSDT", "DOTUSDT", "LINKUSDT",
    "MATICUSDT", "UNIUSDT", "SHIBUSDT", "LTCUSDT", "ATOMUSDT",
    "NEARUSDT", "SUIUSDT", "WLDUSDT",
]

# Volume-tiered split: top 9 by typical volume
HIGH_VOLUME = ["BTCUSDT", "ETHUSDT", "BNBUSDT", "SOLUSDT", "XRPUSDT",
               "DOGEUSDT", "SHIBUSDT", "ADAUSDT", "AVAXUSDT"]
LOW_VOLUME = [s for s in ALL_SYMBOLS if s not in HIGH_VOLUME]

BINANCE_WS_BASE = "wss://stream.binance.com:9443"


@dataclass
class SymbolStats:
    """Per-symbol statistics."""
    msg_count: int = 0
    first_ts: float = 0.0
    last_ts: float = 0.0
    max_gap_ms: float = 0.0
    last_tid: int = 0
    tid_gaps: int = 0


@dataclass
class ConfigResult:
    """Results for one configuration."""
    name: str
    connections: int
    duration_s: float = 0.0
    total_messages: int = 0
    msgs_per_sec: float = 0.0
    symbols: dict[str, SymbolStats] = field(default_factory=dict)
    errors: list[str] = field(default_factory=list)


def build_combined_url(symbols: list[str]) -> str:
    """Build Binance combined stream URL."""
    streams = "/".join(f"{s.lower()}@aggTrade" for s in symbols)
    return f"{BINANCE_WS_BASE}/stream?streams={streams}"


async def run_connection(
    url: str,
    symbols: list[str],
    stats: dict[str, SymbolStats],
    duration: float,
    errors: list[str],
) -> None:
    """Run a single WebSocket connection, collecting stats."""
    ssl_ctx = ssl.create_default_context()
    try:
        async with websockets.connect(url, ssl=ssl_ctx, ping_interval=20) as ws:
            end_time = time.monotonic() + duration
            while time.monotonic() < end_time:
                try:
                    raw = await asyncio.wait_for(ws.recv(), timeout=5.0)
                except TimeoutError:
                    continue

                now = time.monotonic()
                msg = json.loads(raw)

                # Combined stream wraps in {"stream": "...", "data": {...}}
                data = msg.get("data", msg)
                stream_name = msg.get("stream", "")
                symbol = stream_name.split("@")[0].upper() if "@" in stream_name else data.get("s", "")

                if symbol not in stats:
                    continue

                s = stats[symbol]
                s.msg_count += 1
                if s.first_ts == 0:
                    s.first_ts = now
                if s.last_ts > 0:
                    gap = (now - s.last_ts) * 1000
                    s.max_gap_ms = max(gap, s.max_gap_ms)
                s.last_ts = now

                tid = int(data.get("a", 0))
                if s.last_tid > 0 and tid > s.last_tid + 1:
                    s.tid_gaps += 1
                if tid > 0:
                    s.last_tid = tid

    except Exception as e:
        errors.append(f"Connection error: {e}")


async def test_config(
    name: str,
    groups: list[list[str]],
    duration: float,
) -> ConfigResult:
    """Test one configuration with N connections."""
    all_symbols = [s for g in groups for s in g]
    stats: dict[str, SymbolStats] = {s: SymbolStats() for s in all_symbols}
    errors: list[str] = []

    print(f"\n{'='*60}")
    print(f"Config: {name} ({len(groups)} connection(s), {len(all_symbols)} symbols)")
    print(f"Duration: {duration:.0f}s")
    print(f"{'='*60}")

    start = time.monotonic()

    tasks = []
    for group in groups:
        url = build_combined_url(group)
        print(f"  Connecting: {url[:80]}...")
        tasks.append(run_connection(url, group, stats, duration, errors))

    await asyncio.gather(*tasks)

    elapsed = time.monotonic() - start
    total_msgs = sum(s.msg_count for s in stats.values())

    result = ConfigResult(
        name=name,
        connections=len(groups),
        duration_s=elapsed,
        total_messages=total_msgs,
        msgs_per_sec=total_msgs / elapsed if elapsed > 0 else 0,
        symbols=stats,
        errors=errors,
    )

    # Print summary
    print(f"\n  Total messages: {total_msgs:,}")
    print(f"  Messages/sec: {result.msgs_per_sec:.1f}")
    print(f"  Errors: {len(errors)}")

    print(f"\n  {'Symbol':<12} {'Count':>8} {'msg/s':>8} {'MaxGap':>10} {'TID Gaps':>10}")
    print(f"  {'-'*12} {'-'*8} {'-'*8} {'-'*10} {'-'*10}")
    for sym in sorted(stats.keys()):
        s = stats[sym]
        sym_duration = s.last_ts - s.first_ts if s.last_ts > s.first_ts else elapsed
        msg_rate = s.msg_count / sym_duration if sym_duration > 0 else 0
        print(f"  {sym:<12} {s.msg_count:>8,} {msg_rate:>8.1f} {s.max_gap_ms:>9.0f}ms {s.tid_gaps:>10}")

    return result


async def main(duration: float, config: str) -> None:
    """Run spike tests."""
    results: list[ConfigResult] = []

    even_split = (ALL_SYMBOLS[:9], ALL_SYMBOLS[9:])

    configs = {
        "1x18": [ALL_SYMBOLS],
        "2x9_even": [list(even_split[0]), list(even_split[1])],
        "2x9_tiered": [HIGH_VOLUME, LOW_VOLUME],
    }

    to_test = list(configs.items()) if config == "all" else [(config, configs[config])]

    for name, groups in to_test:
        result = await test_config(name, groups, duration)
        results.append(result)
        if len(to_test) > 1:
            print("\n  Cooling down 10s between configs...")
            await asyncio.sleep(10)

    # Comparison table
    print(f"\n{'='*60}")
    print("COMPARISON")
    print(f"{'='*60}")
    print(f"{'Config':<16} {'Conns':>6} {'Total':>10} {'msg/s':>10} {'MaxGap':>10} {'TID Gaps':>10} {'Errors':>8}")
    print(f"{'-'*16} {'-'*6} {'-'*10} {'-'*10} {'-'*10} {'-'*10} {'-'*8}")
    for r in results:
        max_gap = max((s.max_gap_ms for s in r.symbols.values()), default=0)
        tid_gaps = sum(s.tid_gaps for s in r.symbols.values())
        print(f"{r.name:<16} {r.connections:>6} {r.total_messages:>10,} {r.msgs_per_sec:>10.1f} {max_gap:>9.0f}ms {tid_gaps:>10} {len(r.errors):>8}")

    # Write results to JSON
    output = {
        "test_date": time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime()),
        "duration_per_config_s": duration,
        "results": [
            {
                "name": r.name,
                "connections": r.connections,
                "total_messages": r.total_messages,
                "msgs_per_sec": round(r.msgs_per_sec, 1),
                "max_gap_ms": round(max((s.max_gap_ms for s in r.symbols.values()), default=0), 1),
                "tid_gaps": sum(s.tid_gaps for s in r.symbols.values()),
                "errors": len(r.errors),
                "per_symbol": {
                    sym: {
                        "count": s.msg_count,
                        "max_gap_ms": round(s.max_gap_ms, 1),
                        "tid_gaps": s.tid_gaps,
                    }
                    for sym, s in r.symbols.items()
                },
            }
            for r in results
        ],
    }

    from pathlib import Path

    output_path = Path(f"spike_combined_ws_results_{int(time.time())}.json")
    with output_path.open("w") as f:
        json.dump(output, f, indent=2)
    print(f"\nResults written to: {output_path}")

    # Recommendation
    if len(results) > 1:
        best = min(results, key=lambda r: sum(s.tid_gaps for s in r.symbols.values()))
        print(f"\nRECOMMENDATION: {best.name} (fewest trade-ID gaps)")


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Spike test: combined WS stream configs")
    parser.add_argument("--duration", type=int, default=300, help="Test duration per config (seconds)")
    parser.add_argument("--config", choices=["1x18", "2x9_even", "2x9_tiered", "all"], default="all")
    args = parser.parse_args()
    asyncio.run(main(args.duration, args.config))
