"""TURBINE — shared supervised external scanner entrypoint (volume + runners).

Port of senpi-skills/turbine (producer v3.2.2) to the v2 `scan(inputs, ctx)` contract. The
daemon loop is gone — the runtime supervisor calls scan() every interval_seconds.

ONE scanner, TWO recipes. Turbine's two legs (volume + runners) ran the IDENTICAL volume-
rotation alpha on the source; only the DSL exit preset and the per-slot sizing differed, and
those live entirely in the recipe YAMLs. So both recipe-volume.yaml and recipe-runners.yaml
point `path: ./scanners` + `entrypoint: scan.py` at THIS one module. The `inputs` block carries
the per-leg sizing (marginPct, leverage, slots) so the same code emits correctly-sized signals
for whichever leg supervises it.

What stayed faithful to the source (see scoring.py — ported verbatim):
  - Universe MAIN={BTC,ETH,SOL,HYPE} / XYZ={xyz:BRENTOIL,xyz:GOLD,xyz:SPX}.
  - Probabilistic pool pick (xyzWeight 0.80) + deterministic rotation index.
  - Direction = funding fade (crowded-long -> SHORT, crowded-short -> LONG, flat -> random).
  - Spread gate: main <= 3bps, xyz <= 10bps (from the top-of-book mid).
  - Skip held assets + 90s post-close cooldown.
  - Emit one signal per free slot (filled by rotating the index).
  - Per-wallet rotation index, prev-held set, and last-closed map: source kept these in JSON
    files; here they live in ctx.state.

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

  1. cancel_order stale-order sweep — DROPPED (was producer.sweep_stale_resting_orders).
     The source cancelled orphaned resting maker orders each tick. Those orphans were an
     artifact of the OLD daemon/helpers-migration era: a runtime swap left resting ALOs that
     the NEW runtime instance did not own, so they starved slots forever. Under v2 the
     SUPERVISED runtime owns its own order lifecycle end-to-end:
       - FEE_OPTIMIZED_LIMIT `execution_timeout_seconds` cancels the entry maker order the
         runtime itself placed if it doesn't fill in time, and
       - the DSL `hard_timeout` force-closes any position that does fill.
     There is no second uncoordinated runtime to leave orphans, so the sweep is redundant.
     A scanner's job is to PRODUCE SIGNALS, not to manage orders — so the cancel side-effect
     is intentionally not ported. (If, in operation, orphaned ALOs ever reappear, the right
     fix is a runtime-side reconcile, not a scanner side-effect.)

  2. effective_slots auto-downsize / per-tick emission cap — MOVED to the runtime. The source
     computed effective_slots = min(maxSlots, account_value / margin_per_slot) and emitted at
     most that many signals per tick. In v2 the runtime owns slot count (strategy.slots) and
     affordability (risk gates), so scan() emits ALL candidates clearing the gates (held +
     cooldown + spread), carrying marginPct (percent of withdrawable) + leverage at each signal's
     TOP LEVEL. We still emit at most `maxSlots` candidates per tick (the rotation only advances
     that many times) so the scanner doesn't flood the queue, but the runtime makes the final
     open/skip decision.

  3. random pool/direction picks are non-deterministic — kept verbatim (this is the source's
     design: rotation desync + neutral-regime coin flip). Tests assert STRUCTURE (valid gated
     signals) and seed/inject rng for the deterministic paths.

  4. runners optional — an empty TURBINE_RUNNERS_WALLET means runners-only is simply not
     launched; the volume leg runs standalone. There is no cross-leg coupling in this module
     (the source's per-wallet desync came from independent rotation indices, which here is just
     each leg owning its own ctx.state).

State (in ctx.state, newest record wins): {"signaled": {coin_key: ts}, "last_closed":
{coin_key: {"ts": ts}}, "rotation_index": int, "held": [coin_key, ...]}.
"""

import sys
import time

import scoring


def _dex_for(asset):
    return "xyz" if asset.lower().startswith("xyz:") else ""


def _get_universe_meta(ctx):
    """{name: {"max_leverage": int|None}} for live instruments (one call). Used only to clamp
    leverage to the venue max; turbine's source used a fixed 5x and let the runtime clamp."""
    try:
        data = ctx.senpi_mcp.call_tool("market_list_instruments", {})
    except Exception:
        return {}
    out = {}
    if not data:
        return out
    insts = data.get("data", data) if isinstance(data, dict) else data
    if isinstance(insts, dict):
        insts = insts.get("instruments", [])
    for inst in insts or []:
        if not isinstance(inst, dict):
            continue
        if inst.get("is_delisted"):
            continue
        name = inst.get("name") or (inst.get("context", {}) or {}).get("coin")
        if not name:
            continue
        entry = {"max_leverage": inst.get("max_leverage", inst.get("maxLeverage"))}
        out[name] = entry
        out[name.upper()] = entry
    return out


def _fetch_asset_data(ctx, asset):
    """market_get_asset_data with order book + funding for one asset (spread + regime)."""
    try:
        return ctx.senpi_mcp.call_tool("market_get_asset_data", {
            "asset": asset,
            "candle_intervals": [],
            "include_funding": True,
            "include_order_book": True,
            "dex": _dex_for(asset),
        })
    except Exception:
        return None


def _get_held(ctx):
    """held_keys set from open positions + non-reduceOnly resting orders, across both dexes.

    Mirrors the source's held accounting (positions via clearinghouse_state, resting maker
    orders via open_orders), so a slot the runtime is already working is not double-filled.
    Reduce-only / trigger orders are DSL exit legs — never slot occupiers."""
    held = set()
    try:
        ch = ctx.senpi_mcp.call_tool("strategy_get_clearinghouse_state",
                                     {"strategy_wallet": ctx.wallet})
    except Exception:
        ch = None
    account_value = 0.0
    if ch:
        data = ch.get("data", ch) if isinstance(ch, dict) else ch
        if isinstance(data, dict):
            for section in ("main", "xyz"):
                s = data.get(section, {})
                if not isinstance(s, dict):
                    continue
                ms = s.get("marginSummary", {})
                account_value += scoring.safe_float(ms.get("accountValue", 0))
                for ap in s.get("assetPositions", []) or []:
                    pos = ap.get("position", ap)
                    if scoring.safe_float(pos.get("szi", 0)) == 0:
                        continue
                    coin = pos.get("coin", "")
                    if coin:
                        held.add(scoring.normalize_coin_key(coin))

    try:
        od = ctx.senpi_mcp.call_tool("strategy_get_open_orders",
                                     {"strategy_wallet": ctx.wallet})
    except Exception:
        od = None
    if od:
        orders = od.get("data", od) if isinstance(od, dict) else od
        if isinstance(orders, dict):
            orders = orders.get("orders", orders.get("openOrders", []))
        if isinstance(orders, list):
            for o in orders:
                if not isinstance(o, dict):
                    continue
                if o.get("reduceOnly") or o.get("isTrigger"):
                    continue
                coin = o.get("coin") or o.get("asset", "")
                if coin:
                    held.add(scoring.normalize_coin_key(coin))
    return held, account_value


# ── ctx.state I/O ──

def _load_state(ctx):
    if ctx.state is None or len(ctx.state) == 0:
        return {}, {}, 0
    last = ctx.state.last() or {}
    signaled = last.get("signaled", {})
    last_closed = last.get("last_closed", {})
    rot_idx = last.get("rotation_index", 0)
    return (dict(signaled) if isinstance(signaled, dict) else {},
            dict(last_closed) if isinstance(last_closed, dict) else {},
            int(rot_idx) if isinstance(rot_idx, int) else 0)


def _load_prev_held(ctx):
    if ctx.state is None or len(ctx.state) == 0:
        return set()
    last = ctx.state.last() or {}
    held = last.get("held", [])
    return set(held) if isinstance(held, list) else set()


def scan(inputs, ctx):
    now = time.time()
    xyz_weight = float(inputs.get("xyzWeight", 0.80))
    spread_main_bps = float(inputs.get("spreadMainBps", 3))
    spread_xyz_bps = float(inputs.get("spreadXyzBps", 10))
    leg_leverage = float(inputs.get("leverage", 5))
    # marginPct: percent of withdrawable to size each slot (0–100). Default 20 is
    # an example; the old fixed-USD intent can't be preserved as a percent without
    # knowing account size. Set this in the recipe inputs: block.
    margin_pct = float(inputs.get("marginPct", 20))
    max_slots = int(inputs.get("maxSlots", 7))

    held_set, _account_value = _get_held(ctx)

    # Detect closes since last tick -> stamp last_closed (drives the 90s post-close cooldown).
    prev_signaled, last_closed, rot_idx = _load_state(ctx)
    prev_held = _load_prev_held(ctx)
    closed_this_tick = prev_held - held_set
    for k in closed_this_tick:
        last_closed[k] = {"ts": now}
    # Prune cooldown entries well past the window so last_closed doesn't grow unbounded.
    cutoff = now - (scoring.POST_CLOSE_COOLDOWN_SECONDS * 4)
    last_closed = {k: v for k, v in last_closed.items()
                   if scoring.safe_float(v.get("ts", 0)) >= cutoff}

    free_slots = max(0, max_slots - len(held_set))
    if free_slots <= 0:
        if ctx.state is not None:
            try:
                ctx.state.append({"signaled": prev_signaled, "last_closed": last_closed,
                                  "rotation_index": rot_idx, "held": sorted(held_set)})
            except Exception as exc:
                # See note at the end-of-tick append below: a swallowed failure
                # here lets the next tick reload stale dedup state and re-emit
                # suppressed signals. Log-and-continue, don't crash the tick.
                print(
                    f"[turbine.scan] WARNING: dedup-state append failed (no-slot "
                    f"branch); next tick may re-emit suppressed signals: {exc!r}",
                    file=sys.stderr,
                )
        return []

    meta_map = _get_universe_meta(ctx)

    # working_held: assets already held PLUS those we emit this tick, so the rotation index
    # advances past them (mirrors fill_wallet_slots adding to held_keys after each emit).
    working_held = set(held_set)
    out = []
    for _ in range(free_slots):
        asset, rot_idx = scoring.pick_rotation_asset(
            rot_idx, xyz_weight, working_held, last_closed, now=now)
        if asset is None:
            break
        resp = _fetch_asset_data(ctx, asset)
        ad = scoring.parse_asset_data(resp)
        if ad is None:
            continue
        if not scoring.spread_ok(asset, ad["spread_bps"], spread_main_bps, spread_xyz_bps):
            continue
        direction, thesis = scoring.choose_direction(ad["funding_regime"])

        meta = meta_map.get(asset) or meta_map.get(asset.upper()) or {}
        leverage = scoring.clamp_leverage(leg_leverage, meta.get("max_leverage"))
        if leverage <= 0:
            continue

        out.append({
            "asset": asset,
            "direction": direction,
            "marginPct": float(margin_pct),
            "leverage": float(leverage),
            "data": {
                "thesis": thesis,
                "fundingRegime": ad["funding_regime"],
                "fundingAnnualizedPct": float(ad["funding_annualized_pct"]),
                "spreadBps": float(ad["spread_bps"]),
                "isXyz": scoring.is_xyz(asset),
            },
        })
        coin_key = scoring.normalize_coin_key(asset)
        working_held.add(coin_key)
        prev_signaled[coin_key] = now

    # 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({
                "signaled": prev_signaled,
                "last_closed": last_closed,
                "rotation_index": rot_idx,
                "held": sorted(working_held),
            })
        except Exception as exc:
            print(
                f"[turbine.scan] WARNING: dedup-state append failed; next tick "
                f"may re-emit suppressed signals: {exc!r}",
                file=sys.stderr,
            )

    return out
