from flask import Flask, Response, jsonify, request
from flask_cors import CORS
import argparse
import glob
import cv2
import os
import sys
import time
import numpy as np
import threading
import requests
import base64
import struct
import subprocess

app = Flask(__name__)
CORS(app)

# Camera management. Production installs create /dev/robopark-camera from
# the primary USB capture interface, avoiding /dev/videoN renumbering.
def _normalize_camera_device(value):
    configured = str(value or "").strip()
    if configured.lower() in ("", "auto", "default", "first"):
        if sys.platform.startswith("linux"):
            return "/dev/robopark-camera" if os.path.exists("/dev/robopark-camera") else "/dev/video0"
        # Off-Pi we scan instead of assuming index 0: a Windows kiosk with a
        # virtual/IR device in the way exposes the real webcam at 1 or 2.
        return "auto"
    if configured.isdigit():
        return int(configured)
    return configured


_configured_camera = os.getenv("ROBOPARK_CAMERA_DEVICE", "")
current_camera_index = _normalize_camera_device(_configured_camera)
camera = None
camera_lock = threading.Lock()
frame_condition = threading.Condition()
latest_frame_bytes = None
latest_frame_sequence = 0
camera_worker_started = False
camera_watchdog_started = False
camera_read_started_at = 0.0
camera_last_frame_at = 0.0
audio_input_device = "default"
audio_output_device = "default"
ROBOVISION_AUDIO_URL = os.getenv("ROBOVISION_AUDIO_URL", "http://127.0.0.1:8000")
_camera_inventory_cache = []
_camera_inventory_cache_at = 0.0
CAMERA_INVENTORY_CACHE_SECONDS = 30.0
_camera_name_cache = []
_camera_name_cache_at = 0.0
CAMERA_NAME_CACHE_SECONDS = 30.0

CAMERA_SCAN_MAX_INDEX = int(os.getenv("ROBOPARK_CAMERA_SCAN_MAX", "3"))
CAMERA_OPEN_MAX_ATTEMPTS = int(os.getenv("ROBOPARK_CAMERA_OPEN_ATTEMPTS", "3"))
CAMERA_FIRST_FRAME_TRIES = 5
CAMERA_FIRST_FRAME_DELAY = 0.15

active_camera_device = None
active_camera_backend = None
camera_open_attempts = []
camera_open_error = None
# Whether the capture is open, tracked as a plain flag instead of asking the
# VideoCapture each time. cv2 calls serialise against the worker's in-flight
# read/open, so `camera.isOpened()` inside a request handler blocks for as long
# as MSMF is stuck opening a device -- which is exactly when an operator is
# trying to find out what is wrong. Diagnostics must never be able to hang.
camera_open_flag = False
# Set while a background inventory probe is running, so /api/media/inventory
# can answer instantly with what it already knows instead of waiting on OpenCV.
_camera_inventory_probing = False


def _camera_backends():
    """Backends to try, in the order most likely to bind on this platform.

    Windows leads with Media Foundation because that is the stack Chrome's
    getUserMedia uses, and the kiosk that fails here streams fine in the
    browser. DirectShow alone logs "backend is generally available but can't
    be used to capture by index" and never binds on that hardware; it stays as
    the second try because some older UVC bridges only enumerate there.
    Linux/Pi keeps V4L2 first — unchanged from the original behaviour.
    """
    if sys.platform == 'win32':
        return [
            (getattr(cv2, 'CAP_MSMF', cv2.CAP_ANY), 'msmf'),
            (getattr(cv2, 'CAP_DSHOW', cv2.CAP_ANY), 'dshow'),
            (cv2.CAP_ANY, 'default'),
        ]
    if sys.platform.startswith('linux'):
        return [(getattr(cv2, 'CAP_V4L2', cv2.CAP_ANY), 'v4l2'), (cv2.CAP_ANY, 'default')]
    return [(cv2.CAP_ANY, 'default')]


def _tune_capture(cap):
    if sys.platform.startswith('linux'):
        cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*'MJPG'))
        cap.set(cv2.CAP_PROP_FPS, 15)
    cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
    cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)


def _capture_yields_frame(cap):
    """isOpened() is not proof of a working camera — MSMF/DSHOW both hand back
    an "open" handle that never decodes a frame. Only a real read counts."""
    for _ in range(CAMERA_FIRST_FRAME_TRIES):
        try:
            success, frame = cap.read()
        except Exception as exc:
            return False, f"read() raised {exc}"
        if success and frame is not None and getattr(frame, 'size', 0):
            return True, None
        time.sleep(CAMERA_FIRST_FRAME_DELAY)
    return False, f"opened but produced no frame in {CAMERA_FIRST_FRAME_TRIES} reads"


def _open_camera(index, backend=None):
    """Open one device with one backend. Returns the capture (possibly closed)."""
    if backend is None:
        backend = _camera_backends()[0][0]
    cap = cv2.VideoCapture(index, backend)
    if cap.isOpened():
        _tune_capture(cap)
    return cap


def _windows_camera_names():
    """Friendly camera names from Windows PnP, via PowerShell (no new deps).

    OpenCV exposes no device-name API, so the correlation to indices is
    positional and therefore HEURISTIC: the Nth camera PnP entity is assumed
    to be OpenCV index N. That holds on the usual one-or-two-camera kiosk but
    can be wrong when virtual cameras, IR sensors or non-UVC 'Image' devices
    are installed. Selecting by index is always exact; selecting by name
    depends on this guess.
    """
    script = (
        "Get-CimInstance Win32_PnPEntity -ErrorAction SilentlyContinue | "
        "Where-Object { $_.PNPClass -eq 'Camera' -or $_.PNPClass -eq 'Image' } | "
        "ForEach-Object { $_.Name }"
    )
    try:
        completed = subprocess.run(
            ["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
            capture_output=True,
            text=True,
            timeout=10,
            creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0),
        )
    except Exception as exc:
        print(f"Camera name enumeration failed: {exc}")
        return []
    return [line.strip() for line in (completed.stdout or "").splitlines() if line.strip()]


def _camera_names():
    global _camera_name_cache, _camera_name_cache_at
    now = time.monotonic()
    if now - _camera_name_cache_at < CAMERA_NAME_CACHE_SECONDS:
        return _camera_name_cache
    names = _windows_camera_names() if sys.platform == 'win32' else []
    _camera_name_cache = names
    _camera_name_cache_at = now
    return names


def _match_device_name(wanted):
    """Resolve a human-typed camera name to device values.

    Same matching rules as the microphone picker in audio-select.ts: exact
    case-insensitive first, then a unique substring; ambiguity resolves to
    nothing rather than a coin flip.
    """
    lower = wanted.strip().lower()
    if not lower:
        return []
    if sys.platform.startswith('linux'):
        pairs = [(entry.get("name", ""), entry.get("id")) for entry in _enumerate_cameras()]
    else:
        pairs = [(name, index) for index, name in enumerate(_camera_names())]
    exact = [value for name, value in pairs if name.lower() == lower]
    if len(exact) == 1:
        return exact
    partial = [value for name, value in pairs if lower in name.lower()]
    if len(partial) == 1:
        return partial
    return []


def _camera_candidates(device):
    """Device values to try, in order, for the currently configured selection."""
    text = str(device).strip()
    if text.lower() == 'auto':
        return list(range(CAMERA_SCAN_MAX_INDEX + 1))
    if text.isdigit():
        return [int(text)]
    if text.startswith('/dev/') or os.path.sep in text:
        return [text]
    return _match_device_name(text)


def _acquire_camera():
    """Try every candidate device against every backend until one yields a frame.

    Returns (capture, device, backend_label, attempt_log). `capture` is None if
    nothing worked; the log names every backend/index pair that was tried and
    why it failed, so the operator is not left guessing.
    """
    attempts = []
    candidates = _camera_candidates(current_camera_index)
    if not candidates:
        known = ", ".join(_camera_names()) or "(none reported by the OS)"
        attempts.append(f"no camera matches name {current_camera_index!r}; OS reports: {known}")
        return None, None, None, attempts

    for device in candidates:
        for backend, label in _camera_backends():
            cap = None
            try:
                cap = cv2.VideoCapture(device, backend)
            except Exception as exc:
                attempts.append(f"{label}:{device} VideoCapture() raised {exc}")
                continue
            if not cap.isOpened():
                attempts.append(f"{label}:{device} isOpened()=False")
                cap.release()
                continue
            _tune_capture(cap)
            ok, reason = _capture_yields_frame(cap)
            if ok:
                return cap, device, label, attempts
            attempts.append(f"{label}:{device} {reason}")
            cap.release()
    return None, None, None, attempts


def get_camera():
    global camera, active_camera_device, active_camera_backend
    global camera_open_attempts, camera_open_error, camera_open_flag
    if camera is not None and camera.isOpened():
        camera_open_flag = True
        return camera

    cap, device, backend, attempts = _acquire_camera()
    camera_open_attempts = attempts
    if cap is None:
        camera = None
        camera_open_flag = False
        active_camera_device = None
        active_camera_backend = None
        camera_open_error = "; ".join(attempts) or "no candidate devices"
        return None

    camera = cap
    camera_open_flag = True
    active_camera_device = device
    active_camera_backend = backend
    camera_open_error = None
    for failure in attempts:
        print(f"Camera probe skipped {failure}")
    print(f"Camera opened: device={device} backend={backend}")
    return camera


def _report_camera_unavailable():
    backends = "/".join(label for _, label in _camera_backends())
    candidates = _camera_candidates(current_camera_index)
    listed = ", ".join(str(c) for c in candidates) or "(none)"
    print("=" * 60)
    print("CAMERA UNAVAILABLE - giving up after "
          f"{CAMERA_OPEN_MAX_ATTEMPTS} attempts")
    print(f"  configured device : {current_camera_index}")
    print(f"  backends tried    : {backends}")
    print(f"  devices tried     : {listed}")
    for failure in camera_open_attempts or ["(no candidate devices to try)"]:
        print(f"  - {failure}")
    print("  OS-reported cameras: " + (", ".join(_camera_names()) or "(none)"))
    print("  Fix: plug in / free the camera, then POST /api/camera/switch "
          "(or restart). Set ROBOPARK_CAMERA_DEVICE to a name or index; "
          "GET /api/media/inventory lists what this machine can see.")
    print("=" * 60)

# Global variables
latest_detections = []
lock = threading.Lock()
caption_mode_enabled = False
motion_detection_active = False
motion_detected_state = False
last_motion_time = 0
motion_frame_buffer = None
webhook_url = None
last_webhook_send_time = 0
webhook_send_interval = 0.5

# Vision-confirm (Ollama Cloud) config — stage 2 of the motion pipeline.
# Motion detection (frame-diff) is a cheap pre-filter; before we fire the
# session webhook we ask a vision model to confirm a person is actually in
# frame, to cut down on false triggers from pets/shadows/wind.
OLLAMA_CLOUD_API_KEY = os.getenv("OLLAMA_CLOUD_API_KEY", "")
OLLAMA_CLOUD_VISION_MODEL = os.getenv("OLLAMA_CLOUD_VISION_MODEL", "gemma3:27b")
OLLAMA_CLOUD_VISION_URL = "https://ollama.com/v1/chat/completions"
VISION_CONFIRM_TIMEOUT = 8

# Simple object detection using OpenCV DNN (MobileNet SSD)
try:
    # Load pre-trained MobileNet SSD model
    net = cv2.dnn.readNetFromCaffe(
        'deploy.prototxt',
        'mobilenet_iter_73000.caffemodel'
    )
    DETECTION_AVAILABLE = True
    print("Object detection model loaded")
except:
    DETECTION_AVAILABLE = False
    print("Object detection model not found - running without detection")

# COCO class labels
CLASSES = ["background", "aeroplane", "bicycle", "bird", "boat",
    "bottle", "bus", "car", "cat", "chair", "cow", "diningtable",
    "dog", "horse", "motorbike", "person", "pottedplant", "sheep",
    "sofa", "train", "tvmonitor"]

def detect_objects(frame, conf_threshold=0.5):
    """Detect objects using OpenCV DNN"""
    if not DETECTION_AVAILABLE:
        return frame, []

    (h, w) = frame.shape[:2]
    blob = cv2.dnn.blobFromImage(cv2.resize(frame, (300, 300)), 0.007843, (300, 300), 127.5)
    net.setInput(blob)
    detections_dnn = net.forward()

    detected_objects = []

    for i in range(detections_dnn.shape[2]):
        confidence = detections_dnn[0, 0, i, 2]

        if confidence > conf_threshold:
            idx = int(detections_dnn[0, 0, i, 1])
            if idx >= len(CLASSES):
                continue

            box = detections_dnn[0, 0, i, 3:7] * np.array([w, h, w, h])
            (startX, startY, endX, endY) = box.astype("int")

            label = CLASSES[idx]

            # Draw bounding box
            cv2.rectangle(frame, (startX, startY), (endX, endY), (0, 255, 0), 2)

            # Draw label with confidence
            text = f"{label}: {confidence*100:.1f}%"
            y = startY - 15 if startY - 15 > 15 else startY + 15
            cv2.putText(frame, text, (startX, y), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 2)

            detected_objects.append({
                "label": label,
                "confidence": float(confidence),
                "bbox": [int(startX), int(startY), int(endX), int(endY)],
                "is_focus": False
            })

    return frame, detected_objects

def confirm_person_present(frame):
    """Ask an Ollama Cloud vision model whether a person is visible in `frame`.

    This is stage 2 of the motion pipeline: motion detection (frame-diff) is a
    cheap pre-filter, and this confirms a person is actually present before we
    fire the session webhook — cuts down on false triggers from pets, shadows,
    wind, etc.

    Fails OPEN (returns True) on any error — missing key, network failure,
    timeout, bad response — since the pre-existing motion-only trigger is the
    fallback behavior and a vision-API outage shouldn't silently disable the
    whole trigger system.
    """
    if not OLLAMA_CLOUD_API_KEY:
        # No key configured: skip the check entirely, preserve motion-only
        # behavior as the zero-config default. Caller logs this case.
        return True

    try:
        _, buffer = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, 80])
        img_base64 = base64.b64encode(buffer).decode('utf-8')

        payload = {
            "model": OLLAMA_CLOUD_VISION_MODEL,
            "messages": [
                {
                    "role": "user",
                    "content": [
                        {
                            "type": "text",
                            "text": "Is there a person clearly visible in this image? Answer with only YES or NO."
                        },
                        {
                            "type": "image_url",
                            "image_url": {"url": f"data:image/jpeg;base64,{img_base64}"}
                        }
                    ]
                }
            ],
            "stream": False
        }

        response = requests.post(
            OLLAMA_CLOUD_VISION_URL,
            json=payload,
            headers={
                "Authorization": f"Bearer {OLLAMA_CLOUD_API_KEY}",
                "Content-Type": "application/json"
            },
            timeout=VISION_CONFIRM_TIMEOUT
        )
        response.raise_for_status()

        answer = response.json()["choices"][0]["message"]["content"].strip()
        return answer.upper().startswith("YES")
    except Exception as e:
        print(f"WARNING: vision-confirm error, failing open (treating as person present): {e}")
        return True


def send_webhook(frame_data):
    """Send frame to webhook URL"""
    global webhook_url, last_webhook_send_time

    if not webhook_url:
        return

    current_time = time.time()
    if current_time - last_webhook_send_time < webhook_send_interval:
        return

    try:
        _, buffer = cv2.imencode('.jpg', frame_data)
        img_base64 = base64.b64encode(buffer).decode('utf-8')

        payload = {
            'timestamp': time.strftime('%Y-%m-%dT%H:%M:%S'),
            'image': img_base64,
            'format': 'jpeg',
            'encoding': 'base64'
        }

        def send_async():
            try:
                response = requests.post(webhook_url, json=payload, headers={'Content-Type': 'application/json'}, timeout=5)
                if response.status_code == 200:
                    print(f"Webhook sent successfully")
            except Exception as e:
                print(f"Webhook error: {e}")

        thread = threading.Thread(target=send_async, daemon=True)
        thread.start()
        last_webhook_send_time = current_time
    except Exception as e:
        print(f"Error preparing webhook: {e}")

def camera_worker():
    global latest_detections, motion_detection_active, motion_detected_state
    global last_motion_time, motion_frame_buffer
    global latest_frame_bytes, latest_frame_sequence
    global camera_read_started_at, camera_last_frame_at, camera

    global camera_worker_started

    prev_gray = None
    failed_opens = 0

    while True:
        with camera_lock:
            cam = get_camera()
        if cam is None or not cam.isOpened():
            failed_opens += 1
            if failed_opens >= CAMERA_OPEN_MAX_ATTEMPTS:
                # Bounded, not infinite: a doomed 1/sec retry loop buries the
                # real error. Clearing the started flag lets an explicit
                # /api/camera/switch or a new /video_feed request try again.
                _report_camera_unavailable()
                with frame_condition:
                    camera_worker_started = False
                return
            time.sleep(1)
            continue
        failed_opens = 0

        camera_read_started_at = time.monotonic()
        try:
            # This is the only camera reader. Do not hold camera_lock here:
            # the watchdog must be able to release a wedged V4L2 handle.
            success, frame = cam.read()
        except Exception as exc:
            print(f"Camera read error: {exc}")
            success, frame = False, None
        finally:
            camera_read_started_at = 0.0
        if not success:
            with camera_lock:
                if camera is cam:
                    camera.release()
                    camera = None
                    camera_open_flag = False
            time.sleep(0.1)
            continue
        camera_last_frame_at = time.monotonic()

        # Run object detection
        processed_frame, detections = detect_objects(frame, conf_threshold=0.5)

        with lock:
            latest_detections = detections

        # Motion detection logic
        if motion_detection_active:
            gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
            gray = cv2.GaussianBlur(gray, (21, 21), 0)

            if prev_gray is not None:
                frame_delta = cv2.absdiff(prev_gray, gray)
                thresh = cv2.threshold(frame_delta, 25, 255, cv2.THRESH_BINARY)[1]
                thresh = cv2.dilate(thresh, None, iterations=2)
                contours, _ = cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

                motion_detected = False
                for contour in contours:
                    if cv2.contourArea(contour) >= 500:
                        motion_detected = True
                        (x, y, w, h) = cv2.boundingRect(contour)
                        cv2.rectangle(processed_frame, (x, y), (x + w, y + h), (0, 0, 255), 2)
                        break

                if motion_detected:
                    motion_detected_state = True
                    last_motion_time = time.time()
                    motion_frame_buffer = processed_frame.copy()
                    print(f"Motion detected!")

                    # Only run the (network-bound) vision-confirm + webhook
                    # once per motion "event" — reuse the same debounce timer
                    # send_webhook() itself uses, rather than calling the
                    # vision API on every single frame while motion continues.
                    if webhook_url and (time.time() - last_webhook_send_time >= webhook_send_interval):
                        if not OLLAMA_CLOUD_API_KEY:
                            send_webhook(processed_frame)
                        elif confirm_person_present(processed_frame):
                            send_webhook(processed_frame)
                        else:
                            print("Motion event suppressed: vision-confirm found no person present")
                else:
                    motion_detected_state = False

            prev_gray = gray

        ret, buffer = cv2.imencode('.jpg', processed_frame, [cv2.IMWRITE_JPEG_QUALITY, 80])
        if not ret:
            continue
        with frame_condition:
            latest_frame_bytes = buffer.tobytes()
            latest_frame_sequence += 1
            frame_condition.notify_all()


def _ensure_camera_worker():
    global camera_worker_started, camera_watchdog_started
    with frame_condition:
        if camera_worker_started:
            return
        camera_worker_started = True
        start_watchdog = not camera_watchdog_started
        camera_watchdog_started = True
    threading.Thread(target=camera_worker, name='robovision-camera', daemon=True).start()
    if start_watchdog:
        # The worker can restart after a bounded open failure; the watchdog is
        # stateless and must not be duplicated each time it does.
        threading.Thread(target=_camera_watchdog, name='robovision-camera-watchdog', daemon=True).start()


def _camera_watchdog():
    global camera, camera_read_started_at
    while True:
        time.sleep(2.0)
        started = camera_read_started_at
        if not started or time.monotonic() - started < 8.0:
            continue
        print("Camera read stalled for 8s; releasing V4L2 handle")
        with camera_lock:
            if camera is not None:
                try:
                    camera.release()
                except Exception:
                    pass
                camera = None
                camera_open_flag = False
        camera_read_started_at = 0.0


def generate_frames():
    _ensure_camera_worker()
    sequence = -1
    while True:
        with frame_condition:
            frame_condition.wait_for(
                lambda: latest_frame_bytes is not None and latest_frame_sequence != sequence,
                timeout=5.0,
            )
            if latest_frame_bytes is None or latest_frame_sequence == sequence:
                continue
            frame_bytes = latest_frame_bytes
            sequence = latest_frame_sequence

        yield (b'--frame\r\n'
               b'Content-Type: image/jpeg\r\n\r\n' + frame_bytes + b'\r\n')

@app.route('/')
def index():
    return jsonify({"status": "ok", "message": "RoboVision Pi Server", "version": "1.0"})

@app.route('/video_feed')
def video_feed():
    return Response(generate_frames(), mimetype='multipart/x-mixed-replace; boundary=frame')

@app.route('/api/detections')
def get_detections():
    with lock:
        return jsonify(latest_detections)


@app.route('/api/camera/status')
def camera_status():
    age = None if not camera_last_frame_at else round(time.monotonic() - camera_last_frame_at, 3)
    return jsonify({
        "device": str(current_camera_index),
        "active_device": active_camera_device,
        "active_backend": active_camera_backend,
        "error": camera_open_error,
        "attempts": camera_open_attempts,
        # Deliberately the cached flag, not camera.isOpened(): see camera_open_flag.
        "open": bool(camera_open_flag),
        "worker_started": camera_worker_started,
        "frame_sequence": latest_frame_sequence,
        "last_frame_age_seconds": age,
        "read_stalled": bool(camera_read_started_at and time.monotonic() - camera_read_started_at >= 8.0),
    })

@app.route('/api/caption')
def get_caption():
    return jsonify({"caption": "Awaiting caption..."})

@app.route('/api/caption_mode', methods=['GET', 'POST'])
def caption_mode():
    global caption_mode_enabled
    if request.method == 'GET':
        return jsonify({"enabled": caption_mode_enabled})
    data = request.json or {}
    caption_mode_enabled = bool(data.get('enabled', False))
    return jsonify({"enabled": caption_mode_enabled})

def _enumerate_cameras():
    """List cameras with friendly names. Cached: probing reopens devices."""
    global _camera_inventory_cache, _camera_inventory_cache_at
    now = time.monotonic()
    if now - _camera_inventory_cache_at < CAMERA_INVENTORY_CACHE_SECONDS:
        return _camera_inventory_cache

    available = []
    if sys.platform.startswith('linux'):
        # Query capabilities without starting a stream. Opening every V4L2
        # node through OpenCV also opens metadata/output nodes and can contend
        # with the camera stream already owned by RoboVision.
        import fcntl
        vidioc_querycap = 0x80685600
        video_capture = 0x00000001
        video_capture_mplane = 0x00001000
        device_caps_flag = 0x80000000
        for candidate in sorted(glob.glob('/dev/video*')):
            fd = None
            try:
                fd = os.open(candidate, os.O_RDONLY | os.O_NONBLOCK)
                capability = bytearray(104)
                fcntl.ioctl(fd, vidioc_querycap, capability, True)
                capabilities = struct.unpack_from('=I', capability, 84)[0]
                device_caps = struct.unpack_from('=I', capability, 88)[0]
                effective = device_caps if capabilities & device_caps_flag else capabilities
                if not effective & (video_capture | video_capture_mplane):
                    continue
                card = bytes(capability[16:48]).split(b'\0', 1)[0].decode('utf-8', 'replace')
                available.append({
                    "index": candidate,
                    "id": candidate,
                    "name": card or f"Camera {candidate}",
                    "backend": "v4l2",
                })
            except (OSError, ValueError):
                continue
            finally:
                if fd is not None:
                    os.close(fd)
    else:
        names = _camera_names()
        for candidate in range(CAMERA_SCAN_MAX_INDEX + 1):
            # Positional name correlation — see _windows_camera_names().
            name = names[candidate] if candidate < len(names) else f"Camera {candidate}"
            if camera is not None and camera.isOpened() and candidate == active_camera_device:
                # Never reopen the device the streaming worker owns.
                available.append({"index": candidate, "id": str(candidate), "name": name,
                                  "backend": active_camera_backend, "active": True})
                continue
            for backend, label in _camera_backends():
                cap = cv2.VideoCapture(candidate, backend)
                opened = cap.isOpened()
                cap.release()
                if opened:
                    available.append({"index": candidate, "id": str(candidate), "name": name,
                                      "backend": label, "active": False})
                    break

    _camera_inventory_cache = available
    _camera_inventory_cache_at = now
    return available


@app.route('/api/cameras', methods=['GET'])
def list_cameras():
    return jsonify({
        "cameras": _enumerate_cameras(),
        "current": current_camera_index,
        "active_device": active_camera_device,
        "active_backend": active_camera_backend,
    })

def _reselect_camera(value):
    """Point the worker at a new device (index, name or path) and revive it."""
    global camera, current_camera_index, camera_open_error, camera_open_attempts
    global camera_open_flag
    with camera_lock:
        if camera:
            camera.release()
        current_camera_index = _normalize_camera_device(value)
        camera = None
        camera_open_flag = False
        camera_open_error = None
        camera_open_attempts = []
    # The worker exits after a bounded open failure; an explicit selection is
    # the operator saying "try again".
    _ensure_camera_worker()


@app.route('/api/camera/switch', methods=['POST'])
def switch_camera():
    data = request.json or {}
    new_index = data.get('device', data.get('index', 0))
    if isinstance(new_index, str) and new_index.isdigit():
        new_index = int(new_index)

    _reselect_camera(new_index)
    return jsonify({"status": "ok", "camera_index": current_camera_index})


def _audio_inventory():
    """Adapt RoboVision's existing audio_server_pi /devices response."""
    try:
        response = requests.get(f"{ROBOVISION_AUDIO_URL.rstrip('/')}/devices", timeout=0.8)
        response.raise_for_status()
        payload = response.json()
        inputs, outputs = [], []
        for device in payload.get("devices", []):
            item = {
                "id": str(device["index"]),
                "name": str(device.get("name", f"Audio device {device['index']}")),
                "backend": "robovision_audio",
                "sample_rate": device.get("default_samplerate"),
            }
            if device.get("max_input_channels", 0) > 0:
                inputs.append(item.copy())
            if device.get("max_output_channels", 0) > 0:
                outputs.append(item.copy())
        return inputs, outputs, {
            "input": payload.get("bluetooth_input"),
            "output": payload.get("bluetooth_output"),
            "online": True,
        }
    except Exception as exc:
        return [], [], {"online": False, "error": str(exc)}


def _enumerate_cameras_async():
    """Whatever the last probe found, refreshed in the background.

    `_enumerate_cameras()` opens devices through OpenCV, so calling it from a
    request handler hands the caller a request that blocks for as long as the
    driver does -- unbounded on a wedged Windows capture. This endpoint is the
    one an operator (or `robopark start`) reaches for precisely when the camera
    is misbehaving, so it must answer immediately even if the answer is stale
    or empty. The probe runs on its own thread and lands in the cache for the
    next call.
    """
    global _camera_inventory_probing
    now = time.monotonic()
    fresh = now - _camera_inventory_cache_at < CAMERA_INVENTORY_CACHE_SECONDS
    if fresh:
        return _camera_inventory_cache, False
    if not _camera_inventory_probing:
        _camera_inventory_probing = True

        def probe():
            global _camera_inventory_probing
            try:
                _enumerate_cameras()
            except Exception as exc:
                print(f"Camera inventory probe failed: {exc}")
            finally:
                _camera_inventory_probing = False

        threading.Thread(target=probe, daemon=True).start()
    return _camera_inventory_cache, True


@app.route('/api/media/inventory', methods=['GET'])
def media_inventory():
    cameras, probing = _enumerate_cameras_async()
    inputs, outputs, audio_state = _audio_inventory()
    return jsonify({
        "video": [{"id": "auto", "name": "Auto detect"}, {"id": "none", "name": "Disable camera"}] + cameras,
        "video_state": {
            "active_device": active_camera_device,
            "active_backend": active_camera_backend,
            "open": bool(camera_open_flag),
            # True when the OpenCV probe is still running, so a caller can tell
            # "no cameras found" apart from "not finished looking yet".
            "probing": probing,
            "error": camera_open_error,
            "attempts": camera_open_attempts,
            # Names come from the OS, indices from OpenCV; the pairing is
            # positional and best-effort (see _windows_camera_names()). The raw
            # OS list is exposed too so an operator can see when it is longer
            # than the list of indices OpenCV can actually open.
            "name_correlation": "heuristic" if sys.platform == 'win32' else "exact",
            "os_reported_names": _camera_names(),
        },
        "audio_input": inputs,
        "audio_output": outputs,
        "selected": {
            "video_device": str(current_camera_index),
            "audio_device": str(audio_state.get("input") if audio_state.get("input") is not None else audio_input_device),
            "audio_output_device": str(audio_state.get("output") if audio_state.get("output") is not None else audio_output_device),
        },
        "audio_server": audio_state,
        "source": "robovision_pi",
    })


@app.route('/api/media/config', methods=['GET', 'POST'])
def media_config():
    global audio_input_device, audio_output_device, camera, current_camera_index
    if request.method == 'POST':
        data = request.json or {}
        if "video_device" in data:
            value = str(data["video_device"])
            if value not in ("", "none"):
                # "auto" is a legitimate selection now — it means scan.
                _reselect_camera(value)
        if "audio_device" in data:
            audio_input_device = str(data["audio_device"])
        if "audio_output_device" in data:
            audio_output_device = str(data["audio_output_device"])
        if "audio_device" in data or "audio_output_device" in data:
            # audio_server_pi.py is RoboVision's authoritative selector.
            # It accepts its original sounddevice indices via input/output.
            payload = {}
            if "audio_device" in data:
                payload["input"] = int(audio_input_device) if audio_input_device.isdigit() else audio_input_device
            if "audio_output_device" in data:
                payload["output"] = int(audio_output_device) if audio_output_device.isdigit() else audio_output_device
            try:
                requests.post(f"{ROBOVISION_AUDIO_URL.rstrip('/')}/set-device", json=payload, timeout=0.8).raise_for_status()
            except Exception:
                pass
    return jsonify({"video_device": str(current_camera_index), "audio_device": audio_input_device, "audio_output_device": audio_output_device, "source": "robovision_pi"})

@app.route('/api/motion/status', methods=['GET'])
def motion_status():
    global motion_detection_active, motion_detected_state, last_motion_time
    return jsonify({
        "active": motion_detection_active,
        "motion_detected": motion_detected_state,
        "last_motion": last_motion_time,
        "time_since_motion": time.time() - last_motion_time if last_motion_time > 0 else None
    })

@app.route('/api/motion/toggle', methods=['POST'])
def motion_toggle():
    global motion_detection_active
    data = request.json or {}
    motion_detection_active = bool(data.get('active', False))
    return jsonify({"status": "success", "active": motion_detection_active})

@app.route('/api/motion/snapshot', methods=['GET'])
def motion_snapshot():
    global motion_frame_buffer
    if motion_frame_buffer is not None:
        _, buffer = cv2.imencode('.jpg', motion_frame_buffer, [cv2.IMWRITE_JPEG_QUALITY, 85])
        img_base64 = base64.b64encode(buffer).decode('utf-8')
        return jsonify({
            "image": img_base64,
            "timestamp": time.time()
        })
    return jsonify({"error": "No frame available"}), 404

@app.route('/api/motion/webhook', methods=['GET', 'POST'])
def motion_webhook():
    global webhook_url

    if request.method == 'GET':
        return jsonify({
            "webhook_url": webhook_url or "",
            "configured": webhook_url is not None and len(webhook_url) > 0
        })

    data = request.json or {}
    new_url = data.get('url', '').strip()

    if new_url:
        webhook_url = new_url
        return jsonify({
            "status": "success",
            "message": "Webhook URL configured",
            "webhook_url": webhook_url
        })
    else:
        webhook_url = None
        return jsonify({
            "status": "success",
            "message": "Webhook URL cleared",
            "webhook_url": None
        })

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description="RoboVision — camera/motion detection server")
    parser.add_argument("--port", type=int, default=int(os.getenv("VISION_PORT", "5000")))
    parser.add_argument("--motion-webhook-url", default=os.getenv("MOTION_WEBHOOK_URL", ""),
                         help="where to POST a snapshot when motion is detected, e.g. http://localhost:5057/")
    parser.add_argument("--motion-active", action="store_true",
                         default=os.getenv("MOTION_ACTIVE", "").lower() in ("1", "true", "yes"),
                         help="arm motion detection immediately on startup (no manual /api/motion/toggle call needed)")
    args = parser.parse_args()

    if args.motion_webhook_url:
        webhook_url = args.motion_webhook_url
    if args.motion_active:
        motion_detection_active = True

    print("=" * 60)
    print("RoboVision - Raspberry Pi Vision Server (Minimal)")
    print("=" * 60)
    print(f"Starting Flask server on http://0.0.0.0:{args.port}")
    if webhook_url:
        print(f"Motion webhook: {webhook_url}")
    print(f"Motion detection: {'ARMED' if motion_detection_active else 'off (POST /api/motion/toggle to arm)'}")
    print("=" * 60)
    # Start the single camera owner at boot. Production motion and MJPEG
    # readiness must not depend on an operator opening the dashboard first.
    _ensure_camera_worker()
    app.run(host='0.0.0.0', port=args.port, debug=False, threaded=True)
