"""
core/gaps.py — GapAnalyzer
============================
Erkennt ungefüllte Kurslücken (Gaps) im Tageschart (D1) und liefert ihre
Fill-Target-Levels als S/R-Kandidaten (Magnete). Gleiche Rolle wie `daily_levels`
/ `SRDetector`: NUR Kontext/Anzeige + Konfidenz (über `_sr_levels` → Wellen-
Konfidenz → Autotrader), KEINE eigene Handelsrichtung.

ZWEI Gap-Begriffe — bewusst getrennt (Fix 2026-08-03):

 1. `kind="vacuum"` — **Vakuum zwischen den TAGESSPANNEN** (die strenge Definition):
      UP   : Low(heute) > High(gestern)  → Lücke [High_gestern … Low_heute]
      DOWN : High(heute) < Low(gestern)  → Lücke [High_heute … Low_gestern]
    Nur DIESE speisen `zone_lines()` → `_sr_levels` → Wellen-Konfidenz, denn nur auf
    ihnen wurde gemessen (`backtest_gaps.py`: „der Magnet zieht NICHT" — Signale WEG
    vom Gap +0,094 Ø-Edge, ZUM Gap nur +0,014). Die Definition zu ändern würde diese
    Messung ungültig machen, also bleibt der Strategie-Eingang unangetastet.

 2. `kind="close_open"` — **Schlusskurs → Eröffnung**, also das, was ein Mensch als
    „Kurslücke" bezeichnet. REINE ANZEIGE, fließt NICHT in `zone_lines()`.

⚠ WARUM das nötig wurde (User-Meldung „die Kurslückenerkennung funktioniert nicht
richtig"): Am Wochenende 31.07.→03.08. fiel WTI von Schluss 86,33 auf Eröffnung
79,76 — **−6,57 $**. Die Vakuum-Definition sah davon NICHTS, weil Montags Hoch
(81,67) das Freitags-Tief (81,64) um **3 Cent** überlappte. Ein Detektor, der die
größte Lücke des Jahres an drei Cent scheitern lässt, beantwortet die Frage nicht,
die der Nutzer stellt. In einem 24/5-Markt überlappen die Tagesspannen fast immer —
deshalb fand die strenge Regel im ganzen Jahr nur 5 Gaps.

„Gefüllt" = ein späterer Bar handelt wieder in/durch die Lücke.
"""
from __future__ import annotations
import threading
import time
import datetime as dt

import MetaTrader5 as mt5

from core.mt5_utils import mt5_lock
from core.logger import get_logger

log = get_logger("gaps")

_BROKER_OFFSET_S = 3 * 3600   # Brokerzeit (UTC+3) → ~UTC für die Datums-Zuordnung


class GapAnalyzer:
    """Thread-sicher, fail-safe. `maybe_refresh()` drosselt den MT5-Zugriff."""

    def __init__(self, refresh_s: int = 600, lookback_days: int = 400,
                 year: int | None = None, min_close_open: float = 0.50):
        # `min_close_open`: ab welcher Schluss→Eröffnung-Differenz eine Lücke
        # gemeldet wird. 0,50 $ filtert das normale Wochenend-Rauschen heraus und
        # lässt die handelsrelevanten Sprünge übrig (real gefunden: +1,17 / +1,13 /
        # +1,05 / −6,57 $ in den letzten Wochen).
        self._min_co = float(min_close_open)
        self._lock = threading.Lock()
        self._gaps: list[dict] = []     # offene (ungefüllte) Gaps
        self._n_total = 0
        self._ts = 0.0
        self._next_run = 0.0
        self._refresh_s = refresh_s
        self._lookback = lookback_days
        self._year = year               # None = aktuelles Jahr

    def maybe_refresh(self, sym: str | None):
        """Im Loop aufrufen — holt die D1-Bars höchstens alle `refresh_s`."""
        if not sym or time.time() < self._next_run:
            return
        self._next_run = time.time() + self._refresh_s
        self.detect(sym)

    def detect(self, sym: str):
        try:
            with mt5_lock(timeout=3) as got:
                if not got:
                    self._next_run = time.time() + 30   # gleich nochmal versuchen
                    return
                bars = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_D1, 0, self._lookback)
            if bars is None or len(bars) < 2:
                return
            year = self._year or dt.datetime.now().year
            rows = []
            for b in bars:
                # ⚠ DATUMS-FIX 2026-08-03: hier stand `int(b["time"]) - _BROKER_OFFSET_S`.
                # MT5 liefert die Bar-Zeit als NAIVE Broker-Zeit, die bereits als Epoch
                # kodiert ist — ein zusätzlicher Abzug von 3 h schiebt jeden D1-Bar auf
                # den VORTAG. Real: der Bar vom 03.08. wurde als „2026-08-02" geführt,
                # die gemeldete Lücke „2026-07-26" war in Wahrheit der 27.07. Betraf
                # ALLE Gap-Datumsangaben (und am Jahreswechsel den `year`-Filter).
                d = dt.datetime.fromtimestamp(
                    int(b["time"]), tz=dt.timezone.utc).date()
                rows.append({"date": d, "h": float(b["high"]), "o": float(b["open"]),
                             "l": float(b["low"]), "c": float(b["close"])})
            rows = [r for r in rows if r["date"].year == year]
            if len(rows) < 2:
                return

            gaps = []
            for i in range(1, len(rows)):
                p, cur = rows[i - 1], rows[i]
                if cur["l"] > p["h"]:
                    gaps.append({"i": i, "date": cur["date"].isoformat(), "dir": "up",
                                 "kind": "vacuum",
                                 "lo": round(p["h"], 3), "hi": round(cur["l"], 3),
                                 "fill": round(p["h"], 3)})
                elif cur["h"] < p["l"]:
                    gaps.append({"i": i, "date": cur["date"].isoformat(), "dir": "down",
                                 "kind": "vacuum",
                                 "lo": round(cur["h"], 3), "hi": round(p["l"], 3),
                                 "fill": round(p["l"], 3)})
                else:
                    # Schluss → Eröffnung: die Lücke, die ein Mensch meint. Nur wenn
                    # KEIN Vakuum vorliegt (sonst doppelt), und erst ab `min_close_open`.
                    co = cur["o"] - p["c"]
                    if abs(co) >= self._min_co:
                        lo, hi = sorted((p["c"], cur["o"]))
                        gaps.append({"i": i, "date": cur["date"].isoformat(),
                                     "dir": "up" if co > 0 else "down",
                                     "kind": "close_open",
                                     "lo": round(lo, 3), "hi": round(hi, 3),
                                     # Füllung = zurück zum Schlusskurs des Vortags
                                     "fill": round(p["c"], 3)})

            openg = []
            for g in gaps:
                # ⚠ Bei `close_open` zählt der Gap-Bar SELBST mit: eine Eröffnungs-
                # lücke wird oft am selben Tag wieder zugehandelt („Gap am Montag
                # geschlossen"). Ein Vakuum kann sich per Konstruktion nicht selbst
                # füllen (es liegt ja zwischen den Spannen), dort erst ab dem Folgetag.
                later = rows[g["i"]:] if g.get("kind") == "close_open" \
                    else rows[g["i"] + 1:]
                if g["dir"] == "up":
                    mn = min((r["l"] for r in later), default=g["hi"])
                    filled = mn <= g["lo"]
                else:
                    mx = max((r["h"] for r in later), default=g["lo"])
                    filled = mx >= g["hi"]
                g["size"] = round(g["hi"] - g["lo"], 3)
                if not filled:
                    g.pop("i", None)
                    openg.append(g)

            with self._lock:
                self._gaps = openg
                self._n_total = len(gaps)
                self._ts = time.time()
            log.info(f"Gaps {year}: {len(gaps)} gesamt, {len(openg)} offen")
        except Exception as e:
            log.warning(f"GapAnalyzer.detect: {e}")

    def zone_lines(self) -> list[float]:
        """Fill-Target-Levels für `_sr_levels` → Wellen-Konfidenz.

        ⚠ NUR `vacuum`-Gaps (Fix 2026-08-03). Der gemessene Befund
        (`backtest_gaps.py`) beruht auf genau dieser Definition; die 2026-08-03
        ergänzten `close_open`-Lücken sind REINE ANZEIGE und würden den
        Strategie-Eingang sonst still verändern — genau die Sorte unbemerkter
        Grundlagen-Verschiebung, gegen die der Deployment-Drift-Abschnitt steht.
        Wer sie einbeziehen will, misst vorher (2 Stichproben).
        """
        with self._lock:
            return [g["fill"] for g in self._gaps
                    if g.get("kind", "vacuum") == "vacuum"]

    def nearest(self, price: float | None) -> dict:
        """Nächstes offenes Gap-Fill-Level über/unter dem Preis."""
        if price is None:
            return {"above": None, "below": None}
        with self._lock:
            fills = [g["fill"] for g in self._gaps]
        above = sorted(f for f in fills if f > price)
        below = sorted((f for f in fills if f < price), reverse=True)
        return {"above": above[0] if above else None,
                "below": below[0] if below else None}

    def snapshot(self) -> dict:
        with self._lock:
            g = list(self._gaps)
            return {"gaps": g, "n_open": len(g),
                    # getrennt ausweisen, damit im UI erkennbar bleibt, welche
                    # Lücken die Konfidenz speisen (vacuum) und welche nur Anzeige sind
                    "n_vacuum": sum(1 for x in g if x.get("kind", "vacuum") == "vacuum"),
                    "n_close_open": sum(1 for x in g if x.get("kind") == "close_open"),
                    "n_total": self._n_total, "last_update": self._ts}
