#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
vb9(2).py — VaultBoy bot (degen edition, updated)
- Scans ONLY via dex3nou.py (no other APIs/PW here)
- Aggregates overlapping metrics from all sources included by dex3nou
- Posts, in order:
  (1) Degen Summary + extras
  (2) — VAULTBOY QUANT — aggregate score
  (3) Mentions split: Positive vs Negative
  (4) 🧠 AI Take (concise)
  (5) Top whale winners

Commands:
  /start <mint>   → scan anywhere (DM/group)
  /scan <mint>    → scan only in VaultBoy group (guarded)

Env:
  VB_BOT_TOKEN, VB_GROUP_ID, GMGN_REF, DEX3_PATH, SCAN_TIMEOUT
  OPENAI_API_KEY (optional), PREDICT_MODEL (default gpt-5-min)
"""

import asyncio, json, os, sys, time, uuid, tempfile, subprocess, math
from typing import Optional, Tuple, List, Dict, Any
from html import escape as h
from statistics import median

# ====== CONFIG ======
BOT_TOKEN = os.getenv("VB_BOT_TOKEN", "8437529571:AAHFIx8NfY8SjBxJzIIPCYequaDpS03tZko")
VAULTBOY_GROUP_ID = int(os.getenv("VB_GROUP_ID", "-1002596386377"))
GMGN_REF = os.getenv("GMGN_REF", "rLkfkJiz")

DEX3_PATH = os.getenv("DEX3_PATH", "/root/teste/dexcheck/dex3nou.py")
SCAN_TIMEOUT = int(os.getenv("SCAN_TIMEOUT", "180"))

OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "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")
OPENAI_MODEL = os.getenv("PREDICT_MODEL", "gpt-4.1")

PRINT_SUBPROCESS = True

# ====== TELEGRAM ======
from telegram.ext import Application, CommandHandler, ContextTypes
from telegram.constants import ParseMode
from telegram.error import BadRequest

# ---------- Helpers ----------
def _fmt_usd(n: Optional[float]) -> str:
    if n is None: return "N/A"
    try:
        if n >= 1_000_000_000: return f"${n/1_000_000_000:.2f}B"
        if n >= 1_000_000:     return f"${n/1_000_000:.2f}M"
        if n >= 1_000:         return f"${n/1_000:.2f}k"
        if n >= 1:             return f"${n:,.2f}"
        return f"${n:.8f}".rstrip("0").rstrip(".")
    except Exception:
        return f"${n}"

def _to_float(v) -> Optional[float]:
    try:
        if v is None: return None
        if isinstance(v, bool): return float(int(v))
        return float(v)
    except Exception:
        return None

def _safe_int(v) -> Optional[int]:
    try:
        if v is None: return None
        return int(float(v))
    except Exception:
        return None

def _get(obj: Any, path: List[Any], default=None):
    cur = obj
    for k in path:
        if isinstance(cur, dict):
            cur = cur.get(k)
        elif isinstance(cur, list):
            try:
                cur = cur[int(k)]
            except Exception:
                return default
        else:
            return default
    return default if cur is None else cur

def _agg(nums: List[float]) -> Optional[float]:
    vals = [x for x in [_to_float(n) for n in nums] if x is not None and math.isfinite(x)]
    if not vals: return None
    if len(vals) == 1: return vals[0]
    try:
        return float(median(vals))
    except Exception:
        return sum(vals) / len(vals)

def _first_not_none(*vals):
    for v in vals:
        if v is not None:
            return v
    return None

def gmgn_chart_link(mint: str) -> str:
    return f'https://gmgn.ai/sol/token/{GMGN_REF}_{mint}'

def gmgn_address_link(addr: str) -> str:
    return f'https://gmgn.ai/sol/address/{GMGN_REF}_{addr}'

def short_addr(addr: str) -> str:
    if not addr or len(addr) <= 10: return addr or "N/A"
    return addr[:10] + ".."

def pct_change(current: Optional[float], past: Optional[float]) -> Optional[float]:
    c = _to_float(current); p = _to_float(past)
    if c is None or p is None or p == 0: return None
    return (c - p) / p * 100.0
# --- missing helpers (drop-in) ---

def gmgn_can_sell_blocked(val):
    """
    Return True ONLY when GMGN explicitly indicates sell is blocked.
    Be conservative: unknown/empty/ambiguous => assume NOT blocked.
    """
    if val is None:
        return False
    s = str(val).strip().lower()

    # Most APIs: 1/true/yes => can sell, 0/false/no => cannot sell.
    if s in ("0", "true", "yes", "y", "ok", "allow", "allowed", "can", "sellable", "sell_ok"):
        return False
    if s in ("1", "false", "no", "n", "blocked", "block", "cannot", "cant", "can_not_sell", "honeypot", "sell_block"):
        return True

    # Known “safe” words
    if any(w in s for w in ("can sell", "sell allowed", "sellable")):
        return False
    # Known “bad” words
    if any(w in s for w in ("blocked", "cannot sell", "honeypot", "no sell", "sell blocked")):
        return True

    # Unknown -> do NOT block
    return False

def is_gmgn_flag_on(val):
    """
    Normalize a GMGN boolean-ish flag.
    Returns True/False, or None if unknown.
    """
    if val is None:
        return None
    sval = str(val).strip().lower()
    if sval in ("1", "true", "yes", "y", "ok", "on", "allow", "allowed"):
        return True
    if sval in ("0", "false", "no", "n", "off", "deny", "denied", "blocked"):
        return False
    return None

def wallet_tag_counts_from_stats(a):
    """
    Build (label, count) pairs from wallet-tag percentage stats.
    Expects:
      - a['holders'] or a['holder_count'] (absolute holders)
      - a['wallet_tags'] or a['wallet_types'] (percentages per tag)
    Returns top 8 by count.
    """
    # holders total
    holders = 0
    for k in ("holders", "holder_count"):
        v = a.get(k)
        try:
            if v is not None:
                holders = float(v)
                break
        except Exception:
            pass
    if not holders:
        return []

    # source of percentages
    wtypes = a.get("wallet_tags") or a.get("wallet_types") or {}
    if not isinstance(wtypes, dict) or not wtypes:
        return []

    # nice labels for common keys (fallback to key)
    label_map = {
        "smart": "smart",
        "whale": "whales",
        "bot": "bots",
        "sniper": "snipers",
        "new": "new wallets",
        "cex": "CEX",
        "dex": "DEX",
        "paper": "paper hands",
        "diamond": "diamond hands",
        "holder": "holders",
        "retail": "retail"}

    counts = []
    for key, pct_val in wtypes.items():
        # percent can be number or str like "12.3"
        pct = None
        try:
            pct = float(pct_val)
        except Exception:
            pass
        if pct is None:
            continue
        label = label_map.get(str(key).lower(), str(key))
        count = int(round(holders * pct / 100.0))
        counts.append((label, count))

    counts.sort(key=lambda x: x[1], reverse=True)
    return counts[:8]
# --- end helpers ---

# ---------- Run dex3nou ----------
async def run_scan_cli(mint: str, status_msg=None) -> dict:
    """Run dex3nou.py with only --mint and --out, capture JSON."""
    loop = asyncio.get_event_loop()
    tmp = os.path.join(tempfile.gettempdir(), f"vb_{uuid.uuid4().hex}.json")

    def run_cmd(cmd, timeout=SCAN_TIMEOUT) -> Tuple[int, str]:
        if PRINT_SUBPROCESS:
            print("[bot] running:", " ".join(cmd))
        proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)
        ring = []
        start = time.time()
        try:
            for line in proc.stdout:
                line = line.rstrip("\n")
                if PRINT_SUBPROCESS:
                    print("[dex3]", line)
                ring.append(line)
                if len(ring) > 400:
                    ring.pop(0)
                if (time.time() - start) > timeout:
                    proc.kill()
                    return 124, "\n".join(ring[-200:])
        finally:
            rc = proc.wait()
        return rc, "\n".join(ring[-200:])

    def _runner():
        cmd = [sys.executable, DEX3_PATH, "--mint", mint, "--out", tmp]
        rc, tail = run_cmd(cmd)
        if rc == 0 and os.path.exists(tmp):
            with open(tmp, "r", encoding="utf-8") as f:
                return json.load(f)
        raise RuntimeError(f"dex3nou failed. Exit {rc}.\n{tail}")

    return await loop.run_in_executor(None, _runner)

# ---------- Collect per-source candidates ----------
def collect_candidates(bundle: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
    out: Dict[str, Dict[str, Any]] = {}

    # DexCheck details
    det = _get(bundle, ["dexcheck","details","data"], {}) or {}
    out["dexcheck"] = {
        "price": _to_float(det.get("priceUsd")),
        "liquidity": _to_float(det.get("totalLiquidity")),
        "volume24": _first_not_none(_to_float(det.get("_24h_volume")), _to_float(det.get("_24HourVolume"))),
        "change24": _to_float(det.get("change") or det.get("_24hPriceChange")),
        "market_cap": _to_float(det.get("marketCap") or det.get("mcap") or det.get("market_cap")),
        "fdv": _to_float(det.get("fdv")),
        "symbol": det.get("baseSymbol") or det.get("token0Name") or det.get("symbol"),
        "name": det.get("baseSymbolName") or det.get("token0Name") or det.get("name")}

    # DexCheck extras
    whales = _get(bundle, ["dexcheck","whales_insights"], {}) or {}
    out["dexcheck_whales"] = {
        "whale_buy_usd": _to_float(whales.get("total_amount_bought")),
        "whale_sell_usd": _to_float(whales.get("total_amount_sold")),
        "whale_buyers": _safe_int(whales.get("num_traders_bought")),
        "whale_sellers": _safe_int(whales.get("num_traders_sold")),
        "buyers": whales.get("buyers") or [],
        "sellers": whales.get("sellers") or []}

    audit = _get(bundle, ["dexcheck","audit_certik","data"], {}) or {}
    out["dexcheck_security"] = {
        "honeypot": audit.get("honeypot"),
        "verified_contract": audit.get("verified_contract"),
        "ownership_renounced": audit.get("ownership_renounced"),
        "mintable": audit.get("mintable"),
        "tax_modifiable": audit.get("tax_modifiable")}

    # Dexscreener
    ds = _get(bundle, ["enrichment","dexscreener","token"], {}) or {}
    pairs = ds.get("pairs") or []
    best = pairs[0] if pairs else {}
    out["dexscreener"] = {
        "price": _to_float(best.get("priceUsd")),
        "liquidity": _to_float(_get(best, ["liquidity","usd"])),
        "volume24": _to_float(_get(best, ["volume","h24"])),
        "change24": _to_float(_get(best, ["priceChange","h24"])),
        "market_cap": _to_float(best.get("marketCap") or best.get("fdv")),
        "fdv": _to_float(best.get("fdv")),
        "trades24": (_safe_int(_get(best, ["txns","h24","buys"])) or 0) + (_safe_int(_get(best, ["txns","h24","sells"])) or 0) if best else None,
        "votes": _safe_int(_get(best, ["info","votes"])) if isinstance(best.get("info"), dict) else None}

    # GMGN
    gm_full = _get(bundle, ["enrichment","gmgn","full"], {}) or {}
    gm_tokstat = _get(gm_full, ["token_stat","data"], {}) or {}
    gm_mutil = _get(gm_full, ["mutil_window_token_info","data","0"], {}) or {}
    gm_sec = _get(gm_full, ["token_security_launchpad","data","security"], {}) or {}

    # Try to pull a top10 list from any gmgn path that might exist
    top10_list = _get(gm_full, ["token_holder_stat","data","top_holders"], []) \
                 or _get(gm_full, ["holder_stat","data","top_holders"], []) \
                 or _get(gm_full, ["top_holders","data"], [])

    # Wallet tags distribution
    wallet_tags = _get(gm_full, ["token_wallet_tags_stat","data"], {}) or {}

    out["gmgn"] = {
        "holders": _first_not_none(_to_float(gm_tokstat.get("holder_count")), _to_float(gm_mutil.get("holder_count"))),
        "top10_pct": gm_sec.get("top_10_holder_rate"),
        "top10_list": top10_list,
        "wallet_types": {
            "bluechip_owner_pct": gm_tokstat.get("bluechip_owner_percentage"),
            "top_rat_trader_pct": gm_tokstat.get("top_rat_trader_percentage"),
            "top_bundler_trader_pct": gm_tokstat.get("top_bundler_trader_percentage"),
            "top_bot_degen_pct": gm_tokstat.get("top_bot_degen_percentage")},
        "wallet_tags": wallet_tags,
        "dev": {
            "creator_address": gm_mutil.get("creator_address") or gm_tokstat.get("creator_address")}}
    out["gmgn_security"] = {
        "honeypot": str(gm_sec.get("honeypot", "")),
        "renounced": gm_sec.get("renounced"),
        "renounced_mint": gm_sec.get("renounced_mint"),
        "can_sell": gm_sec.get("can_sell"),
        "buy_tax": gm_sec.get("buy_tax"),
        "sell_tax": gm_sec.get("sell_tax"),
        "lock_percent": gm_sec.get("lock_summary",{}).get("lock_percent"),
        "top10_rate": gm_sec.get("top_10_holder_rate")}

    # MEVX
    mevx = _get(bundle, ["enrichment","mevx","pool_details"], {}) or {}
    out["mevx"] = {"liquidity": _to_float(mevx.get("liquidUsd")), "market_cap": _to_float(mevx.get("marketCap"))}

    # Cielo
    cielo = _get(bundle, ["enrichment","cielo","token_stats","data"], {}) or {}
    out["cielo"] = {
        "price": _to_float(cielo.get("price_usd")),
        "market_cap": _to_float(cielo.get("market_cap_usd")),
        "change24": _to_float(cielo.get("change")),
        "volume24": _to_float(cielo.get("volume"))}

    # Dextools
    dxt = _get(bundle, ["enrichment","dextools","pair","data"], [])
    d0 = dxt[0] if isinstance(dxt, list) and dxt else {}
    out["dextools"] = {
        "price": _to_float(_get(d0, ["price"])),
        "dextScore": _get(d0, ["dextScore","total"]),
        "periodStats": _get(d0, ["periodStats"], {}),
        "price5m": _get(d0, ["price5m","price"]),
        "price1h": _get(d0, ["price1h","price"]),
        "price6h": _get(d0, ["price6h","price"]),
        "price24h": _get(d0, ["price24h","price"]),
        "swaps24": _get(d0, ["price24h","swaps"]),
        "makers24": _get(d0, ["price24h","makers"])}

    # DexCheck: volume breakdown daily (organic %)
    vb = _get(bundle, ["dexcheck","volume_breakdown","data"], []) or []
    out["dexcheck_vbreakdown"] = vb

    # DexCheck: mentions + KOL tweets + trader stats
    out["twitter_mentions"] = _get(bundle, ["dexcheck","twitter_mention_stats","data"], {}) or {}
    out["kol_tweets"] = _get(bundle, ["dexcheck","twitter_kol_tweets"], []) or []
    out["trader_stats"] = _get(bundle, ["dexcheck","trader_stats","data"], {}) or {}

    return out

# ---------- Aggregation ----------
def aggregate_fields(bundle: Dict[str, Any]) -> Dict[str, Any]:
    meta = {
        "pair_id": bundle.get("pair_id"),
        "base_address": bundle.get("base_address"),
        "symbol": bundle.get("symbol") or _get(bundle, ["dexcheck","details","data","baseSymbol"]),
        "name": bundle.get("name") or _get(bundle, ["dexcheck","details","data","baseSymbolName"])}

    per = collect_candidates(bundle)

    price = _agg([per["dexcheck"]["price"], per["dexscreener"]["price"], per["cielo"]["price"], per["dextools"]["price"]])
    market_cap = _agg([per["dexcheck"]["market_cap"], per["dexscreener"]["market_cap"], per["cielo"]["market_cap"], per["mevx"]["market_cap"]])
    fdv = _agg([per["dexscreener"]["fdv"]])
    liquidity = _agg([per["dexcheck"]["liquidity"], per["dexscreener"]["liquidity"], per["mevx"]["liquidity"]])
    volume24 = _agg([per["dexcheck"]["volume24"], per["dexscreener"]["volume24"], per["cielo"]["volume24"]])
    change24 = _agg([per["dexcheck"]["change24"], per["dexscreener"]["change24"], per["cielo"]["change24"]])
    holders = _agg([per["gmgn"]["holders"]])
    trades24 = _agg([per["dexscreener"]["trades24"], _to_float(per["dextools"]["swaps24"])])

    votes = _agg([per["dexscreener"].get("votes")])
    whale_buy_usd = _agg([per["dexcheck_whales"]["whale_buy_usd"]])
    whale_sell_usd = _agg([per["dexcheck_whales"]["whale_sell_usd"]])
    whale_buyers = _agg([per["dexcheck_whales"]["whale_buyers"]])
    whale_sellers = _agg([per["dexcheck_whales"]["whale_sellers"]])

    wallets_active = _first_not_none(
        whale_buyers and whale_sellers and int(whale_buyers + whale_sellers),
        trades24 and int(trades24)
    )

    organic_pct = None
    vb = per.get("dexcheck_vbreakdown") or []
    if vb:
        organic_pct = _to_float(vb[0].get("organic_percentage"))

    fields = {
        "meta": meta,
        "agg": {
            "price": price, "market_cap": market_cap, "fdv": fdv,
            "liquidity": liquidity, "volume24": volume24, "change24_pct": change24,
            "holders": holders, "trades24": trades24, "votes": votes,
            "wallets_active": wallets_active,
            "whale_buy_usd": whale_buy_usd, "whale_sell_usd": whale_sell_usd,
            "whale_buyers": whale_buyers, "whale_sellers": whale_sellers,
            "organic_pct": organic_pct,
            "dextScore": per["dextools"]["dextScore"],
            "price_ref": {
                "now": per["dextools"]["price"],
                "m5": per["dextools"]["price5m"],
                "h1": per["dextools"]["price1h"],
                "h6": per["dextools"]["price6h"],
                "h24": per["dextools"]["price24h"]},
            "wallet_types": per["gmgn"]["wallet_types"],
            "wallet_tags": per["gmgn"]["wallet_tags"],
            "dev": per["gmgn"]["dev"],
            "top10_pct": per["gmgn"]["top10_pct"],
            "top10_list": per["gmgn"]["top10_list"],
            "vbreakdown": vb,
            "buyers": per["dexcheck_whales"]["buyers"],
            "sellers": per["dexcheck_whales"]["sellers"],
            "kol_tweets": per["kol_tweets"],
            "twitter_mentions": per["twitter_mentions"],
            "trader_stats": per["trader_stats"]},
        "per_source": per,  # keep for debugging/guards
    }
    return fields

# ---------- Message builders ----------
def build_first_message(bundle: Dict[str, Any], d: Dict[str, Any]) -> str:
    m = d["meta"]; a = d["agg"]
    sym = (m.get("symbol") or "").upper(); name = m.get("name") or sym
    mint = m.get("base_address") or ""

    # price change over time using dextools price refs
    pr = a.get("price_ref") or {}
    pc = {
        "5m": pct_change(pr.get("now"), pr.get("m5")),
        "1h": pct_change(pr.get("now"), pr.get("h1")),
        "6h": pct_change(pr.get("now"), pr.get("h6")),
        "24h": pct_change(pr.get("now"), pr.get("h24"))}

    lines = []
    lines.append(f'📎 ${h(sym)} — {h(name)}')
    if a["price"] is not None and a["change24_pct"] is not None:
        lines.append(f'💵 Price: {_fmt_usd(a["price"])} ({("+" if a["change24_pct"]>=0 else "")}{a["change24_pct"]:.2f}% 24h)')
    elif a["price"] is not None:
        lines.append(f'💵 Price: {_fmt_usd(a["price"])}')
    else:
        lines.append('💵 Price: N/A')

    if a["market_cap"] is not None: lines.append(f'🏷️ MC: {_fmt_usd(a["market_cap"])}')
    if a["fdv"] is not None:         lines.append(f'🏷️ FDV: {_fmt_usd(a["fdv"])}')
    lines.append(f'💧 Liquidity: {_fmt_usd(a["liquidity"])}')
    if a["volume24"] is not None:    lines.append(f'📊 24h Vol: {_fmt_usd(a["volume24"])}')
    if a["holders"] is not None:     lines.append(f'👥 Holders: {int(a["holders"]):}')
    if a["trades24"] is not None:    lines.append(f'🔁 24h Trades: {int(a["trades24"]):}')

    # Whale flow & wallets
    wb, ws = a["whale_buy_usd"], a["whale_sell_usd"]
    wnb, wns = a["whale_buyers"], a["whale_sellers"]
    if any(x is not None for x in [wb, ws, wnb, wns]):
        parts = []
        if wnb is not None or wns is not None:
            parts.append(f'buyers {int(wnb or 0)} vs sellers {int(wns or 0)}')
        if wb is not None or ws is not None:
            parts.append(f'${_fmt_usd(wb or 0).lstrip("$")} in / ${_fmt_usd(ws or 0).lstrip("$")} out')
        lines.append('🐳 Whale Flow: ' + " • ".join(parts))

    if a["wallets_active"] is not None:
        lines.append(f'👛 Active wallets (approx): {int(a["wallets_active"]):}')

    # Price change over time
    trail = []
    for label in ["5m","1h","6h","24h"]:
        if pc.get(label) is not None:
            trail.append(f'{label} {("+" if pc[label]>=0 else "")}{pc[label]:.2f}%')
    if trail:
        lines.append("⏱️ Change: " + " • ".join(trail))

    # DextScore & Organic
    if a.get("dextScore") is not None:
        ds = a["dextScore"]
        lines.append(f'🧪 DextScore: {int(ds)}/100')
    if a.get("organic_pct") is not None:
        lines.append(f'🌱 Organic: {a["organic_pct"]:.2f}%')

    # Dev info & top10 %
    dev = a.get("dev") or {}
    sec = d.get("per_source",{}).get("gmgn_security",{})
    devbits = []
    if dev.get("creator_address"): devbits.append(f'creator <a href="{gmgn_address_link(dev["creator_address"])}">{short_addr(dev["creator_address"])}</a>')
    if str(sec.get("renounced")).lower() in ("1","true","yes"): devbits.append("renounced✅")
    if gmgn_can_sell_blocked(sec.get("can_sell")):
        devbits.append("sell_block❌")
    elif is_gmgn_flag_on(sec.get("can_sell")) is False:
        devbits.append("can_sell✅")
    buy_tax = _to_float(sec.get("buy_tax"))
    sell_tax = _to_float(sec.get("sell_tax"))
    if (buy_tax and buy_tax > 0) or (sell_tax and sell_tax > 0):
        devbits.append(f'tax {int(buy_tax or 0)}/{int(sell_tax or 0)}')
    if sec.get("lock_percent") not in (None, "", "0"): devbits.append(f'LP lock {sec["lock_percent"]}%')
    if a.get("top10_pct") not in (None, "", "0"): devbits.append(f'Top10 {a["top10_pct"]}%')
    if devbits:
        lines.append("🧑‍💻 Dev/Token: " + " • ".join(devbits))

    # Wallet types (counts)
    counts = wallet_tag_counts_from_stats(a)
    if counts:
        lines.append("🏷️ Wallet types: " + " • ".join([f"{k} {int(v)}" for k, v in counts]))

    # TOP10 holders list (if present)
    top10 = a.get("top10_list") or []
    if isinstance(top10, list) and top10:
        lines.append("👑 Top10 holders:")
        for t in top10[:10]:
            addr = t.get("address") or t.get("holder") or t.get("account")
            share = t.get("percent") or t.get("percentage") or t.get("hold_rate") or t.get("share")
            share = f"{float(share):.2f}%" if _to_float(share) is not None else "N/A"
            if addr:
                lines.append(f'• <a href="{gmgn_address_link(addr)}">{h(short_addr(addr))}</a> — {share}')
            else:
                lines.append(f'• {share}')
    # Community Mentions (from DexCheck)
    tm = a.get("twitter_mentions") or {}
    if tm.get("total_mentions") is not None:
        pos_pct = tm.get("positive_percent"); neg_pct = tm.get("negative_percent")
        parts = [f'{int(tm["total_mentions"])} mentions']
        if pos_pct is not None and neg_pct is not None:
            parts.insert(0, f'{pos_pct:.2f}% 👍 / {neg_pct:.2f}% 👎')
        lines.append("🗣️ Community Mentions: " + " — ".join(parts))

    # Traders Insights (DexCheck trader_stats)
    tstats = a.get("trader_stats") or {}
    if any(x in tstats for x in ["winning_makers","losing_makers","offloading_makers"]):
        lines.append("📈 Traders Insights: "
            + f'Winning {int(tstats.get("winning_makers",0))} (+{_fmt_usd(_to_float(tstats.get("winning_usd_value")) or 0)}) • '
            + f'Losing {int(tstats.get("losing_makers",0))} (-{_fmt_usd(abs(_to_float(tstats.get("losing_usd_value")) or 0))}) • '
            + f'Offloading {int(tstats.get("offloading_makers",0))} ({_fmt_usd(_to_float(tstats.get("offloading_usd_value")) or 0)})'
        )

    # Whales Insights
    if any(x is not None for x in [a["whale_buyers"], a["whale_sellers"], a["whale_buy_usd"], a["whale_sell_usd"]]):
        lines.append("🐋 Whales Insights: "
            + (f'Buying {int(a["whale_buyers"] or 0)} (+{_fmt_usd(a["whale_buy_usd"] or 0)})' if a["whale_buyers"] is not None else '')
            + (' • ' if a["whale_buyers"] is not None and a["whale_sellers"] is not None else '')
            + (f'Dumping {int(a["whale_sellers"] or 0)} ({_fmt_usd(a["whale_sell_usd"] or 0)})' if a["whale_sellers"] is not None else '')
        )

    lines.append(f'🔗 📊 <a href="{gmgn_chart_link(mint)}">CHART</a>')
    return "\n".join(lines)

# ---------- VAULTBOY QUANT ----------
def score_vaultboy(bundle: Dict[str, Any], d: Dict[str, Any]) -> Tuple[str, float]:
    a = d["agg"]
    liq_pct = (a["liquidity"] / a["market_cap"] * 100.0) if a.get("liquidity") and a.get("market_cap") else None

    def liq_score(p):
        if p is None: return 2.0
        if p >= 30: return 10.0
        if p >= 20: return 8.5
        if p >= 15: return 7.0
        if p >= 10: return 5.5
        if p >= 7:  return 4.0
        if p >= 4:  return 2.5
        return 1.0

    s_liq = liq_score(liq_pct)
    org = a.get("organic_pct")
    s_org = 5.0 if org is None else max(0.0, min(10.0, org/10.0))

    depth_ratio = (a["volume24"]/a["market_cap"]) if a.get("volume24") and a.get("market_cap") else None
    s_depth = 10.0 if depth_ratio and depth_ratio>=1 else (7.0 if depth_ratio and depth_ratio>=0.5 else (4.0 if depth_ratio and depth_ratio>=0.2 else (2.0 if depth_ratio and depth_ratio>0 else 1.0)))

    s_activity = 5.0
    if a.get("trades24") and a.get("holders"):
        ratio = a["trades24"] / max(1.0, a["holders"])
        if ratio >= 0.5: s_activity = 10.0
        elif ratio >= 0.2: s_activity = 8.0
        elif ratio >= 0.1: s_activity = 6.5
        elif ratio >= 0.05: s_activity = 5.0
        else: s_activity = 3.0

    net_whale = (a.get("whale_buy_usd") or 0) - (a.get("whale_sell_usd") or 0)
    s_whale = 6.0
    if net_whale > 0:
        s_whale = 7.5
        if net_whale > 50_000: s_whale = 8.5
        if net_whale > 200_000: s_whale = 9.5
    elif net_whale < 0:
        s_whale = 4.5
        if net_whale < -50_000: s_whale = 3.5
        if net_whale < -200_000: s_whale = 2.0

    pr = a.get("price_ref") or {}
    now = pr.get("now")
    deltas = [x for x in [pct_change(now, pr.get("m5")), pct_change(now, pr.get("h1")), pct_change(now, pr.get("h6")), pct_change(now, pr.get("h24"))] if x is not None]
    price_sigma = sum(abs(x) for x in deltas) if deltas else None
    s_align = 6.0
    if price_sigma is not None:
        if price_sigma <= 15: s_align = 9.0
        elif price_sigma <= 30: s_align = 7.5
        elif price_sigma <= 60: s_align = 6.0
        else: s_align = 4.5

    total = (0.3*s_liq + 0.2*s_org + 0.15*s_depth + 0.15*s_activity + 0.1*s_whale + 0.1*s_align)

    # Gates
    gate_reason = None
    gmsec = d.get("per_source",{}).get("gmgn_security",{})
    if str(gmsec.get("honeypot","")).lower() in ("1","true","yes"):
        total = min(total, 1.5); gate_reason = "honeypot flagged"
    if gmgn_can_sell_blocked(gmsec.get("can_sell")):
        total = min(total, 2.0); gate_reason = "sell_block"
    if liq_pct is not None and liq_pct < 10:
        total = min(total, 6.0); gate_reason = (gate_reason or "Liq%≤10 cap")

    lines = []
    lines.append("— <b>VAULTBOY QUANT</b> —")
    lines.append(f"MCap: {_fmt_usd(a.get('market_cap'))} • Liq: {_fmt_usd(a.get('liquidity'))} • 24h Vol: {_fmt_usd(a.get('volume24'))}")
    if liq_pct is not None:
        lines.append(f"🔹 Liquidity: {s_liq:.2f}/10 ({liq_pct:.2f}% of MC)")
    lines.append(f"🔹 Organic: {s_org:.2f}/10 ({a.get('organic_pct'):.2f}% recent organic)" if a.get('organic_pct') is not None else "🔹 Organic: N/A")
    lines.append(f"🔹 Depth: {s_depth:.2f}/10 (Vol/MC {depth_ratio:.2f}×)" if depth_ratio is not None else "🔹 Depth: N/A")
    lines.append(f"🔹 Activity: {s_activity:.2f}/10 (Trades/Holders { (a.get('trades24') or 0) / max(1,(a.get('holders') or 1)) :.4f})" if a.get('holders') else f"🔹 Activity: {s_activity:.2f}/10")
    lines.append(f"🔹 Alignment: {s_align:.2f}/10 (priceΣ={price_sigma:.2f}%)" if price_sigma is not None else "🔹 Alignment: N/A")

    agg = max(1.0, min(10.0, round(total/1.0, 2)))
    if gate_reason:
        lines.append(f"🏁 Aggregate: <b>{agg}/10</b>  (gate: {gate_reason})")
    else:
        lines.append(f"🏁 Aggregate: <b>{agg}/10</b>")
    return "\n".join(lines), agg

# ---------- KOL tweets split ----------
def build_kol_sentiment_message(d: Dict[str, Any]) -> Optional[str]:
    tm = d["agg"].get("twitter_mentions") or {}
    pos = tm.get("positive_profiles") or []
    neg = tm.get("negative_profiles") or []
    if not pos and not neg:
        return None

    def fmt(profiles):
        outs = []
        for p in profiles[:12]:
            u = p.get("username"); link = p.get("tweet_url") or p.get("url")
            if not u or not link: continue
            outs.append(f'<a href="{h(link)}">@{h(u)}</a>')
        return " | ".join(outs) if outs else None

    pos_line = fmt(pos)
    neg_line = fmt(neg)

    lines = []
    lines.append("🧵 <b>Mentions by Sentiment</b>")
    if pos_line: lines.append("✅ Positive: " + pos_line)
    if neg_line: lines.append("❌ Negative: " + neg_line)
    return "\n".join(lines) if len(lines) > 1 else None

# ---------- Top whale winners ----------
def build_top_whales_message(d: Dict[str, Any]) -> Optional[str]:
    buyers = d["agg"].get("buyers") or []
    if not buyers:
        return None
    pos = [b for b in buyers if _to_float(b.get("net_amount")) and _to_float(b.get("net_amount")) > 0]
    if not pos:
        return None
    pos.sort(key=lambda x: _to_float(x.get("net_amount")) or 0.0, reverse=True)
    lines = []
    lines.append("🏆 <b>Top whale winners</b>")
    for b in pos[:5]:
        maker = b.get("maker")
        tag = b.get("tag") or "whale"
        entry = _to_float(b.get("avg_entry_price"))
        net = _to_float(b.get("net_amount"))
        link = gmgn_address_link(maker) if maker else None
        name = short_addr(maker) if maker else "N/A"
        parts = [f'• <a href="{h(link)}">{h(name)}</a> — tag: {h(tag)}']
        if entry is not None: parts.append(f'• Entry {entry:.6f}')
        if net is not None: parts.append(f'• Net {_fmt_usd(net)}')
        lines.append(" ".join(parts))
    return "\n".join(lines)

# ---------- AI Summary via Responses API ----------
async def ai_summary_from_agg(d: Dict[str, Any]) -> str:
    if not OPENAI_API_KEY:
        return "AI summary disabled (missing OPENAI_API_KEY)."

    m = d["meta"]; a = d["agg"]
    feats = {
        "symbol": m.get("symbol"),
        "name": m.get("name"),
        "mint": m.get("base_address"),
        "price_usd": a.get("price"),
        "market_cap_usd": a.get("market_cap"),
        "fdv_usd": a.get("fdv"),
        "liq_usd": a.get("liquidity"),
        "vol24_usd": a.get("volume24"),
        "chg24_pct": a.get("change24_pct"),
        "holders": a.get("holders"),
        "trades24": a.get("trades24"),
        "votes": a.get("votes"),
        "wallets_active": a.get("wallets_active"),
        "whale_buy_usd": a.get("whale_buy_usd"),
        "whale_sell_usd": a.get("whale_sell_usd"),
        "whale_buyers": a.get("whale_buyers"),
        "whale_sellers": a.get("whale_sellers")}

    prompt = (
        "You are an assistant that evaluates Solana memecoins using only the aggregated JSON below.\n"
        "Return 4 short sections:\n"
        "1) Snapshot (price, mcap/fdv, liq, 24h vol, 24h change, holders).\n"
        "2) Flow & Participation (trades, active wallets approx, whales in/out + counts).\n"
        "3) Security Checks (brief, if any info was provided elsewhere by the user).\n"
        "4) Outlook (Very Bearish / Bearish / Neutral / Bullish / Very Bullish) + a one-sentence rationale.\n"
        "Avoid financial advice phrasing. Be concise.\n"
        f"DATA:\n{json.dumps(feats, ensure_ascii=False)}"
    )

    import aiohttp
    url = "https://api.openai.com/v1/responses"
    headers = {"Authorization": f"Bearer {OPENAI_API_KEY}", "Content-Type": "application/json"}
    body = {"model": OPENAI_MODEL, "input": prompt, "max_output_tokens": 500}

    async with aiohttp.ClientSession() as s:
        async with s.post(url, headers=headers, json=body, timeout=90) as r:
            txt = await r.text()
            if r.status != 200:
                return f"AI summary error: HTTP {r.status}: {txt[:500]}"
            data = json.loads(txt)
            # Responses API: try output[].content[].text
            out = data.get("output") or []
            if out and isinstance(out, list):
                for blk in out:
                    content = blk.get("content") or []
                    for node in content:
                        if isinstance(node, dict) and "text" in node and isinstance(node["text"], str):
                            return node["text"].strip()
            # Fallback
            if "output_text" in data and isinstance(data["output_text"], str):
                return data["output_text"].strip()
            if "choices" in data:
                try:
                    return data["choices"][0]["message"]["content"].strip()
                except Exception:
                    pass
            return txt[:1200]

# ---------- Telegram flow ----------
async def do_scan_flow(update, context: ContextTypes.DEFAULT_TYPE, mint: str):
    await update.message.reply_text(f"Grab a beer and wait until i extract all the data for {mint} then i run a scan on all platforms + 3 AI…")
    try:
        bundle = await run_scan_cli(mint, status_msg=update.message)
        agg_fields = aggregate_fields(bundle)

        # (1) First message
        msg1 = build_first_message(bundle, agg_fields)
        await update.message.reply_html(msg1, disable_web_page_preview=True)

        # (2) VAULTBOY QUANT
        msg2, _ = score_vaultboy(bundle, agg_fields)
        await update.message.reply_html(msg2, disable_web_page_preview=True)

        # (3) KOL sentiment split
        msg3 = build_kol_sentiment_message(agg_fields)
        if msg3:
            await update.message.reply_html(msg3, disable_web_page_preview=True)

        # (4) AI
        summary = await ai_summary_from_agg(agg_fields)
        await update.message.reply_html("🧠 <b>Vaultboy AI - GPT5.1 & GROK </b>\n" + h(summary), disable_web_page_preview=True)

        # (5) Top whale winners
        msg4 = build_top_whales_message(agg_fields)
        if msg4:
            await update.message.reply_html(msg4, disable_web_page_preview=True)

    except Exception as e:
        await update.message.reply_text(f"❌ Scan failed:\n{e}")

async def is_member(bot, group_id, user_id):
    """
    Check if the user is a member of the given group.
    """
    try:
        member = await bot.get_chat_member(group_id, user_id)
        return member.status in ("member", "administrator", "creator")
    except BadRequest:
        return False

async def handle_start(update, context: ContextTypes.DEFAULT_TYPE):
    # /start <mint> works ONLY if user is a member of VaultBoy group
    user_id = update.effective_user.id

    if not await is_member(context.bot, VAULTBOY_GROUP_ID, user_id):
        await update.message.reply_text(
            "❌ You must be a member of the VaultBoy group (@vaultboyportal) to use this command."
        )
        return

    args = context.args or []
    if not args:
        await update.message.reply_text("Usage: /start <mint_address>")
        return

    mint = args[0].strip()
    await do_scan_flow(update, context, mint)
async def handle_scan(update, context: ContextTypes.DEFAULT_TYPE):
    # /scan <mint> only in VaultBoy group
    if update.effective_chat and update.effective_chat.id != VAULTBOY_GROUP_ID:
        await update.message.reply_text("This command runs in the VaultBoy group only.")
        return
    args = context.args or []
    if not args:
        await update.message.reply_text("Usage: /scan <mint>")
        return
    mint = args[0].strip()
    await do_scan_flow(update, context, mint)

def main():
    if len(sys.argv) > 1 and sys.argv[1] == "--mint":
        # CLI test
        mint = sys.argv[2]
        import asyncio as _a
        async def _t():
            # Fake bundle only for CLI? No, run real scan.
            b = await run_scan_cli(mint)
            d = aggregate_fields(b)
            print(build_first_message(b, d))
        _a.run(_t())
        return

    app = Application.builder().token(BOT_TOKEN).build()
    app.add_handler(CommandHandler("start", handle_start))
    app.add_handler(CommandHandler("scan", handle_scan))
    print("[INFO] vb9(2) bot running. /start <mint> anywhere • /scan <mint> in VaultBoy group only")
    app.run_polling()

if __name__ == "__main__":
    main()
