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

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"
        return 0
    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_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

def _open_camera(index):
    # On Windows, cv2.VideoCapture(index) with no explicit backend can
    # auto-probe into an unrelated backend (e.g. Orbbec's "obsensor") that
    # fails with "Camera index out of range" for a perfectly normal UVC
    # webcam — isOpened() then stays False forever and callers retry in an
    # infinite doomed loop. Force DirectShow, the backend that actually
    # enumerates standard Windows webcams.
    if sys.platform == 'win32':
        return cv2.VideoCapture(index, cv2.CAP_DSHOW)
    if sys.platform.startswith('linux'):
        cap = cv2.VideoCapture(index, cv2.CAP_V4L2)
        if cap.isOpened():
            cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*'MJPG'))
            cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
            cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
            cap.set(cv2.CAP_PROP_FPS, 15)
        return cap
    return cv2.VideoCapture(index)


def get_camera():
    global camera, current_camera_index
    if camera is None or not camera.isOpened():
        camera = _open_camera(current_camera_index)
        camera.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
        camera.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
        print(f"Camera {current_camera_index} initialized")
    return camera

# 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

    prev_gray = None

    while True:
        with camera_lock:
            cam = get_camera()
        if cam is None or not cam.isOpened():
            time.sleep(1)
            continue

        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
            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
    with frame_condition:
        if camera_worker_started:
            return
        camera_worker_started = True
    threading.Thread(target=camera_worker, name='robovision-camera', daemon=True).start()
    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_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),
        "open": bool(camera is not None and camera.isOpened()),
        "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})

@app.route('/api/cameras', methods=['GET'])
def list_cameras():
    global _camera_inventory_cache, _camera_inventory_cache_at
    now = time.monotonic()
    if now - _camera_inventory_cache_at < CAMERA_INVENTORY_CACHE_SECONDS:
        return jsonify({"cameras": _camera_inventory_cache, "current": current_camera_index})

    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:
        for candidate in range(4):
            cap = _open_camera(candidate)
            if cap.isOpened():
                available.append({"index": candidate, "id": str(candidate), "name": f"Camera {candidate}", "backend": "dshow"})
            cap.release()

    _camera_inventory_cache = available
    _camera_inventory_cache_at = now
    return jsonify({"cameras": available, "current": current_camera_index})

@app.route('/api/camera/switch', methods=['POST'])
def switch_camera():
    global camera, current_camera_index
    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)

    with camera_lock:
        if camera:
            camera.release()
        current_camera_index = _normalize_camera_device(new_index)
        camera = None

    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)}


@app.route('/api/media/inventory', methods=['GET'])
def media_inventory():
    cameras = list_cameras().get_json().get("cameras", [])
    inputs, outputs, audio_state = _audio_inventory()
    return jsonify({
        "video": [{"id": "auto", "name": "Auto detect"}, {"id": "none", "name": "Disable camera"}] + cameras,
        "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 ("", "auto", "none"):
                switch_camera_value = value
                if camera:
                    camera.release()
                current_camera_index = _normalize_camera_device(switch_camera_value)
                camera = None
        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)
