"""veedon-agent — وكيل جهاز الفرع.

المسؤوليات:
- سحب الإعدادات من سحابة Veedon كل 30ث وتطبيقها OTA (توليد إعدادات المحرك وإعادة تشغيله).
- تطبيق الإيقاف عن التشغيل فوراً (suspend عند عدم السداد) وإعادة التفعيل تلقائياً.
- تحويل أحداث المحرك إلى أحداث Veedon (منطقة ممنوعة) مع cooldown.
- فحوصات دورية: تكدس، عدّ الطابور، لقطات VLM المجدولة، heartbeat وصحة الكاميرات.
- تنزيل حزم نماذج edge_model من سجل النماذج عند تفعيلها.
- store & forward: أحداث فشل إرسالها تُعاد المحاولة لاحقاً.
"""

import http.client
import json
import os
import socket
import time

import requests
import yaml

import fingerprint

CLOUD = os.environ["VEEDON_CLOUD_URL"].rstrip("/")
TOKEN = os.environ["VEEDON_DEVICE_TOKEN"]
ENGINE = os.environ.get("VEEDON_ENGINE_URL", "http://veedon-engine:5000")
ENGINE_CONTAINER = os.environ.get("VEEDON_ENGINE_CONTAINER", "veedon-engine")

_hw = fingerprint.collect()  # يُحسب مرة عند الإقلاع
FINGERPRINT = _hw["fingerprint"]
HEADERS = {"Authorization": f"Bearer {TOKEN}",
           "X-Device-Fingerprint": FINGERPRINT}
CONFIG_PATH = "/config/config.yml"
MODELS_DIR = "/models"

POLL_S = 30          # سحب الإعدادات
EVENTS_S = 5         # سحب أحداث المحرك
CROWD_S = 15         # فحص التكدس
HEARTBEAT_S = 60
MQTT_HOST = os.environ.get("VEEDON_MQTT_HOST", "mosquitto")
MQTT_PORT = int(os.environ.get("VEEDON_MQTT_PORT", "1883"))

state = {
    "version": None,          # إصدار الإعدادات المطبق محلياً
    "config": None,
    "gated": True,            # المحرك موقوف حتى تعتمد البوابة الجهاز
    "status": "starting",     # آخر حالة من البوابة (active/pending/suspended/…)
    "last_event_ts": time.time(),
    "cooldowns": {},          # (feature, camera) -> آخر إرسال
    "pending": [],            # store & forward
    "cam_down_sent": set(),
}


# ---------- Docker (عبر unix socket — بلا اعتماديات) ----------

class _DockerConn(http.client.HTTPConnection):
    def __init__(self):
        super().__init__("localhost")

    def connect(self):
        s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        s.connect("/var/run/docker.sock")
        self.sock = s


def docker(method: str, path: str, body: dict | None = None):
    """يرجع (status, json|None). body للـ create وغيره."""
    try:
        conn = _DockerConn()
        headers = {"Content-Type": "application/json"} if body is not None else {}
        conn.request(method, path,
                     body=json.dumps(body) if body is not None else None,
                     headers=headers)
        resp = conn.getresponse()
        raw = resp.read()  # يقرأ حتى النهاية (مهم لسحب الصور)
        conn.close()
        try:
            return resp.status, json.loads(raw) if raw else None
        except ValueError:
            return resp.status, None
    except Exception as exc:
        log(f"docker api error: {exc}")
        return 0, None


def engine_restart():
    docker("POST", f"/containers/{ENGINE_CONTAINER}/restart?t=10")


def engine_stop():
    docker("POST", f"/containers/{ENGINE_CONTAINER}/stop?t=10")


def engine_start():
    docker("POST", f"/containers/{ENGINE_CONTAINER}/start")


def update_engine(image: str):
    """تحديث نسخة المحرك عن بُعد: سحب الصورة وإعادة إنشاء الحاوية بنفس الإعدادات.

    ponytail: نمط watchtower المبسط — يكفي لحاوية واحدة معروفة.
    """
    _, info = docker("GET", f"/containers/{ENGINE_CONTAINER}/json")
    if not info:
        return
    current = info["Config"]["Image"]
    if current == image:
        return
    log(f"engine update: {current} -> {image} (pulling…)")
    from urllib.parse import quote
    status, _ = docker("POST", f"/images/create?fromImage={quote(image, safe='')}")
    if status >= 300:
        log(f"image pull failed ({status}) — keeping {current}")
        return
    spec = {
        "Image": image,
        "Env": info["Config"].get("Env"),
        "Labels": info["Config"].get("Labels"),
        "HostConfig": info["HostConfig"],
    }
    docker("POST", f"/containers/{ENGINE_CONTAINER}/stop?t=15")
    docker("POST", f"/containers/{ENGINE_CONTAINER}/rename?name={ENGINE_CONTAINER}-old")
    status, _ = docker("POST", f"/containers/create?name={ENGINE_CONTAINER}", spec)
    if status >= 300:  # فشل الإنشاء → تراجع
        log("engine recreate failed — rolling back")
        docker("POST", f"/containers/{ENGINE_CONTAINER}-old/rename?name={ENGINE_CONTAINER}")
        engine_start()
        return
    engine_start()
    docker("DELETE", f"/containers/{ENGINE_CONTAINER}-old?force=true")
    log(f"engine updated to {image}")


def log(msg):
    print(f"[veedon-agent] {msg}", flush=True)


# ---------- الإعدادات ----------

def features_of(kind=None, key=None):
    cfg = state["config"] or {}
    for f in cfg.get("features", []):
        if (kind is None or f["kind"] == kind) and (key is None or f["key"] == key):
            yield f


def _detector_cfg() -> dict:
    """نوع المسرّع من البيئة (cpu افتراضي / openvino على iGPU / …)."""
    dtype = os.environ.get("VEEDON_DETECTOR_TYPE", "cpu")
    d = {"type": dtype}
    device = os.environ.get("VEEDON_DETECTOR_DEVICE")
    if device:                                  # مثال: GPU لـ openvino
        d["device"] = device
    return d


def render_engine_config(cfg: dict) -> str:
    """توليد إعدادات المحرك من إعدادات Veedon السحابية.

    وحدات kind=engine تُترجم لأقسام config المحرك مباشرة —
    عامة (وجوه/لوحات/بحث دلالي/birdseye) أو لكل كاميرا (تسجيل/صوت/قناع).
    """
    features = cfg.get("features", [])

    def cam_features(cam_name, key):
        return [f["config"] for f in features
                if f["key"] == key and f["config"].get("camera") == cam_name]

    def module_on(key):
        return any(f["key"] == key for f in features)

    cameras = {}
    for cam in cfg.get("cameras", []):
        name = cam["name"]
        # كل قاعدة ذات إحداثيات تُرندَر منطقةَ محرك باسم مفتاحها (اسم المنطقة = مفتاح الميزة)
        zones = {}
        for f in features:
            fc = f.get("config", {})
            if f["kind"] == "rule" and fc.get("camera") == name and fc.get("coordinates"):
                zones[f["key"]] = {"coordinates": fc["coordinates"]}
        roles = ["detect"]
        src = cam["rtsp_url"]
        inp = {"path": src, "roles": roles}
        if src.startswith("rtsp"):
            pass  # RTSP: معاملات المحرك الافتراضية
        elif src.startswith("http"):  # كاميرا شبكة MJPEG/HTTP حية
            inp["input_args"] = ("-avoid_negative_ts make_zero "
                                 "-fflags +genpts+discardcorrupt -rw_timeout 5000000")
        else:  # ملف محلي (تطوير/اختبار) → تكرار مستمر
            inp["input_args"] = "-re -stream_loop -1 -fflags +genpts"
        cam_cfg = {
            "ffmpeg": {"inputs": [inp]},
            "detect": {"enabled": True, "width": 1280, "height": 720, "fps": 5},
        }
        if zones:
            cam_cfg["zones"] = zones
        rec = cam_features(name, "recording")
        if rec:  # صيغة 0.17+ (continuous/motion أيام)
            cam_cfg["record"] = {"enabled": True, "motion": {
                "days": int(rec[0].get("retain_days", 7))}}
        audio = cam_features(name, "audio_detection")
        if audio:
            roles.append("audio")
            cam_cfg["audio"] = {"enabled": True,
                                "listen": audio[0].get("labels", ["scream", "yell"])}
        masks = [m["coordinates"] for m in cam_features(name, "motion_mask")
                 if m.get("coordinates")]
        if masks:
            cam_cfg["motion"] = {"mask": masks}
        cameras[cam["name"]] = cam_cfg

    engine_cfg = {
        "mqtt": {"enabled": True, "host": MQTT_HOST, "port": MQTT_PORT},
        # مسرّع قابل للتهيئة حسب العتاد (stack 2026): cpu افتراضي، أو openvino على iGPU،
        # أو أي نوع يدعمه المحرك — عبر VEEDON_DETECTOR_TYPE/VEEDON_DETECTOR_DEVICE.
        "detectors": {"det1": _detector_cfg()},
        "objects": {"track": ["person", "car"]},
        "snapshots": {"enabled": True},
        "cameras": cameras,
    }
    # وحدات المحرك العامة — أول تفعيل ينزّل نماذجها تلقائياً داخل المحرك
    if module_on("face_recognition"):
        engine_cfg["face_recognition"] = {"enabled": True}
    if module_on("lpr"):
        engine_cfg["lpr"] = {"enabled": True}
    if module_on("semantic_search"):
        engine_cfg["semantic_search"] = {"enabled": True, "reindex": False}
    if module_on("birdseye"):
        bcfg = next(f["config"] for f in features if f["key"] == "birdseye")
        engine_cfg["birdseye"] = {"enabled": True,
                                  "mode": bcfg.get("mode", "objects")}
    return yaml.safe_dump(engine_cfg, allow_unicode=True, sort_keys=False)


def ensure_models(cfg: dict):
    """تنزيل حزم النماذج المفعّلة وغير الموجودة محلياً (OTA)."""
    os.makedirs(MODELS_DIR, exist_ok=True)
    for f in cfg.get("features", []):
        model = f.get("config", {}).get("model")
        if f["kind"] == "edge_model" and model:
            path = os.path.join(MODELS_DIR, model)
            if not os.path.exists(path):
                log(f"downloading model {model}…")
                r = requests.get(f"{CLOUD}/api/device/models/{model}",
                                 headers=HEADERS, timeout=120)
                if r.ok:
                    with open(path, "wb") as fh:
                        fh.write(r.content)
                    log(f"model {model} ready")
                else:
                    log(f"model {model} not published yet ({r.status_code})")


def apply_config(cfg: dict):
    if not cfg.get("cameras"):
        log("no cameras configured yet — engine idle")
        engine_stop()
        return
    with open(CONFIG_PATH, "w", encoding="utf-8") as fh:
        fh.write(render_engine_config(cfg))
    ensure_models(cfg)
    engine_restart()
    log(f"applied config v{cfg['config_version']} "
        f"({len(cfg.get('cameras', []))} cameras, {len(cfg.get('features', []))} features)")
    vde_sync()   # يعيد بناء كواشف VDE (edge_model/vlm) بعد كل تغيير إعداد


def activate():
    """يسجّل بصمة الجهاز لدى البوابة (يُعاد كل دورة — رخيص ويصمد عند إعادة تشغيل السحابة)."""
    try:
        r = requests.post(f"{CLOUD}/api/device/activate", headers=HEADERS, timeout=15,
                          json={"fingerprint": FINGERPRINT,
                                "hardware_info": _hw["hardware_info"],
                                "is_vm": _hw["is_vm"]})
        if r.ok:
            return r.json().get("status")
    except Exception as exc:
        log(f"activate failed: {exc}")
    return None


def pull_config():
    r = requests.get(f"{CLOUD}/api/device/config", headers=HEADERS, timeout=15)
    r.raise_for_status()
    cfg = r.json()
    status = cfg["status"]
    state["status"] = status
    if status != "active":
        # أي حالة غير معتمدة (pending/suspended/vm_blocked/fingerprint_mismatch/rejected)
        if not state["gated"]:
            log(f"engine gated by cloud: {status} — stopping engine")
            engine_stop()
            state["gated"] = True
        elif status != "active":
            log(f"awaiting authorization: {status}")
        if status == "pending":  # لم يُسجّل/يُعتمد بعد → أعد محاولة التسجيل
            activate()
        return
    if state["gated"]:
        log("authorized by cloud — starting engine")
        engine_start()
        state["gated"] = False
        state["version"] = None  # أعد تطبيق الإعدادات
    engine_image = (cfg.get("engine") or {}).get("image")
    if engine_image:
        update_engine(engine_image)  # لا يفعل شيئاً إن كانت النسخة مطابقة
    if cfg["config_version"] != state["version"]:
        state["config"] = cfg
        apply_config(cfg)
        state["version"] = cfg["config_version"]


# ---------- إرسال الأحداث ----------

def post_event(feature_key, camera, score=0.0, meta=None, snapshot=None, flagged=True):
    data = {"feature_key": feature_key, "camera": camera, "score": score,
            "meta": json.dumps(meta or {}), "flagged": json.dumps(flagged)}
    files = {"snapshot": ("s.jpg", snapshot, "image/jpeg")} if snapshot else None
    try:
        r = requests.post(f"{CLOUD}/api/device/events", headers=HEADERS,
                          data=data, files=files, timeout=30)
        if r.status_code == 403:  # موقوف — تجاهل
            return
        r.raise_for_status()
    except Exception as exc:
        log(f"event upload failed, queued: {exc}")
        if snapshot is None or len(state["pending"]) < 200:
            state["pending"].append((data, snapshot))


def flush_pending():
    pending, state["pending"] = state["pending"], []
    for data, snapshot in pending:
        post_event(data["feature_key"], data["camera"], float(data["score"]),
                   json.loads(data["meta"]), snapshot, json.loads(data["flagged"]))


def cooled(feature_key, camera, seconds):
    key = (feature_key, camera)
    if time.time() - state["cooldowns"].get(key, 0) < seconds:
        return False
    state["cooldowns"][key] = time.time()
    return True


def camera_snapshot(camera):
    try:
        r = requests.get(f"{ENGINE}/api/{camera}/latest.jpg", timeout=10)
        return r.content if r.ok else None
    except Exception:
        return None


# ---------- القواعد ----------

# قواعد لا تُشتقّ من عدّ الأشخاص — تحتاج مدخلاً آخر (تُترك بوعي، لا تُنتج أحداثاً)
_UNWIRED_RULES = {
    "machine_idle": "توقف خط الإنتاج — يحتاج كشف سكون/غياب حركة لا عدّ أشخاص",
    "self_checkout_fraud": "احتيال الدفع الذاتي — يحتاج ربط POS",
}


def _rule_mode(cfg, key=None):
    """نمط القاعدة من شكل إعدادها (يعمّم كل القواعد المكانية بلا تخصيص لكل مفتاح):
      threshold → عدّ داخل منطقة/إطار ≥ حدّ = مخالفة (كثافة/تدافع/إشغال حرج).
      count     → عدّ دوري داخل منطقة = معلومة (حضور/طابور/إشغال/تدفق).
      presence  → وجود شخص داخل منطقة = مخالفة فورية (اقتحام/هروب/تسكّع).
    """
    if key in _UNWIRED_RULES:
        return None
    if cfg.get("threshold") is not None:
        return "threshold"
    if cfg.get("interval_min"):
        return "count"
    if cfg.get("coordinates"):
        return "presence"
    return None


def maybe_lockdown(fired_key, cam):
    """قاعدة تفاعلية: إغلاق طارئ إن نصّت قاعدته على المفتاح الذي أطلق ضمن trigger_on."""
    for f in features_of(key="emergency_lockdown"):
        if fired_key in (f["config"].get("trigger_on") or []) and \
                cooled("emergency_lockdown", cam, f["config"].get("cooldown_s", 0)):
            post_event("emergency_lockdown", cam, meta={"triggered_by": fired_key},
                       snapshot=camera_snapshot(cam))
            log(f"emergency_lockdown triggered by {fired_key} on {cam}")


def check_engine_events():
    """قواعد الوجود المكاني: أي حدث محرك دخل منطقة قاعدة (اسم المنطقة = مفتاحها)."""
    if state["gated"] or not state["config"]:
        return
    try:
        r = requests.get(f"{ENGINE}/api/events",
                         params={"after": state["last_event_ts"], "limit": 50},
                         timeout=10)
        events = r.json() if r.ok else []
    except Exception:
        return
    # camera -> {zone_key: cfg} لقواعد نمط الوجود
    presence = {}
    for f in features_of(kind="rule"):
        if f["key"] in vde_runner.VDE_KEYS:          # يستضيفها VDE
            continue
        cam = f["config"].get("camera")
        if cam and _rule_mode(f["config"], f["key"]) == "presence":
            presence.setdefault(cam, {})[f["key"]] = f["config"]
    for ev in events:
        state["last_event_ts"] = max(state["last_event_ts"],
                                     ev.get("start_time", 0) + 0.001)
        cam = ev.get("camera", "")
        zones = ev.get("zones") or []
        for key, cfg in presence.get(cam, {}).items():
            # ponytail: after_hours/wrong_direction تُطلق على الوجود؛ نافذة الوقت/الاتجاه ترقية لاحقة
            if key in zones and cooled(key, cam, cfg.get("cooldown_s", 300)):
                post_event(key, cam,
                           score=ev.get("data", {}).get("score", 0) or 0,
                           meta={"engine_event": ev.get("id")},
                           snapshot=camera_snapshot(cam))
                maybe_lockdown(key, cam)
                log(f"{key} alert on {cam}")


def active_person_count(camera, zone=None):
    try:
        r = requests.get(f"{ENGINE}/api/events",
                         params={"camera": camera, "label": "person",
                                 "in_progress": 1, "limit": 100},
                         timeout=10)
        events = r.json() if r.ok else []
    except Exception:
        return 0
    if zone:
        events = [e for e in events if zone in (e.get("zones") or [])]
    return len(events)


def check_thresholds():
    """قواعد الكثافة: عدّ الأشخاص (داخل منطقة إن وُجدت، وإلا الإطار) ≥ حدّ = مخالفة."""
    if state["gated"]:
        return
    for f in features_of(kind="rule"):
        if f["key"] in vde_runner.VDE_KEYS:
            continue
        cfg, cam = f["config"], f["config"].get("camera")
        if not cam or _rule_mode(cfg, f["key"]) != "threshold":
            continue
        zone = f["key"] if cfg.get("coordinates") else None  # crowding = إطار كامل
        count = active_person_count(cam, zone=zone)
        if count >= int(cfg.get("threshold", 6)) and \
                cooled(f["key"], cam, cfg.get("cooldown_s", 600)):
            post_event(f["key"], cam, score=count,
                       meta={"count": count}, snapshot=camera_snapshot(cam))
            maybe_lockdown(f["key"], cam)
            log(f"{f['key']} alert on {cam}: {count} persons")


def check_scheduled():
    """عدّ القواعد الدوري داخل منطقة (طابور/حضور/إشغال/تدفق).
    ملاحظة: edge_model وvlm انتقلا لمحرك VDE (مدفوعان بـ MQTT/الجدولة/البثّ)."""
    if state["gated"]:
        return
    for f in features_of(kind="rule"):
        if f["key"] in vde_runner.VDE_KEYS:
            continue
        cfg, cam = f["config"], f["config"].get("camera")
        interval = int(cfg.get("interval_min", 0)) * 60
        if cam and interval and _rule_mode(cfg, f["key"]) == "count" \
                and cooled(f["key"], cam, interval):
            zone = f["key"] if cfg.get("coordinates") else None
            count = active_person_count(cam, zone=zone)
            post_event(f["key"], cam, score=count, meta={"count": count},
                       flagged=False, snapshot=camera_snapshot(cam))


def heartbeat():
    cams_up = {}
    try:
        r = requests.get(f"{ENGINE}/api/stats", timeout=10)
        stats = r.json() if r.ok else {}
        for name, s in (stats.get("cameras") or {}).items():
            cams_up[name] = (s.get("camera_fps") or 0) > 0
    except Exception:
        pass
    try:
        r = requests.post(f"{CLOUD}/api/device/heartbeat", headers=HEADERS,
                          json={"agent_version": "0.1.0",
                                "status": state["status"],
                                "cameras_up": cams_up,
                                # عنوان النفق (إن فُعّل) → اللوحة تصل لصفحات المحرك خلف NAT
                                "tunnel_url": os.environ.get("VEEDON_TUNNEL_URL", "")},
                          timeout=15)
        if r.ok and r.json().get("action") == "stop" and not state["gated"]:
            engine_stop()          # البوابة سحبت الاعتماد بين الدورات
            state["gated"] = True
    except Exception as exc:
        log(f"heartbeat failed: {exc}")
    # صحة الكاميرات: تنبيه مرة واحدة عند السقوط، ويُصفّر عند العودة
    if any(features_of(key="camera_health")) and not state["gated"]:
        for cam, up in cams_up.items():
            if not up and cam not in state["cam_down_sent"]:
                post_event("camera_health", cam, meta={"status": "down"})
                state["cam_down_sent"].add(cam)
            elif up:
                state["cam_down_sent"].discard(cam)


# ---------- محرك VDE (edge_model + vlm + موديولات OTA، مدفوع بـ MQTT) ----------

import threading  # noqa: E402

import vde_runner  # noqa: E402

_vde = None
_vde_lock = threading.Lock()


def _vlm_cloud(feature_key, frames, camera):
    """ctx.vlm: يرسل لقطات متتالية لـ/analyze — **السحابة** تحكم وتنشئ الحدث، ونرجع الحكم."""
    files = [("frames", (f"f{i}.jpg", b, "image/jpeg")) for i, b in enumerate(frames)]
    try:
        r = requests.post(f"{CLOUD}/api/device/analyze", headers=HEADERS,
                          data={"feature_key": feature_key, "camera": camera or ""},
                          files=files, timeout=180)
        return r.json() if r.ok else {"flagged": False}
    except Exception as exc:
        log(f"vlm cloud error {feature_key}: {exc}")
        return {"flagged": False}


def _vde_emit(key, camera, det):
    """إخراج كواشف الحافة (edge_model/custom) → حدث في السحابة."""
    post_event(key, camera, score=det.score,
               meta={"note": det.note, "labels": det.label, "source_kind": "vde"},
               snapshot=det.snapshot)
    maybe_lockdown(key, camera)


def _vde_mqtt_loop():
    while True:                                     # يعيد الاتصال إن سقط الوسيط
        try:
            _vde.run_mqtt(MQTT_HOST, MQTT_PORT)
        except Exception as exc:
            log(f"[vde] mqtt reconnect ({exc})")
            time.sleep(5)


def vde_engine():
    """يبني المحرك مرة ويشغّل خيطي MQTT + الجدولة."""
    global _vde
    with _vde_lock:
        if _vde is None:
            from analyzer import OnnxDetector
            _vde = vde_runner.build_engine(
                frame_fn=camera_snapshot, vlm_fn=_vlm_cloud, log_fn=log,
                onnx_cls=OnnxDetector, emit_fn=_vde_emit)
            _vde.start_schedule()
            threading.Thread(target=_vde_mqtt_loop, daemon=True,
                             name="vde-mqtt").start()
        return _vde


def vde_sync():
    """يعيد بناء كواشف VDE من الإعدادات الحالية (بعد كل تطبيق config)."""
    if not state["config"]:
        return
    n = vde_runner.sync(vde_engine(), state["config"].get("features", []), MODELS_DIR)
    log(f"[vde] synced {n} detectors (edge_model/vlm/custom)")


_ota = None
MODULES_DIR = os.path.join(MODELS_DIR, "vde_modules")


def _module_cameras(key):
    """كاميرات هذا الفرع التي فُعّل عليها الموديول (من الإعدادات) → (كاميرا، config)."""
    return [(f["config"]["camera"], f["config"])
            for f in (state["config"] or {}).get("features", [])
            if f["key"] == key and f["config"].get("camera")]


def vde_ota():
    """عميل OTA للموديولات الموقّعة — يسحب الحزم، يتحقق التوقيع، يحمّلها ساخنة.
    بلا مفتاح عام مُزوّد (VEEDON_MODULE_PUBKEY) لا يُحمّل شيء (أمان)."""
    global _ota
    if _ota is None:
        from vde.registry_client import ModuleOTA
        pub = os.environ.get("VEEDON_MODULE_PUBKEY", "")
        _ota = ModuleOTA(vde_engine(), CLOUD, HEADERS, pub, MODULES_DIR,
                         _module_cameras, log=log)
    return _ota


# ---------- الحلقة الرئيسية ----------

def main():
    log(f"starting — cloud: {CLOUD}")
    timers = {}

    def due(name, every):
        if time.time() - timers.get(name, 0) >= every:
            timers[name] = time.time()
            return True
        return False

    while True:
        try:
            if due("config", POLL_S):
                pull_config()
                flush_pending()
            if due("heartbeat", HEARTBEAT_S):
                heartbeat()
            if not state["gated"] and state["config"]:
                if due("events", EVENTS_S):
                    check_engine_events()
                if due("crowd", CROWD_S):
                    check_thresholds()
                if due("sched", 30):
                    check_scheduled()
                # edge_model + vlm يُداران في محرك VDE (MQTT/جدولة/بثّ) — بدء كسول
                vde_engine()
                if due("modules", POLL_S):
                    vde_ota().sync()   # موديولات OTA موقّعة (بلا مفتاح عام = no-op)
        except Exception as exc:
            log(f"loop error: {exc}")
        time.sleep(2)


if __name__ == "__main__":
    main()
