"""IGUANA — supervised external scanner entrypoint (XYZ macro index trend).

Port of senpi-skills/iguana (producer v1.0.1, config v1.0.0) to the v2
`scan(inputs, ctx)` contract. The daemon loop is gone — the runtime supervisor
calls scan() every interval_seconds (300s).

THE STRATEGY — the simplest possible XYZ equity exposure (ported VERBATIM; see
scoring.py for the pure trend/scoring functions):

  1. Whitelist — the two broad XYZ indices {xyz:SP500, xyz:XYZ100}. No
     stock-picking, no commodities, no pre-IPO.
  2. Per non-held index, fetch 4h candles (market_get_asset_data,
     candle_intervals ["4h"]) and compute a 4-day trend strength: the % change
     of the latest 4h close vs the close `trendLookbackBars` (24) bars ago.
  3. Pick the index with the highest |strength| above `minTrendPct` (1.5%).
  4. Score it: base 3, +2 if |strength| >= `strongTrendPct` (4.0%), +1 if volume
     is rising (> 15% over 6 bars). Emit only if score >= `minScore` (4).
  5. Direction = sign of the trend (LONG if up, SHORT if down).
  6. Emit AT MOST ONE signal per tick (one decision, two assets).

DESIGN CHOICE — emit best-only vs runtime-owns-slots. Turbine/spider emit ALL
gated candidates and let the runtime apply the slot ceiling. IGUANA is
deliberately different: it emits AT MOST ONE signal per tick (the single
strongest index trend). This is the strategy, not an oversight — iguana's whole
thesis is "two assets, ONE decision per tick," and the source producer picked the
single strongest trend and pushed exactly one signal. Keeping `slots: 1` in the
recipe matches the single-position thesis. We preserve the per-tick <=1 emission
ceiling on purpose.

Sizing — carried on each signal's data{} so the OPEN_POSITION rule action sizes
identically to the source:
  - marginPct = percent of withdrawable (0–100), 20.0. Dual-DEX equity is
    collapsed via max() (NOT sum()) — one cross-margined wallet, two sub-DEX
    views; summing double-counts free balance -> 2x sizing.
  - leverage = min(config leverage 3, MAX_LEVERAGE 5).

FLAGS — behaviors that changed under the v2 contract (NOT silent drops):

  1. Daemon loop / producer_daemon — DROPPED. The runtime supervises this module
     and calls scan() each interval_seconds. scan() is single-pass and SYNC.

  2. push_signal / ingest POST — DROPPED. scan() returns a plain list[dict]; the
     scaffold owns delivery, dedup (signal_id), and the wire envelope. The
     source's [0,1] normalized wire score (min(score/6, 1.0)) is NOT recomputed
     here — the raw additive score rides on data{} and the scaffold/runtime own
     wire scoring. (SCORE_NORMALIZATION_DIVISOR is kept in scoring.py for parity.)

  3. recent-signals.json 240s dedup — MOVED to ctx.state. The per-coin 240s
     recent-signal dedup that the source kept in a JSON file lives in ctx.state
     now: each tick reads the latest {coin: ts} map, prunes by the source's
     window (4 × TTL), skips coins signaled within TTL, stamps the chosen
     candidate, and appends the updated map for next tick.

  4. ONLY read-only MCP calls. scan() never opens/closes/cancels — it produces
     signals. (A test asserts no create/close/cancel tool is ever called.)

MCP tools used (all read-only): market_get_asset_data (4h candles),
strategy_get_clearinghouse_state.

State (in ctx.state, newest record wins): {"recent": {COIN: epoch_seconds}}.
"""

import sys
import time

import scoring

RECENT_SIGNAL_TTL_SEC = 240    # source iguana_config.RECENT_SIGNAL_TTL_SEC


# ── MCP data fetchers (route the producer's calls through ctx.senpi_mcp) ──

def _fetch_candles(ctx, asset):
    """market_get_asset_data(4h) -> list of 4h candle dicts (or []).

    Verbatim parse from iguana-producer.fetch_candles: honors the `success`
    flag and digs into data.candles["4h"].
    """
    try:
        data = ctx.senpi_mcp.call_tool("market_get_asset_data", {
            "asset": asset,
            "candle_intervals": ["4h"],
            "include_funding": False,
            "include_order_book": False,
        })
    except Exception:
        return []
    if not data or (isinstance(data, dict) and not data.get("success", True)):
        return []
    d = data.get("data", data) if isinstance(data, dict) else data
    if not isinstance(d, dict):
        return []
    candles = d.get("candles", {}) or {}
    if not isinstance(candles, dict):
        return []
    return candles.get("4h", []) or []


def _get_account(ctx):
    """(account_value, held_assets) from strategy_get_clearinghouse_state.

    Dual-DEX equity collapse: account_value via max() across main/xyz sections
    (two views of ONE cross-margined wallet — summing double-counts free
    balance -> 2x sizing). Verbatim from iguana_config.get_positions.
    """
    try:
        ch = ctx.senpi_mcp.call_tool("strategy_get_clearinghouse_state",
                                     {"strategy_wallet": ctx.wallet})
    except Exception:
        return 0.0, []
    if not ch:
        return 0.0, []
    data = ch.get("data", ch) if isinstance(ch, dict) else ch
    if not isinstance(data, dict):
        return 0.0, []
    account_value = 0.0
    held = []
    for section in ("main", "xyz"):
        s = data.get(section, {})
        if not isinstance(s, dict):
            continue
        ms = s.get("marginSummary", {})
        account_value = max(account_value, scoring._f(ms, "accountValue"))
        for ap in s.get("assetPositions", []) or []:
            pos = ap.get("position", ap)
            if scoring._f(pos, "szi") == 0:
                continue
            coin = pos.get("coin", "")
            if coin:
                held.append(coin)
    return account_value, held


# ── ctx.state I/O (per-coin 240s recent-signal dedup) ──

def _load_recent(ctx):
    if ctx.state is None or len(ctx.state) == 0:
        return {}
    last = ctx.state.last() or {}
    recent = last.get("recent", {})
    return dict(recent) if isinstance(recent, dict) else {}


def _prune_recent(recent, now):
    """Drop entries older than the source window (4 × TTL). Verbatim from
    iguana_config._prune_recent_signals."""
    cutoff = now - (RECENT_SIGNAL_TTL_SEC * 4)
    return {k: v for k, v in recent.items() if v >= cutoff}


def _was_recently_signaled(recent, coin, now, ttl_sec=RECENT_SIGNAL_TTL_SEC):
    """True if `coin` was signaled within `ttl_sec`. Verbatim window logic from
    iguana_config.was_recently_signaled."""
    if not coin:
        return False
    last = recent.get(coin.upper())
    if last is None:
        return False
    return (now - last) < ttl_sec


def scan(inputs, ctx):
    now = time.time()
    whitelist = inputs.get("whitelist", scoring.DEFAULT_WHITELIST)
    lookback = int(inputs.get("trendLookbackBars", scoring.DEFAULT_TREND_LOOKBACK))
    min_pct = float(inputs.get("minTrendPct", scoring.DEFAULT_MIN_TREND_PCT))
    min_score = int(inputs.get("minScore", scoring.DEFAULT_MIN_SCORE))
    margin_pct = float(inputs.get("marginPct", scoring.DEFAULT_MARGIN_PCT))
    leverage = min(int(inputs.get("leverage", scoring.DEFAULT_LEVERAGE)),
                   scoring.MAX_LEVERAGE)

    account_value, held_assets = _get_account(ctx)
    if account_value <= 0:
        return []
    held_set = {h.upper() for h in held_assets}

    recent = _prune_recent(_load_recent(ctx), now)

    # config-shaped knobs for build_thesis (reads minTrendPct / strongTrendPct).
    thesis_config = {
        "minTrendPct": min_pct,
        "strongTrendPct": float(inputs.get("strongTrendPct",
                                           scoring.DEFAULT_STRONG_TREND_PCT)),
        "minScore": min_score,
    }

    # Compute 4d trend strength for each non-held, non-recently-signaled index.
    candles_by_asset = {}
    strength_by_asset = {}
    for asset in whitelist:
        if asset.upper() in held_set or _was_recently_signaled(recent, asset, now):
            continue
        candles = _fetch_candles(ctx, asset)
        if len(candles) <= lookback:
            continue
        closes = [scoring._f(c, "close", "c") for c in candles]
        candles_by_asset[asset] = candles
        strength_by_asset[asset] = scoring.trend_strength(closes, lookback)

    out = []
    picked = scoring.pick_strongest_trend(strength_by_asset, min_pct)
    if picked is not None:
        asset, strength = picked
        thesis = scoring.build_thesis(asset, strength, candles_by_asset[asset],
                                      thesis_config)
        if thesis is not None and thesis["score"] >= min_score:
            coin = thesis["coin"]
            if coin.upper() not in held_set:
                # Stamp the 240s recent-signal dedup so we don't re-emit.
                recent[coin.upper()] = now
                out.append({
                    "asset": coin,
                    "direction": thesis["direction"],
                    "marginPct": round(margin_pct * 100, 2),
                    "leverage": float(leverage),
                    "data": {
                        "score": thesis["score"],
                        "direction": thesis["direction"],
                        "reasons": thesis["reasons"],
                        "trendPct": thesis.get("trend_pct") or 0.0,
                        "volumeTrendPct": thesis.get("volume_trend_pct") or 0.0,
                        "heldAssets": held_assets,
                    },
                })

    # Persist the pruned + updated dedup map for next tick.
    # If this append fails (e.g. state_history_max_count is 0/unset so append
    # raises, or a transient persist error), the next tick reloads stale dedup
    # state and may re-emit already-suppressed signals. Log-and-continue: don't
    # crash the tick, but make the failure visible on stderr (the runtime
    # supervisor captures the scaffold child's stderr) instead of swallowing it.
    if ctx.state is not None:
        try:
            ctx.state.append({"recent": recent})
        except Exception as exc:
            print(
                f"[iguana.scan] WARNING: dedup-state append failed; next tick "
                f"may re-emit suppressed signals: {exc!r}",
                file=sys.stderr,
            )

    return out
