"""ORB Per-Bar State Dataset Builder.

For each entry in a V49.91 ORB intraday backtest run, expand to one row per
5-min bar from entry through 16:00 ET (regardless of when V49.91's rule
exited — the ML model may decide to hold longer or exit earlier).

Each row is a "decision point" with state features available at that time.
The continuation-value labels are computed in Phase C; this script only
prepares features.

Usage:
    python scripts/orb_per_bar_state.py \
        --run tmp/v49_91_baseline_200_20260506/intraday_20260506_081008_effaac09.json \
        --out tmp/orb_perbar_v49_91_200d
"""
from __future__ import annotations

import argparse
import json
from pathlib import Path

import numpy as np
import pandas as pd

ET = "America/New_York"
DEFAULT_INTRADAY_CACHE = Path("data/cache/intraday")
DEFAULT_ATR_STOP_MULT = 0.75


def _infer_risk_per_share(t: dict) -> float:
    r = t.get("r_multiple_at_exit")
    entry = t["entry_price"]
    exit_p = t["exit_price"]
    direction = t.get("orb_direction", "long")
    if r not in (None, 0) and abs(r) > 1e-6:
        if direction == "long":
            return (exit_p - entry) / r
        return (entry - exit_p) / r
    atr = t.get("atr_at_entry") or 0.0
    return max(atr * DEFAULT_ATR_STOP_MULT, 1e-6)


def _load_intraday(ticker: str, date: str, cache_root: Path) -> pd.DataFrame | None:
    p = cache_root / ticker / f"{date}.parquet"
    if not p.exists():
        return None
    df = pd.read_parquet(p).sort_values("timestamp").reset_index(drop=True)
    df["ts"] = pd.to_datetime(df["timestamp"], utc=True)
    return df


def _market_open_close_utc(date_str: str) -> tuple[pd.Timestamp, pd.Timestamp]:
    d = pd.Timestamp(date_str, tz=ET)
    open_et = d + pd.Timedelta(hours=9, minutes=30)
    close_et = d + pd.Timedelta(hours=16)
    return open_et.tz_convert("UTC"), close_et.tz_convert("UTC")


def _running_vwap(df: pd.DataFrame, market_open_utc: pd.Timestamp) -> np.ndarray:
    """Compute running VWAP from market open onward; bars before open get NaN."""
    typical = (df["high"] + df["low"] + df["close"]) / 3
    vol = df["volume"].astype(float)
    in_session = df["ts"] >= market_open_utc
    pv = np.where(in_session, typical * vol, 0.0)
    v = np.where(in_session, vol, 0.0)
    cum_pv = np.cumsum(pv)
    cum_v = np.cumsum(v)
    with np.errstate(divide="ignore", invalid="ignore"):
        vwap = np.where(cum_v > 0, cum_pv / cum_v, np.nan)
    vwap = np.where(in_session, vwap, np.nan)
    return vwap


def _bars_since_peak(running_peak: np.ndarray) -> np.ndarray:
    """For each i, how many bars ago was the most recent strict new high in running_peak."""
    n = len(running_peak)
    out = np.zeros(n, dtype=np.int32)
    last_peak_idx = 0
    last_peak = running_peak[0]
    for i in range(n):
        if running_peak[i] > last_peak:
            last_peak = running_peak[i]
            last_peak_idx = i
        out[i] = i - last_peak_idx
    return out


def _market_context_aligned(
    market_df: pd.DataFrame | None,
    target_ts: pd.DatetimeIndex,
    entry_ts: pd.Timestamp,
    market_open: pd.Timestamp,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
    """Align market index closes to target bar timestamps via asof; compute returns since entry / open."""
    n = len(target_ts)
    if market_df is None or market_df.empty:
        nan = np.full(n, np.nan)
        return nan, nan.copy(), nan.copy()
    m = market_df.sort_values("ts").reset_index(drop=True)
    target = pd.DataFrame({"ts": target_ts})
    aligned = pd.merge_asof(target, m[["ts", "close"]], on="ts", direction="backward")
    closes = aligned["close"].to_numpy()

    # Entry-time close & open-time close from the same series
    entry_close = (
        m[m["ts"] <= entry_ts]["close"].iloc[-1] if (m["ts"] <= entry_ts).any() else np.nan
    )
    open_close = (
        m[m["ts"] <= market_open]["close"].iloc[-1] if (m["ts"] <= market_open).any()
        else (m["close"].iloc[0] if len(m) else np.nan)
    )
    ret_since_entry = closes / entry_close - 1.0 if pd.notna(entry_close) else np.full(n, np.nan)
    ret_since_open = closes / open_close - 1.0 if pd.notna(open_close) else np.full(n, np.nan)
    return closes, ret_since_entry, ret_since_open


# Trade-level passthrough columns (broadcast across all bars of that trade).
PASSTHROUGH_COLS = [
    "atr_at_entry",
    "gap_pct",
    "rvol",
    "morning_gain_pct",
    "entropy_20d",
    "ret_5d",
    "candidate_score",
    "score_rank_pct",
    "sector_confirmation_active",
    "sector_confirmation_score",
    "entry_dollar_volume",
    "avg_dollar_vol_30d",
    "premarket_dollar_vol",
    "first_bar_dollar_vol",
    "body_ratio",
    "close_location",
    "gap_zscore_20d",
    "obv_slope_20",
    "obv_slope_5",
    "orb_return",
    "is_liquid_largecap",
    "is_moderate_gap_liquid",
    "entry_market_guard_active",
    "entry_market_guard_return_pct",
    "trigger_type",
]


def build_for_trade(
    trade_id: int,
    t: dict,
    intraday_root: Path,
    spy_by_date: dict[str, pd.DataFrame],
    qqq_by_date: dict[str, pd.DataFrame],
) -> pd.DataFrame | None:
    ticker = t["ticker"]
    date = t["date"]
    bars = _load_intraday(ticker, date, intraday_root)
    if bars is None or bars.empty:
        return None

    entry_time = pd.Timestamp(t["entry_time"]).tz_convert("UTC")
    exit_time = pd.Timestamp(t["exit_time"]).tz_convert("UTC")
    entry_price = float(t["entry_price"])
    direction = t.get("orb_direction", "long")
    risk = _infer_risk_per_share(t)
    realized_r = float(t.get("r_multiple_at_exit") or 0.0)

    market_open_utc, market_close_utc = _market_open_close_utc(date)

    # Compute running VWAP on full-day bars (so it accumulates correctly), then slice.
    bars["running_vwap"] = _running_vwap(bars, market_open_utc)

    win = bars[(bars["ts"] >= entry_time) & (bars["ts"] <= market_close_utc)].reset_index(drop=True)
    if win.empty:
        return None

    n = len(win)
    bar_idx = np.arange(n, dtype=np.int32)
    ts = win["ts"]
    minutes_since_entry = ((ts - entry_time).dt.total_seconds() / 60.0).to_numpy()
    minutes_to_close = ((market_close_utc - ts).dt.total_seconds() / 60.0).to_numpy()
    minutes_since_open = ((ts - market_open_utc).dt.total_seconds() / 60.0).to_numpy()

    opens = win["open"].to_numpy()
    highs = win["high"].to_numpy()
    lows = win["low"].to_numpy()
    closes = win["close"].to_numpy()
    vols = win["volume"].to_numpy().astype(float)
    vwap = win["running_vwap"].to_numpy()

    # Favorable-direction R-multiples per bar (long: price-entry; short: entry-price).
    if direction == "long":
        fav_high = (highs - entry_price) / risk
        fav_low = (lows - entry_price) / risk
        fav_close = (closes - entry_price) / risk
    else:
        fav_high = (entry_price - lows) / risk
        fav_low = (entry_price - highs) / risk
        fav_close = (entry_price - closes) / risk

    # Next-bar open R: actionable execution price if you decide at this bar's close.
    next_open_price = np.append(opens[1:], closes[-1])
    if direction == "long":
        fav_next_open = (next_open_price - entry_price) / risk
    else:
        fav_next_open = (entry_price - next_open_price) / risk

    # Running MFE / MAE / peak (vectorized over bars).
    mfe_so_far = np.maximum.accumulate(fav_high)
    mae_so_far = np.minimum.accumulate(fav_low)
    giveback_from_peak = mfe_so_far - fav_close
    bars_since_peak = _bars_since_peak(mfe_so_far)

    # Bar dynamics.
    bar_return_pct = np.where(opens > 0, (closes - opens) / opens, np.nan)
    bar_range = np.maximum(highs - lows, 1e-9)
    bar_close_loc = (closes - lows) / bar_range  # 0 = closed at low, 1 = closed at high

    # Liquidity / VWAP.
    with np.errstate(divide="ignore", invalid="ignore"):
        vwap_dev_pct = np.where((vwap > 0) & np.isfinite(vwap), (closes - vwap) / vwap, np.nan)
    first_vol = vols[0] if vols[0] > 0 else np.nan
    vol_vs_first = vols / first_vol if pd.notna(first_vol) and first_vol > 0 else np.full(n, np.nan)

    # Bar volume vs avg-daily expectation: avg_daily_dollar_volume / entry_price / 78 bars.
    avg_dvol_30d = t.get("avg_dollar_vol_30d") or 0.0
    expected_bar_vol = (avg_dvol_30d / entry_price / 78.0) if entry_price > 0 else np.nan
    if expected_bar_vol and expected_bar_vol > 0 and np.isfinite(expected_bar_vol):
        vol_vs_avg = vols / expected_bar_vol
    else:
        vol_vs_avg = np.full(n, np.nan)

    # Market context (SPY, QQQ).
    spy_close, spy_ret_entry, spy_ret_open = _market_context_aligned(
        spy_by_date.get(date), ts, entry_time, market_open_utc
    )
    qqq_close, qqq_ret_entry, qqq_ret_open = _market_context_aligned(
        qqq_by_date.get(date), ts, entry_time, market_open_utc
    )

    # Baseline benchmark anchors (reference, not features).
    is_held_by_baseline = (ts <= exit_time).to_numpy()
    baseline_exit_at_or_before = (ts >= exit_time).to_numpy()
    # Index of bar nearest to (>=) baseline exit_time.
    diffs = (ts - exit_time).dt.total_seconds().to_numpy()
    if (diffs >= 0).any():
        baseline_exit_bar_idx_val = int(np.argmax(diffs >= 0))
    else:
        baseline_exit_bar_idx_val = n - 1

    # Build the per-bar DataFrame.
    base = {
        # ID
        "trade_id": trade_id,
        "ticker": ticker,
        "date": date,
        "direction": direction,
        "entry_time": entry_time,
        "bar_ts": ts.to_numpy(),
        "bar_idx": bar_idx,
        # Time
        "minutes_since_entry": minutes_since_entry,
        "minutes_to_close": minutes_to_close,
        "minutes_since_open": minutes_since_open,
        # Trade-level static
        "entry_price": entry_price,
        "risk_per_share": risk,
        # Bar OHLCV
        "bar_open": opens,
        "bar_high": highs,
        "bar_low": lows,
        "bar_close": closes,
        "bar_volume": vols,
        # Bar dynamics
        "bar_return_pct": bar_return_pct,
        "bar_close_loc": bar_close_loc,
        # Trade R state
        "current_close_r": fav_close,
        "current_high_r": fav_high,
        "current_low_r": fav_low,
        "next_open_r": fav_next_open,
        "mfe_so_far_r": mfe_so_far,
        "mae_so_far_r": mae_so_far,
        "giveback_from_peak_r": giveback_from_peak,
        "bars_since_peak": bars_since_peak,
        # Liquidity
        "vwap": vwap,
        "vwap_dev_pct": vwap_dev_pct,
        "vol_vs_first_bar": vol_vs_first,
        "vol_vs_avg_dvol30d": vol_vs_avg,
        # Market context
        "spy_close": spy_close,
        "spy_return_since_entry": spy_ret_entry,
        "spy_return_since_open": spy_ret_open,
        "qqq_close": qqq_close,
        "qqq_return_since_entry": qqq_ret_entry,
        "qqq_return_since_open": qqq_ret_open,
        # Baseline benchmark anchors (NOT features for training; used in eval)
        "is_held_by_baseline": is_held_by_baseline,
        "baseline_exit_bar_idx": baseline_exit_bar_idx_val,
        "baseline_exit_time": exit_time,
        "baseline_realized_r": realized_r,
        "baseline_exit_reason": t.get("exit_reason"),
    }

    out = pd.DataFrame(base)
    # Broadcast trade-level passthrough columns (entry-time-known features).
    for col in PASSTHROUGH_COLS:
        out[col] = t.get(col)
    return out


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--run", required=True)
    ap.add_argument("--out", required=True)
    ap.add_argument("--intraday-cache", default=str(DEFAULT_INTRADAY_CACHE))
    args = ap.parse_args()

    out_dir = Path(args.out)
    out_dir.mkdir(parents=True, exist_ok=True)
    intraday_root = Path(args.intraday_cache)

    payload = json.loads(Path(args.run).read_text())
    trades = payload["trades"]
    print(f"Loaded {len(trades)} trades")

    # Pre-load SPY / QQQ once per unique trade date.
    unique_dates = sorted({t["date"] for t in trades})
    spy_by_date: dict[str, pd.DataFrame] = {}
    qqq_by_date: dict[str, pd.DataFrame] = {}
    for d in unique_dates:
        s = _load_intraday("SPY", d, intraday_root)
        q = _load_intraday("QQQ", d, intraday_root)
        if s is not None:
            spy_by_date[d] = s
        if q is not None:
            qqq_by_date[d] = q
    print(f"Loaded SPY for {len(spy_by_date)}/{len(unique_dates)} dates, QQQ for {len(qqq_by_date)}")

    frames = []
    skipped = 0
    for i, t in enumerate(trades):
        try:
            df = build_for_trade(i, t, intraday_root, spy_by_date, qqq_by_date)
            if df is None or df.empty:
                skipped += 1
                continue
            frames.append(df)
        except Exception as e:
            print(f"  trade {i} {t.get('ticker')} {t.get('date')}: {e}")
            skipped += 1
        if (i + 1) % 25 == 0:
            print(f"  processed {i+1}/{len(trades)}")

    if not frames:
        raise SystemExit("No trades produced rows")

    big = pd.concat(frames, ignore_index=True)
    parquet = out_dir / "per_bar_states.parquet"
    big.to_parquet(parquet, index=False)
    print(
        f"\nWrote {parquet} | trades: {big['trade_id'].nunique()} | "
        f"rows: {len(big):,} | columns: {big.shape[1]} | skipped: {skipped}"
    )

    # Quick sanity report.
    rep_path = out_dir / "summary.md"
    rep = build_summary(big, payload.get("metrics", {}))
    rep_path.write_text(rep)
    print(f"Wrote {rep_path}")


def build_summary(df: pd.DataFrame, run_meta: dict) -> str:
    n_trades = df["trade_id"].nunique()
    n_rows = len(df)
    bars_per_trade = df.groupby("trade_id").size()
    cov = {
        "spy_return_since_entry": df["spy_return_since_entry"].notna().mean(),
        "qqq_return_since_entry": df["qqq_return_since_entry"].notna().mean(),
        "vwap_dev_pct": df["vwap_dev_pct"].notna().mean(),
        "vol_vs_avg_dvol30d": df["vol_vs_avg_dvol30d"].notna().mean(),
    }
    lines = [
        "# Per-Bar State Dataset",
        "",
        f"**Run:** `{run_meta.get('run_id')}` "
        f"({run_meta.get('start_date')} → {run_meta.get('end_date')}, "
        f"{run_meta.get('total_trades')} entries)",
        "",
        f"- Trades expanded: **{n_trades}**",
        f"- Total decision-point rows: **{n_rows:,}**",
        f"- Bars per trade: median {int(bars_per_trade.median())}, "
        f"min {int(bars_per_trade.min())}, max {int(bars_per_trade.max())}",
        f"- Schema: **{df.shape[1]}** columns",
        "",
        "## Feature coverage (non-null fraction)",
        "",
        "| feature | coverage |",
        "|---|---|",
    ]
    for k, v in cov.items():
        lines.append(f"| {k} | {v:.1%} |")
    lines += [
        "",
        "## Sanity distributions (random sample of decision points)",
        "",
        f"- current_close_r quartiles: {df['current_close_r'].quantile([0.1,0.25,0.5,0.75,0.9]).round(3).to_dict()}",
        f"- mfe_so_far_r quartiles:    {df['mfe_so_far_r'].quantile([0.1,0.25,0.5,0.75,0.9]).round(3).to_dict()}",
        f"- mae_so_far_r quartiles:    {df['mae_so_far_r'].quantile([0.1,0.25,0.5,0.75,0.9]).round(3).to_dict()}",
        f"- giveback_from_peak_r:      {df['giveback_from_peak_r'].quantile([0.1,0.25,0.5,0.75,0.9]).round(3).to_dict()}",
        f"- vwap_dev_pct:              {df['vwap_dev_pct'].quantile([0.1,0.25,0.5,0.75,0.9]).round(4).to_dict()}",
        f"- spy_return_since_entry:    {df['spy_return_since_entry'].quantile([0.1,0.25,0.5,0.75,0.9]).round(4).to_dict()}",
        "",
        "## Held vs post-baseline-exit bars",
        "",
        f"- Bars while baseline still held: **{int(df['is_held_by_baseline'].sum()):,}** "
        f"({df['is_held_by_baseline'].mean():.1%})",
        f"- Bars after baseline exited (extension data, ML may use to learn 'hold longer'): "
        f"**{int((~df['is_held_by_baseline']).sum()):,}**",
        "",
    ]
    return "\n".join(lines)


if __name__ == "__main__":
    main()
