"""
HeatDebt - Cumulative Heat Exposure Tracker (Pi 5 controller, TMP-only build)

Tracks accumulated heat exposure like a fitness tracker accumulates
steps/rings, but for heat load. This build has no humidity sensor and no
PIR: temperature comes from a bare TMP-style sensor on the UNO Q, and
presence is detected entirely by the Pi 5 camera (EyesPi-style OpenCV
background subtraction) running continuously.

Run: python heatdebt.py
"""

import csv
import json
import os
import threading
import time
from datetime import datetime

import requests

import config

try:
    from gpiozero import Buzzer
    buzzer = Buzzer(config.BUZZER_GPIO_PIN)
except Exception:
    buzzer = None
    print("[warn] gpiozero/Buzzer not available - buzzer alerts disabled")

try:
    from twilio.rest import Client as TwilioClient
    if config.TWILIO_ACCOUNT_SID and config.TWILIO_AUTH_TOKEN:
        twilio_client = TwilioClient(config.TWILIO_ACCOUNT_SID, config.TWILIO_AUTH_TOKEN)
    else:
        twilio_client = None
        print("[warn] Twilio credentials not set - SMS alerts disabled")
except Exception:
    twilio_client = None
    print("[warn] Twilio library not available - SMS alerts disabled")

if config.CAMERA_ENABLED:
    try:
        import cv2
        from picamera2 import Picamera2
    except Exception:
        config.CAMERA_ENABLED = False
        print("[warn] camera libs not available - presence detection disabled, "
              "exposure model will not run without it")


# ---------- shared state ----------
state_lock = threading.Lock()
latest = {
    "temp_c": None,
    "last_update": None,
}

exposure_score = 0.0
alert_level = "OK"                 # OK -> WARNING -> BREAK -> DANGER
last_motion_time = 0.0             # last time the camera saw motion/presence
last_score_time = time.time()


def send_sms(body: str):
    if not twilio_client or not config.OWNER_PHONE_NUMBER:
        print(f"[sms-skip] {body}")
        return
    try:
        twilio_client.messages.create(
            body=body,
            from_=config.TWILIO_FROM_NUMBER,
            to=config.OWNER_PHONE_NUMBER,
        )
        print(f"[sms-sent] {body}")
    except Exception as e:
        print(f"[sms-error] {e}")


def log_row(row: dict):
    is_new = not os.path.exists(config.LOG_CSV_PATH)
    with open(config.LOG_CSV_PATH, "a", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=list(row.keys()))
        if is_new:
            writer.writeheader()
        writer.writerow(row)


# ---------- continuous camera presence thread ----------
def camera_presence_loop():
    """Runs for the life of the program, polling the camera for motion and
    updating last_motion_time whenever someone is present. This is the ONLY
    presence signal in this build (no PIR)."""
    global last_motion_time

    if not config.CAMERA_ENABLED:
        print("[warn] camera disabled - presence will never register, "
              "exposure score will stay at 0")
        return

    picam2 = Picamera2()
    picam2.configure(picam2.create_video_configuration(main={"size": (640, 480)}))
    picam2.start()
    bg_sub = cv2.createBackgroundSubtractorMOG2(
        history=config.BG_SUBTRACTOR_HISTORY, detectShadows=False
    )

    while True:
        frame = picam2.capture_array()
        gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
        mask = bg_sub.apply(gray)
        if cv2.countNonZero(mask) > config.MOTION_PIXEL_THRESHOLD:
            last_motion_time = time.time()
        time.sleep(config.CAMERA_POLL_INTERVAL_SECONDS)


# ---------- exposure score model ----------
def update_exposure_score():
    """Integrates heat exposure (or recovery) since the last call. Called on
    every sensor tick so the score updates continuously, not just on alerts."""
    global exposure_score, last_score_time

    now = time.time()
    dt_minutes = (now - last_score_time) / 60.0
    last_score_time = now

    with state_lock:
        temp_c = latest["temp_c"]

    if temp_c is None:
        return

    present = (now - last_motion_time) <= config.PRESENCE_TIMEOUT_SECONDS

    if present and temp_c > config.BASELINE_TEMP_C:
        excess = temp_c - config.BASELINE_TEMP_C
        multiplier = config.DANGER_MULTIPLIER if temp_c >= config.DANGER_TEMP_C else 1.0
        exposure_score += excess * config.ACCUM_RATE * multiplier * dt_minutes
    elif not present:
        exposure_score -= config.DECAY_RATE * dt_minutes
        exposure_score = max(exposure_score, 0.0)
    # else: present but under baseline -> score holds steady (safe zone)

    evaluate_alerts(temp_c, present)


# ---------- alert state machine ----------
def evaluate_alerts(temp_c, present):
    global alert_level

    new_level = "OK"
    if exposure_score >= config.DANGER_SCORE:
        new_level = "DANGER"
    elif exposure_score >= config.BREAK_SCORE:
        new_level = "BREAK"
    elif exposure_score >= config.WARNING_SCORE:
        new_level = "WARNING"

    if new_level != alert_level:
        alert_level = new_level
        print(f"[alert] level -> {alert_level} (score={exposure_score:.1f}, temp={temp_c:.1f}C, present={present})")

        if alert_level == "WARNING":
            if buzzer:
                buzzer.beep(on_time=0.2, off_time=0.2, n=2)

        elif alert_level == "BREAK":
            if buzzer:
                buzzer.beep(on_time=0.3, off_time=0.3, n=4)
            send_sms(
                f"HeatDebt: exposure score {exposure_score:.0f} - time for a break "
                f"in the shade/AC. Temp is {temp_c:.1f}C."
            )

        elif alert_level == "DANGER":
            if buzzer:
                buzzer.on()
            send_sms(
                f"HeatDebt DANGER: exposure score {exposure_score:.0f}, temp "
                f"{temp_c:.1f}C. Get to a cool space now."
            )

        elif alert_level == "OK":
            if buzzer:
                buzzer.off()


# ---------- HTTP polling thread (UNO Q App Lab WebUI endpoint) ----------
def temp_poller():
    url = f"http://{config.UNO_Q_HOST}:{config.UNO_Q_PORT}/temp"

    while True:
        try:
            resp = requests.get(url, timeout=3)
            resp.raise_for_status()
            data = resp.json()
        except Exception as e:
            print(f"[uno_q] request failed: {e}")
            time.sleep(config.UNO_Q_POLL_INTERVAL_SECONDS)
            continue

        with state_lock:
            latest["temp_c"] = data.get("temp_c")
            latest["last_update"] = datetime.now().isoformat(timespec="seconds")

        update_exposure_score()

        with state_lock:
            log_row({
                "timestamp": latest["last_update"],
                "temp_c": latest["temp_c"],
                "exposure_score": round(exposure_score, 1),
                "alert_level": alert_level,
            })

        time.sleep(config.UNO_Q_POLL_INTERVAL_SECONDS)


def print_status_loop():
    while True:
        time.sleep(10)
        with state_lock:
            if latest["temp_c"] is None:
                print("[status] waiting for sensor data...")
                continue
            present = (time.time() - last_motion_time) <= config.PRESENCE_TIMEOUT_SECONDS
            print(
                f"[status] {latest['last_update']} | "
                f"temp={latest['temp_c']}C present={present} "
                f"score={exposure_score:.1f} alert={alert_level}"
            )


if __name__ == "__main__":
    threading.Thread(target=temp_poller, daemon=True).start()
    threading.Thread(target=camera_presence_loop, daemon=True).start()
    print_status_loop()
