"""Order planning for the Bybit testnet real-paper execution leg (design §4.2) — pure, no I/O.

⚠️ NO OVERLAY RE-APPLY. The design (review #1) assumed `bybit_4edge` applied a risk overlay that had to be
re-run on the testnet book's own equity/drawdown. The Task-1 gate confirmed it does NOT: the book is built
naive equal-weight with the live overlay deliberately NOT applied (see the `bybit_paper_book` module docstring
and `latest_raw_sleeve_weights`, which returns the RAW pre-overlay per-symbol target). So the raw sleeve weights
ARE the effective weights — there is nothing to re-apply, and `effective_weights` is intentionally absent.

`plan_orders` therefore diffs the raw target weights straight against the live testnet positions:
  * target_qty[s] = (weight·equity)/marks[s], snapped to the instrument `qty_step` (toward zero, never over);
  * a target below the tradable minimum (`|target·mark| < max(minNotional, min_order_usd)` or
    `|target| < minOrderQty`) collapses to 0 — we don't hold a sub-minimum dust position;
  * delta = target_qty − current_position; an order below the tradable minimum is dropped (the exchange would
    reject it anyway);
  * `reduce_only=True` on reductions/closes (magnitude shrinks toward/through zero without flipping sign);
  * the gross book is capped: Σ|weight·equity| ≤ equity·`max_gross` (=1.0, no leverage) — over-gross weights are
    scaled down (clip + log), matching the overlay's gross-cap invariant even though the overlay itself is off.

The `OrderIntent` / `InstrumentLimit` frozen DTOs are the stable contract consumed by the ccxt adapter (Task 5)
and the Dagster reconcile asset (Task 8).
"""
from __future__ import annotations

import logging
import math
from dataclasses import dataclass

_LOG = logging.getLogger(__name__)


@dataclass(frozen=True, slots=True)
class OrderIntent:
    """A planned testnet order. `qty` is the signed delta (>0 buy, <0 sell), already snapped to `qty_step`."""

    symbol: str
    qty: float
    reduce_only: bool


@dataclass(frozen=True, slots=True)
class InstrumentLimit:
    """Exchange filters for one symbol (parsed from ccxt `load_markets`)."""

    min_order_qty: float
    qty_step: float
    min_notional: float


def _snap(qty: float, step: float) -> float:
    """Snap `qty` toward zero to a multiple of `step` (never overshoots the target / the gross cap)."""
    if step <= 0.0:
        return qty
    n = math.floor(abs(qty) / step + 1e-9)
    return math.copysign(round(n * step, 12), qty)


def _tradable(qty: float, mark: float, limit: InstrumentLimit, min_order_usd: float) -> bool:
    """A qty is tradable if its notional clears max(minNotional, min_order_usd) and its size clears
    minOrderQty."""
    if qty == 0.0:
        return False
    if abs(qty) < limit.min_order_qty:
        return False
    return abs(qty * mark) >= max(limit.min_notional, min_order_usd)


def plan_orders(
    target_weights: dict[str, float],
    current_positions: dict[str, float],
    equity: float,
    marks: dict[str, float],
    limits: dict[str, InstrumentLimit],
    *,
    min_order_usd: float = 10.0,
    max_gross: float = 1.0,
) -> list[OrderIntent]:
    """Diff the (already-effective) raw target weights against the testnet positions into a list of
    `OrderIntent` deltas. See the module docstring for the rules. Pure; no I/O."""
    weights = _cap_gross(target_weights, equity, max_gross)

    intents: list[OrderIntent] = []
    for symbol in sorted(weights):
        mark = marks.get(symbol)
        limit = limits.get(symbol)
        if mark is None or mark <= 0.0 or limit is None:
            continue
        target_qty = _snap(weights[symbol] * equity / mark, limit.qty_step)
        if not _tradable(target_qty, mark, limit, min_order_usd):
            target_qty = 0.0  # below the minimum → don't hold a dust position
        current = current_positions.get(symbol, 0.0)
        delta = _snap(target_qty - current, limit.qty_step)
        if not _tradable(delta, mark, limit, min_order_usd):
            continue  # nothing to do / sub-minimum residual the exchange would reject
        intents.append(OrderIntent(symbol, delta, _is_reduce_only(target_qty, current)))
    return intents


def _cap_gross(target_weights: dict[str, float], equity: float, max_gross: float) -> dict[str, float]:
    """Enforce Σ|weight·equity| ≤ equity·max_gross by scaling all weights down if needed (clip + log)."""
    gross = sum(abs(w) for w in target_weights.values())
    if gross <= max_gross or gross == 0.0:
        return dict(target_weights)
    scale = max_gross / gross
    _LOG.warning("bybit_testnet gross %.4f > max_gross %.4f → scaling weights by %.4f", gross, max_gross, scale)
    return {s: w * scale for s, w in target_weights.items()}


def _is_reduce_only(target_qty: float, current: float) -> bool:
    """True when the order shrinks the position toward/through zero without flipping sign (a reduce or close)."""
    if current == 0.0:
        return False
    if target_qty == 0.0:
        return True
    same_sign = math.copysign(1.0, target_qty) == math.copysign(1.0, current)
    return same_sign and abs(target_qty) < abs(current)
