"""
Testnet demo on Lighter — all four markets, each at its own MAX leverage.

  python lighter_live.py --dry                 # signals + would-be orders (no key)
  python lighter_live.py --run                 # signed testnet orders
  python lighter_live.py --run --market        # cross the spread instead of resting

Execution, both solved problems in one place:
  * default is a POST-ONLY limit resting ON the line — the spread cost disappears
    and you can earn it, at the risk of not being filled;
  * if the venue rejects post-only (price already through the line) it falls back
    to a market order so the trade still happens.

Safety, so it can run while you are unreachable:
  * daily loss cap — stops opening after MAX_DAILY_LOSS, keeps managing what is open
  * exchange-side stop on every position, two boxes beyond the line
  * kill switch — create a file called STOP in this folder, or send /stop on Telegram:
    every position is flattened at market and the process exits
  * Telegram: profit booked => "withdraw X" alert with the running total to withdraw;
    /status, /flat, /stop commands when TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID are set

Keys live in the environment on your machine, never here:
  LIGHTER_API_PRIVATE_KEY, LIGHTER_ACCOUNT_INDEX=8073, LIGHTER_API_KEY_INDEX=2
Orders must be signed even on testnet — there is no unsigned path.
"""
import argparse
import asyncio
import csv
import os
import sys
import time
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from decimal import Decimal as D, ROUND_DOWN, ROUND_HALF_UP

import lighter
import zec_renko_bot as z

try:
    from lighter import SignerClient
    from lighter.ws_client import WsClient
except ImportError:
    sys.exit("pip install lighter-sdk")

SYMBOLS = ["BTC", "ETH", "SOL"]      # ASELS dropped: thin book, no hourly source
STOP_BOXES = D("2")
SPLIT = len(SYMBOLS)
STOP_FILE = "STOP"
TG_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
TG_CHAT = os.getenv("TELEGRAM_CHAT_ID")


def ms_now():
    return int(time.time() * 1000)


def load_env(path="lighter.env"):
    """Read keys from a local file so they never live in code or in the shell history."""
    if not os.path.exists(path):
        return
    for line in open(path, encoding="utf-8"):
        line = line.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        k, v = line.split("=", 1)
        os.environ.setdefault(k.strip(), v.strip())


SEED_PAIRS = {"BTC": "BTCUSDT", "ETH": "ETHUSDT", "SOL": "SOLUSDT"}


class Meta:
    def __init__(self, d):
        self.index = int(d["market_id"])
        self.symbol = d["symbol"]
        self.price_dec = int(d["price_decimals"])
        self.size_dec = int(d["size_decimals"])
        self.min_base = D(str(d["min_base_amount"]))
        self.last = D(str(d["last_trade_price"]))
        self.price_step = D(1).scaleb(-self.price_dec)
        self.size_step = D(1).scaleb(-self.size_dec)
        frac = int(d.get("min_initial_margin_fraction") or d.get("default_initial_margin_fraction") or 500)
        self.max_lev = max(1, 10000 // frac)

    def px(self, price):
        return int((D(str(price)) * (D(10) ** self.price_dec)).to_integral_value(rounding=ROUND_HALF_UP))

    def amount(self, qty):
        return int((D(str(qty)) * (D(10) ** self.size_dec)).to_integral_value(rounding=ROUND_DOWN))

    def __repr__(self):
        return (f"{self.symbol} idx {self.index} | {self.max_lev}x | px {self.price_dec}dp "
                f"| size {self.size_dec}dp | min {self.min_base} | last {self.last}")


class Venue:
    def __init__(self, maker=True, mainnet=False):
        p = lighter.MAINNET if mainnet else lighter.TESTNET
        self.url, self.ws_url, self.chain = p.api_url, p.ws_url, p.chain_id
        self.mainnet = mainnet
        self.maker = maker
        self.metas, self.signer = {}, None
        self.account_index = int(os.getenv("LIGHTER_ACCOUNT_INDEX", "0") or 0) or None
        self.api_key_index = int(os.getenv("LIGHTER_API_KEY_INDEX", "0") or 0)
        self.key = os.getenv("LIGHTER_API_PRIVATE_KEY")

    async def load(self):
        cfg = lighter.Configuration(host=self.url)
        client = lighter.ApiClient(cfg)
        try:
            api = lighter.OrderApi(client)
            res = await api.order_book_details()
            for b in getattr(res, "order_book_details", []):
                d = b.to_dict()
                if d.get("symbol") in SYMBOLS:
                    self.metas[d["symbol"]] = Meta(d)
        finally:
            await client.close()
        if not self.metas:
            raise RuntimeError(f"no {SYMBOLS} markets on {self.url}")
        if self.key and self.account_index:
            self.signer = SignerClient(self.url, self.account_index,
                                       {self.api_key_index: self.key}, chain_id=self.chain)
            if self.signer.check_client() is not None:
                raise RuntimeError("signer refused the key")
        return self.metas

    async def _limit(self, m, side, amt, px, reduce_only):
        return await self.signer.create_order(
            m.index, 0, amt, px, side == "SHORT", SignerClient.ORDER_TYPE_LIMIT,
            SignerClient.ORDER_TIME_IN_FORCE_POST_ONLY, reduce_only=reduce_only)

    async def _market(self, m, side, amt, px, reduce_only):
        return await self.signer.create_market_order(m.index, 0, amt, px,
                                                     side == "SHORT", reduce_only=reduce_only)

    async def order(self, sym, side, qty, price, reduce_only=False):
        m = self.metas[sym]
        amt, px = m.amount(qty), m.px(price)
        if amt <= 0:
            return f"{sym}: {qty} rounds to 0 at {m.size_dec}dp"
        if self.signer is None:
            return f"DRY {sym} {side} amt {amt} px {px} reduce={reduce_only} " \
                   f"{'post-only limit' if self.maker else 'market'}"
        if self.maker:
            tx, resp, err = await self._limit(m, side, amt, px, reduce_only)
            if err:
                tx, resp, err = await self._market(m, side, amt, px, reduce_only)   # fill anyway
                return f"{sym} {side} post-only refused -> market: {'ERR ' + str(err) if err else 'ok'}"
            return f"{sym} {side} limit @ {px} resting (maker)"
        tx, resp, err = await self._market(m, side, amt, px, reduce_only)
        return f"{sym} {side} market amt {amt}: {'ERR ' + str(err) if err else 'ok'}"

    async def cancel_all(self, sym):
        m = self.metas[sym]
        if self.signer is None:
            return "DRY cancel"
        tx, resp, err = await self.signer.cancel_all_orders(m.index, 0)
        return err or "cancelled"

    async def stop(self, sym, side, qty, trigger):
        m = self.metas[sym]
        amt, px = m.amount(qty), m.px(trigger)
        if self.signer is None:
            return f"DRY STOP {sym} {side} amt {amt} @ {px}"
        tx, resp, err = await self.signer.create_order(
            m.index, 0, amt, px, side == "SHORT", SignerClient.ORDER_TYPE_STOP_LOSS,
            SignerClient.ORDER_TIME_IN_FORCE_IMMEDIATE_OR_CANCEL,
            reduce_only=True, trigger_price=px)
        return f"STOP {sym} @ {px} {'ERR ' + str(err) if err else 'placed'}"


def tg(text):
    if not (TG_TOKEN and TG_CHAT):
        return
    try:
        data = urllib.parse.urlencode({"chat_id": TG_CHAT, "text": text}).encode()
        urllib.request.urlopen(f"https://api.telegram.org/bot{TG_TOKEN}/sendMessage",
                               data, timeout=10)
    except Exception:
        pass


class LiveBot(z.Bot):
    def __init__(self, meta, capital, venue, max_daily_loss):
        self.sym = meta.symbol
        self.meta = meta
        self.venue = venue
        self.lev = meta.max_lev
        self.logpath = f"events_{self.sym}.csv"
        self._prev = None
        self.max_daily_loss = D(str(max_daily_loss))
        self.day = None
        self.day_pnl = D(0)
        self.blocked = False
        self.pending = False
        self.withdrawable = D(0)
        super().__init__(None)
        self.capital, self.savings, self.dead = D(str(capital)), D(0), False
        self.tick = meta.price_step
        self.qty_step = meta.size_step
        self.min_notional = meta.min_base * meta.last

    def load(self):
        pass

    def save(self):
        pass

    def liquidated(self, price):
        z.LEVERAGE = int(self.lev)
        return super().liquidated(price)

    def log(self, event, detail="", pnl="", fees=""):
        stamp = datetime.now(timezone.utc).strftime("%H:%M:%S")
        print(f"{stamp} {self.sym:<5} {event:<18} {detail}"
              + (f" | PnL {pnl}" if pnl else "") + f" | cash {self.capital:.4f}", flush=True)
        new = not os.path.exists(self.logpath)
        with open(self.logpath, "a", newline="") as f:
            w = csv.writer(f)
            if new:
                w.writerow(["time", "market", "event", "detail", "pnl", "fees", "capital"])
            w.writerow([stamp, self.sym, event, detail, pnl, fees, f"{self.capital:.6f}"])

    def book(self, reason):
        before = self.capital + self.savings
        super().book(reason)
        after = self.capital + self.savings
        gained = after - before
        today = datetime.now(timezone.utc).date()
        if self.day != today:
            self.day, self.day_pnl = today, D(0)
        self.day_pnl += gained
        if gained > 0:
            out = gained * z.SAVINGS_PCT / 100
            self.withdrawable += out
            tg(f"{self.sym} profit booked: +{gained:.4f} USDC\n"
               f"withdraw now: {out:.4f} USDC\n"
               f"total waiting to withdraw: {self.withdrawable:.4f} USDC")
        if self.day_pnl <= -self.max_daily_loss and not self.blocked:
            self.blocked = True
            self.log("DAILY CAP", f"stopped opening after {self.day_pnl:.4f} today")
            tg(f"{self.sym}: daily loss cap {self.max_daily_loss} hit ({self.day_pnl:.4f}). "
               f"No new trades today; open position still managed.")

    def set_line(self, line, side):
        """A new line kills any entry still resting at the old one — at zero cost."""
        super().set_line(line, side)
        if self.pending and self.venue is not None:
            asyncio.create_task(self._expire())

    async def _expire(self):
        self.pending = False
        try:
            print(f"  expired resting entry on {self.sym}: {await self.venue.cancel_all(self.sym)}",
                  flush=True)
        except Exception as e:
            print(f"  expire error {self.sym}: {type(e).__name__}: {e}", flush=True)

    async def seed(self):
        """Build the 1hr line from already-closed hours so trading starts immediately."""
        pair = SEED_PAIRS.get(self.sym)
        if not pair:
            self.log("SEED", "no hourly source — line warms up from live prices")
            return

        def fetch():
            import json
            url = (f"https://fapi.binance.com/fapi/v1/klines?symbol={pair}"
                   f"&interval=1h&limit=300")
            return json.load(urllib.request.urlopen(url, timeout=20))
        try:
            rows = await asyncio.to_thread(fetch)
        except Exception as e:
            self.log("SEED", f"failed: {type(e).__name__}")
            return
        now = ms_now()
        for c in rows:
            if int(c[6]) < now:
                self.r1h.feed(D(c[4]))
        if self.r1h.dir:
            self.set_line(self.r1h.open, self.r1h.dir)
        self.log("SEED", f"line {self.line} from {len(rows)} closed hours (same price, other feed)")

    async def new_hour(self):
        if self._prev is None:
            return
        for b in self.r1h.feed(self._prev):
            self.set_line(b[0], b[2])

    async def on_trade(self, price, ts):
        self._prev = price
        await super().on_trade(price, ts)

    def open(self, d, raw, slip, on_line):
        z.LEVERAGE = int(self.lev)
        if self.blocked:
            return
        super().open(d, raw, slip, on_line)
        if not self.pos:
            return
        side = "LONG" if d == 1 else "SHORT"
        qty, entry = self.pos["qty"], self.pos["entry"]
        self.pending = True
        asyncio.create_task(self._send(self.venue.order, self.sym, side, qty, entry, False))
        line = self.line or entry
        stop = line * (1 - STOP_BOXES * z.BOX_HOURLY) if d == 1 else line * (1 + STOP_BOXES * z.BOX_HOURLY)
        asyncio.create_task(self._send(self.venue.stop, self.sym, side, qty, stop))

    def close(self, raw, slip, reason):
        z.LEVERAGE = int(self.lev)
        p = self.pos
        super().close(raw, slip, reason)
        if p:
            asyncio.create_task(self._send(self.venue.order, self.sym,
                                           "LONG" if p["dir"] == 1 else "SHORT",
                                           p["qty"], raw, True))

    def flatten(self):
        if self.pos:
            p = self.pos
            asyncio.create_task(self._send(self.venue.order, self.sym,
                                           "LONG" if p["dir"] == 1 else "SHORT",
                                           p["qty"], self.last_price or p["entry"], True))
            self.pos = None

    async def _send(self, fn, *args):
        try:
            print(f"  venue: {await fn(*args)}", flush=True)
        except Exception as e:
            print(f"  venue error: {type(e).__name__}: {e}", flush=True)


async def feed(bot, venue):
    meta = bot.meta
    pending = {"px": None}

    def on_book(*args):
        msg = args[-1]
        if not isinstance(msg, dict):
            return
        try:
            pending["px"] = (D(msg["bids"][0]["price"]) + D(msg["asks"][0]["price"])) / 2
        except (KeyError, IndexError, TypeError):
            return

    while True:
        try:
            ws = WsClient(ws_url=venue.ws_url, order_book_ids=[meta.index], account_ids=[],
                          on_order_book_update=on_book)
            task = asyncio.create_task(ws.run_async())
            while True:
                await asyncio.sleep(0.2)
                px = pending["px"]
                if px is not None:
                    pending["px"] = None
                    await bot.on_trade(px.quantize(meta.price_step), ms_now())
                if task.done():
                    raise RuntimeError("stream closed")
        except asyncio.CancelledError:
            raise
        except Exception as e:
            print(f"  {meta.symbol} feed dropped ({type(e).__name__}); reconnecting", flush=True)
            await asyncio.sleep(3)


async def telegram(bots):
    if not (TG_TOKEN and TG_CHAT):
        return
    offset = None
    while True:
        try:
            q = {"timeout": 20}
            if offset:
                q["offset"] = offset
            url = f"https://api.telegram.org/bot{TG_TOKEN}/getUpdates?" + urllib.parse.urlencode(q)
            data = await asyncio.to_thread(lambda: urllib.request.urlopen(url, timeout=25).read())
            import json
            for upd in json.loads(data).get("result", []):
                offset = upd["update_id"] + 1
                msg = upd.get("message", {}).get("text", "").strip().lower()
                if msg in ("/status", "status"):
                    tg("\n".join(f"{b.sym}: {'flat' if not b.pos else 'in trade'} | "
                                 f"cash {b.capital:.2f} | withdraw {b.withdrawable:.2f}"
                                 for b in bots))
                elif msg in ("/flat", "flat"):
                    for b in bots:
                        b.flatten()
                    tg("all positions flattened")
                elif msg in ("/stop", "stop"):
                    tg("kill switch: flattening and exiting")
                    for b in bots:
                        b.flatten()
                    await asyncio.sleep(2)
                    os._exit(0)
        except Exception:
            await asyncio.sleep(5)


async def watchdog(bots):
    while True:
        await asyncio.sleep(5)
        if os.path.exists(STOP_FILE):
            print("STOP file found — flattening everything", flush=True)
            tg("kill switch (STOP file): flattening and exiting")
            for b in bots:
                b.flatten()
            await asyncio.sleep(2)
            os.remove(STOP_FILE)
            os._exit(0)


async def status(bots, every=60):
    while True:
        await asyncio.sleep(every)
        print(f"--- {datetime.now(timezone.utc):%H:%M:%S} ---", flush=True)
        for b in bots:
            pos = "flat" if not b.pos else (
                f"{'LONG' if b.pos['dir'] == 1 else 'SHORT'} {b.pos['qty']} @ {b.pos['entry']}"
                + (" HELD" if b.pos["carried"] else ""))
            print(f"  {b.sym:<5} {b.lev}x | {pos:<32} | line {b.line} | gate "
                  f"{'ON' if b.gate else 'off'} | cash {b.capital:.2f} "
                  f"| withdraw {b.withdrawable:.2f}", flush=True)


async def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--dry", action="store_true")
    ap.add_argument("--run", action="store_true")
    ap.add_argument("--market", action="store_true", help="cross the spread instead of resting")
    ap.add_argument("--mainnet", action="store_true")
    ap.add_argument("--capital", type=float, default=10000.0)
    ap.add_argument("--max-loss", type=float, default=None,
                    help="USD loss per market per day before it stops opening (default 25%%)")
    a = ap.parse_args()
    if not (a.dry or a.run):
        sys.exit("pick --dry or --run")
    load_env()

    venue = Venue(maker=not a.market, mainnet=a.mainnet)
    metas = await venue.load()
    per = D(str(a.capital)) / SPLIT
    cap = D(str(a.max_loss)) if a.max_loss else per * D("0.25")
    print(f"connected {venue.url} | chain {venue.chain} | "
          f"{'MAINNET' if a.mainnet else 'testnet'} | signer "
          f"{'yes' if venue.signer else 'NO (dry)'} | {'maker' if venue.maker else 'taker'}",
          flush=True)
    for m in metas.values():
        print(" ", m, flush=True)

    bots = [LiveBot(metas[s], per, venue if a.run else None, cap) for s in SYMBOLS if s in metas]
    for b in bots:
        b.log("START", f"{b.lev}x | {per:.2f} USDC | daily cap {cap:.2f} | line building")
    if a.run and venue.signer is None:
        print("!! --run needs LIGHTER_API_PRIVATE_KEY + LIGHTER_ACCOUNT_INDEX "
              "(put them in lighter.env)", flush=True)
        return
    for b in bots:
        await b.seed()

    await asyncio.gather(*[feed(b, venue) for b in bots], status(bots),
                         watchdog(bots), telegram(bots))


if __name__ == "__main__":
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        print("\nstopped")
