"""Pure helpers for causal same-side block snapshots and shadow replacement.

This module is intentionally free of Freqtrade imports so the formulas can be
unit-tested without a running bot.  The shadow decision is observational only:
it never closes a trade or admits a new one.
"""

from __future__ import annotations

import math
from datetime import datetime, timezone
from typing import Any, Callable, Mapping


def _finite(value: Any) -> float | None:
    try:
        number = float(value)
    except (TypeError, ValueError):
        return None
    return number if math.isfinite(number) else None


def candidate_signal_snapshot(candle: Mapping[str, Any], side: str) -> dict[str, Any]:
    """Return only fields available on the analyzed candle at decision time."""

    close = _finite(candle.get("close"))
    atr = _finite(candle.get("atr"))
    adx = _finite(candle.get("adx"))
    boundary_key = "donchian_high" if side == "long" else "donchian_low"
    boundary = _finite(candle.get(boundary_key))

    distance = None
    distance_atr = None
    if close is not None and boundary is not None:
        distance = close - boundary if side == "long" else boundary - close
        if atr is not None and atr > 0:
            distance_atr = distance / atr

    return {
        "close": close,
        "atr": atr,
        "adx": adx,
        "donchian_boundary": boundary,
        "breakout_distance": distance,
        "breakout_atr": distance_atr,
    }


def _aware_utc(value: datetime | None) -> datetime | None:
    if value is None:
        return None
    if value.tzinfo is None:
        return value.replace(tzinfo=timezone.utc)
    return value.astimezone(timezone.utc)


def occupied_trade_snapshots(
    trades: list[Any],
    *,
    current_time: datetime,
    mark_lookup: Callable[[str], float | None],
    initial_risk: float,
) -> list[dict[str, Any]]:
    """Capture age and current R for occupied positions without future data."""

    now = _aware_utc(current_time)
    rows = []
    for trade in trades:
        opened = _aware_utc(
            getattr(trade, "open_date_utc", None)
            or getattr(trade, "open_date", None)
        )
        age_h = None
        if now is not None and opened is not None:
            age_h = max(0.0, (now - opened).total_seconds() / 3600)

        pair = str(getattr(trade, "pair", ""))
        mark = _finite(mark_lookup(pair))
        profit_ratio = None
        if mark is not None:
            try:
                profit_ratio = _finite(trade.calc_profit_ratio(mark))
            except Exception:  # Observability must never alter the gate decision.
                profit_ratio = None
        current_r = (
            profit_ratio / initial_risk
            if profit_ratio is not None and initial_risk > 0
            else None
        )
        rows.append(
            {
                "pair": pair,
                "trade_id": getattr(trade, "id", None),
                "age_h": age_h,
                "mark": mark,
                "profit_ratio": profit_ratio,
                "current_r": current_r,
            }
        )
    return sorted(rows, key=lambda row: (row["pair"], str(row["trade_id"])))


def replacement_shadow_decision(
    candidate: Mapping[str, Any],
    occupied: list[Mapping[str, Any]],
    *,
    min_breakout_atr: float,
    min_adx: float,
    max_weakest_r: float,
) -> dict[str, Any]:
    """Pre-registered one-factor shadow rule; never executes a replacement."""

    breakout_atr = _finite(candidate.get("breakout_atr"))
    adx = _finite(candidate.get("adx"))
    ranked = [row for row in occupied if _finite(row.get("current_r")) is not None]
    if breakout_atr is None or adx is None:
        return {"eligible": False, "reason": "candidate_snapshot_incomplete", "weakest": None}
    if len(ranked) != len(occupied) or not ranked:
        return {"eligible": False, "reason": "occupied_snapshot_incomplete", "weakest": None}

    weakest = min(ranked, key=lambda row: float(row["current_r"]))
    strong_candidate = breakout_atr >= min_breakout_atr and adx >= min_adx
    weak_incumbent = float(weakest["current_r"]) <= max_weakest_r
    eligible = strong_candidate and weak_incumbent
    if not strong_candidate:
        reason = "candidate_not_strong"
    elif not weak_incumbent:
        reason = "incumbents_not_weak"
    else:
        reason = "shadow_replace"
    return {
        "eligible": eligible,
        "reason": reason,
        "weakest": {
            "pair": weakest.get("pair"),
            "trade_id": weakest.get("trade_id"),
            "current_r": weakest.get("current_r"),
        },
    }
