"""
DeepGyre Python SDK — single file, stdlib only.

    from deepgyre import DeepGyre

    dg = DeepGyre()                      # https://deepgyre.com
    dg.health()
    dg.protocol_revenue(limit=10)

    dg = DeepGyre(api_key="…")           # a developer API key, minted in
    dg.data_smart_money(limit=20)        # your account on plans that have it

Design notes
------------
* Rate limits are honoured, not fought: a 429 is retried after `Retry-After`
  rather than hammered.
* `available: false` is passed through untouched. The API deliberately
  distinguishes "could not compute" from "zero", and an SDK that flattened that
  into 0 would reintroduce exactly the bug the platform avoids.
* Nothing is cached here. The server already serves stale-while-revalidate and
  tells you the age; a second cache layer would only obscure it.
"""
from __future__ import annotations

import json
import time
import urllib.error
import urllib.parse
import urllib.request
from typing import Any, Dict, Optional

__version__ = "1.0.0"


class DeepGyreError(RuntimeError):
    def __init__(self, status: int, message: str, body: Any = None):
        super().__init__(f"HTTP {status}: {message}")
        self.status, self.message, self.body = status, message, body


class PlanRequired(DeepGyreError):
    """402 — the route needs a plan the account does not have."""


class RateLimited(DeepGyreError):
    """429 — retry after `retry_after` seconds."""

    def __init__(self, status, message, body=None, retry_after: float = 60.0):
        super().__init__(status, message, body)
        self.retry_after = retry_after


class DeepGyre:
    def __init__(self, base_url: str = "https://deepgyre.com",
                 token: Optional[str] = None, api_key: Optional[str] = None,
                 timeout: float = 60.0, max_retries: int = 3):
        self.base_url = base_url.rstrip("/")
        self.token = token
        self.api_key = api_key          # developer API key (X-API-Key)
        self.timeout = timeout
        self.max_retries = max_retries

    # ── plumbing ────────────────────────────────────────────────────────
    def _headers(self) -> Dict[str, str]:
        h = {"Accept": "application/json",
             "User-Agent": f"deepgyre-python/{__version__}"}
        if self.token:
            h["Authorization"] = f"Bearer {self.token}"
        if self.api_key:
            h["X-API-Key"] = self.api_key
        return h

    def _request(self, method: str, path: str, params: Optional[Dict] = None,
                 body: Optional[Dict] = None) -> Any:
        url = f"{self.base_url}{path}"
        if params:
            clean = {k: v for k, v in params.items() if v is not None}
            if clean:
                url += "?" + urllib.parse.urlencode(clean)

        data = json.dumps(body).encode() if body is not None else None
        headers = self._headers()
        if data:
            headers["Content-Type"] = "application/json"

        for attempt in range(self.max_retries):
            req = urllib.request.Request(url, data=data, headers=headers,
                                         method=method)
            try:
                with urllib.request.urlopen(req, timeout=self.timeout) as r:
                    return json.loads(r.read().decode() or "null")
            except urllib.error.HTTPError as e:
                raw = e.read().decode(errors="replace")
                try:
                    parsed = json.loads(raw)
                except Exception:
                    parsed = raw
                msg = (parsed.get("message") or parsed.get("detail") or raw
                       ) if isinstance(parsed, dict) else raw
                if e.code == 402:
                    raise PlanRequired(402, str(msg), parsed) from None
                if e.code == 429:
                    wait = float(e.headers.get("Retry-After") or 5)
                    if attempt < self.max_retries - 1:
                        time.sleep(min(wait, 65) + 0.5)
                        continue
                    raise RateLimited(429, str(msg), parsed, wait) from None
                raise DeepGyreError(e.code, str(msg), parsed) from None
        raise DeepGyreError(0, "retries exhausted")

    def _get(self, path, **params):
        return self._request("GET", path, params=params)

    def _post(self, path, body=None, **params):
        return self._request("POST", path, params=params, body=body or {})

    # ── auth ────────────────────────────────────────────────────────────
    def login(self, email: str, password: str) -> str:
        """Authenticate and keep the session token for later calls.

        The live site signs in with an httpOnly cookie and returns no token,
        so this raises there; programs use `api_key` and the data_* methods.
        """
        out = self._post("/api/v1/auth/login",
                         {"email": email, "password": password})
        tok = out.get("token") or out.get("access_token")
        if not tok:
            raise DeepGyreError(200, "login returned no token", out)
        self.token = tok
        return tok

    def entitlements(self):
        return self._get("/api/v1/auth/entitlements")

    # ── system ──────────────────────────────────────────────────────────
    def health(self):
        """Public shape: `status`, `chains`, `maintenance`. The full diagnostic
        picture is admin-only (/api/v1/admin/health)."""
        return self._get("/api/v1/health")

    def sources(self):
        """Upstream source health (admin sign-in required). `quota` means a
        valid key with no units left — a different problem from `down`."""
        return self._get("/api/v1/sources")

    # ── market ──────────────────────────────────────────────────────────
    def protocol_revenue(self, limit: int = 100):
        return self._get("/api/v1/market/defi/revenue", limit=limit)

    def protocol(self, slug: str):
        return self._get(f"/api/v1/market/defi/revenue/{slug}")

    def onchain_metrics(self):
        """SOPR, HODL Waves, Realized Cap, MVRV, NUPL.

        Check `available` per metric — some decline to return a value rather
        than publish one computed from an incomplete input.
        """
        return self._get("/api/v1/market/onchain/metrics")

    def regime(self):
        """Cycle regime. Needs the `regime` plan tab."""
        return self._get("/api/v1/market/regime")

    def smart_money(self):
        """Cohort accumulation. Needs the `whale` plan tab."""
        return self._get("/api/v1/market/smart-money")

    def pulse(self):
        return self._get("/api/v1/market/pulse")

    # ── wallet & entity ─────────────────────────────────────────────────
    def wallet_profile(self, address: str, chain: str = "ethereum"):
        return self._get(f"/api/v1/wallet/profile/{chain}/{address}")

    def wallet_flow(self, address: str, chain: str = "ethereum",
                    limit: int = 300):
        return self._post("/api/v1/wallet/flow",
                          {"chain": chain, "address": address, "limit": limit})

    def label(self, address: str):
        """Entity label with provenance. `found: false` means unknown — the
        API does not guess."""
        return self._get(f"/api/v1/intelligence/label/{address}")

    def entity_links(self, address: str, limit: int = 50):
        return self._get(f"/api/v1/intelligence/entity/links/{address}",
                         limit=limit)

    # ── alerts ──────────────────────────────────────────────────────────
    def alert_rules(self):
        return self._get("/api/v1/intelligence/alerts/rules")

    def create_alert_rule(self, symbol: str, metric: str, op: str,
                          threshold: float, note: str = ""):
        """`symbol` is a ticker for market metrics, or an address / protocol
        slug for on-chain ones (wallet_balance, tvl, cohort_buyers, …)."""
        return self._post("/api/v1/intelligence/alerts/rules",
                          {"symbol": symbol, "metric": metric, "op": op,
                           "threshold": threshold, "note": note})


    # ── developer data API (X-API-Key) ──────────────────────────────────
    # Every response carries X-API-Calls-Used / X-API-Calls-Limit; a key
    # past its monthly quota answers 429, a revoked one 401.
    def data_smart_money(self, limit: int = 100):
        """Wallets ranked by measured earliness (not profit); 10..200."""
        return self._get("/api/v1/data/smart-money/leaderboard", limit=limit)

    def data_token_map(self, contract: str, limit: int = 30):
        """Largest net buyers and sellers of an Ethereum token; 5..60."""
        return self._get(f"/api/v1/data/token-map/{contract}", limit=limit)

    def data_entities(self):
        """The curated entity book. total_usd_value None = never aggregated."""
        return self._get("/api/v1/data/entities")

    def data_label(self, address: str):
        """One address's label; None means unknown, never a guess."""
        return self._get(f"/api/v1/data/label/{address}")


if __name__ == "__main__":
    bs = DeepGyre()
    h = bs.health()
    print(f"status={h['status']}  chains={len(h.get('chains') or [])}")

    # limit has a floor of 10 server-side; the SDK surfaces the 422 verbatim
    # rather than silently clamping, so you find out at the call site.
    rev = bs.protocol_revenue(limit=10)
    print(f"\nprotocols with fee data: {rev.get('count')} "
          f"({rev.get('with_revenue')} with revenue)")
    for p in rev.get("protocols", [])[:5]:
        tr = p.get("take_rate_30d")
        take = f"{tr:.0%}" if tr is not None else "—"
        print(f"  {p['name'][:22]:<24} fees30d={(p.get('fees_30d') or 0):>14,.0f}"
              f"  take={take:>5}")

    m = bs.onchain_metrics()["metrics"]
    print("\non-chain metrics:")
    for k, v in m.items():
        state = "ok" if v.get("available") else f"unavailable ({v.get('blocked_by') or 'see reason'})"
        print(f"  {k:<18} {state}")
