#!/usr/bin/env python3
"""
update_live_quotes.py — writes forecast_live.json for the Daily Forecasts
page AND fires level-touch Telegram alerts.

v2 adds LEVEL TOUCH ALERTS: each cycle compares the session's high/low
against the eight forecast levels per instrument. The FIRST touch of each
level per session goes to every dashboard-token holder (with their
personal forecast-page link). Uses day high/low, so a fast wick between
cycles still counts. Multiple new touches in one cycle are bundled into
one message per instrument.

Deploy-safe: the first cycle of each session (or after a mid-session
deploy) silently baselines whatever is already touched — alerts flow
from the next cycle onward. Alert activity: data\\logs\\level_alerts.log

The dashboard server runs this about once a minute during sessions.
Save to: src\\execution\\update_live_quotes.py  (next to notify_forecast.py,
which it borrows the Telegram plumbing from).
"""
import json
import os
import sys
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path

try:
    import MetaTrader5 as mt5
except ImportError:
    sys.exit("MetaTrader5 package missing — run: python -m pip install MetaTrader5")

BASE_PATH = Path(__file__).resolve().parents[2]
LOG_DIR = BASE_PATH / "data" / "logs"
FORECAST_JSON = BASE_PATH / "forecast_data.json"
OUT_JSON = BASE_PATH / "forecast_live.json"
ALERT_STATE = LOG_DIR / "level_alerts_state.json"
ALERT_LOG = LOG_DIR / "level_alerts.log"
M15_BARS = 40

# Telegram plumbing shared with the daily forecast alert
try:
    from notify_forecast import (bot_token, send as tg_send, load_json,
                                 TOKENS_FILE, FORECAST_PAGE_URL)
    ALERTS_AVAILABLE = True
except Exception:
    ALERTS_AVAILABLE = False

# Fallback only — normally symbols come from forecast_data.json so the
# two files always agree. Keep in sync with build_forecast.py.
INSTRUMENTS = [
    ("GOLD",   ["XAUUSD", "GOLD", "XAUUSD.a", "XAUUSD.r"]),
    ("EURUSD", ["EURUSD", "EURUSD.a", "EURUSD.r"]),
    ("GBPUSD", ["GBPUSD", "GBPUSD.a", "GBPUSD.r"]),
    ("USDJPY", ["USDJPY", "USDJPY.a", "USDJPY.r"]),
    ("NQ",     ["USTEC", "NAS100", "US100", "USTEC.cash", "NAS100.cash",
                "US100.cash"]),
]


def session_iso():
    """Session date, rolling at 17:00 New York (= broker midnight)."""
    try:
        from zoneinfo import ZoneInfo
        ny = datetime.now(ZoneInfo("America/New_York"))
    except Exception:
        ny = datetime.now(timezone.utc) - timedelta(hours=4)
    return (ny + timedelta(hours=7)).date().isoformat()


def alog(msg):
    try:
        with open(ALERT_LOG, "a", encoding="utf-8") as f:
            f.write(f"{datetime.now(timezone.utc):%Y-%m-%d %H:%M:%S}Z  {msg}\n")
    except Exception:
        pass


def symbol_map():
    """display -> broker symbol, from forecast_data.json when possible."""
    try:
        with open(FORECAST_JSON, encoding="utf-8") as f:
            data = json.load(f)
        m = {i["display"]: i["symbol"]
             for i in data.get("instruments", [])
             if i.get("ok") and i.get("symbol")}
        if m:
            return m
    except Exception:
        pass
    m = {}
    for display, candidates in INSTRUMENTS:
        for cand in candidates:
            if mt5.symbol_select(cand, True):
                t = mt5.copy_rates_from_pos(cand, mt5.TIMEFRAME_D1, 0, 2)
                if t is not None and len(t) > 0:
                    m[display] = cand
                    break
    return m


def instrument_levels(inst):
    """The eight forecast levels, with side relative to the anchor."""
    a = inst["anchor"]
    pc = lambda x: x / 100.0
    return [
        ("Close +med", a * (1 + pc(inst["oc_med"])), "up"),
        ("Close +75p", a * (1 + pc(inst["oc_75"])), "up"),
        ("Proj H med", a * (1 + pc(inst["hl_med"])), "up"),
        ("Proj H 75p", a * (1 + pc(inst["hl_75"])), "up"),
        ("Close -med", a * (1 - pc(inst["oc_med"])), "dn"),
        ("Close -75p", a * (1 - pc(inst["oc_75"])), "dn"),
        ("Proj L med", a * (1 - pc(inst["hl_med"])), "dn"),
        ("Proj L 75p", a * (1 - pc(inst["hl_75"])), "dn"),
    ]


def check_level_alerts(quotes, today):
    """First-touch alerts, once per level per session, bundled per cycle."""
    if not ALERTS_AVAILABLE:
        return
    data = load_json(FORECAST_JSON, None)
    if not data or data.get("session_date") != today:
        return

    state = load_json(ALERT_STATE, {})
    seeding = state.get("session_date") != today
    if seeding:
        state = {"session_date": today, "touched": {}}

    hits_by_inst = []
    for inst in data.get("instruments", []):
        if not inst.get("ok"):
            continue
        disp = inst["display"]
        q = quotes.get(disp)
        if not q or q.get("day_high") is None or q.get("day_low") is None:
            continue
        done = set(state["touched"].get(disp, []))
        new = []
        for name, px, side in instrument_levels(inst):
            if name in done:
                continue
            if (side == "up" and q["day_high"] >= px) or \
               (side == "dn" and q["day_low"] <= px):
                done.add(name)
                new.append((name, px))
        state["touched"][disp] = sorted(done)
        if new and not seeding:
            hits_by_inst.append((disp, new, inst, q))

    tmp = ALERT_STATE.with_name(ALERT_STATE.name + ".tmp")
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(state, f)
    os.replace(tmp, ALERT_STATE)

    if seeding:
        alog("session baseline seeded — no alerts for pre-existing touches")
        return
    if not hits_by_inst:
        return

    token = bot_token()
    users = [u for u in load_json(TOKENS_FILE, {}).get("tokens", [])
             if u.get("chat_id") and u.get("token")]
    if not token or not users:
        alog("touches detected but no bot token / users — nothing sent")
        return

    for disp, new, inst, q in hits_by_inst:
        dig = int(inst.get("digits", 5))
        names = ", ".join(f"{n} ({px:.{dig}f})"
                          for n, px in sorted(new, key=lambda t: -t[1]))
        ctx = ""
        if q.get("last") is not None:
            ctx = f"last {q['last']:.{dig}f}"
        if q.get("day_open"):
            used = (q["day_high"] - q["day_low"]) / q["day_open"] * 100
            ctx += (f" · range used {used:.2f}% "
                    f"({used / inst['hl_med'] * 100:.0f}% of median · "
                    f"{used / inst['hl_75'] * 100:.0f}% of 75th)")
        base = f"🔔 {disp} level touch — {names}\n{ctx}\n📈 Levels chart:"
        sent = 0
        for u in users:
            try:
                tg_send(token, u["chat_id"],
                        base + "\n" + f"{FORECAST_PAGE_URL}?key={u['token']}")
                sent += 1
            except Exception as e:
                alog(f"send fail -> {u.get('name', u.get('chat_id'))}: {e}")
            time.sleep(0.3)
        alog(f"{disp}: {names} -> sent to {sent} users")


def main():
    if not mt5.initialize():
        sys.exit(f"MT5 initialize failed: {mt5.last_error()}")
    try:
        today = session_iso()
        quotes = {}
        for display, sym in symbol_map().items():
            mt5.symbol_select(sym, True)
            info = mt5.symbol_info(sym)
            digits = int(info.digits) if info else 5
            tick = mt5.symbol_info_tick(sym)
            last = None
            if tick:
                if getattr(tick, "last", 0):
                    last = float(tick.last)
                elif getattr(tick, "bid", 0):
                    last = float(tick.bid)
            entry = {"symbol": sym, "digits": digits,
                     "last": round(last, digits) if last is not None else None,
                     "day_open": None, "day_high": None, "day_low": None,
                     "m15": []}
            d1 = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_D1, 0, 1)
            if d1 is not None and len(d1) > 0:
                bar_date = datetime.fromtimestamp(
                    d1[0]['time'], timezone.utc).strftime("%Y-%m-%d")
                if bar_date == today:
                    entry["day_open"] = round(float(d1[0]['open']), digits)
                    entry["day_high"] = round(float(d1[0]['high']), digits)
                    entry["day_low"] = round(float(d1[0]['low']), digits)
            m15 = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M15, 0, M15_BARS)
            if m15 is not None and len(m15) > 0:
                entry["m15"] = [
                    {"t": int(r['time']),
                     "o": round(float(r['open']), digits),
                     "h": round(float(r['high']), digits),
                     "l": round(float(r['low']), digits),
                     "c": round(float(r['close']), digits)}
                    for r in m15
                ]
            quotes[display] = entry
        out = {
            "generated_utc":
                datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S"),
            "generated_ts": int(datetime.now(timezone.utc).timestamp()),
            "session_date": today,
            "quotes": quotes,
        }
        tmp = OUT_JSON.with_name(OUT_JSON.name + ".tmp")
        with open(tmp, "w", encoding="utf-8") as f:
            json.dump(out, f)
        os.replace(tmp, OUT_JSON)
        print(f"live quotes: {len(quotes)} symbols written")

        try:
            check_level_alerts(quotes, today)
        except Exception as e:
            alog(f"alert check failed: {e}")
    finally:
        mt5.shutdown()


if __name__ == "__main__":
    main()
