#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# run_all.py — iTV INFOTV | Zentraler Scheduler

import os
import sys
import json
import time
import glob
import logging
import subprocess
import traceback
from datetime import datetime, timedelta

import requests
import ssl
from requests.adapters import HTTPAdapter

class _SSLAdapter(HTTPAdapter):
    """Behebt SSL UNEXPECTED_EOF_WHILE_READING in Python 3.12+ (z.B. bei open-meteo.com)."""
    def init_poolmanager(self, *args, **kwargs):
        ctx = ssl.create_default_context()
        if hasattr(ssl, "OP_IGNORE_UNEXPECTED_EOF"):
            ctx.options |= ssl.OP_IGNORE_UNEXPECTED_EOF
        kwargs["ssl_context"] = ctx
        super().init_poolmanager(*args, **kwargs)

def _wetter_session():
    s = requests.Session()
    adapter = _SSLAdapter(max_retries=3)
    s.mount("https://", adapter)
    s.mount("http://",  adapter)
    return s

_WETTER_SESSION = _wetter_session()

# ================== Pfade ==================
ROOT_DIR     = os.path.dirname(os.path.abspath(__file__))
CONFIG_PATH  = os.path.join(ROOT_DIR, "config.json")
SCRAPER_DIR  = os.path.join(ROOT_DIR, "scraper")
OUTPUT_DIR   = os.path.join(ROOT_DIR, "output")
WEB_DIR      = os.path.join(ROOT_DIR, "web")
LOGS_DIR     = os.path.join(ROOT_DIR, "logs")
ANZEIGEN_DIR = os.path.join(WEB_DIR, "anzeigen")
VIDEO_DIR    = os.path.join(WEB_DIR, "video")

# ================== Config ==================
def load_config() -> dict:
    with open(CONFIG_PATH, "r", encoding="utf-8") as f:
        return json.load(f)

# ================== Logging ==================
def setup_logging(log_path: str):
    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s  %(levelname)-8s  %(message)s",
        handlers=[
            logging.FileHandler(log_path, encoding="utf-8"),
            logging.StreamHandler(sys.stdout),
        ],
    )

# ================== Scraper-Steuerung ==================
SCRAPER_MAP = {
    "pm":     "pmpress.py",
    "ds":     "dspress.py",
    "tf":     "tfpress.py",
    "sf":     "sfpress.py",
    "pot":    "potpress.py",
    "bra":    "branpress.py",
    "pol_pm": "pol_pm.py",
    "pol_ds": "pol_ds.py",
    "pol_tf": "pol_tf.py",
    "mwfk":          "mwfkpress.py",
    "kw":            "kwpress.py",
    "schwielowsee":  "schwielowseepress.py",
    "kleinmachnow":  "kleinmachnowpress.py",
    "stahnsdorf":    "stahnsdorfpress.py",
    "teltow":        "teltowpress.py",
    "jueterbog":     "jueterbogpress.py",
    "luebben":       "luebbenpress.py",
    "luebbenau":     "luebbenaupress.py",
    "treuenbrietzen":"treuenbrietzenpress.py",
    "werder":         "werderpress.py",
}

def run_scraper(kuerzel: str, script_name: str) -> bool:
    script_path = os.path.join(SCRAPER_DIR, script_name)
    if not os.path.exists(script_path):
        logging.error(f"Script nicht gefunden: {script_path}")
        return False
    logging.info(f"Starte Scraper: {kuerzel} ({script_name})")
    try:
        env = os.environ.copy()
        env["PYTHONIOENCODING"] = "utf-8"
        result = subprocess.run(
            [sys.executable, script_path, "--once"],
            cwd=ROOT_DIR,
            timeout=300,
            capture_output=True,
            text=True,
            encoding="utf-8",
            errors="replace",
            env=env,
        )
        if result.stdout: logging.info(result.stdout.strip())
        if result.returncode != 0:
            logging.error(f"Scraper {kuerzel} Fehlercode {result.returncode}")
            if result.stderr: logging.error(result.stderr.strip())
            return False
        logging.info(f"Scraper {kuerzel} fertig")
        return True
    except subprocess.TimeoutExpired:
        logging.error(f"Scraper {kuerzel} Timeout (300s)")
        return False
    except Exception as e:
        logging.error(f"Scraper {kuerzel} Ausnahme: {e}")
        return False

# ================== JS-Hilfsfunktion ==================
def write_js_var(path_js: str, varname: str, data):
    """Schreibt Daten als window.VARNAME = {...}; JS-Datei."""
    with open(path_js, "w", encoding="utf-8") as f:
        f.write(f"window.{varname} = ")
        json.dump(data, f, ensure_ascii=False)
        f.write(";")

# ================== JSON zusammenbauen ==================
SKIP_TITEL = {
    "Derzeit keine neuen Meldungen",
    "Derzeit keine neue Meldung",
    "Derzeit keine passenden Meldungen",
}

def _lade_json(fp: str):
    try:
        with open(fp, "r", encoding="utf-8") as f:
            return json.load(f)
    except Exception:
        return None

def _ist_echte_meldung(data: dict, max_tage: int) -> bool:
    if not data:
        return False
    titel = data.get("titel", "")
    if not titel or titel in SKIP_TITEL:
        return False
    datum = data.get("datum", "")
    if datum:
        try:
            d = datetime.strptime(datum, "%d.%m.%Y")
            if d < datetime.now() - timedelta(days=max_tage):
                return False
        except ValueError:
            pass
    return True

def read_all_meldungen() -> list:
    """Liest alle aktuellen Meldungen aus output/.
    Ist eine Slot-Datei ein Platzhalter, wird automatisch die jüngste
    Archiv-Datei innerhalb von max_tage als Fallback verwendet."""
    cfg      = load_config()
    max_tage = cfg.get("max_tage_zurueck", 10)

    pattern  = os.path.join(OUTPUT_DIR, "*_meldung_*.json")
    files    = sorted(glob.glob(pattern))
    meldungen = []
    placeholder_slots = []

    for fp in files:
        data = _lade_json(fp)
        if _ist_echte_meldung(data, max_tage):
            meldungen.append(data)
        else:
            bn = os.path.basename(fp)
            placeholder_slots.append(bn)

    # Archiv-Fallback für leere Slots
    if placeholder_slots:
        archiv_dir   = os.path.join(OUTPUT_DIR, "archiv")
        archiv_files = sorted(
            glob.glob(os.path.join(archiv_dir, "*_meldung_*.json")), reverse=True
        )
        already = {(m.get("quelle_kuerzel"), m.get("url", "")) for m in meldungen}

        for slot_bn in placeholder_slots:
            prefix = slot_bn.replace(".json", "")
            for afp in archiv_files:
                abn = os.path.basename(afp)
                if not abn.startswith(prefix + "_"):
                    continue
                adata = _lade_json(afp)
                if not _ist_echte_meldung(adata, max_tage):
                    continue
                key = (adata.get("quelle_kuerzel"), adata.get("url", ""))
                if key in already:
                    continue
                meldungen.append(adata)
                already.add(key)
                logging.info(f"Archiv-Fallback: {abn}")
                break

    return meldungen

def build_meldungen_json(cfg: dict):
    os.makedirs(WEB_DIR, exist_ok=True)
    meldungen = read_all_meldungen()
    regional  = [m for m in meldungen if m.get("rubrik") == "regional"]
    polizei   = [m for m in meldungen if m.get("rubrik") == "polizei"]
    stand     = datetime.now().strftime("%d.%m.%Y %H:%M")
    data = {"stand": stand, "regional": regional, "polizei": polizei, "wetter": {}}
    path = os.path.join(WEB_DIR, "meldungen.json")
    with open(path, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)
    write_js_var(os.path.join(WEB_DIR, "meldungen.js"), "MELDUNGEN_DATA", data)
    logging.info(f"meldungen.json/.js: {len(regional)} regional, {len(polizei)} polizei")
    return data

def build_ticker_json(meldungen_data: dict):
    alle    = meldungen_data.get("regional", []) + meldungen_data.get("polizei", [])
    ticker  = [m["titel"] for m in alle if m.get("titel")]
    payload = {"ticker": ticker}
    path    = os.path.join(WEB_DIR, "ticker.json")
    with open(path, "w", encoding="utf-8") as f:
        json.dump(payload, f, ensure_ascii=False)
    write_js_var(os.path.join(WEB_DIR, "ticker.js"), "TICKER_DATA", payload)
    logging.info(f"ticker.json/.js: {len(ticker)} Schlagzeilen")

# ================== Wetter (Open-Meteo) ==================
WETTER_ORTE = [
    {"name": "Kleinmachnow",        "lat": 52.3633, "lon": 13.2211},
    {"name": "Potsdam",             "lat": 52.3906, "lon": 13.0645},
    {"name": "Luckenwalde",         "lat": 52.0878, "lon": 13.1681},
    {"name": "Kgs. Wusterhausen",   "lat": 52.3006, "lon": 13.6333},
    {"name": "Berlin-Schönefeld",   "lat": 52.3806, "lon": 13.5225},
    {"name": "Teltow",              "lat": 52.4000, "lon": 13.2667},
    {"name": "Baruth/Mark",         "lat": 52.0578, "lon": 13.5000},
    {"name": "Lübben",              "lat": 51.9433, "lon": 13.9011},
    {"name": "Jüterbog",            "lat": 51.9983, "lon": 13.0708},
    {"name": "Erkner",              "lat": 52.4239, "lon": 13.7489},
    {"name": "Belzig",              "lat": 52.1422, "lon": 12.5933},
    {"name": "Brandenburg a.d.H.",  "lat": 52.4125, "lon": 12.5503},
    {"name": "Werder (Havel)",       "lat": 52.3756, "lon": 12.9342},
    {"name": "Falkensee",             "lat": 52.5625, "lon": 13.0922},
]

WMO_CODES = {
    0:  ("Klar",              "☀️"),
    1:  ("Überwiegend klar",  "🌤️"),
    2:  ("Teilw. bewölkt",    "⛅"),
    3:  ("Bedeckt",           "☁️"),
    45: ("Nebel",             "🌫️"),
    48: ("Reifnebel",         "🌫️"),
    51: ("Nieselregen",       "🌦️"),
    53: ("Nieselregen",       "🌦️"),
    55: ("Nieselregen",       "🌧️"),
    61: ("Leicht. Regen",     "🌧️"),
    63: ("Regen",             "🌧️"),
    65: ("Starker Regen",     "🌧️"),
    71: ("Leicht. Schnee",    "🌨️"),
    73: ("Schnee",            "🌨️"),
    75: ("Starker Schnee",    "❄️"),
    77: ("Schneekörner",      "🌨️"),
    80: ("Regenschauer",      "🌦️"),
    81: ("Regenschauer",      "🌧️"),
    82: ("Starke Schauer",    "⛈️"),
    85: ("Schneeschauer",     "🌨️"),
    86: ("Starke Schneeschauer", "❄️"),
    95: ("Gewitter",          "⛈️"),
    96: ("Gewitter m. Hagel", "⛈️"),
    99: ("Schweres Gewitter", "⛈️"),
}
WOCHENTAGE = ["Mo","Di","Mi","Do","Fr","Sa","So"]

def wmo_beschreibung(code):
    info = WMO_CODES.get(int(code), ("Unbekannt", "🌡️"))
    return {"text": info[0], "icon": info[1]}

def fetch_wetter_ort(name: str, lat: float, lon: float) -> dict:
    url = (
        f"https://api.open-meteo.com/v1/forecast"
        f"?latitude={lat}&longitude={lon}"
        f"&current=temperature_2m,weathercode,windspeed_10m,relativehumidity_2m"
        f"&daily=weathercode,temperature_2m_max,temperature_2m_min,precipitation_sum"
        f"&timezone=Europe%2FBerlin&forecast_days=4"
    )
    for attempt in range(3):
      try:
        resp  = _WETTER_SESSION.get(url, timeout=15)
        if resp.status_code == 429:
            logging.warning(f"Wetter 429 {name}, warte 5s ...")
            time.sleep(5)
            continue
        resp.raise_for_status()
        raw   = resp.json()
        cur   = raw.get("current", {})
        daily = raw.get("daily", {})
        wb    = wmo_beschreibung(cur.get("weathercode", 0))
        aktuell = {
            "temperatur":   round(cur.get("temperature_2m", 0), 1),
            "wind":         round(cur.get("windspeed_10m", 0), 0),
            "luftfeuchte":  cur.get("relativehumidity_2m", 0),
            "beschreibung": wb["text"],
            "icon":         wb["icon"],
        }
        vorschau = []
        dates = daily.get("time", [])
        for i in range(1, min(4, len(dates))):
            dt   = datetime.strptime(dates[i], "%Y-%m-%d")
            code = daily.get("weathercode",        [])[i] if i < len(daily.get("weathercode",        [])) else 0
            tmax = daily.get("temperature_2m_max", [])[i] if i < len(daily.get("temperature_2m_max", [])) else "--"
            tmin = daily.get("temperature_2m_min", [])[i] if i < len(daily.get("temperature_2m_min", [])) else "--"
            nied = daily.get("precipitation_sum",  [])[i] if i < len(daily.get("precipitation_sum",  [])) else 0
            b    = wmo_beschreibung(code)
            vorschau.append({
                "tag":   WOCHENTAGE[dt.weekday()],
                "datum": dt.strftime("%d.%m."),
                "icon":  b["icon"],
                "text":  b["text"],
                "tmax":  round(tmax, 0) if isinstance(tmax, (int, float)) else tmax,
                "tmin":  round(tmin, 0) if isinstance(tmin, (int, float)) else tmin,
                "regen": round(nied, 1)  if isinstance(nied, (int, float)) else 0,
            })
        return {"ort": name, "aktuell": aktuell, "vorschau": vorschau}
      except Exception as e:
        logging.warning(f"Wetter-Fehler {name} (Versuch {attempt+1}/3): {e}")
        if attempt < 2:
            time.sleep(3)
    # Alle 3 Versuche fehlgeschlagen
    return {"ort": name, "aktuell": {}, "vorschau": []}

def build_wetter_json() -> dict:
    orte_data = []
    for o in WETTER_ORTE:
        orte_data.append(fetch_wetter_ort(o["name"], o["lat"], o["lon"]))
        time.sleep(1.5)   # Rate-Limit: max ~40 Req/min bei open-meteo
    wetter = {"orte": orte_data}
    path   = os.path.join(WEB_DIR, "wetter.json")
    with open(path, "w", encoding="utf-8") as f:
        json.dump(wetter, f, ensure_ascii=False, indent=2)
    write_js_var(os.path.join(WEB_DIR, "wetter.js"), "WETTER_DATA", wetter)
    logging.info(f"wetter.json/.js: {len(orte_data)} Orte")
    return wetter

# ================== Anzeigenbilder ==================
def build_anzeigen_js():
    os.makedirs(ANZEIGEN_DIR, exist_ok=True)
    exts   = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}
    bilder = sorted([
        "anzeigen/" + f
        for f in os.listdir(ANZEIGEN_DIR)
        if os.path.splitext(f.lower())[1] in exts
    ])
    data = {"bilder": bilder}
    write_js_var(os.path.join(WEB_DIR, "anzeigen.js"), "ANZEIGEN_DATA", data)
    logging.info(f"anzeigen.js: {len(bilder)} Bild(er)")
    return data

# ================== Video-Playlist ==================
def build_video_js():
    """Scannt web/video/ nach Videodateien und schreibt video.js.
    Korrigiert automatisch doppelte Dateiendungen (sendung.mp4.mp4 → sendung.mp4)."""
    os.makedirs(VIDEO_DIR, exist_ok=True)
    exts = {".mp4", ".webm", ".ogg", ".mov", ".m4v"}
    try:
        alle = os.listdir(VIDEO_DIR)
    except Exception as e:
        logging.error(f"build_video_js: Kann VIDEO_DIR nicht lesen: {e}")
        alle = []

    # Doppelte Endungen korrigieren
    for fname in alle:
        fpath = os.path.join(VIDEO_DIR, fname)
        if not os.path.isfile(fpath):
            continue
        name, ext = os.path.splitext(fname.lower())
        if ext in exts:
            inner_ext = os.path.splitext(name)[1]
            if inner_ext == ext:
                neuer_name = fname[: -len(ext)]
                neuer_pfad = os.path.join(VIDEO_DIR, neuer_name)
                try:
                    os.rename(fpath, neuer_pfad)
                    logging.info(f"Umbenannt: {fname} → {neuer_name}")
                except Exception as e:
                    logging.warning(f"Umbenennen fehlgeschlagen {fname}: {e}")

    try:
        alle = os.listdir(VIDEO_DIR)
    except Exception:
        alle = []

    videos = sorted([
        "video/" + f
        for f in alle
        if os.path.isfile(os.path.join(VIDEO_DIR, f))
           and os.path.splitext(f.lower())[1] in exts
    ])
    data = {"videos": videos}
    write_js_var(os.path.join(WEB_DIR, "video.js"), "VIDEO_DATA", data)
    logging.info(f"video.js: {len(videos)} Video(s) gefunden in: {VIDEO_DIR}")
    for v in videos:
        logging.info(f"  → {v}")
    return data

# ================== Verkehr (Autobahn-API) ==================
VERKEHR_STRASSEN = ["A9", "A10", "A13", "A115", "A24", "A2"]

VERKEHR_API_URLS = [
    "https://verkehr.autobahn.de/o/autobahn/{road}/services/warning",
    "https://autobahn.api.bund.dev/v0/details/roads/{road}/services/warning",
]

# Brandenburg + Berlin Bounding Box
BB_LAT_MIN, BB_LAT_MAX = 51.35, 53.60
BB_LON_MIN, BB_LON_MAX = 11.20, 14.80

def _in_brandenburg(item: dict) -> bool:
    coord = item.get("coordinate") or {}
    try:
        lat = float(coord.get("lat", 0))
        lon = float(coord.get("long", 0))
        if lat == 0 and lon == 0:
            return True   # keine Koordinaten → nicht ausschließen
        return BB_LAT_MIN <= lat <= BB_LAT_MAX and BB_LON_MIN <= lon <= BB_LON_MAX
    except Exception:
        return True

def _vorfallstyp(item: dict) -> str:
    icon    = (item.get("icon") or "").lower()
    blocked = item.get("isBlocked", False)
    title   = (item.get("title") or "").lower()
    sub     = (item.get("subtitle") or "").lower()
    text    = title + " " + sub
    if blocked or "gesperrt" in text or "sperrung" in text or "voll" in text:
        return "🚫 GESPERRT"
    if "stau" in text or "stockend" in text or "congestion" in icon:
        return "🚦 STAU"
    if "unfall" in text or "accident" in icon:
        return "🚨 UNFALL"
    if "baustelle" in text or "bauarbeiten" in text or "road_works" in icon or "work" in icon:
        return "🚧 BAUSTELLE"
    if "gefahr" in text or "warning" in icon:
        return "⚠️ GEFAHR"
    return "ℹ️ MELDUNG"

def fetch_verkehr() -> list:
    meldungen = []
    headers   = {"Accept": "application/json"}
    for strasse in VERKEHR_STRASSEN:
        item_list = None
        for tmpl in VERKEHR_API_URLS:
            try:
                url  = tmpl.format(road=strasse)
                resp = requests.get(url, timeout=10, headers=headers)
                if resp.status_code == 200:
                    body = resp.json()
                    raw  = body.get("warning") if isinstance(body, dict) else body
                    item_list = [x for x in (raw or []) if isinstance(x, dict)]
                    break
                elif resp.status_code == 404:
                    item_list = []
                    break
                else:
                    logging.debug(f"Verkehr {strasse} via {url}: HTTP {resp.status_code}")
            except Exception as e:
                logging.debug(f"Verkehr {strasse} via {tmpl}: {e}")

        if item_list is None:
            logging.warning(f"Verkehr {strasse}: alle Endpunkte fehlgeschlagen")
            continue

        bb_items = [x for x in item_list if _in_brandenburg(x)]
        logging.debug(f"Verkehr {strasse}: {len(item_list)} gesamt, {len(bb_items)} in BB/BE")

        for item in bb_items[:2]:
            title = item.get("title", "").strip()
            sub   = item.get("subtitle", "").strip()
            desc  = ""
            for d in (item.get("description") or []):
                if isinstance(d, dict) and d.get("value"):
                    desc = d["value"].strip(); break
                elif isinstance(d, str) and d.strip():
                    desc = d.strip(); break
            if title:
                meldungen.append({
                    "strasse": strasse,
                    "titel":   title,
                    "text":    sub or desc,
                    "typ":     _vorfallstyp(item),
                })

    data = {"meldungen": meldungen}
    write_js_var(os.path.join(WEB_DIR, "verkehr.js"), "VERKEHR_DATA", data)
    logging.info(f"verkehr.js: {len(meldungen)} Verkehrsmeldungen (Brandenburg/BE gefiltert)")
    return meldungen

# ================== Blitzer TF ==================
BLITZER_MONATE = [
    "januar","februar","maerz","april","mai","juni",
    "juli","august","september","oktober","november","dezember",
]
BLITZER_DE_MONATE = {
    "januar":1,"februar":2,"märz":3,"maerz":3,"april":4,"mai":5,"juni":6,
    "juli":7,"august":8,"september":9,"oktober":10,"november":11,"dezember":12,
}

def _blitzer_parse_datum(text: str):
    import re
    from datetime import date
    text = text.strip()
    m = re.search(r"(\d{1,2})\.(\d{1,2})\.(\d{2,4})", text)
    if m:
        d, mo, y = int(m.group(1)), int(m.group(2)), int(m.group(3))
        if y < 100: y += 2000
        try: return date(y, mo, d)
        except ValueError: pass
    m = re.search(r"(\d{1,2})\.\s*([A-Za-zäöüÄÖÜ]+)(?:\s+(\d{4}))?", text)
    if m:
        d       = int(m.group(1))
        mon_str = m.group(2).lower().strip(".")
        year    = int(m.group(3)) if m.group(3) else datetime.now().year
        mo      = BLITZER_DE_MONATE.get(mon_str)
        if mo:
            try: return date(year, mo, d)
            except ValueError: pass
    m = re.search(r"(\d{1,2})\.(\d{1,2})\.", text)
    if m:
        d, mo = int(m.group(1)), int(m.group(2))
        y = datetime.now().year
        try: return date(y, mo, d)
        except ValueError: pass
    return None

def _blitzer_tabelle(soup, zieldaten: dict) -> list:
    import re
    eintraege = []
    for table in soup.find_all("table"):
        rows = table.find_all("tr")
        for row in rows:
            cells = [td.get_text(" ", strip=True) for td in row.find_all(["td","th"])]
            if len(cells) < 2:
                continue
            for ci in range(min(2, len(cells))):
                dt = _blitzer_parse_datum(cells[ci])
                if dt and dt in zieldaten:
                    tag_idx   = zieldaten[dt]
                    tag_label = ["Heute","Morgen","Übermorgen"][tag_idx]
                    rest = [c for j, c in enumerate(cells) if j != ci and c]
                    zeit, ort = "", ""
                    for j, r in enumerate(rest):
                        if re.search(r"\d{1,2}:\d{2}", r):
                            zeit = r
                            ort  = " ".join(rest[j+1:]) if j+1 < len(rest) else ""
                            break
                    if not zeit:
                        ort = " ".join(rest)
                    eintraege.append({
                        "tag":   tag_label,
                        "datum": dt.strftime("%d.%m."),
                        "zeit":  zeit,
                        "ort":   ort,
                    })
                    break
    return eintraege

def _blitzer_text(soup, zieldaten: dict) -> list:
    import re
    eintraege = []

    # Scope to the article body div (TYPO3/standard CMS classes)
    content = soup.find("div", class_=re.compile(
        r"bodytext|ce-bodytext|csc-default|news-text|tx-news|article__body|article-content", re.I
    ))
    if not content:
        # Try broader fallback but stay away from nav/header/footer
        for tag in ["main", "article"]:
            content = soup.find(tag)
            if content:
                break
    if not content:
        content = soup.body or soup

    # Remove navigation, sidebar, header/footer noise before scanning
    for noise in content.find_all(["nav", "header", "footer", "aside",
                                    "script", "style", "noscript"]):
        noise.decompose()

    # Only scan <p> and <li> — NOT <div>, which can contain entire article blobs.
    # Also skip any element whose text is longer than 320 chars: actual blitzer
    # entries are short one-liners; anything longer is article boilerplate.
    text_blocks = []
    for el in content.find_all(["p", "li"]):
        t = el.get_text(" ", strip=True)
        if 8 < len(t) <= 320:
            text_blocks.append(t)

    # Additionally try splitting long <p> blocks by newline/semicolon in case
    # the page puts multiple entries into a single paragraph.
    extra = []
    for el in content.find_all("p"):
        t = el.get_text(" ", strip=True)
        if len(t) > 320:
            for part in re.split(r"[\n;]", t):
                part = part.strip()
                if 8 < len(part) <= 320:
                    extra.append(part)
    text_blocks += extra

    for line in text_blocks:
        dt = _blitzer_parse_datum(line)
        if not dt or dt not in zieldaten:
            continue
        # Safety: the date must appear within the first 30 characters of the
        # line — this excludes intro sentences like "Kontrollen finden am
        # 01.04.2026 statt" where the date is buried mid-sentence.
        date_pos = re.search(r"\d{1,2}[./]\d{1,2}[./]", line)
        if date_pos and date_pos.start() > 30:
            continue

        tag_idx   = zieldaten[dt]
        tag_label = ["Heute","Morgen","Übermorgen"][tag_idx]
        zeit_m    = re.search(
            r"(\d{1,2}:\d{2}\s*[-–]\s*\d{1,2}:\d{2}(?:\s*Uhr)?|\d{1,2}:\d{2}\s*Uhr)", line
        )
        zeit      = zeit_m.group(0).strip() if zeit_m else ""
        ort_line  = re.sub(r"\d{1,2}\.\d{1,2}\.(?:\d{2,4})?", "", line)
        ort_line  = re.sub(r"\d{1,2}:\d{2}\s*[-–]\s*\d{1,2}:\d{2}(?:\s*Uhr)?", "", ort_line)
        ort_line  = re.sub(r"\d{1,2}:\d{2}\s*Uhr", "", ort_line)
        ort_line  = re.sub(r"[,;:\-–]+\s*$", "", ort_line).strip(" ,;:-–")
        ort       = ort_line if len(ort_line) > 3 else ""
        eintraege.append({
            "tag":   tag_label,
            "datum": dt.strftime("%d.%m."),
            "zeit":  zeit,
            "ort":   ort,
        })
    return eintraege

def fetch_blitzer_tf() -> list:
    from datetime import date, timedelta
    try:
        from bs4 import BeautifulSoup
    except ImportError:
        logging.warning("Blitzer TF: BeautifulSoup nicht installiert")
        _blitzer_write_empty()
        return []

    today     = date.today()
    zieldaten = {today + timedelta(days=i): i for i in range(3)}

    def build_url(d):
        monat = BLITZER_MONATE[d.month - 1]
        return (
            f"https://www.teltow-flaeming.de/presse/uebersicht-aktuelle-meldungen"
            f"/aktuelles-details/flitzerblitzer-{monat}-{d.year}"
        )

    urls = [build_url(today)]
    first_next = (today.replace(day=28) + timedelta(days=4)).replace(day=1)
    if (first_next - today).days <= 3:
        urls.append(build_url(first_next))

    alle    = []
    headers = {
        "User-Agent": (
            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
            "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36"
        )
    }
    for url in urls:
        try:
            resp = requests.get(url, timeout=15, headers=headers)
            if resp.status_code != 200:
                logging.warning(f"Blitzer TF: HTTP {resp.status_code} – {url}")
                continue
            from bs4 import BeautifulSoup
            soup    = BeautifulSoup(resp.text, "html.parser")
            treffer = _blitzer_tabelle(soup, zieldaten)
            if treffer:
                logging.info(f"Blitzer TF (Tabelle): {len(treffer)} Einträge aus {url}")
            else:
                treffer = _blitzer_text(soup, zieldaten)
                logging.info(f"Blitzer TF (Text): {len(treffer)} Einträge aus {url}")
                if not treffer:
                    # Diagnostic: log the first few candidate lines so we can
                    # see what the page actually contains and tune the parser.
                    import re as _re
                    _content = soup.find("div", class_=_re.compile(
                        r"bodytext|ce-bodytext|csc-default|news-text|tx-news|article__body|article-content", _re.I
                    )) or soup.body or soup
                    _lines = [el.get_text(" ", strip=True) for el in _content.find_all(["p","li"]) if len(el.get_text(" ", strip=True)) > 8][:6]
                    for _l in _lines:
                        logging.info(f"  Blitzer-Debug: {_l[:120]!r}")
            alle.extend(treffer)
        except Exception as e:
            logging.warning(f"Blitzer TF Fehler ({url}): {e}")

    seen_keys, unique = set(), []
    for e in alle:
        key = (e["datum"], e["ort"])
        if key not in seen_keys:
            seen_keys.add(key)
            unique.append(e)

    data = {"meldungen": unique, "stand": datetime.now().strftime("%d.%m.%Y %H:%M")}
    write_js_var(os.path.join(WEB_DIR, "blitzer.js"), "BLITZER_DATA", data)
    logging.info(f"blitzer.js: {len(unique)} Blitzer-Einträge (nächste 3 Tage)")
    return unique

def _blitzer_write_empty():
    data = {"meldungen": [], "stand": datetime.now().strftime("%d.%m.%Y %H:%M")}
    write_js_var(os.path.join(WEB_DIR, "blitzer.js"), "BLITZER_DATA", data)

# ================== S-Bahn Berlin ==================
def fetch_sbahn():
    """Ruft sbahn.py als Subprocess auf."""
    script = os.path.join(SCRAPER_DIR, "sbahn.py")
    if not os.path.exists(script):
        logging.warning("sbahn.py nicht gefunden")
        return
    env = os.environ.copy()
    env["PYTHONIOENCODING"] = "utf-8"
    result = subprocess.run(
        [sys.executable, script, "--once"],
        cwd=ROOT_DIR,
        timeout=120,
        capture_output=True,
        text=True,
        encoding="utf-8",
        errors="replace",
        env=env,
    )
    for line in (result.stdout or "").splitlines():
        logging.info(f"  sbahn: {line}")
    if result.returncode != 0:
        logging.warning(f"sbahn.py Exitcode {result.returncode}: {(result.stderr or '')[:300]}")

# ================== Schnell-Zyklus (60 min) ==================
def run_fast_cycle():
    """Wetter, Verkehr, Blitzer, S-Bahn, Videos — alle 60 Minuten."""
    logging.info("--- Schnell-Zyklus: Wetter / Verkehr / Blitzer / S-Bahn / Videos ---")
    try:
        build_wetter_json()
    except Exception as e:
        logging.error(f"Wetter-Fehler: {e}")
    try:
        fetch_verkehr()
    except Exception as e:
        logging.error(f"Verkehr-Fehler: {e}")
    try:
        fetch_blitzer_tf()
    except Exception as e:
        logging.error(f"Blitzer-Fehler: {e}")
    try:
        fetch_sbahn()
    except Exception as e:
        logging.error(f"S-Bahn-Fehler: {e}")
    try:
        build_video_js()
    except Exception as e:
        logging.error(f"Video-JS-Fehler: {e}")
    logging.info("--- Schnell-Zyklus abgeschlossen ---")

# ================== Voll-Zyklus (4 h) ==================
def run_cycle(cfg: dict):
    quellen = cfg.get("quellen", {})
    errors  = []

    for kuerzel, qinfo in quellen.items():
        if not qinfo.get("aktiv", False):
            logging.info(f"{kuerzel} deaktiviert")
            continue
        script = SCRAPER_MAP.get(kuerzel)
        if not script:
            logging.warning(f"Kein Script fuer '{kuerzel}'")
            continue
        ok = run_scraper(kuerzel, script)
        if not ok:
            errors.append(kuerzel)
        time.sleep(5)

    meldungen_data = build_meldungen_json(cfg)
    build_ticker_json(meldungen_data)
    build_anzeigen_js()
    build_video_js()

    # Wetter/Verkehr/Blitzer/S-Bahn auch im Voll-Zyklus frisch holen
    run_fast_cycle()

    # Wetter in meldungen.json einbetten (Rückwärts-Compat)
    if window_wetter := _lese_wetter_js():
        meldungen_data["wetter"] = window_wetter
        path = os.path.join(WEB_DIR, "meldungen.json")
        with open(path, "w", encoding="utf-8") as f:
            json.dump(meldungen_data, f, ensure_ascii=False, indent=2)

    logging.info("Voll-Zyklus abgeschlossen")
    if errors:
        logging.warning(f"Fehler bei: {', '.join(errors)}")
    return errors

def _lese_wetter_js() -> dict:
    try:
        path = os.path.join(WEB_DIR, "wetter.json")
        with open(path, "r", encoding="utf-8") as f:
            return json.load(f)
    except Exception:
        return {}

# ================== Haupt-Schleife ==================
def main():
    os.makedirs(LOGS_DIR,     exist_ok=True)
    os.makedirs(OUTPUT_DIR,   exist_ok=True)
    os.makedirs(WEB_DIR,      exist_ok=True)
    os.makedirs(ANZEIGEN_DIR, exist_ok=True)
    os.makedirs(VIDEO_DIR,    exist_ok=True)

    log_file = os.path.join(LOGS_DIR, f"run_{datetime.now().strftime('%Y%m%d')}.log")
    setup_logging(log_file)
    logging.info("iTV INFOTV run_all.py gestartet")
    logging.info("Voll-Zyklus: alle 4 h  |  Schnell-Zyklus: alle 60 min")

    SCHNELL_INTERVALL_SEK = 3600
    letzter_voll          = None

    while True:
        jetzt              = datetime.now()
        cfg                = load_config()
        voll_intervall_sek = cfg.get("intervall_stunden", 4) * 3600
        seit_voll          = (jetzt - letzter_voll).total_seconds() if letzter_voll else float("inf")

        if seit_voll >= voll_intervall_sek:
            logging.info(f"=== Voll-Zyklus: {jetzt.strftime('%d.%m.%Y %H:%M')} ===")
            try:
                run_cycle(cfg)
                letzter_voll = datetime.now()
            except Exception as e:
                logging.error(f"Unbehandelter Fehler im Voll-Zyklus: {e}")
                traceback.print_exc()
                letzter_voll = datetime.now()
        else:
            logging.info(f"=== Schnell-Zyklus: {jetzt.strftime('%d.%m.%Y %H:%M')} ===")
            try:
                run_fast_cycle()
            except Exception as e:
                logging.error(f"Unbehandelter Fehler im Schnell-Zyklus: {e}")
                traceback.print_exc()

        verbleibend_voll = voll_intervall_sek - (datetime.now() - letzter_voll).total_seconds()
        warte            = min(SCHNELL_INTERVALL_SEK, max(60, int(verbleibend_voll)))
        logging.info(
            f"Naechster Schnell-Zyklus in {warte // 60} min  "
            f"| Naechster Voll-Zyklus in {max(0, int(verbleibend_voll)) // 60} min"
        )
        time.sleep(warte)

if __name__ == "__main__":
    if "--once" in sys.argv:
        os.makedirs(LOGS_DIR, exist_ok=True)
        log_file = os.path.join(LOGS_DIR, f"run_{datetime.now().strftime('%Y%m%d')}.log")
        setup_logging(log_file)
        cfg = load_config()
        run_cycle(cfg)
    elif "--fast" in sys.argv:
        os.makedirs(LOGS_DIR, exist_ok=True)
        log_file = os.path.join(LOGS_DIR, f"run_{datetime.now().strftime('%Y%m%d')}.log")
        setup_logging(log_file)
        run_fast_cycle()
    else:
        main()
