#!/usr/bin/env python3
"""Oracle-branded alert cards.

RH AGENT brand is dead. This module is the Oracle desk surface:
same lime/red candles, Oracle splash kit, official chain lockup (logo, not
a typeset fake), and price-only chrome for Polymarket / HIP-4.

TA is printed only when the live series actually trips a rule.
"""
from __future__ import annotations

import json
import os
import tempfile
from datetime import datetime, timezone
from pathlib import Path

# Splash tokens (oracle.demi.la :root)
ORACLE = {
    "bg": "#0B1018",
    "panel": "#111925",
    "ink": "#F4F9FE",
    "mute": "#9FB8D2",
    "blue": "#7CC4FF",
    "blue2": "#A7D8FF",
    "line": (124 / 255, 196 / 255, 255 / 255, 0.18),
    "glow": "#1a3a55",
    "up": "#7CC4FF",
    "up_wick": "#A7D8FF",
    "down": "#FF5A6A",
    "down_wick": "#C94A56",
    "yes": "#7CC4FF",
    "no": "#FF5A6A",
    "chip_ink": "#061018",
}

# Assets ship with the package. Search env, next to this file, then the
# published npm graphics tree so a copied renderer still finds lockups.
def _resolve_assets() -> Path:
    env = os.environ.get("ORACLE_CARD_ASSETS")
    if env:
        p = Path(env)
        if p.is_dir():
            return p
    here = Path(__file__).resolve().parent
    candidates = [
        here / "assets",
        Path.home() / ".local/share/oracle-assets",
        here.parent / "assets" / "graphics" / "assets",
        here.parent / "graphics" / "assets",
    ]
    for cand in candidates:
        if (cand / "robinhood-lockup.png").is_file() or (cand / "oracle-globe.png").is_file():
            return cand
    return here / "assets"


_ASSETS = _resolve_assets()
_WM = _ASSETS
ORACLE_MARK = _ASSETS / "oracle-globe.png"
CHAINS = {
    "hyperliquid": {"label": "HYPERLIQUID", "accent": "#50D2C1", "logo": _ASSETS / "hyperliquid-lockup.png", "kind": "ohlc"},
    "hip-3": {"label": "HYPERLIQUID HIP-3", "accent": "#50D2C1", "logo": _ASSETS / "hyperliquid-lockup.png", "kind": "ohlc"},
    "hip-4": {"label": "HYPERLIQUID HIP-4", "accent": "#50D2C1", "logo": _ASSETS / "hyperliquid-lockup.png", "kind": "yesno"},
    "polymarket": {"label": "POLYMARKET", "accent": "#3B82F6", "logo": _ASSETS / "polymarket-lockup.png", "kind": "yesno"},
    "ethereum": {"label": "ETHEREUM", "accent": "#7CC4FF", "logo": _ASSETS / "ethereum-lockup.png", "kind": "ohlc"},
    "base": {"label": "BASE", "accent": "#0052FF", "logo": _ASSETS / "base-lockup.png", "kind": "ohlc"},
    "solana": {"label": "SOLANA", "accent": "#9945FF", "logo": _ASSETS / "solana-lockup.png", "kind": "ohlc"},
    "bitcoin": {"label": "BITCOIN", "accent": "#F7931A", "logo": _ASSETS / "bitcoin-lockup.png", "kind": "ohlc"},
    "arbitrum": {"label": "ARBITRUM", "accent": "#12AAFF", "logo": _ASSETS / "arbitrum-lockup.png", "kind": "ohlc"},
    "optimism": {"label": "OPTIMISM", "accent": "#FF0420", "logo": _ASSETS / "optimism-lockup.png", "kind": "ohlc"},
    "polygon": {"label": "POLYGON", "accent": "#8247E5", "logo": _ASSETS / "polygon-lockup.png", "kind": "ohlc"},
    "bnb": {"label": "BNB CHAIN", "accent": "#F0B90B", "logo": _ASSETS / "bnb-lockup.png", "kind": "ohlc"},
    "avalanche": {"label": "AVALANCHE", "accent": "#E84142", "logo": _ASSETS / "avalanche-lockup.png", "kind": "ohlc"},
    "stable": {"label": "STABLE", "accent": "#26A17B", "logo": _ASSETS / "stable-lockup.png", "kind": "ohlc"},
    "robinhood": {"label": "ROBINHOOD CHAIN", "accent": "#d7ff4d", "logo": _ASSETS / "robinhood-lockup.png", "kind": "ohlc"},
}

_FONTS = {"display": "DejaVu Serif", "sans": "DejaVu Sans", "mono": "DejaVu Sans Mono"}


def _init_fonts():
    try:
        import matplotlib.font_manager as fm

        roots = [
            Path.home() / ".local/share/fonts",
            Path.home() / ".local/share/fonts/dmmono",
            Path(__file__).resolve().parent / "fonts",
        ]
        for root in roots:
            if not root.exists():
                continue
            for p in root.rglob("*.ttf"):
                try:
                    fm.fontManager.addfont(str(p))
                except Exception:
                    pass
        names = {f.name for f in fm.fontManager.ttflist}
        for pref in ("Cormorant Garamond", "CormorantGaramond", "DejaVu Serif"):
            if pref in names:
                _FONTS["display"] = pref
                break
        for pref in ("Outfit", "IBM Plex Sans", "DejaVu Sans"):
            if pref in names:
                _FONTS["sans"] = pref
                break
        for pref in ("IBM Plex Mono", "DM Mono", "JetBrains Mono", "DejaVu Sans Mono"):
            if pref in names:
                _FONTS["mono"] = pref
                break
    except Exception:
        pass
    return _FONTS


def _oracle_display_fp(size: float):
    """Oracle display face at a real weight. Variable Cormorant has no bold
    instance in matplotlib — we ship a 700 static cut next to this file."""
    from matplotlib import font_manager as fm

    here = Path(__file__).resolve().parent / "fonts"
    for name in ("CormorantGaramond-Bold.ttf", "CormorantGaramond-SemiBold.ttf"):
        p = here / name
        if p.exists():
            try:
                fm.fontManager.addfont(str(p))
            except Exception:
                pass
            return fm.FontProperties(fname=str(p), size=size)
    return fm.FontProperties(family=_FONTS.get("display") or "DejaVu Serif", size=size, weight="bold")


def _oracle_num_fp(size: float):
    """Oracle data face for figures. Cormorant is for names; numbers use
    DM Mono / Roboto Mono from a real file so they stay bold and tabular."""
    from matplotlib import font_manager as fm

    cands = [
        Path(__file__).resolve().parent / "fonts" / "RobotoMono-Bold.ttf",
        Path.home() / ".local/share/fonts/keyitdev/RobotoMono-Bold.ttf",
        Path(__file__).resolve().parent / "fonts" / "dmmono-Medium.ttf",
        Path.home() / ".local/share/fonts/dmmono/dmmono-Medium.ttf",
        Path.home() / ".local/share/fonts/MapleMono/MapleMono-Bold.ttf",
    ]
    for p in cands:
        if not p.exists():
            continue
        try:
            fm.fontManager.addfont(str(p))
        except Exception:
            pass
        return fm.FontProperties(fname=str(p), size=size)
    return fm.FontProperties(family=_FONTS.get("mono") or "DejaVu Sans Mono", size=size, weight="bold")


def _esc(s: str) -> str:
    return str(s or "")


def _fmt_px(n: float) -> str:
    a = abs(n)
    if a >= 1000:
        return f"{n:,.0f}"
    if a >= 1:
        return f"{n:,.2f}"
    if a >= 0.01:
        return f"{n:.4f}"
    return f"{n:.6f}"


def _rsi(closes: list[float], period: int = 14) -> float | None:
    if len(closes) < period + 1:
        return None
    gains = []
    losses = []
    for i in range(-period, 0):
        d = closes[i] - closes[i - 1]
        gains.append(max(d, 0.0))
        losses.append(max(-d, 0.0))
    ag = sum(gains) / period
    al = sum(losses) / period
    if al == 0:
        return 100.0
    rs = ag / al
    return 100.0 - (100.0 / (1.0 + rs))


def _ema(values: list[float], period: int) -> float | None:
    if len(values) < period:
        return None
    k = 2 / (period + 1)
    e = sum(values[:period]) / period
    for v in values[period:]:
        e = v * k + e * (1 - k)
    return e


def spot_ta(rows: list[dict]) -> list[str]:
    """Return only rules that fire on this series. Empty = print nothing."""
    closes = [float(r["c"]) for r in rows if r.get("c") is not None]
    highs = [float(r["h"]) for r in rows if r.get("h") is not None]
    lows = [float(r["l"]) for r in rows if r.get("l") is not None]
    opens = [float(r["o"]) for r in rows if r.get("o") is not None]
    if len(closes) < 8:
        return []
    spotted: list[str] = []
    rsi = _rsi(closes)
    if rsi is not None and rsi >= 70:
        spotted.append(f"RSI {rsi:.0f} overbought")
    elif rsi is not None and rsi <= 30:
        spotted.append(f"RSI {rsi:.0f} oversold")
    e9, e21 = _ema(closes, 9), _ema(closes, 21)
    e9p = _ema(closes[:-1], 9)
    e21p = _ema(closes[:-1], 21)
    if e9 and e21 and e9p and e21p:
        if e9p <= e21p and e9 > e21:
            spotted.append("EMA 9/21 bull cross")
        elif e9p >= e21p and e9 < e21:
            spotted.append("EMA 9/21 bear cross")
    if len(closes) >= 3 and closes[-1] > max(closes[-6:-1]):
        spotted.append("new local high")
    if len(closes) >= 3 and closes[-1] < min(closes[-6:-1]):
        spotted.append("new local low")
    if opens and closes and highs and lows:
        o, c, h, l = opens[-1], closes[-1], highs[-1], lows[-1]
        body = abs(c - o)
        rng = h - l
        if rng > 0 and body / rng >= 0.7 and c > o and o <= closes[-2]:
            spotted.append("bullish engulf")
        if rng > 0 and body / rng >= 0.7 and c < o and o >= closes[-2]:
            spotted.append("bearish engulf")
    return spotted[:4]


def chain_meta(venue: str) -> dict:
    key = str(venue or "").lower().replace(" ", "-")
    if key in ("hl", "hypercore", "hype"):
        key = "hyperliquid"
    if key in ("poly", "pm"):
        key = "polymarket"
    if key in ("rh", "rh-chain", "4663", "robinhood-chain"):
        key = "robinhood"
    return CHAINS.get(key, {"label": key.upper() or "UNKNOWN", "accent": ORACLE["blue"], "logo": None, "kind": "ohlc"})


def _logo_wh(lockup):
    size = getattr(lockup, "size", None)
    if isinstance(size, (tuple, list)) and len(size) >= 2:
        return int(size[0] or 0), int(size[1] or 0)
    shape = getattr(lockup, "shape", None)
    if isinstance(shape, tuple) and len(shape) >= 2:
        return int(shape[1] or 0), int(shape[0] or 0)
    return 180, 28


def _load_logo(path: Path | None):
    if not path or not Path(path).exists():
        return None
    p = Path(path)
    try:
        if p.suffix.lower() == ".svg":
            import subprocess
            import tempfile
            from PIL import Image

            tmp = Path(tempfile.gettempdir()) / f"{p.stem}-raster.png"
            subprocess.run(["rsvg-convert", "-h", "200", str(p)], check=True, stdout=open(tmp, "wb"))
            return Image.open(tmp).convert("RGBA")
        from PIL import Image

        return Image.open(p).convert("RGBA")
    except Exception:
        try:
            import matplotlib.image as mpimg

            return mpimg.imread(str(p))
        except Exception:
            return None


def _chrome(fig, *, venue: str, eyebrow: str, hero: str, hero_color: str, chip: str, chip_bg: str, footer: str, pair_label: str = ""):
    from matplotlib import patheffects as pe
    from matplotlib.lines import Line2D

    fonts = _init_fonts()
    B = ORACLE
    meta = chain_meta(venue)
    fig.add_artist(Line2D([0.045, 0.955], [0.845, 0.845], transform=fig.transFigure, color=B["line"], linewidth=1.0))
    text_x = 0.048
    globe = _load_logo(ORACLE_MARK)
    if globe is not None:
        try:
            gax = fig.add_axes([0.040, 0.872, 0.078, 0.100], zorder=8)
            gax.imshow(globe)
            gax.axis("off")
            text_x = 0.108
        except Exception:
            text_x = 0.048
    wm = fig.text(text_x, 0.922, "ORACLE", color=B["blue2"], fontsize=16, fontfamily=fonts["display"], fontweight="medium", va="center")
    wm.set_path_effects([pe.withStroke(linewidth=3.0, foreground=B["glow"], alpha=0.65)])
    tag_x = text_x + 0.118
    fig.text(tag_x, 0.922, "CHAIN:", color=B["mute"], fontsize=8.0, fontfamily=fonts["mono"], va="center")
    lockup = _load_logo(meta.get("logo"))
    drew_lockup = False
    if lockup is not None:
        try:
            w, h = _logo_wh(lockup)
            aspect = (w / max(h, 1)) if w and h else 6.0
            lh = 0.062
            lw = min(lh * aspect * (6.3 / 11.2), 0.30)
            lax = fig.add_axes([tag_x + 0.052, 0.922 - lh / 2, lw, lh], zorder=8)
            lax.set_facecolor("none")
            lax.imshow(lockup)
            lax.axis("off")
            drew_lockup = True
        except Exception:
            drew_lockup = False
    if not drew_lockup:
        fig.text(tag_x + 0.052, 0.922, meta["label"], color=meta["accent"], fontsize=8.0, fontfamily=fonts["mono"], va="center")
    fig.text(0.048, 0.815, eyebrow, color=B["blue"], fontsize=7.2, fontfamily=fonts["mono"], va="top")
    if hero:
        hp = None
        try:
            hp = _oracle_num_fp(26)
        except Exception:
            hp = None
        ht = fig.text(
            0.952, 0.932, _esc(hero), color=hero_color or B["blue2"],
            fontsize=26, fontfamily=fonts["mono"], fontweight="bold",
            fontproperties=hp, ha="right", va="center", zorder=9,
            parse_math=False,
        )
        if pair_label:
            tp = None
            try:
                tp = _oracle_display_fp(20)
            except Exception:
                tp = None
            # Sit flush left of the MC using the real glyph box.
            gap = 0.014
            x = 0.74
            try:
                fig.canvas.draw()
                bb = ht.get_window_extent(renderer=fig.canvas.get_renderer())
                x0, _ = fig.transFigure.inverted().transform((bb.x0, bb.y0))
                x = x0 - gap
            except Exception:
                x = 0.952 - max(0.18, len(_esc(hero)) * 0.014)
            fig.text(
                x, 0.932, _esc(pair_label),
                color=B["ink"], fontsize=20, fontfamily=fonts["display"],
                fontweight="bold", fontproperties=tp, ha="right", va="center",
                zorder=9,
            )
    if chip:
        fig.text(0.952, 0.868, chip, color=B["blue2"], fontsize=8.2, fontfamily=fonts["mono"], ha="right", va="center")
    fig.add_artist(Line2D([0.045, 0.955], [0.072, 0.072], transform=fig.transFigure, color=B["line"], linewidth=1.0))
    fig.text(0.048, 0.040, os.environ.get("ORACLE_CARD_FOOTER", "oracle.demi.la"), color=B["blue2"], fontsize=6.8, fontfamily=fonts["mono"], fontweight="bold", va="center")
    tag = fig.text(0.500, 0.040, "T H E   F U T U R E   I S   A G E N T I C", color=B["blue2"], fontsize=6.8, fontfamily=fonts["display"], ha="center", va="center")
    tag.set_path_effects([pe.withStroke(linewidth=2.2, foreground=B["glow"], alpha=0.6)])
    right = f"{_esc(footer)} · " if footer else ""
    fig.text(0.952, 0.040, right + datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"), color=B["mute"], fontsize=5.6, fontfamily=fonts["mono"], ha="right", va="center")


def render_pnl_card(presentation: dict, output: str, *, synthetic: bool = False):
    """Canonical renderer hook for an already scoped pnlPresentation model.

    No fetching, accounting, authorization or sending. Caller owns a private
    temporary output directory. synthetic is a test-only visible watermark.
    """
    import re
    from matplotlib.figure import Figure
    from matplotlib.backends.backend_agg import FigureCanvasAgg

    p = presentation if isinstance(presentation, dict) else {}
    state = p.get('state', 'unknown')
    if state not in ('positive', 'negative', 'neutral', 'unknown', 'hidden'):
        state = 'unknown'
    hidden = state == 'hidden'
    def clean(value, fallback='UNKNOWN'):
        return re.sub(r'[\x00-\x1f\x7f]', ' ', value)[:200] if isinstance(value, str) and value.strip() else fallback
    basis = p.get('basis')
    if basis not in ('Realized PnL', 'Unrealized PnL', 'PnL · basis UNKNOWN'):
        basis = 'PnL · basis UNKNOWN'
    amount = 'HIDDEN' if hidden else 'UNKNOWN' if state == 'unknown' else clean(p.get('amount'))
    coverage = 'Hidden' if hidden else 'Complete' if p.get('coverage') == 'Complete' and state != 'unknown' else 'Incomplete'
    fees = 'Hidden' if hidden else 'Fee-inclusive' if p.get('fees') == 'Fee-inclusive' and coverage == 'Complete' else 'Fees incomplete / not confirmed included'
    source = 'Hidden' if hidden else clean(p.get('source'))
    as_of = 'Hidden' if hidden else clean(p.get('asOf'))
    fonts = _init_fonts()
    fig = Figure(figsize=(11.2, 6.3), dpi=160, facecolor=ORACLE['bg'])
    FigureCanvasAgg(fig)
    _bg(fig)
    logo = _load_logo(ORACLE_MARK)
    if logo is not None:
        ax = fig.add_axes([0.055, 0.82, 0.075, 0.12])
        ax.imshow(logo)
        ax.axis('off')
    fig.text(0.15, 0.88, 'ORACLE', color=ORACLE['blue2'], fontproperties=_oracle_display_fp(25), va='center', parse_math=False)
    fig.text(0.94, 0.88, coverage, color=ORACLE['mute'], fontfamily=fonts['mono'], fontsize=10, ha='right', parse_math=False)
    color = ORACLE['down'] if state == 'negative' else ORACLE['blue'] if state == 'positive' else ORACLE['mute']
    def line(y, value, size=11, ink=None, numeric=False):
        artist = fig.text(0.06, y, value, color=ink or ORACLE['mute'], fontproperties=_oracle_num_fp(size) if numeric else None, fontfamily=fonts['mono'], fontsize=size, parse_math=False)
        # Measure actual glyphs rather than truncate exact ledger amounts.
        fig.canvas.draw()
        limit = fig.bbox.width * 0.88
        width = artist.get_window_extent(fig.canvas.get_renderer()).width
        if width > limit:
            artist.set_fontsize(size * limit / width)
        return artist
    line(0.70, basis, 13)
    line(0.54, amount, 30, color, True)
    line(0.44, state.upper(), 11, color)
    line(0.31, fees)
    line(0.23, 'Source: ' + source)
    line(0.15, 'As of: ' + as_of)
    line(0.055, 'SYNTHETIC SAMPLE - NOT LIVE' if synthetic else 'ORACLE · PnL evidence · not an execution receipt', 9)
    try:
        fig.savefig(output, format='png', facecolor=ORACLE['bg'], metadata={'Software': 'Oracle'})
        return str(output)
    finally:
        fig.clear()


def _bg(fig):
    try:
        import numpy as np

        h, w = 90, 160
        y, x = np.mgrid[0:h, 0:w].astype(float)
        y /= h - 1
        x /= w - 1
        base = np.zeros((h, w, 3))
        base[..., 0], base[..., 1], base[..., 2] = 0x0B / 255, 0x10 / 255, 0x18 / 255
        d = ((x - 0.78) ** 2 * 1.3 + (y + 0.1) ** 2) ** 0.5
        g = np.clip(1 - d / 0.9, 0, 1) ** 2 * 0.16
        base[..., 0] += g * (124 / 255)
        base[..., 1] += g * (196 / 255)
        base[..., 2] += g * (255 / 255)
        ax = fig.add_axes([0, 0, 1, 1], zorder=-10)
        ax.imshow(np.clip(base, 0, 1), aspect="auto", interpolation="bilinear")
        ax.set_xticks([])
        ax.set_yticks([])
        for sp in ax.spines.values():
            sp.set_visible(False)
    except Exception:
        pass


def render_spot_card(
    *,
    symbol: str,
    venue: str = "robinhood",
    hero: str | None = None,
    chip: str | None = None,
    mid: str | None = None,
    sub: str | None = None,
    reason: str = "OHLCV UNAVAILABLE",
    out_path: str | None = None,
) -> str | None:
    """Oracle chrome with live stats but no fake candles.

    Used when Gecko OHLCV 429s. Honest: no data, no fake lines.
    """
    import matplotlib

    matplotlib.use("Agg")
    import matplotlib.pyplot as plt

    fonts = _init_fonts()
    B = ORACLE
    fig = plt.figure(figsize=(11.2, 6.3), dpi=160, facecolor=B["bg"])
    _bg(fig)
    ax = fig.add_axes([0.075, 0.135, 0.875, 0.68])
    ax.set_facecolor(B["panel"])
    ax.set_xticks([])
    ax.set_yticks([])
    for sp in ax.spines.values():
        sp.set_visible(False)
    sym = str(symbol or "TOKEN").lstrip("$").upper()
    ax.text(0.06, 0.72, f"${sym}", color=B["ink"], fontsize=28, fontfamily=fonts["display"], fontweight="medium", transform=ax.transAxes)
    if mid:
        ax.text(0.06, 0.48, mid, color=B["blue2"], fontsize=18, fontfamily=fonts["display"], transform=ax.transAxes)
    if sub:
        ax.text(0.06, 0.30, sub, color=B["mute"], fontsize=12, fontfamily=fonts["mono"], transform=ax.transAxes)
    ax.text(0.06, 0.12, reason, color=B["mute"], fontsize=8.5, fontfamily=fonts["mono"], transform=ax.transAxes)
    _chrome(
        fig,
        venue=venue,
        eyebrow=f"{sym}  ·  SPOT",
        hero=hero or "",
        hero_color=B["blue2"],
        chip=chip or "",
        chip_bg=B["blue"],
        footer=reason,
    )
    dest = out_path or str(Path(tempfile.gettempdir()) / f"oracle-spot-{sym[:12]}.png")
    fig.savefig(dest, facecolor=fig.get_facecolor())
    plt.close(fig)
    return dest if Path(dest).exists() else None


def render_ohlc_card(
    rows: list[dict],
    *,
    venue: str,
    symbol: str,
    out_path: str | None = None,
    source: str = "LIVE",
    trades: list[dict] | None = None,
    avg_entry: float | None = None,
    targets: list[float] | None = None,
    stop: float | None = None,
    setup: str | None = None,
    pair: str | None = None,
    market: str | None = None,
    hero: str | None = None,
    chip: str | None = None,
    chip_bg: str | None = None,
    edge_px: float | None = None,
    holders_delta_24h: int | None = None,
    holders_now: int | None = None,
) -> str | None:
    """OHLC card with optional position overlays.

    trades: [{"t": ms|s, "px": float, "side": "buy"|"sell"}] — entry/exit markers.
    avg_entry: dashed line at position average.
    targets: sell-point / take-profit lines (TP1, TP2, ...).
    stop: stop-loss line.
    setup: one-line trade setup note, rendered with the TA chips.
    pair: full trading pair, e.g. "BTC-USD" or "HYPE/USDC".
    market: market type — "PERP", "SPOT", "HIP-3", ... shown next to the pair.
    hero: override the top-right hero text (e.g. "PNL +$132") — price when None.
    chip: override the chip text (e.g. "MC $866.8K · -55.8%") — 24h % when None.
    chip_bg: override chip color; defaults to up/down by 24h change.
    """
    if not rows or len(rows) < 3:
        return None
    import matplotlib

    matplotlib.use("Agg")
    import matplotlib.dates as mdates
    import matplotlib.pyplot as plt
    from matplotlib.patches import Rectangle

    fonts = _init_fonts()
    B = ORACLE
    xs = [datetime.fromtimestamp(int(r["t"]) / (1000 if int(r["t"]) > 1e12 else 1), tz=timezone.utc) for r in rows]
    opens = [float(r["o"]) for r in rows]
    highs = [float(r["h"]) for r in rows]
    lows = [float(r["l"]) for r in rows]
    closes = [float(r["c"]) for r in rows]
    vols = [float(r.get("v") or 0) for r in rows]
    last_c = closes[-1] if closes else 0.0
    if last_c > 0:
        keep = [i for i in range(len(closes)) if 0.4 * last_c <= closes[i] <= 2.5 * last_c]
        if 8 <= len(keep) < len(closes):
            xs = [xs[i] for i in keep]
            opens = [opens[i] for i in keep]
            highs = [highs[i] for i in keep]
            lows = [lows[i] for i in keep]
            closes = [closes[i] for i in keep]
            vols = [vols[i] for i in keep]
            rows = [rows[i] for i in keep]
    fig = plt.figure(figsize=(11.2, 6.3), dpi=160, facecolor=B["bg"])
    _bg(fig)
    ax = fig.add_axes([0.075, 0.195, 0.875, 0.575])
    axv = fig.add_axes([0.075, 0.115, 0.875, 0.078], sharex=ax)
    for a in (ax, axv):
        a.set_facecolor(B["panel"])
        a.tick_params(colors=B["mute"], labelsize=7.0)
        for lbl in a.get_xticklabels() + a.get_yticklabels():
            lbl.set_fontfamily(fonts["mono"])
        for sp in a.spines.values():
            sp.set_visible(False)
        a.grid(color="#152031", linewidth=0.6)
        a.set_axisbelow(True)
    plt.setp(ax.get_xticklabels(), visible=False)
    xnum = mdates.date2num(xs)
    deltas = [xnum[i] - xnum[i - 1] for i in range(1, len(xnum)) if xnum[i] > xnum[i - 1]]
    med = sorted(deltas)[len(deltas) // 2] if deltas else (15.0 / 1440.0)
    wide = min(max(med * 0.55, 4.0 / 1440.0), 18.0 / 1440.0)
    last_px = closes[-1] if closes else 0.0
    clip_wicks = last_px >= 20
    for i in range(len(rows)):
        o, h, l, c = opens[i], highs[i], lows[i], closes[i]
        if clip_wicks:
            body_hi, body_lo = max(o, c), min(o, c)
            cap = max(abs(c) * 0.012, (body_hi - body_lo) * 2.5, 0.15)
            h = min(h, body_hi + cap)
            l = max(l, body_lo - cap)
        up = c >= o
        body_c = B["up"] if up else B["down"]
        wick_c = B["up_wick"] if up else B["down_wick"]
        ax.plot([xnum[i], xnum[i]], [l, h], color=wick_c, linewidth=0.85, zorder=2, solid_capstyle="round")
        body_lo = min(o, c)
        body_h = abs(c - o) or (h - l) * 0.04 or abs(c) * 1e-4
        ax.add_patch(Rectangle((xnum[i] - wide / 2, body_lo), wide, body_h, facecolor=body_c, edgecolor=body_c, linewidth=0.4, zorder=3))
        axv.bar(xnum[i], vols[i], width=wide, color=body_c, alpha=0.42, zorder=2)
    last = closes[-1]
    ax.axhline(last, color=B["blue"], linewidth=0.8, alpha=0.35, linestyle=(0, (4, 3)), zorder=4)

    edge_pins = {"top": 0, "bottom": 0}

    def _pxline(y, color, label):
        # Levels inside (or near) the price window draw as lines; far-away
        # levels pin to the panel edge so they never squash the candles.
        lo_v, hi_v = ax.get_ylim()
        if lo_v <= y <= hi_v:
            ax.axhline(y, color=color, linewidth=0.9, alpha=0.75, linestyle=(0, (5, 3)), zorder=4)
            ax.annotate(
                f"{label} {_fmt_px(y)}", xy=(1.0, y), xycoords=("axes fraction", "data"),
                xytext=(-4, 3), textcoords="offset points", va="bottom", ha="right",
                fontsize=6.4, fontfamily=fonts["mono"], fontweight="bold", color=color, zorder=6,
            )
        else:
            above = y > hi_v
            slot = edge_pins["top" if above else "bottom"]
            edge_pins["top" if above else "bottom"] += 1
            off = 4 + slot * 11  # stack multiple out-of-range labels
            ax.annotate(
                f"{label} {_fmt_px(y)} {'↑' if above else '↓'}",
                xy=(1.0, 1.0 if above else 0.0), xycoords="axes fraction",
                xytext=(-4, -off if above else off), textcoords="offset points",
                va="top" if above else "bottom", ha="right",
                fontsize=6.4, fontfamily=fonts["mono"], fontweight="bold", color=color, zorder=6,
            )

    # Zoom to the candles: the 24h range owns the panel. Overlay levels never
    # stretch the scale (that's what made candles tiny); they clamp instead.
    lo_c, hi_c = min(lows), max(highs)
    pad_c = (hi_c - lo_c) * 0.08 or max(abs(hi_c) * 1e-4, 1e-9)
    ax.set_ylim(lo_c - pad_c, hi_c + pad_c)
    if avg_entry is not None:
        _pxline(avg_entry, "#FFD166", "AVG")
    for i_t, tp in enumerate(targets or [], start=1):
        _pxline(tp, "#7DF9B9", f"TP{i_t}" if len(targets or []) > 1 else "SELL")
    if stop is not None:
        _pxline(stop, B["down"], "STOP")
    # Entry/exit markers on the candles.
    for tr in trades or []:
        try:
            tt = int(tr.get("t"))
            if tt > 1e12:
                tt /= 1000
            tx = mdates.date2num(datetime.fromtimestamp(tt, tz=timezone.utc))
            tpx = float(tr.get("px"))
            buy = str(tr.get("side", "buy")).lower().startswith("b")
            ax.scatter(
                [tx], [tpx], marker="^" if buy else "v", s=64,
                color="#7DF9B9" if buy else B["down"], edgecolors=B["bg"],
                linewidths=0.8, zorder=7,
            )
            ax.annotate(
                "ENTRY" if buy else "EXIT", xy=(tx, tpx),
                xytext=(0, 10 if buy else -14), textcoords="offset points",
                ha="center", fontsize=5.6, fontfamily=fonts["mono"], fontweight="bold",
                color="#7DF9B9" if buy else B["down"], zorder=7,
            )
        except Exception:
            continue
    ax.annotate(
        _esc(_fmt_px(edge_px if edge_px is not None else last)), xy=(1.0, last), xycoords=("axes fraction", "data"),
        xytext=(4, 0), textcoords="offset points", va="center", ha="left",
        fontsize=7.0, fontfamily=fonts["mono"], fontweight="bold", color=B["chip_ink"],
        bbox={"boxstyle": "round,pad=0.30", "facecolor": B["blue2"], "edgecolor": "none"}, zorder=6, annotation_clip=False,
    )
    ta = spot_ta(rows)
    chips = ([f"SETUP: {setup}"] if setup else []) + ta
    if chips:
        ax.text(
            0.01, 0.97, "  ·  ".join(chips), transform=ax.transAxes, ha="left", va="top",
            fontsize=7.2, fontfamily=fonts["mono"], color=B["blue2"],
            bbox={"boxstyle": "round,pad=0.32", "facecolor": "#0B1018", "edgecolor": B["blue"], "linewidth": 0.8, "alpha": 0.9},
            zorder=10,
        )
    axv.xaxis_date()
    axv.xaxis.set_major_formatter(mdates.DateFormatter("%m-%d %H:%M"))
    axv.set_yticks([])
    if last >= 1:
        ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda v, _p: f"{v:,.2f}"))
    # 24h % gain: change vs the close nearest 24h before the last bar.
    last_ts = xs[-1].timestamp()
    base = None
    for i in range(len(xs)):
        if xs[i].timestamp() <= last_ts - 24 * 3600:
            base = closes[i]
    if base is None:
        base = closes[0]
    chg = (closes[-1] / base - 1) * 100 if base > 0 else 0
    if last >= 20 and abs(chg) > 80:
        chg = 0.0
    if chip is None:
        bits = []
        if not (last >= 20 and abs((closes[-1] / base - 1) * 100 if base > 0 else 0) > 80):
            bits.append(f"{chg:+.2f}% 24H")
        elif chg != 0:
            bits.append(f"{chg:+.2f}% 24H")
        else:
            bits.append("24H n/a")
        if holders_delta_24h is not None:
            bits.append(f"HLD {int(holders_delta_24h):+d}/24H")
        elif holders_now is not None:
            bits.append(f"HLD {int(holders_now)}")
        chip = " · ".join(bits)
    _chip_bg = chip_bg or (B["up"] if chg >= 0 else B["down"])
    _chrome(
        fig, venue=venue, eyebrow="",
        hero=hero if hero is not None else f"{_fmt_px(last)}", hero_color=B["blue2"],
        chip=chip,
        chip_bg=_chip_bg,
        footer=f"{source} · CANDLES · {chain_meta(venue)['label']}",
        pair_label=str(pair or "").upper(),
    )
    out = out_path or str(Path(tempfile.gettempdir()) / f"oracle-card-{venue}-{symbol}.png")
    fig.savefig(out, facecolor=fig.get_facecolor(), bbox_inches="tight", pad_inches=0.08)
    plt.close(fig)
    return out if os.path.exists(out) and os.path.getsize(out) > 0 else None


def render_yesno_card(
    yes_series: list[dict],
    no_series: list[dict] | None = None,
    *,
    venue: str,
    title: str,
    yes: float | None = None,
    no: float | None = None,
    volume: float | None = None,
    out_path: str | None = None,
    source: str = "LIVE",
) -> str | None:
    """Polymarket / HIP-4: two-line YES/NO price graph. Not candles."""
    import matplotlib

    matplotlib.use("Agg")
    import matplotlib.dates as mdates
    import matplotlib.pyplot as plt

    fonts = _init_fonts()
    B = ORACLE
    def series(rows):
        xs, ys = [], []
        for r in rows or []:
            t = r.get("t") or r.get("time")
            p = r.get("p") if r.get("p") is not None else r.get("c")
            if t is None or p is None:
                continue
            t = int(t)
            if t > 1e12:
                t = t / 1000
            xs.append(datetime.fromtimestamp(t, tz=timezone.utc))
            ys.append(float(p) * (100.0 if float(p) <= 1.5 else 1.0))
        return xs, ys

    yx, yy = series(yes_series)
    nx, ny = series(no_series)
    if not yy and yes is not None:
        now = datetime.now(timezone.utc)
        yx, yy = [now], [(yes * 100 if yes <= 1.5 else yes)]
    if not ny:
        if no is not None:
            nx, ny = yx[:], [(no * 100 if no <= 1.5 else no)] * len(yx or [0])
        elif yy:
            nx, ny = yx, [100.0 - v for v in yy]
    if not yy:
        return None
    fig = plt.figure(figsize=(11.2, 6.3), dpi=160, facecolor=B["bg"])
    _bg(fig)
    ax = fig.add_axes([0.075, 0.125, 0.875, 0.655])
    ax.set_facecolor(B["panel"])
    ax.tick_params(colors=B["mute"], labelsize=7.0)
    for lbl in ax.get_xticklabels() + ax.get_yticklabels():
        lbl.set_fontfamily(fonts["mono"])
    for sp in ax.spines.values():
        sp.set_visible(False)
    ax.grid(color="#152031", linewidth=0.5, alpha=0.7)
    ax.set_axisbelow(True)
    ax.plot(yx, yy, color=B["yes"], linewidth=2.0, label="YES", zorder=3, solid_capstyle="round")
    if ny and nx:
        ax.plot(nx, ny, color=B["no"], linewidth=2.0, label="NO", zorder=3, solid_capstyle="round")
    last = yy[-1]
    ax.set_ylim(0, 100)
    ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda v, _p: f"{v:.0f}¢"))
    ax.xaxis.set_major_formatter(mdates.DateFormatter("%m-%d"))
    ax.legend(loc="upper left", frameon=False, labelcolor=B["ink"], fontsize=8, prop={"family": fonts["mono"]})
    last = yy[-1]
    chg_24 = None
    if yx and len(yx) >= 2:
        cutoff = yx[-1].timestamp() - 24 * 3600
        older = [yy[i] for i, t in enumerate(yx) if t.timestamp() <= cutoff]
        if older and older[-1] > 0:
            chg_24 = (last / older[-1] - 1.0) * 100.0
    chip = f"{chg_24:+.1f}%  24H" if chg_24 is not None else ""
    _chrome(
        fig, venue=venue, eyebrow=title[:52],
        hero=f"{last:.1f}¢", hero_color=B["blue2"],
        chip=chip, chip_bg=B["blue"],
        footer=f"{source} · YES / NO",
    )
    out = out_path or str(Path(tempfile.gettempdir()) / f"oracle-yesno-{venue}.png")
    fig.savefig(out, facecolor=fig.get_facecolor(), bbox_inches="tight", pad_inches=0.08)
    plt.close(fig)
    return out if os.path.exists(out) and os.path.getsize(out) > 0 else None


def render_stat_card(
    *,
    venue: str,
    title: str,
    hero: str,
    hero_color: str | None = None,
    chip: str = "",
    stats: list[tuple[str, str]] | None = None,
    rows: list[list[str]] | None = None,
    row_header: list[str] | None = None,
    note: str | None = None,
    out_path: str | None = None,
    source: str = "LIVE",
    kind: str = "stat",
) -> str | None:
    """Generic Oracle stat card: hero + stat tiles + optional table.

    stats: [(label, value)] tiles across the top of the panel (max 8).
    rows/row_header: optional table (e.g. vault list, trait floors, top offers).
    Backs the DeFi yield and NFT surfaces — anything card-worthy without candles.
    """
    import matplotlib

    matplotlib.use("Agg")
    import matplotlib.pyplot as plt

    fonts = _init_fonts()
    B = ORACLE
    fig = plt.figure(figsize=(11.2, 6.3), dpi=160, facecolor=B["bg"])
    _bg(fig)
    ax = fig.add_axes([0.048, 0.105, 0.904, 0.685])
    ax.set_facecolor(B["panel"])
    ax.set_xticks([])
    ax.set_yticks([])
    ax.set_xlim(0, 1)
    ax.set_ylim(0, 1)
    for sp in ax.spines.values():
        sp.set_color(B["line"])
        sp.set_linewidth(1.0)
    tiles = (stats or [])[:8]
    if tiles:
        per_row = 4 if len(tiles) > 3 else max(len(tiles), 1)
        tile_w = 1.0 / per_row
        n_tile_rows = (len(tiles) + per_row - 1) // per_row
        tile_h = 0.30 if n_tile_rows > 1 else 0.34
        for i, (lab, val) in enumerate(tiles):
            cx = (i % per_row) * tile_w + tile_w / 2
            cy = 0.985 - (i // per_row) * tile_h
            ax.text(cx, cy - 0.055, str(lab).upper(), ha="center", va="top", fontsize=7.0, fontfamily=fonts["mono"], color=B["mute"])
            np_ = None
            try:
                np_ = _oracle_num_fp(15)
            except Exception:
                np_ = None
            ax.text(
                cx, cy - 0.115, _esc(str(val)), ha="center", va="top",
                fontsize=15, fontfamily=fonts["mono"], fontweight="bold",
                fontproperties=np_, color=B["ink"], parse_math=False,
            )
        table_top = 0.985 - n_tile_rows * tile_h - 0.03
    else:
        table_top = 0.95
    if rows:
        cols = max(len(r) for r in rows) if rows else 0
        col_x = [0.03 + c * (0.94 / max(cols - 1, 1)) for c in range(cols)] if cols > 1 else [0.03]
        y = table_top
        if row_header:
            for c, htxt in enumerate(row_header[:cols]):
                ax.text(col_x[c], y, str(htxt).upper(), ha="left" if c == 0 else "right", va="top", fontsize=6.6, fontfamily=fonts["mono"], color=B["blue"])
            y -= 0.075
        for r in rows[:7]:
            for c, cell in enumerate(r[:cols]):
                ax.text(col_x[c], y, _esc(str(cell)), ha="left" if c == 0 else "right", va="top", fontsize=8.2, fontfamily=fonts["mono"], color=B["ink"])
            y -= 0.082
    if note:
        ax.text(0.03, 0.035, _esc(note), ha="left", va="bottom", fontsize=6.6, fontfamily=fonts["sans"], color=B["mute"])
    _chrome(
        fig, venue=venue, eyebrow=title[:60],
        hero=hero, hero_color=hero_color or B["blue2"],
        chip=chip, chip_bg=B["blue"],
        footer=source,
    )
    out = out_path or str(Path(tempfile.gettempdir()) / f"oracle-{kind}-{venue}.png")
    fig.savefig(out, facecolor=fig.get_facecolor(), bbox_inches="tight", pad_inches=0.08)
    plt.close(fig)
    return out if os.path.exists(out) and os.path.getsize(out) > 0 else None


def render_yield_card(
    *,
    venue: str,
    title: str,
    vaults: list[dict],
    out_path: str | None = None,
    source: str = "LIVE",
) -> str | None:
    """DeFi yield card. vaults: [{name, apy, tvl, chain?, note?}] sorted best-first."""
    if not vaults:
        return None
    best = vaults[0]
    rows = []
    for v in vaults[:7]:
        rows.append([
            str(v.get("name", "?"))[:28],
            str(v.get("chain", "") or "").upper(),
            f"{float(v['apy']):.2f}%" if v.get("apy") is not None else "UNKNOWN",
            f"${float(v['tvl']):,.0f}" if v.get("tvl") is not None else "UNKNOWN",
        ])
    return render_stat_card(
        venue=venue, title=title,
        hero=f"{float(best['apy']):.2f}% APY" if best.get("apy") is not None else "UNKNOWN",
        hero_color=ORACLE["up"],
        chip=f"TOP: {str(best.get('name', ''))[:24]}",
        rows=rows, row_header=["VAULT", "CHAIN", "APY", "TVL"],
        note="APY is variable. Live read, not a promise.",
        out_path=out_path, source=source, kind="yield",
    )


def render_nft_card(
    *,
    venue: str,
    collection: str,
    floor: float | None = None,
    currency: str = "ETH",
    floor_chg_24h: float | None = None,
    volume_24h: float | None = None,
    sales_24h: int | None = None,
    listed: int | None = None,
    listed_plus: bool = False,
    supply: int | None = None,
    owners: int | None = None,
    top_offer: float | None = None,
    traits: list[list[str]] | None = None,
    floor_series: list[dict] | None = None,
    image: str | None = None,
    note: str | None = None,
    out_path: str | None = None,
    source: str = "LIVE",
) -> str | None:
    """Premium NFT card: NFT image panel + floor-price chart + stat strip.

    floor_series: [{"t": ms|s, "p"|"c": float}] floor history → line chart.
    image: local path or http(s) URL of the NFT / collection image.
    Falls back to the stat-tile layout when neither image nor series exists.
    """
    if not floor_series and not image:
        fmt = lambda v: f"{_fmt_px(v)} {currency}" if v is not None else "UNKNOWN"
        stats = [
            ("floor", fmt(floor)),
            ("top offer", fmt(top_offer)),
            ("24h vol", fmt(volume_24h)),
            ("24h sales", f"{sales_24h:,}" if sales_24h is not None else "UNKNOWN"),
            ("listed", f"{listed:,}" if listed is not None else "UNKNOWN"),
            ("supply", f"{supply:,}" if supply is not None else "UNKNOWN"),
            ("owners", f"{owners:,}" if owners is not None else "UNKNOWN"),
            ("listed %", f"{listed / supply * 100:.1f}%" if listed and supply else "UNKNOWN"),
        ]
        chip = f"{floor_chg_24h:+.1f}% FLOOR 24H" if floor_chg_24h is not None else ""
        hero_color = ORACLE["up"] if (floor_chg_24h or 0) >= 0 else ORACLE["down"]
        return render_stat_card(
            venue=venue, title=collection,
            hero=fmt(floor), hero_color=hero_color, chip=chip,
            stats=stats,
            rows=traits, row_header=["TRAIT", "FLOOR", "LISTED"] if traits else None,
            note=note, out_path=out_path, source=source, kind="nft",
        )

    import matplotlib

    matplotlib.use("Agg")
    import matplotlib.dates as mdates
    import matplotlib.pyplot as plt

    fonts = _init_fonts()
    B = ORACLE
    fmt = lambda v: f"{_fmt_px(v)} {currency}" if v is not None else "UNKNOWN"
    fig = plt.figure(figsize=(11.2, 6.3), dpi=160, facecolor=B["bg"])
    _bg(fig)

    img_obj = None
    if image:
        try:
            src = image
            if str(image).startswith(("http://", "https://")):
                import urllib.request

                tmp_img = Path(tempfile.gettempdir()) / f"oracle-nft-img-{abs(hash(image))}.bin"
                if not tmp_img.exists():
                    req = urllib.request.Request(str(image), headers={
                        "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36",
                        "accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
                    })
                    tmp_img.write_bytes(urllib.request.urlopen(req, timeout=15).read())
                src = str(tmp_img)
            from PIL import Image

            raw = Path(src).read_bytes()[:200]
            if src.lower().endswith(".svg") or raw.lstrip().startswith(b"<svg") or b"<svg" in raw[:80]:
                png = Path(src).with_suffix(".png")
                import subprocess
                subprocess.run(
                    ["rsvg-convert", "-w", "720", "-h", "720", "-o", str(png), src],
                    check=True, timeout=12, capture_output=True,
                )
                src = str(png)
            img_obj = Image.open(src).convert("RGB")
        except Exception:
            img_obj = None

    # Left: NFT image, flush under the collection name. Right: floor chart.
    chart_left = 0.075
    panel_y, panel_h = 0.228, 0.538
    if img_obj is not None:
        iax = fig.add_axes([0.048, panel_y, 0.248, panel_h], zorder=5)
        iax.imshow(img_obj)
        iax.set_xticks([])
        iax.set_yticks([])
        for sp in iax.spines.values():
            sp.set_color(B["blue"])
            sp.set_linewidth(1.15)
            sp.set_alpha(0.55)
        chart_left = 0.318
    ax = fig.add_axes([chart_left, panel_y, 0.952 - chart_left, panel_h])
    ax.set_facecolor(B["panel"])
    ax.tick_params(colors=B["mute"], labelsize=6.8)
    for lbl in ax.get_xticklabels() + ax.get_yticklabels():
        lbl.set_fontfamily(fonts["mono"])
    for sp in ax.spines.values():
        sp.set_visible(False)
    ax.grid(color="#152031", linewidth=0.5, alpha=0.7)
    ax.set_axisbelow(True)
    fx, fy = [], []
    for r in floor_series or []:
        t = r.get("t") or r.get("time")
        p = r.get("p") if r.get("p") is not None else r.get("c")
        if t is None or p is None:
            continue
        t = int(t)
        if t > 1e12:
            t /= 1000
        fx.append(datetime.fromtimestamp(t, tz=timezone.utc))
        fy.append(float(p))
    if fx:
        # Clamp the y-range to the recent floor action. A single ancient spike
        # (mint-day 1.9 ETH vs 0.0125 today) must not flatten the whole line —
        # same law as the candle window. Focus on the last 60% of samples plus
        # padding; the full series still draws, the axis just doesn't chase
        # outliers.
        tail = fy[max(0, int(len(fy) * 0.4)):]
        lo, hi = min(tail), max(tail)
        span = (hi - lo) or (hi * 0.1) or 1e-9
        pad = span * 0.18
        ax.plot(fx, fy, color=B["blue"], linewidth=2.0, zorder=3, solid_capstyle="round")
        ax.fill_between(fx, fy, lo - pad, alpha=0.10, color=B["blue"], zorder=2)
        ax.set_ylim(lo - pad, hi + pad)
        if max(fy) > hi + pad:
            ax.annotate(
                _esc(f"ATH {fmt(max(fy))}"), xy=(0.01, 0.99), xycoords="axes fraction",
                va="top", ha="left", fontsize=6.0, fontfamily=fonts["mono"], color=B["mute"],
            )
        ax.axhline(fy[-1], color=B["blue"], linewidth=0.7, alpha=0.35, linestyle=(0, (4, 3)))
        ax.annotate(
            _esc(fmt(fy[-1])), xy=(1.0, fy[-1]), xycoords=("axes fraction", "data"),
            xytext=(4, 0), textcoords="offset points", va="center", ha="left",
            fontsize=6.6, fontfamily=fonts["mono"], fontweight="bold", color=B["chip_ink"],
            bbox={"boxstyle": "round,pad=0.30", "facecolor": B["blue2"], "edgecolor": "none"}, zorder=6, annotation_clip=False,
        )
        ax.xaxis.set_major_formatter(mdates.DateFormatter("%m-%d"))
        ax.text(0.01, 0.97, "FLOOR", transform=ax.transAxes, ha="left", va="top", fontsize=6.6, fontfamily=fonts["mono"], color=B["mute"])
    else:
        ax.set_xticks([])
        ax.set_yticks([])
        ax.text(0.5, 0.5, "NO FLOOR HISTORY", ha="center", va="center", fontsize=9, fontfamily=fonts["mono"], color=B["mute"], transform=ax.transAxes)
    # Bottom stat strip.
    chg_txt = f"{floor_chg_24h:+.1f}%" if floor_chg_24h is not None else "UNKNOWN"
    if listed is None:
        listed_txt = "UNKNOWN"
    elif listed_plus and supply:
        listed_txt = f"{listed:,}+/{supply:,}"
    elif listed_plus:
        listed_txt = f"{listed:,}+"
    elif supply:
        listed_txt = f"{listed:,}/{supply:,}"
    else:
        listed_txt = f"{listed:,}"
    strip = [
        ("FLOOR", fmt(floor if floor is not None else (fy[-1] if fy else None))),
        ("24H %", chg_txt),
        ("TOP OFFER", fmt(top_offer)),
        ("24H VOL", fmt(volume_24h)),
        ("24H SALES", f"{sales_24h:,}" if sales_24h is not None else "UNKNOWN"),
        ("LISTED", listed_txt),
        ("HOLDERS", f"{owners:,}" if owners is not None else "UNKNOWN"),
    ]
    n = len(strip)
    for i, (lab, val) in enumerate(strip):
        cx = 0.048 + (0.904 / n) * (i + 0.5)
        col = B["ink"]
        if lab == "24H %" and floor_chg_24h is not None:
            col = B["up"] if floor_chg_24h >= 0 else B["down"]
        fig.text(cx, 0.185, lab, ha="center", va="center", fontsize=6.2, fontfamily=fonts["mono"], color=B["mute"])
        np_ = None
        try:
            np_ = _oracle_num_fp(12.5)
        except Exception:
            np_ = None
        fig.text(
            cx, 0.135, _esc(str(val)), ha="center", va="center",
            fontsize=12.5, fontfamily=fonts["mono"], fontweight="bold",
            fontproperties=np_, color=col, parse_math=False,
        )
    chip_bits = []
    if floor_chg_24h is not None:
        chip_bits.append(f"{floor_chg_24h:+.1f}% 24H")
    if sales_24h is not None:
        chip_bits.append(f"{sales_24h:,} SOLD")
    if owners is not None:
        chip_bits.append(f"{owners:,} HOLDERS")
    chip = " · ".join(chip_bits)
    chip_color = B["blue"]
    if floor_chg_24h is not None:
        chip_color = B["up"] if floor_chg_24h >= 0 else B["down"]
    hero_color = ORACLE["blue2"]
    _chrome(
        fig, venue=venue, eyebrow="",
        hero=fmt(floor if floor is not None else (fy[-1] if fy else None)),
        hero_color=hero_color, chip=chip, chip_bg=chip_color,
        footer=source,
    )
    # Collection name: Oracle display (Cormorant 700), ice-blue like IF/USD.
    name = collection[:44].upper()
    t = fig.text(
        0.048, 0.800, name, color=B["blue2"],
        fontproperties=_oracle_display_fp(22.5),
        va="center", ha="left", zorder=9,
    )
    try:
        from matplotlib import patheffects as pe
        t.set_path_effects([pe.withStroke(linewidth=2.4, foreground=B["glow"], alpha=0.55)])
    except Exception:
        pass
    out = out_path or str(Path(tempfile.gettempdir()) / f"oracle-nft-{venue}.png")
    fig.savefig(out, facecolor=fig.get_facecolor(), bbox_inches="tight", pad_inches=0.08)
    plt.close(fig)
    return out if os.path.exists(out) and os.path.getsize(out) > 0 else None


def render_price_card(*args, **kwargs):
    """Back-compat alias — prediction venues are yes/no lines."""
    yes = kwargs.get("yes")
    no = kwargs.get("no")
    return render_yesno_card(
        [], None,
        venue=kwargs.get("venue") or "polymarket",
        title=kwargs.get("title") or "MARKET",
        yes=yes, no=no, volume=kwargs.get("volume"),
        out_path=kwargs.get("out_path"),
    )


def format_card_markdown(*, venue: str, title: str, rows: list[tuple[str, str]], ta: list[str] | None = None) -> str:
    from telegram_rich import md_kv_table

    meta = chain_meta(venue)
    lines = [f"ORACLE  ·  {meta['label']}", title]
    body = list(rows)
    if ta:
        body.append(("TA", " · ".join(ta)))
    lines.append(md_kv_table(body))
    return "\n".join(lines)


def card_buttons(*, chart_url: str | None = None, trade_url: str | None = None, explorer_url: str | None = None) -> dict:
    """URL buttons work for every recipient. No execute callback here."""
    row = []
    if chart_url:
        row.append({"text": "Chart", "url": chart_url})
    if trade_url:
        row.append({"text": "Trade", "url": trade_url})
    if explorer_url:
        row.append({"text": "Explorer", "url": explorer_url})
    return {"inline_keyboard": [row]} if row else {"inline_keyboard": []}


def fetch_hl_candles(coin: str = "BTC", interval: str = "1h", n: int = 25) -> list[dict]:
    """Default window: 24h of hourly candles (25 bars so the 24h-ago baseline exists)."""
    import urllib.request

    end = int(datetime.now(timezone.utc).timestamp() * 1000)
    start = end - n * 3600 * 1000
    body = json.dumps({"type": "candleSnapshot", "req": {"coin": coin, "interval": interval, "startTime": start, "endTime": end}}).encode()
    req = urllib.request.Request(
        "https://api.hyperliquid.xyz/info",
        data=body,
        headers={"Content-Type": "application/json", "User-Agent": "oracle-card/1"},
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=20) as r:
        raw = json.loads(r.read().decode())
    out = []
    for c in raw or []:
        out.append({"t": int(c["t"]), "o": float(c["o"]), "h": float(c["h"]), "l": float(c["l"]), "c": float(c["c"]), "v": float(c.get("v") or 0)})
    return out


if __name__ == "__main__":
    dest = Path.home() / ".cache/oracle-test-tmp"
    dest.mkdir(parents=True, exist_ok=True)
    candles = fetch_hl_candles("BTC")
    last = candles[-1]["c"]
    demo_trades = [
        {"t": candles[len(candles) // 3]["t"], "px": candles[len(candles) // 3]["c"], "side": "buy"},
        {"t": candles[2 * len(candles) // 3]["t"], "px": candles[2 * len(candles) // 3]["c"], "side": "sell"},
    ]
    p1 = render_ohlc_card(
        candles, venue="hyperliquid", symbol="BTC",
        out_path=str(dest / "oracle-hl-btc.png"), source="HL /info candleSnapshot",
        trades=demo_trades, avg_entry=last * 0.997,
        targets=[last * 1.006, last * 1.012], stop=last * 0.991,
        setup="long pullback", pair="BTC-USD", market="PERP",
    )
    yes = json.loads(Path("/tmp/poly-yes.json").read_text()) if Path("/tmp/poly-yes.json").exists() else []
    no = json.loads(Path("/tmp/poly-no.json").read_text()) if Path("/tmp/poly-no.json").exists() else []
    p2 = render_yesno_card(yes, no, venue="polymarket", title="JD VANCE 2028", volume=None, out_path=str(dest / "oracle-poly-yesno.png"), source="CLOB prices-history")
    p3 = render_yield_card(
        venue="ethereum", title="STABLE YIELD SCAN",
        vaults=[
            {"name": "DEMO USDC Vault", "chain": "base", "apy": 12.4, "tvl": 41_200_000},
            {"name": "DEMO sUSDe Loop", "chain": "ethereum", "apy": 9.8, "tvl": 220_000_000},
            {"name": "DEMO GHO Stk", "chain": "ethereum", "apy": 7.1, "tvl": 96_000_000},
        ],
        out_path=str(dest / "oracle-yield.png"), source="DEMO DATA",
    )
    import random
    random.seed(7)
    now_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
    fs, v = [], 42.5
    for i in range(60):
        v = max(30.0, v + random.uniform(-0.8, 0.85))
        fs.append({"t": now_ms - (60 - i) * 6 * 3600 * 1000, "p": v})
    p4 = render_nft_card(
        venue="ethereum", collection="DEMO PUNKS #4156",
        floor=fs[-1]["p"], currency="ETH", floor_chg_24h=3.2, volume_24h=812.0,
        sales_24h=19, listed=412, supply=10000, owners=3801, top_offer=41.9,
        floor_series=fs, image=str(Path.home() / ".local/share/oracle-assets/oracle-globe.png"),
        out_path=str(dest / "oracle-nft.png"), source="DEMO DATA",
    )
    print(json.dumps({"hl": p1, "poly": p2, "yield": p3, "nft": p4, "ta": spot_ta(candles)}, indent=2))
