"""
regime_engine.py

[SIMULATION-ONLY 2026-09-05] LIVE, continuously-re-evaluated regime
classifier -- combines trend_engine.TrendReading + structure_engine.
StructureReading + stream_features.StreamFeatures into one label
describing what's happening RIGHT NOW, and which entry setups (per the
future setup_engine.py) are allowed in that regime. Not consumed
anywhere in the live bot yet -- see config.json's regime_engine._note.

NOT THE SAME MODULE AS regime_detector.py (built earlier this session)
-- deliberately kept separate, answering a different question:

    regime_detector.py   "What KIND OF SESSION did/is this symbol
                          have(ing), overall?" -- computed once (or on a
                          fixed window) for offline strategy simulation
                          against a full day's data
                          (simulate_regime_strategies.py). Its regimes:
                          NORMAL_MOMENTUM, GAP_MOMENTUM,
                          CATALYST_GAP_HIGH_VOLATILITY, RANGE_CHOP,
                          FAILED_BREAKOUT, RECOVERY_RECLAIM,
                          INTRADAY_SPIKE_NO_GAP_WHIPSAW.

    regime_engine.py (this file)   "What's happening in this symbol's
                          price action RIGHT NOW, this instant?" --
                          re-evaluated every cycle from live trend/
                          structure/feature readings, feeding the live
                          entry decision. Its regimes are the original
                          design brief's list: STRONG_UPTREND,
                          WEAK_UPTREND, BREAKOUT, PULLBACK, VWAP_RECLAIM,
                          OPENING_VOLATILITY, RANGE, CHOP, DISTRIBUTION,
                          DOWNTREND, FAILED_BREAKOUT, RECOVERY.

Same names appearing in both (FAILED_BREAKOUT) describe genuinely
related but differently-scoped ideas -- one retrospective/whole-session,
one instantaneous/live -- exactly like structure_engine.py's swing-pivot
detection vs indicators.is_higher_highs_higher_lows()'s windowed-bar
check: neither is a duplicate of the other.
"""

from dataclasses import dataclass, field

from config_loader import get_config

OPENING_VOLATILITY = "OPENING_VOLATILITY"
FAILED_BREAKOUT = "FAILED_BREAKOUT"
RECOVERY = "RECOVERY"
VWAP_RECLAIM = "VWAP_RECLAIM"
BREAKOUT = "BREAKOUT"  # [Phase 4] legacy label -- classify_regime() no longer emits this bare
                        # value (see BREAKOUT_ATTEMPT/CONFIRMED/EXPANSION/CONTINUATION below);
                        # kept defined (and in allowed_setups, for safety) in case any external
                        # caller still checks for it, but nothing in this codebase does (verified).
PULLBACK = "PULLBACK"
DISTRIBUTION = "DISTRIBUTION"
STRONG_UPTREND = "STRONG_UPTREND"
WEAK_UPTREND = "WEAK_UPTREND"
DOWNTREND = "DOWNTREND"
RANGE = "RANGE"
CHOP = "CHOP"

# [Phase 4 -- new] Market-state taxonomy extension. See this module's
# updated docstring section below and config.json's regime_engine._note for
# the full reasoning. Two of these (STRUCTURE_BREAKDOWN, the BREAKOUT
# progression) are real, live-relevant classification changes; the rest
# (ACCUMULATION, PRESSURE_BUILDING) only fire when the caller opts in by
# passing a `compression` reading -- omitting it reproduces this module's
# exact pre-Phase-4 behavior for that part of the chain.
STRUCTURE_BREAKDOWN = "STRUCTURE_BREAKDOWN"
ACCUMULATION = "ACCUMULATION"
PRESSURE_BUILDING = "PRESSURE_BUILDING"
BREAKOUT_ATTEMPT = "BREAKOUT_ATTEMPT"
BREAKOUT_CONFIRMED = "BREAKOUT_CONFIRMED"
EXPANSION = "EXPANSION"
CONTINUATION = "CONTINUATION"

DEFAULT_CONFIG = {
    "opening_volatility_window_minutes": 30,
    "opening_volatility_min_atr_pct": 1.0,
    "pullback_trend_slope_threshold": -0.5,
    "distribution_min_volume_acceleration": 1.3,
    "distribution_max_trend_slope": -0.2,
    "vwap_reclaim_min_time_below_pct": 30.0,
    "chop_min_atr_pct": 0.8,
    "range_max_atr_pct": 0.8,
    # [Phase 4] Hard safety gate -- config-toggleable per this project's
    # usual convention for anything that changes live gating, even a
    # strictly-tightening one. See classify_regime()'s first check.
    "structure_breakdown_hard_block": True,
    # [Phase 4] compression_engine.CompressionReading thresholds for
    # qualifying a RANGE as ACCUMULATION or PRESSURE_BUILDING instead of
    # plain RANGE. Only consulted when the caller passes `compression`.
    "accumulation_min_compression_score": 55,
    "pressure_building_min_breakout_pressure_score": 70,
    # [Phase 4] BREAKOUT_ATTEMPT -> BREAKOUT_CONFIRMED -> EXPANSION ->
    # CONTINUATION progression, driven by a caller-held `state["bars_since_
    # breakout"]` counter (same ownership pattern as entry_score.py's
    # confirmation_state / stream_features.py's `prior`) -- this module
    # still stores nothing itself. Bar counts are deliberately small
    # (checked every eval cycle, not every 1-min bar necessarily).
    "breakout_confirm_bars": 2,
    "expansion_min_bars": 3,
    "continuation_min_bars": 5,
    "breakout_volume_confirm_ratio": 1.0,
    # Which setup_engine.py setup types are permitted per regime -- the
    # project's original design brief's explicit requirement that "the
    # regime determines which entry patterns are allowed." Config-driven,
    # not hard-coded per-regime if/else in entry logic.
    "allowed_setups": {
        STRONG_UPTREND: ["PULLBACK_CONTINUATION", "BREAKOUT_RETEST", "VWAP_RECLAIM"],
        WEAK_UPTREND: ["PULLBACK_CONTINUATION", "VWAP_RECLAIM"],
        BREAKOUT: ["BREAKOUT_RETEST"],  # legacy, unreachable -- see BREAKOUT's own comment above
        PULLBACK: ["PULLBACK_CONTINUATION"],
        VWAP_RECLAIM: ["VWAP_RECLAIM"],
        OPENING_VOLATILITY: ["BREAKOUT_RETEST"],  # retest-only, never a raw first-breakout chase
        RANGE: ["COMPRESSION_EXPANSION"],
        CHOP: [],
        DISTRIBUTION: [],
        DOWNTREND: [],
        FAILED_BREAKOUT: [],
        RECOVERY: ["HIGHER_LOW_REVERSAL"],
        # [Phase 4 -- new]
        STRUCTURE_BREAKDOWN: [],
        ACCUMULATION: ["COMPRESSION_EXPANSION"],
        PRESSURE_BUILDING: ["COMPRESSION_EXPANSION", "BREAKOUT_RETEST"],
        BREAKOUT_ATTEMPT: ["BREAKOUT_RETEST"],
        # [Phase 4] EXPANSION/CONTINUATION deliberately permit
        # PULLBACK_CONTINUATION in addition to BREAKOUT_RETEST -- a
        # documented, one-way LOOSENING vs the old bare BREAKOUT regime
        # (which only ever allowed BREAKOUT_RETEST), justified because a
        # sustained post-breakout trend is exactly the kind of setup
        # PULLBACK_CONTINUATION is meant to catch and the old taxonomy had
        # no state that could reach it from here. Never a tightening.
        BREAKOUT_CONFIRMED: ["BREAKOUT_RETEST", "PULLBACK_CONTINUATION"],
        EXPANSION: ["BREAKOUT_RETEST", "PULLBACK_CONTINUATION"],
        CONTINUATION: ["PULLBACK_CONTINUATION", "BREAKOUT_RETEST"],
    },
}


@dataclass
class RegimeReading:
    symbol: str
    regime: str = CHOP
    score: float = 0.0
    allowed_setups: list = field(default_factory=list)
    reasons: list = field(default_factory=list)
    insufficient_data: bool = False
    missing: list = field(default_factory=list)


def _insufficient(symbol: str, missing: list) -> RegimeReading:
    return RegimeReading(symbol=symbol, insufficient_data=True, missing=missing)


def classify_regime(symbol: str, features, trend, structure,
                     session_elapsed_minutes: float = None, compression=None,
                     state: dict = None, cfg: dict = None) -> RegimeReading:
    """
    features: stream_features.StreamFeatures
    trend: trend_engine.TrendReading
    structure: structure_engine.StructureReading
    session_elapsed_minutes: minutes since regular-session open, or None
        if unknown (in which case OPENING_VOLATILITY can never fire --
        fails safe to "don't know if we're in the opening window" rather
        than guessing).
    compression: [Phase 4, optional] compression_engine.CompressionReading.
        Omit entirely to reproduce this function's exact pre-Phase-4
        behavior for the RANGE branch (ACCUMULATION/PRESSURE_BUILDING can
        only ever fire when this is supplied).
    state: [Phase 4, optional] caller-held per-symbol dict, same ownership
        pattern as entry_score.py's confirmation_state -- this function
        mutates state["bars_since_breakout"] in place and also returns it
        via the reading isn't needed since the caller already holds the
        same dict object; a fresh {} (or None) starts the counter at 0,
        exactly like a cold start anywhere else in this pipeline. Drives
        the BREAKOUT_ATTEMPT -> BREAKOUT_CONFIRMED -> EXPANSION ->
        CONTINUATION progression below.
    """
    cfg = {**DEFAULT_CONFIG, **(cfg or get_config().get("regime_engine", {}))}
    allowed_setups_map = cfg["allowed_setups"]

    missing = []
    if getattr(features, "insufficient_data", False):
        missing.append("stream_features")
    if getattr(trend, "insufficient_data", False):
        missing.append("trend")
    if getattr(structure, "insufficient_data", False):
        missing.append("structure")
    if missing:
        return _insufficient(symbol, missing)

    reasons = []

    # ---- 0. [Phase 4] Track the raw "price above last confirmed swing high
    # in an uptrend" condition every call, independent of which regime label
    # ends up winning priority below -- the BREAKOUT progression (branch #6)
    # needs to know how many CONSECUTIVE calls this has been true for, not
    # just whether it's true right now.
    is_breakout_condition = (
        structure.last_swing_high is not None
        and features.price.get("last", 0) > structure.last_swing_high
        and trend.direction == "UP"
    )
    if state is not None:
        state["bars_since_breakout"] = (state.get("bars_since_breakout", 0) + 1
                                         if is_breakout_condition else 0)
    bars_since_breakout = (state or {}).get("bars_since_breakout", 1 if is_breakout_condition else 0)

    # ---- [Phase 4] Structure breakdown: structure_engine already detects
    # a confirmed break of the last swing low (support_failure) -- promote
    # that to its own regime, ABOVE even the opening-volatility override,
    # since a genuine support failure is a real risk condition regardless
    # of time of day (this project's own principle: hard blockers are for
    # genuine risk conditions, and this is one). Config-toggleable per this
    # project's usual "everything live-relevant stays tunable" convention.
    if cfg["structure_breakdown_hard_block"] and structure.flags.get("support_failure"):
        reasons.append("structure_engine confirmed a support failure (broke below the last "
                        "swing low) -- highest-priority risk state, overrides opening-window handling")
        return RegimeReading(symbol=symbol, regime=STRUCTURE_BREAKDOWN, score=100.0 - structure.score,
                              allowed_setups=allowed_setups_map[STRUCTURE_BREAKDOWN], reasons=reasons)

    # ---- 1. Opening volatility: time-gated, overrides everything else
    # early in the session per the project's original design brief's
    # explicit "do not blindly chase breakouts" instruction for this
    # window.
    if (session_elapsed_minutes is not None
            and session_elapsed_minutes <= cfg["opening_volatility_window_minutes"]
            and features.volatility.get("atr_pct", 0) >= cfg["opening_volatility_min_atr_pct"]):
        reasons.append(f"within opening window ({session_elapsed_minutes:.1f}m) with elevated "
                        f"volatility (atr_pct={features.volatility.get('atr_pct')})")
        return RegimeReading(symbol=symbol, regime=OPENING_VOLATILITY, score=90.0,
                              allowed_setups=allowed_setups_map[OPENING_VOLATILITY], reasons=reasons)

    # ---- 2. Failed breakout: structure_engine already confirmed a
    # round-tripped breakout -- highest-priority warning state.
    if structure.flags.get("failed_breakout"):
        reasons.append("structure_engine confirmed a failed breakout (broke resistance, "
                        "gave it back)")
        return RegimeReading(symbol=symbol, regime=FAILED_BREAKOUT, score=structure.score,
                              allowed_setups=allowed_setups_map[FAILED_BREAKOUT], reasons=reasons)

    # ---- 3. Recovery: the most recent two LOW swings went LL -> HL
    # (bottomed and printing a higher low) with trend_slope now positive
    # -- an early reversal-off-the-bottom signal, distinct from a
    # continuation pullback (#5 below).
    low_swings = [s for s in structure.swings if s.kind == "LOW" and s.label is not None]
    if len(low_swings) >= 2 and low_swings[-2].label == "LL" and low_swings[-1].label == "HL" \
            and trend.trend_slope > 0:
        reasons.append("bottomed (LL) then printed a higher low (HL) with momentum turning positive")
        return RegimeReading(symbol=symbol, regime=RECOVERY, score=60.0,
                              allowed_setups=allowed_setups_map[RECOVERY], reasons=reasons)

    # ---- 4. VWAP reclaim: currently above VWAP, having spent real time
    # below it recently, with momentum now turning positive.
    price_vs_vwap = features.vwap.get("price_vs_vwap_pct", 0)
    time_below = features.vwap.get("time_below_pct", 0)
    if price_vs_vwap > 0 and time_below >= cfg["vwap_reclaim_min_time_below_pct"] and trend.trend_slope > 0:
        reasons.append(f"price reclaimed VWAP (was below it {time_below:.0f}% of the recent "
                        f"window) with momentum turning positive")
        return RegimeReading(symbol=symbol, regime=VWAP_RECLAIM, score=65.0,
                              allowed_setups=allowed_setups_map[VWAP_RECLAIM], reasons=reasons)

    # ---- 5. [Phase 4] Breakout progression: price currently above the
    # last confirmed swing high (not yet flagged as failed) with an
    # established uptrend -- same trigger condition as the old bare
    # BREAKOUT regime, now split into a progression by how many
    # CONSECUTIVE calls it's held (bars_since_breakout, tracked in branch
    # #0 above), matching the project's request to distinguish a fresh
    # breakout attempt from one that's actually held and expanded.
    if is_breakout_condition:
        volume_confirms = (features.volume.get("volume_acceleration") or 1.0) >= cfg["breakout_volume_confirm_ratio"]
        confirm_at = cfg["breakout_confirm_bars"]
        expansion_at = confirm_at + cfg["expansion_min_bars"]
        continuation_at = expansion_at + cfg["continuation_min_bars"]
        if bars_since_breakout < confirm_at:
            regime = BREAKOUT_ATTEMPT
            reasons.append(f"price ({features.price.get('last')}) above last swing high "
                            f"({structure.last_swing_high}), uptrend intact, attempt #{bars_since_breakout}")
        elif bars_since_breakout < expansion_at:
            regime = BREAKOUT_CONFIRMED if volume_confirms else BREAKOUT_ATTEMPT
            reasons.append(f"breakout held for {bars_since_breakout} consecutive reads "
                            f"(volume_confirms={volume_confirms})")
        elif bars_since_breakout < continuation_at:
            regime = EXPANSION
            reasons.append(f"breakout held {bars_since_breakout} reads past the initial break -- expansion")
        else:
            regime = CONTINUATION
            reasons.append(f"breakout sustained {bars_since_breakout} reads -- continuation")
        return RegimeReading(symbol=symbol, regime=regime, score=trend.score,
                              allowed_setups=allowed_setups_map[regime], reasons=reasons)

    # ---- 6. Pullback: an established uptrend whose SHORT-term momentum
    # has turned negative, but the confirmed swing structure hasn't
    # broken -- a healthy retracement, not a reversal.
    if (trend.state in ("STRONG_UPTREND", "WEAK_UPTREND")
            and trend.trend_slope <= cfg["pullback_trend_slope_threshold"]
            and structure.structure == "BULLISH_STRUCTURE"):
        reasons.append(f"uptrend intact (structure=BULLISH) but short-term momentum "
                        f"weakening (trend_slope={trend.trend_slope})")
        return RegimeReading(symbol=symbol, regime=PULLBACK, score=trend.score,
                              allowed_setups=allowed_setups_map[PULLBACK], reasons=reasons)

    # ---- 7. Distribution: momentum fading while volume is elevated and
    # structure hasn't broken down yet -- selling into apparent strength.
    vol_accel = features.volume.get("volume_acceleration")
    if (vol_accel is not None and vol_accel >= cfg["distribution_min_volume_acceleration"]
            and trend.trend_slope <= cfg["distribution_max_trend_slope"]
            and structure.structure != "BEARISH_STRUCTURE"):
        reasons.append(f"elevated volume (accel={vol_accel}x) with fading momentum "
                        f"(trend_slope={trend.trend_slope}) ahead of a confirmed breakdown")
        return RegimeReading(symbol=symbol, regime=DISTRIBUTION, score=60.0,
                              allowed_setups=allowed_setups_map[DISTRIBUTION], reasons=reasons)

    # ---- 8. Plain trend pass-through, when structure agrees and
    # nothing more specific matched.
    if trend.state in ("STRONG_UPTREND", "WEAK_UPTREND") and structure.structure != "BEARISH_STRUCTURE":
        reasons.append(f"trend_engine={trend.state}, structure does not contradict it")
        regime = STRONG_UPTREND if trend.state == "STRONG_UPTREND" else WEAK_UPTREND
        return RegimeReading(symbol=symbol, regime=regime, score=trend.score,
                              allowed_setups=allowed_setups_map[regime], reasons=reasons)

    # ---- 9. Downtrend
    if trend.state in ("WEAK_DOWNTREND", "STRONG_DOWNTREND"):
        reasons.append(f"trend_engine={trend.state}")
        return RegimeReading(symbol=symbol, regime=DOWNTREND, score=trend.score,
                              allowed_setups=allowed_setups_map[DOWNTREND], reasons=reasons)

    # ---- 10/11. Range vs chop -- both are "no clear directional edge,"
    # distinguished by whether volatility is calm (RANGE, a legitimate
    # base worth watching for a breakout) or elevated/whippy (CHOP,
    # actively dangerous to trade -- matches this project's real
    # 2026-09-04 evidence that whipsaw losers came from exactly this
    # shape).
    atr_pct = features.volatility.get("atr_pct", 0)
    if structure.structure == "RANGE_STRUCTURE" and atr_pct <= cfg["range_max_atr_pct"]:
        # [Phase 4] Qualify a plain RANGE as ACCUMULATION or PRESSURE_
        # BUILDING when a compression reading is available and says so --
        # omitting `compression` entirely reproduces the exact pre-Phase-4
        # RANGE classification below, unchanged.
        if compression is not None and not getattr(compression, "insufficient_data", False):
            if compression.breakout_pressure_score >= cfg["pressure_building_min_breakout_pressure_score"]:
                reasons.append(f"structure=RANGE, calm volatility (atr_pct={atr_pct}), "
                                f"breakout_pressure_score={compression.breakout_pressure_score} -- "
                                f"compression + bullish context building")
                return RegimeReading(symbol=symbol, regime=PRESSURE_BUILDING, score=compression.breakout_pressure_score,
                                      allowed_setups=allowed_setups_map[PRESSURE_BUILDING], reasons=reasons)
            if compression.compression_score >= cfg["accumulation_min_compression_score"]:
                reasons.append(f"structure=RANGE, calm volatility (atr_pct={atr_pct}), "
                                f"compression_score={compression.compression_score} -- qualifies as accumulation")
                return RegimeReading(symbol=symbol, regime=ACCUMULATION, score=compression.compression_score,
                                      allowed_setups=allowed_setups_map[ACCUMULATION], reasons=reasons)
        reasons.append(f"structure=RANGE, calm volatility (atr_pct={atr_pct})")
        return RegimeReading(symbol=symbol, regime=RANGE, score=structure.score,
                              allowed_setups=allowed_setups_map[RANGE], reasons=reasons)

    reasons.append(f"no clear directional edge (structure={structure.structure}, "
                    f"trend={trend.state}, atr_pct={atr_pct})")
    return RegimeReading(symbol=symbol, regime=CHOP, score=100.0 - trend.score,
                          allowed_setups=allowed_setups_map[CHOP], reasons=reasons)
