"""
universe.py

Builds the tradable symbol universe and does a cheap first-pass filter
before any bar data is pulled. Ported from screener/premarket's
premarket_scanner.py (get_universe_symbols / prefilter_by_snapshot),
which itself already implements the same universe filters the
breakout_bot design doc calls for (Part II section 2) -- price band,
liquidity floor, ETF/leveraged/name-complexity exclusion. Reimplemented
here rather than imported so this project has no runtime dependency on
screener/premarket.

Deliberately does NOT apply any RVOL/spread/volatility hard gate here
-- those belong to sip_bot's own scorer.py, not this project's
philosophy (see scanner.py's module docstring: candidate_score is a
pure continuous rank, with no hard reject gates beyond universe
membership and a minimum-bars data-availability check).
"""

from config_loader import get_config
from logger_setup import get_logger
from datetime import datetime
import symbol_filters

log = get_logger("universe")


def _bars_to_dicts(bars) -> list:
    out = []
    for b in bars:
        out.append({
            "t": b.timestamp, "o": float(b.open), "h": float(b.high),
            "l": float(b.low), "c": float(b.close), "v": float(b.volume),
        })
    return out


def get_universe_symbols(client) -> list:
    """
    Pulls active, tradable US equities and applies:
      1. the include/exclude_symbols lists from config
      2. ETF/fund/trust exclusion
      3. leveraged/inverse ("multiplier") product exclusion
      4. company-name syllable-count cap
    """
    cfg = get_config()["universe"]
    if cfg.get("include_only_symbols"):
        return cfg["include_only_symbols"]

    assets = client.get_tradable_assets()
    excluded = set(cfg.get("exclude_symbols", []))

    candidates = [a for a in assets if getattr(a, "tradable", False) and a.symbol not in excluded]
    before = len(candidates)
    survivors = symbol_filters.filter_symbol_list(candidates, log_rejections=True)
    log.info(f"[UNIVERSE] Symbol filters (ETF/leveraged/name-complexity) removed "
             f"{before - len(survivors)} of {before} candidates")
    return survivors


def _prior_session_bar(snap):
    """[BUGFIX 2026-09-25] The last COMPLETE session's daily bar.
    Before the open, today's daily bar doesn't exist yet, so the snapshot's
    daily_bar IS the prior session and previous_daily_bar is two sessions
    back. Confirmed: every 9:28 opening scan (09-17..09-25) saved prev-day
    high/close/volume from two sessions back for 29-30 of 30 symbols (JAGX
    9/25: 'previous day high' 19.46 = 9/23's high, real 9/24 high 7.72;
    gap 1.1% instead of 44%). After the open, daily_bar is today's partial
    bar and previous_daily_bar is correct, as before."""
    from zoneinfo import ZoneInfo
    daily = getattr(snap, "daily_bar", None)
    prev = getattr(snap, "previous_daily_bar", None)
    if daily is not None and getattr(daily, "timestamp", None) is not None:
        tz = ZoneInfo(get_config()["schedule"]["timezone"])
        if daily.timestamp.astimezone(tz).date() < datetime.now(tz).date():
            return daily
    return prev


def prefilter_by_snapshot(client, symbols: list, baseline_out: dict = None,
                           prev_day_high_out: dict = None, prev_close_out: dict = None) -> list:
    """
    Cheap first pass using snapshot data to cut the universe down before
    pulling minute bars for scoring. Filters on price band and a real
    prior-day volume floor.

    baseline_out: if a dict is passed, populated with {symbol: real
        prior-day volume} from snap.previous_daily_bar.volume (the
        prior COMPLETE session -- NOT snap.daily_bar, which is today's
        session-so-far and would make an RVOL calculation compare
        today's volume to itself).

    prev_day_high_out / prev_close_out: same pattern, populated with
        {symbol: previous session's high / close} from
        snap.previous_daily_bar. prev_close is a REQUIRED input to
        scanner.py's gap_pct -- a symbol missing it there gets skipped
        entirely, unlike prev_day_high which fails open (falls back to
        the next resistance level).
    """
    cfg = get_config()["universe"]
    survivors = []

    snapshots = client.get_snapshots(symbols)
    if not snapshots:
        return survivors

    for symbol, snap in snapshots.items():
        try:
            latest_trade = snap.latest_trade
            if latest_trade is None:
                continue
            price = float(latest_trade.price)
            if not (cfg["price_min"] <= price <= cfg["price_max"]):
                continue

            prev_bar = _prior_session_bar(snap)
            if prev_bar is not None and prev_bar.volume:
                prev_volume = float(prev_bar.volume)
                if prev_volume < cfg["min_avg_daily_volume"]:
                    continue
                if baseline_out is not None:
                    baseline_out[symbol] = prev_volume
            if prev_day_high_out is not None and prev_bar is not None and prev_bar.high:
                prev_day_high_out[symbol] = float(prev_bar.high)
            if prev_close_out is not None and prev_bar is not None and prev_bar.close:
                prev_close_out[symbol] = float(prev_bar.close)
            survivors.append(symbol)
        except (AttributeError, TypeError):
            continue

    return survivors
