"""
Live match monitor — polls API-Football for scores, detects halftime,
then checks Kalshi for edge.

Watchlist is read from E:\\soccer\\watchlist.txt and auto-reloads whenever
the file changes — no restart needed. Just edit the file and save.

Format: home | away | league | kickoff (HH:MM 24h)  [| fixture_id]
Lines starting with # are ignored.

Usage:
  python live_monitor.py
"""
import sys, requests, time, csv, smtplib, json
from datetime import date, datetime
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
from pathlib import Path
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
import maps

# ── Config ─────────────────────────────────────────────────────────────────
API_KEY      = "81b487be73030f5dec980d2a1ef2c406"
POLL_SECS    = 60        # seconds between Kalshi polls
AF_POLL_SECS = 300       # seconds between API-Football score fetches. Was 180,
                         # which oversampled a feed this same line already noted
                         # "updates every ~5 min" -- two of every five score
                         # batches could only return what we already had.
LIVE_LOG_DIR = Path(r"E:\soccer\live_data")
CLV_LOG      = Path(r"E:\soccer\data\clv_kalshi_log.csv")
AF_LOW_WATER = 300       # remaining API calls below which we stop using API-Football
QUOTA_CHECK_SECS = 600   # how often to ask /status. Was an undeclared 120.
WATCHLIST_F  = Path(r"E:\soccer\data\watchlist.txt")

# ── GRACEFUL RECYCLE (2026-09-07) ────────────────────────────────────────
# Touch this file to ask the monitor to exit at the top of its next cycle;
# SoccerMonitorGuard then restarts it within ~5 minutes.
#
# Why it exists. The guard launches this process as SYSTEM, so a non-elevated
# `Stop-Process` gets Access Denied, and until now there was no other way to
# recycle it -- which matters because config edits only take effect on restart
# (daily_signals is a cached module, so an edit to AUTO_TRADE_EXCLUDE_LEAGUES
# is invisible to a running monitor). Worse, the obvious elevated one-liner
# filters on CommandLine, which reads EMPTY for another user's process at
# normal privilege, so it silently matches nothing and looks like it worked.
#
# THE FILE IS DELETED BEFORE EXITING, on every path. A kill file that survives
# the thing it killed is an outage, not a switch: STOP_MAKER once sat on disk
# after a failed restart and disabled the maker AND its watchdog for 23 hours.
# Deleting first means a delete that throws still leaves a running monitor.
RECYCLE_F = Path(r"E:\soccer\RECYCLE_MONITOR")
CLV_THRESH   = 0.05      # flag outcomes where CLV > 5%

# ── Email config ────────────────────────────────────────────────────────────
def _load_env(path):
    env = {}
    try:
        for line in open(path):
            line = line.strip()
            if line and not line.startswith('#') and '=' in line:
                k, v = line.split('=', 1)
                env[k.strip()] = v.strip()
    except Exception:
        pass
    return env

_env               = _load_env(r'E:\soccer\.env')
EMAIL_ADDRESS      = _env.get('EMAIL_SENDER', '')
EMAIL_RECIPIENT    = _env.get('EMAIL_RECIPIENT', '')
EMAIL_APP_PASSWORD = _env.get('EMAIL_APP_PASSWORD', '')
BANKROLL_FILE        = Path(r'E:\soccer\data\bankroll.json')
KALSHI_BANKROLL_FILE = Path(r'E:\soccer\data\kalshi_bankroll.json')
POLY_BANKROLL_FILE   = Path(r'E:\soccer\data\poly_bankroll.json')
EDGE_THRESH_AWAY   = 0.05   # Kalshi or B365 CLV threshold for Away bets
EDGE_THRESH_HOME   = 0.05   # Kalshi or B365 CLV threshold for Home bets (lowered 8%->5%, 2026-05-23 audit)
EDGE_UPPER_CAP_AWAY = 0.12  # AWAY CLV >=12% has been -ROI (long-shot trap, 2026-05-23 audit)
EDGE_UPPER_CAP_HOME = 0.12  # HOME CLV >=12% has been -ROI (Kalshi-CLV audit 2026-05-23: 14 bets, -41% ROI)

# MAX ASK-MINUS-MID ON THE CONFIRMING BOOK (2026-09-06).
#
# The rule decides on the MID and records/pays the ASK. On a liquid book those
# differ by a cent and it does not matter. On an empty one they diverge
# completely and the signal describes a price nobody can trade.
#
# FC Sion v FC Thun (CH1, Poly-US): polyus mid ~0.50 gave clv_polyus +7.8% and
# fired, while polyus_home_ask was 0.99 -- a 49-cent spread. The entry went to
# the ledger with ask 0.99 beside CLV +7.8%, two numbers that cannot both be
# true, and the dashboard then scored it at 0.99: a 1c win against a 99c loss.
#
# Sized from the data, not taste. Across 425 scoped f10 signals the ask-mid
# spread is p50 +0.005, p99 +0.015, MAX +0.030 -- so 0.10 drops NOTHING from
# the measured rule and fires only on books like that one. It is a liquidity
# sanity check, not a tightening of the edge condition: requiring the ask to
# clear the full band was tried first and dropped 75% of signals, including
# ones running +19.4%.
MAX_ASK_MID_SPREAD = 0.10
KELLY_MULT         = 0.25
MAX_BET_PCT        = 0.25
# Kalshi paper Kelly sizing COMPOUNDS off the live Kalshi tracker (mirrors the
# B365 leg), not a fixed notional. Changed 2026-05-31 at user request. The
# $5/order cap on any real auto-trade still lives in kalshi_trader.

# ── Strategy selector ─────────────────────────────────────────────────────
# Which signal rule live_monitor fires on (alerts + pending + auto-trade all
# flow from this). Change ACTIVE_STRATEGY here and RESTART live_monitor, or
# override at launch:  python main\live_monitor.py --strategy xlow
STRATEGIES = {
    'baseline': "pin-CLV [5,12)% vs Pinnacle on each book independently (original live strategy).",
    'xlow':     "exclusive-low: bet the CHEAPER exchange (Kalshi vs Poly) when it is BOTH "
                "below the other book AND below Pinnacle (pin-CLV [5,12)%). Requires BOTH "
                "exchanges quoting — fires nothing on matches without live Polymarket prices.",
}
ACTIVE_STRATEGY = 'baseline'

# Which signals get EMAILED (bookkeeping/pending/auto-trade follow ACTIVE_STRATEGY
# and are unaffected): 'all' = every ACTIVE_STRATEGY alert; 'xlow' = only strict
# exclusive-low configurations (checked independently each snapshot, so an xlow
# shape that forms AFTER the baseline signal still emails once). 2026-07-11.
EMAIL_ALERT_STRATEGY = 'none'   # 2026-07-24: per-signal alert emails REPLACED by the
                                # periodic dashboard email (main/dashboard.py --email,
                                # SoccerDashboard task, every 3h) which covers signals +
                                # real trades + positions + forward test in one place.
                                # Set back to 'xlow' or 'all' to re-enable instant alerts.
                                # NOTE: bookkeeping/pending-logging/auto-trade are NOT
                                # gated on this -- they run regardless.

# xlow stakes are FLAT, not Kelly (research/xlow_sizing_compare.py, 2026-07-16):
# on the 286-bet backtest stream Kelly refused 88 still-profitable bets (f<=0 at
# Kalshi's ask) and under-staked the rest — flat $10 ended +$619 vs Kelly's +$141.
# Never skip an xlow signal because Kelly f<=0; the band shape IS the signal.
# Sizing is a flat PERCENT of the day-start tracker (2026-07-17, user decision):
# the tracker only moves at the 00:05 nightly resolve, so reading it at alert
# time IS the day-start value — every bet in a day is equal-sized, and stakes
# compound across days (the best scheme in the sizing sim: +$752 vs +$619 fixed).
XLOW_STAKE_PCT = 0.01
XLOW_STAKE_FALLBACK = 10.0   # if a tracker file is unreadable

# FLAT $1 STAKES (user, 2026-08-23, on re-enabling live execution).
# Overrides the percent-of-tracker scheme above whenever it is not None. The
# percent scheme compounds and would size ~$18/bet off the current $1,803
# tracker; the point of this restart is a deliberately small live sample while
# the edge is still inside its own execution cost (project_xlow_spread_verdict),
# so the stake is pinned rather than scaled.
#
# This is the PAPER/tracker stake too, so the tracker ledger and the real orders
# stay the same size and remain comparable. Set back to None to restore
# percent-of-bankroll sizing.
XLOW_STAKE_FIXED = 1.00
XLOW_PREFER_POLYUS = False # was True: try Poly-US first, fall back to Kalshi
                           # (2026-08-31, user). OFF 2026-09-29 with
                           # XLOW_KALSHI_ONLY below.
XLOW_KALSHI_ONLY = True    # 2026-09-29, user: "only trade on Kalshi for now".
                           # Poly-US is dropped from venue selection entirely,
                           # so the Kalshi $50 account floor (kalshi_trader.
                           # KALSHI_FLOOR_USD) covers every real xlow order.
                           # data/AUTO_TRADE_POLYUS_KILL stays engaged as a
                           # second guard. Remove both to re-allow Poly-US.

# Leagues with NO Polymarket.com coverage (poly_tag=None in league_map.json)
# but WITH Kalshi + verified Polymarket US coverage (poly_us_prices.LEAGUE_SLUG).
# For these leagues ONLY, the xlow rule substitutes Poly-US for Poly.com in the
# CLV comparison (added 2026-07-23) -- entries get tagged strategy='xlow_polyus'
# so they never mix into the frozen Kalshi-vs-Poly.com xlow ledger/forward test.
# CH1 added 2026-07-29: Kalshi (KXSWISSLEAGUEGAME) + Poly-US (`swsl`, 6
# events) both carry it; Poly.com coverage UNCONFIRMED, so treat it as a
# gap league. If Poly.com turns out to list it, drop CH1 from this set so
# xlow uses the frozen Kalshi-vs-Poly.com path instead.
GAP_LEAGUES = {'USL', 'PL1', 'CH1'}

# ── API-FOOTBALL BUDGET: cheaper odds for leagues that cannot trade ───────
# 2026-09-05. The daily quota (Pro, 7,500) hit ZERO in the evening and the
# monitor spent the rest of the day logging "quota low -- Kalshi only". xlow
# needs BOTH books quoting, so with no Pinnacle side it cannot fire at all:
# the quota running out silently ends signal generation for the day.
#
# The cause is not a leak. _ODDS_TTL's own budget note was written for "a
# 124-fixture Saturday" at ~3,673 calls (49%). The watchlist now carries 44
# leagues and 197 fixtures on a peak day, which scales to ~5,835 (78%) before
# find_fixture_id, daily_signals, daily_watchlist and the refresh jobs are
# added on top. The fixture universe outgrew the plan.
#
# 41% of those fixtures are in leagues that can NEVER produce a real bet --
# xlow only trades PRIMARY_LEAGUES plus the Poly-US gap leagues. Their odds
# are collected purely so the "outside scope, tracking" row keeps accruing.
#
# NOT dropped, only sampled more slowly. That tracking row is how a league
# earns promotion: KO1 joined the scope on 2026-09-01 on a record of n=41,
# +32.8% ROI, z+1.62 built exactly this way. Blinding it would remove the only
# mechanism by which the scope can ever grow.
#
# 6x is anchored on measurement, not taste: research/xlow_vs_line_age.py found
# the Pinnacle line moves about every 5.8 HOURS, so a ~6h floor on a
# non-trading league loses roughly one sample per line move. Peak day goes
# 5,835 -> ~3,811 calls (78% -> 51%), which leaves headroom for the fixed
# costs that actually pushed it over.
TRACKING_TTL_MULT = 6

def _tradeable_leagues():
    """Leagues a real xlow bet can happen in: the live scope plus the gap set.

    Read from daily_signals rather than duplicated, so the two cannot drift --
    a league added to the scope must not keep being polled at tracking cadence.

    FAILS OPEN. If the import breaks, every league is treated as tradeable and
    we merely spend quota at the old rate. The opposite default would quietly
    coarsen the leagues we actually trade, which is the expensive direction.
    """
    global _TRADEABLE_CACHE
    if _TRADEABLE_CACHE is None:
        try:
            from daily_signals import AUTO_TRADE_LEAGUES
            _TRADEABLE_CACHE = set(AUTO_TRADE_LEAGUES) | GAP_LEAGUES
        except Exception as e:
            print(f'  [odds] cannot read trade scope ({e}) -- polling every '
                  f'league at full rate')
            _TRADEABLE_CACHE = None
            return None
    return _TRADEABLE_CACHE

_TRADEABLE_CACHE = None

def _side_book_flags(row, side, strategy=None):
    """Which books are eligible to bet on `side` under `strategy` (default
    ACTIVE_STRATEGY). Returns (b365_ok, kalshi_ok, poly_ok). Pre-match snapshot
    has already run compute_clv(), so clv_* and *_mid keys are present on `row`."""
    strategy = strategy or ACTIVE_STRATEGY
    lo = EDGE_THRESH_AWAY if side == 'away' else EDGE_THRESH_HOME
    hi = EDGE_UPPER_CAP_AWAY if side == 'away' else EDGE_UPPER_CAP_HOME
    clv_b = float(row.get(f'clv_b365_{side}') or 0)
    clv_k = float(row.get(f'clv_kalshi_{side}') or 0)
    clv_p = float(row.get(f'clv_poly_{side}') or 0)
    inband = lambda c: lo <= c < hi
    if strategy == 'xlow':
        # GAP_LEAGUES (no Poly.com coverage, e.g. USL/PL1): substitute Poly-US
        # for Poly.com in the same strict comparison, added 2026-07-23. Kept as
        # a separate branch (not a column-name parameter) so the proven
        # Kalshi-vs-Poly.com path below is untouched byte-for-byte.
        if row.get('league') in GAP_LEAGUES:
            k_mid = float(row.get(f'kalshi_{side}_mid') or 0)
            pu_mid = float(row.get(f'polyus_{side}_mid') or 0)
            clv_pu = float(row.get(f'clv_polyus_{side}') or 0)
            if not (0 < k_mid < 1 and 0 < pu_mid < 1):
                return (False, False, False)       # needs BOTH Kalshi + Poly-US quoting
            # Same structure as the normal branch below: slot2=kalshi_ok when
            # Kalshi is the cheaper/qualifying book, slot3=poly_ok-equivalent
            # when Poly-US is (2026-07-24 fix -- previously this always reported
            # "kalshi" regardless of which book actually fired, which broke the
            # "execute via whichever book xlow actually named" rule below).
            kalshi_ok = (k_mid < pu_mid) and inband(clv_k) and not inband(clv_pu)
            polyus_ok = (pu_mid < k_mid) and inband(clv_pu) and not inband(clv_k)
            return (False, kalshi_ok, polyus_ok)
        # STRICT exclusive-low (spec corrected 2026-07-05): bet the cheaper
        # exchange only if it's below the other, below Pinnacle (in band), AND
        # the other book is NOT in band — one book in band while the other
        # isn't. The other book sitting near fair is what confirms Pinnacle;
        # both-in-band = overlap (weak bucket), skip.
        k_mid = float(row.get(f'kalshi_{side}_mid') or 0)
        p_mid = float(row.get(f'poly_{side}_mid') or 0)
        if not (0 < k_mid < 1 and 0 < p_mid < 1):
            return (False, False, False)          # xlow needs BOTH exchanges quoting
        kalshi_ok = (k_mid < p_mid) and inband(clv_k) and not inband(clv_p)
        poly_ok   = (p_mid < k_mid) and inband(clv_p) and not inband(clv_k)
        return (False, kalshi_ok, poly_ok)        # B365 not used by xlow
    # 'baseline' (default): each book independently vs Pinnacle fair.
    return (inband(clv_b), inband(clv_k), inband(clv_p))

def _load_bankroll():
    try:
        return json.loads(BANKROLL_FILE.read_text())['bankroll']
    except Exception:
        return None

def _load_kalshi_bankroll():
    try:
        return json.loads(KALSHI_BANKROLL_FILE.read_text())['bankroll']
    except Exception:
        return None

def _load_poly_bankroll():
    try:
        return json.loads(POLY_BANKROLL_FILE.read_text())['bankroll']
    except Exception:
        return None

def send_email(subject, html_body):
    if not EMAIL_ADDRESS or not EMAIL_APP_PASSWORD:
        print("  [EMAIL] Not configured — skipping.")
        return
    try:
        msg = MIMEMultipart('alternative')
        msg['Subject'] = subject
        msg['From']    = EMAIL_ADDRESS
        msg['To']      = EMAIL_RECIPIENT
        msg.attach(MIMEText(html_body, 'html'))
        with smtplib.SMTP_SSL('smtp.gmail.com', 465) as srv:
            srv.login(EMAIL_ADDRESS, EMAIL_APP_PASSWORD)
            srv.sendmail(EMAIL_ADDRESS, EMAIL_RECIPIENT, msg.as_string())
        print(f"  [EMAIL] Sent: {subject}")
    except Exception as e:
        print(f"  [EMAIL] FAILED: {e}")

def _build_clv_alert(w, row, mins_to_ko, strategy=None, gate_pending=True):
    """Compute CLV edges for one match. Returns alert dict or None if no edge."""
    bankroll        = _load_bankroll()
    kalshi_bankroll = _load_kalshi_bankroll()
    poly_bankroll   = _load_poly_bankroll()

    def _kelly_stake(pf, odds, use_kalshi=False, use_poly=False):
        br = poly_bankroll if use_poly else (kalshi_bankroll if use_kalshi else bankroll)
        if not (pf and odds and br):
            return None
        b = odds - 1
        f = max((pf * b - (1 - pf)) / b, 0)
        stake = round(min(f * KELLY_MULT, MAX_BET_PCT) * br, 2)
        return max(stake, 1.0) if stake > 0 else None

    def _kalshi_odds(mid):
        return (1 / mid) if mid and mid > 0 else 0

    _strat = strategy or ACTIVE_STRATEGY

    def _stake(pf, odds, use_kalshi=False, use_poly=False):
        if _strat == 'xlow':
            if XLOW_STAKE_FIXED is not None:
                return XLOW_STAKE_FIXED
            br = poly_bankroll if use_poly else (kalshi_bankroll if use_kalshi else bankroll)
            return round(XLOW_STAKE_PCT * br, 2) if br else XLOW_STAKE_FALLBACK
        return _kelly_stake(pf, odds, use_kalshi=use_kalshi, use_poly=use_poly)

    pfh = row.get('pin_fair_home') or 0
    pfd = row.get('pin_fair_draw') or 0
    pfa = row.get('pin_fair_away') or 0

    clv_kh = row.get('clv_kalshi_home') or 0
    clv_ka = row.get('clv_kalshi_away') or 0
    clv_bh = row.get('clv_b365_home') or 0
    clv_ba = row.get('clv_b365_away') or 0
    _is_gap = row.get('league') in GAP_LEAGUES

    # For GAP_LEAGUES the confirming/qualifying book is Poly-US, not Poly.com
    # (which has no coverage there at all). Read the CLV from the book that
    # actually drove the decision -- otherwise the logged clv_poly is 0 and the
    # dashboard shows a misleading "CLV P +0.0%" on every USL/PL1 row, and the
    # xlow_polyus records are useless for later analysis. Fixed 2026-07-25.
    if _is_gap:
        clv_ph = row.get('clv_polyus_home') or 0
        clv_pa = row.get('clv_polyus_away') or 0
    else:
        clv_ph = row.get('clv_poly_home') or 0
        clv_pa = row.get('clv_poly_away') or 0

    edges = []
    away_b365_ok, away_kalshi_ok, away_poly_ok = _side_book_flags(row, 'away', strategy)
    if away_b365_ok or away_kalshi_ok or away_poly_ok:
        k_mid = row.get('kalshi_away_mid') or 0
        k_ask = float(row.get('kalshi_away_ask') or 0) or k_mid
        if _is_gap:
            p_ask = float(row.get('polyus_away_ask') or 0) or float(row.get('polyus_away_mid') or 0)
        else:
            p_ask = float(row.get('poly_away_ask') or 0) or float(row.get('poly_away_mid') or 0)
        b365_stake   = _stake(pfa, row.get('b365_away') or 0) if away_b365_ok else None
        kalshi_stake = _stake(pfa, _kalshi_odds(k_ask), use_kalshi=True) if away_kalshi_ok else None
        _p_mid = float(row.get('polyus_away_mid') or 0) if _is_gap else                  float(row.get('poly_away_mid') or 0)
        if away_poly_ok and _p_mid > 0 and (p_ask - _p_mid) > MAX_ASK_MID_SPREAD:
            print(f"  [SPREAD] {w['home']} vs {w['away']} away: confirming ask "
                  f"{p_ask:.3f} vs mid {_p_mid:.3f} -- the mid fired on liquidity "
                  f"that is not there; dropping the poly leg")
            away_poly_ok = False
        poly_stake   = _stake(pfa, _kalshi_odds(p_ask), use_poly=True) if away_poly_ok else None
        edges.append(('AWAY YES', clv_ka, clv_ba, pfa, row.get('b365_away'), k_ask, b365_stake, kalshi_stake, None, clv_pa, p_ask, poly_stake))
    home_b365_ok, home_kalshi_ok, home_poly_ok = _side_book_flags(row, 'home', strategy)
    if home_b365_ok or home_kalshi_ok or home_poly_ok:
        k_mid = row.get('kalshi_home_mid') or 0
        k_ask = float(row.get('kalshi_home_ask') or 0) or k_mid
        if _is_gap:
            p_ask = float(row.get('polyus_home_ask') or 0) or float(row.get('polyus_home_mid') or 0)
        else:
            p_ask = float(row.get('poly_home_ask') or 0) or float(row.get('poly_home_mid') or 0)
        b365_stake   = _stake(pfh, row.get('b365_home') or 0) if home_b365_ok else None
        kalshi_stake = _stake(pfh, _kalshi_odds(k_ask), use_kalshi=True) if home_kalshi_ok else None
        _p_mid = float(row.get('polyus_home_mid') or 0) if _is_gap else                  float(row.get('poly_home_mid') or 0)
        if home_poly_ok and _p_mid > 0 and (p_ask - _p_mid) > MAX_ASK_MID_SPREAD:
            print(f"  [SPREAD] {w['home']} vs {w['away']} home: confirming ask "
                  f"{p_ask:.3f} vs mid {_p_mid:.3f} -- the mid fired on liquidity "
                  f"that is not there; dropping the poly leg")
            home_poly_ok = False
        poly_stake   = _stake(pfh, _kalshi_odds(p_ask), use_poly=True) if home_poly_ok else None
        edges.append(('HOME YES', clv_kh, clv_bh, pfh, row.get('b365_home'), k_ask, b365_stake, kalshi_stake, None, clv_ph, p_ask, poly_stake))

    # Drop edges with no actionable stake on any book
    edges = [e for e in edges if e[6] is not None or e[7] is not None or e[11] is not None]

    if not edges:
        return None

    # If both HOME YES and AWAY YES fire on the same match, keep only the
    # higher-CLV side. Reason: both firing == one synthetic NO DRAW call;
    # the smaller CLV is mostly noise on the same underlying signal.
    # (Added 2026-05-23 per strategy discussion.)
    yes_sides = [e for e in edges if not e[0].startswith('NO')]
    if len(yes_sides) >= 2 and {e[0] for e in yes_sides} >= {'AWAY YES', 'HOME YES'}:
        def _clv_max(e):
            # e = (side, k_clv, b_clv, ..., p_clv, ...) — pick whichever book's CLV is larger
            return max(e[1] or 0, e[2] or 0, e[9] or 0)
        winner = max(yes_sides, key=_clv_max)
        loser = next(e for e in yes_sides if e[0] != winner[0])
        print(f"  [DEDUPE] both sides fired — keeping {winner[0]} "
              f"(CLV {_clv_max(winner)*100:+.1f}%) over {loser[0]} "
              f"(CLV {_clv_max(loser)*100:+.1f}%)")
        edges = [winner]

    # Cross-restart alert dedupe (2026-07-05): clv_alerted lives in process
    # memory, so every monitor restart re-alerted any pre-KO game still in
    # band (duplicate emails). pending_bets.json is the durable record of what
    # was already signalled — drop edges whose (home, away, side) is already
    # pending. _log_pending_bets has the same check for bookkeeping; this one
    # stops the EMAIL.
    # gate_pending=False is used by the xlow email path: the baseline signal
    # usually logs the pending entry BEFORE the xlow shape forms, so the
    # pending gate would permanently suppress the xlow email. That path
    # dedupes via w['xlow_alerted'] instead.
    if gate_pending:
        try:
            _pend = json.loads(Path(r'E:\soccer\data\pending_bets.json').read_text())
            _pkeys = {(b.get('home'), b.get('away'), b.get('bet')) for b in _pend}
            _OPP = {'HOME YES': 'AWAY YES', 'AWAY YES': 'HOME YES'}
            # drop an edge if the SAME side is already pending (restart re-alert) OR
            # the OPPOSITE side is (first-signal-wins — _log_pending_bets would
            # refuse to log it, so don't email it either)
            edges = [e for e in edges
                     if (w['home'], w['away'], e[0]) not in _pkeys
                     and (w['home'], w['away'], _OPP.get(e[0])) not in _pkeys]
        except Exception:
            pass
    if not edges:
        return None

    NO_THRESH = 0.60
    k_away_mid = float(row.get('kalshi_away_mid') or 0)
    if any(e[0] == 'HOME YES' for e in edges) and k_away_mid >= NO_THRESH:
        p_no  = pfh + pfd
        price = 1 - k_away_mid
        edges.append(('NO AWAY', 0, 0, p_no, None, round(price, 3), None, _kelly_stake(p_no, _kalshi_odds(price)), k_away_mid, 0, None, None))
    k_home_mid = float(row.get('kalshi_home_mid') or 0)
    if any(e[0] == 'AWAY YES' for e in edges) and k_home_mid >= NO_THRESH:
        p_no  = pfa + pfd
        price = 1 - k_home_mid
        edges.append(('NO HOME', 0, 0, p_no, None, round(price, 3), None, _kelly_stake(p_no, _kalshi_odds(price)), k_home_mid, 0, None, None))

    league = w.get('lc', '')
    ko_str = w['kickoff'].strftime('%H:%M ET') if hasattr(w['kickoff'], 'strftime') else str(w['kickoff'])
    pin_str = (f"H {row.get('pin_home')}  D {row.get('pin_draw')}  A {row.get('pin_away')}"
               if row.get('pin_home') else 'N/A')

    return {
        'w': w, 'row': row, 'mins_to_ko': mins_to_ko,
        'edges': edges, 'league': league, 'ko_str': ko_str, 'pin_str': pin_str,
        'bankroll': bankroll, 'kalshi_bankroll': kalshi_bankroll,
        'poly_bankroll': poly_bankroll,
    }


def _log_pending_bets(alert):
    """Log bets to pending_bets.json for a single alert dict from _build_clv_alert.
    Returns {side: {'kalshi': status_or_None, 'polyus': status_or_None}} so the
    caller can report real trade outcomes in the alert email -- a status dict
    means an order was ATTEMPTED (kill-switch/enabled gates already passed);
    'placed': True carries order_id/amount, 'placed': False means it attempted
    but was skipped/failed downstream (excluded league, no market, drift, etc.)."""
    w, row, edges = alert['w'], alert['row'], alert['edges']
    league = alert['league']
    pending_path = Path(r'E:\soccer\data\pending_bets.json')
    try:
        pending = json.loads(pending_path.read_text()) if pending_path.exists() else []
    except Exception:
        pending = []
    gd = w.get('game_date', date.today())
    OPPOSITE = {'HOME YES': 'AWAY YES', 'AWAY YES': 'HOME YES'}
    trade_status = {}

    def _norm_side(s):
        """Collapse the two strategies' side labels to a common key. The baseline
        path (daily_signals.add_to_pending) writes 'Away'/'Home' while xlow writes
        'AWAY YES'/'HOME YES' -- an exact-string dedupe therefore saw them as
        different bets and let BOTH log, double-staking the same outcome across
        venues (Santos vs Chapecoense 2026-07-25: baseline $0.96 on Kalshi AND
        xlow $1.00 on Poly-US). Found 2026-07-24."""
        s = (s or '').strip().upper()
        if s.startswith('NO '):
            return s              # NO HOME / NO AWAY are genuinely distinct bets
        if 'AWAY' in s:
            return 'AWAY'
        if 'HOME' in s:
            return 'HOME'
        return s

    # Bets already SETTLED today. The pending-only dedupe below protects a bet
    # only while it is open; once resolve_pending_bets moves it to history,
    # nothing stops this firing again. In-memory `clv_alerted` normally covers
    # that gap -- but it resets on every restart, and live_monitor was restarted
    # four times on 2026-08-08 alone. The baseline path had the same hole and
    # did double-book a bet that day (see daily_signals._settled_keys).
    try:
        _hist = json.loads(Path(r'E:\soccer\data\bet_history.json').read_text(encoding='utf-8'))
        _hist = _hist if isinstance(_hist, list) else _hist.get('bets', _hist)
    except Exception:
        _hist = []
    settled = {(str(b.get('date'))[:10], b.get('home'), b.get('away'),
                _norm_side(b.get('bet'))) for b in _hist}

    for e in edges:
        side, k_clv, b_clv, pf, b365, k_mid, b365_stake, kalshi_stake, opp_mid, p_clv, p_ask, poly_stake = e
        if (str(gd), w['home'], w['away'], _norm_side(side)) in settled:
            print(f"  [DEDUPE] {w['home']} vs {w['away']} {side} already settled "
                  f"-- not re-logging")
            continue
        already = any(
            b.get('home') == w['home'] and b.get('away') == w['away']
            and _norm_side(b.get('bet')) == _norm_side(side)
            for b in pending
        )
        if already:
            continue
        # Cross-cycle both-sides dedupe: the in-memory dedupe in _build_clv_alert
        # only sees one alert's edges. Once a match has a pending bet, the FIRST
        # CLV that fired is the one we'd have actually placed in real time. A later
        # opposite-side signal must NOT replace it — flipping to whichever side
        # looks better afterward is look-ahead bias that inflates apparent edge.
        # One bet per match: first signal wins.
        opp_side = OPPOSITE.get(side)
        if opp_side and any(b.get('home') == w['home'] and b.get('away') == w['away']
                            and _norm_side(b.get('bet')) == _norm_side(opp_side)
                            for b in pending):
            print(f"  [DEDUPE] {opp_side} already pending for "
                  f"{w['home']} vs {w['away']} — keeping first, skipping {side}")
            continue
        is_no = side.startswith('NO')
        entry = {
            'date':        str(gd),
            'home':        w['home'],
            'away':        w['away'],
            'league':      maps.league_kalshi_series(league) or league,
            'div':         league,
            # GAP_LEAGUES fire via Kalshi-vs-Poly-US, not the frozen Kalshi-vs-
            # Poly.com rule -- tag distinctly so the primary xlow ledger/forward
            # test never mixes in an unproven exchange pairing (2026-07-23).
            'strategy':    'xlow_polyus' if league in GAP_LEAGUES else ACTIVE_STRATEGY,
            'bet':         side,
            'fixture_id':  w.get('fid', ''),
            'pin_fair':    round(pf, 4),
            'clv_kalshi':  round(k_clv * 100, 1) if not is_no else None,
            'clv_b365':    round(b_clv * 100, 1) if not is_no else None,
            'clv_poly':    round(p_clv * 100, 1) if not is_no else None,
            # ── CLV-realisation baseline (added 2026-08-08) ────────────────
            # The MID on each book at the exact instant the signal fired, plus
            # that instant. Purely additive; nothing reads these in the trading
            # path. They exist because realised P&L cannot validate this rule:
            # measured 2026-08-08, per-bet stdev is 1.94u against a +1.2% ROI,
            # so proving the observed edge needs ~100,000 bets (~46 years at
            # 6/day). Line DRIFT after entry has ~1/80th the variance -- ~92
            # bets for a 0.5c effect -- and a first pass over 61 reconstructed
            # bets already showed -0.77c (z=-2.46), i.e. the market moving AWAY
            # from our side.
            #
            # MID, not ask, on purpose: entry-ask vs closing-mid is biased by
            # half the spread, which is 0.50c here -- almost exactly the effect
            # being measured. That comparison manufactures the result.
            'entry_ts':    datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
            'entry_k_mid': round(k_mid, 4) if k_mid else None,
            'entry_p_mid': round(opp_mid, 4) if opp_mid else None,
        }
        if b365_stake:
            entry['b365']  = b365
            entry['stake'] = b365_stake
        if kalshi_stake:
            side_key = 'home' if 'HOME' in side else 'away'
            entry['kalshi_ask']   = k_mid if is_no else row.get(f'kalshi_{side_key}_ask', '')
            entry['kalshi_stake'] = kalshi_stake
        if poly_stake:
            entry['poly_ask']   = round(p_ask, 4) if p_ask else p_ask
            entry['poly_stake'] = poly_stake
        if is_no:
            continue
        status = {'kalshi': None, 'polyus': None}
        if b365_stake or kalshi_stake or poly_stake:
            pending.append(entry)
            print(f"  [PENDING] Logged: {w['home']} vs {w['away']} {side}"
                  + (f" B365 ${b365_stake:.2f}" if b365_stake else '')
                  + (f" Kalshi ${kalshi_stake:.2f}" if kalshi_stake else '')
                  + (f" Poly ${poly_stake:.2f}" if poly_stake else ''))
            # Phase 3 auto-trade (redesigned 2026-07-24 per user correction):
            # execute via whichever book xlow ACTUALLY NAMED as the qualifying
            # leg -- kalshi_stake and poly_stake are mutually exclusive under
            # strict xlow, so exactly one of the two branches below applies.
            #   - signal says Kalshi -> trade Kalshi directly, no Poly attempt.
            #   - signal says Poly -> Poly-US is the only tradeable Poly venue
            #     (Poly.com itself stays geo-blocked), so try it first; only if
            #     Poly-US can't place, fall back to Kalshi at a FRESH live price
            #     (kalshi_stake was never computed for this signal since Kalshi
            #     wasn't the qualifying leg -- row still has its raw ask/mid,
            #     matches project_kalshi_only_execution's +0.83c-markup finding
            #     that the confirming book is a cheap proxy, not a free ride).
            signal_is_kalshi = bool(kalshi_stake)
            signal_is_poly   = bool(poly_stake) and not kalshi_stake

            if signal_is_kalshi:
                # TRY POLY-US EVEN WHEN THE SIGNAL NAMED KALSHI (2026-09-01,
                # user: "Falkirk should have run on poly us").
                #
                # XLOW_PREFER_POLYUS only ever applied to the signal_is_poly
                # branch below, so a signal whose CHEAPER book was Kalshi went
                # straight to Kalshi and Poly-US was never consulted. Falkirk v
                # Rangers is the case: Poly-US listed it at 0.15 and we paid
                # 0.18 on Kalshi.
                #
                # Two reasons this is right. Execution: Poly-US was the cheaper
                # fill here, and a rescore of 175 bets put the two venues within
                # $4.40 of each other, so there is no systematic penalty.
                # Structural: xlow on Kalshi shares one account with the maker
                # and Kalshi NETS both into a single position per ticker, which
                # is the interference the subaccount split exists to end --
                # every bet that goes to Poly-US instead cannot collide at all.
                #
                # CAVEAT worth knowing: the signal's edge was measured at the
                # NAMED book's price, so filling elsewhere is a different price
                # than the forward test scores. It stays logged against the
                # signal either way; only the execution venue moves.
                _placed_pus = False
                if ACTIVE_STRATEGY == 'xlow' and XLOW_PREFER_POLYUS:
                    status['polyus'] = {'placed': False}
                    try:
                        import poly_us_trader as put
                        rec = put.place_auto_order(entry)
                        if rec:
                            entry['auto_polyus_order_id'] = rec['order_id']
                            entry['auto_polyus_qty']      = rec['quantity']
                            entry['auto_polyus_price']    = rec['price']
                            status['polyus'] = {'placed': True, 'order_id': rec['order_id'],
                                                 'amount': rec['dollar_committed']}
                            _placed_pus = True
                    except Exception as e:
                        print(f"  [AUTO-POLYUS] hook crashed for "
                              f"{w['home']} vs {w['away']}: {e}")
                    if not _placed_pus:
                        print(f"  [AUTO] Poly-US could not place "
                              f"{w['home']} vs {w['away']} -- falling back to Kalshi")

                status['kalshi'] = {'placed': False}
                try:
                    from daily_signals import place_auto_order, AUTO_TRADE_ENABLED
                    if AUTO_TRADE_ENABLED and not _placed_pus:
                        rec = place_auto_order(entry)
                        if rec:
                            entry['auto_order_id']    = rec['order_id']
                            entry['auto_placed_at']   = rec['placed_at']
                            entry['auto_count']       = rec['count']
                            entry['auto_price_cents'] = rec['price_cents']
                            status['kalshi'] = {'placed': True, 'order_id': rec['order_id'],
                                                 'amount': rec['count'] * rec['price_cents'] / 100.0}
                except Exception as e:
                    print(f"  [AUTO] hook crashed for {w['home']} vs {w['away']}: {e}")

            elif signal_is_poly:
                # BEST EXECUTION (user instruction 2026-07-24): the signal's edge
                # was computed at POLY.COM's price, which we cannot trade. So
                # compare BOTH tradeable venues' live asks against that signal
                # price and execute on whichever is CLOSER -- minimizing slippage
                # vs the price that actually generated the edge, rather than
                # blindly preferring Poly-US.
                #
                # Motivating case: CL1 Coquimbo/U.Concepcion 2026-07-25 -- signal
                # was Poly.com 0.13 (pin_fair 0.1333, +10% CLV). Poly-US's book
                # was effectively empty (bid 0.05 / ask 0.22, our own 6.25
                # contracts were the ONLY trade the market had ever seen) so it
                # filled at 0.16 -- above pin_fair, i.e. the edge was already
                # gone at the price we paid. Kalshi would likely have been nearer.
                side_key = 'home' if 'HOME' in side else 'away'
                signal_px = float(p_ask or 0)

                polyus_px = None
                polyus_has_market = False
                try:
                    import poly_us_prices as _pup
                    _mkt = _pup.find_market(league, w['home'], w['away'], side_key)
                    if _mkt:
                        polyus_has_market = True
                        _bbo = _pup.get_bbo(_mkt['slug'])
                        if _bbo and _bbo.get('bestAsk'):
                            polyus_px = float(_bbo['bestAsk'])
                except Exception as e:
                    print(f"  [AUTO] Poly-US price check failed: {e}")

                kalshi_px = (float(row.get(f'kalshi_{side_key}_ask') or 0)
                             or float(row.get(f'kalshi_{side_key}_mid') or 0)) or None

                venues = []
                if polyus_px and signal_px:
                    venues.append(('polyus', abs(polyus_px - signal_px), polyus_px))
                elif polyus_has_market and XLOW_PREFER_POLYUS:
                    # MARKET EXISTS BUT THE BBO READ CAME BACK EMPTY. Do not drop
                    # Poly-US for that (2026-09-01).
                    #
                    # Millwall v Wrexham, 2026-09-02 08:55 UTC: find_market
                    # resolved atc-eflch-mil-wre-2026-09-02-wre fine, but the
                    # book was empty at 4:55am ET so get_bbo returned no
                    # bestAsk. polyus_px stayed None, Poly-US never entered
                    # `venues`, and the bet went to Kalshi -- logged as
                    # "kalshi is closer" when Poly-US had not been considered
                    # at all. The venue log line makes that look like a price
                    # decision; it was a missing quote.
                    #
                    # This read is only used to RANK venues. poly_us_trader
                    # re-resolves the market and pulls its own live BBO with a
                    # drift check before placing, so letting it through costs
                    # nothing: if the book really is empty it refuses there and
                    # we fall to Kalshi one step later, with a reason logged.
                    # The 0.0 distance is a sort key, not a price.
                    venues.append(('polyus', 0.0, 0.0))
                    print(f"  [AUTO] Poly-US has a market for {w['home']} vs "
                          f"{w['away']} but no BBO -- trying it anyway")
                if kalshi_px and signal_px:
                    venues.append(('kalshi', abs(kalshi_px - signal_px), kalshi_px))
                venues.sort(key=lambda v: v[1])
                # XLOW TRADES POLY-US ONLY (2026-08-30, user). The cascade above
                # picked whichever venue quoted nearest the signal price; xlow now
                # goes to Poly-US whatever fired it, so Kalshi is dropped rather
                # than left as a fallback that would quietly take the trade when
                # Poly-US has no market.
                #
                # WHY POLY-US AND NOT KALSHI: the two venues price this signal
                # equivalently -- rescoring the frozen curve at Poly-US asks moved
                # 175 bets by +$4.40, median difference 0.0c -- and the Kalshi
                # account is where the market maker lives. Keeping xlow off it
                # ends the position-netting interference that stopped auto-trading
                # on 2026-08-24 (see feedback_xlow_maker_position_netting).
                #
                # This does NOT make the signal profitable. Measured edge is
                # +0.31c against a ~0.7c half-spread on either venue, reproduced
                # twice; xlow has lost $51.94 real on Kalshi over 89 settled legs.
                # Live at $1 a bet is a deliberate, bounded bet on that
                # measurement being wrong. See project_xlow_spread_verdict.
                # PREFER POLY-US, FALL BACK TO KALSHI (2026-08-31, user).
                #
                # Poly-US only was too strict: it carries top-flight and MLS but
                # not lower-division football, so a valid signal like Bradford v
                # Cambridge (E2, ask 0.21, CLV +7.2%, every filter passed) had no
                # venue and placed nothing. find_market returns None and the
                # venue list came out empty.
                #
                # THE COST OF THE FALLBACK is real and is the reason Poly-US-only
                # existed: xlow back on Kalshi shares one account with the market
                # maker, and Kalshi NETS both into a single position per ticker.
                # That is what stopped auto-trading on 2026-08-24 -- the maker
                # sold 3 of xlow's own 5 contracts back at the same price, and the
                # netted position was read as maker fills, booking phantom sales
                # at 0c that latched ratchet floors and killed two books. The
                # accounting bugs are fixed (order-id attribution + self-heal);
                # the economic interference is not.
                #
                # Ordering, not filtering: Poly-US is tried first whenever it has
                # a market, so the shared-account case only arises where it does
                # not. See feedback_xlow_maker_position_netting.
                if XLOW_PREFER_POLYUS:
                    venues.sort(key=lambda v: (0 if v[0] == 'polyus' else 1, v[1]))
                if XLOW_KALSHI_ONLY:
                    # Filter, not reorder: with Poly-US merely last, a signal
                    # whose Kalshi quote is missing would still route there.
                    venues = [v for v in venues if v[0] == 'kalshi']

                if venues:
                    print(f"  [AUTO] signal(poly.com)={signal_px:.3f} | "
                          + "  ".join(f"{n}=" + (f"{px:.3f} (d={d:.3f})" if px
                                                     else "no-quote")
                                        for n, d, px in venues)
                          + f"  -> {venues[0][0]} is closer")

                placed = False
                for vname, _dist, vpx in venues:
                    if placed:
                        break
                    if vname == 'polyus':
                        status['polyus'] = {'placed': False}
                        try:
                            import poly_us_trader as put
                            rec = put.place_auto_order(entry)
                            if rec:
                                entry['auto_polyus_order_id'] = rec['order_id']
                                entry['auto_polyus_qty']      = rec['quantity']
                                entry['auto_polyus_price']    = rec['price']
                                status['polyus'] = {'placed': True, 'order_id': rec['order_id'],
                                                     'amount': rec['dollar_committed']}
                                placed = True
                        except Exception as e:
                            print(f"  [AUTO-POLYUS] hook crashed for {w['home']} vs {w['away']}: {e}")
                    else:
                        kb = _load_kalshi_bankroll()
                        fallback_stake = (XLOW_STAKE_FIXED if XLOW_STAKE_FIXED is not None
                                          else (round(XLOW_STAKE_PCT * kb, 2) if kb
                                                else XLOW_STAKE_FALLBACK))
                        entry['kalshi_ask']   = round(vpx, 4)
                        entry['kalshi_stake'] = fallback_stake
                        status['kalshi'] = {'placed': False}
                        try:
                            from daily_signals import place_auto_order, AUTO_TRADE_ENABLED
                            if AUTO_TRADE_ENABLED:
                                rec = place_auto_order(entry)
                                if rec:
                                    entry['auto_order_id']    = rec['order_id']
                                    entry['auto_placed_at']   = rec['placed_at']
                                    entry['auto_count']       = rec['count']
                                    entry['auto_price_cents'] = rec['price_cents']
                                    status['kalshi'] = {'placed': True, 'order_id': rec['order_id'],
                                                         'amount': rec['count'] * rec['price_cents'] / 100.0}
                                    placed = True
                        except Exception as e:
                            print(f"  [AUTO] hook crashed for {w['home']} vs {w['away']}: {e}")
        trade_status[side] = status
    pending_path.write_text(json.dumps(pending, indent=2))
    return trade_status


def send_batch_clv_alert(alerts):
    """Send one email containing all CLV alerts collected this poll cycle."""
    if not alerts:
        return

    bankroll        = alerts[0]['bankroll']
    kalshi_bankroll = alerts[0]['kalshi_bankroll']
    poly_bankroll   = alerts[0].get('poly_bankroll')
    br_str        = f'${bankroll:,.2f}'        if bankroll        else 'N/A'
    kalshi_br_str = f'${kalshi_bankroll:,.2f}' if kalshi_bankroll else 'N/A'
    poly_br_str   = f'${poly_bankroll:,.2f}'   if poly_bankroll   else 'N/A'

    match_blocks = ''
    subject_parts = []

    for alert in alerts:
        w, edges, league = alert['w'], alert['edges'], alert['league']
        mins_to_ko, ko_str, pin_str = alert['mins_to_ko'], alert['ko_str'], alert['pin_str']

        alert_trade_status = alert.get('trade_status') or {}
        rows_html = ''
        for e in edges:
            side, k_clv, b_clv, pf, b365, k_mid, b365_stake, kalshi_stake, opp_mid, p_clv, p_ask, poly_stake = e
            if side.startswith('NO'):
                continue
            def _fmt_leg(s):
                if s is None:
                    return '—'
                if s.get('placed'):
                    return f"<span style='color:#2d7d2d'>&#10003; ${s['amount']:.2f}</span>"
                return "<span style='color:#cc3333'>not placed</span>"
            side_status = alert_trade_status.get(side, {})
            trade_cell = (f"K: {_fmt_leg(side_status.get('kalshi'))}<br>"
                          f"PolyUS: {_fmt_leg(side_status.get('polyus'))}")
            rows_html += f"""
        <tr>
          <td><b>{side}</b></td>
          <td>{k_clv*100:+.1f}%</td>
          <td>{b_clv*100:+.1f}%</td>
          <td>{p_clv*100:+.1f}%</td>
          <td>{pf:.3f}</td>
          <td>{b365 or '—'}</td>
          <td>{k_mid or '—'}</td>
          <td>{p_ask or '—'}</td>
          <td>{'$'+f'{b365_stake:.2f}' if b365_stake else '—'}</td>
          <td>{'$'+f'{kalshi_stake:.2f}' if kalshi_stake else '—'}</td>
          <td>{'$'+f'{poly_stake:.2f}' if poly_stake else '—'}</td>
          <td>{trade_cell}</td>
        </tr>"""

        yes_sides = [e[0] for e in edges if not e[0].startswith('NO')]
        subject_parts.append(f"{w['home']} vs {w['away']} ({', '.join(yes_sides)})")

        match_blocks += f"""
    <h3 style="margin-top:24px">{w['home']} vs {w['away']}</h3>
    <p style="margin:4px 0"><b>League:</b> {league} &nbsp;|&nbsp; <b>Kickoff:</b> {ko_str} &nbsp;|&nbsp;
       <b>T-{abs(mins_to_ko)} min</b></p>
    <p style="margin:4px 0"><b>Pinnacle:</b> {pin_str}</p>
    <table border="1" cellpadding="6" cellspacing="0" style="border-collapse:collapse;margin-bottom:8px">
      <tr style="background:#eee">
        <th>Side</th><th>Kalshi CLV</th><th>B365 CLV</th><th>Poly CLV</th>
        <th>Pin Fair</th><th>B365 Odds</th><th>Kalshi Mid/Price</th><th>Poly Ask/Mid</th><th>B365 Kelly</th><th>Kalshi Kelly</th><th>Poly Kelly</th><th>Real Trade</th>
      </tr>
      {rows_html}
    </table>"""

    n = len(alerts)
    tag = 'XLOW ALERT' if EMAIL_ALERT_STRATEGY == 'xlow' else 'CLV ALERT'
    subject = f"{tag} ({n} MATCH{'ES' if n > 1 else ''}) - {datetime.now().strftime('%Y%m%d:%H%M')}"

    html = f"""<html><body style="font-family:sans-serif">
    <p><b>B365 Bankroll:</b> {br_str} &nbsp;|&nbsp; <b>Kalshi Bankroll:</b> {kalshi_br_str} &nbsp;|&nbsp; <b>Poly Bankroll:</b> {poly_br_str}</p>
    <hr style="margin:8px 0">
    {match_blocks}
    </body></html>"""

    send_email(subject, html)

# ─────────────────────────────────────────────────────────────────────────
from kalshi_live import check_halftime, list_matches, get_all_prices, COMEBACK_RATE
import polymarket_live

AF_BASE    = "https://v3.football.api-sports.io"
AF_HEADERS = {"x-apisports-key": API_KEY}

# League IDs and Kalshi series are now loaded from maps.py (league_map.json).
# Edit that file to add/change leagues — live_monitor hot-reloads changes automatically.

# ── API-Football helpers ───────────────────────────────────────────────────

def get_api_remaining():
    """Requests left today. 0 when the plan's daily limit is spent.

    API-FOOTBALL SIGNALS EXHAUSTION WITH HTTP 200. It answers every endpoint --
    /status included -- with 200, `response: []` and the reason in an `errors`
    object. So `r.json()['response']['requests']` raised on a list index, the
    bare except caught it, and this returned 9999 ("assume OK") at exactly the
    moment there was no quota left.

    The cost of that fail-open on 2026-08-29: api_ok stayed True, every score
    fetch came back empty, and 161 fixtures were logged status=FT with NO GOALS
    from the clock-based fallback, whose reason string blamed "fixture not
    found" 1,644 times while the real cause was never printed once. Results for
    a whole Saturday did not resolve and the dashboard showed them as open.

    A genuine network failure still returns 9999, because a transient blip
    should not shut off score collection; only a quota answer returns 0.
    """
    try:
        r = requests.get(f"{AF_BASE}/status", headers=AF_HEADERS, timeout=10)
        j = r.json()
        if _quota_exhausted(j):
            return 0
        d = j['response']['requests']
        return d['limit_day'] - d['current']
    except Exception:
        return 9999  # network trouble, not a quota answer -- keep polling


def _quota_exhausted(j):
    """True when a 200 response is really 'you are out of requests'."""
    errs = (j or {}).get('errors')
    if isinstance(errs, dict):
        return 'requests' in errs
    return False

def find_fixture_id(home, away, league_code, game_date):
    """Search API-Football for a fixture. Tries game_date and game_date+1 to handle UTC/ET offset."""
    from datetime import timedelta
    league_id = maps.league_api_id(league_code.upper())
    if not league_id:
        return None
    for delta in [0, 1]:
        search_date = game_date + timedelta(days=delta)
        for season_year in [search_date.year, search_date.year - 1]:
            r = requests.get(f"{AF_BASE}/fixtures", headers=AF_HEADERS, params={
                'league': league_id,
                'season': season_year,
                'date':   search_date.isoformat(),
            }, timeout=10)
            for fix in r.json().get('response', []):
                h = fix['teams']['home']['name'].lower()
                a = fix['teams']['away']['name'].lower()
                if home.lower() in h or h in home.lower():
                    if away.lower() in a or a in away.lower():
                        return fix['fixture']['id']
    return None

# ── Cross-process cache (added 2026-08-07) ────────────────────────────────
# live_monitor, mm_quoter and mm_quoter_polyus are three SEPARATE processes
# that all import this module, so each held its own module-level dict. The same
# fixture's Pinnacle line was therefore fetched 3x per hour and its status 2x
# per 30 min -- roughly 5,400 duplicate calls on a peak day, for data that is
# byte-identical across the three readers.
#
# sqlite rather than a JSON file specifically BECAUSE there are concurrent
# writers: it gives real locking, where two processes rewriting a JSON file
# race and lose entries. Failures are swallowed throughout -- a cache miss
# costs one API call, but an exception here would take down a quoter mid-cycle.
_SHARED_DB = Path(r'E:\soccer\data\af_cache.sqlite')
_shared_conn = None


def _shared_db():
    global _shared_conn
    if _shared_conn is None:
        import sqlite3
        _SHARED_DB.parent.mkdir(parents=True, exist_ok=True)
        _shared_conn = sqlite3.connect(str(_SHARED_DB), timeout=5.0)
        _shared_conn.execute('PRAGMA journal_mode=WAL')      # concurrent readers
        _shared_conn.execute('CREATE TABLE IF NOT EXISTS cache '
                             '(k TEXT PRIMARY KEY, ts REAL, v TEXT)')
        _shared_conn.commit()
    return _shared_conn


def _shared_get(key):
    """(ts, value) from the cross-process cache, or None."""
    try:
        row = _shared_db().execute('SELECT ts, v FROM cache WHERE k=?', (key,)).fetchone()
        return (row[0], json.loads(row[1])) if row else None
    except Exception:
        return None


def _shared_put(key, value):
    try:
        db = _shared_db()
        db.execute('INSERT OR REPLACE INTO cache (k, ts, v) VALUES (?,?,?)',
                   (key, time.time(), json.dumps(value)))
        db.commit()
    except Exception:
        pass


# Fixture playability, cached (added 2026-07-29). API-Football leaves a
# postponed match on its ORIGINAL date with status PST, so the watchlist
# generator now filters those out -- but a game can be postponed AFTER the
# watchlist is built, so the quoters check here as a backstop. Cached because
# they poll every 20s and status changes on the order of hours, not seconds.
_STATUS_CACHE = {}
_STATUS_TTL = 1800          # seconds
NOT_PLAYED = ('PST', 'CANC', 'ABD', 'SUSP', 'AWD', 'WO')


def fixture_status(fixture_id, max_age=None):
    """API-Football short status ('NS','PST',...), or None if unknown.

    Two cache tiers: the in-process dict avoids sqlite on the hot path (both
    quoters call this every 20s), the shared DB avoids the network across
    processes. max_age overrides the default TTL for callers that can accept a
    staler answer -- see fixture_playable.
    """
    if not fixture_id:
        return None
    import time as _t
    ttl = _STATUS_TTL if max_age is None else max_age
    hit = _STATUS_CACHE.get(fixture_id)
    if hit and _t.time() - hit[0] < ttl:
        return hit[1]
    shared = _shared_get(f'status:{fixture_id}')
    if shared and _t.time() - shared[0] < ttl:
        _STATUS_CACHE[fixture_id] = (shared[0], shared[1])
        return shared[1]
    try:
        r = requests.get(f"{AF_BASE}/fixtures", headers=AF_HEADERS,
                         params={'id': fixture_id}, timeout=15)
        resp = r.json().get('response', [])
        st = ((resp[0].get('fixture') or {}).get('status') or {}).get('short') if resp else None
    except Exception:
        st = None
    _STATUS_CACHE[fixture_id] = (_t.time(), st)
    _shared_put(f'status:{fixture_id}', st)
    return st


def fixture_playable(fixture_id, max_age=None):
    """False only when we KNOW it is not being played. Unknown -> True, so a
    lookup failure never silently stops us quoting a real match.

    max_age lets a caller accept a staler answer. The quoters now gate on the
    EXCHANGE's own view first (a delisted or non-active market is a stronger,
    free signal) and use this only as a backstop, so they pass a long max_age
    -- postponements are announced hours ahead, and re-asking every 30 min was
    ~2,200 calls/day for a fact that changes maybe twice a week.
    """
    return fixture_status(fixture_id, max_age=max_age) not in NOT_PLAYED


# How stale an API-Football status may be when it is only a BACKSTOP behind the
# exchange's own listing. 6h still catches a postponement announced the morning
# of a match, at ~1/12th the calls.
PLAYABLE_BACKSTOP_TTL = 6 * 3600


# Pre-match odds cache (added 2026-08-01, after exhausting the API-Football
# daily quota mid-morning).
#
# WHY. Both market-maker quoters call fetch_prematch_odds() once per match per
# cycle, and they poll every 20s. That is 180 calls/hour PER MATCH. On a normal
# ~15-match slate that is 5,400/hour across the two quoters -- already 86% of the
# 75,000 daily quota over 12h. On 2026-08-01, a Saturday with 64 fixtures in the
# window, it worked out to 23,040/hour and blew the cap before midday, which
# silently killed result backfill, xlow signals and the maker's fair values.
#
# The absurdity: we MEASURED that API-Football's Pinnacle line only changes
# about every 5.8 HOURS (research/xlow_vs_line_age.py). So we were re-fetching
# 180 times an hour a number that moves three or four times a day. A 1-hour
# TTL loses literally nothing and cuts calls ~99.7% (23,040/hour -> ~64/hour).
#
# Negative results are cached too but briefly (NEG_TTL): a fixture with no
# Pinnacle line stays that way for a while, and re-asking every cycle is exactly
# what burned the quota -- but a short TTL lets a line that appears later still
# be picked up.
_ODDS_CACHE = {}
_ODDS_TTL = 3600         # 1 hour for a real quote (900 -> 3600 -> 7200 -> 3600).
                         # Back to 1h on 2026-08-07 to keep the prematch CLV
                         # series at hourly resolution while the pinnapi
                         # stale-vs-fresh comparison is running -- 2h coarsened
                         # exactly the data that study reads.
                         #
                         # COST: this constant dominates the API-Football budget,
                         # because a fixture sits in a 24h pre-kickoff snapshot
                         # window and is re-fetched 24h/TTL times. On a 124-fixture
                         # Saturday, against the Pro plan's 7,500/day:
                         #     2h -> 1,488 odds calls, ~2,185 peak day (29%)
                         #     1h -> 2,976 odds calls, ~3,673 peak day (49%)
                         #    30m -> 5,952 odds calls, ~6,649 peak day (89%)
                         # 30m was considered and rejected: it costs +4,464
                         # calls/day to buy staleness WORSE than pinnapi's
                         # 20-minute board, which is one call. Note also that
                         # API-Football rounds odds to 2dp (it reports 1.50 where
                         # pinnapi returns 1.5917), so polling harder re-fetches
                         # the same rounded number -- the precision loss is not a
                         # staleness problem and no TTL fixes it.
                         #
                         # If the comparison favours the live feed, this term
                         # goes to ~zero and the TTL stops mattering.
_ODDS_NEG_TTL = 300      # 5 min for the FIRST miss on a fixture, then doubling
                         # (see _ODDS_MISS below).
                         #
                         # This was a flat 5 minutes, justified by "misses are
                         # only the ~6-8% tail, so the extra calls are small".
                         # That estimate was never checked against the log, and
                         # it is wrong: on 2026-08-15, 110 distinct fixtures had
                         # Pin=N/A against 216 with a quote -- a 34% miss rate,
                         # because the watchlist now carries lower divisions
                         # (Argentine, Croatian, Peruvian, Romanian) that
                         # API-Football simply has no Pinnacle line for and never
                         # will. Each of those sat in a 24h window being re-asked
                         # every 5 minutes: 110 x 288 = ~31,000 calls/day from
                         # misses alone, against a 7,500/day plan. The quota was
                         # gone every morning, which silently stopped result
                         # backfill -- finished matches showed as "open" on the
                         # dashboard for the rest of the day.
                         #
                         # A fixture with no line 5 minutes ago is overwhelmingly
                         # likely to still have none now, so back off: 5m, 10m,
                         # 20m ... capped at _ODDS_NEG_TTL_MAX. A line that
                         # appears late is still picked up within the cap, while
                         # a fixture that never gets one costs ~8 calls/day
                         # instead of 288.
_ODDS_NEG_TTL_MAX = 10800    # 3h ceiling on the backoff
_ODDS_MISS = {}              # fixture_id -> consecutive misses


def _neg_ttl(fixture_id):
    """Negative-cache TTL for this fixture: 5m doubling per consecutive miss."""
    n = _ODDS_MISS.get(fixture_id, 0)
    return min(_ODDS_NEG_TTL * (2 ** min(n, 12)), _ODDS_NEG_TTL_MAX)


def fetch_prematch_odds(fixture_id, max_age=None, hours_to_ko=None, league=None):
    """Fetch B365 and Pinnacle match-winner odds from API-Football (cached).

    Pass max_age=0 to force a fresh fetch (used by the nightly paths that want
    the closing line rather than a cached one).

    `hours_to_ko` scales the TTL. A fixture sits in a 24h pre-kickoff window and
    at a flat 1h TTL costs 24 calls whether or not anything moves -- and we
    measured (research/xlow_vs_line_age.py) that the Pinnacle line only changes
    about every 5.8 HOURS. The hourly resolution is only actually wanted near
    kickoff, where CLV is decided; twenty hours out it is re-fetching a number
    that will not move. Far out the TTL relaxes, inside 4h it stays hourly, so
    the CLV series keeps its resolution exactly where it reads it. Callers with
    no kickoff information get the old flat behaviour.
    """
    import time as _t
    ttl = _ODDS_TTL if max_age is None else max_age
    if max_age is None and hours_to_ko is not None:
        try:
            h = float(hours_to_ko)
        except (TypeError, ValueError):
            h = 0.0
        if h > 12:
            ttl = _ODDS_TTL * 4        # >12h out: line is nearly static
        elif h > 4:
            ttl = _ODDS_TTL * 2        # 4-12h out
        # inside 4h: full hourly resolution, unchanged
    # A league that cannot trade is sampled for the tracking series only, so it
    # does not need kickoff-grade resolution. Applied AFTER the hours_to_ko
    # scaling and multiplicatively, so a far-out tracking fixture is cheapest of
    # all -- which is exactly where the waste was.
    if max_age is None and league:
        _tr = _tradeable_leagues()
        if _tr is not None and str(league).upper() not in _tr:
            ttl *= TRACKING_TTL_MULT

    def _fresh(entry):
        """Is this (ts, (b365, pin)) entry still usable? A cached MISS expires
        sooner than a cached quote -- but on a backoff, not a flat 5 minutes."""
        b, p = entry[1]
        limit = ttl if (b or p) else min(ttl, _neg_ttl(fixture_id))
        return (_t.time() - entry[0]) < limit

    hit = _ODDS_CACHE.get(fixture_id)
    if hit is not None and ttl and _fresh(hit):
        return hit[1]
    if ttl:
        shared = _shared_get(f'odds:{fixture_id}')
        # json round-trips the (b365, pin) tuple as a list -- normalise it back
        if shared and isinstance(shared[1], list) and len(shared[1]) == 2:
            entry = (shared[0], (shared[1][0], shared[1][1]))
            if _fresh(entry):
                _ODDS_CACHE[fixture_id] = entry
                return entry[1]
    # ── PINNAPI FIRST, API-FOOTBALL AS FALLBACK (2026-09-07, user) ───────
    #
    # Both sources carry the SAME book. What differs is how they are billed:
    #
    #   API-Football /odds : one call PER FIXTURE  -> ~1,400/day of a 7,500 cap
    #   pinnapi            : one call for the WHOLE slate -> 72/day of a 100 cap
    #
    # pin_fresh_capture already pulls the entire prematch slate every 20 minutes
    # and now writes each matched fixture's Pinnacle line here, so on a covered
    # fixture the line is free AND fresher than API-Football's (which reprices
    # about every 5.8h). Every hit is one API-Football call not made.
    #
    # Quality is not a trade-off. Scored against 1,303 settled fixtures, the two
    # are indistinguishable as probabilities once the fixture join is sound:
    # paired Brier difference +0.001 to +0.003, t < 1.3 at match_score >= 2.
    # The t=+4.47 "API-Football wins" result was entirely 362 single-token name
    # matches -- i.e. the wrong fixture -- which is why only score >= 2 is
    # written to the cache.
    #
    # b365 IS NOT AVAILABLE from pinnapi. It is carried over from whatever
    # API-Football last cached, so the Bet365 series degrades gracefully rather
    # than vanishing. Nothing in xlow reads it -- only the retired baseline
    # strategy and the logged clv_b365 column do.
    _pin_shared = _shared_get(f'pinodds:{fixture_id}') if ttl else None
    if _pin_shared and _fresh((_pin_shared[0], (None, _pin_shared[1]))):
        _pin = _pin_shared[1]
        if isinstance(_pin, dict) and _pin.get('h') and _pin.get('d') and _pin.get('a'):
            _b365 = None
            _prev = _shared_get(f'odds:{fixture_id}')
            if _prev and isinstance(_prev[1], list) and len(_prev[1]) == 2:
                _b365 = _prev[1][0]
            _ODDS_MISS.pop(fixture_id, None)
            _ODDS_CACHE[fixture_id] = (_t.time(), (_b365, _pin))
            return _b365, _pin
    try:
        r = requests.get(f"{AF_BASE}/odds", headers=AF_HEADERS,
                         params={"fixture": fixture_id, "bet": 1}, timeout=15)
        books = {}
        for entry in r.json().get("response", []):
            for bk in entry.get("bookmakers", []):
                name = bk["name"].lower()
                if "bet365" in name or "pinnacle" in name:
                    for bet in bk.get("bets", []):
                        if bet.get("id") == 1:  # Match Winner
                            vals = {v["value"].lower(): float(v["odd"])
                                    for v in bet["values"]}
                            books[bk["name"]] = {
                                "h": vals.get("home"),
                                "d": vals.get("draw"),
                                "a": vals.get("away"),
                            }
        b365 = next((v for k,v in books.items() if "bet365" in k.lower()), None)
        pin  = next((v for k,v in books.items() if "pinnacle" in k.lower()), None)
        # A hit resets the backoff; a miss lengthens it. Most misses are lower
        # divisions API-Football has no Pinnacle line for at all, so they settle
        # at the cap instead of being re-asked every five minutes all day.
        if b365 or pin:
            _ODDS_MISS.pop(fixture_id, None)
        else:
            _ODDS_MISS[fixture_id] = _ODDS_MISS.get(fixture_id, 0) + 1
        _ODDS_CACHE[fixture_id] = (_t.time(), (b365, pin))
        _shared_put(f'odds:{fixture_id}', [b365, pin])
        return b365, pin
    except Exception as e:
        # Cache the failure briefly too. Without this, a quota-exhausted or
        # rate-limited API gets hammered once per match per 20s cycle by both
        # quoters -- which is how the quota went from "fine" to "gone" in a
        # morning. A short negative TTL keeps retrying, just not 180x/hour.
        print(f"  [odds] Error: {e}")
        _ODDS_MISS[fixture_id] = _ODDS_MISS.get(fixture_id, 0) + 1
        _ODDS_CACHE[fixture_id] = (_t.time(), (None, None))
        # Shared too: quota exhaustion and rate limiting are GLOBAL conditions,
        # so letting the other two processes discover them independently is how
        # a bad minute turns into three times the retries.
        _shared_put(f'odds:{fixture_id}', [None, None])
        return None, None

# fetch_live_odds() lived here until 2026-08-15. It hit /odds/live once
# per fixture and had NO caller anywhere in the project -- dead since it
# was written, but still a live-looking per-fixture API call for anyone
# reading this file while hunting the quota leak. Deleted rather than
# left to be re-wired by accident; git has it.

def remove_margin(h, d, a):
    over = 1/h + 1/d + 1/a
    return 1/h/over, 1/d/over, 1/a/over

def clv_check(w):
    """Run pre-match CLV + Kalshi price check. Logs to CLV_LOG. Call once per match."""
    if not w.get('fid'):
        return
    b365, pin = fetch_prematch_odds(w['fid'], hours_to_ko=_hours_to_ko(w),
                                    league=w.get('lc'))
    if not b365 or not pin:
        print(f"  [CLV] {w['home']} vs {w['away']}: odds not available on API-Football")
        return
    if not all([b365['h'], b365['d'], b365['a'], pin['h'], pin['d'], pin['a']]):
        return

    ph, pd, pa = remove_margin(pin['h'], pin['d'], pin['a'])
    fair_h, fair_d, fair_a = 1/ph, 1/pd, 1/pa

    clv_h = (b365['h'] - fair_h) / fair_h
    clv_d = (b365['d'] - fair_d) / fair_d
    clv_a = (b365['a'] - fair_a) / fair_a

    flags = []
    if clv_h > CLV_THRESH: flags.append(f"HOME+{clv_h*100:.1f}%")
    if clv_d > CLV_THRESH: flags.append(f"DRAW+{clv_d*100:.1f}%")
    if clv_a > CLV_THRESH: flags.append(f"AWAY+{clv_a*100:.1f}%")

    print(f"\n  [CLV] {w['home']} vs {w['away']} ({w['lc']})")
    print(f"    B365:     H={b365['h']}  D={b365['d']}  A={b365['a']}")
    print(f"    Pin fair: H={fair_h:.2f}  D={fair_d:.2f}  A={fair_a:.2f}")
    print(f"    CLV:      H={clv_h*100:+.1f}%  D={clv_d*100:+.1f}%  A={clv_a*100:+.1f}%")
    if flags:
        print(f"    *** EDGE: {', '.join(flags)} ***")
    else:
        print(f"    No CLV edge (all outcomes within {CLV_THRESH*100:.0f}%)")

    # Fetch Kalshi prices
    series = maps.league_kalshi_series(w['lc'].upper())
    kalshi = None
    if series:
        try:
            gd = w.get('game_date', date.today())
            kalshi = get_all_prices(series, gd, w['home'], w['away'])
        except Exception as e:
            print(f"    [Kalshi] Error: {e}")

    if kalshi:
        print(f"    Kalshi:   H={kalshi.get('home_mid','?')}  D={kalshi.get('draw_mid','?')}  A={kalshi.get('away_mid','?')}")
        # CLV vs Kalshi (Kalshi implied prob vs Pinnacle fair)
        for label, mid_key, fair_p in [('H', 'home_mid', ph), ('D', 'draw_mid', pd), ('A', 'away_mid', pa)]:
            mid = kalshi.get(mid_key)
            if mid and mid > 0:
                k_edge = (fair_p - mid) / fair_p
                print(f"    Kalshi edge {label}: {k_edge*100:+.1f}%  (Kalshi {mid:.3f} vs Pin fair {fair_p:.3f})")

    # Log row
    row = {
        'timestamp':       datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
        'game_date':       str(w.get('game_date', date.today())),
        'league':          w['lc'],
        'home':            w['home'],
        'away':            w['away'],
        'kickoff':         w.get('kickoff', ''),
        'fixture_id':      w.get('fid', ''),
        'b365_home':       b365['h'], 'b365_draw': b365['d'], 'b365_away': b365['a'],
        'pin_home':        pin['h'],  'pin_draw':  pin['d'],  'pin_away':  pin['a'],
        'pin_fair_home':   round(fair_h, 4), 'pin_fair_draw': round(fair_d, 4), 'pin_fair_away': round(fair_a, 4),
        'clv_home':        round(clv_h, 4),  'clv_draw':      round(clv_d, 4),  'clv_away':      round(clv_a, 4),
        'clv_flag':        ', '.join(flags) if flags else '',
        'kalshi_home_mid': kalshi.get('home_mid', '') if kalshi else '',
        'kalshi_draw_mid': kalshi.get('draw_mid', '') if kalshi else '',
        'kalshi_away_mid': kalshi.get('away_mid', '') if kalshi else '',
        'kalshi_home_bid': kalshi.get('home_bid', '') if kalshi else '',
        'kalshi_draw_bid': kalshi.get('draw_bid', '') if kalshi else '',
        'kalshi_away_bid': kalshi.get('away_bid', '') if kalshi else '',
        'kalshi_home_ask': kalshi.get('home_ask', '') if kalshi else '',
        'kalshi_draw_ask': kalshi.get('draw_ask', '') if kalshi else '',
        'kalshi_away_ask': kalshi.get('away_ask', '') if kalshi else '',
    }
    CLV_FIELDS = list(row.keys())
    for attempt in range(5):
        try:
            write_header = not CLV_LOG.exists()
            with open(CLV_LOG, 'a', newline='', encoding='utf-8') as f:
                w2 = csv.DictWriter(f, fieldnames=CLV_FIELDS)
                if write_header:
                    w2.writeheader()
                w2.writerow(row)
            break
        except PermissionError:
            time.sleep(1)

def fetch_scores_batch(fids):
    """Fetch live scores for multiple fixture IDs in one API call. Returns {fid: score_dict}."""
    if not fids:
        return {}
    results = {}
    for i in range(0, len(fids), 20):
        chunk = fids[i:i+20]
        ids_str = '-'.join(str(f) for f in chunk)
        try:
            r = requests.get(f"{AF_BASE}/fixtures", headers=AF_HEADERS,
                             params={"ids": ids_str}, timeout=10)
            j = r.json()
            # An empty response here is ambiguous: no such fixture, or no quota.
            # Say which, or the log blames the fixture for an account problem.
            if _quota_exhausted(j):
                print('  [batch scores] API-Football daily request limit reached '
                      '-- scores unavailable until it resets')
                return results
            for fix in j.get('response', []):
                fid = fix['fixture']['id']
                results[fid] = {
                    'status':  fix['fixture']['status']['short'],
                    'elapsed': fix['fixture']['status']['elapsed'],
                    'home':    fix['goals']['home'],
                    'away':    fix['goals']['away'],
                }
        except Exception as e:
            print(f"  [batch scores] Error: {e}")
    return results

# ── Watchlist file loader ─────────────────────────────────────────────────

def load_watchlist_file():
    """Parse watchlist.txt. Returns (game_date, list of dicts)."""
    if not WATCHLIST_F.exists():
        return date.today(), []
    game_date = date.today()
    matches = []
    with open(WATCHLIST_F, encoding='utf-8') as f:
        for line in f:
            line = line.strip()
            if line.startswith('DATE='):
                try:
                    game_date = date.fromisoformat(line[5:].strip())
                except:
                    pass
                continue
            if not line or line.startswith('#'):
                continue
            parts = [p.strip() for p in line.split('|')]
            if len(parts) < 4:
                continue
            home, away, lc, kickoff = parts[0], parts[1], parts[2], parts[3]
            # The generator appends coverage markers after the id, e.g.
            # "1493018 [K]" for Kalshi coverage. A bare .isdigit() on the whole
            # field therefore returned None for exactly the fixtures we most
            # need -- no fid means fetch_prematch_odds() gets None, so no
            # Pinnacle, so mm_quoter skips the match and live_monitor never
            # CLV-checks it. On 2026-07-28 that silenced 16 of 25 fixtures and
            # the paper maker quoted nothing all morning. Take the leading
            # token and ignore any trailing flags.
            fid = None
            if len(parts) > 4:
                tok = parts[4].split()[0] if parts[4].split() else ''
                if tok.isdigit():
                    fid = int(tok)
            matches.append({'home': home, 'away': away, 'lc': lc,
                            'kickoff': kickoff, 'fid': fid, 'game_date': game_date})
    return game_date, matches

# ── Clock-based halftime detection ───────────────────────────────────────

def clock_elapsed_min(kickoff_str, game_date):
    """Minutes elapsed since kickoff. Returns None if unparseable, negative if not yet started."""
    try:
        ko = datetime.combine(game_date, datetime.strptime(kickoff_str, '%H:%M').time())
        return (datetime.now() - ko).total_seconds() / 60
    except:
        return None

def _hours_to_ko(w):
    """Hours until this fixture kicks off, or None if unknown/unparseable.

    Only used to relax the odds cache TTL far from kickoff, so an unknown value
    must degrade to the old flat behaviour rather than to a guess.
    """
    try:
        el = clock_elapsed_min(w.get('kickoff'), w.get('game_date', date.today()))
        return None if el is None else (-el / 60.0)
    except Exception:
        return None


SCORE_WINDOW_PRE  = 5      # minutes before kickoff to start polling scores
SCORE_WINDOW_POST = 150    # minutes after kickoff to keep polling (90 + HT +
                           # stoppage + a margin for late kickoffs)


def _in_score_window(w):
    """Is this watchlist entry near enough to kickoff for a live score to exist?

    Unparseable kickoff -> True, so a bad time string degrades to the old
    always-poll behaviour rather than silently going blind on a live match.
    A fixture already known to be in play is always in window, which covers
    a kickoff that ran late past the POST bound.
    """
    if w.get('last_status') in ('1H', '2H', 'HT', 'ET', 'BT', 'P', 'LIVE'):
        return True
    e = clock_elapsed_min(w.get('kickoff'), w.get('game_date', date.today()))
    if e is None:
        return True
    return -SCORE_WINDOW_PRE <= e <= SCORE_WINDOW_POST


def is_ht_window(kickoff_str, game_date):
    """True if we're roughly in the HT window (46-62 min after kickoff)."""
    e = clock_elapsed_min(kickoff_str, game_date)
    return e is not None and 46 <= e <= 62

# ── Alert ─────────────────────────────────────────────────────────────────

LOG_FIELDS = [
    'timestamp', 'game_date', 'league', 'home', 'away',
    'status', 'elapsed', 'home_goals', 'away_goals',
    'pin_home', 'pin_draw', 'pin_away',
    'b365_home', 'b365_draw', 'b365_away',
    'kalshi_home_mid', 'kalshi_home_bid', 'kalshi_home_ask',
    'kalshi_draw_mid', 'kalshi_draw_bid', 'kalshi_draw_ask',
    'kalshi_away_mid', 'kalshi_away_bid', 'kalshi_away_ask',
    'poly_home_mid', 'poly_home_bid', 'poly_home_ask',
    'poly_draw_mid', 'poly_draw_bid', 'poly_draw_ask',
    'poly_away_mid', 'poly_away_bid', 'poly_away_ask',
    'polyus_home_mid', 'polyus_home_bid', 'polyus_home_ask',
    'polyus_draw_mid', 'polyus_draw_bid', 'polyus_draw_ask',
    'polyus_away_mid', 'polyus_away_bid', 'polyus_away_ask',
    'pin_fair_home', 'pin_fair_draw', 'pin_fair_away',
    'clv_b365_home', 'clv_b365_draw', 'clv_b365_away',
    'clv_kalshi_home', 'clv_kalshi_draw', 'clv_kalshi_away',
    'clv_poly_home', 'clv_poly_draw', 'clv_poly_away',
    'clv_polyus_home', 'clv_polyus_draw', 'clv_polyus_away',
]

def compute_clv(row):
    """Compute pin_fair and CLV columns in-place. Returns row with CLV fields added."""
    try:
        ph = float(row.get('pin_home') or 0)
        pd_ = float(row.get('pin_draw') or 0)
        pa = float(row.get('pin_away') or 0)
        if ph and pd_ and pa:
            ov = 1/ph + 1/pd_ + 1/pa
            pfh = (1/ph) / ov
            pfd = (1/pd_) / ov
            pfa = (1/pa) / ov
            row['pin_fair_home'] = round(pfh, 6)
            row['pin_fair_draw'] = round(pfd, 6)
            row['pin_fair_away'] = round(pfa, 6)
            # B365 CLV (odds space): positive = B365 offers better odds than Pinnacle fair
            for b_key, pf in [('b365_home', pfh), ('b365_draw', pfd), ('b365_away', pfa)]:
                b = float(row.get(b_key) or 0)
                clv_key = 'clv_' + b_key
                if b and pf:
                    fair_odds = 1 / pf
                    row[clv_key] = round((b - fair_odds) / fair_odds, 6)
            # Kalshi edge (probability space): positive = Kalshi underpricing = good for bettor
            for k_key, pf in [('kalshi_home_mid', pfh), ('kalshi_draw_mid', pfd), ('kalshi_away_mid', pfa)]:
                k = float(row.get(k_key) or 0)
                clv_key = 'clv_kalshi_' + k_key.split('_')[1]
                if k and pf:
                    row[clv_key] = round((pf - k) / pf, 6)
            # Polymarket edge (probability space, mid-based): same formula as Kalshi
            for p_key, pf in [('poly_home_mid', pfh), ('poly_draw_mid', pfd), ('poly_away_mid', pfa)]:
                p = float(row.get(p_key) or 0)
                clv_key = 'clv_poly_' + p_key.split('_')[1]
                if p and pf:
                    row[clv_key] = round((pf - p) / pf, 6)
            # Polymarket US edge (GAP_LEAGUES only -- same formula, substitute book)
            for p_key, pf in [('polyus_home_mid', pfh), ('polyus_draw_mid', pfd), ('polyus_away_mid', pfa)]:
                p = float(row.get(p_key) or 0)
                clv_key = 'clv_polyus_' + p_key.split('_')[1]
                if p and pf:
                    row[clv_key] = round((pf - p) / pf, 6)
    except Exception:
        pass
    return row

def live_log_path(game_date):
    """Return the per-day CSV path for a given game_date (date or 'YYYY-MM-DD' string)."""
    if hasattr(game_date, 'isoformat'):
        gd_str = game_date.isoformat()
    else:
        gd_str = str(game_date)
    LIVE_LOG_DIR.mkdir(parents=True, exist_ok=True)
    return LIVE_LOG_DIR / f"{gd_str}.csv"

def migrate_live_log():
    """Add any missing columns to each per-day live_data CSV header."""
    if not LIVE_LOG_DIR.exists():
        return
    import io
    for path in sorted(LIVE_LOG_DIR.glob("*.csv")):
        with open(path, 'r', newline='', encoding='utf-8') as f:
            content = f.read()
        if not content.strip():
            continue
        reader = csv.DictReader(io.StringIO(content))
        existing = reader.fieldnames or []
        missing = [c for c in LOG_FIELDS if c not in existing]
        if not missing:
            continue
        print(f"  [migrate] Adding columns to {path.name}: {missing}")
        rows = list(reader)
        with open(path, 'w', newline='', encoding='utf-8') as f:
            w = csv.DictWriter(f, fieldnames=LOG_FIELDS)
            w.writeheader()
            for row in rows:
                w.writerow({k: row.get(k, '') for k in LOG_FIELDS})

def fetch_kalshi(home, away, league_code, game_date):
    """Fetch home/draw/away prices from Kalshi. Returns dict with kalshi_ prefix keys, or None."""
    try:
        series = maps.league_kalshi_series(league_code.upper())
        if not series:
            return None
        raw = get_all_prices(series, game_date, home, away)
        if not raw:
            return None
        return {f'kalshi_{k}': v for k, v in raw.items()}
    except:
        return None


def fetch_poly(home, away, league_code, game_date):
    """Fetch home/draw/away prices from Polymarket. Returns poly_-prefixed dict, or None.
    Mid/bid/ask, same shape as fetch_kalshi. Cheap single-tag fetch via league=."""
    try:
        raw = polymarket_live.get_all_prices(game_date, home, away, league=league_code.upper())
        if not raw:
            return None
        return {f'poly_{k}': v for k, v in raw.items()}
    except Exception:
        return None


def fetch_polyus(home, away, league_code, game_date):
    """Fetch home/draw/away prices from Polymarket US. Returns polyus_-prefixed
    dict, or None. Only called for GAP_LEAGUES (no Poly.com coverage)."""
    try:
        import poly_us_prices
        raw = poly_us_prices.get_all_prices(game_date, home, away, league=league_code.upper())
        if not raw:
            return None
        return {f'polyus_{k}': v for k, v in raw.items()}
    except Exception:
        return None


def log_row(row):
    gd = row.get('game_date') or date.today().isoformat()
    path = live_log_path(gd)
    for attempt in range(5):
        try:
            write_header = not path.exists()
            with open(path, 'a', newline='', encoding='utf-8') as f:
                w = csv.DictWriter(f, fieldnames=LOG_FIELDS)
                if write_header:
                    w.writeheader()
                w.writerow({k: row.get(k, '') for k in LOG_FIELDS})
            return
        except PermissionError:
            time.sleep(1)  # file open in Excel — wait and retry


def alert(home, away, league_code, score_h, score_a, game_date=None):
    if game_date is None:
        game_date = date.today()
    print(f"\n{'='*60}")
    print(f"HALFTIME ALERT  {datetime.now().strftime('%H:%M:%S')}")
    if score_h is None:
        print(f"Score unknown (API unavailable) — check score manually")
        print(f"Then run: check_halftime(date.today(), '{home}', '{away}', H, A, '{league_code}')")
    else:
        print(f"{'='*60}")
        check_halftime(game_date, home, away, score_h, score_a, league_code)
    print(f"{'='*60}\n")

# ── Main loop ─────────────────────────────────────────────────────────────

def main():
    print(f"\n=== Live Match Monitor — {date.today()} ===")
    print(f"Watchlist: {WATCHLIST_F}")
    print(f"Strategy:  {ACTIVE_STRATEGY} — {STRATEGIES.get(ACTIVE_STRATEGY, '?')}")
    if ACTIVE_STRATEGY == 'xlow':
        print("  NOTE: xlow fires ONLY on matches where BOTH Kalshi and Polymarket quote a price.")
    print(f"Edit and save the file any time — picks up changes automatically.\n")

    migrate_live_log()

    watchlist  = {}    # (home.lower, away.lower) → state dict
    file_mtime = 0
    api_ok     = True
    quota_checked_at = 0
    af_last_poll     = 0   # last time we fetched AF scores
    af_scores        = {}  # fid -> score dict, refreshed every AF_POLL_SECS
    clv_last_sent    = 0  # timestamp of last CLV digest email
    clv_batch        = []  # persist across poll cycles so throttled alerts aren't dropped

    while True:
        now_ts = time.time()

        # ── Graceful recycle request? (see RECYCLE_F) ────────────────────
        if RECYCLE_F.exists():
            try:
                RECYCLE_F.unlink()          # clear BEFORE exiting -- never
            except OSError as e:            # leave a kill file behind
                print(f"  [RECYCLE] cannot clear {RECYCLE_F} ({e}) -- "
                      f"staying up rather than exiting into a stuck flag")
            else:
                print("  [RECYCLE] requested -- exiting; "
                      "SoccerMonitorGuard will restart within ~5 min")
                return

        # ── Reload watchlist if file changed ─────────────────────────────
        try:
            mtime = WATCHLIST_F.stat().st_mtime
        except:
            mtime = 0

        if maps.changed():
            maps.reload()
            print(f"  [config] league_map.json / team_map.json reloaded")

        if mtime != file_mtime:
            _, fresh = load_watchlist_file()
            fresh_keys = {(m['home'].lower(), m['away'].lower()) for m in fresh}

            # Add new entries
            for m in fresh:
                key = (m['home'].lower(), m['away'].lower())
                if key not in watchlist:
                    print(f"  + Watching: {m['home']} vs {m['away']} ({m['lc']}) ko={m['kickoff']}")
                    watchlist[key] = {**m, 'alerted': False, 'clv_alerted': False, 'last_status': None, 'ft_time': None,
                                      'clv_checked': False, 'last_api_elapsed': None, 'api_elapsed_since': None,
                                      'last_prematch_log': None, 'fid_last_tried': None}
                else:
                    # Update metadata but NEVER touch alerted/clv_alerted/ft_time/last_status
                    for f in ('kickoff', 'lc', 'game_date'):
                        watchlist[key][f] = m[f]
                    if m['fid']:
                        watchlist[key]['fid'] = m['fid']

            # Remove entries no longer in file — but a match that is IN PLAY
            # stays until it finishes (the midnight watchlist regen was cutting
            # off live coverage of late games that cross midnight, e.g. 22:30 ET
            # MLS kickoffs; their final goals never reached live_data and the
            # resolver had to fall back to the API).
            _NOT_STARTED = (None, 'NS', 'TBD', 'PST', 'CANC', 'ABD')
            for k in list(watchlist.keys()):
                if k not in fresh_keys:
                    w = watchlist[k]
                    if w['ft_time'] is None and w.get('last_status') not in _NOT_STARTED:
                        w['finish_then_drop'] = True
                        print(f"  - Removal deferred (in play): {w['home']} vs {w['away']}")
                    else:
                        watchlist.pop(k)
                        print(f"  - Removed: {w['home']} vs {w['away']}")

            if not watchlist:
                print(f"  Watchlist empty — add matches to {WATCHLIST_F}")

            file_mtime = mtime

        # Drop deferred-removal matches once they reach a terminal state (or
        # get postponed/abandoned mid-game, so they can't linger forever).
        for k in list(watchlist.keys()):
            w = watchlist[k]
            if w.get('finish_then_drop') and (
                    w['ft_time'] or w.get('last_status') in ('PST', 'CANC', 'ABD')):
                watchlist.pop(k)
                print(f"  - Removed (finished): {w['home']} vs {w['away']}")

        if not watchlist:
            time.sleep(POLL_SECS)
            continue

        # ── Check API quota (once per 10 min) ────────────────────────────
        # THE COMMENT SAID 10 MINUTES AND THE CODE SAID 120 SECONDS. Fixed
        # 2026-08-30. At a 60s main loop that fired every other cycle: 720
        # /status calls a day, 9.6% of a 7,500 plan, spent entirely on asking
        # how much quota was left. At the documented 600s it is 144.
        #
        # Safe to slow down now that exhaustion is detected where it actually
        # bites: fetch_scores_batch reads the `errors.requests` payload directly
        # (API-Football answers 200 with an empty response when the plan is
        # spent), so this poll is a backstop rather than the only signal.
        if now_ts - quota_checked_at > QUOTA_CHECK_SECS:
            remaining = get_api_remaining()
            quota_checked_at = now_ts
            if remaining < AF_LOW_WATER:
                if api_ok:
                    print(f"  [!] API-Football quota low ({remaining} left) — switching to clock-based HT detection")
                api_ok = False
            else:
                api_ok = True

        # ── Batch fetch AF scores (one call per 20 in-play matches) ──────
        # Only fixtures actually near or inside their match window. Previously
        # this took EVERY unfinished fixture, so a game kicking off 20 hours
        # from now was polled for a live score every 3 minutes, and the
        # overnight hours -- with nothing in play at all -- still cost 7 calls
        # per poll. The watchlist spans today+tomorrow (140 entries) while peak
        # CONCURRENT in-play is about 40, so the batch was ~3.5x larger than
        # anything it could learn from. Measured: 3,360 -> 478 calls/day (-86%).
        if api_ok and (now_ts - af_last_poll) >= AF_POLL_SECS:
            live_fids = [
                w['fid'] for w in watchlist.values()
                if w['fid'] and not (w['ft_time'] and (datetime.now() - w['ft_time']).total_seconds() > 1800)
                and _in_score_window(w)
            ]
            if live_fids:
                af_scores = fetch_scores_batch(live_fids)
                af_last_poll = now_ts

        # ── Poll each match ───────────────────────────────────────────────
        for key, w in list(watchlist.items()):
            # Stop polling 30 min after final whistle
            if w['ft_time'] and (datetime.now() - w['ft_time']).total_seconds() > 1800:
                continue

            elapsed = clock_elapsed_min(w['kickoff'], w.get('game_date', date.today()))

            # ── Pre-match snapshots every 15 min (24h window before kickoff) ──
            # (within the final 60 min we fall through to the 60s live-poll loop,
            #  so CLV is logged every minute in the hour before kickoff)
            if elapsed is not None and elapsed < -60:
                if elapsed >= -1450 and api_ok:
                    last = w['last_prematch_log']
                    if last is None or (datetime.now() - last).total_seconds() >= 900:
                        # Resolve fixture ID if not yet known
                        if not w['fid']:
                            try:
                                w['fid'] = find_fixture_id(w['home'], w['away'], w['lc'], w['game_date'])
                            except Exception:
                                pass
                        pre_b365, pre_pin = (None, None)
                        if w['fid']:
                            pre_b365, pre_pin = fetch_prematch_odds(
                                w['fid'], hours_to_ko=_hours_to_ko(w),
                                league=w.get('lc'))
                        gd = w.get('game_date', date.today())
                        kalshi = fetch_kalshi(w['home'], w['away'], w['lc'], gd)
                        poly   = fetch_poly(w['home'], w['away'], w['lc'], gd)
                        # Log Poly-US prices for EVERY mapped league, not just
                        # GAP_LEAGUES (2026-07-24): no historical backfill is
                        # possible for Poly-US (no quote-history endpoint, past
                        # events aren't listable, platform is months old), so
                        # forward collection is the only way to ever build a
                        # dataset -- for validating the Kalshi-vs-Poly-US pairing
                        # and for measuring Poly-US-vs-Poly.com slippage
                        # systematically instead of anecdotally. Unmapped leagues
                        # cost nothing (find_event returns early, no HTTP).
                        polyus = fetch_polyus(w['home'], w['away'], w['lc'], gd)
                        mins_to_ko = int(elapsed)  # negative value e.g. -480
                        row = {
                            'timestamp':  datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
                            'game_date':  str(gd),
                            'league':     w['lc'],
                            'home':       w['home'],
                            'away':       w['away'],
                            'status':     'PRE',
                            'elapsed':    mins_to_ko,
                            'home_goals': 0,
                            'away_goals': 0,
                            'pin_home':   pre_pin['h'] if pre_pin else '',
                            'pin_draw':   pre_pin['d'] if pre_pin else '',
                            'pin_away':   pre_pin['a'] if pre_pin else '',
                            'b365_home':  pre_b365['h'] if pre_b365 else '',
                            'b365_draw':  pre_b365['d'] if pre_b365 else '',
                            'b365_away':  pre_b365['a'] if pre_b365 else '',
                        }
                        if kalshi:
                            row.update(kalshi)
                        if poly:
                            row.update(poly)
                        if polyus:
                            row.update(polyus)
                        compute_clv(row)
                        log_row(row)
                        w['last_prematch_log'] = datetime.now()
                        # Alert eligibility: within the T-24h snapshot window (game today
                        # or tomorrow). Was today-only; extended 2026-07-04 with the
                        # two-day watchlist so morning games get the same ~24h signal
                        # horizon evening games always had. The elapsed >= -1450 bound
                        # on this branch keeps alerts <= ~24h before kickoff.
                        from datetime import timedelta as _td
                        _gd_ok = str(w.get('game_date', '')) in (str(date.today()),
                                                                 str(date.today() + _td(days=1)))
                        trade_status = None
                        if not w.get('clv_alerted') and _gd_ok:
                            clv_alert = _build_clv_alert(w, row, mins_to_ko)
                            if clv_alert:
                                trade_status = _log_pending_bets(clv_alert)   # bookkeeping: ACTIVE_STRATEGY, unchanged
                                w['clv_alerted'] = True
                                if EMAIL_ALERT_STRATEGY == 'all':
                                    clv_alert['trade_status'] = trade_status
                                    clv_batch.append(clv_alert)
                        # xlow-only email path (2026-07-11): checked independently every
                        # snapshot with its own once-per-match flag, so an xlow shape that
                        # forms AFTER the baseline signal still emails. Email-only — no
                        # pending/bookkeeping side effects (gate_pending=False bypasses the
                        # pending dedupe, which would otherwise suppress it because the
                        # baseline entry is usually already logged). trade_status comes from
                        # the sibling call above (same row/snapshot, same edges by side) —
                        # 2026-07-23: previously this email never reflected whether a real
                        # order was placed at all.
                        if EMAIL_ALERT_STRATEGY == 'xlow' and not w.get('xlow_alerted') and _gd_ok:
                            xlow_alert = _build_clv_alert(w, row, mins_to_ko,
                                                          strategy='xlow', gate_pending=False)
                            if xlow_alert:
                                xlow_alert['trade_status'] = trade_status or {}
                                clv_batch.append(xlow_alert)
                                w['xlow_alerted'] = True
                        pin_str   = 'N/A' if not pre_pin  else f"{pre_pin['a']:.2f}A"
                        b365_str  = 'N/A' if not pre_b365 else f"{pre_b365['a']:.2f}A"
                        kal_away  = kalshi.get('kalshi_away_mid', '?') if kalshi else None
                        kal_str   = 'N/A' if not kal_away else f"{kal_away}A"
                        print(f"  [PRE {mins_to_ko:+d}min] {w['home']} vs {w['away']}: "
                              f"Pin={pin_str}  B365={b365_str}  Kalshi={kal_str}")
                continue  # don't do live polling until 60 min before kickoff

            tag = f"[{datetime.now().strftime('%H:%M')}] {w['home']} vs {w['away']}"

            # Pre-match CLV check (once per match, only before kickoff)
            if not w['clv_checked'] and w.get('fid') and api_ok:
                elapsed = clock_elapsed_min(w['kickoff'], w.get('game_date', date.today()))
                if elapsed is not None and elapsed < 0:
                    w['clv_checked'] = True
                    clv_check(w)
                else:
                    w['clv_checked'] = True  # game started, unknown elapsed, or no kickoff — skip

            score = None
            if api_ok:
                # Resolve fixture ID if needed — back off 10 min after each failed attempt
                if not w['fid']:
                    last_tried = w.get('fid_last_tried')
                    if last_tried is None or (datetime.now() - last_tried).total_seconds() >= 600:
                        w['fid_last_tried'] = datetime.now()
                        try:
                            w['fid'] = find_fixture_id(w['home'], w['away'], w['lc'], w['game_date'])
                            if w['fid']:
                                print(f"  {tag}: fixture ID {w['fid']}")
                        except Exception as e:
                            print(f"  {tag}: lookup error ({e})")

                # Use batch-fetched score (refreshed every AF_POLL_SECS)
                if w['fid']:
                    score = af_scores.get(w['fid'])

            # ── Score available via API ───────────────────────────────────
            if score:
                status = score['status']
                h = score['home'] or 0
                a = score['away'] or 0

                # Interpolate elapsed: API only updates every ~5 min
                api_elapsed = score['elapsed']
                now_dt = datetime.now()
                if api_elapsed != w['last_api_elapsed']:
                    w['last_api_elapsed'] = api_elapsed
                    w['api_elapsed_since'] = now_dt
                if api_elapsed and w['api_elapsed_since'] and status not in ('NS', 'TBD', 'HT', 'FT', 'AET', 'PEN'):
                    extra = (now_dt - w['api_elapsed_since']).total_seconds() / 60
                    est_elapsed = int(api_elapsed + extra)
                elif status in ('NS', 'TBD'):
                    est_elapsed = int(clock_elapsed_min(w['kickoff'], w.get('game_date', date.today())) or 0)
                else:
                    est_elapsed = api_elapsed or 0

                print(f"  {tag}: {h}-{a}  [{status} {est_elapsed}']")

                # Fetch Kalshi on every poll
                gd = w.get('game_date', date.today())
                kalshi = fetch_kalshi(w['home'], w['away'], w['lc'], gd)
                poly   = fetch_poly(w['home'], w['away'], w['lc'], gd)

                # Log every poll with a score — but only log FT/AET/PEN once, skip >120 min
                already_terminal = w['ft_time'] is not None and status in ('FT', 'AET', 'PEN')
                if not already_terminal and est_elapsed <= 120:
                    row = {
                        'timestamp':  now_dt.strftime('%Y-%m-%d %H:%M:%S'),
                        'game_date':  str(gd),
                        'league':     w['lc'],
                        'home':       w['home'],
                        'away':       w['away'],
                        'status':     status,
                        'elapsed':    est_elapsed,
                        'home_goals': h,
                        'away_goals': a,
                    }
                    if kalshi:
                        row.update(kalshi)
                    if poly:
                        row.update(poly)
                    log_row(row)

                if status == 'HT' and not w['alerted']:
                    w['alerted'] = True
                    alert(w['home'], w['away'], w['lc'], h, a, gd)
                elif status in ('FT', 'AET', 'PEN') and not w['ft_time']:
                    w['ft_time'] = datetime.now()
                    if not w['alerted']:
                        print(f"  {tag}: finished ({status}) without HT data — nothing to do")
                    else:
                        print(f"  {tag}: {status} — will stop polling at {(w['ft_time'].replace(second=0, microsecond=0)).strftime('%H:%M')} +30min")

                w['last_status'] = status

            # ── Fallback: no AF score — still log Kalshi + clock-based HT ──
            elif w['kickoff']:
                gd = w.get('game_date', date.today())
                clock_el = clock_elapsed_min(w['kickoff'], gd)
                if clock_el is not None and clock_el >= 0 and clock_el <= 120 and not w['ft_time']:
                    reason = "quota low" if not api_ok else "fixture not found"
                    # Estimate status from clock
                    if clock_el < 46:
                        est_status = '1H'
                    elif clock_el < 62:
                        est_status = 'HT'
                    elif clock_el < 115:
                        est_status = '2H'
                    else:
                        est_status = 'FT'
                    now_dt = datetime.now()
                    kalshi = fetch_kalshi(w['home'], w['away'], w['lc'], gd)
                    poly   = fetch_poly(w['home'], w['away'], w['lc'], gd)
                    row = {
                        'timestamp':  now_dt.strftime('%Y-%m-%d %H:%M:%S'),
                        'game_date':  str(gd),
                        'league':     w['lc'],
                        'home':       w['home'],
                        'away':       w['away'],
                        'status':     est_status,
                        'elapsed':    int(clock_el),
                        'home_goals': None,
                        'away_goals': None,
                    }
                    if kalshi:
                        row.update(kalshi)
                    if poly:
                        row.update(poly)
                    log_row(row)
                    print(f"  {tag}: {clock_el:.0f}min elapsed ({reason}) — logged Kalshi only")

                    if not w['alerted'] and is_ht_window(w['kickoff'], gd):
                        print(f"  {tag}: clock HT window — triggering alert")
                        w['alerted'] = True
                        alert(w['home'], w['away'], w['lc'], None, None)

        # ── Send CLV alerts — at most once per hour ───────────────────────────
        if clv_batch and (now_ts - clv_last_sent) >= 3600:
            send_batch_clv_alert(clv_batch)
            clv_last_sent = now_ts
            clv_batch = []  # clear ONLY after successful send (was previously cleared every poll, dropping throttled alerts)

        # ── All games done — clear watchlist and wait for midnight reload ──
        if watchlist and all(
            w['ft_time'] and (datetime.now() - w['ft_time']).total_seconds() > 1800
            for w in watchlist.values()
        ):
            print(f"\n[{datetime.now().strftime('%H:%M')}] All matches complete — waiting for next watchlist.")
            watchlist.clear()

        time.sleep(POLL_SECS)

if __name__ == '__main__':
    import argparse
    _ap = argparse.ArgumentParser(description="Live match monitor")
    _ap.add_argument('--strategy', choices=list(STRATEGIES), default=ACTIVE_STRATEGY,
                     help="signal rule to fire on (default: %(default)s)")
    _args, _ = _ap.parse_known_args()
    ACTIVE_STRATEGY = _args.strategy
    main()
