from __future__ import annotations

import random
import time
import threading
from pathlib import Path
from typing import Any

import numpy as np
import os

try:
    import requests as _requests
except ImportError:
    _requests = None

# URL of the PULLUP. API (Docker container). Override via PULLUP_API_URL env var.
API_BASE_URL: str = os.environ.get("PULLUP_API_URL", "http://localhost:8000")

try:
    import cv2
except ImportError:
    cv2 = None

try:
    import gradio as gr
except ImportError:
    gr = None

try:
    import joblib
except ImportError:
    joblib = None

try:
    import mediapipe as mp
    from mediapipe.tasks import python as mp_python
    from mediapipe.tasks.python import vision as mp_vision
except Exception:
    mp = None
    mp_python = None
    mp_vision = None

# ── RFID — try every common import path for Freenove / generic mfrc522 ────────
RFID_AVAILABLE = False
SimpleMFRC522 = None
try:
    from mfrc522 import SimpleMFRC522   # pip install mfrc522
    RFID_AVAILABLE = True
except Exception:
    pass

if not RFID_AVAILABLE:
    try:
        from RPi_MFRC522 import SimpleMFRC522   # alternate package name
        RFID_AVAILABLE = True
    except Exception:
        pass

if not RFID_AVAILABLE:
    try:
        # Freenove ships the library as a raw file — importable after adding its
        # directory to sys.path or copying MFRC522.py next to this script.
        import sys, importlib
        # Look for MFRC522.py in common Freenove locations
        for _candidate in [
            Path(__file__).resolve().parent / "MFRC522.py",
            Path(__file__).resolve().parent / "lib" / "MFRC522.py",
            Path("/home/pi/Freenove-Ultimate-Starter-Kit-for-Raspberry-Pi/Code/Python_GPIOZero/19.1_RFID/MFRC522.py"),
        ]:
            if _candidate.exists():
                sys.path.insert(0, str(_candidate.parent))
                break
        _mod = importlib.import_module("MFRC522")
        SimpleMFRC522 = getattr(_mod, "SimpleMFRC522", None)
        if SimpleMFRC522 is None:
            # Some Freenove versions only expose the raw MFRC522 class;
            # wrap it so the rest of the code sees the same interface.
            _MFRC522 = getattr(_mod, "MFRC522")
            class SimpleMFRC522:  # minimal shim
                READER = None
                def __init__(self):
                    self.READER = _MFRC522()
                def read_no_block(self):
                    uid = self.READER.MFRC522_Request(self.READER.PICC_REQIDL)
                    if uid[0] != self.READER.MI_OK:
                        return None, None
                    uid = self.READER.MFRC522_Anticoll()
                    if uid[0] != self.READER.MI_OK:
                        return None, None
                    tag_id = uid[1][0] << 24 | uid[1][1] << 16 | uid[1][2] << 8 | uid[1][3]
                    return tag_id, ""
                def read(self):         # blocking shim — not used
                    while True:
                        tag_id, text = self.read_no_block()
                        if tag_id:
                            return tag_id, text
                        time.sleep(0.1)
        RFID_AVAILABLE = True
    except Exception:
        pass

# ── Server-process guard ──────────────────────────────────────────────────────
# `gradio script.py` spawns TWO processes: a file-watcher and the real server.
# Only the server process has GRADIO_SERVER_PORT set. Hardware init must run
# only in the server process to avoid "GPIO busy" when both try to claim pins.
_IS_SERVER = (os.environ.get("GRADIO_SERVER_PORT") is not None
              or __name__ == "__main__")

# ── Pi 5 RPi.GPIO shim for mfrc522 ────────────────────────────────────────────
# Pi 5 (BCM2712/RP1) uses gpiochip0. rpi-lgpio's GPIO.setup() claims the pin
# via lgpio but then raises "Cannot determine SOC peripheral base address",
# leaving the pin locked. We replace it with a clean lgpio shim that also
# handles BOARD→BCM translation (MFRC522 defaults to BOARD mode, pin 22=BCM25).
class _LgpioShim:
    BCM = 11; BOARD = 10; OUT = 0; IN = 1; HIGH = 1; LOW = 0
    PUD_UP = 2; PUD_DOWN = 21
    _BOARD_TO_BCM = {
        3:2,  5:3,  7:4,  8:14, 10:15, 11:17, 12:18, 13:27,
        15:22, 16:23, 18:24, 19:10, 21:9,  22:25, 23:11,
        24:8,  26:7,  29:5,  31:6,  32:12, 33:13, 35:19,
        36:16, 37:26, 38:20, 40:21,
    }
    _h = None; _mode = None; _lg = None

    def _handle(self):
        if self._h is None:
            import lgpio as _lg_mod
            self._lg = _lg_mod
            self._h = _lg_mod.gpiochip_open(0)  # gpiochip0 = pinctrl-rp1 on Pi 5
        return self._h

    def _bcm(self, pin: int) -> int:
        return self._BOARD_TO_BCM.get(pin, pin) if self._mode == self.BOARD else pin

    def setwarnings(self, _): pass
    def getmode(self): return self._mode
    def setmode(self, m): self._mode = m

    def setup(self, pin, mode, pull_up_down=None, initial=-1):
        h = self._handle(); bcm = self._bcm(pin)
        if mode == self.OUT:
            self._lg.gpio_claim_output(h, bcm, max(0, initial))
        else:
            flags = (self._lg.SET_PULL_UP if pull_up_down == self.PUD_UP
                     else self._lg.SET_PULL_DOWN if pull_up_down == self.PUD_DOWN
                     else 0)
            self._lg.gpio_claim_input(h, bcm, flags)

    def output(self, pin, val):
        self._handle(); self._lg.gpio_write(self._h, self._bcm(pin), int(val))

    def input(self, pin):
        self._handle(); return self._lg.gpio_read(self._h, self._bcm(pin))

    def cleanup(self, *_):
        if self._h is not None:
            self._lg.gpiochip_close(self._h)
            self._h = None

# Patch mfrc522's RPi.GPIO with our shim (only if the pip mfrc522 package was
# loaded — it's the only path that uses the mfrc522.MFRC522 submodule).
if _IS_SERVER and RFID_AVAILABLE:
    try:
        import sys as _sys
        if 'mfrc522.MFRC522' in _sys.modules:
            _sys.modules['mfrc522.MFRC522'].GPIO = _LgpioShim()
    except Exception:
        pass

from config import AppConfig

cfg = AppConfig()

# ── Optional hardware (graceful degradation on non-Pi) ────────────────────────
try:
    from hardware.lcd_service import LCDService
    lcd = LCDService(
        addr=cfg.lcd_i2c_addr,
        bus=cfg.lcd_i2c_bus,
        width=cfg.lcd_width,
        min_update_interval=0.1,
    )
    LCD_AVAILABLE = True
except Exception:
    lcd = None
    LCD_AVAILABLE = False

# ── gpiozero RGB LED + Button ─────────────────────────────────────────────────
GPIO_AVAILABLE = False
_led = None
_btn = None

if _IS_SERVER:
    try:
        from gpiozero import RGBLED, Button as _GPIOButton
        _led = RGBLED(red=17, green=27, blue=22, active_high=False)
        _btn = _GPIOButton(23)
        _led.color = (1, 0, 0)   # red = idle on startup
        GPIO_AVAILABLE = True
    except Exception:
        _led = None
        _btn = None


def _set_led(r, g, b) -> None:
    if not GPIO_AVAILABLE or _led is None:
        return
    try:
        _led.color = (float(r), float(g), float(b))
    except Exception:
        pass

_led_busy_until: float = 0.0   # monotonic; LED is owned by the flash until this time
_led_last_color: tuple = (-1, -1, -1)  # skip redundant writes to stop flickering

def _set_led_once(r, g, b) -> None:
    """Call _set_led only when the color actually changes."""
    global _led_last_color
    color = (float(r), float(g), float(b))
    if color == _led_last_color:
        return
    _led_last_color = color
    _set_led(r, g, b)

def _update_led_for_state(recording: bool, state: str) -> None:
    """Set LED colour. Skipped during a timed flash."""
    if time.monotonic() < _led_busy_until:
        return
    if not recording:
        _set_led_once(1, 0, 0)          # red = idle
    elif "resting" in state.lower():
        _set_led_once(1, 1, 1)          # white = resting position (ready for next rep)
    else:
        _set_led_once(0, 1, 0)          # green = recording

def _flash_rep_blue() -> None:
    """Flash blue for 1 s when a rep is counted, then return to green."""
    global _led_busy_until, _led_last_color
    if time.monotonic() < _led_busy_until:
        return   # another flash already owns the LED
    _led_busy_until = time.monotonic() + 1.0
    _led_last_color = (-1, -1, -1)
    _set_led(0, 0, 1)   # blue = rep completed
    time.sleep(1.0)
    _led_busy_until = 0.0
    _led_last_color = (-1, -1, -1)
    _set_led(0, 1, 0)   # back to green = still recording

# ── Paths ──────────────────────────────────────────────────────────────────────
SCRIPT_DIR = Path(__file__).resolve().parent
BASE_DIR = SCRIPT_DIR if (SCRIPT_DIR / "ai").exists() else (
    SCRIPT_DIR.parent if (SCRIPT_DIR.parent / "ai").exists() else Path(os.getcwd())
)
MODEL_PATH = BASE_DIR / "ai" / "pull-up_model.pkl"
POSE_TASK_MODEL_PATH = BASE_DIR / "ai" / "pose_landmarker_lite.task"

DEFAULT_AI_STATE = "empty bar"

# ── Global model state ─────────────────────────────────────────────────────────
PULLUP_MODEL: Any = None
PULLUP_SCALER: Any = None
PULLUP_LABEL_ENCODER: Any = None
POSE_LANDMARKER: Any = None
MODEL_LOAD_ERROR: str | None = None

# ── Session state ──────────────────────────────────────────────────────────────
session_lock = threading.Lock()
session_data = {
    "sets": 0,
    "total_reps": 0,
    "current_set_reps": 0,
    "current_set_attempts": 0,
    "history": [],
    "is_recording": False,
    "last_state": DEFAULT_AI_STATE,
    "rep_flash_until": 0.0,
    "start_time": time.time(),
    "streak_days": 7,
    # RFID
    "logged_in": False,
    "username": "Guest",
    "rfid_tag_id": None,
    # API-backed persistence (only set when logged in via RFID)
    "api_user_id": None,
    "api_session_id": None,
    # Rep-counting internals
    "_last_rep_time": 0.0,    # monotonic time of last counted rep
    "_saw_completed": False,  # True while at top of pull-up; rep counted on exit
    "_need_resting": False,   # True after a rep; must see resting_position before next
}

# ── Background prediction cache (written by _prediction_loop, read by _pi_tick) ─
_pred_lock  = threading.Lock()
_pred_state: str = DEFAULT_AI_STATE
_pred_score: str = "0.00"
_pred_info:  str = ""
_pred_frame        = None

# ── Camera ─────────────────────────────────────────────────────────────────────
_pi_cap = None
_pi_cap_lock = threading.Lock()

def _open_pi_camera():
    if cv2 is None:
        return None
    if cfg.camera_device:
        dev_path = f"/dev/v4l/by-id/{cfg.camera_device}"
        if Path(dev_path).exists():
            cap = cv2.VideoCapture(dev_path, cv2.CAP_V4L2)
            if cap.isOpened():
                cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
                return cap
    for flags in (cv2.CAP_V4L2, 0):
        cap = cv2.VideoCapture(cfg.camera_index, flags) if flags else cv2.VideoCapture(cfg.camera_index)
        if cap.isOpened():
            cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
            return cap
    return None

def get_pi_frame():
    global _pi_cap
    if cv2 is None:
        return None
    with _pi_cap_lock:
        if _pi_cap is None or not _pi_cap.isOpened():
            _pi_cap = _open_pi_camera()
        if _pi_cap is None:
            return None
        ret, frame = _pi_cap.read()
        if not ret or frame is None:
            _pi_cap.release()
            _pi_cap = None
            return None
    frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
    try:
        w, h = cfg.default_resolution.split("x")
        frame_rgb = cv2.resize(frame_rgb, (int(w), int(h)))
    except Exception:
        pass
    return frame_rgb

# ── Model loading ──────────────────────────────────────────────────────────────
def _load_pullup_pipeline():
    if joblib is None:
        raise RuntimeError("joblib required.")
    if not MODEL_PATH.exists():
        raise FileNotFoundError(f"Model not found: {MODEL_PATH}")
    pipeline = joblib.load(MODEL_PATH)
    if isinstance(pipeline, dict):
        model = pipeline.get("model")
        if hasattr(model, "named_steps"):
            scaler = model.named_steps.get("scaler")
            clf = model.named_steps.get("clf")
            le = pipeline.get("label_encoder")
            return clf, scaler, le
        return model, pipeline.get("scaler"), pipeline.get("label_encoder")
    raise ValueError("Unexpected model format.")

def _ensure_pose_task_model() -> None:
    if POSE_TASK_MODEL_PATH.exists():
        return
    url = (
        "https://storage.googleapis.com/mediapipe-models/pose_landmarker/"
        "pose_landmarker_lite/float16/1/pose_landmarker_lite.task"
    )
    POSE_TASK_MODEL_PATH.parent.mkdir(parents=True, exist_ok=True)
    from urllib.request import urlretrieve
    urlretrieve(url, str(POSE_TASK_MODEL_PATH))

def _build_pose_landmarker():
    if mp is None:
        raise RuntimeError("MediaPipe required.")
    _ensure_pose_task_model()
    options = mp_vision.PoseLandmarkerOptions(
        base_options=mp_python.BaseOptions(model_asset_path=str(POSE_TASK_MODEL_PATH)),
        running_mode=mp_vision.RunningMode.IMAGE,
        num_poses=1,
        min_pose_detection_confidence=0.5,
        min_pose_presence_confidence=0.5,
        min_tracking_confidence=0.5,
    )
    return mp_vision.PoseLandmarker.create_from_options(options)

def _load_models() -> None:
    global PULLUP_MODEL, PULLUP_SCALER, PULLUP_LABEL_ENCODER, POSE_LANDMARKER, MODEL_LOAD_ERROR
    if MODEL_LOAD_ERROR is not None:
        return
    if PULLUP_MODEL is not None:
        return
    try:
        PULLUP_MODEL, PULLUP_SCALER, PULLUP_LABEL_ENCODER = _load_pullup_pipeline()
        POSE_LANDMARKER = _build_pose_landmarker()
    except Exception as exc:
        MODEL_LOAD_ERROR = str(exc)

# ── Inference ──────────────────────────────────────────────────────────────────
def _normalize_frame(frame: np.ndarray) -> np.ndarray:
    if frame.ndim == 3 and frame.shape[2] == 4:
        frame = frame[:, :, :3]
    return frame.astype(np.uint8) if frame.dtype != np.uint8 else frame

def _angle_between(a, b, c) -> float:
    ba = a[:2] - b[:2]
    bc = c[:2] - b[:2]
    cos_a = np.dot(ba, bc) / (np.linalg.norm(ba) * np.linalg.norm(bc) + 1e-8)
    return float(np.degrees(np.arccos(np.clip(cos_a, -1.0, 1.0))))

def _extract_features(result) -> dict | None:
    if not result.pose_landmarks:
        return None
    landmarks = result.pose_landmarks[0]
    row: dict = {}
    for i, lm in enumerate(landmarks):
        row[f"lm{i}_x"] = float(lm.x)
        row[f"lm{i}_y"] = float(lm.y)
        row[f"lm{i}_z"] = float(lm.z)
        row[f"lm{i}_vis"] = float(getattr(lm, "visibility", 0.0))
    coords = np.array([[lm.x, lm.y, lm.z] for lm in landmarks], dtype=np.float32)
    for feat_name, ia, ib, ic in [
        ("angle_left_elbow", 11, 13, 15),
        ("angle_right_elbow", 12, 14, 16),
        ("angle_left_shoulder", 13, 11, 23),
        ("angle_right_shoulder", 14, 12, 24),
        ("angle_left_hip", 11, 23, 25),
        ("angle_right_hip", 12, 24, 26),
        ("angle_left_knee", 23, 25, 27),
        ("angle_right_knee", 24, 26, 28),
    ]:
        row[feat_name] = _angle_between(coords[ia], coords[ib], coords[ic])
    return row

def _classify_pose(result) -> tuple[str | None, float]:
    if PULLUP_MODEL is None or PULLUP_SCALER is None or PULLUP_LABEL_ENCODER is None:
        return None, 0.0
    features = _extract_features(result)
    if features is None:
        return None, 0.0
    X = np.array([list(features.values())], dtype=np.float64)
    X_scaled = PULLUP_SCALER.transform(X)
    pred_num = PULLUP_MODEL.predict(X_scaled)[0]
    label = PULLUP_LABEL_ENCODER.inverse_transform([pred_num])[0]
    score = float(np.max(PULLUP_MODEL.predict_proba(X_scaled))) if hasattr(PULLUP_MODEL, "predict_proba") else 1.0
    return str(label), score

# Rep-counting thresholds  (model labels: completed / moving / resting_position / empty_bar)
_REP_CONFIDENCE = 0.55  # min confidence to accept a "completed" frame
_REP_COOLDOWN   = 0.4   # seconds — anti-flicker guard only (resting gate is the real limiter)

def _update_rep_count(state: str, confidence: float) -> None:
    with session_lock:
        if not session_data["is_recording"] or confidence < 0.45:
            session_data["last_state"] = state
            return

        now  = time.monotonic()
        s    = state.lower()
        prev = session_data["last_state"].lower()

        # Clear the resting gate as soon as resting_position is seen
        if "resting" in s:
            session_data["_need_resting"] = False

        # Attempt = entering upward movement from resting/empty base.
        # Count "moving" OR "completed" — model sometimes skips straight to completed.
        if ("moving" in s or "completed" in s) and ("resting" in prev or "empty" in prev):
            session_data["current_set_attempts"] += 1

        # Mark top-of-rep — only when resting gate is clear (enforces reset between reps)
        if "completed" in s and confidence >= _REP_CONFIDENCE and not session_data["_need_resting"]:
            session_data["_saw_completed"] = True

        # Count rep on exit from "completed" state
        flash = False
        if "completed" not in s and session_data["_saw_completed"]:
            if (now - session_data["_last_rep_time"]) >= _REP_COOLDOWN:
                session_data["current_set_reps"] += 1
                session_data["total_reps"] += 1
                session_data["rep_flash_until"] = time.time() + 0.5
                session_data["_last_rep_time"] = now
                session_data["_need_resting"] = True  # require resting before next rep
                flash = True
            session_data["_saw_completed"] = False

        session_data["last_state"] = state

    if flash and GPIO_AVAILABLE:
        threading.Thread(target=_flash_rep_blue, daemon=True, name="led-flash-blue").start()

_SKELETON_EDGES = [
    (11, 12), (11, 13), (13, 15), (12, 14), (14, 16),
    (11, 23), (12, 24), (23, 24), (23, 25), (25, 27),
    (24, 26), (26, 28), (0, 11), (0, 12),
]

def _annotate_frame(frame, result, state, score):
    if cv2 is None or not result.pose_landmarks:
        return frame
    out = frame.copy()
    lms = result.pose_landmarks[0]
    h, w = out.shape[:2]
    for a, b in _SKELETON_EDGES:
        if a < len(lms) and b < len(lms):
            la, lb = lms[a], lms[b]
            if getattr(la, "visibility", 1.0) > 0.25 and getattr(lb, "visibility", 1.0) > 0.25:
                cv2.line(out, (int(la.x*w), int(la.y*h)), (int(lb.x*w), int(lb.y*h)),
                         (0, 200, 255), 2, cv2.LINE_AA)
    for lm in lms:
        if getattr(lm, "visibility", 1.0) > 0.25:
            cv2.circle(out, (int(lm.x*w), int(lm.y*h)), 5, (0, 255, 80), -1, cv2.LINE_AA)
    label = f"{state}  {score*100:.0f}%"
    (tw, th), bl = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.65, 2)
    cv2.rectangle(out, (6, 6), (14+tw, 14+th+bl), (0, 0, 0), -1)
    cv2.putText(out, label, (10, 10+th), cv2.FONT_HERSHEY_SIMPLEX, 0.65, (255, 230, 0), 2, cv2.LINE_AA)
    return out

def predict_state_from_frame(frame):
    if frame is None:
        frame = get_pi_frame()
    if frame is None:
        return DEFAULT_AI_STATE, "0.00", "No camera feed.", None
    _load_models()
    if MODEL_LOAD_ERROR:
        return DEFAULT_AI_STATE, "0.00", f"Model error: {MODEL_LOAD_ERROR}", frame
    if PULLUP_MODEL is None or POSE_LANDMARKER is None:
        return DEFAULT_AI_STATE, "0.00", "Models not loaded.", frame
    frame = _normalize_frame(frame)
    mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=frame)
    result = POSE_LANDMARKER.detect(mp_image)
    if not result.pose_landmarks:
        return DEFAULT_AI_STATE, "0.00", "No pose detected.", frame
    label, score = _classify_pose(result)
    state = DEFAULT_AI_STATE if label is None else str(label).replace("_", " ")
    _update_rep_count(state, score)
    with session_lock:
        _recording = session_data["is_recording"]
    _update_led_for_state(_recording, state)
    annotated = _annotate_frame(frame, result, state, score)
    info = f"landmarks={len(result.pose_landmarks[0])}, recording={session_data['is_recording']}"
    return state, f"{score:.2f}", info, annotated

# ── Session controls ───────────────────────────────────────────────────────────
def start_set():
    with session_lock:
        if session_data["current_set_reps"] > 0:
            session_data["sets"] += 1
            session_data["history"].append({
                "set": session_data["sets"],
                "completed": session_data["current_set_reps"],
                "attempts": session_data["current_set_attempts"],
                "failed": max(0, session_data["current_set_attempts"] - session_data["current_set_reps"]),
                "time": time.strftime("%H:%M"),
            })
        session_data["current_set_reps"] = 0
        session_data["current_set_attempts"] = 0
        session_data["_last_rep_time"] = 0.0
        session_data["_saw_completed"] = False
        session_data["_need_resting"] = False
        session_data["is_recording"] = True
    _set_led_once(0, 1, 0)   # green = recording
    return _get_session_stats()

def stop_set():
    global _led_busy_until
    with session_lock:
        session_data["is_recording"] = False
        completed = session_data["current_set_reps"]
        attempts = session_data["current_set_attempts"]
        failed = max(0, attempts - completed)
        api_session_id = session_data["api_session_id"]
        session_data["sets"] += 1
        session_data["history"].append({
            "set": session_data["sets"],
            "completed": completed,
            "attempts": attempts,
            "failed": failed,
            "time": time.strftime("%H:%M"),
        })
        session_data["current_set_reps"] = 0
        session_data["current_set_attempts"] = 0

    if api_session_id is not None:
        _api_save_set(api_session_id, attempts, completed, failed)

    def _flash_white():
        global _led_busy_until, _led_last_color
        _led_busy_until = time.monotonic() + 2.0
        _led_last_color = (-1, -1, -1)  # force next color write after flash
        _set_led(1, 1, 1)
        time.sleep(2)
        _led_busy_until = 0.0
        _set_led(1, 0, 0)   # red = idle after flash
    if GPIO_AVAILABLE:
        threading.Thread(target=_flash_white, daemon=True, name="led-flash-white").start()
    else:
        _set_led(1, 0, 0)   # red = idle
    return _get_session_stats()

def reset_session():
    with session_lock:
        session_data["sets"] = 0
        session_data["total_reps"] = 0
        session_data["current_set_reps"] = 0
        session_data["current_set_attempts"] = 0
        session_data["history"].clear()
        session_data["is_recording"] = False
        session_data["rep_flash_until"] = 0.0
        session_data["start_time"] = time.time()
        session_data["_last_rep_time"] = 0.0
        session_data["_saw_completed"] = False
        session_data["_need_resting"] = False
    _set_led_once(1, 0, 0)   # red = idle
    return _get_session_stats()

def _update_lcd(state: str, score: float) -> None:
    """Update LCD: line 1 = set reps + rate, line 2 = state + session totals."""
    if not LCD_AVAILABLE or lcd is None:
        return
    with session_lock:
        reps        = session_data["current_set_reps"]
        attempts    = session_data["current_set_attempts"]
        total_reps  = session_data["total_reps"]
        total_sets  = session_data["sets"]
        recording   = session_data["is_recording"]
        logged_in   = session_data["logged_in"]
    rate    = f"{reps / attempts * 100:.0f}%" if attempts > 0 else "--"
    status  = "[REC]" if recording else "[IDLE]"
    auth    = "IN" if logged_in else "OUT"
    # Line 1: "[REC] 3r 75% IN"   (set stats + login status)
    # Line 2: "resting pos 10r 2s" (state truncated to 10 + session totals)
    line2 = f"{state[:10].ljust(10)} {total_reps}r {total_sets}s"
    lcd.show_lines(f"{status} {reps}r {rate} {auth}", line2)

def _prediction_loop() -> None:
    """Always-running background thread: AI prediction, LED, LCD — independent of Gradio."""
    global _pred_state, _pred_score, _pred_info, _pred_frame
    while True:
        try:
            state, score_str, info, frame = predict_state_from_frame(None)
            _update_lcd(state, float(score_str))   # always update — even when no pose detected
            with _pred_lock:
                _pred_state = state
                _pred_score = score_str
                _pred_info  = info
                _pred_frame = frame
        except Exception:
            _update_lcd(DEFAULT_AI_STATE, 0.0)     # still refresh stats on error
        time.sleep(0.2)

def _gpio_toggle():
    with session_lock:
        recording = session_data["is_recording"]
    if recording:
        stop_set()
    else:
        start_set()

if GPIO_AVAILABLE and _btn is not None:
    _btn.when_pressed = _gpio_toggle

def _get_session_stats() -> dict:
    with session_lock:
        return {
            "sets": session_data["sets"],
            "total_reps": session_data["total_reps"],
            "current_reps": session_data["current_set_reps"],
            "current_attempts": session_data["current_set_attempts"],
            "is_recording": session_data["is_recording"],
            "history": list(session_data["history"]),
            "streak": session_data["streak_days"],
            "logged_in": session_data["logged_in"],
            "username": session_data["username"],
            "rfid_tag_id": session_data["rfid_tag_id"],
            "api_user_id": session_data["api_user_id"],
            "api_session_id": session_data["api_session_id"],
        }

def _history_html(history: list) -> str:
    if not history:
        return "<p style='color:#888;font-family:Inter,sans-serif;'>No sets recorded yet.</p>"
    rows = "".join(
        f"<tr><td>Set {h['set']}</td><td>{h['reps']} reps</td><td>{h['time']}</td></tr>"
        for h in reversed(history)
    )
    return f"""
    <table style='width:100%;border-collapse:collapse;font-family:Inter,sans-serif;color:#eee;font-size:14px;'>
      <thead><tr style='color:#F8B923;'>
        <th style='text-align:left;padding:6px 0;'>Set</th>
        <th style='text-align:left;padding:6px 0;'>Reps</th>
        <th style='text-align:left;padding:6px 0;'>Time</th>
      </tr></thead>
      <tbody>{rows}</tbody>
    </table>"""

# ── API client ────────────────────────────────────────────────────────────────
def _api_upsert_user(rfid_tag_id: int, name: str) -> int | None:
    """Register/update the user in the API and return their DB user_id."""
    if _requests is None:
        return None
    try:
        r = _requests.post(
            f"{API_BASE_URL}/users",
            json={"rfid_tag_id": rfid_tag_id, "name": name},
            timeout=5,
        )
        r.raise_for_status()
        return r.json()["id"]
    except Exception as exc:
        print(f"[API] upsert_user failed: {exc}")
        return None


def _api_open_session(user_id: int) -> int | None:
    """Open a new training session in the API and return the session_id."""
    if _requests is None:
        return None
    try:
        r = _requests.post(
            f"{API_BASE_URL}/sessions",
            json={"user_id": user_id},
            timeout=5,
        )
        r.raise_for_status()
        return r.json()["id"]
    except Exception as exc:
        print(f"[API] open_session failed: {exc}")
        return None


def _api_end_session(session_id: int) -> None:
    if _requests is None or session_id is None:
        return
    try:
        _requests.patch(f"{API_BASE_URL}/sessions/{session_id}/end", timeout=5)
    except Exception as exc:
        print(f"[API] end_session failed: {exc}")


def _api_delete_session(session_id: int) -> bool:
    if _requests is None:
        return False
    try:
        r = _requests.delete(f"{API_BASE_URL}/sessions/{session_id}", timeout=5)
        return r.status_code == 204
    except Exception as exc:
        print(f"[API] delete_session failed: {exc}")
        return False


def _api_delete_set(set_id: int) -> bool:
    if _requests is None:
        return False
    try:
        r = _requests.delete(f"{API_BASE_URL}/sets/{set_id}", timeout=5)
        return r.status_code == 204
    except Exception as exc:
        print(f"[API] delete_set failed: {exc}")
        return False


def _fetch_api_sessions(user_id: int) -> list[dict]:
    """Fetch all past sessions for a user from the API, most recent first."""
    if _requests is None or user_id is None:
        return []
    try:
        r = _requests.get(f"{API_BASE_URL}/sessions/user/{user_id}", timeout=5)
        if r.ok:
            return r.json()
    except Exception as exc:
        print(f"[API] fetch_sessions failed: {exc}")
    return []


def _fetch_api_sets(session_id: int) -> list[dict]:
    """Fetch all sets for a given session from the API."""
    if _requests is None:
        return []
    try:
        r = _requests.get(f"{API_BASE_URL}/sets/session/{session_id}", timeout=5)
        if r.ok:
            return r.json()
    except Exception as exc:
        print(f"[API] fetch_sets failed: {exc}")
    return []


def _api_save_set(session_id: int, attempts: int, completed: int, failed: int) -> None:
    """Post a completed set to the API (fire-and-forget in a daemon thread)."""
    if _requests is None or session_id is None:
        return

    def _post() -> None:
        try:
            _requests.post(
                f"{API_BASE_URL}/sets",
                json={"session_id": session_id, "attempts": attempts, "completed": completed, "failed": failed},
                timeout=5,
            )
        except Exception as exc:
            print(f"[API] save_set failed: {exc}")

    threading.Thread(target=_post, daemon=True, name="api-save-set").start()


# ── RFID ───────────────────────────────────────────────────────────────────────
# Map physical card UIDs → display names. Add your own cards here.
RFID_TAG_NAMES: dict[int, str] = {
    # 123456789: "Alice",
}

def _rfid_login(tag_id: int) -> None:
    """Toggle login/logout when a card is scanned."""
    name = RFID_TAG_NAMES.get(tag_id, f"User #{tag_id}")
    with session_lock:
        if session_data["logged_in"] and session_data["rfid_tag_id"] == tag_id:
            # Same card again → log out
            old_session_id = session_data["api_session_id"]
            session_data["logged_in"] = False
            session_data["username"] = "Guest"
            session_data["rfid_tag_id"] = None
            session_data["api_user_id"] = None
            session_data["api_session_id"] = None
        else:
            old_session_id = None
            session_data["logged_in"] = True
            session_data["username"] = name
            session_data["rfid_tag_id"] = tag_id

    if old_session_id is not None:
        threading.Thread(
            target=_api_end_session, args=(old_session_id,), daemon=True, name="api-end-session"
        ).start()
        return

    # Login path — register user and open a session (done off the lock in a thread)
    def _setup_api() -> None:
        user_id = _api_upsert_user(tag_id, name)
        if user_id is None:
            return
        session_id = _api_open_session(user_id)
        with session_lock:
            # Only apply if still logged in with the same card
            if session_data["rfid_tag_id"] == tag_id:
                session_data["api_user_id"] = user_id
                session_data["api_session_id"] = session_id

    threading.Thread(target=_setup_api, daemon=True, name="api-login-setup").start()


def rfid_logout() -> None:
    """Force log out — called by the UI Log Out button."""
    with session_lock:
        old_session_id = session_data["api_session_id"]
        session_data["logged_in"] = False
        session_data["username"] = "Guest"
        session_data["rfid_tag_id"] = None
        session_data["api_user_id"] = None
        session_data["api_session_id"] = None

    if old_session_id is not None:
        threading.Thread(
            target=_api_end_session, args=(old_session_id,), daemon=True, name="api-end-session"
        ).start()

def _rfid_real_loop() -> None:
    """Non-blocking poll loop — checks for a card every 500 ms."""
    reader = SimpleMFRC522()
    _last_tag: int | None = None
    _last_seen: float = 0.0
    LOGIN_DEBOUNCE  = 3.0  # ignore same card within 3 s of a login tap
    LOGOUT_DEBOUNCE = 1.0  # shorter debounce for logout taps

    while True:
        try:
            tag_id, _ = reader.read_no_block()
            now = time.time()
            if tag_id:
                tag_id = int(tag_id)
                with session_lock:
                    is_logout = session_data["logged_in"] and session_data["rfid_tag_id"] == tag_id
                debounce = LOGOUT_DEBOUNCE if is_logout else LOGIN_DEBOUNCE
                if tag_id != _last_tag or (now - _last_seen) > debounce:
                    _rfid_login(tag_id)
                    _last_tag = tag_id
                    _last_seen = now
            else:
                # Card removed — reset so next tap is treated as fresh
                if _last_tag is not None and (now - _last_seen) > LOGIN_DEBOUNCE:
                    _last_tag = None
        except Exception:
            pass
        time.sleep(0.5)

def _rfid_stub_loop() -> None:
    """Simulate a card scan every 15–20 s when hardware is absent."""
    STUB_TAGS = [111111111, 222222222, 333333333]
    while True:
        time.sleep(random.uniform(15, 20))
        _rfid_login(random.choice(STUB_TAGS))

def _start_rfid_thread() -> None:
    target = _rfid_real_loop if RFID_AVAILABLE else _rfid_stub_loop
    threading.Thread(target=target, daemon=True, name="rfid-reader").start()

if _IS_SERVER:
    _start_rfid_thread()

# ── CSS ────────────────────────────────────────────────────────────────────────
DARK_CSS = """
body, .gradio-container { background:#000000 !important; color:#FFFFFF !important; font-family:'Inter',sans-serif !important; }
.gradio-container { max-width:100% !important; width:100% !important; padding-left:16px !important; padding-right:16px !important; }
.tab-nav { background:#1A1A1A !important; border-bottom:2px solid #F8B923 !important; }
.tab-nav button { color:#888 !important; font-family:'Space Grotesk',sans-serif !important; font-weight:700 !important; text-transform:uppercase !important; letter-spacing:1px !important; font-size:12px !important; }
.tab-nav button.selected { color:#F8B923 !important; border-bottom:2px solid #F8B923 !important; }
.stat-card { background:#1A1A1A; border:1px solid #2A2A2A; border-radius:12px; padding:20px; margin:8px 0; }
.stat-number { font-family:'Space Grotesk',sans-serif; font-size:48px; font-weight:700; color:#F8B923; line-height:1; }
.stat-label { font-size:12px; color:#888; text-transform:uppercase; letter-spacing:1px; margin-top:4px; }
h1,h2,h3 { font-family:'Space Grotesk',sans-serif !important; font-weight:700 !important; color:#FFFFFF !important; }
.gr-textbox, textarea, input[type=text] { background:#1A1A1A !important; color:#eee !important; border:1px solid #2A2A2A !important; border-radius:8px !important; }
label { color:#888 !important; font-size:12px !important; text-transform:uppercase !important; letter-spacing:0.5px !important; }
button.primary { background:#F8B923 !important; color:#000 !important; font-family:'Space Grotesk',sans-serif !important; font-weight:700 !important; border-radius:8px !important; border:none !important; }
button.secondary { background:#1A1A1A !important; color:#F8B923 !important; font-family:'Space Grotesk',sans-serif !important; font-weight:700 !important; border:1px solid #F8B923 !important; border-radius:8px !important; }
button.stop { background:#2A2A2A !important; color:#fff !important; font-family:'Space Grotesk',sans-serif !important; border:1px solid #444 !important; border-radius:8px !important; }
.progress-bar-bg { background:#2A2A2A; border-radius:6px; height:10px; width:100%; margin:8px 0; }
.progress-bar-fill { background:#F8B923; border-radius:6px; height:10px; transition:width 0.3s; }
.gr-image { border-radius:12px !important; border:1px solid #2A2A2A !important; }
.conf-high { color:#4ADE80; font-weight:700; }
.conf-low  { color:#F87171; font-weight:700; }
/* logout button in header */
.rfid-logout-btn { background:#F8B923 !important; color:#000 !important; font-family:'Space Grotesk',sans-serif !important; font-weight:700 !important; border-radius:6px !important; border:none !important; font-size:11px !important; padding:4px 12px !important; cursor:pointer; }
.rfid-guest-btn  { background:#2A2A2A !important; color:#888 !important; font-family:'Space Grotesk',sans-serif !important; font-weight:700 !important; border-radius:6px !important; border:1px solid #444 !important; font-size:11px !important; padding:4px 12px !important; }
/* history sidebar radio — make each item look like a card */
.history-radio .wrap { display:flex !important; flex-direction:column !important; gap:8px !important; }
.history-radio label { display:block !important; background:#1A1A1A !important; border:1px solid #2A2A2A !important; border-radius:10px !important; padding:14px 16px !important; cursor:pointer !important; transition:border-color 0.15s; color:#fff !important; text-transform:none !important; font-size:14px !important; letter-spacing:0 !important; }
.history-radio label:hover { border-color:#F8B923 !important; }
.history-radio label:has(input:checked) { border-color:#F8B923 !important; background:#231f0f !important; }
.history-radio input[type=radio] { display:none !important; }
/* set grid cards */
.set-grid { display:flex; flex-wrap:wrap; gap:12px; margin-top:12px; }
.set-card { flex:1; min-width:180px; background:#1A1A1A; border:1px solid #2A2A2A; border-radius:10px; padding:16px; }
"""

# ── UI helpers ─────────────────────────────────────────────────────────────────
def _header_html(stats: dict) -> str:
    """Header bar with logo, tagline, and RFID login badge."""
    if stats.get("logged_in"):
        badge = (
            f'<span style="font-family:\'Space Grotesk\',sans-serif;font-size:11px;'
            f'font-weight:700;color:#000;background:#F8B923;border-radius:6px;'
            f'padding:4px 12px;">{stats["username"]}&nbsp;·&nbsp;Log Out ↩</span>'
        )
        # Clicking this span is handled by the Gradio button below it (overlay trick)
        badge_clickable = True
    else:
        badge = (
            '<span style="font-family:\'Space Grotesk\',sans-serif;font-size:11px;'
            'font-weight:700;color:#888;background:#2A2A2A;border-radius:6px;'
            'border:1px solid #444;padding:4px 12px;">Scan card to log in</span>'
        )
        badge_clickable = False
    return f"""
    <div style="display:flex;align-items:center;gap:16px;padding:24px 0 12px;">
      <div style="font-family:'Space Grotesk',sans-serif;font-size:22px;font-weight:900;color:#F8B923;letter-spacing:2px;">PULLUP.</div>
      <div style="flex:1;"></div>
      <div style="font-family:'Space Grotesk',sans-serif;font-size:11px;color:#888;text-transform:uppercase;letter-spacing:1px;margin-right:16px;">Track · Progress · Get Stronger</div>
      {badge}
    </div>"""


def _build_session_card(stats: dict) -> str:
    sets = stats["sets"]
    total = stats["total_reps"]
    streak = stats["streak"]
    goal = 20
    pct = min(int(total / goal * 100), 100)
    recording_badge = (
        '<span style="background:#F8B923;color:#000;border-radius:4px;padding:2px 8px;font-size:11px;font-weight:700;">● RECORDING</span>'
        if stats["is_recording"] else
        '<span style="background:#2A2A2A;color:#888;border-radius:4px;padding:2px 8px;font-size:11px;">IDLE</span>'
    )
    return f"""
    <div class="stat-card">
      <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
        <div class="stat-label">Current Session</div>
        {recording_badge}
      </div>
      <div style="display:flex;gap:32px;">
        <div>
          <div class="stat-number">{total}</div>
          <div class="stat-label">Total Pull-ups</div>
        </div>
        <div>
          <div class="stat-number" style="color:#fff;">{sets}</div>
          <div class="stat-label">Sets</div>
        </div>
        <div>
          <div class="stat-number" style="color:#fff;font-size:32px;">{streak}</div>
          <div class="stat-label">🔥 Day Streak</div>
        </div>
      </div>
      <div style="margin-top:16px;">
        <div style="display:flex;justify-content:space-between;font-size:12px;color:#888;margin-bottom:4px;">
          <span>Progress to goal</span><span>{total}/{goal}</span>
        </div>
        <div class="progress-bar-bg">
          <div class="progress-bar-fill" style="width:{pct}%;"></div>
        </div>
      </div>
    </div>"""


def _live_stats_html(stats: dict, state: str, conf: str) -> str:
    recording_color = "#F8B923" if stats["is_recording"] else "#888"
    status_text = "RECORDING" if stats["is_recording"] else "IDLE"
    reps     = stats["current_reps"]
    attempts = stats.get("current_attempts", 0)
    failed   = max(0, attempts - reps)
    return f"""
    <div class="stat-card" style="text-align:center;">
      <div style="color:{recording_color};font-family:'Space Grotesk',sans-serif;font-size:11px;font-weight:700;letter-spacing:2px;margin-bottom:16px;">● {status_text}</div>
      <div class="stat-number">{reps}</div>
      <div class="stat-label">Reps this set</div>
      <div style="display:flex;justify-content:center;gap:24px;margin-top:8px;">
        <div>
          <div style="font-size:20px;font-weight:700;color:#F8B923;font-family:'Space Grotesk',sans-serif;">{attempts}</div>
          <div style="font-size:10px;color:#888;text-transform:uppercase;letter-spacing:1px;">Attempts</div>
        </div>
        <div>
          <div style="font-size:20px;font-weight:700;color:#F87171;font-family:'Space Grotesk',sans-serif;">{failed}</div>
          <div style="font-size:10px;color:#888;text-transform:uppercase;letter-spacing:1px;">Failed</div>
        </div>
      </div>
      <hr style="border-color:#2A2A2A;margin:16px 0;">
      <div style="font-size:11px;color:#888;text-transform:uppercase;letter-spacing:1px;">Sets done</div>
      <div style="font-size:28px;font-weight:700;color:#fff;font-family:'Space Grotesk',sans-serif;">{stats['sets']}</div>
      <hr style="border-color:#2A2A2A;margin:16px 0;">
      <div style="font-size:11px;color:#888;text-transform:uppercase;letter-spacing:1px;">Total reps</div>
      <div style="font-size:28px;font-weight:700;color:#F8B923;font-family:'Space Grotesk',sans-serif;">{stats['total_reps']}</div>
      <hr style="border-color:#2A2A2A;margin:16px 0;">
      <div style="font-size:11px;color:#888;text-transform:uppercase;letter-spacing:1px;">Last state</div>
      <div style="font-size:14px;font-weight:600;color:#ccc;margin-top:4px;">{state}</div>
    </div>"""


def _fmt_session_timing(started_at: str, ended_at: str | None) -> str:
    if not started_at:
        return ""
    start_time = started_at[11:16]
    if not ended_at:
        return f"Started: {start_time}&nbsp;&nbsp;·&nbsp;&nbsp;In progress"
    end_time = ended_at[11:16]
    try:
        import datetime as _dt
        s = _dt.datetime.fromisoformat(started_at)
        e = _dt.datetime.fromisoformat(ended_at)
        mins = max(0, int((e - s).total_seconds() / 60))
        return f"Started: {start_time}&nbsp;&nbsp;·&nbsp;&nbsp;Ended: {end_time}&nbsp;&nbsp;·&nbsp;&nbsp;{mins} min"
    except Exception:
        return f"Started: {start_time}&nbsp;&nbsp;·&nbsp;&nbsp;Ended: {end_time}"


def _history_session_detail_html(sets: list[dict], label: str, from_api: bool, session_id: int | None = None, is_open: bool = False, started_at: str = "", ended_at: str | None = None) -> str:
    total = sum(s.get("completed", 0) for s in sets)
    total_attempts = sum(s.get("attempts", 0) for s in sets)
    n = len(sets)
    avg = total / n if n else 0
    overall_rate = f"{total/total_attempts*100:.0f}%" if total_attempts else "—"
    timing = _fmt_session_timing(started_at, ended_at)

    delete_session_btn = ""
    if from_api and session_id and not is_open:
        delete_session_btn = f"""
        <button onclick="fetch('{API_BASE_URL}/sessions/{session_id}',{{method:'DELETE'}}).then(()=>document.getElementById('hist-refresh-trigger').click())"
          style="background:#2A2A2A;color:#F87171;border:1px solid #F87171;border-radius:6px;
                 padding:4px 12px;font-size:11px;font-family:'Space Grotesk',sans-serif;
                 font-weight:700;cursor:pointer;float:right;">
          🗑 Delete Session
        </button>"""

    summary = f"""
    <div class="stat-card" style="margin-bottom:16px;">
      <div style="display:flex;justify-content:space-between;align-items:flex-start;">
        <div>
          <h2 style="font-family:'Space Grotesk',sans-serif;font-size:24px;font-weight:900;color:#fff;margin:0 0 4px;">{label}</h2>
          {f"<p style='font-family:Inter,sans-serif;font-size:12px;color:#555;margin:0 0 6px;'>{timing}</p>" if timing else ""}
          <p style="font-family:Inter,sans-serif;font-size:14px;color:#888;margin:0;">
            Total: {total}&nbsp;&nbsp;·&nbsp;&nbsp;Sets: {n}&nbsp;&nbsp;·&nbsp;&nbsp;Avg Reps: {avg:.1f}&nbsp;&nbsp;·&nbsp;&nbsp;Avg Completion: {overall_rate}
          </p>
        </div>
        {delete_session_btn}
      </div>
    </div>"""

    if not sets:
        return summary + "<p style='color:#888;font-family:Inter,sans-serif;padding:8px 0;'>No sets recorded yet.</p>"

    cards = ""
    for i, s in enumerate(sets, 1):
        if from_api:
            raw_time = s.get("recorded_at", "")
            t = raw_time[:16].replace("T", " ") if raw_time else ""
        else:
            t = s.get("time", "")
        attempts  = s.get("attempts", 0)
        completed = s.get("completed", 0)
        failed    = s.get("failed", 0)
        rate = f"{completed/attempts*100:.0f}%" if attempts else "—"
        set_id = s.get("id")
        delete_set_btn = ""
        if from_api and set_id:
            delete_set_btn = f"""
            <button onclick="fetch('{API_BASE_URL}/sets/{set_id}',{{method:'DELETE'}}).then(()=>document.getElementById('hist-refresh-trigger').click())"
              style="background:transparent;color:#F87171;border:1px solid #F87171;border-radius:4px;
                     padding:2px 8px;font-size:10px;font-family:'Space Grotesk',sans-serif;
                     font-weight:700;cursor:pointer;margin-top:8px;display:block;">
              🗑 Delete Set
            </button>"""
        cards += f"""
        <div class="set-card">
          <div style="font-family:'Space Grotesk',sans-serif;font-size:18px;font-weight:700;color:#fff;margin-bottom:8px;">Set {i}</div>
          <div style="font-family:Inter,sans-serif;font-size:13px;color:#ccc;line-height:1.8;">
            Attempts: <b style="color:#fff;">{attempts}</b><br>
            Complete: <b style="color:#4ADE80;">{completed}</b><br>
            Failed: <b style="color:#F87171;">{failed}</b><br>
            Rate: <b style="color:#F8B923;">{rate}</b>
          </div>
          {"<div style='font-family:Inter,sans-serif;font-size:11px;color:#555;margin-top:6px;'>" + t + "</div>" if t else ""}
          {delete_set_btn}
        </div>"""
    return summary + f'<div class="set-grid">{cards}</div>'


def _history_sidebar_choices(stats: dict) -> tuple[list[str], list[dict]]:
    """Return (radio_choices, sessions_meta) combining current session + API history."""
    choices: list[str] = []
    sessions: list[dict] = []

    # Current in-memory session (show even without RFID)
    if stats["history"]:
        label = "Current Session"
        choices.append(label)
        sessions.append({
            "label": label,
            "sets": [
                {
                    "attempts": h.get("attempts", h.get("completed", 0)),
                    "completed": h.get("completed", 0),
                    "failed": h.get("failed", 0),
                    "time": h["time"],
                }
                for h in stats["history"]
            ],
            "from_api": False,
        })

    # API sessions (only when logged in with RFID)
    current_api_sid = stats.get("api_session_id")
    if stats.get("api_user_id"):
        for s in _fetch_api_sessions(stats["api_user_id"]):
            sid = s["id"]
            is_open = s.get("ended_at") is None
            total_sets = s.get("total_sets", 0)
            # Delete any empty session that isn't the current active one
            if total_sets == 0 and sid != current_api_sid:
                threading.Thread(
                    target=_api_delete_session, args=(sid,), daemon=True, name="api-prune-session"
                ).start()
                continue
            raw_started = s.get("started_at", "")
            date = raw_started[:10]
            time_str = raw_started[11:16]
            label = f"Session {sid}  ({date} {time_str})" if time_str else f"Session {sid}  ({date})"
            choices.append(label)
            sessions.append({
                "label": label,
                "session_id": sid,
                "sets": [],        # fetched on demand in _on_session_select
                "from_api": True,
                "is_open": is_open,
                "started_at": raw_started,
                "ended_at": s.get("ended_at"),
            })

    return choices, sessions


def _build_system_status() -> str:
    model_ok = MODEL_PATH.exists()
    pose_ok = POSE_TASK_MODEL_PATH.exists()
    cam_frame = get_pi_frame()
    cam_ok = cam_frame is not None

    def row(label, ok, detail=""):
        icon = "✅" if ok else "❌"
        color = "#4ADE80" if ok else "#F87171"
        return f"<tr><td style='padding:6px 8px;'>{icon}</td><td style='color:{color};padding:6px 8px;'>{label}</td><td style='color:#888;font-size:12px;padding:6px 8px;'>{detail}</td></tr>"

    rows = (
        row("Pull-up model", model_ok, str(MODEL_PATH) if model_ok else "Not found")
        + row("Pose landmarker", pose_ok, str(POSE_TASK_MODEL_PATH) if pose_ok else "Not found")
        + row("Pi camera", cam_ok, cfg.camera_device if cam_ok else f"Index {cfg.camera_index} not accessible")
        + row("GPIO / LED", GPIO_AVAILABLE, "gpiozero OK" if GPIO_AVAILABLE else "Not available")
        + row("LCD display", LCD_AVAILABLE, f"I2C 0x{cfg.lcd_i2c_addr:02X}" if LCD_AVAILABLE else "Not available")
        + row("RFID reader", RFID_AVAILABLE, "mfrc522 OK — read_no_block polling" if RFID_AVAILABLE else "Stub/simulation active")
    )
    return f"""
    <div class="stat-card">
      <table style="width:100%;font-family:Inter,sans-serif;font-size:14px;border-collapse:collapse;">
        <thead><tr style="color:#F8B923;font-size:12px;text-transform:uppercase;">
          <th style="text-align:left;padding:4px 8px;"></th>
          <th style="text-align:left;padding:4px 8px;">Component</th>
          <th style="text-align:left;padding:4px 8px;">Detail</th>
        </tr></thead>
        <tbody>{rows}</tbody>
      </table>
    </div>"""


# ── Gradio UI ──────────────────────────────────────────────────────────────────
def build_demo():
    with gr.Blocks(title="PULLUP. Tracker", fill_width=True) as demo:

        # ── Header (dynamic — updates with login state) ────────────────────
        header = gr.HTML(value=_header_html(_get_session_stats()))

        with gr.Tabs(selected=2) as tabs:

            # ── Dashboard ─────────────────────────────────────────────────
            with gr.Tab("Dashboard", id=0):
                with gr.Row():
                    with gr.Column(scale=1):
                        gr.HTML("""
                        <div style="padding:16px 0;">
                        <div style="font-family:'Space Grotesk',sans-serif;font-size:36px;font-weight:900;line-height:1.1;color:#fff;">
                            TRACK.<br>PROGRESS.<br><span style="color:#F8B923;">GET STRONGER.</span>
                        </div>
                        <p style="color:#888;font-family:Inter,sans-serif;margin-top:12px;">
                            A focused pull-up tracker built for <span style="color:#F8B923;font-weight:600;">consistency</span>.
                        </p>
                        </div>""")
                    with gr.Column(scale=1):
                        gr.HTML("""
                        <div class="stat-card">
                          <div class="stat-label">How it works</div>
                          <ol style="color:#ccc;font-family:Inter,sans-serif;font-size:14px;line-height:1.8;margin-top:12px;padding-left:18px;">
                            <li>Mount your camera so your <b>full body</b> is visible</li>
                            <li>Go to <b>Operating</b> and press <b>Start Set</b></li>
                            <li>Perform pull-ups — reps are counted automatically</li>
                            <li>Press <b>Stop Set</b> when done; check <b>History</b> for past sessions</li>
                          </ol>
                        </div>""")
                with gr.Row():
                    go_btn = gr.Button("▶  Start Training", variant="primary", size="lg")
                go_btn.click(fn=lambda: gr.Tabs(selected=2), outputs=tabs)

            # ── History ───────────────────────────────────────────────────
            with gr.Tab("History", id=1):
                _hist_sessions_state = gr.State([])  # list of session dicts

                with gr.Row():
                    # Left — selected session detail
                    with gr.Column(scale=2):
                        hist_detail = gr.HTML(
                            value="<p style='color:#888;font-family:Inter,sans-serif;padding:20px 0;'>"
                                  "Press Refresh to load your session history.</p>"
                        )

                    # Right — session list sidebar
                    with gr.Column(scale=1):
                        gr.HTML(
                            "<h3 style='font-family:\"Space Grotesk\",sans-serif;font-weight:700;"
                            "color:#F8B923;margin:8px 0 12px;'>History</h3>"
                        )
                        hist_radio = gr.Radio(
                            choices=[],
                            value=None,
                            label="",
                            elem_classes=["history-radio"],
                        )
                        hist_refresh_btn = gr.Button("↻ Refresh", variant="secondary")
                        # Hidden button triggered by JS after a delete to reload history
                        hist_js_trigger = gr.Button(
                            "js-refresh",
                            visible=False,
                            elem_id="hist-refresh-trigger",
                        )

                def _load_history() -> tuple:
                    stats = _get_session_stats()
                    choices, sessions = _history_sidebar_choices(stats)
                    if not sessions:
                        detail = "<p style='color:#888;font-family:Inter,sans-serif;padding:20px 0;'>No sessions found. Log in with your RFID card to save data.</p>"
                        return gr.Radio(choices=[], value=None), detail, []
                    first = sessions[0]
                    if first["from_api"] and first.get("session_id"):
                        raw = _fetch_api_sets(first["session_id"])
                        first_sets = [
                            {
                                "id": r.get("id"),
                                "attempts": r.get("attempts", 0),
                                "completed": r.get("completed", 0),
                                "failed": r.get("failed", 0),
                                "recorded_at": r.get("recorded_at", ""),
                            }
                            for r in raw
                        ]
                    else:
                        # Re-read live so recently stopped sets are included
                        first_sets = [
                            {
                                "attempts": h.get("attempts", h.get("completed", 0)),
                                "completed": h.get("completed", 0),
                                "failed": h.get("failed", 0),
                                "time": h.get("time", ""),
                            }
                            for h in stats.get("history", [])
                        ]
                    detail = _history_session_detail_html(
                        first_sets, first["label"], first["from_api"],
                        session_id=first.get("session_id"),
                        is_open=first.get("is_open", not first["from_api"]),
                        started_at=first.get("started_at", ""),
                        ended_at=first.get("ended_at"),
                    )
                    return gr.Radio(choices=choices, value=choices[0]), detail, sessions

                def _on_session_select(label: str, sessions: list) -> str:
                    for s in sessions:
                        if s["label"] == label:
                            if s["from_api"] and s.get("session_id"):
                                raw = _fetch_api_sets(s["session_id"])
                                sets = [
                                    {
                                        "id": r.get("id"),
                                        "attempts": r.get("attempts", 0),
                                        "completed": r.get("completed", 0),
                                        "failed": r.get("failed", 0),
                                        "recorded_at": r.get("recorded_at", ""),
                                    }
                                    for r in raw
                                ]
                                return _history_session_detail_html(
                                    sets, s["label"], True,
                                    session_id=s["session_id"],
                                    is_open=s.get("is_open", False),
                                    started_at=s.get("started_at", ""),
                                    ended_at=s.get("ended_at"),
                                )
                            # Re-read live so sets added after last Refresh appear
                            live_stats = _get_session_stats()
                            live_sets = [
                                {
                                    "attempts": h.get("attempts", h.get("completed", 0)),
                                    "completed": h.get("completed", 0),
                                    "failed": h.get("failed", 0),
                                    "time": h.get("time", ""),
                                }
                                for h in live_stats.get("history", [])
                            ]
                            return _history_session_detail_html(
                                live_sets, s["label"], False,
                                is_open=True,
                            )
                    return "<p style='color:#888;font-family:Inter,sans-serif;padding:20px 0;'>Could not load session.</p>"

                hist_refresh_btn.click(
                    fn=_load_history,
                    outputs=[hist_radio, hist_detail, _hist_sessions_state],
                )
                hist_js_trigger.click(
                    fn=_load_history,
                    outputs=[hist_radio, hist_detail, _hist_sessions_state],
                )
                hist_radio.change(
                    fn=_on_session_select,
                    inputs=[hist_radio, _hist_sessions_state],
                    outputs=[hist_detail],
                )

            # ── Operating ─────────────────────────────────────────────────
            with gr.Tab("Operating", id=2):
                with gr.Row():
                    with gr.Column(scale=1, min_width=180):
                        gr.HTML("<h3 style='margin:8px 0;'>Live Stats</h3>")
                        live_state = gr.HTML(value=_live_stats_html(_get_session_stats(), DEFAULT_AI_STATE, "0.00"))

                    with gr.Column(scale=2):
                        gr.HTML("<h2 style='margin:8px 0 4px;'>Operating</h2>")
                        camera_feed = gr.Image(label="Camera Feed", type="numpy", height=360)
                        with gr.Row():
                            state_out = gr.Textbox(label="Detected State", interactive=False)
                            confidence_out = gr.Textbox(label="Confidence", interactive=False)
                        details_out = gr.Textbox(label="Details", interactive=False, visible=False)
                        with gr.Row():
                            start_btn = gr.Button("▶ Start", variant="primary")
                            stop_btn  = gr.Button("■ Stop", variant="secondary")
                            reset_btn = gr.Button("↺ Reset Session", elem_classes=["stop"])
                        set_save_msg = gr.HTML(value="")
                        hw_toggle = gr.Button("⚙ Physical Button Toggle (GPIO)", variant="secondary")

                def _start_and_refresh():
                    start_set()
                    stats = _get_session_stats()
                    return _live_stats_html(stats, session_data["last_state"], "—"), _header_html(stats), ""

                def _stop_and_refresh():
                    with session_lock:
                        completed = session_data["current_set_reps"]
                    stop_set()
                    stats = _get_session_stats()
                    msg = (f"<p style='color:#4ADE80;font-family:Inter,sans-serif;"
                           f"font-size:13px;margin:6px 0;'>✓ Set saved — {completed} rep{'s' if completed != 1 else ''}</p>")
                    return _live_stats_html(stats, session_data["last_state"], "—"), _header_html(stats), msg

                def _reset_and_refresh():
                    reset_session()
                    stats = _get_session_stats()
                    return _live_stats_html(stats, DEFAULT_AI_STATE, "—"), _header_html(stats), ""

                def _do_logout():
                    rfid_logout()
                    stats = _get_session_stats()
                    return _header_html(stats), gr.update(visible=False)

                start_btn.click(fn=_start_and_refresh, outputs=[live_state, header, set_save_msg])
                stop_btn.click(fn=_stop_and_refresh, outputs=[live_state, header, set_save_msg])
                reset_btn.click(fn=_reset_and_refresh, outputs=[live_state, header, set_save_msg])
                hw_toggle.click(fn=_gpio_toggle if GPIO_AVAILABLE else lambda: None)

                logout_btn = gr.Button("↩ Log Out", variant="secondary", visible=False)
                logout_btn.click(fn=_do_logout, outputs=[header, logout_btn])

            # ── Debugging ─────────────────────────────────────────────────
            with gr.Tab("Debugging", id=3):
                gr.HTML("<h2 style='margin:16px 0 4px;'>Debug & Diagnostics</h2>")
                with gr.Row():
                    with gr.Column():
                        gr.HTML("""
                        <div class="stat-card">
                          <div class="stat-label">System Status</div>
                        </div>""")
                        system_status = gr.HTML(value=_build_system_status())
                        refresh_diag_btn = gr.Button("↻ Refresh Status", variant="secondary")
                        refresh_diag_btn.click(fn=_build_system_status, outputs=[system_status])
                    with gr.Column():
                        gr.HTML("""
                        <div class="stat-card">
                          <div class="stat-label">Live Debug Feed</div>
                        </div>""")
                        debug_img = gr.Image(label="Debug Camera Frame", type="numpy", height=240)
                        snap_btn = gr.Button("📷 Grab Frame", variant="secondary")
                        snap_btn.click(fn=get_pi_frame, outputs=[debug_img])

                with gr.Row():
                    gr.HTML("""
                    <div class="stat-card" style="width:100%;">
                      <div class="stat-label">Config</div>
                      <pre style="color:#ccc;font-size:12px;font-family:monospace;margin-top:8px;white-space:pre-wrap;">
camera_device  : """ + cfg.camera_device + """
camera_index   : """ + str(cfg.camera_index) + """
resolution     : """ + cfg.default_resolution + """
target_fps     : """ + str(cfg.default_target_fps) + """
model_path     : """ + str(MODEL_PATH) + """
pose_model     : """ + str(POSE_TASK_MODEL_PATH) + """
lcd_available  : """ + str(LCD_AVAILABLE) + """
gpio_available : """ + str(GPIO_AVAILABLE) + """
rfid_available : """ + str(RFID_AVAILABLE) + """
                      </pre>
                    </div>""")

        # ── 200 ms polling timer ───────────────────────────────────────────
        cam_timer = gr.Timer(value=0.2, active=True)

        def _pi_tick():
            try:
                with _pred_lock:
                    state = _pred_state
                    conf  = _pred_score
                    info  = _pred_info
                    frame = _pred_frame
                stats = _get_session_stats()
                return (
                    frame, state, conf, info,
                    _live_stats_html(stats, state, conf),
                    _header_html(stats),
                    gr.update(visible=stats.get("logged_in", False)),
                )
            except Exception as exc:
                import traceback; traceback.print_exc()
                stats = _get_session_stats()
                return (
                    None, DEFAULT_AI_STATE, "0.00", f"tick error: {exc}",
                    _live_stats_html(stats, DEFAULT_AI_STATE, "—"),
                    _header_html(stats),
                    gr.update(visible=False),
                )

        cam_timer.tick(
            fn=_pi_tick,
            outputs=[camera_feed, state_out, confidence_out, details_out, live_state, header, logout_btn],
            show_progress="hidden",
        )

    return demo


# ── Entry point ────────────────────────────────────────────────────────────────
if __name__ == "__main__":
    if _IS_SERVER:
        threading.Thread(target=_prediction_loop, daemon=True, name="prediction-loop").start()
    demo = build_demo()
    try:
        demo.queue().launch(
            server_name=cfg.gradio_host,
            server_port=cfg.gradio_port,
            show_error=True,
            css=DARK_CSS,
            theme=gr.themes.Base(),
        )
    finally:
        if _pi_cap is not None:
            _pi_cap.release()