"""
exit_prf_experimental.py

[2026-09-19] NOT wired into monitor.py's live loop -- backtest-only.
Implements the "Price Response Failure" (PRF) concept worked out in the
2026-09-18 GLOO conversation (entered 4.93, peaked 5.58 at 10:58-11:00
ET on repeated +90-100% buy imbalance that never produced a new high,
imbalance then flipped negative for several reads, price broke down to
5.41/5.46 by 11:02 -- exit.py's three real layers stayed silent through
all of it; the trade didn't actually close until 18:17, round-tripping
back to 5.225. See also exit_giveback_experimental.py, built the same
day off the same trade to attack the same gap from a different angle
(peak-giveback voting instead of failed-breakout detection).

Core idea, straight from that conversation: the earliest tell isn't
negative imbalance by itself -- it's strong POSITIVE imbalance that
stops moving price. That's absorption at resistance, and it shows up
before the deterioration engine's price-structure signals do. A
four-stage, reversible state machine per symbol:

  NORMAL            -- price making new highs (or hasn't yet moved
                        min_mfe_pct since entry -- PRF doesn't evaluate
                        pre-move noise, same "after +3% MFE" gate the
                        conversation proposed).
  WATCH -> WARNING   -- price fails to exceed its own peak-since-entry
                        for resistance_confirm_reads consecutive polls
                        while trade imbalance stays >= strong_imbalance_
                        threshold (buyers hitting the market, price not
                        responding).
  STRONG_WARNING      -- imbalance then flips negative for
                        imbalance_reversal_reads consecutive polls,
                        still no new high.
  PROFIT_PROTECTION   -- structure also deteriorates (lower-highs/
                        lower-lows over the recent window -- reused from
                        exit.py's own deterioration check). Exits once
                        this holds for confirm_reads consecutive polls.

Reversible at every stage: any poll where price prints a new peak high
cancels the warning outright and returns to NORMAL, per the
conversation's explicit safeguard ("if they dip and recover, the
warning disappears") -- this is what should keep PRF from tripping on
ordinary chop the way a naive giveback-from-peak measure alone might.
"""

from dataclasses import dataclass, field

from indicators import is_lower_highs_lower_lows

STATE_NORMAL = "NORMAL"
STATE_WATCH = "WATCH"
STATE_WARNING = "WARNING"
STATE_STRONG_WARNING = "STRONG_WARNING"
STATE_PROFIT_PROTECTION = "PROFIT_PROTECTION"

STATE_HOLD = "HOLD"
STATE_EXIT = "EXIT"

DEFAULT_CONFIG = {
    "min_mfe_pct": 3.0,               # PRF doesn't arm until peak has run this far above entry
    "resistance_band_pct": 0.15,      # within this % of peak == "at resistance", not a new high
    "strong_imbalance_threshold": 0.70,   # +70% buy imbalance counts as "strong buying"
    "resistance_confirm_reads": 2,    # consecutive at-resistance+strong-imbalance reads -> WARNING
    "imbalance_reversal_reads": 2,    # consecutive negative-imbalance reads after a failed high -> STRONG_WARNING
    "structure_lookback_bars": 4,     # lower-highs/lower-lows window, same as exit.py's
    "confirm_reads": 2,               # consecutive PROFIT_PROTECTION reads required to actually exit
    "min_giveback_pct_to_exit": 1.5,  # price must ALSO be down at least this much from peak to fire --
                                       # [2026-09-19] added after the 2026-09-18 full-day backtest showed
                                       # FWDI/SBET false-triggering on imbalance/structure noise during
                                       # ordinary consolidation (0.69%/0.78% giveback at exit) vs GLOO's
                                       # genuine reversal (2.06% giveback). UNRESOLVED even with this gate:
                                       # a flat % doesn't separate "real reversal" from "normal deep
                                       # pullback in an intact breakout" -- at 2.0% a DIFFERENT FWDI
                                       # position still false-triggered at 2.05% giveback and then ran to
                                       # +12.79% by EOD in the baseline, while GLOO's own genuine trigger
                                       # was only 2.06% giveback, so pushing this threshold up to dodge
                                       # FWDI risks losing or delaying the one catch this exists for. This
                                       # is the same open question as Bug #3's original "1.2x ATR or
                                       # tighter/looser" trailing-distance question -- a flat percentage
                                       # is a placeholder, not a validated answer. Full-day P/L with this
                                       # gate is still below the no-PRF baseline on 2026-09-18 (the only
                                       # day tested): $417-443 vs $559 baseline, depending on threshold.
}


@dataclass
class PRFDecision:
    symbol: str
    state: str = STATE_HOLD
    should_exit: bool = False
    reason: str = ""
    metrics: dict = field(default_factory=dict)
    state_out: dict = field(default_factory=dict)


def _merge_cfg(cfg):
    return {**DEFAULT_CONFIG, **(cfg or {})}


def evaluate(symbol: str, bars: list, entry_price: float, trade_imbalance,
             state_in: dict, cfg: dict = None) -> PRFDecision:
    """
    bars: same short rolling 1-min-bar window exit.py/smart_engine.py take.
    trade_imbalance: stream.py's get_trade_imbalance() live read for this
        symbol, same signal exit.py's imbalance-decline gate and
        smart_engine's Stage 3 both use -- None means a cold buffer this
        poll (fails open: holds the PRF stage steady, doesn't advance or
        reset it).
    state_in: caller-held state, fed back every poll -- {"peak_price",
        "stage", "resistance_count", "imbalance_reversal_count",
        "confirm_count"}. Fresh {} at entry.
    """
    cfg = _merge_cfg(cfg)
    state_in = dict(state_in or {})

    if len(bars) < 3:
        return PRFDecision(symbol=symbol, reason="insufficient bar data", state_out=state_in)

    price = bars[-1]["c"]
    prior_peak = state_in.get("peak_price", price)
    peak_price = max(prior_peak, price)
    made_new_high = price > prior_peak

    stage = state_in.get("stage", STATE_NORMAL)
    resistance_count = state_in.get("resistance_count", 0)
    imbalance_reversal_count = state_in.get("imbalance_reversal_count", 0)
    confirm_count = state_in.get("confirm_count", 0)

    mfe_pct = (peak_price - entry_price) / entry_price * 100.0 if entry_price else 0.0
    armed = mfe_pct >= cfg["min_mfe_pct"]

    metrics = {
        "price": price, "peak_price": round(peak_price, 4), "mfe_pct": round(mfe_pct, 3),
        "armed": armed, "made_new_high": made_new_high, "trade_imbalance": trade_imbalance,
        "stage_in": stage,
    }

    base_state = {"peak_price": peak_price}

    if not armed:
        return PRFDecision(symbol=symbol, state=STATE_HOLD,
                            reason=f"not armed yet (MFE {mfe_pct:.2f}% < {cfg['min_mfe_pct']}%)",
                            metrics=metrics,
                            state_out={**base_state, "stage": STATE_NORMAL, "resistance_count": 0,
                                       "imbalance_reversal_count": 0, "confirm_count": 0})

    # A fresh peak high cancels any active warning outright -- reversible
    # by design, so ordinary chop that recovers never reaches exit.
    if made_new_high:
        return PRFDecision(symbol=symbol, state=STATE_HOLD,
                            reason=f"new high ${price:.4f} -- PRF warning cancelled",
                            metrics={**metrics, "stage_out": STATE_NORMAL},
                            state_out={**base_state, "stage": STATE_NORMAL, "resistance_count": 0,
                                       "imbalance_reversal_count": 0, "confirm_count": 0})

    if trade_imbalance is None:
        # Cold read -- hold the stage steady rather than punish/advance on
        # a data gap, same fail-open contract exit.py's imbalance gate uses.
        return PRFDecision(symbol=symbol, state=STATE_HOLD,
                            reason="no imbalance data this poll -- holding stage",
                            metrics={**metrics, "stage_out": stage},
                            state_out={**base_state, "stage": stage, "resistance_count": resistance_count,
                                       "imbalance_reversal_count": imbalance_reversal_count,
                                       "confirm_count": confirm_count})

    at_resistance = price >= peak_price * (1 - cfg["resistance_band_pct"] / 100.0)
    strong_buying = trade_imbalance >= cfg["strong_imbalance_threshold"]
    metrics.update({"at_resistance": at_resistance, "strong_buying": strong_buying})

    # Stage progression while price is NOT making a new high (guaranteed
    # true here -- the made_new_high branch above already returned).
    if stage == STATE_NORMAL:
        if at_resistance and strong_buying:
            resistance_count += 1
            stage = STATE_WATCH if resistance_count < cfg["resistance_confirm_reads"] else STATE_WARNING
        else:
            resistance_count = 0

    elif stage in (STATE_WATCH, STATE_WARNING):
        if at_resistance and strong_buying:
            resistance_count += 1
            if resistance_count >= cfg["resistance_confirm_reads"]:
                stage = STATE_WARNING
        if trade_imbalance < 0:
            imbalance_reversal_count += 1
            if imbalance_reversal_count >= cfg["imbalance_reversal_reads"]:
                stage = STATE_STRONG_WARNING
        else:
            imbalance_reversal_count = 0

    elif stage == STATE_STRONG_WARNING:
        if trade_imbalance < 0:
            imbalance_reversal_count += 1
        else:
            imbalance_reversal_count = 0
        lhl = is_lower_highs_lower_lows(bars, lookback=min(cfg["structure_lookback_bars"], len(bars)))
        metrics["lower_highs_lower_lows"] = lhl
        if lhl:
            stage = STATE_PROFIT_PROTECTION

    elif stage == STATE_PROFIT_PROTECTION:
        lhl = is_lower_highs_lower_lows(bars, lookback=min(cfg["structure_lookback_bars"], len(bars)))
        metrics["lower_highs_lower_lows"] = lhl
        if lhl or trade_imbalance < 0:
            confirm_count += 1
        else:
            # structure/imbalance both recovered without a new high yet --
            # step back down one level rather than exiting on a stale read.
            confirm_count = 0
            stage = STATE_STRONG_WARNING

    giveback_pct = (peak_price - price) / peak_price * 100.0 if peak_price else 0.0
    metrics["giveback_pct"] = round(giveback_pct, 3)
    metrics["stage_out"] = stage
    out_state = {**base_state, "stage": stage, "resistance_count": resistance_count,
                 "imbalance_reversal_count": imbalance_reversal_count, "confirm_count": confirm_count}

    if stage == STATE_PROFIT_PROTECTION and confirm_count >= cfg["confirm_reads"]:
        if giveback_pct >= cfg["min_giveback_pct_to_exit"]:
            return PRFDecision(
                symbol=symbol, state=STATE_EXIT, should_exit=True,
                reason=(f"PRF confirmed reversal: failed breakout at ${peak_price:.4f} "
                        f"({resistance_count} resistance reads), imbalance reversal "
                        f"({imbalance_reversal_count} negative reads), structure deteriorating "
                        f"for {confirm_count} reads, giveback {giveback_pct:.2f}%"),
                metrics=metrics, state_out={**out_state, "confirm_count": 0})
        # Structure/imbalance confirmed but price hasn't actually given back
        # enough yet -- keep holding the confirmed stage rather than exiting
        # on noise; confirm_count is intentionally not reset here so this
        # fires the moment giveback catches up, without waiting through
        # another full confirm_reads cycle.
        return PRFDecision(
            symbol=symbol, state=STATE_HOLD,
            reason=(f"PRF stage {stage}: confirmed ({confirm_count}/{cfg['confirm_reads']}) but giveback "
                    f"{giveback_pct:.2f}% < {cfg['min_giveback_pct_to_exit']}% -- holding"),
            metrics=metrics, state_out=out_state)

    return PRFDecision(
        symbol=symbol, state=STATE_HOLD,
        reason=(f"PRF stage {stage}: peak ${peak_price:.4f}, imbalance {trade_imbalance:+.0%}, "
                f"resistance reads {resistance_count}, reversal reads {imbalance_reversal_count}, "
                f"confirm {confirm_count}/{cfg['confirm_reads']}, giveback {giveback_pct:.2f}%"),
        metrics=metrics, state_out=out_state)
