from __future__ import annotations

import json
import random
import threading
import time
from dataclasses import dataclass
from typing import Any, Callable

from websocket import WebSocketApp

from .events import FillEvent, UserEvent


def _sleep(seconds: float) -> None:
    time.sleep(max(0.0, seconds))

from .hyperliquid_constants import DEFAULT_WS_URL


@dataclass(frozen=True)
class WsConfig:
    ws_url: str = DEFAULT_WS_URL
    ping_interval: int = 30
    ping_timeout: int = 10
    debug_ping_pong: bool = False
    # Hyperliquid may require application-level keepalive: send {"method":"ping"}, receive {"channel":"pong"}.
    # websocket-client ping_interval is protocol-level ping/frame, not the same as app-level JSON ping.
    use_app_ping: bool = False
    app_ping_interval_seconds: int = 30


class HyperliquidWsSubscriber:
    """
    Minimal WS subscriber for OpenClaw skills.

    - Supports subscribe: userFills + userEvents
    - Auto-reconnect with exponential backoff + jitter
    - Filters snapshot events when desired
    """

    def __init__(
        self,
        cfg: WsConfig,
        *,
        on_fill: Callable[[FillEvent], None] | None = None,
        on_user_event: Callable[[UserEvent], None] | None = None,
        on_non_funding_ledger_update: Callable[[dict[str, Any]], None] | None = None,
        on_all_mids: Callable[[str, dict[str, float]], None] | None = None,
        on_status: Callable[[str], None] | None = None,
        ignore_snapshots: bool = True,
        ignore_old_fills: bool = True,
    ) -> None:
        self.cfg = cfg
        self.on_fill = on_fill
        self.on_user_event = on_user_event
        self.on_non_funding_ledger_update = on_non_funding_ledger_update
        self.on_all_mids = on_all_mids
        self.on_status = on_status
        self.ignore_snapshots = ignore_snapshots
        self.ignore_old_fills = ignore_old_fills

        self._lock = threading.Lock()
        # subscription_key -> subscription_payload
        # Use a dict to avoid duplicate subscriptions on repeated add()
        self._subscriptions: dict[str, dict[str, Any]] = {}
        self._stop = threading.Event()
        self._ws: WebSocketApp | None = None
        # user -> ms timestamp: only accept fills with fill.time >= since_ms
        self._user_fills_since_ms: dict[str, int] = {}
        # per-user: ignore first userFills message batch after (re)subscribe
        self._ignore_next_fills_batch: set[str] = set()

    @staticmethod
    def _sub_key(sub: dict[str, Any]) -> str:
        # stable key for subscription
        return json.dumps(sub, sort_keys=True, separators=(",", ":"))

    def add_user_fills(self, user: str, *, aggregate_by_time: bool = True) -> None:
        sub: dict[str, Any] = {"type": "userFills", "user": user}
        if aggregate_by_time:
            sub["aggregateByTime"] = True
        self._add_subscription(sub)
        if self.ignore_old_fills:
            self._user_fills_since_ms[str(user)] = int(time.time() * 1000)

    def add_user_events(self, user: str) -> None:
        self._add_subscription({"type": "userEvents", "user": user})

    def add_user_non_funding_ledger_updates(self, user: str) -> None:
        # Non-funding ledger updates can be used to derive deposits/withdrawals
        # subscription type name based on Hyperliquid WS channel naming.
        self._add_subscription({"type": "userNonFundingLedgerUpdates", "user": user})

    def add_all_mids(self, dex: str = "") -> None:
        # Hyperliquid app-level subscription for all mid prices
        # Expected by docs: { "type":"allMids", "dex": "<dex>" }
        sub: dict[str, Any] = {"type": "allMids", "dex": str(dex or "")}
        self._add_subscription(sub)

    def _add_subscription(self, sub: dict[str, Any]) -> None:
        key = self._sub_key(sub)
        with self._lock:
            self._subscriptions[key] = sub
        # if already connected, subscribe immediately
        ws = self._ws
        if ws is not None:
            try:
                ws.send(json.dumps({"method": "subscribe", "subscription": sub}))
            except Exception:
                pass

    def _remove_subscription_matching(self, predicate: Callable[[dict[str, Any]], bool]) -> None:
        with self._lock:
            to_remove_keys = [k for k, sub in self._subscriptions.items() if predicate(sub)]
            removed_subs = [self._subscriptions.pop(k) for k in to_remove_keys]

        # if already connected, best-effort unsubscribe
        ws = self._ws
        if ws is not None and removed_subs:
            for sub in removed_subs:
                try:
                    ws.send(json.dumps({"method": "unsubscribe", "subscription": sub}))
                except Exception:
                    pass

    def remove_user_fills(self, user: str, *, aggregate_by_time: bool | None = None) -> None:
        def pred(sub: dict[str, Any]) -> bool:
            if str(sub.get("user", "")) != str(user):
                return False
            if str(sub.get("type", "")) != "userFills":
                return False
            if aggregate_by_time is None:
                return True
            has_agg = "aggregateByTime" in sub
            # When aggregate_by_time=True, we subscribe with aggregateByTime=True
            return (aggregate_by_time is True and has_agg) or (aggregate_by_time is False and not has_agg)

        self._remove_subscription_matching(pred)

    def remove_user_events(self, user: str) -> None:
        self._remove_subscription_matching(
            lambda sub: str(sub.get("user", "")) == str(user) and str(sub.get("type", "")) == "userEvents"
        )

    def remove_user_non_funding_ledger_updates(self, user: str) -> None:
        self._remove_subscription_matching(
            lambda sub: str(sub.get("user", "")) == str(user) and str(sub.get("type", "")) == "userNonFundingLedgerUpdates"
        )

    def stop(self) -> None:
        self._stop.set()
        ws = self._ws
        if ws is not None:
            try:
                ws.close()
            except Exception:
                pass

    def run_forever(self) -> None:
        backoff = 1.0
        while not self._stop.is_set():
            try:
                self._run_once()
                backoff = 1.0
            except Exception as e:
                self._emit_status(f"ws error: {e}")
            if self._stop.is_set():
                break
            # exponential backoff + jitter
            jitter = random.random() * 0.25
            _sleep(min(20.0, backoff) + jitter)
            backoff = min(20.0, backoff * 2.0)

    def _emit_status(self, msg: str) -> None:
        if self.on_status:
            try:
                # print(msg, flush=True)
                pass
                # self.on_status(msg)
            except Exception:
                pass

    def _run_once(self) -> None:
        self._emit_status(f"ws connecting {self.cfg.ws_url}")

        app_ping_stop = threading.Event()

        def on_open(ws: WebSocketApp) -> None:
            self._emit_status("ws connected")
            with self._lock:
                subs = list(self._subscriptions.values())
            if self.ignore_old_fills:
                now_ms = int(time.time() * 1000)
                for sub in subs:
                    if str(sub.get("type", "")) == "userFills":
                        u = str(sub.get("user", ""))
                        if u:
                            self._user_fills_since_ms[u] = now_ms
                            # ignore the first pushed batch for this userFills after reconnect
                            self._ignore_next_fills_batch.add(u)
            for sub in subs:
                ws.send(json.dumps({"method": "subscribe", "subscription": sub}))

            def _app_ping_loop() -> None:
                while not self._stop.is_set() and not app_ping_stop.is_set():
                    try:
                        if self.cfg.use_app_ping:
                            ws.send(json.dumps({"method": "ping"}))
                            if self.cfg.debug_ping_pong:
                                print(f"[ws-app] ping", flush=True)
                    except Exception:
                        pass
                    _sleep(max(1.0, float(self.cfg.app_ping_interval_seconds)))

            if self.cfg.use_app_ping:
                t = threading.Thread(target=_app_ping_loop, daemon=True)
                t.start()

        def on_message(ws: WebSocketApp, message: str) -> None:
            try:
                msg = json.loads(message)
            except Exception:
                return
            channel = msg.get("channel")
            data = msg.get("data")
            if channel == "subscriptionResponse":
                return
            if channel == "allMids":
                # expected shapes (best-effort):
                # - {"channel":"allMids","data":{"dex":"", "mids":{"BTC":"123.4", ...}}}
                # - {"channel":"allMids","data":{"mids":{...}}}
                # - {"channel":"allMids","data":{...mids...}}
                if not self.on_all_mids:
                    return
                dex = ""
                mids_raw: Any = None
                if isinstance(data, dict):
                    dex = str(data.get("dex", "") or "")
                    if isinstance(data.get("mids"), dict):
                        mids_raw = data.get("mids")
                    else:
                        # sometimes data itself is a mids dict
                        mids_raw = data
                if not isinstance(mids_raw, dict):
                    return
                mids: dict[str, float] = {}
                for k, v in mids_raw.items():
                    try:
                        mids[str(k)] = float(v)
                    except Exception:
                        continue
                if mids:
                    self.on_all_mids(dex, mids)
                return
            if channel == "userFills" and isinstance(data, dict):
                if self.ignore_snapshots and data.get("isSnapshot") is True:
                    return
                user = str(data.get("user", ""))
                if self.ignore_old_fills and user in self._ignore_next_fills_batch:
                    # sockets may immediately push a few recent historical fills right after subscribe.
                    # drop this first batch entirely.
                    self._ignore_next_fills_batch.discard(user)
                    return
                since_ms = self._user_fills_since_ms.get(user) if self.ignore_old_fills else None
                fills = data.get("fills") if isinstance(data.get("fills"), list) else []
                for f in fills:
                    if not isinstance(f, dict):
                        continue
                    if since_ms is not None:
                        try:
                            t_ms = int(f.get("time"))
                        except Exception:
                            t_ms = None
                        if t_ms is None or t_ms < since_ms:
                            continue
                    ev = FillEvent.from_ws_fill(user, f)
                    if self.on_fill:
                        self.on_fill(ev)
                return
            if channel == "userEvents":
                # userEvents can be union; preserve raw
                # sometimes user is nested; keep best-effort
                user = ""
                if isinstance(data, dict) and isinstance(data.get("user"), str):
                    user = data["user"]
                ev = UserEvent.from_ws(user=user, data=data)
                if self.on_user_event:
                    self.on_user_event(ev)
                return

            if channel == "userNonFundingLedgerUpdates":
                # data is usually dict; forward raw for best-effort parsing by caller
                if self.on_non_funding_ledger_update and isinstance(data, dict):
                    self.on_non_funding_ledger_update(data)
                return

            if channel == "pong":
                # Application-layer heartbeat response
                if self.cfg.debug_ping_pong:
                    # print("[ws-app] pong", flush=True)
                    pass
                return

        def on_ping(ws: WebSocketApp, data: Any) -> None:
            # websocket-client ping/pong frames (transport-level)
            if self.cfg.debug_ping_pong:
                pass

        def on_pong(ws: WebSocketApp, data: Any) -> None:
            # websocket-client ping/pong frames (transport-level)
            if self.cfg.debug_ping_pong:
                pass

        def on_close(ws: WebSocketApp, status_code: Any, close_msg: Any) -> None:
            self._emit_status(f"ws closed code={status_code} msg={close_msg}")
            if self.cfg.use_app_ping:
                app_ping_stop.set()

        def on_error(ws: WebSocketApp, error: Any) -> None:
            self._emit_status(f"ws error: {error}")

        ws = WebSocketApp(
            self.cfg.ws_url,
            on_open=on_open,
            on_message=on_message,
            on_ping=on_ping,
            on_pong=on_pong,
            on_close=on_close,
            on_error=on_error,
        )
        self._ws = ws
        try:
            ws.run_forever(ping_interval=self.cfg.ping_interval, ping_timeout=self.cfg.ping_timeout)
        finally:
            self._ws = None

