"""Optional CoinGlass liquidation heatmap feed.

The bot treats this as advisory context for the LLM, not as an execution
dependency. API errors return a compact unavailable block so live scanning keeps
running.
"""
from __future__ import annotations

import logging
import os
import time
from dataclasses import dataclass
from typing import Any, Optional

import requests

log = logging.getLogger(__name__)


BASE_URL = "https://open-api-v4.coinglass.com"
PAIR_HEATMAP_PATH = "/api/futures/liquidation/heatmap/model2"
AGG_HEATMAP_PATH = "/api/futures/liquidation/aggregated-heatmap/model2"
PAIR_MAP_PATH = "/api/futures/liquidation/map"
AGG_MAP_PATH = "/api/futures/liquidation/aggregated-map"
LIQUIDATION_COIN_LIST_PATH = "/api/futures/liquidation/coin-list"
FUNDING_RATE_LIST_PATH = "/api/futures/funding-rate/exchange-list"
GLOBAL_LONG_SHORT_PATH = "/api/futures/global-long-short-account-ratio/history"


@dataclass
class CoinGlassConfig:
    enabled: bool = True
    base_url: str = BASE_URL
    exchange: str = "Hyperliquid"
    heatmap_range: str = "24h"
    cache_seconds: int = 180
    timeout: float = 8.0
    prefer_pair_heatmap: bool = True
    include_coin_liquidations: bool = True
    include_funding_snapshot: bool = True
    include_long_short_ratio: bool = True


class CoinGlassClient:
    """Small v4 client for liquidation heatmap summaries."""

    def __init__(self, config: CoinGlassConfig, api_key: Optional[str] = None):
        self.config = config
        self.api_key = api_key or os.environ.get("COINGLASS_API_KEY", "")
        self.session = requests.Session()
        self._cache: dict[tuple[str, str, float], tuple[float, dict]] = {}
        self._shared_cache: dict[tuple[str, tuple[tuple[str, str], ...]], tuple[float, dict]] = {}

    @property
    def available(self) -> bool:
        return bool(self.config.enabled and self.api_key)

    def context(self, coin: str, mid: float) -> dict:
        """Return compact liquidation context for one coin."""
        if not self.config.enabled:
            return {"enabled": False, "status": "disabled"}
        if not self.api_key:
            return {"enabled": False, "status": "missing_api_key"}

        coin = str(coin).upper().strip()
        mid = float(mid or 0.0)
        cache_key = (coin, self.config.heatmap_range, round(mid, 6))
        cached = self._cache.get(cache_key)
        now = time.time()
        if cached and now - cached[0] < float(self.config.cache_seconds):
            out = dict(cached[1])
            out["cache_age_s"] = round(now - cached[0], 1)
            return out

        payload = None
        source = ""
        errors = []
        for source, fetcher in self._fetchers(coin):
            payload, error = fetcher()
            if payload is not None:
                break
            if error:
                errors.append(f"{source}: {error}")

        if payload is None:
            out = {
                "enabled": True,
                "status": "error",
                "source": source or "none",
                "range": self.config.heatmap_range,
                "error": _short_error("; ".join(errors)),
            }
        else:
            out = summarize_heatmap(payload, mid=mid, source=source, heatmap_range=self.config.heatmap_range)

        enrich = self._hobbyist_context(coin)
        if enrich:
            heatmap_status = out.get("status", "unknown")
            out = {
                "enabled": True,
                "status": "ok" if heatmap_status == "ok" else "partial",
                "heatmap_status": heatmap_status,
                "heatmap": out,
                **enrich,
            }

        self._cache[cache_key] = (now, out)
        return out

    def _fetchers(self, coin: str):
        fetchers = []
        if self.config.prefer_pair_heatmap:
            fetchers.append(("pair_heatmap_model2", lambda: self._pair_heatmap(coin)))
            fetchers.append(("pair_map", lambda: self._pair_map(coin)))
        fetchers.append(("aggregated_heatmap_model2", lambda: self._aggregated_heatmap(coin)))
        fetchers.append(("aggregated_map", lambda: self._aggregated_map(coin)))
        return fetchers

    def _pair_heatmap(self, coin: str) -> tuple[Optional[dict], str]:
        symbol = f"{coin}USDT"
        return self._get(
            PAIR_HEATMAP_PATH,
            {
                "exchange": self.config.exchange,
                "symbol": symbol,
                "range": self.config.heatmap_range,
            },
        )

    def _pair_map(self, coin: str) -> tuple[Optional[dict], str]:
        symbol = f"{coin}USDT"
        return self._get(
            PAIR_MAP_PATH,
            {
                "exchange": self.config.exchange,
                "symbol": symbol,
                "range": _map_range(self.config.heatmap_range),
            },
        )

    def _aggregated_heatmap(self, coin: str) -> tuple[Optional[dict], str]:
        return self._get(
            AGG_HEATMAP_PATH,
            {
                "symbol": coin,
                "range": self.config.heatmap_range,
            },
        )

    def _aggregated_map(self, coin: str) -> tuple[Optional[dict], str]:
        return self._get(
            AGG_MAP_PATH,
            {
                "symbol": coin,
                "range": _map_range(self.config.heatmap_range),
            },
        )

    def _get(self, path: str, params: dict) -> tuple[Optional[dict], str]:
        url = self.config.base_url.rstrip("/") + path
        headers = {
            "accept": "application/json",
            "CG-API-KEY": self.api_key,
        }
        try:
            r = self.session.get(url, params=params, headers=headers, timeout=float(self.config.timeout))
        except requests.RequestException as exc:
            return None, f"request failed: {exc.__class__.__name__}: {exc}"
        if r.status_code >= 400:
            return None, f"http {r.status_code}: {r.text[:240]}"
        try:
            data = r.json()
        except ValueError:
            return None, f"non-json response: {r.text[:240]}"
        if isinstance(data, dict) and str(data.get("code", "")).lower() not in ("", "0", "200", "success"):
            msg = data.get("msg") or data.get("message") or data.get("error") or data.get("code")
            return None, f"api error: {msg}"
        return data if isinstance(data, dict) else {"data": data}, ""

    def _cached_get(self, path: str, params: dict) -> tuple[Optional[dict], str]:
        key = (path, tuple(sorted((str(k), str(v)) for k, v in params.items())))
        now = time.time()
        cached = self._shared_cache.get(key)
        if cached and now - cached[0] < float(self.config.cache_seconds):
            return cached[1], ""
        payload, error = self._get(path, params)
        if payload is not None:
            self._shared_cache[key] = (now, payload)
        return payload, error

    def _hobbyist_context(self, coin: str) -> dict:
        context: dict[str, Any] = {}
        if self.config.include_coin_liquidations:
            liq = self._exchange_liquidation_context(coin)
            if liq:
                context["exchange_liquidations_24h"] = liq
        if self.config.include_funding_snapshot:
            funding = self._funding_context(coin)
            if funding:
                context["coinglass_funding_snapshot"] = funding
        if self.config.include_long_short_ratio:
            ratio = self._long_short_context(coin)
            if ratio:
                context["global_long_short_ratio"] = ratio
        return context

    def _exchange_liquidation_context(self, coin: str) -> dict:
        payload, error = self._cached_get(LIQUIDATION_COIN_LIST_PATH, {"exchange": self.config.exchange})
        if payload is None:
            return {"status": "error", "exchange": self.config.exchange, "error": _short_error(error)}
        rows = payload.get("data") if isinstance(payload, dict) else None
        if not isinstance(rows, list):
            return {"status": "unparsed", "exchange": self.config.exchange}
        row = _find_symbol_row(rows, coin)
        if not row:
            return {"status": "missing_coin", "exchange": self.config.exchange}
        total = _to_float(row.get("liquidation_usd_24h")) or 0.0
        long_liq = _to_float(row.get("long_liquidation_usd_24h")) or 0.0
        short_liq = _to_float(row.get("short_liquidation_usd_24h")) or 0.0
        imbalance = (short_liq - long_liq) / total if total > 0 else 0.0
        return {
            "status": "ok",
            "exchange": self.config.exchange,
            "symbol": row.get("symbol", coin),
            "total_usd_24h": _round(total),
            "long_liq_usd_24h": _round(long_liq),
            "short_liq_usd_24h": _round(short_liq),
            "short_minus_long_ratio": round(imbalance, 4),
            "interpretation": "positive means more shorts liquidated; negative means more longs liquidated",
        }

    def _funding_context(self, coin: str) -> dict:
        payload, error = self._cached_get(FUNDING_RATE_LIST_PATH, {})
        if payload is None:
            return {"status": "error", "error": _short_error(error)}
        rows = payload.get("data") if isinstance(payload, dict) else None
        if not isinstance(rows, list):
            return {"status": "unparsed"}
        row = _find_symbol_row(rows, coin)
        if not row:
            return {"status": "missing_coin"}
        venues = row.get("stablecoin_margin_list") or row.get("coin_margin_list") or []
        if not isinstance(venues, list):
            venues = []
        parsed = []
        for item in venues:
            if not isinstance(item, dict):
                continue
            rate = _to_float(item.get("funding_rate"))
            if rate is None:
                continue
            parsed.append({
                "exchange": str(item.get("exchange", ""))[:24],
                "rate": round(rate, 8),
                "interval_h": _to_float(item.get("funding_rate_interval")) or None,
            })
        parsed = sorted(parsed, key=lambda x: abs(float(x["rate"])), reverse=True)[:8]
        rates = [float(x["rate"]) for x in parsed]
        return {
            "status": "ok",
            "symbol": row.get("symbol", coin),
            "venues": parsed,
            "avg_rate": round(sum(rates) / len(rates), 8) if rates else 0.0,
            "max_abs_rate": max((abs(x) for x in rates), default=0.0),
        }

    def _long_short_context(self, coin: str) -> dict:
        payload, error = self._cached_get(
            GLOBAL_LONG_SHORT_PATH,
            {"exchange": "Binance", "symbol": f"{coin}USDT", "interval": "4h"},
        )
        if payload is None:
            return {"status": "error", "exchange": "Binance", "error": _short_error(error)}
        rows = payload.get("data") if isinstance(payload, dict) else None
        if not isinstance(rows, list) or not rows:
            return {"status": "missing_coin", "exchange": "Binance"}
        latest = rows[-1] if isinstance(rows[-1], dict) else rows[0]
        long_pct = _to_float(latest.get("global_account_long_percent"))
        short_pct = _to_float(latest.get("global_account_short_percent"))
        ratio = _to_float(latest.get("global_account_long_short_ratio"))
        return {
            "status": "ok" if long_pct is not None and short_pct is not None else "unparsed",
            "exchange": "Binance",
            "interval": "4h",
            "long_pct": round(long_pct, 2) if long_pct is not None else None,
            "short_pct": round(short_pct, 2) if short_pct is not None else None,
            "long_short_ratio": round(ratio, 4) if ratio is not None else None,
            "timestamp": latest.get("time"),
            "interpretation": "crowding context only; do not trade against/with it without a playbook trigger",
        }


def summarize_heatmap(payload: dict, *, mid: float, source: str, heatmap_range: str) -> dict:
    levels = _extract_levels(payload, mid=mid)
    deduped = _dedupe_levels(levels)
    above = sorted((x for x in deduped if x["price"] > mid), key=lambda x: x["price"])
    below = sorted((x for x in deduped if x["price"] < mid), key=lambda x: x["price"], reverse=True)
    largest_above = sorted(above, key=lambda x: x.get("size", 0.0), reverse=True)[:5]
    largest_below = sorted(below, key=lambda x: x.get("size", 0.0), reverse=True)[:5]

    return {
        "enabled": True,
        "status": "ok" if deduped else "ok_unparsed",
        "source": source,
        "range": heatmap_range,
        "mid": _round(mid),
        "level_count": len(deduped),
        "nearest_above": [_format_level(x, mid) for x in above[:6]],
        "nearest_below": [_format_level(x, mid) for x in below[:6]],
        "largest_above": [_format_level(x, mid) for x in largest_above],
        "largest_below": [_format_level(x, mid) for x in largest_below],
        "note": "estimated liquidation heatmap; use as magnet/risk context, not exact stop evidence",
        "raw_keys": sorted(list(payload.keys()))[:12],
    }


def _extract_levels(obj: Any, *, mid: float, path: str = "", limit: int = 5000) -> list[dict]:
    out: list[dict] = []
    if len(out) >= limit:
        return out

    if isinstance(obj, dict):
        key_price_rows = _levels_from_numeric_keys(obj, mid, path)
        out.extend(key_price_rows)

        price = _first_float(obj, ("price", "px", "p", "liqPrice", "liquidationPrice"))
        size = _first_float(
            obj,
            (
                "size",
                "amount",
                "vol",
                "volume",
                "value",
                "usd",
                "notional",
                "liquidationUsd",
                "liqUsd",
            ),
        )
        if price and _looks_like_price(price, mid) and size and size > 0:
            out.append({"price": price, "size": size, "side": _side_from_path(path, obj), "source": path[-80:]})

        for key, value in obj.items():
            if len(out) >= limit:
                break
            child_path = f"{path}.{key}" if path else str(key)
            out.extend(_extract_levels(value, mid=mid, path=child_path, limit=limit - len(out)))

    elif isinstance(obj, list):
        if len(obj) <= 16:
            row = _level_from_numeric_row(obj, mid, path)
            if row:
                out.append(row)
        for idx, value in enumerate(obj[:limit]):
            if len(out) >= limit:
                break
            if isinstance(value, (dict, list)):
                out.extend(_extract_levels(value, mid=mid, path=f"{path}[{idx}]", limit=limit - len(out)))
    return out[:limit]


def _levels_from_numeric_keys(obj: dict, mid: float, path: str) -> list[dict]:
    rows = []
    for key, value in obj.items():
        price = _to_float(key)
        if not price or not _looks_like_price(price, mid):
            continue
        size = _to_float(value)
        if size is None and isinstance(value, dict):
            size = _first_float(
                value,
                (
                    "size",
                    "amount",
                    "vol",
                    "volume",
                    "value",
                    "usd",
                    "notional",
                    "liquidationUsd",
                    "liqUsd",
                ),
            )
        if size and size > 0:
            rows.append({"price": price, "size": size, "side": _side_from_path(path, value), "source": path[-80:]})
    return rows


def _level_from_numeric_row(row: list, mid: float, path: str) -> Optional[dict]:
    nums = [_to_float(x) for x in row]
    valid = [(i, x) for i, x in enumerate(nums) if x is not None and x > 0]
    if len(valid) < 2:
        return None
    for idx, price in valid:
        if not _looks_like_price(price, mid):
            continue
        sizes = [x for j, x in valid if j != idx and x > 0]
        if not sizes:
            continue
        return {"price": price, "size": max(sizes), "side": _side_from_path(path, row), "source": path[-80:]}
    return None


def _dedupe_levels(levels: list[dict]) -> list[dict]:
    bucket: dict[tuple[float, str], dict] = {}
    for level in levels:
        price = float(level.get("price") or 0.0)
        size = float(level.get("size") or 0.0)
        if price <= 0 or size <= 0:
            continue
        side = str(level.get("side") or "unknown")
        key = (round(price, 8), side)
        existing = bucket.get(key)
        if existing:
            existing["size"] += size
        else:
            bucket[key] = {"price": price, "size": size, "side": side, "source": level.get("source", "")}
    return list(bucket.values())


def _find_symbol_row(rows: list, coin: str) -> Optional[dict]:
    coin = str(coin).upper()
    targets = {coin, f"{coin}USDT", f"{coin}-USDT", f"{coin}_USDT"}
    for row in rows:
        if not isinstance(row, dict):
            continue
        symbol = str(row.get("symbol") or row.get("instrument_id") or row.get("base_asset") or "").upper()
        if symbol in targets:
            return row
    for row in rows:
        if not isinstance(row, dict):
            continue
        symbol = str(row.get("symbol") or row.get("instrument_id") or "").upper()
        if symbol.startswith(coin):
            return row
    return None


def _format_level(level: dict, mid: float) -> dict:
    price = float(level.get("price") or 0.0)
    size = float(level.get("size") or 0.0)
    dist_pct = (price - mid) / mid if mid else 0.0
    return {
        "price": _round(price),
        "dist_pct": round(dist_pct * 100.0, 3),
        "size": _round(size),
        "side": str(level.get("side") or "unknown"),
    }


def _first_float(obj: dict, keys: tuple[str, ...]) -> Optional[float]:
    lower_map = {str(k).lower(): v for k, v in obj.items()}
    for key in keys:
        value = lower_map.get(key.lower())
        parsed = _to_float(value)
        if parsed is not None:
            return parsed
    return None


def _to_float(value: Any) -> Optional[float]:
    if isinstance(value, bool) or value is None:
        return None
    if isinstance(value, (int, float)):
        return float(value)
    if isinstance(value, str):
        text = value.strip().replace(",", "")
        if not text:
            return None
        try:
            return float(text)
        except ValueError:
            return None
    return None


def _looks_like_price(value: float, mid: float) -> bool:
    if value <= 0:
        return False
    if mid <= 0:
        return True
    return mid * 0.2 <= value <= mid * 5.0


def _side_from_path(path: str, obj: Any) -> str:
    text = path.lower()
    if isinstance(obj, dict):
        for key in ("side", "type", "direction"):
            if key in obj:
                text += " " + str(obj.get(key, "")).lower()
    if "short" in text or "ask" in text or "sell" in text:
        return "shorts"
    if "long" in text or "bid" in text or "buy" in text:
        return "longs"
    return "unknown"


def _short_error(error: str) -> str:
    return " ".join(str(error or "unknown").split())[:500]


def _round(value: float) -> float:
    value = float(value or 0.0)
    if abs(value) >= 100:
        return round(value, 2)
    if abs(value) >= 1:
        return round(value, 5)
    return round(value, 8)


def _map_range(value: str) -> str:
    value = str(value or "1d")
    return {
        "12h": "1d",
        "24h": "1d",
        "3d": "7d",
        "90d": "180d",
        "1y": "365d",
    }.get(value, value)
