#!/usr/bin/env python3
"""Read-only exchange universe discovery for Callme symbol availability.

Uses public ccxt market metadata only. No secrets, no balances, no orders.
"""

import argparse
import csv
import json
import math
import signal
import time
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Tuple


VENUES = {
    "bingx": ["bingx"],
    "gateio": ["gateio"],
    "bybit": ["bybit"],
    "kraken": ["kraken", "krakenfutures"],
    "htx": ["htx"],
    "kucoin": ["kucoin", "kucoinfutures"],
    "bitget": ["bitget"],
    "okx": ["okx"],
    "mexc": ["mexc"],
    "kcex": ["kcex"],
}


def finite(raw: Any) -> Optional[float]:
    try:
        val = float(raw)
    except Exception:
        return None
    return val if math.isfinite(val) and val > 0 else None


def wanted(symbols: Iterable[str]) -> List[str]:
    out = []
    for symbol in symbols:
        text = str(symbol).upper().strip()
        if not text:
            continue
        if "/" in text:
            base = text.split("/", 1)[0]
        elif text.endswith("USDT"):
            base = text[:-4]
        else:
            base = text
        out.append(base)
    return sorted(set(out))


def market_matches(market: Dict[str, Any], bases: List[str]) -> bool:
    base = str(market.get("base") or "").upper()
    quote = str(market.get("quote") or "").upper()
    settle = str(market.get("settle") or "").upper()
    symbol = str(market.get("symbol") or "").upper()
    if base not in bases:
        return False
    if quote == "USDT" or settle == "USDT":
        return True
    return any(symbol.startswith(f"{base}/USDT") or symbol.startswith(f"{base}USDT") for base in bases)


def compact_market(market: Dict[str, Any], exchange_id: str, venue: str) -> Dict[str, Any]:
    limits = market.get("limits") or {}
    amount_limits = limits.get("amount") or {}
    cost_limits = limits.get("cost") or {}
    price_limits = limits.get("price") or {}
    precision = market.get("precision") or {}
    return {
        "venue": venue,
        "exchange_id": exchange_id,
        "symbol": market.get("symbol"),
        "id": market.get("id"),
        "base": market.get("base"),
        "quote": market.get("quote"),
        "settle": market.get("settle"),
        "type": market.get("type"),
        "spot": bool(market.get("spot")),
        "swap": bool(market.get("swap")),
        "future": bool(market.get("future")),
        "linear": market.get("linear"),
        "active": market.get("active"),
        "contract": market.get("contract"),
        "contract_size": market.get("contractSize"),
        "maker": market.get("maker"),
        "taker": market.get("taker"),
        "min_amount": amount_limits.get("min"),
        "min_cost": cost_limits.get("min"),
        "min_price": price_limits.get("min"),
        "precision_amount": precision.get("amount"),
        "precision_price": precision.get("price"),
    }


def estimate_min_notional(row: Dict[str, Any], mark: Optional[float]) -> Optional[float]:
    min_cost = finite(row.get("min_cost"))
    min_amount = finite(row.get("min_amount"))
    contract_size = finite(row.get("contract_size")) or 1.0
    candidates = []
    if min_cost is not None:
        candidates.append(min_cost)
    if min_amount is not None and mark is not None:
        candidates.append(min_amount * contract_size * mark)
    return max(candidates) if candidates else None


def market_snapshot(ex: Any, symbol: str, row: Dict[str, Any]) -> Dict[str, Any]:
    out: Dict[str, Any] = {}
    try:
        ticker = ex.fetch_ticker(symbol)
        mark = finite(ticker.get("mark")) or finite(ticker.get("last")) or finite(ticker.get("close"))
        bid = finite(ticker.get("bid"))
        ask = finite(ticker.get("ask"))
        out.update({"mark": mark, "ticker_bid": bid, "ticker_ask": ask})
    except Exception as exc:
        out["ticker_error"] = str(exc)[:200]
        mark = None
    try:
        book = ex.fetch_order_book(symbol, limit=5)
        bids = book.get("bids") or []
        asks = book.get("asks") or []
        bid = finite(bids[0][0]) if bids else out.get("ticker_bid")
        ask = finite(asks[0][0]) if asks else out.get("ticker_ask")
        mid = (bid + ask) / 2.0 if bid and ask else None
        out.update(
            {
                "best_bid": bid,
                "best_ask": ask,
                "mid": mid,
                "spread_bp": ((ask - bid) / mid * 10000.0) if bid and ask and mid else None,
                "bid_depth_top5_usdt": sum(float(px) * float(qty) for px, qty in bids[:5]),
                "ask_depth_top5_usdt": sum(float(px) * float(qty) for px, qty in asks[:5]),
            }
        )
    except Exception as exc:
        out["orderbook_error"] = str(exc)[:200]
    out["estimated_min_notional_usdt"] = estimate_min_notional(row, finite(out.get("mark")) or finite(out.get("mid")))
    return out


class ExchangeTimeout(RuntimeError):
    pass


def timeout_handler(_signum: int, _frame: Any) -> None:
    raise ExchangeTimeout("exchange_timeout")


def discover_exchange(exchange_id: str, venue: str, bases: List[str], timeout_ms: int, exchange_timeout_sec: int) -> Tuple[List[Dict[str, Any]], Optional[str]]:
    import ccxt  # type: ignore

    cls = getattr(ccxt, exchange_id, None)
    if cls is None:
        return [], f"ccxt_exchange_not_supported:{exchange_id}"
    old_handler = None
    alarm_supported = hasattr(signal, "SIGALRM")
    try:
        if alarm_supported:
            old_handler = signal.signal(signal.SIGALRM, timeout_handler)
            signal.alarm(exchange_timeout_sec)
        ex = cls({"enableRateLimit": True, "timeout": timeout_ms})
        markets = ex.load_markets()
    except ExchangeTimeout:
        return [], f"exchange_timeout:{exchange_id}:{exchange_timeout_sec}s"
    except Exception as exc:
        return [], f"load_markets_error:{str(exc)[:240]}"
    finally:
        if alarm_supported:
            signal.alarm(0)
            if old_handler is not None:
                signal.signal(signal.SIGALRM, old_handler)

    rows: List[Dict[str, Any]] = []
    for market in markets.values():
        if not isinstance(market, dict) or not market_matches(market, bases):
            continue
        row = compact_market(market, exchange_id, venue)
        row.update(market_snapshot(ex, str(market.get("symbol")), row))
        rows.append(row)
    rows.sort(key=lambda r: (str(r.get("base")), str(r.get("type")), str(r.get("symbol"))))
    return rows, None


def write_csv(path: Path, rows: List[Dict[str, Any]]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    if not rows:
        path.write_text("", encoding="utf-8")
        return
    fields = sorted({key for row in rows for key in row})
    with path.open("w", encoding="utf-8", newline="") as fp:
        writer = csv.DictWriter(fp, fieldnames=fields)
        writer.writeheader()
        writer.writerows(rows)


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--symbols", nargs="+", required=True)
    ap.add_argument("--out-dir", required=True)
    ap.add_argument("--venues", nargs="*", default=sorted(VENUES))
    ap.add_argument("--timeout-ms", type=int, default=20000)
    ap.add_argument("--exchange-timeout-sec", type=int, default=45)
    args = ap.parse_args()

    bases = wanted(args.symbols)
    out_dir = Path(args.out_dir)
    rows: List[Dict[str, Any]] = []
    errors: Dict[str, Any] = {}
    started = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
    out_dir.mkdir(parents=True, exist_ok=True)
    for venue in args.venues:
        exchange_ids = VENUES.get(venue)
        if not exchange_ids:
            errors[venue] = [f"unknown_venue:{venue}"]
            continue
        venue_rows: List[Dict[str, Any]] = []
        venue_errors = []
        for exchange_id in exchange_ids:
            found, err = discover_exchange(exchange_id, venue, bases, args.timeout_ms, args.exchange_timeout_sec)
            venue_rows.extend(found)
            if err:
                venue_errors.append(err)
        rows.extend(venue_rows)
        if venue_errors:
            errors[venue] = venue_errors
        partial = {
            "started_at": started,
            "updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
            "state": "running",
            "symbols_requested": args.symbols,
            "bases": bases,
            "venues_requested": sorted(args.venues),
            "matches": rows,
            "errors": errors,
        }
        (out_dir / "exchange_universe_discovery.partial.json").write_text(json.dumps(partial, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
        write_csv(out_dir / "exchange_universe_discovery.partial.csv", rows)

    summary = {
        "started_at": started,
        "completed_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
        "safety": {
            "public_market_metadata_only": True,
            "no_secrets_read": True,
            "no_orders": True,
        },
        "symbols_requested": args.symbols,
        "bases": bases,
        "venues": sorted(VENUES),
        "venues_requested": sorted(args.venues),
        "matches": rows,
        "errors": errors,
    }
    (out_dir / "exchange_universe_discovery.json").write_text(json.dumps(summary, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
    write_csv(out_dir / "exchange_universe_discovery.csv", rows)

    lines = [
        "# Exchange Universe Discovery",
        "",
        f"Symbols: `{', '.join(args.symbols)}`.",
        "",
        "Safety: public market metadata only; no secrets; no orders.",
        "",
        "## Matches",
        "",
    ]
    if rows:
        for row in rows:
            lines.append(
                "- `{venue}` / `{exchange_id}` `{symbol}` type=`{type}` active=`{active}` "
                "min_notional≈`{minn}` maker=`{maker}` taker=`{taker}` spread_bp=`{spread}`".format(
                    venue=row.get("venue"),
                    exchange_id=row.get("exchange_id"),
                    symbol=row.get("symbol"),
                    type=row.get("type"),
                    active=row.get("active"),
                    minn=row.get("estimated_min_notional_usdt"),
                    maker=row.get("maker"),
                    taker=row.get("taker"),
                    spread=row.get("spread_bp"),
                )
            )
    else:
        lines.append("- No matches.")
    if errors:
        lines.extend(["", "## Errors", ""])
        for venue, err in errors.items():
            lines.append(f"- `{venue}`: `{err}`")
    (out_dir / "EXCHANGE_UNIVERSE_DISCOVERY.md").write_text("\n".join(lines) + "\n", encoding="utf-8")


if __name__ == "__main__":
    main()
