#!/usr/bin/env python3
"""SKY-Cockpit Radar-Animation Pre-Render (parallel).

Session 92 (29.05.2026): Trigger-statt-Cron-Logik.
Statt starr alle 5 min zu rendern, prueft das Skript bei jedem Aufruf
das DWD-Open-Data-RV-Listing, parst den juengsten verfuegbaren Anker
und rendert NUR wenn dieser neuer ist als der bisher gerenderte
(manifest.json.anchor). Dadurch sinkt der Drift zwischen DWD-
Veroeffentlichung und unserer Auslieferung auf max ~30 s (statt bis
zu 8 min bei festem 5-min-Cron).
"""
import datetime, json, re, sys, time
from pathlib import Path
import urllib.request, urllib.error, urllib.parse
from concurrent.futures import ThreadPoolExecutor, as_completed

OUT_DIR = Path('/opt/mapproxy/static/radar')
OUT_DIR.mkdir(parents=True, exist_ok=True)

WMS_BASE = 'https://maps.dwd.de/geoserver/dwd/wms'   # direkt zum DWD-GeoServer
LAYER    = 'Niederschlagsradar'                      # DWD-Layername; MapProxy-Umweg bringt nichts (niederschlag = disable_storage)
BBOX     = (550000, 5780000, 1840000, 7560000)
WIDTH, HEIGHT = 1500, 1500
TIMEOUT  = 90    # grosszuegig: maps.dwd.de ist zeitweise sehr langsam
PARALLEL = 3     # niedrig haelt MapProxy-Lock-Contention klein
OFFSETS  = list(range(-90, 95, 5))

DWD_RV_LISTING = 'https://opendata.dwd.de/weather/radar/composite/rv/'
DWD_RV_RE      = re.compile(r'RV(\d{10})_LATEST\.tar\.bz2|RV(\d{10})\.tar\.bz2')


def latest_dwd_anchor():
    """Liest das DWD-RV-Listing und gibt den UTC-Zeitstempel der
    juengsten verfuegbaren RV-Datei zurueck (datetime, UTC).
    Returns None bei Netzwerk-Fehler oder leerem Listing.
    """
    try:
        req = urllib.request.Request(DWD_RV_LISTING,
                                     headers={'User-Agent':'SKY-Radar-Render/1.0'})
        with urllib.request.urlopen(req, timeout=15) as r:
            html = r.read().decode('utf-8', errors='ignore')
        stamps = []
        for m in DWD_RV_RE.finditer(html):
            ts = m.group(1) or m.group(2)
            if ts and len(ts) == 10:
                # YYMMDDHHMM
                try:
                    dt = datetime.datetime.strptime(ts, '%y%m%d%H%M')
                    stamps.append(dt)
                except ValueError:
                    pass
        return max(stamps) if stamps else None
    except Exception as e:
        print('  latest_dwd_anchor: ' + type(e).__name__ + ': ' + str(e), flush=True)
        return None


def current_manifest_anchor():
    """Liest manifest.anchor aus dem bestehenden manifest.json (oder None)."""
    try:
        m = json.loads((OUT_DIR / 'manifest.json').read_text())
        a = m.get('anchor', '').rstrip('Z')
        return datetime.datetime.fromisoformat(a) if a else None
    except Exception:
        return None


def anchor_slot():
    """Fallback: errechnet den 5-min-Slot fuer NOW-5min (wird nur
    verwendet wenn latest_dwd_anchor() None liefert)."""
    now = datetime.datetime.utcnow()
    s = 5 * 60
    last = (int(now.timestamp() - 300) // s) * s
    return datetime.datetime.utcfromtimestamp(last)

def fetch_frame(args):
    offset_min, iso_time = args
    params = {
        'service': 'WMS', 'version': '1.3.0', 'request': 'GetMap',
        'layers': LAYER, 'styles': '', 'crs': 'EPSG:3857',
        'bbox': ','.join(str(c) for c in BBOX),
        'width': WIDTH, 'height': HEIGHT,
        'format': 'image/png', 'transparent': 'true',
        'time': iso_time
    }
    url = WMS_BASE + '?' + urllib.parse.urlencode(params)
    try:
        req = urllib.request.Request(url, headers={'User-Agent':'SKY-Radar-Render/1.0'})
        with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
            if r.status != 200:
                return offset_min, False, 'HTTP ' + str(r.status)
            data = r.read()
        sign = '+' if offset_min >= 0 else ''
        out = OUT_DIR / ('T' + sign + str(offset_min) + '.png')
        tmp = OUT_DIR / ('T' + sign + str(offset_min) + '.tmp')
        tmp.write_bytes(data)
        tmp.rename(out)
        return offset_min, True, len(data)
    except Exception as e:
        return offset_min, False, type(e).__name__ + ': ' + str(e)

def main():
    started = time.time()
    # Session 92: Anker direkt aus DWD-Listing holen statt starr nach Uhr.
    dwd_anchor = latest_dwd_anchor()
    if dwd_anchor is None:
        # Fallback: alter Slot-Mechanismus (falls DWD-Listing nicht erreichbar)
        anchor = anchor_slot()
        print('[' + datetime.datetime.utcnow().isoformat(timespec='seconds') + 'Z] '
              'DWD-Listing nicht erreichbar, Fallback-Anker: ' + anchor.isoformat() + 'Z', flush=True)
    else:
        anchor = dwd_anchor
    # Idempotenz: wenn unser manifest schon auf diesem Anker steht, nichts tun.
    cur = current_manifest_anchor()
    if cur and cur >= anchor:
        elapsed = time.time() - started
        print('[' + datetime.datetime.utcnow().isoformat(timespec='seconds') + 'Z] '
              'no-op (manifest.anchor=' + cur.isoformat() + 'Z >= dwd=' + anchor.isoformat() + 'Z) '
              + str(round(elapsed,2)) + 's', flush=True)
        sys.exit(0)
    print('[' + datetime.datetime.utcnow().isoformat(timespec='seconds') + 'Z] '
          'Render-Anchor: ' + anchor.isoformat() + 'Z, parallel=' + str(PARALLEL), flush=True)
    jobs = []
    for off in OFFSETS:
        t   = anchor + datetime.timedelta(minutes=off)
        iso = t.replace(microsecond=0).isoformat() + 'Z'
        jobs.append((off, iso))
    ok = fail = 0
    sizes = {}
    with ThreadPoolExecutor(max_workers=PARALLEL) as ex:
        futures = {ex.submit(fetch_frame, j): j for j in jobs}
        for fut in as_completed(futures):
            off, success, info = fut.result()
            if success:
                ok += 1
                sizes[off] = info
            else:
                fail += 1
                print('  T' + str(off) + ': FAIL ' + str(info), flush=True)
    # Session 92 (bak25): Manifest-Anker bleibt alt wenn zu viele Frames scheitern.
    # So vermeiden wir Mischzustand (neue + alte Frames). Cockpit zeigt zusaetzlich
    # 'DWD ueberlastet' Warning via status-Feld.
    FAIL_TOLERANCE = 5
    elapsed = time.time() - started
    new_anchor_iso = anchor.isoformat() + 'Z'
    try:
        old_manifest = json.loads((OUT_DIR / 'manifest.json').read_text())
    except Exception:
        old_manifest = {}
    commit_anchor = (fail <= FAIL_TOLERANCE)
    effective_anchor = new_anchor_iso if commit_anchor else (old_manifest.get('anchor') or new_anchor_iso)
    status = {
        'ok': ok,
        'fail': fail,
        'attempted_anchor': new_anchor_iso,
        'last_attempt_at': datetime.datetime.utcnow().isoformat(timespec='seconds') + 'Z',
        'dwd_overload': (not commit_anchor),
        'msg': ('DWD-Server ueberlastet (nur ' + str(ok) + '/' + str(ok+fail) + ' Frames OK)') if not commit_anchor else 'OK'
    }
    manifest = {
        'anchor':    effective_anchor,
        'generated': datetime.datetime.utcnow().isoformat(timespec='seconds') + 'Z',
        'bbox_3857': list(BBOX),
        'bbox_4326': [4.94, 46.04, 16.52, 56.10],
        'width':  WIDTH,
        'height': HEIGHT,
        'status':  status,
        'frames': [{'offset_min': o,
                    'file': 'T' + ('+' if o >= 0 else '') + str(o) + '.png',
                    'size': sizes.get(o)} for o in OFFSETS]
    }
    (OUT_DIR / 'manifest.json').write_text(json.dumps(manifest, indent=2))
    if commit_anchor:
        print('Done: ' + str(ok) + ' ok / ' + str(fail) + ' fail / ' + str(round(elapsed, 1)) + 's', flush=True)
    else:
        print('PARTIAL FAIL: ' + str(ok) + ' ok / ' + str(fail) + ' fail · anchor bleibt '
              + effective_anchor + ' (versucht: ' + new_anchor_iso + ') · DWD ueberlastet · '
              + str(round(elapsed, 1)) + 's', flush=True)
    sys.exit(0 if fail == 0 else (0 if not commit_anchor else 0))

if __name__ == '__main__':
    main()
