"""
setup_analyzer.py

[2026-09-23] Per-tick trade-plan engine, built from the user's request to
have the bot do the same read Claude did by hand on AMPL at 14:57 ET
(steady uptrend with rising lows, above VWAP, but pressing a 3-hour
$13.70 ceiling with only 9c to the prior-day high and ~1h left -- "pass
now; buy a volume breakout above $13.72 or a pullback that holds
$13.63"). Replaces smart_engine's Stage 3 (imbalance) + entry_score
final gate when smart_engine.use_setup_plan is on; its stop replaces the
3x 1-min ATR stop.

Recomputed from scratch every call -- nothing cached -- so the plan's
levels, stop, target and triggers move with the chart:

  levels   VWAP, high of day, premarket high, prior-day high, 20-day
           high, and the current ceiling (exit_resistance_stall's
           cluster finder: >=3 one-minute highs within max(2c, 0.3%)).
  lows     pivot lows (a completed bar's low that is the lowest within
           +/- pivot_bars); "rising lows" = the last 3 strictly
           increasing. The last pivot low under price anchors the stop.
  stop     min(last pivot low - buffer, price - floor), floor =
           max(min_stop_pct of price, 2x spread) -- placeholder until
           the volatility-scaled floor is chosen from backtests.
  target   nearest level above the ceiling / above price; with nothing
           above (clear air), price + the typical 60-minute move.
  R:R      (target - price) / (price - stop), must be >= min_reward_risk.
  time     target distance must fit in what the stock typically moves
           in the minutes left before force-liquidation.
  volume   avg shares/min must clear min_shares_per_min; a breakout bar
           needs >= breakout_volume_ratio x average volume.

Plan states: PASS (with reasons), WATCH (breakout and/or pullback
triggers pending), BUY (a trigger fired and every check passed).
BUY triggers:
  breakout  one of the last 2 completed bars closed above a level
            (the ceiling or a key level it was below the bar before) by
            breakout_buffer on >= breakout_volume_ratio x avg volume.
  pullback  rising lows intact, price inside [last pivot low, +zone],
            and the last completed bar closed green with price holding
            at/above its close (turning back up).
"""

from dataclasses import dataclass, field
from datetime import datetime
from statistics import median

from config_loader import get_config
from indicators import vwap, atr, normalized_slope_pct
from exit_resistance_stall import _find_ceiling

PASS = "PASS"
WATCH = "WATCH"
BUY = "BUY"

DEFAULT_CONFIG = {
    "min_reward_risk": 1.5,
    "pivot_bars": 2,
    "ceiling_lookback_bars": 120,
    "ceiling_min_touches": 3,
    "band_cents": 2.0,
    "band_pct": 0.3,
    "breakout_buffer_cents": 1.0,
    "breakout_volume_ratio": 1.5,
    "stop_buffer_cents": 1.0,
    "min_stop_pct": 0.5,
    "min_shares_per_min": 500,
    "pullback_zone_fraction": 0.25,
    "pullback_zone_min_cents": 2.0,
    "time_fit_factor": 1.0,
    # [2026-09-23] Where a BREAKOUT's stop goes: "pivot" (the last pivot low,
    # same as pullbacks -- original behavior) or "level" (just under the
    # level that was broken, which should now act as support; still at
    # least the volatility floor away). With "pivot", breakout stops ran
    # ~29c wide on average and R:R failed 57 of 61 breakouts.
    "breakout_stop": "pivot",
    # [2026-09-23] Setup-specific ("context-dependent") entry rules, user's
    # design: slope matters differently for a breakout than for a pullback.
    # OFF by default -- the plan then behaves exactly as before.
    "context_rules": {
        "enabled": False,
        "breakout_slope_bars": 5,                 # breakout: last N bars must slope UP (above flat band)
        "pullback_require_reclaim": True,         # pullback: last bar closes above the prior bar's high
        "pullback_max_sell_volume_ratio": 0.7,    # pullback: red-bar volume <= this x the advance leg's volume
        "pullback_min_imbalance": -0.3,           # pullback: 20s trade imbalance not below this (selling fading)
        "pullback_require_support": True,         # pullback low holds the nearest support (VWAP / broken level)
        "pullback_exempt_lower_highs": True,      # Stage 1's 4-bar lower-highs/lows reject is waived for a live pullback
        "swing_lookback_bars": 30,
    },
}


@dataclass
class Plan:
    state: str
    setup: str = ""
    stop: float = None
    target: float = None
    target_label: str = ""
    reward_risk: float = None
    triggers: list = field(default_factory=list)
    reasons: list = field(default_factory=list)
    metrics: dict = field(default_factory=dict)

    def summary(self) -> str:
        parts = [self.state + (f" ({self.setup})" if self.setup else "")]
        if self.stop is not None and self.target is not None:
            parts.append(f"stop {self.stop:.2f}, target {self.target:.2f} ({self.target_label}), "
                         f"R:R {self.reward_risk:.2f}")
        parts += self.reasons + self.triggers
        return "; ".join(parts)

    def to_dict(self) -> dict:
        return {"state": self.state, "setup": self.setup, "stop": self.stop, "target": self.target,
                "target_label": self.target_label, "reward_risk": self.reward_risk,
                "triggers": self.triggers, "reasons": self.reasons, **self.metrics}


def _merge_cfg(cfg):
    base = {**DEFAULT_CONFIG, **(cfg or get_config().get("setup_plan", {}))}
    base["context_rules"] = {**DEFAULT_CONFIG["context_rules"], **base.get("context_rules", {})}
    return base


def _pullback_context(completed: list, price: float, v: float, key: dict, band: float,
                      trade_imbalance, ctx: dict) -> tuple:
    """Checks the user's pullback conditions beyond 'higher low intact' and
    'R:R acceptable' (already in analyze). Returns (ok, [failed reasons],
    details)."""
    # The last completed bar is the candidate reclaim/trigger bar -- find the
    # swing and the pullback on the bars BEFORE it, or a reclaim bar that
    # prints a new high would erase the very pullback it is confirming.
    win = completed[-ctx["swing_lookback_bars"] - 1:-1]
    if len(win) < 3:
        return False, ["not enough bars for pullback context"], {}
    hi_i = max(range(len(win)), key=lambda i: win[i]["h"])
    lo_i = min(range(hi_i + 1), key=lambda i: win[i]["l"]) if hi_i > 0 else 0
    advance, pull = win[lo_i:hi_i + 1], win[hi_i + 1:]
    fails, d = [], {}
    if not pull:
        return False, ["no pullback yet (still at the swing high)"], d
    adv_vol = sum(b["v"] for b in advance) / len(advance)
    red = [b for b in pull if b["c"] < b["o"]]
    sell_ratio = (sum(b["v"] for b in red) / len(red)) / adv_vol if red and adv_vol else 0.0
    d["sell_volume_ratio"] = round(sell_ratio, 2)
    if sell_ratio > ctx["pullback_max_sell_volume_ratio"]:
        fails.append(f"selling not fading: red-bar volume {sell_ratio:.2f}x the advance "
                     f"(max {ctx['pullback_max_sell_volume_ratio']})")
    if trade_imbalance is not None:
        d["imbalance"] = round(trade_imbalance, 2)
        if trade_imbalance < ctx["pullback_min_imbalance"]:
            fails.append(f"imbalance {trade_imbalance:+.0%} still heavy selling")
    if ctx["pullback_require_support"]:
        pull_low = min(b["l"] for b in pull)
        supports = [v] + [lv for k, lv in key.items() if k != "high_of_day" and lv < price]
        support = max(supports)
        d["support"] = round(support, 4)
        if pull_low < support - band:
            fails.append(f"pullback low {pull_low:.2f} broke support {support:.2f}")
    if ctx["pullback_require_reclaim"] and len(completed) >= 2:
        reclaim = completed[-1]["c"] > completed[-2]["h"]
        d["reclaim"] = reclaim
        if not reclaim:
            fails.append(f"no reclaim yet (needs a close > {completed[-2]['h']:.2f})")
    return not fails, fails, d


def _pivot_lows(bars: list, k: int) -> list:
    out = []
    for i in range(k, len(bars) - k):
        lo = bars[i]["l"]
        if all(lo <= bars[j]["l"] for j in range(i - k, i + k + 1)):
            if not out or out[-1][1] != lo or i - out[-1][0] > k:
                out.append((i, lo))
    return out


def _typical_move(closes: list, span: int) -> float:
    moves = [abs(closes[i] - closes[i - span]) for i in range(span, len(closes))]
    return median(moves) if moves else 0.0


def analyze(symbol: str, bars: list, levels: dict, quote: dict, now: datetime,
            cfg: dict = None, stop_floor: float = None, trade_imbalance: float = None) -> Plan:
    """stop_floor: minimum stop distance in dollars from volatility.py
    (smart_engine passes it when smart_engine.min_stop is enabled); when
    None, the placeholder max(min_stop_pct, 2x spread) floor applies."""
    cfg = _merge_cfg(cfg)
    sched = get_config()["schedule"]
    completed = bars[:-1]
    if len(completed) < 10:
        return Plan(PASS, reasons=["not enough bars yet"])

    price = bars[-1]["c"]
    v = vwap(bars)
    a1 = atr(bars, period=min(14, len(bars) - 1))
    spread = (quote["ask"] - quote["bid"]) if quote and quote.get("ask") and quote.get("bid") else 0.0
    vols = [b["v"] for b in completed[-60:]]
    avg_vol = sum(vols) / len(vols)
    shares_per_min = sum(b["v"] for b in completed[-30:]) / min(30, len(completed))
    closes = [b["c"] for b in bars]
    band = max(cfg["band_cents"] / 100.0, price * cfg["band_pct"] / 100.0)
    buf = cfg["breakout_buffer_cents"] / 100.0

    # --- minutes left and how far the stock typically travels in that time
    from zoneinfo import ZoneInfo
    tz = ZoneInfo(sched["timezone"])
    now_et = now.astimezone(tz)
    fh, fm, fs = (int(x) for x in sched["force_liquidate_time"].split(":"))
    minutes_left = max(0.0, (now_et.replace(hour=fh, minute=fm, second=fs, microsecond=0)
                             - now_et).total_seconds() / 60.0)
    # Early in the session there isn't 30 min of history yet: measure the
    # typical move over whatever span exists and scale it (sqrt-time).
    span = max(1, min(30, len(closes) // 2))
    move_span = _typical_move(closes, span)
    reachable = move_span * (minutes_left / span) ** 0.5 * cfg["time_fit_factor"] if minutes_left else 0.0

    # --- structure
    pivots = _pivot_lows(completed, cfg["pivot_bars"])
    below = [p for p in pivots if p[1] < price]
    last_low = below[-1][1] if below else None
    last3 = [p[1] for p in pivots[-3:]]
    rising_lows = len(last3) == 3 and last3[0] < last3[1] < last3[2]

    window = completed[-cfg["ceiling_lookback_bars"]:]
    c_top, c_bottom, cluster = _find_ceiling(window, band, cfg["ceiling_min_touches"])
    hod = max(b["h"] for b in bars)
    key = {k: lv for k, lv in (levels or {}).items() if lv and k not in ("session_high", "daily_atr")}
    key["high_of_day"] = hod
    if c_top is not None:
        key["ceiling"] = c_top

    # --- stop
    floor = stop_floor if stop_floor is not None else max(price * cfg["min_stop_pct"] / 100.0, 2 * spread)
    candidates = [price - floor]
    if last_low is not None:
        candidates.append(last_low - cfg["stop_buffer_cents"] / 100.0)
    stop = min(candidates)
    risk = price - stop

    # --- target: nearest level above price (beyond the ceiling when pressing it)
    ref = max(price, c_top) if c_top is not None and price >= c_bottom - band else price
    above = sorted((lv, k) for k, lv in key.items() if lv > ref + buf)
    if above:
        target, target_label = above[0]
    else:
        target, target_label = price + _typical_move(closes, 60), "clear air (typical 60-min move)"
    rr = (target - price) / risk if risk > 0 else 0.0

    metrics = {
        "price": price, "vwap": round(v, 4), "atr_1m": round(a1, 4), "spread": round(spread, 4),
        "shares_per_min": round(shares_per_min), "rising_lows": rising_lows,
        "last_pivot_low": last_low, "ceiling_top": c_top, "ceiling_bottom": c_bottom,
        "ceiling_touches": len(cluster), "minutes_left": round(minutes_left, 1),
        "reachable": round(reachable, 4),
    }
    plan = Plan(WATCH, stop=round(stop, 4), target=round(target, 4), target_label=target_label,
                reward_risk=round(rr, 2), metrics=metrics)

    # --- hard passes
    if shares_per_min < cfg["min_shares_per_min"]:
        plan.state = PASS
        plan.reasons.append(f"thin: {shares_per_min:.0f} sh/min < {cfg['min_shares_per_min']}")
    if price < v:
        plan.state = PASS
        plan.reasons.append(f"below VWAP {v:.2f}")
    if (target - price) > reachable:
        plan.state = PASS
        plan.reasons.append(f"target {target - price:.2f} away > ~{reachable:.2f} reachable "
                            f"in {minutes_left:.0f} min")
    if plan.state == PASS:
        return plan

    # --- breakout trigger
    breakout = None
    lvls = dict(key)
    for i in (-2, -1):
        if len(completed) < abs(i) + 1:
            continue
        b, prev = completed[i], completed[i - 1]
        closed_before = max(x["c"] for x in completed[:len(completed) + i])
        for name, lv in lvls.items():
            if name == "high_of_day":
                continue
            # A breakout is the FIRST close through a level; re-crossing a
            # level already closed above earlier is a reclaim, not a breakout.
            if name != "ceiling" and closed_before > lv + buf:
                continue
            if prev["c"] <= lv and b["c"] > lv + buf and b["v"] >= avg_vol * cfg["breakout_volume_ratio"]:
                breakout = (name, lv, b)
    ctx = cfg["context_rules"]
    slope_ok = True
    if ctx["enabled"]:
        n = ctx["breakout_slope_bars"]
        slope_n = normalized_slope_pct(closes[-n:])
        flat = max(0.05, (a1 / price * 100.0) * 0.25) if price else 0.05
        slope_ok = slope_n > flat
        metrics["breakout_slope_pct"], metrics["flat_band_pct"] = round(slope_n, 4), round(flat, 4)
    if breakout and cfg["breakout_stop"] == "level":
        name, lv, b = breakout
        b_stop = min(lv - cfg["stop_buffer_cents"] / 100.0, price - floor)
        if price - b_stop > 0:
            rr = (target - price) / (price - b_stop)
            plan.stop, plan.reward_risk = round(b_stop, 4), round(rr, 2)
    if breakout:
        name, lv, b = breakout
        if not slope_ok:
            plan.reasons.append(f"breakout of {name} {lv:.2f} but last {ctx['breakout_slope_bars']} bars not "
                                f"sloping up ({metrics['breakout_slope_pct']:.3f}% <= {metrics['flat_band_pct']:.3f}%)")
        elif rr >= cfg["min_reward_risk"] and price > lv:
            plan.state, plan.setup = BUY, "breakout"
            plan.reasons.append(f"{b['t'].strftime('%H:%M') if hasattr(b['t'], 'strftime') else ''} bar closed "
                                f"{b['c']:.2f} > {name} {lv:.2f} on {b['v']:.0f} sh "
                                f"({b['v'] / avg_vol:.1f}x avg)")
            return plan
        if slope_ok:
            plan.reasons.append(f"broke {name} {lv:.2f} but R:R {rr:.2f} < {cfg['min_reward_risk']}"
                                if rr < cfg["min_reward_risk"] else f"broke {name} but price back under it")

    # --- pullback trigger
    if rising_lows and last_low is not None:
        zone_top = last_low + max((target - last_low) * cfg["pullback_zone_fraction"],
                                  cfg["pullback_zone_min_cents"] / 100.0)
        last_bar = completed[-1]
        in_zone = last_low <= price <= zone_top
        metrics["pullback_candidate"] = in_zone
        if ctx["enabled"]:
            ok, fails, details = _pullback_context(completed, price, v, key, band, trade_imbalance, ctx)
            metrics["pullback_context"] = details
            turning = ok and price >= last_bar["c"]
        else:
            fails = []
            turning = last_bar["c"] > last_bar["o"] and price >= last_bar["c"]
        if in_zone and turning and rr >= cfg["min_reward_risk"]:
            plan.state, plan.setup = BUY, "pullback"
            plan.reasons.append(f"held rising low {last_low:.2f}, turning up in zone "
                                f"{last_low:.2f}-{zone_top:.2f}"
                                + (" (reclaim, selling fading, support held)" if ctx["enabled"] else ""))
            return plan
        if in_zone and fails:
            plan.reasons.extend(fails)
        plan.triggers.append(f"pullback: buy {last_low:.2f}-{zone_top:.2f} holding + "
                             + ("reclaim/fading selling/support" if ctx["enabled"] else "green bar"))

    # --- still watching: say what would trigger
    if c_top is not None and price >= c_bottom - band:
        plan.triggers.append(f"breakout: 1-min close > {c_top + buf:.2f} on "
                             f">= {avg_vol * cfg['breakout_volume_ratio']:.0f} sh")
    if rr < cfg["min_reward_risk"]:
        plan.reasons.append(f"R:R {rr:.2f} < {cfg['min_reward_risk']} at {price:.2f}")
    if not plan.triggers:
        plan.state = PASS
        plan.reasons.append("no breakout level or rising-low pullback to trade")
    return plan
