"""Live ON-CHAIN / FUNDAMENTAL data for the on-chain edge probe (READ-ONLY research).

Two free sources, both also used by `unlock_calendar_live` (same patterns, same defensive parsing):
  * DefiLlama — aggregate stablecoin supply series (`stablecoins.llama.fi`), per-protocol TVL + fees
    (`api.llama.fi`);
  * CoinGecko — the `coins/list` symbol→id map + per-id market cap (`api.coingecko.com`).

HTTP is INJECTED (`get`) so tests run with NO network. A wall-clock seam (`clock: Callable[[], float] =
time.time`) is threaded through so the "today" cutoff is deterministic under a fixed injected clock — the SAME
determinism bug we fixed twice this session in the Deribit adapters (NEVER a bare `time.time()`/`date.today()`
in a loop). Parsing is defensive everywhere: `.get(...)` with skips for malformed rows (a missing field skips
the row, never raises) — mirrors the `KeyError: 'quoteVolume'` fix.
"""
from __future__ import annotations

import json
import time
import urllib.request
from collections.abc import Callable
from typing import Any

_STABLECOINS = "https://stablecoins.llama.fi"
_LLAMA = "https://api.llama.fi"
_CG = "https://api.coingecko.com/api/v3"

_SECONDS_PER_DAY = 86_400


def _http_get(url: str, tries: int = 4) -> Any:
    """Default JSON fetcher (urllib, retry+backoff) — mirrors `unlock_calendar_live._http_get`."""
    for a in range(tries):
        try:
            with urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}),
                                        timeout=40) as r:
                return json.loads(r.read())
        except Exception:  # noqa: BLE001 — transient HTTP; retry then re-raise
            if a == tries - 1:
                raise
            time.sleep(2 * (a + 1))
    raise RuntimeError("unreachable")


def _as_float(x: Any) -> float | None:
    """Best-effort float parse; None on anything non-numeric (defensive — a bad cell skips, never raises)."""
    try:
        if x is None:
            return None
        return float(x)
    except (TypeError, ValueError):
        return None


class OnchainFundamentalLive:
    def __init__(self, *, get: Callable[[str], Any] = _http_get,
                 clock: Callable[[], float] = time.time) -> None:
        self._get = get
        # The wall-clock seam: the "today" cutoff (only COMPLETED UTC days enter the supply series — no
        # lookahead on a partial current day). A fixed injected clock makes the cutoff deterministic.
        self._clock = clock

    def _asof_day(self) -> int:
        return int(self._clock() // _SECONDS_PER_DAY)

    # --- Angle ①: aggregate stablecoin supply (a genuine DAILY series) --------------------------
    def stablecoin_supply_series(self) -> dict[int, float]:
        """`{epoch_day: total_stablecoin_circulating_usd}` from DefiLlama `stablecoincharts/all`.

        Each row is `{date: "<unix_seconds>", totalCirculatingUSD: {peggedUSD: ..., peggedEUR: ...}}`. We SUM
        the per-peg USD values to one daily total. Rows missing `date` or a numeric supply are SKIPPED (never
        raise). Only days STRICTLY BEFORE the clock's `asof_day` are kept (a partial current day is excluded —
        no lookahead). Deterministic under a fixed injected clock."""
        rows = self._get(f"{_STABLECOINS}/stablecoincharts/all")
        asof = self._asof_day()
        out: dict[int, float] = {}
        for row in rows if isinstance(rows, list) else []:
            if not isinstance(row, dict):
                continue
            ts = _as_float(row.get("date"))
            if ts is None:
                continue
            day = int(ts) // _SECONDS_PER_DAY
            if day >= asof:
                continue                      # exclude the (partial) current/future day — no lookahead
            circ = row.get("totalCirculatingUSD")
            if not isinstance(circ, dict):
                continue
            total = sum(v for v in (_as_float(x) for x in circ.values()) if v is not None)
            if total > 0.0:
                out[day] = float(total)
        return out

    # --- Angle ②: per-protocol fundamentals (CURRENT SNAPSHOT) + market cap ---------------------
    def _tvl_fees_by_base(self, tradeable_bases: set[str]) -> dict[str, dict[str, float | None]]:
        """`{base: {tvl, fees, revenue}}` for protocols whose token symbol is in `tradeable_bases`. TVL from
        `/protocols` (current snapshot), fees/revenue from `/overview/fees`. Defensive: a row without a usable
        `symbol` is skipped; a missing numeric field is left as None (excluded later). On a token mapped by
        several protocols the fundamentals are SUMMED (the token's total across its protocols)."""
        out: dict[str, dict[str, float | None]] = {}

        def _bump(base: str, key: str, val: float | None) -> None:
            if val is None:
                return
            cell = out.setdefault(base, {"tvl": None, "fees": None, "revenue": None})
            cell[key] = (cell[key] or 0.0) + val

        protocols = self._get(f"{_LLAMA}/protocols")
        for p in protocols if isinstance(protocols, list) else []:
            if not isinstance(p, dict):
                continue
            sym = (p.get("symbol") or "").upper()
            if not sym or sym == "-" or sym not in tradeable_bases:
                continue
            _bump(sym, "tvl", _as_float(p.get("tvl")))

        try:
            fees = self._get(f"{_LLAMA}/overview/fees")
        except Exception:  # noqa: BLE001 — fees overview is best-effort; TVL still usable without it
            fees = {}
        fee_rows = fees.get("protocols") if isinstance(fees, dict) else None
        for p in fee_rows if isinstance(fee_rows, list) else []:
            if not isinstance(p, dict):
                continue
            sym = (p.get("symbol") or "").upper()
            if not sym or sym == "-" or sym not in tradeable_bases:
                continue
            _bump(sym, "fees", _as_float(p.get("total24h")))
            # DefiLlama exposes revenue on the fees row as `revenue24h` on some payloads; fall back to None.
            _bump(sym, "revenue", _as_float(p.get("revenue24h")))
        return out

    def _mcap_by_base(self, bases: set[str]) -> dict[str, float]:
        """`{base: market_cap_usd}` from CoinGecko: `coins/list` (symbol→id) then `coins/markets` paginated.

        For a symbol mapped to several ids we keep the LARGEST market cap (the canonical token, not a long-tail
        impostor sharing the ticker). Defensive: rows without a numeric `market_cap` are skipped."""
        if not bases:
            return {}
        listing = self._get(f"{_CG}/coins/list")
        ids_for_sym: dict[str, list[str]] = {}
        for c in listing if isinstance(listing, list) else []:
            if not isinstance(c, dict):
                continue
            sym = (c.get("symbol") or "").upper()
            cid = c.get("id")
            if sym in bases and cid:
                ids_for_sym.setdefault(sym, []).append(str(cid))
        wanted_ids = sorted({i for ids in ids_for_sym.values() for i in ids})
        if not wanted_ids:
            return {}

        mcap_by_id: dict[str, float] = {}
        page_size = 250
        for start in range(0, len(wanted_ids), page_size):
            chunk = wanted_ids[start:start + page_size]
            url = (f"{_CG}/coins/markets?vs_currency=usd&ids={','.join(chunk)}"
                   f"&per_page={page_size}&page=1")
            try:
                rows = self._get(url)
            except Exception:  # noqa: BLE001 — a transient page failure skips that chunk, keeps the rest
                continue
            for m in rows if isinstance(rows, list) else []:
                if not isinstance(m, dict):
                    continue
                cid = m.get("id")
                mc = _as_float(m.get("market_cap"))
                if cid and mc is not None and mc > 0.0:
                    mcap_by_id[str(cid)] = mc

        out: dict[str, float] = {}
        for sym, ids in ids_for_sym.items():
            caps = [mcap_by_id[i] for i in ids if i in mcap_by_id]
            if caps:
                out[sym] = max(caps)
        return out

    def fundamentals_by_coin(self, tradeable_bases: set[str]) -> dict[str, dict[str, float]]:
        """`{base: {tvl, fees, revenue, mcap}}` for Bybit-tradeable bases that HAVE both fundamentals AND a
        market cap — the value universe ∩ has-fundamentals. A CURRENT SNAPSHOT (not point-in-time): the caller
        flags `fundamentals_point_in_time=False` so the value angle is reported N/A. Defensive throughout."""
        tf = self._tvl_fees_by_base(tradeable_bases)
        mcap = self._mcap_by_base(set(tf))
        out: dict[str, dict[str, float]] = {}
        for base, cell in tf.items():
            mc = mcap.get(base)
            if mc is None or mc <= 0.0:
                continue
            row: dict[str, float] = {"mcap": mc}
            for k in ("tvl", "fees", "revenue"):
                if cell.get(k) is not None:
                    row[k] = float(cell[k])
            out[base] = row
        return out
