from __future__ import annotations

import argparse
import json
import os
import signal
import threading
import time
from hl1m_cli.copytrade._shared.config import load_json
from hl1m_cli.copytrade._shared.events import FillEvent, UserEvent
from typing import Any
from hl1m_cli.copytrade._shared.ws_subscriber import HyperliquidWsSubscriber, WsConfig
from hl1m_cli.copytrade._shared.hyperliquid_constants import DEFAULT_HTTP_BASE_URL
from hl1m_cli.copytrade._shared.hyperliquid_constants import DEFAULT_WS_URL
from hl1m_cli.copytrade._shared.notifiers import send_message
from hl1m_cli.copytrade._shared.sdk_info_client import HyperliquidInfoClient
from hl1m_cli.copytrade._shared.message_format import (
    format_common_status,
    format_monitor_close_alert,
    format_monitor_ledger_message,
    format_monitor_open_alert,
    format_monitor_user_event_message,
    normalize_language,
)


class DedupeCache:
    def __init__(self, ttl_seconds: int) -> None:
        self.ttl_seconds = int(ttl_seconds)
        self._seen: dict[str, float] = {}

    def add_if_new(self, key: str) -> bool:
        now = time.time()
        self._gc(now)
        if key in self._seen:
            return False
        self._seen[key] = now
        return True

    def _gc(self, now: float) -> None:
        if self.ttl_seconds <= 0:
            return
        cutoff = now - self.ttl_seconds
        stale = [k for k, t in self._seen.items() if t < cutoff]
        for k in stale:
            self._seen.pop(k, None)


def _fill_key(ev: FillEvent) -> str:
    if ev.tid is not None:
        return f"fill:tid:{ev.tid}"
    return f"fill:{ev.user}:{ev.coin}:{ev.side}:{ev.time_ms}:{ev.px}:{ev.sz}"


def _f(v: Any) -> float:
    try:
        return float(v)
    except Exception:
        return 0.0


def _extract_usdc_value(delta: dict[str, Any]) -> float:
    """
    Best-effort:
    - Only count USDC transfers for send/spotTransfer (token=USDC)
    - Prefer usdcValue/usdc fields when present
    """
    token = str(delta.get("token", "") or "").upper()
    t = str(delta.get("type") or delta.get("deltaType") or "").lower()

    if t in ("send", "spottransfer"):
        if token and token != "USDC":
            return 0.0

    if "usdcValue" in delta:
        return _f(delta.get("usdcValue"))
    if "usdc" in delta:
        return _f(delta.get("usdc"))
    if "amount" in delta:
        return _f(delta.get("amount"))
    return 0.0


def _get_state_dir() -> str:
    base = os.environ.get("OPENCLAW_STATE_DIR") or os.path.join(os.path.expanduser("~"), ".openclaw")
    d = os.path.join(base, "1m-trade-copy-trading", "state")
    os.makedirs(d, exist_ok=True)
    return d


def _safe_filename(s: str) -> str:
    return "".join(ch if (ch.isalnum() or ch in ("-", "_", ".")) else "_" for ch in s)


def _monitor_pid_path(target: str) -> str:
    return os.path.join(_get_state_dir(), f"monitor_{_safe_filename(target)}.pid")


def _monitor_stop_req_path(target: str) -> str:
    return os.path.join(_get_state_dir(), f"monitor_{_safe_filename(target)}.stop.json")


def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(description="1m-trade Hyperliquid monitor (WS userFills + userEvents)")
    p.add_argument("--user", action="append", default=[], help="address to monitor (repeatable)")
    p.add_argument("--stop-user", action="append", default=[], help="addresses to stop monitoring (repeatable)")
    p.add_argument("--config", help="json config path (see scripts/examples/monitor.json)")
    p.add_argument("--target", default=None, help="openclaw message target (required)")
    p.add_argument("--min-notional-usd", type=float, default=0.0, help="drop events below this notional")
    p.add_argument("--dedupe-window-seconds", type=int, default=30, help="dedupe TTL seconds")
    p.add_argument("--ws-url", default=DEFAULT_WS_URL, help="websocket url")
    p.add_argument("--language", default="zh", help="message language: zh/en (default: zh)")
    return p


def run(args: argparse.Namespace) -> int:

    if args.config:
        raw = load_json(args.config)
        users = [str(u).strip() for u in (raw.get("users") or []) if str(u).strip()]
        stop_users = [str(u).strip() for u in (raw.get("stopUsers") or []) if str(u).strip()]
        target = str(raw.get("target") or args.target or "").strip() or None
        agg = bool(raw.get("aggregateByTime", True))
        min_notional = float(raw.get("minNotionalUsd", 0.0) or 0.0)
        dedupe_ttl = int(raw.get("dedupeWindowSeconds", 30) or 30)
        ws_url = str(((raw.get("ws") or {}) if isinstance(raw.get("ws"), dict) else {}).get("url", DEFAULT_WS_URL))
        language = normalize_language(raw.get("language") or args.language)
    else:
        users = args.user
        stop_users = args.stop_user
        target = (str(args.target).strip() if args.target is not None else None)
        agg = True
        min_notional = float(args.min_notional_usd or 0.0)
        dedupe_ttl = int(args.dedupe_window_seconds)
        ws_url = str(args.ws_url)
        language = normalize_language(args.language)

    if not target:
        raise SystemExit("monitor requires --target (openclaw message target)")

    # stop action: if openclaw only provides stop users, write stop request and exit
    if (not users) and stop_users:
        req_path = _monitor_stop_req_path(target)
        existing: dict[str, Any] = {}
        try:
            if os.path.exists(req_path):
                with open(req_path, "r", encoding="utf-8") as f:
                    existing = json.load(f) if f.readable() else {}
        except Exception:
            existing = {}
        prev = existing.get("stopUsers") or []
        prev_set = set(str(x).strip() for x in prev if str(x).strip())
        for u in stop_users:
            prev_set.add(u)
        payload = {"stopUsers": sorted(prev_set)}
        with open(req_path, "w", encoding="utf-8") as f:
            json.dump(payload, f, ensure_ascii=False, indent=2)
        # stdout output disabled: do not print
        return 0

    # run action
    if stop_users:
        users = [u for u in users if u not in set(stop_users)]

    if not users:
        raise SystemExit("monitor requires --user (after stop-user filter may be empty)")

    # stdout output disabled: only keep OpenClaw message send

    dedupe = DedupeCache(dedupe_ttl)
    info_client = HyperliquidInfoClient(base_url=DEFAULT_HTTP_BASE_URL)

    # Best-effort cache to avoid spamming HTTP on hot fills
    info_cache_lock = threading.Lock()
    info_cache: dict[str, tuple[float, dict[str, Any] | None, Any]] = {}
    info_cache_ttl_seconds = 6.0

    def _safe_float(v: Any) -> float | None:
        try:
            return float(v)
        except Exception:
            return None

    def _extract_user_account_value_usd(state: dict[str, Any] | None) -> float | None:
        if not isinstance(state, dict):
            return None
        ms = state.get("marginSummary")
        if isinstance(ms, dict):
            v = _safe_float(ms.get("accountValue"))
            if v is not None:
                return v
        cm = state.get("crossMarginSummary")
        if isinstance(cm, dict):
            v = _safe_float(cm.get("accountValue"))
            if v is not None:
                return v
        return None

    def _extract_total_unrealized_pnl_usd(state: dict[str, Any] | None) -> float | None:
        if not isinstance(state, dict):
            return None
        total = 0.0
        seen = False
        positions = state.get("assetPositions")
        if isinstance(positions, list):
            for p in positions:
                if not isinstance(p, dict):
                    continue
                pos = p.get("position")
                if not isinstance(pos, dict):
                    continue
                u = _safe_float(pos.get("unrealizedPnl"))
                if u is None:
                    continue
                seen = True
                total += float(u)
        return total if seen else None

    def _extract_24h_pnl_change_from_portfolio(portfolio_data: Any) -> float | None:
        # portfolio: list like [["day", {...}], ...] where pnlHistory is cumulative pnl series.
        if not isinstance(portfolio_data, list):
            return None
        day_obj: dict[str, Any] | None = None
        for it in portfolio_data:
            if not (isinstance(it, list) or isinstance(it, tuple)) or len(it) != 2:
                continue
            key = str(it[0] or "")
            if key == "day":
                if isinstance(it[1], dict):
                    day_obj = it[1]
                break
        if not isinstance(day_obj, dict):
            return None
        pnl_hist = day_obj.get("pnlHistory")
        if not isinstance(pnl_hist, list) or len(pnl_hist) < 2:
            return None
        first = pnl_hist[0]
        last = pnl_hist[-1]
        try:
            v0 = _safe_float(first[1] if isinstance(first, (list, tuple)) and len(first) >= 2 else None)
            v1 = _safe_float(last[1] if isinstance(last, (list, tuple)) and len(last) >= 2 else None)
        except Exception:
            return None
        if v0 is None or v1 is None:
            return None
        return float(v1 - v0)

    def _extract_leverage_x_from_fill_raw(raw: dict[str, Any] | None) -> float | None:
        r = raw if isinstance(raw, dict) else {}
        for k in ("leverage", "leverageValue", "leverageMultiplier"):
            if k in r:
                v = r.get(k)
                if isinstance(v, dict):
                    break
                try:
                    return float(v)
                except Exception:
                    return None
        lev = r.get("leverage")
        if isinstance(lev, dict):
            for kk in ("value", "leverage", "multiplier"):
                if kk in lev:
                    try:
                        return float(lev.get(kk))
                    except Exception:
                        return None
        return None

    def _get_state_and_portfolio(user: str, *, force: bool = False) -> tuple[dict[str, Any] | None, Any]:
        """
        Fetch (state, portfolio) with a small TTL cache.

        For open/close alerts we typically pass force=True to get the latest position/leverage/balance/upnl.
        If refresh fails, fall back to cached values.
        """

        now = time.time()
        with info_cache_lock:
            cached = info_cache.get(user)

        if (not force) and cached and (now - float(cached[0])) <= info_cache_ttl_seconds:
            return cached[1], cached[2]

        state: dict[str, Any] | None = None
        portfolio_data: Any = None
        ok_any = False
        try:
            state = info_client.user_state_no_dex(user)
            if not isinstance(state, dict):
                state = None
            else:
                ok_any = True
        except Exception:
            state = None
        try:
            portfolio_data = info_client.portfolio(user)
            ok_any = True
        except Exception:
            portfolio_data = None

        # if refresh failed completely, fall back to cache
        if not ok_any and cached:
            return cached[1], cached[2]

        with info_cache_lock:
            info_cache[user] = (now, state, portfolio_data)
        return state, portfolio_data

    def _extract_leverage_x_from_state_for_coin(state: dict[str, Any] | None, coin: str) -> float | None:
        """
        Prefer extracting leverage from current positions (user_state assetPositions).
        If position doesn't exist (e.g. fully closed), returns None.
        """

        if not isinstance(state, dict):
            return None
        aps = state.get("assetPositions")
        if not isinstance(aps, list):
            return None
        for ap in aps:
            if not isinstance(ap, dict):
                continue
            pos = ap.get("position")
            if not isinstance(pos, dict):
                continue
            if str(pos.get("coin", "") or "") != str(coin):
                continue
            # leverage can be dict: {"type":"cross","value":20}
            lev = pos.get("leverage")
            if isinstance(lev, dict):
                for kk in ("value", "leverage", "multiplier"):
                    if kk in lev:
                        try:
                            v = float(lev.get(kk))
                            return v if v > 0 else None
                        except Exception:
                            return None
            # or leverageValue / leverageMultiplier
            for k in ("leverageValue", "leverageMultiplier", "leverage"):
                if k in pos and not isinstance(pos.get(k), dict):
                    try:
                        v = float(pos.get(k))
                        return v if v > 0 else None
                    except Exception:
                        return None
        return None

    def _is_open_new(raw: dict[str, Any] | None) -> tuple[bool, bool]:
        r = raw if isinstance(raw, dict) else {}
        dir_text = str(r.get("dir", "") or "").strip().lower()
        if not dir_text:
            return False, False
        start_pos = _f(r.get("startPosition"))
        if "open" not in dir_text:
            return False, False
        is_new = abs(float(start_pos)) < 1e-12
        is_short = ("short" in dir_text) and ("open" in dir_text)
        return bool(is_new), bool(is_short)

    def _is_close(raw: dict[str, Any] | None) -> tuple[bool, bool]:
        r = raw if isinstance(raw, dict) else {}
        dir_text = str(r.get("dir", "") or "").strip().lower()
        if not dir_text:
            return False, False
        if "close" not in dir_text:
            return False, False
        # "Close Short" means closing a short position => was_short=True
        was_short = "short" in dir_text
        return True, bool(was_short)

    def _extract_entry_px_from_fill_raw(raw: dict[str, Any] | None) -> float | None:
        r = raw if isinstance(raw, dict) else {}
        for k in ("entryPx", "entryPrice", "startPx", "startPrice", "openPx", "openPrice"):
            if k in r:
                v = _safe_float(r.get(k))
                if v is not None and v > 0:
                    return v
        return None

    # runtime stop coordination
    stop_req_path = _monitor_stop_req_path(target)
    stop_req_seen: set[str] = set()
    users_set: set[str] = set(users)

    def on_status(msg: str) -> None:
        status_msg = format_common_status(language, msg)
        send_message(target, status_msg)

    def on_fill(ev: FillEvent) -> None:
        if ev.notional_usd < min_notional:
            return
        if not dedupe.add_if_new(_fill_key(ev)):
            return
        raw = ev.raw if isinstance(ev.raw, dict) else {}
        is_open, is_short = _is_open_new(raw)
        is_close, was_short = _is_close(raw)

        # Open-position reminder (zh/en via one formatter)
        if is_open:
            # For alerts, force refresh to reflect latest position/leverage/balance.
            state, portfolio_data = _get_state_and_portfolio(str(ev.user), force=True)

            msg = format_monitor_open_alert(
                lang=str(language),
                coin=str(ev.coin),
                is_short=bool(is_short),
                leverage_x=(
                    _extract_leverage_x_from_state_for_coin(state, str(ev.coin))
                    or _extract_leverage_x_from_fill_raw(raw)
                ),
                sz=float(ev.sz),
                entry_px=float(ev.px),
                user=str(ev.user),
                account_value_usd=_extract_user_account_value_usd(state),
                pnl_24h_usd=_extract_24h_pnl_change_from_portfolio(portfolio_data),
                unrealized_pnl_usd=_extract_total_unrealized_pnl_usd(state),
            )
            send_message(target, msg)
            return

        # Close-position reminder (zh/en via one formatter)
        if is_close:
            # For alerts, force refresh to reflect latest position/leverage/balance.
            state, portfolio_data = _get_state_and_portfolio(str(ev.user), force=True)

            entry_px = _extract_entry_px_from_fill_raw(raw)
            value_usd = abs(float(ev.px) * float(ev.sz)) if ev.px and ev.sz else None
            pnl_usd = float(getattr(ev, "closed_pnl", 0.0) or 0.0)

            msg = format_monitor_close_alert(
                lang=str(language),
                coin=str(ev.coin),
                was_short=bool(was_short),
                leverage_x=_extract_leverage_x_from_state_for_coin(state, str(ev.coin)),
                sz=float(ev.sz),
                entry_px=entry_px,
                value_usd=value_usd,
                pnl_usd=pnl_usd,
                user=str(ev.user),
                account_value_usd=_extract_user_account_value_usd(state),
                pnl_24h_usd=_extract_24h_pnl_change_from_portfolio(portfolio_data),
                unrealized_pnl_usd=_extract_total_unrealized_pnl_usd(state),
            )
            send_message(target, msg)
            return

        # Suppress generic fill notifications to reduce noise.
        # Only open/close alerts (and user/ledger events) are sent.
        return

    def on_user_event(ev: UserEvent) -> None:
        line = format_monitor_user_event_message(language=language, user=ev.user, kind=ev.kind)
        send_message(target, line)

    def on_non_funding_ledger_update(data: dict[str, Any]) -> None:
        # best-effort: derive deposit/withdraw (USDC only)
        updates: list[Any] = []
        if isinstance(data.get("updates"), list):
            updates = data["updates"]
        elif isinstance(data.get("delta"), dict):
            updates = [data]
        else:
            # unknown shape: still forward a lightweight message
            # send_message(target, f"[LEDGER] unparsed keys={list(data.keys())}")
            return

        for upd in updates:
            if not isinstance(upd, dict):
                continue
            delta = upd.get("delta")
            if not isinstance(delta, dict):
                continue

            amount_usdc = _extract_usdc_value(delta)
            if amount_usdc == 0.0:
                continue

            src_user = str(delta.get("user", "") or "")
            destination = str(delta.get("destination", "") or "")
            t = str(delta.get("type") or delta.get("deltaType") or "unknown")

            action = None
            for u in users:
                if src_user == u and destination != u:
                    action = "withdraw"
                elif destination == u and src_user != u:
                    action = "deposit"
            if not action:
                continue

            ts = upd.get("time") or upd.get("timestamp") or delta.get("time") or delta.get("timestamp") or 0
            key = f"ledger:{action}:{t}:{amount_usdc}:{src_user}:{destination}:{ts}"
            if not dedupe.add_if_new(key):
                continue

            msg = format_monitor_ledger_message(
                language=language,
                action=action,
                amount_usdc=amount_usdc,
                type_name=t,
                src_user=src_user,
                destination=destination,
                time_ms=ts,
            )
            send_message(target, msg)

    sub = HyperliquidWsSubscriber(
        WsConfig(
            ws_url=ws_url,
            debug_ping_pong=False,
            use_app_ping=True,
            app_ping_interval_seconds=30,
        ),
        on_fill=on_fill,
        on_user_event=on_user_event,
        on_non_funding_ledger_update=on_non_funding_ledger_update,
        on_status=on_status,
        ignore_snapshots=True,
    )

    for u in users:
        sub.add_user_fills(u, aggregate_by_time=agg)
        sub.add_user_events(u)
        sub.add_user_non_funding_ledger_updates(u)

    # persist pid so that openclaw can stop by target
    pid_path = _monitor_pid_path(target)
    try:
        with open(pid_path, "w", encoding="utf-8") as f:
            f.write(str(os.getpid()))
    except Exception:
        pass

    # watcher thread: read stop requests and cancel subscriptions dynamically
    def _watch_stop_requests() -> None:
        while True:
            try:
                if not os.path.exists(stop_req_path):
                    time.sleep(2.0)
                    continue
                with open(stop_req_path, "r", encoding="utf-8") as f:
                    req = json.load(f) if f.readable() else {}
                stop_users = req.get("stopUsers") or []
                # stop all users (and stop ws) if sentinel present
                stop_all = False
                for u0 in stop_users:
                    u0 = str(u0).strip().lower()
                    if u0 in {"*", "all"}:
                        stop_all = True
                        break

                if stop_all:
                    if language == "en":
                        on_status("monitor stop all; stopping ws")
                    else:
                        on_status("监控停止全部用户；停止 websocket")
                    sub.stop()
                    return

                for u in stop_users:
                    u = str(u).strip()
                    if not u or u in stop_req_seen:
                        continue
                    stop_req_seen.add(u)
                    if u in users_set:
                        sub.remove_user_fills(u)
                        sub.remove_user_events(u)
                        sub.remove_user_non_funding_ledger_updates(u)
                        users_set.discard(u)
                        if language == "en":
                            on_status(f"monitor stopped user={u}; remaining={sorted(users_set)}")
                        else:
                            on_status(f"监控已停止 用户={u}；剩余={sorted(users_set)}")

                if not users_set:
                    if language == "en":
                        on_status("monitor no remaining users; stopping ws")
                    else:
                        on_status("监控无剩余用户；停止 websocket")
                    sub.stop()
                    return
            except Exception:
                # never crash the WS thread due to stop watcher
                pass
            time.sleep(2.0)

    t = threading.Thread(target=_watch_stop_requests, daemon=True)
    t.start()

    # if language == "en":
    #     on_status(f"monitor started users={users} ws={ws_url}")
    # else:
    #     on_status(f"监控已启动 用户={users} ws={ws_url}")
    sub.run_forever()
    return 0


def main() -> int:
    args = build_parser().parse_args()
    return run(args)


if __name__ == "__main__":
    raise SystemExit(main())

