#!/usr/bin/env python3
"""
superbot.py – fully autonomous multi-pair futures engine
"""
import os, sys, json, time, math, asyncio, logging, yaml, traceback
from datetime import datetime, timedelta
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import typer, aiofiles
import ccxt.async_support as ccxt
import pandas as pd
import numpy as np
import talib
from rich.console import Console
from rich.live import Live
from rich.table import Table
from rich.logging import RichHandler
from dotenv import load_dotenv
import httpx

# -----------------------------  ENV  ---------------------------------
load_dotenv()
API_KEY    = os.getenv("BINANCE_API_KEY")
SECRET_KEY = os.getenv("BINANCE_SECRET_KEY")
TG_TOKEN   = os.getenv("TELEGRAM_TOKEN")
TG_CHAT    = os.getenv("TELEGRAM_CHAT_ID")

# -----------------------------  CONFIG  ------------------------------
CFG_PATH = Path("config.yaml")
DEFAULT_CFG = {
    "pairs": 5,
    "tf": "15m",
    "max_positions": 5,
    "risk_per_trade": 1.0,
    "max_dd_pct": 10,
    "leverage": 10,
    "paper": False,
    "slippage": 0.01,
    "grid": {"levels": 5, "spacing_pct": 0.5},
    "partial_tp": [25, 50, 100]  # %
}
if not CFG_PATH.exists():
    with open(CFG_PATH, "w") as f:
        yaml.dump(DEFAULT_CFG, f)
CFG = yaml.safe_load(open(CFG_PATH))

# -----------------------------  LOGGING  -----------------------------
LOG_DIR = Path("logs")
LOG_DIR.mkdir(exist_ok=True)
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s | %(levelname)s | %(message)s",
    handlers=[
        RichHandler(console=Console()),
        logging.FileHandler(LOG_DIR / "superbot.log", encoding="utf-8"),
    ],
)
log = logging.getLogger("superbot")

# -----------------------------  UTILS  -------------------------------
async def tg(msg: str):
    if not (TG_TOKEN and TG_CHAT):
        return
    url = f"https://api.telegram.org/bot{TG_TOKEN}/sendMessage"
    try:
        async with httpx.AsyncClient(timeout=5) as client:
            await client.post(url, data={"chat_id": TG_CHAT, "text": msg[:3500]})
    except Exception as e:
        log.warning(f"Telegram error: {e}")

# -----------------------------  DATA  --------------------------------
class DataFeed:
    def __init__(self, exch: ccxt.Exchange):
        self.ex = exch

    async def ohlcv(self, symbol: str, tf: str, limit: int = 200) -> pd.DataFrame:
        ohlcv = await self.ex.fetch_ohlcv(symbol, tf, limit=limit)
        df = pd.DataFrame(ohlcv, columns=["ts", "o", "h", "l", "c", "v"])
        df["ts"] = pd.to_datetime(df["ts"], unit="ms")
        return df.rename(columns={"o": "open", "h": "high", "l": "low", "c": "close", "v": "volume"})

# -----------------------------  REGIME  ------------------------------
class Regime:
    @staticmethod
    def adx(df: pd.DataFrame, period=14) -> float:
        return talib.ADX(df["high"], df["low"], df["close"], timeperiod=period).iloc[-1]

    @staticmethod
    def classify(df: pd.DataFrame) -> str:
        return "trend" if Regime.adx(df) > 25 else "grid"

# -----------------------------  STRATEGIES  ----------------------------
class Trend:
    def __init__(self):
        self.sl_pct = 1.5
        self.tp_pct = 2.5
        self.trail_pct = 0.5
        self.be_pct = 1.0

    def signal(self, df: pd.DataFrame) -> Optional[str]:
        fast = talib.EMA(df["close"], timeperiod=10)
        slow = talib.EMA(df["close"], timeperiod=21)
        if fast.iloc[-2] < slow.iloc[-2] and fast.iloc[-1] > slow.iloc[-1]:
            return "buy"
        if fast.iloc[-2] > slow.iloc[-2] and fast.iloc[-1] < slow.iloc[-1]:
            return "sell"
        return None

    def sl_tp(self, entry: float, side: str) -> Tuple[float, float]:
        sign = 1 if side == "buy" else -1
        sl = entry * (1 - sign * self.sl_pct / 100)
        tp = entry * (1 + sign * self.tp_pct / 100)
        return sl, tp

    def trail(self, entry: float, side: str, price: float, cur_sl: float) -> float:
        sign = 1 if side == "buy" else -1
        pnl_pct = (price - entry) / entry * 100 * sign
        if pnl_pct < self.be_pct:
            return cur_sl
        new = price * (1 - sign * self.trail_pct / 100)
        return max(new, cur_sl) if side == "buy" else min(new, cur_sl)


class Grid:
    def __init__(self, levels: int = 5, spacing: float = 0.5):
        self.levels = levels
        self.spacing = spacing
        self.orders: Dict[str, List[str]] = {}

    def levels_around(self, mid: float) -> List[float]:
        half = self.spacing * self.levels / 2
        return [mid * (1 + pct / 100) for pct in np.linspace(-half, half, self.levels)]

    def create(self, exch: ccxt.Exchange, symbol: str, mid: float):
        if symbol in self.orders:
            return
        buys = self.levels_around(mid)[: self.levels // 2]
        sells = self.levels_around(mid)[self.levels // 2 :]
        orders = []
        for p in buys:
            o = exch.create_order(symbol, "limit", "buy", 10, p)
            orders.append(o["id"])
        for p in sells:
            o = exch.create_order(symbol, "limit", "sell", 10, p)
            orders.append(o["id"])
        self.orders[symbol] = orders

    async def cancel(self, exch: ccxt.Exchange, symbol: str):
        if symbol not in self.orders:
            return
        for oid in self.orders[symbol]:
            try:
                await exch.cancel_order(oid, symbol)
            except Exception:
                pass
        del self.orders[symbol]


# -----------------------------  POSITION  ---------------------------------
class Position:
    def __init__(self, symbol: str, side: str, size: float,
                 entry: float, sl: float, tp: float, strat: str):
        self.symbol = symbol
        self.side = side
        self.size = size
        self.entry = entry
        self.sl = sl
        self.tp = tp
        self.strat = strat
        self.closed = False
        self.pnl = 0.0
        self.partials = CFG["partial_tp"]

    def update(self, price: float):
        if self.side == "buy":
            self.pnl = (price - self.entry) * self.size
        else:
            self.pnl = (self.entry - price) * self.size

    def should_close(self, price: float) -> Optional[str]:
        if self.side == "buy":
            if price <= self.sl:
                return "SL"
            if price >= self.tp and len(self.partials) == 0:
                return "TP"
        else:
            if price >= self.sl:
                return "SL"
            if price <= self.tp and len(self.partials) == 0:
                return "TP"
        return None


# -----------------------------  ENGINE  -----------------------------------
class Engine:
    def __init__(self, dry: bool = False):
        self.ex = ccxt.binance({
            "apiKey": API_KEY,
            "secret": SECRET_KEY,
            "options": {"defaultType": "future"},
            "sandbox": dry,
        })
        self.data = DataFeed(self.ex)
        self.positions: Dict[str, Position] = {}
        self.trend = Trend()
        self.grid  = Grid(CFG["grid"]["levels"], CFG["grid"]["spacing_pct"])
        self.start_balance = 0.0

    async def init(self):
        await self.ex.load_markets()
        self.start_balance = await self.balance()
        log.info(f"Start balance: {self.start_balance:.2f} USDT")

    async def balance(self) -> float:
        return await self.ex.fetch_balance()["USDT"]["free"]

    async def discover_pairs(self) -> List[str]:
        tickers = await self.ex.fetch_tickers()
        usdt = {s: t for s, t in tickers.items() if s.endswith("USDT") and t.get("quoteVolume")}
        top = sorted(usdt.items(), key=lambda x: x[1]["quoteVolume"], reverse=True)[: CFG["pairs"]]
        return [s for s, _ in top]

    async def process_pair(self, symbol: str):
        df = await self.data.ohlcv(symbol, CFG["tf"], 150)
        price = df["close"].iloc[-1]
        regime = Regime.classify(df)

        # equity stop
        bal = await self.balance()
        if (bal - self.start_balance) / self.start_balance * 100 < -CFG["max_dd_pct"]:
            log.error("Max draw-down reached. Closing all.")
            await self.close_all()
            return

        # trend
        if regime == "trend":
            await self.grid.cancel(self.ex, symbol)
            sig = self.trend.signal(df)
            if sig and symbol not in self.positions and len(self.positions) < CFG["max_positions"]:
                size = self.size(price)
                sl, tp = self.trend.sl_tp(price, sig)
                if not CFG["paper"]:
                    await self.ex.create_market_order(symbol, sig, size)
                pos = Position(symbol, sig, size, price, sl, tp, "trend")
                self.positions[symbol] = pos
                await tg(f"OPEN {symbol} {sig} {size:.1f} @ {price:.4f}")

        else:  # grid
            if symbol in self.positions:
                pass  # close trend positions if switching?
            if not CFG["paper"]:
                await self.grid.create(self.ex, symbol, price)

        # monitor trend positions
        pos = self.positions.get(symbol)
        if pos and pos.strat == "trend":
            pos.update(price)
            pos.sl = self.trend.trail(pos.entry, pos.side, price, pos.sl)
            reason = pos.should_close(price)
            if reason:
                if not CFG["paper"]:
                    await self.ex.close_position(symbol, pos.side, pos.size)
                await tg(f"CLOSE {symbol} {reason} PnL {pos.pnl:.2f}")
                del self.positions[symbol]

    async def close_all(self):
        for sym, pos in list(self.positions.items()):
            if not CFG["paper"]:
                await self.ex.close_position(sym, pos.side, pos.size)
            del self.positions[sym]

    def size(self, price: float) -> float:
        bal = asyncio.get_event_loop().run_until_complete(self.balance())
        risk_amt = bal * CFG["risk_per_trade"] / 100
        sl_pct = 1.5  # hard-coded for trend
        return max(1, risk_amt / (price * sl_pct / 100))

    async def run(self):
        await self.init()
        pairs = await self.discover_pairs()
        for p in pairs:
            await self.ex.set_leverage(CFG["leverage"], p)

        async with Live(self.dashboard(), refresh_per_second=1, console=console):
            while True:
                try:
                    tasks = [self.process_pair(p) for p in pairs]
                    await asyncio.gather(*tasks)
                    await asyncio.sleep(5)
                except KeyboardInterrupt:
                    log.info("Graceful shutdown")
                    await self.close_all()
                    break
                except Exception as e:
                    log.error(f"Loop: {e}")
                    await asyncio.sleep(30)

    # dashboard
    def dashboard(self):
        table = Table(title="SuperBot")
        table.add_column("Symbol")
        table.add_column("Side")
        table.add_column("Size")
        table.add_column("Entry")
        table.add_column("PnL")
        for p in self.positions.values():
            table.add_row(p.symbol, p.side, f"{p.size:.1f}",
                          f"{p.entry:.4f}", f"{p.pnl:.2f}")
        return table


# -----------------------------  CLI  ---------------------------------
app = typer.Typer()

@app.command()
def main(dry: bool = typer.Option(False, "--dry-run", help="Paper trading mode")):
    CFG["paper"] = dry
    asyncio.run(Engine(dry=dry).run())

if __name__ == "__main__":
    app()