"""weltiq_ccxt — a CCXT-shaped shim over the weltiq paper API.

Why this exists
---------------
LLM-Auto-Trader (and every other bot that reaches the exchange through
``ccxt.async_support``) touches a very small surface: load_markets / market /
fetch_ohlcv / fetch_ticker / fetch_order_book / fetch_balance /
fetch_positions / create_order / cancel_order / fetch_open_orders.

So we implement exactly that surface against weltiq's REST API and register
the class on the ccxt module under the id ``weltiq``.  The bot then finds it
with ``getattr(ccxt, "weltiq")`` and **not one line of the bot changes**.

    $ export EXCHANGE_NAME=weltiq
    $ python -m scripts.paper_trade        # via run_with_weltiq.py

What the shim deliberately does NOT hide
----------------------------------------
* ``fetch_order_book`` returns **one level per side** — weltiq's public API
  exposes the BBO, not the 20-level book its own matcher walks.
* ``fetch_funding_rate`` / ``fetch_open_interest`` / ``set_leverage`` raise
  ``NotSupported``.  weltiq paper is spot; there is no funding and no leverage.
* ``watch_ticker`` **polls** ``/v1/quote``.  It is not a websocket.
* Instrument precision comes from the table below, not from the server —
  weltiq has no instrument-spec endpoint (see PRECISION).

Env
---
    WELTIQ_BASE              default http://localhost:8741
    WELTIQ_API_KEY           key with read-data + operate-sim
    WELTIQ_VENUE             default binance-spot
    WELTIQ_INVALIDATION_PCT  default 5  (entries need an invalidation clause)
    WELTIQ_FILL_TIMEOUT_S    default 20

License: same as the article's evidence bundle — copy it, it is 300 lines.
"""

from __future__ import annotations

import asyncio
import os
import time
import uuid
from typing import Any

import httpx

# ccxt's own exception types: the bot already catches these, so raising the
# real ones is what makes the swap invisible.
from ccxt.base.errors import (
    ExchangeError,
    InsufficientFunds,
    InvalidOrder,
    NotSupported,
    OrderNotFound,
)

TF_MINUTES = {"1m": 1, "3m": 3, "5m": 5, "15m": 15, "30m": 30,
              "1h": 60, "2h": 120, "4h": 240, "6h": 360, "12h": 720, "1d": 1440}

# weltiq enforces tick/lot/min_notional server-side but does not publish them.
# These are the values its rejections quote (weltiq/simbroker/profile.py).
# When a qty misses the lot, we fall back to quote-sizing and let the server
# round — so a wrong row here costs a retry, not a wrong order.
PRECISION = {
    "binance-spot": {"BTCUSDT": (0.01, 0.00001, 5.0), "ETHUSDT": (0.01, 0.0001, 5.0),
                     "_default": (0.01, 0.00001, 10.0)},
    "binance-usdm": {"BTCUSDT": (0.1, 0.001, 50.0), "ETHUSDT": (0.01, 0.001, 20.0),
                     "_default": (0.1, 0.001, 100.0)},
}

# Rejections the bot should treat as "no funds" rather than a hard error.
_FUNDS_CODES = {"insufficient_cash", "insufficient_funds", "insufficient_position"}


def to_weltiq(symbol: str) -> str:
    """BTC/USDT  ->  BTCUSDT ;  BTC/USDT:USDT -> BTCUSDT."""
    return symbol.split(":")[0].replace("/", "").upper()


class weltiq:                                     # noqa: N801 — ccxt ids are lowercase
    """Minimal async CCXT-compatible exchange backed by the weltiq paper API."""

    id = "weltiq"
    name = "weltiq paper"
    has = {"fetchOHLCV": True, "fetchTicker": True, "fetchOrderBook": True,
           "fetchBalance": True, "fetchPositions": True, "createOrder": True,
           "cancelOrder": True, "fetchOpenOrders": True, "watchTicker": True,
           "setLeverage": False, "fetchFundingRate": False, "fetchOpenInterest": False}

    def __init__(self, config: dict[str, Any] | None = None):
        config = config or {}
        self.options = config.get("options", {})
        self.base = os.environ.get("WELTIQ_BASE", "http://localhost:8741").rstrip("/")
        self.venue = os.environ.get("WELTIQ_VENUE", "binance-spot")
        self.quote_ccy = os.environ.get("WELTIQ_QUOTE_CCY", "USDT")
        self.inval_pct = float(os.environ.get("WELTIQ_INVALIDATION_PCT", "5"))
        self.fill_timeout = float(os.environ.get("WELTIQ_FILL_TIMEOUT_S", "20"))
        # Sub-ledger boundary: the slice of the paper account this bot may use,
        # and the timestamp from which its own orders count (see _own_book).
        self.equity0 = float(os.environ.get("WELTIQ_EQUITY_USD", "10000"))
        self.since = os.environ.get("WELTIQ_SINCE", "")
        key = config.get("apiKey") or os.environ.get("WELTIQ_API_KEY", "")
        self.http = httpx.AsyncClient(
            base_url=self.base, timeout=20.0,
            headers={"Authorization": f"Bearer {key}"} if key else {})
        self.markets: dict[str, dict] = {}
        self.symbols: list[str] = []
        self._by_id: dict[str, str] = {}   # weltiq symbol -> unified name we report

    # ---------------------------------------------------------------- http

    async def _get(self, path: str, **params) -> dict:
        r = await self.http.get(path, params={k: v for k, v in params.items()
                                              if v is not None})
        if r.status_code >= 400:
            raise ExchangeError(f"weltiq GET {path} {r.status_code}: {r.text[:300]}")
        return r.json()

    async def _post(self, path: str, body: dict) -> tuple[int, dict]:
        r = await self.http.post(path, json=body)
        try:
            return r.status_code, r.json()
        except Exception:
            return r.status_code, {"detail": r.text[:300]}

    # ------------------------------------------------------------- markets

    async def load_markets(self, reload: bool = False, params=None) -> dict:
        """Build the markets dict from /v1/markets + the PRECISION table.

        weltiq lists venues, not instruments, so the tradable set comes from
        WELTIQ_SYMBOLS (unified notation, comma separated).
        """
        if self.markets and not reload:
            return self.markets
        await self._get("/v1/markets")            # liveness check; raises if the key is bad
        wanted = os.environ.get("WELTIQ_SYMBOLS", "BTC/USDT:USDT,BTC/USDT")
        table = PRECISION.get(self.venue, PRECISION["binance-spot"])
        # List both notations for the same instrument (BTC/USDT:USDT and
        # BTC/USDT). The bot's candidate list is perpetual-shaped and its
        # membership test is exact, so a venue that only lists one of the two
        # would come back "no valid symbols". The FIRST one wins as the name
        # we report back in positions.
        for sym in [s.strip() for s in wanted.split(",") if s.strip()]:
            wq = to_weltiq(sym)
            swap = ":" in sym
            tick, lot, min_notional = table.get(wq, table["_default"])
            self.markets[sym] = {
                "id": wq, "symbol": sym, "base": sym.split("/")[0],
                "quote": self.quote_ccy, "settle": self.quote_ccy if swap else None,
                "type": "swap" if swap else "spot", "spot": not swap, "swap": swap,
                "contract": swap, "contractSize": 1, "active": True,
                "precision": {"amount": lot, "price": tick},
                "limits": {"amount": {"min": lot}, "cost": {"min": min_notional}},
                "taker": 0.001, "maker": 0.001, "info": {"venue": self.venue},
            }
            self._by_id.setdefault(wq, sym)
        self.symbols = list(self.markets)
        return self.markets

    def market(self, symbol: str) -> dict:
        if symbol not in self.markets:
            raise ExchangeError(f"weltiq: unknown market {symbol}")
        return self.markets[symbol]

    # ---------------------------------------------------------------- data

    async def fetch_ohlcv(self, symbol, timeframe="1m", since=None, limit=100,
                          params=None) -> list[list]:
        minutes = TF_MINUTES.get(timeframe)
        if minutes is None:
            raise NotSupported(f"weltiq: timeframe {timeframe}")
        hours = max(1, int((limit + 2) * minutes / 60) + 1)
        d = await self._get("/v1/bars", venue=self.venue, symbol=to_weltiq(symbol),
                            tf=timeframe, hours=hours)
        out = []
        for b in d.get("bars", [])[-limit:]:
            out.append([_ms(b["t"]), float(b["o"]), float(b["h"]),
                        float(b["l"]), float(b["c"]), float(b["v"])])
        return out

    async def fetch_ticker(self, symbol, params=None) -> dict:
        q = await self._get("/v1/quote", venue=self.venue, symbol=to_weltiq(symbol))
        s = q.get("stats_24h") or {}
        bbo = q.get("bbo") or {}
        last = float(q["last"]) if q.get("last") else None
        return {
            "symbol": symbol, "timestamp": _ms(q.get("server_time")),
            "datetime": q.get("server_time"), "last": last, "close": last,
            "bid": _f(bbo.get("bid")), "ask": _f(bbo.get("ask")),
            "bidVolume": _f(bbo.get("bid_qty")), "askVolume": _f(bbo.get("ask_qty")),
            "high": _f(s.get("high")), "low": _f(s.get("low")),
            "open": _f(s.get("open")), "baseVolume": _f(s.get("volume")),
            "quoteVolume": (_f(s.get("volume")) or 0) * (last or 0),
            "percentage": _f(q.get("change_24h_pct")), "vwap": None,
            "info": q,
        }

    async def fetch_order_book(self, symbol, limit=None, params=None) -> dict:
        """ONE level per side — weltiq publishes the BBO, not the depth ladder."""
        q = await self._get("/v1/quote", venue=self.venue, symbol=to_weltiq(symbol))
        bbo = q.get("bbo") or {}
        if not bbo.get("bid") or not bbo.get("ask"):
            return {"symbol": symbol, "bids": [], "asks": [], "timestamp": None,
                    "datetime": None, "nonce": None, "info": q}
        return {
            "symbol": symbol,
            "bids": [[float(bbo["bid"]), float(bbo.get("bid_qty") or 0)]],
            "asks": [[float(bbo["ask"]), float(bbo.get("ask_qty") or 0)]],
            "timestamp": _ms(bbo.get("event_time")), "datetime": bbo.get("event_time"),
            "nonce": None, "info": q,
        }

    async def fetch_funding_rate(self, symbol, params=None):
        raise NotSupported("weltiq paper has no funding leg")

    async def fetch_open_interest(self, symbol, params=None):
        raise NotSupported("weltiq does not publish open interest")

    async def set_leverage(self, leverage, symbol=None, params=None):
        raise NotSupported("weltiq paper is unlevered")

    # ------------------------------------------------------------- account
    #
    # A weltiq user has ONE paper account per venue.  Resident slots, webhook
    # orders and this bot all book into it, and /v1/account/summary aggregates
    # them into a single position row per symbol — an external agent cannot
    # tell its own inventory from a slot's.  So the shim keeps its own sub-
    # ledger, rebuilt from the server's own order log (survives a restart):
    # every one-off order this API key placed at or after WELTIQ_SINCE.

    async def _own_book(self) -> tuple[dict[str, dict], float, float]:
        """(positions by weltiq symbol, cash spent, fees paid) — this bot only."""
        d = await self._get("/v1/orders/oneoff")
        pos: dict[str, dict] = {}
        cash = fees = 0.0
        for o in sorted(d["orders"], key=lambda r: r["created_at"]):
            if o.get("source") != "oneoff" or o["venue"] != self.venue:
                continue
            if self.since and _ms(o["created_at"]) < _ms(self.since):
                continue   # epoch compare — the API returns +09:00, not Z
            for f in ((o.get("result") or {}).get("fills") or []):
                q, px, fee = float(f["qty"]), float(f["px"]), float(f["fee"])
                sign = 1.0 if o["side"] == "buy" else -1.0
                p = pos.setdefault(o["symbol"], {"qty": 0.0, "cost": 0.0})
                p["qty"] += sign * q
                p["cost"] += sign * q * px
                cash -= sign * q * px
                cash -= fee
                fees += fee
        return ({k: v for k, v in pos.items() if abs(v["qty"]) > 1e-12}, cash, fees)

    async def fetch_balance(self, params=None) -> dict:
        """Equity = the slice assigned to this bot (WELTIQ_EQUITY_USD) plus its
        own realised cash flow and its own open position, marked to market."""
        pos, cash, _fees = await self._own_book()
        value = 0.0
        for wq, p in pos.items():
            q = await self._get("/v1/quote", venue=self.venue, symbol=wq)
            value += p["qty"] * float(q["last"])
        free = self.equity0 + cash
        total = free + value
        cur = self.quote_ccy
        return {cur: {"free": free, "used": value, "total": total},
                "free": {cur: free}, "used": {cur: value},
                "total": {cur: total},
                "info": {"assigned": self.equity0, "own_cash_flow": cash,
                         "own_position_value": value}}

    async def fetch_positions(self, symbols=None, params=None) -> list[dict]:
        pos, _c, _f2 = await self._own_book()
        out = []
        for wq, p in pos.items():
            q = await self._get("/v1/quote", venue=self.venue, symbol=wq)
            mark = float(q["last"])
            entry = p["cost"] / p["qty"] if p["qty"] else None
            out.append({
                "symbol": self._by_id.get(wq, wq),
                "side": "long" if p["qty"] >= 0 else "short",
                "contracts": abs(p["qty"]), "contractSize": 1,
                "notional": abs(p["qty"] * mark),
                "entryPrice": entry, "markPrice": mark,
                "unrealizedPnl": p["qty"] * mark - p["cost"], "leverage": 1,
                "marginRatio": None, "liquidationPrice": None, "info": p,
            })
        return out

    # -------------------------------------------------------------- orders

    async def create_order(self, symbol, type, side, amount, price=None,
                           params=None) -> dict:
        params = params or {}
        wq = to_weltiq(symbol)
        closing = bool(params.get("reduceOnly"))
        if side == "sell":
            # HARD GUARD. The paper account is shared with resident slots, so a
            # sell larger than this bot's own inventory would liquidate someone
            # else's position. weltiq does not enforce this — the shim must.
            own, _c, _f3 = await self._own_book()
            have = own.get(wq, {}).get("qty", 0.0)
            if float(amount) > have + 1e-12:
                raise InsufficientFunds(
                    f"weltiq shim: sell {amount} {wq} > own inventory {have} "
                    f"(the rest of the account belongs to resident slots)")
        coid = params.get("clientOrderId") or f"lat-{uuid.uuid4().hex[:12]}"
        body = {
            "venue": self.venue, "symbol": wq, "side": side, "type": type,
            "qty": _num(amount), "target": "paper", "client_order_id": coid,
            # weltiq refuses an order with no reason attached; the bot does not
            # pass one through create_order, so the shim states the source.
            "thesis": {"why": params.get("why")
                       or f"LLM-Auto-Trader {'exit' if closing else 'entry'} via CCXT shim"},
        }
        if price is not None:
            body["price"] = _num(price)
        if not closing:
            # weltiq requires an invalidation clause on every ENTRY. The bot's
            # stop-loss lives in its own risk layer and never reaches CCXT, so
            # the shim writes a percentage floor/ceiling off the current price.
            ref = (await self.fetch_ticker(symbol))["last"] or 0.0
            k = self.inval_pct / 100.0
            body["invalidation"] = ({"px_below": _num(ref * (1 - k))} if side == "buy"
                                    else {"px_above": _num(ref * (1 + k))})

        status, resp = await self._post("/v1/orders/oneoff", body)
        if status >= 400:
            detail = resp.get("detail") if isinstance(resp.get("detail"), dict) else {}
            code = detail.get("code", "")
            if code == "lot_size":
                # let the server round: resend as a notional instead of a qty
                ref = (await self.fetch_ticker(symbol))["last"] or 0.0
                body.pop("qty")
                body["size"] = {"unit": "quote", "value": _num(amount * ref)}
                body["client_order_id"] = coid + "-q"
                status, resp = await self._post("/v1/orders/oneoff", body)
            if status >= 400:
                raise _as_ccxt(resp)
        return await self._await_fill(resp["queue_id"], symbol, side, amount, type)

    async def _await_fill(self, queue_id, symbol, side, amount, type) -> dict:
        """Poll until the paper matcher settles. Rejections become ccxt errors."""
        deadline = time.time() + self.fill_timeout
        row = None
        while time.time() < deadline:
            d = await self._get("/v1/orders/oneoff")
            row = next((o for o in d["orders"] if o["queue_id"] == queue_id), None)
            if row and row["status"] in ("filled", "rejected", "canceled", "expired"):
                break
            await asyncio.sleep(0.25)
        if row is None:
            raise OrderNotFound(f"weltiq: order {queue_id} vanished")
        if row["status"] == "rejected":
            raise _as_ccxt({"detail": (row.get("result") or {})})
        fills = ((row.get("result") or {}).get("fills")) or []
        filled = sum(float(f["qty"]) for f in fills)
        fee = sum(float(f["fee"]) for f in fills)
        avg = (sum(float(f["px"]) * float(f["qty"]) for f in fills) / filled
               if filled else None)
        return {
            "id": queue_id, "clientOrderId": queue_id, "symbol": symbol, "side": side,
            "type": type, "status": {"filled": "closed", "queued": "open"}.get(
                row["status"], row["status"]),
            "amount": float(amount), "filled": filled,
            "remaining": max(float(amount) - filled, 0.0),
            "average": avg, "price": avg, "cost": (avg or 0) * filled,
            "fee": {"cost": fee, "currency": "USDT"},
            "timestamp": _ms(row.get("created_at")), "datetime": row.get("created_at"),
            "trades": fills, "info": row,
        }

    async def cancel_order(self, id, symbol=None, params=None) -> dict:
        status, resp = await self._post(f"/v1/orders/oneoff/{id}/cancel", {})
        if status >= 400:
            raise _as_ccxt(resp)
        return {"id": id, "status": resp.get("status"), "info": resp}

    async def fetch_open_orders(self, symbol=None, since=None, limit=None,
                                params=None) -> list[dict]:
        d = await self._get("/v1/orders/oneoff")
        wq = to_weltiq(symbol) if symbol else None
        return [{
            "id": o["queue_id"], "symbol": self._by_id.get(o["symbol"], o["symbol"]),
            "side": o["side"], "type": o["order_type"], "status": "open",
            "amount": float(o["qty"]), "filled": 0.0,
            "price": _f(o.get("limit_px")),
            "timestamp": _ms(o.get("created_at")), "info": o,
        } for o in d["orders"]
            if o["status"] in ("queued", "claimed")
            and (wq is None or o["symbol"] == wq)]

    # ------------------------------------------------------- pro (polling)

    async def watch_ticker(self, symbol, params=None) -> dict:
        """ccxt.pro shape, implemented as a 2s poll. Not a websocket."""
        await asyncio.sleep(float(os.environ.get("WELTIQ_POLL_S", "2")))
        return await self.fetch_ticker(symbol)

    async def close(self) -> None:
        await self.http.aclose()


# ------------------------------------------------------------------ helpers

def _f(v):
    return None if v is None else float(v)


def _num(v) -> str:
    return f"{float(v):.10f}".rstrip("0").rstrip(".") or "0"


def _ms(iso: str | None):
    if not iso:
        return None
    from datetime import datetime
    return int(datetime.fromisoformat(iso.replace("Z", "+00:00")).timestamp() * 1000)


def _as_ccxt(resp: dict) -> Exception:
    d = resp.get("detail")
    if isinstance(d, str):
        return ExchangeError(f"weltiq: {d}")
    d = d or {}
    code = d.get("code") or ""
    msg = f"weltiq {code}: {d.get('detail') or d}"
    if code in _FUNDS_CODES:
        return InsufficientFunds(msg)
    if code in ("lot_size", "min_notional", "invalid_size", "qty_required",
                "tick_size", "invalidation_required"):
        return InvalidOrder(msg)
    return ExchangeError(msg)


def install() -> None:
    """Register this class on ccxt so getattr(ccxt, 'weltiq') finds it."""
    import ccxt
    import ccxt.async_support as ccxt_async
    for mod in (ccxt, ccxt_async):
        setattr(mod, "weltiq", weltiq)
        if "weltiq" not in getattr(mod, "exchanges", []):
            try:
                mod.exchanges.append("weltiq")
            except Exception:
                pass
    try:
        import ccxt.pro as ccxtpro
        setattr(ccxtpro, "weltiq", weltiq)
    except Exception:
        pass


if __name__ == "__main__":
    # Self-check: the whole surface the bot touches, against a live server.
    async def demo() -> None:
        install()
        ex = weltiq({})
        m = await ex.load_markets()
        sym = next(iter(m))
        assert m[sym]["precision"]["amount"] > 0, m[sym]
        bars = await ex.fetch_ohlcv(sym, "1m", limit=5)
        assert len(bars) == 5 and bars[0][0] < bars[-1][0], bars
        t = await ex.fetch_ticker(sym)
        assert t["last"] and t["bid"] <= t["ask"], t
        ob = await ex.fetch_order_book(sym)
        assert len(ob["bids"]) == 1, "BBO only — by design"
        bal = await ex.fetch_balance()
        assert bal["USDT"]["total"] >= 0, bal
        pos = await ex.fetch_positions()
        await ex.fetch_open_orders()
        try:                                  # the guard must fire, always
            await ex.create_order(sym, "market", "sell", 999)
            raise AssertionError("sell guard did not fire")
        except InsufficientFunds:
            pass
        print(f"OK  {sym}  last={t['last']}  bars={len(bars)}  "
              f"equity={bal['USDT']['total']:.2f}  own_positions={len(pos)}")
        await ex.close()

    asyncio.run(demo())
