"""WhiteCore Vision AI detector - standalone ONNX build.

Functionally identical to ../detect.py (SERVER + DEMO modes, same env-var
contract, same GET /player/zones + POST /player/vision-events shapes, same
IGNORE-zone privacy masking, same per-zone people-count + dwell + trigger
detection types) - but the inference core is onnxruntime + a light IOU tracker
instead of ultralytics/torch/ByteTrack, so it PyInstaller-freezes into a single
exe that needs NO Python install on the customer's box.

Only the detection+tracking is different; every server/analytics behaviour is
copied verbatim so the dashboard, analytics, and triggers see the exact same
data. Validate people-count/dwell parity against detect.py before shipping.
"""

import json
import os
import sys
import threading
import time
import urllib.request
import urllib.error
import urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

# OpenCV's FFmpeg backend defaults to 4,096 failed packet reads before it gives
# up. On an interrupted RTSP stream that can block cap.read() for nearly a minute
# while the preview server continues serving the last good image. Keep the
# failure window short so the reconnect loop can recover promptly. These must be
# present before cv2 is imported because OpenCV reads environment settings once.
os.environ.setdefault("OPENCV_FFMPEG_READ_ATTEMPTS", "256")
os.environ.setdefault("OPENCV_FFMPEG_DECODE_ATTEMPTS", "32")
os.environ.setdefault("OPENCV_FFMPEG_CAPTURE_OPTIONS", "rtsp_transport;tcp")

import cv2
import numpy as np
import onnxruntime as ort

# Sibling module, deliberately dependency-free so the tracker can be unit-tested
# without cv2 / onnxruntime (see detector/test_tracker.py). PyInstaller resolves
# it from the script's own directory; the workflow also passes --paths detector.
from tracker import IouTracker
from visitor_segments import (
    SEGMENT_KEYS,
    VisitorSegmentTracker,
    ZonePassageTracker,
    empty_segments,
)

MODEL_FILE = "yolov8n.onnx"
IMGSZ = 640
PERSON_CLASS = 0
CONF_THRESHOLD = 0.4
NMS_IOU = 0.45
WIN = "Vision AI POC"

# ── WhiteCore server integration (identical contract to detect.py) ────────────
WC_API_URL = os.environ.get("WHITECORE_API_URL", "").rstrip("/")
WC_DEVICE_TOKEN = os.environ.get("WHITECORE_DEVICE_TOKEN", "")
WC_CAMERA_ID = os.environ.get("WHITECORE_CAMERA_ID", "")
WC_POST_INTERVAL = float(os.environ.get("WHITECORE_POST_INTERVAL", "1.0"))
# How often the (expensive) YOLO inference + counting runs. Frames are grabbed +
# published for the live preview EVERY loop iteration regardless, so the preview
# stays smooth (limited only by the viewer's ~6fps poll) even though inference -
# which caps at ~3fps on a CPU box - runs on this slower cadence. Counting a few
# times a second is plenty; decoupling the two is what unstuck the 3fps preview.
WC_INFER_INTERVAL = float(os.environ.get("WHITECORE_INFER_INTERVAL", "0.3"))
# Inference frames a track must be seen before it counts as an arrival (footfall).
# 1 restores the old count-on-first-sight behaviour, which counted single-frame
# false positives as visitors. Raise it on a noisy outdoor camera.
WC_MIN_TRACK_HITS = max(1, int(os.environ.get("WHITECORE_MIN_TRACK_HITS", "3")))
WC_ZONE_REFRESH_SEC = float(os.environ.get("WHITECORE_ZONE_REFRESH_SEC", "60"))
WC_BUSY_THRESHOLD = int(os.environ.get("WHITECORE_BUSY_THRESHOLD", "3"))
WC_QUEUE_THRESHOLD = int(os.environ.get("WHITECORE_QUEUE_THRESHOLD", "2"))
DWELL_LOG_CAP = 500

SERVER_MODE = bool(WC_API_URL and WC_DEVICE_TOKEN and WC_CAMERA_ID)
HEADLESS = os.environ.get("WHITECORE_HEADLESS", "1" if SERVER_MODE else "0") == "1"

zones = []
_zones_fetched_at = 0.0
_corner1 = None
demo_zone = None
zone_members = {}
zone_dwell_log = {}
_last_post = 0.0
_post_fail_streak = 0

# ── Optional demographics (gender + age band). OFF by default: a net-new,
# accuracy-sensitive capability that only runs when explicitly enabled on a
# camera (WHITECORE_DEMOGRAPHICS=1). Uses insightface (face detect + gender/age)
# on a throttled cadence - heavier than the person detector, and a SUBSET of the
# people count (only faces the camera can actually see). Reads/aggregates are
# face-based; the whole-frame people count stays YOLO-based (more robust).
WC_DEMOGRAPHICS = os.environ.get("WHITECORE_DEMOGRAPHICS", "0") == "1"
WC_DEMOG_INTERVAL = float(os.environ.get("WHITECORE_DEMOG_INTERVAL", "1.0"))
# Anonymous foot-position dots (normalised [x,y] per person, bottom-centre of the
# box) for the floor/frame density heatmap. No identity, no faces - just where in
# the frame people stood. Off = the server only ever gets counts + zones (the
# original privacy posture). Capped per post so the payload stays small.
WC_HEATMAP_POSITIONS = os.environ.get("WHITECORE_HEATMAP_POSITIONS", "1") == "1"
WC_POSITIONS_CAP = int(os.environ.get("WHITECORE_POSITIONS_CAP", "400"))
_face_app = None
_face_app_failed = False


def _get_face_app():
    """Lazy-init insightface. Returns None (once) if it can't load, so a box
    without the models just runs person-counting as before."""
    global _face_app, _face_app_failed
    if _face_app is not None or _face_app_failed:
        return _face_app
    try:
        import sys
        from insightface.app import FaceAnalysis
        # Frozen (PyInstaller): models are bundled at <exe>/insightface/models/
        # buffalo_s. Local dev: insightface's default (~/.insightface).
        kwargs = {"name": "buffalo_s", "providers": ["CPUExecutionProvider"],
                  "allowed_modules": ["detection", "genderage"]}
        if getattr(sys, "frozen", False):
            kwargs["root"] = os.path.join(sys._MEIPASS, "insightface")
        else:
            # Run un-frozen (the unified Linux/Pi/macOS path): the install scripts
            # bundle the face model next to this script in the frozen layout
            # (insightface/models/buffalo_s). Point insightface at it so
            # demographics works offline; else it falls back to ~/.insightface
            # (auto-download on first run).
            here = os.path.dirname(os.path.abspath(__file__))
            bundled = os.path.join(here, "insightface")
            if os.path.isdir(os.path.join(bundled, "models", "buffalo_s")):
                kwargs["root"] = bundled
        app = FaceAnalysis(**kwargs)
        app.prepare(ctx_id=-1, det_size=(640, 640))
        _face_app = app
        print("[whitecore] demographics: insightface buffalo_s ready")
    except Exception as exc:
        _face_app_failed = True
        print(f"[whitecore] demographics disabled (insightface init failed: {exc})")
    return _face_app


def _classify_demographics(frame):
    """Per-frame gender/age face counts. A SUBSET of the people count - only the
    faces the camera can see. Returns {male, female, adult, child, faces} or None."""
    app = _get_face_app()
    if app is None:
        return None
    try:
        faces = app.get(frame)
    except Exception as exc:
        print(f"[whitecore] demographics classify failed: {exc}")
        return None
    male = female = adult = child = 0
    for f in faces:
        sex = getattr(f, "sex", None) or ("M" if int(getattr(f, "gender", 0)) == 1 else "F")
        age = int(round(float(f.age)))
        if sex == "M":
            male += 1
        else:
            female += 1
        if age < 13:
            child += 1
        else:
            adult += 1
    return {"male": male, "female": female, "adult": adult, "child": child, "faces": len(faces)}

# ── single-owner camera: the detector is the ONLY process that opens the device.
# It keeps the latest decoded frame in memory so the player can serve snapshots
# (and, later, live preview) WITHOUT opening the camera a second time - which on a
# USB webcam is impossible (single-owner) and caused the "device busy" / on-off
# thrash. A tiny loopback HTTP server exposes that in-RAM frame; the player reads
# the bound port from WHITECORE_PORTFILE.
_frame_lock = threading.Lock()
_latest_frame = None  # numpy BGR array, most recent decoded frame
_latest_frame_at = 0.0  # monotonic time of the actual camera read
WC_PREVIEW_MAX_AGE_SEC = max(1.0, float(os.environ.get("WHITECORE_PREVIEW_MAX_AGE_SEC", "10")))


def _set_latest_frame(frame):
    global _latest_frame, _latest_frame_at
    with _frame_lock:
        _latest_frame = frame
        _latest_frame_at = time.monotonic()


def _clear_latest_frame():
    """Stop serving a cached image as soon as the source is unhealthy."""
    global _latest_frame, _latest_frame_at
    with _frame_lock:
        _latest_frame = None
        _latest_frame_at = 0.0


def _fresh_frame_copy():
    with _frame_lock:
        if _latest_frame is None or _latest_frame_at <= 0:
            return None
        if time.monotonic() - _latest_frame_at > WC_PREVIEW_MAX_AGE_SEC:
            return None
        return _latest_frame.copy()


def _latest_jpeg(quality=85):
    f = _fresh_frame_copy()
    if f is None:
        return None
    ok, buf = cv2.imencode(".jpg", f, [int(cv2.IMWRITE_JPEG_QUALITY), quality])
    return buf.tobytes() if ok else None


def _preview_jpeg(max_w=640, quality=60):
    """A downscaled, lighter JPEG for the LIVE preview stream (vs the full-res
    /snapshot used for calibration). ~30-50 KB so the fan-out stays cheap."""
    f = _fresh_frame_copy()
    if f is None:
        return None
    h, w = f.shape[:2]
    if w > max_w:
        f = cv2.resize(f, (max_w, max(1, int(h * max_w / w))), interpolation=cv2.INTER_AREA)
    ok, buf = cv2.imencode(".jpg", f, [int(cv2.IMWRITE_JPEG_QUALITY), quality])
    return buf.tobytes() if ok else None


class _PreviewHandler(BaseHTTPRequestHandler):
    def log_message(self, *args):
        pass  # never spam the player log ring

    def do_GET(self):
        # /snapshot = full-res still (calibration); /preview = light frame (live).
        if self.path.startswith("/snapshot"):
            jpg = _latest_jpeg(85)
        elif self.path.startswith("/preview"):
            jpg = _preview_jpeg(640, 60)
        else:
            self.send_response(404)
            self.end_headers()
            return
        if not jpg:
            self.send_response(503)
            self.end_headers()
            return
        self.send_response(200)
        self.send_header("Content-Type", "image/jpeg")
        self.send_header("Content-Length", str(len(jpg)))
        self.end_headers()
        try:
            self.wfile.write(jpg)
        except Exception:
            pass


def _start_preview_server():
    """Bind a loopback HTTP server on a free port and publish it to the portfile."""
    portfile = os.environ.get("WHITECORE_PORTFILE", "")
    try:
        srv = ThreadingHTTPServer(("127.0.0.1", 0), _PreviewHandler)
    except Exception as exc:
        print(f"[whitecore] preview server bind failed: {exc}")
        return
    port = srv.server_address[1]
    print(f"[whitecore] preview server on 127.0.0.1:{port}")
    if portfile:
        try:
            tmp = portfile + ".tmp"
            with open(tmp, "w") as f:
                f.write(str(port))
            os.replace(tmp, portfile)  # atomic: the player never reads a half-written port
        except Exception as exc:
            print(f"[whitecore] could not write portfile {portfile}: {exc}")
    threading.Thread(target=srv.serve_forever, daemon=True).start()


# ── inference core (onnxruntime + IOU tracker) ────────────────────────────────
def _model_path():
    # PyInstaller unpacks bundled data to sys._MEIPASS; fall back to this dir.
    base = getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__)))
    return os.path.join(base, MODEL_FILE)


def _letterbox(img, new_shape=IMGSZ):
    h, w = img.shape[:2]
    r = min(new_shape / h, new_shape / w)
    nh, nw = int(round(h * r)), int(round(w * r))
    resized = cv2.resize(img, (nw, nh), interpolation=cv2.INTER_LINEAR)
    canvas = np.full((new_shape, new_shape, 3), 114, dtype=np.uint8)
    dh, dw = (new_shape - nh) // 2, (new_shape - nw) // 2
    canvas[dh:dh + nh, dw:dw + nw] = resized
    return canvas, r, dw, dh


def _infer_persons(session, input_name, frame):
    """Return person boxes [x1,y1,x2,y2] in original-frame pixels."""
    h0, w0 = frame.shape[:2]
    img, r, dw, dh = _letterbox(frame, IMGSZ)
    blob = cv2.cvtColor(img, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0
    blob = np.ascontiguousarray(blob.transpose(2, 0, 1)[None])  # [1,3,640,640]
    out = session.run(None, {input_name: blob})[0]  # [1, 84, 8400]
    preds = out[0].T  # [8400, 84] -> cx,cy,w,h, cls0..cls79
    scores = preds[:, 4 + PERSON_CLASS]
    keep = scores > CONF_THRESHOLD
    preds, scores = preds[keep], scores[keep]
    if len(preds) == 0:
        return []
    cx, cy, ww, hh = preds[:, 0], preds[:, 1], preds[:, 2], preds[:, 3]
    x1 = (cx - ww / 2 - dw) / r
    y1 = (cy - hh / 2 - dh) / r
    x2 = (cx + ww / 2 - dw) / r
    y2 = (cy + hh / 2 - dh) / r
    nms_boxes = np.stack([x1, y1, x2 - x1, y2 - y1], axis=1).tolist()
    idxs = cv2.dnn.NMSBoxes(nms_boxes, scores.tolist(), CONF_THRESHOLD, NMS_IOU)
    boxes = []
    for i in np.array(idxs).flatten() if len(idxs) else []:
        boxes.append([
            max(0, int(x1[i])), max(0, int(y1[i])),
            min(w0, int(x2[i])), min(h0, int(y2[i])),
        ])
    return boxes


# ── server contract (verbatim from detect.py) ─────────────────────────────────
def _fetch_zones():
    global zones, _zones_fetched_at
    if not SERVER_MODE:
        return
    _zones_fetched_at = time.time()
    url = f"{WC_API_URL}/player/zones?cameraId={urllib.parse.quote(WC_CAMERA_ID)}"
    req = urllib.request.Request(url, method="GET",
                                 headers={"Authorization": "Bearer " + WC_DEVICE_TOKEN})
    try:
        with urllib.request.urlopen(req, timeout=5) as resp:
            payload = json.loads(resp.read().decode("utf-8"))
        fetched = payload.get("zones") or []
        zones = [z for z in fetched if isinstance(z.get("polygon"), list) and len(z["polygon"]) >= 3]
        print(f"[whitecore] loaded {len(zones)} zone(s) for camera {WC_CAMERA_ID}")
    except Exception as exc:
        print(f"[whitecore] zone fetch failed (keeping {len(zones)} cached): {exc}")


def _point_in_poly(px, py, polygon):
    n = len(polygon)
    if n < 3:
        return False
    inside = False
    j = n - 1
    for i in range(n):
        xi, yi = polygon[i].get("x", 0.0), polygon[i].get("y", 0.0)
        xj, yj = polygon[j].get("x", 0.0), polygon[j].get("y", 0.0)
        if ((yi > py) != (yj > py)) and (px < (xj - xi) * (py - yi) / ((yj - yi) or 1e-12) + xi):
            inside = not inside
        j = i
    return inside


def _active_zones(w, h):
    if SERVER_MODE:
        return zones
    if demo_zone is None:
        return []
    x1, y1, x2, y2 = demo_zone
    return [{
        "id": "demo", "name": "Demo zone", "kind": "COUNTING",
        "direction": None, "sensitivity": 50,
        "polygon": [
            {"x": x1 / w, "y": y1 / h}, {"x": x2 / w, "y": y1 / h},
            {"x": x2 / w, "y": y2 / h}, {"x": x1 / w, "y": y2 / h},
        ],
    }]


def _post_vision_event(
        detections,
        zone_events,
        people_in_frame=0,
        entries=0,
        demographics=None,
        visitor_segments=None,
        positions=None):
    global _last_post, _post_fail_streak
    # Post even when detections/zone_events are empty: the server treats any
    # vision-event carrying a cameraId as the camera's liveness heartbeat
    # (sets lastSeenAt + ONLINE). Bailing on empty here left a capturing camera
    # with no zones (or an empty scene) stuck OFFLINE despite the webcam light
    # being on. The interval throttle below still caps this to ~1 post/sec.
    # Returns True only when a post actually went out, so the caller can reset
    # its pending-footfall accumulator (a throttled/failed call keeps it).
    if not SERVER_MODE:
        return False
    now = time.time()
    interval = WC_POST_INTERVAL * (1 + min(_post_fail_streak, 5))
    if now - _last_post < interval:
        return False
    _last_post = now
    # peopleInFrame is the WHOLE-view person count (everyone the camera sees,
    # minus IGNORE-masked areas) - the honest "live count". zoneEvents stays the
    # per-area breakdown for zone-scoped triggers + dwell. entries = distinct NEW
    # people since the last post (real footfall): the server SUMS these for the
    # "visitors" count, instead of summing occupancy samples (the old way, which
    # counted one lingering person once per sample and inflated it ~30x).
    body_obj = {
        "cameraId": WC_CAMERA_ID, "detections": detections, "zoneEvents": zone_events,
        "peopleInFrame": people_in_frame,
        "entries": entries,
    }
    # demographics (gender/age face counts) only when enabled + computed; absent
    # otherwise, so the server treats it as "no demographics" (never a fake 0).
    if demographics is not None:
        body_obj["demographics"] = demographics
    if visitor_segments and any(visitor_segments.get(key, 0) > 0 for key in SEGMENT_KEYS):
        body_obj["visitorSegments"] = visitor_segments
    # Anonymous foot-position dots for the density heatmap; absent (not empty)
    # when disabled or nobody was seen, so the server treats it as "no positions".
    if positions:
        body_obj["positions"] = positions
    body = json.dumps(body_obj).encode("utf-8")
    req = urllib.request.Request(
        WC_API_URL + "/player/vision-events", data=body, method="POST",
        headers={"Content-Type": "application/json", "Authorization": "Bearer " + WC_DEVICE_TOKEN})
    try:
        urllib.request.urlopen(req, timeout=3).close()
        _post_fail_streak = 0
        return True
    except urllib.error.HTTPError as exc:
        _post_fail_streak += 1
        print(f"[whitecore] vision-event POST {exc.code}: {exc.reason}")
    except Exception as exc:
        _post_fail_streak += 1
        print(f"[whitecore] vision-event POST failed: {exc}")
    return False


def _mouse(event, x, y, flags, param):
    global _corner1, demo_zone
    if event != cv2.EVENT_LBUTTONDOWN:
        return
    if _corner1 is None:
        _corner1 = (x, y)
    else:
        x1, y1 = min(_corner1[0], x), min(_corner1[1], y)
        x2, y2 = max(_corner1[0], x), max(_corner1[1], y)
        if x2 - x1 > 10 and y2 - y1 > 10:
            demo_zone = (x1, y1, x2, y2)
        _corner1 = None
        zone_members.clear()
        zone_dwell_log.clear()


def _resolve_source():
    arg = sys.argv[1] if len(sys.argv) > 1 else os.environ.get("WC_CAMERA_SOURCE", "")
    if not arg:
        return 0
    return int(arg) if str(arg).isdigit() else arg


def _open_capture(source):
    if isinstance(source, str) and source.lower().startswith("rtsp"):
        # Open/read timeouts prevent a dead network camera from wedging the
        # detector indefinitely. Older OpenCV builds do not support the params
        # overload, so keep a safe fallback for those installations.
        try:
            return cv2.VideoCapture(
                source,
                cv2.CAP_FFMPEG,
                [
                    cv2.CAP_PROP_OPEN_TIMEOUT_MSEC,
                    8_000,
                    cv2.CAP_PROP_READ_TIMEOUT_MSEC,
                    8_000,
                ],
            )
        except (AttributeError, TypeError):
            return cv2.VideoCapture(source)
    return cv2.VideoCapture(source)


def main():
    source = _resolve_source()
    session = ort.InferenceSession(_model_path(), providers=["CPUExecutionProvider"])
    input_name = session.get_inputs()[0].name
    tracker = IouTracker(min_hits=WC_MIN_TRACK_HITS)
    visitor_segment_tracker = VisitorSegmentTracker()
    zone_passage_tracker = ZonePassageTracker()

    cap = _open_capture(source)
    if not cap.isOpened():
        print(f"ERROR: Cannot open camera/stream {source!r}")
        sys.exit(1)

    if SERVER_MODE:
        print(f"[whitecore] server mode (onnx), camera {WC_CAMERA_ID}, headless={HEADLESS}")
        _fetch_zones()
        _start_preview_server()  # serve snapshots (and later live preview) from our in-RAM frame
    else:
        print("Demo mode. Click corner 1, click corner 2 to draw a zone. R = reset, Q = quit.")

    if not HEADLESS:
        cv2.namedWindow(WIN)
        cv2.setMouseCallback(WIN, _mouse)

    _last_infer = 0.0
    # Footfall: count each DISTINCT person once. An arrival is a track the tracker
    # has CONFIRMED (seen for WHITECORE_MIN_TRACK_HITS inference frames), not a
    # track id appearing for the first time: at ~3fps a single-frame false positive
    # minted an id, and a walking person's box stopped overlapping its own track so
    # it minted a fresh id every few frames. Together those turned a handful of
    # real people into 396 "visitors". See detector/tracker.py.
    # _entries_pending accumulates arrivals between posts, reset only on an actual post.
    _entries_pending = 0
    _positions_pending = []  # anonymous foot dots since the last post (heatmap)
    _segments_pending = empty_segments()
    _zone_passages_pending = {}
    _active_shopper_threshold = None
    _last_demog = 0.0
    _demog_latest = None  # last {male, female, adult, child, faces} computed
    try:
        while True:
            ret, frame = cap.read()
            if not ret:
                _clear_latest_frame()
                if isinstance(source, str):
                    print("Stream read failed; reconnecting in 3s...")
                    cap.release()
                    time.sleep(3)
                    cap = _open_capture(source)
                    continue
                print("ERROR: Failed to read frame")
                break

            # Publish the raw (un-annotated) frame so snapshots/preview serve a
            # clean image and calibration can draw zones on top of it.
            _set_latest_frame(frame)

            now = time.time()
            h, w = frame.shape[:2]

            if SERVER_MODE and now - _zones_fetched_at >= WC_ZONE_REFRESH_SEC:
                _fetch_zones()

            # The frame is already published above for the live preview. Run the
            # heavy inference/counting only on its own cadence so the preview
            # isn't throttled to the inference rate (server/headless path only;
            # the interactive demo keeps inferring every frame).
            if SERVER_MODE and (now - _last_infer) < WC_INFER_INTERVAL:
                continue
            _last_infer = now

            active = _active_zones(w, h)
            ignore_polys = [z["polygon"] for z in active if z.get("kind") == "IGNORE"]
            counted = [z for z in active if z.get("kind") != "IGNORE"]

            boxes = _infer_persons(session, input_name, frame)
            tracked = tracker.update(boxes)

            inside_now = {z["id"]: set() for z in counted}
            people_in_frame = 0  # whole-view count (excludes IGNORE-masked people)
            frame_new_entries = 0        # distinct new arrivals this frame (footfall)
            # Confirmed this frame. A confirmed track standing in an IGNORE mask is
            # still not a visitor, so the arrival is only booked inside the loop
            # below, after the mask check.
            confirmed_now = tracker.newly_confirmed
            for tid, (x1, y1, x2, y2) in tracked:
                cx, cy = (x1 + x2) // 2, (y1 + y2) // 2
                nx, ny = cx / w, cy / h
                if any(_point_in_poly(nx, ny, poly) for poly in ignore_polys):
                    continue
                people_in_frame += 1
                if WC_HEATMAP_POSITIONS:
                    # Foot point = bottom-centre of the box (where the person stands),
                    # normalised to the frame. Anonymous: no id, no face. Capped so
                    # a busy scene between posts can't bloat the payload.
                    _positions_pending.append([round(nx, 3), round(y2 / h, 3)])
                    if len(_positions_pending) > WC_POSITIONS_CAP:
                        del _positions_pending[: len(_positions_pending) - WC_POSITIONS_CAP]
                if tid in confirmed_now:   # a newly CONFIRMED track = a new arrival
                    frame_new_entries += 1
                in_any = False
                for z in counted:
                    if _point_in_poly(nx, ny, z["polygon"]):
                        in_any = True
                        inside_now[z["id"]].add(tid)
                        members = zone_members.setdefault(z["id"], {})
                        if tid not in members:
                            members[tid] = now
                if not HEADLESS:
                    color = (30, 140, 255) if in_any else (0, 200, 80)
                    cv2.rectangle(frame, (x1, y1), (x2, y2), color, 2)
                    cv2.circle(frame, (cx, cy), 4, color, -1)
                    cv2.putText(frame, f"ID:{tid}", (x1, y1 - 6),
                                cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1)

            # Commit this frame's arrivals into the pending footfall count
            # (sent + reset on the next actual post).
            _entries_pending += frame_new_entries

            # Classify anonymous tracks from explicitly configured zones. Tracker
            # ids remain in memory on this player; only aggregate flow counts are
            # included in the next post.
            segment_delta, segment_threshold = visitor_segment_tracker.update(
                now,
                counted,
                inside_now,
                confirmed_now,
                {track.id for track in tracker.tracks},
            )
            for key in SEGMENT_KEYS:
                _segments_pending[key] += segment_delta[key]
            if segment_threshold is not None:
                _active_shopper_threshold = segment_threshold
            passage_delta = zone_passage_tracker.update(
                counted,
                inside_now,
                confirmed_now,
                {track.id for track in tracker.tracks},
            )
            for zone_id, count in passage_delta.items():
                _zone_passages_pending[zone_id] = _zone_passages_pending.get(zone_id, 0) + count

            detection_types = set()
            zone_events = []
            for z in counted:
                zid = z["id"]
                members = zone_members.setdefault(zid, {})
                here = inside_now.get(zid, set())
                for tid in list(members):
                    if tid not in here:
                        dur = now - members.pop(tid)
                        log = zone_dwell_log.setdefault(zid, [])
                        log.append(dur)
                        if len(log) > DWELL_LOG_CAP:
                            del log[: len(log) - DWELL_LOG_CAP]
                count = len(here)
                dwells = [now - t for t in members.values()]
                avg_dwell = round(sum(dwells) / len(dwells), 1) if dwells else 0.0
                zone_event = {
                    "zoneId": zid,
                    "peopleInZone": count,
                    "dwellTimeSeconds": avg_dwell,
                }
                if str(z.get("kind") or "").upper() == "FOOTFALL":
                    zone_event["passages"] = _zone_passages_pending.get(zid, 0)
                zone_events.append(zone_event)
                if count >= WC_BUSY_THRESHOLD:
                    detection_types.add("occupancy_busy")
                elif count > 0:
                    detection_types.add("occupancy_quiet")
                if z.get("kind") == "QUEUE" and count >= WC_QUEUE_THRESHOLD:
                    detection_types.add("behaviour_queue")

            # Occupancy on the WHOLE frame too (not only zones), so a no-zone
            # camera still drives busy/quiet + the people-count trigger. Added to
            # the type SET so it can't duplicate a zone-derived occupancy type.
            if people_in_frame >= WC_BUSY_THRESHOLD:
                detection_types.add("occupancy_busy")
            elif people_in_frame > 0:
                detection_types.add("occupancy_quiet")
            detections = [{"type": t, "confidence": 0.9} for t in sorted(detection_types)]
            # Post every interval regardless of whether anyone is in a zone. An
            # actively-capturing camera must report ONLINE, and a freshly-created
            # camera has no zones yet - gating the post on "someone in a zone"
            # left it OFFLINE forever. _post_vision_event throttles to ~1/sec.
            # Demographics on its own (slower) cadence - face classification is
            # heavier than person detection. Send the latest computed counts with
            # each post; absent until enabled + first computed.
            if WC_DEMOGRAPHICS and (now - _last_demog) >= WC_DEMOG_INTERVAL:
                _last_demog = now
                _demog_latest = _classify_demographics(frame)
            segments_to_post = {
                **_segments_pending,
                **({"activeShopperThresholdSeconds": _active_shopper_threshold}
                   if _active_shopper_threshold is not None else {}),
            }
            posted = _post_vision_event(
                detections,
                zone_events,
                people_in_frame,
                entries=_entries_pending,
                demographics=_demog_latest,
                visitor_segments=segments_to_post,
                positions=_positions_pending,
            )
            if posted:
                _entries_pending = 0
                _positions_pending = []
                _segments_pending = empty_segments()
                _zone_passages_pending = {}

            if not HEADLESS:
                for z in counted:
                    poly_px = [(int(p["x"] * w), int(p["y"] * h)) for p in z["polygon"]]
                    pts = np.array(poly_px, dtype=np.int32)
                    cv2.polylines(frame, [pts], True, (255, 200, 0), 2)
                    cv2.putText(frame, z.get("name", "zone"), (poly_px[0][0] + 4, poly_px[0][1] + 18),
                                cv2.FONT_HERSHEY_SIMPLEX, 0.55, (255, 200, 0), 1)
                total_in = sum(ev["peopleInZone"] for ev in zone_events)
                cv2.rectangle(frame, (0, 0), (240, 30), (0, 0, 0), -1)
                cv2.putText(frame, f"In zones: {total_in}", (10, 21),
                            cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 100), 2)
                if _corner1:
                    cv2.drawMarker(frame, _corner1, (255, 200, 0), cv2.MARKER_CROSS, 20, 2)
                cv2.imshow(WIN, frame)
                key = cv2.waitKey(1) & 0xFF
                if key == ord("q"):
                    break
                if key == ord("r"):
                    zone_members.clear()
                    zone_dwell_log.clear()
                    globals()["demo_zone"] = None
    except KeyboardInterrupt:
        print("Interrupted; shutting down.")
    finally:
        cap.release()
        if not HEADLESS:
            cv2.destroyAllWindows()


if __name__ == "__main__":
    main()
