"""
Simple Flask server for the PARA experiment dashboard.
Serves static HTML reports + media with directory listing fallback.

Usage:
    python serve.py                  # localhost:8080
    python serve.py --public         # + cloudflared tunnel for public URL
    python serve.py --port 9090      # custom port
"""

import argparse
import subprocess
import sys
import threading
from pathlib import Path

from flask import Flask, send_from_directory, send_file, redirect, abort, request, Response, make_response, jsonify
import mimetypes
import os
import uuid
from datetime import datetime

REPORTS_DIR = Path(__file__).parent
BROWSE_ROOT = Path("/data/cameron")
app = Flask(__name__)


@app.after_request
def add_range_header(response):
    """Add Accept-Ranges to all responses so browsers know they can seek videos."""
    response.headers.setdefault("Accept-Ranges", "bytes")
    # CORS: lets the packaged glasses app (which runs from a local origin) call
    # the feeds + /api cross-origin. Wildcard is safe here — auth is via a
    # password header/param, not cookies.
    response.headers.setdefault("Access-Control-Allow-Origin", "*")
    response.headers.setdefault("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
    response.headers.setdefault("Access-Control-Allow-Headers", "Content-Type, X-Agents-Password")
    return response


@app.before_request
def _cors_preflight():
    """Short-circuit CORS preflight requests with the headers above."""
    if request.method == "OPTIONS":
        return ("", 204)


@app.route("/")
def index():
    index_file = REPORTS_DIR / "index.html"
    if index_file.exists():
        return send_from_directory(REPORTS_DIR, "index.html")
    return "<h1>No reports yet</h1><p>Agents haven't generated any reports.</p>"


@app.route("/style.css")
def style():
    return send_from_directory(REPORTS_DIR, "style.css")


# ---------------------------------------------------------------------------
# Life Dashboard
# ---------------------------------------------------------------------------

@app.route("/life")
def life_dashboard():
    life_dir = Path("/data/cameron/life")
    if (life_dir / "dashboard.html").exists():
        return send_from_directory(life_dir, "dashboard.html")
    return "Life dashboard not found.", 404


@app.route("/life/vision/<path:filename>")
def life_vision(filename):
    return send_from_directory(Path("/data/cameron/life/vision"), filename)


# ---------------------------------------------------------------------------
# Notes
# ---------------------------------------------------------------------------

_NOTES_PASSWORD = "fabregas"
_NOTES_FILE = Path("/data/cameron/life/notes.md")


def _check_notes_auth():
    from flask import request, abort
    if request.headers.get("X-Notes-Auth") != _NOTES_PASSWORD:
        abort(401)


@app.route("/notes")
def notes_page():
    return send_from_directory(Path("/data/cameron/life"), "notes.html")


@app.route("/api/notes", methods=["GET"])
def get_notes():
    _check_notes_auth()
    if _NOTES_FILE.exists():
        return _NOTES_FILE.read_text(), 200, {"Content-Type": "text/plain; charset=utf-8"}
    return "", 200


@app.route("/api/notes", methods=["POST"])
def save_notes():
    _check_notes_auth()
    from flask import request
    _NOTES_FILE.write_text(request.get_data(as_text=True))
    return "ok", 200


@app.route("/api/annotate", methods=["POST"])
def annotate_rollout():
    """Save one rollout's annotation to `<eval_dir>/_annotations.json`.

    Body: {"eval_dir": "<browse-relative path>",
           "rollout_id": "rollout_NNN",
           "fields": {"success": "yes|partial|no",
                       "touched_obj": bool,
                       "failure_mode": "..."|null,
                       "notes": "..."}}
    """
    import json
    body = request.get_json(force=True, silent=True) or {}
    eval_rel = body.get("eval_dir", "")
    roll = body.get("rollout_id", "")
    fields = body.get("fields", {}) or {}
    if not eval_rel or not roll:
        return jsonify({"ok": False, "error": "missing eval_dir / rollout_id"}), 400
    eval_path = _check_browse_path(eval_rel)
    if eval_path is None or not eval_path.is_dir():
        return jsonify({"ok": False, "error": f"eval_dir not allowed/found: {eval_rel}"}), 403
    ann_path = eval_path / "_annotations.json"
    state: dict = {}
    if ann_path.exists():
        try:
            state = json.loads(ann_path.read_text())
        except Exception:
            state = {}
    state[roll] = fields
    try:
        ann_path.write_text(json.dumps(state, indent=2, sort_keys=True))
    except OSError as e:
        return jsonify({"ok": False, "error": str(e)}), 500
    return jsonify({"ok": True, "saved": str(ann_path)}), 200


@app.route("/api/annotate", methods=["GET"])
def annotate_get():
    """Return the existing `_annotations.json` for a given eval_dir (browse-rel)."""
    import json
    eval_rel = request.args.get("eval_dir", "")
    if not eval_rel:
        return jsonify({}), 200
    eval_path = _check_browse_path(eval_rel)
    if eval_path is None or not eval_path.is_dir():
        return jsonify({}), 200
    ann_path = eval_path / "_annotations.json"
    if not ann_path.exists():
        return jsonify({}), 200
    try:
        return jsonify(json.loads(ann_path.read_text())), 200
    except Exception:
        return jsonify({}), 200


# ---------------------------------------------------------------------------
# Chat interface
# ---------------------------------------------------------------------------

@app.route("/567_project")
@app.route("/567_project/<path:filename>")
def school_project(filename="index.html"):
    site_dir = Path("/data/cameron/567_augmentation_viewpoint_project/website")
    if site_dir.is_dir():
        return send_from_directory(site_dir, filename)
    return "Not found.", 404


# ---------------------------------------------------------------------------
# Meeting Notes
# ---------------------------------------------------------------------------

NOTES_DIR = REPORTS_DIR / "meeting_notes"


@app.route("/meeting_notes")
@app.route("/meeting_notes/")
def meeting_notes_index():
    """List all meeting notes, newest first."""
    notes = []
    if NOTES_DIR.is_dir():
        for f in sorted(NOTES_DIR.iterdir(), reverse=True):
            if f.is_dir() and (f / "notes.md").exists():
                md = (f / "notes.md").read_text()
                # Extract title from first # heading
                title = f.name.replace("-", " ").replace("_", " ")
                for line in md.split("\n"):
                    if line.startswith("# "):
                        title = line[2:].strip()
                        break
                mtime = datetime.fromtimestamp((f / "notes.md").stat().st_mtime).strftime("%Y-%m-%d %H:%M")
                notes.append((f.name, title, mtime))
    items = "".join(
        f'<li><a href="/meeting_notes/{slug}">{title}</a>'
        f'<span class="meta">{mtime}</span></li>'
        for slug, title, mtime in notes
    )
    return f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Meeting Notes</title>
<style>
body {{ font-family: -apple-system, sans-serif; max-width: 800px; margin: 2rem auto; padding: 0 1rem; background: #fff; color: #1f2328; }}
a {{ color: #0969da; text-decoration: none; }} a:hover {{ text-decoration: underline; }}
h1 {{ font-size: 1.5rem; margin-bottom: 1rem; }}
ul {{ list-style: none; padding: 0; }}
li {{ padding: 0.7rem 0; border-bottom: 1px solid #d8dee4; display: flex; justify-content: space-between; align-items: center; }}
li a {{ font-size: 1rem; font-weight: 500; }}
.meta {{ color: #656d76; font-size: 0.8rem; }}
nav {{ margin-bottom: 1rem; font-size: 0.85rem; }}
</style></head><body>
<nav><a href="/">Dashboard</a></nav>
<h1>Meeting Notes</h1>
<ul>{items or '<li style="color:#656d76;">No meeting notes yet.</li>'}</ul>
</body></html>"""


@app.route("/meeting_notes/<note_id>")
def meeting_notes_view(note_id):
    """Render a meeting note as HTML with collapsible sections."""
    note_dir = NOTES_DIR / note_id
    md_file = note_dir / "notes.md"
    if not md_file.exists():
        return "Not found", 404
    md_content = md_file.read_text()
    return MEETING_NOTE_HTML.replace("{{NOTE_ID}}", note_id).replace("{{MD_CONTENT}}", md_content.replace("`", "\\`").replace("${", "\\${"))


@app.route("/meeting_notes/<note_id>/media/<path:filename>")
def meeting_notes_media(note_id, filename):
    return send_from_directory(NOTES_DIR / note_id / "media", filename)


MEETING_NOTE_HTML = """<!DOCTYPE html>
<html><head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Meeting Notes</title>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
       max-width: 860px; margin: 0 auto; padding: 1.5rem 1rem; background: #fff; color: #1f2328; line-height: 1.65; }
nav { margin-bottom: 1.5rem; font-size: 0.85rem; }
nav a { color: #0969da; text-decoration: none; }
nav a:hover { text-decoration: underline; }

/* Rendered markdown */
.note h1 { font-size: 1.7rem; margin: 0.5rem 0 1rem; color: #0d1117; border-bottom: 1px solid #d8dee4; padding-bottom: 0.5rem; }
.note h2 { font-size: 1.25rem; margin: 1.5rem 0 0.5rem; color: #1f2328; cursor: pointer; user-select: none;
            display: flex; align-items: center; gap: 0.5rem; }
.note h2::before { content: "\\25BE"; font-size: 0.8rem; color: #656d76; transition: transform 0.15s; display: inline-block; }
.note h2.collapsed::before { transform: rotate(-90deg); }
.note h3 { font-size: 1.05rem; margin: 1rem 0 0.4rem; color: #1f2328; }
.note p { margin: 0.4rem 0; }
.note ul, .note ol { margin: 0.4rem 0 0.4rem 1.5rem; }
.note li { margin: 0.25rem 0; }
.note li::marker { color: #656d76; }
.note strong { color: #0d1117; }
.note code { background: #f6f8fa; padding: 0.15em 0.35em; border-radius: 4px; font-size: 0.88em; color: #cf222e; }
.note pre { background: #f6f8fa; padding: 1rem; border-radius: 8px; overflow-x: auto; margin: 0.8rem 0; border: 1px solid #d8dee4; }
.note pre code { background: none; color: #1f2328; padding: 0; }
.note blockquote { border-left: 3px solid #0969da; padding: 0.3rem 0 0.3rem 1rem; margin: 0.8rem 0; color: #656d76; }
.note img { max-width: 100%; border-radius: 8px; margin: 0.8rem 0; border: 1px solid #d8dee4; }
.note table { border-collapse: collapse; margin: 0.8rem 0; font-size: 0.9rem; width: 100%; }
.note th, .note td { border: 1px solid #d8dee4; padding: 0.5rem 0.75rem; text-align: left; }
.note th { background: #f6f8fa; font-weight: 600; }
.note tr:hover td { background: #f6f8fa; }
.note hr { border: none; border-top: 1px solid #d8dee4; margin: 1.5rem 0; }

/* Collapsible sections */
.section-content { overflow: hidden; transition: max-height 0.25s ease; }
.section-content.collapsed { max-height: 0 !important; }
</style>
</head><body>
<nav><a href="/meeting_notes/">&larr; All Notes</a> &middot; <a href="/">Dashboard</a></nav>
<div class="note" id="note"></div>
<script>
const md = `{{MD_CONTENT}}`;

// Render markdown
document.getElementById('note').innerHTML = marked.parse(md);

// Make h2 sections collapsible
document.querySelectorAll('.note h2').forEach(h2 => {
    // Wrap everything between this h2 and the next h2/h1 in a div
    const wrapper = document.createElement('div');
    wrapper.className = 'section-content';
    let sibling = h2.nextElementSibling;
    const toWrap = [];
    while (sibling && !sibling.matches('h1, h2')) {
        toWrap.push(sibling);
        sibling = sibling.nextElementSibling;
    }
    if (toWrap.length === 0) return;
    h2.after(wrapper);
    toWrap.forEach(el => wrapper.appendChild(el));

    // Set initial max-height
    wrapper.style.maxHeight = wrapper.scrollHeight + 'px';

    h2.addEventListener('click', () => {
        h2.classList.toggle('collapsed');
        wrapper.classList.toggle('collapsed');
        if (!wrapper.classList.contains('collapsed')) {
            wrapper.style.maxHeight = wrapper.scrollHeight + 'px';
        }
    });
});
</script>
</body></html>"""


UPLOAD_DIR = Path("/data/cameron/scratch_files")


@app.route("/upload")
def upload_page():
    return """<!DOCTYPE html><html><head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Upload</title>
<style>
body { font-family: -apple-system, sans-serif; max-width: 600px; margin: 2rem auto; padding: 0 1rem; background: #fff; color: #1f2328; }
a { color: #0969da; text-decoration: none; } a:hover { text-decoration: underline; }
nav { margin-bottom: 1.5rem; font-size: 0.85rem; }
h1 { font-size: 1.4rem; margin-bottom: 1rem; }
.drop-zone {
    border: 2px dashed #d8dee4; border-radius: 12px; padding: 3rem 1rem; text-align: center;
    color: #656d76; font-size: 0.95rem; cursor: pointer; transition: all 0.15s; margin-bottom: 1rem;
}
.drop-zone.dragover { border-color: #0969da; background: #f0f7ff; color: #0969da; }
.drop-zone.uploading { border-color: #9a6700; color: #9a6700; }
.drop-zone.done { border-color: #1a7f37; color: #1a7f37; }
input[type=text] {
    width: 100%; padding: 0.6rem 0.8rem; border: 1px solid #d8dee4; border-radius: 8px;
    font-size: 0.9rem; margin-bottom: 0.75rem; outline: none; box-sizing: border-box;
}
input[type=text]:focus { border-color: #0969da; }
input[type=file] { display: none; }
.preview { margin: 1rem 0; text-align: center; }
.preview img, .preview video { max-width: 100%; max-height: 300px; border-radius: 8px; border: 1px solid #d8dee4; }
.file-name { font-size: 0.8rem; color: #656d76; margin-top: 0.3rem; }
button {
    background: #0969da; color: #fff; border: none; border-radius: 8px; padding: 0.6rem 1.5rem;
    font-size: 0.9rem; cursor: pointer; width: 100%;
}
button:hover { background: #0550ae; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.result { margin-top: 1rem; padding: 0.8rem; background: #dafbe1; border-radius: 8px; font-size: 0.85rem; color: #1a7f37; }
.result a { color: #1a7f37; font-weight: 500; }
</style></head><body>
<nav><a href="/">Dashboard</a></nav>
<h1>Upload File</h1>
<input type="text" id="fileName" placeholder="File name (optional — uses original name if empty)">
<div class="drop-zone" id="dropZone" onclick="document.getElementById('fileInput').click()">
    Drag & drop a file here, or click to select
</div>
<input type="file" id="fileInput" accept="image/*,video/*,*/*">
<div class="preview" id="preview"></div>
<button id="uploadBtn" onclick="doUpload()" disabled>Upload</button>
<div id="result"></div>
<script>
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const preview = document.getElementById('preview');
const uploadBtn = document.getElementById('uploadBtn');
let selectedFile = null;

dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('dragover'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
dropZone.addEventListener('drop', e => {
    e.preventDefault(); dropZone.classList.remove('dragover');
    if (e.dataTransfer.files.length) selectFile(e.dataTransfer.files[0]);
});
fileInput.addEventListener('change', () => { if (fileInput.files.length) selectFile(fileInput.files[0]); });

function selectFile(file) {
    selectedFile = file;
    dropZone.textContent = file.name + ' (' + (file.size / 1024).toFixed(0) + ' KB)';
    uploadBtn.disabled = false;

    // Preview
    preview.innerHTML = '';
    if (file.type.startsWith('image/')) {
        const img = document.createElement('img');
        img.src = URL.createObjectURL(file);
        preview.appendChild(img);
    } else if (file.type.startsWith('video/')) {
        const vid = document.createElement('video');
        vid.src = URL.createObjectURL(file);
        vid.controls = true;
        vid.style.maxWidth = '100%';
        preview.appendChild(vid);
    }

    // Pre-fill name if empty
    const nameInput = document.getElementById('fileName');
    if (!nameInput.value) nameInput.value = file.name;
}

async function doUpload() {
    if (!selectedFile) return;
    const name = document.getElementById('fileName').value.trim() || selectedFile.name;
    uploadBtn.disabled = true;
    dropZone.className = 'drop-zone uploading';
    dropZone.textContent = 'Uploading...';

    const form = new FormData();
    form.append('file', selectedFile);
    form.append('name', name);

    try {
        const res = await fetch('/upload', { method: 'POST', body: form });
        const data = await res.json();
        if (data.error) throw new Error(data.error);
        dropZone.className = 'drop-zone done';
        dropZone.textContent = 'Uploaded!';
        document.getElementById('result').innerHTML =
            '<div class="result">Saved to: <code>' + data.path + '</code><br>' +
            '<a href="/browse/' + data.browse_path + '">View in file browser</a></div>';
    } catch(e) {
        dropZone.className = 'drop-zone';
        dropZone.textContent = 'Upload failed: ' + e.message;
        uploadBtn.disabled = false;
    }
}
</script>
</body></html>"""


@app.route("/upload", methods=["POST"])
def upload_file():
    if "file" not in request.files:
        return jsonify({"error": "No file provided"}), 400
    file = request.files["file"]
    name = request.form.get("name", "").strip() or file.filename
    # Sanitize filename
    import re
    safe_name = re.sub(r'[^\w\-\. ]', '_', name)
    dest = UPLOAD_DIR / safe_name
    file.save(str(dest))
    rel = str(dest).replace("/data/cameron/", "")
    return jsonify({"path": str(dest), "browse_path": rel})


@app.route("/data_viewer")
@app.route("/data_viewer/")
@app.route("/data_viewer/<path:filename>")
def data_viewer(filename="index.html"):
    viewer_dir = REPORTS_DIR / "data_viewer"
    if viewer_dir.is_dir():
        return send_from_directory(viewer_dir, filename)
    return "Data viewer not built yet.", 404


# ---------------------------------------------------------------------------
# Data Viewer JSON API — lists directory contents under BROWSE_ROOT
# ---------------------------------------------------------------------------

_NATURAL_RE = __import__("re").compile(r"(\d+)")


def _natural_key(s):
    return [int(t) if t.isdigit() else t.lower() for t in _NATURAL_RE.split(s)]


@app.route("/api/data_viewer/datasets")
def data_viewer_datasets():
    cfg = REPORTS_DIR / "data_viewer" / "datasets.json"
    if not cfg.exists():
        return jsonify({"datasets": []})
    import json as _json
    return jsonify(_json.loads(cfg.read_text()))


@app.route("/api/data_viewer/annotations", methods=["GET"])
def data_viewer_get_annotations():
    """Load episodes.json (if present) from a frames directory, else return an empty skeleton with frame count."""
    import json as _json
    rel = request.args.get("path", "").lstrip("/")
    full = _check_browse_path(rel)
    if full is None or not full.is_dir():
        return jsonify({"error": "path not a directory"}), 400

    meta_file = full / "episodes.json"
    if meta_file.exists():
        try:
            return jsonify(_json.loads(meta_file.read_text()))
        except Exception as e:
            return jsonify({"error": f"invalid episodes.json: {e}"}), 500

    IMG_EXTS = {".png", ".jpg", ".jpeg", ".webp"}
    try:
        frames = [p for p in full.iterdir() if p.is_file() and p.suffix.lower() in IMG_EXTS]
    except OSError:
        frames = []
    return jsonify({
        "version": 1,
        "source_path": rel,
        "total_frames": len(frames),
        "fps": 4,
        "episodes": [],
    })


@app.route("/api/data_viewer/annotations", methods=["POST"])
def data_viewer_put_annotations():
    """Persist episodes.json into the frames directory. Body: {path, total_frames, fps, episodes}."""
    import json as _json
    from datetime import timezone as _tz
    data = request.get_json(silent=True) or {}
    rel = (data.get("path") or "").lstrip("/")
    full = _check_browse_path(rel)
    if full is None or not full.is_dir():
        return jsonify({"error": "path not a directory"}), 400

    eps = data.get("episodes", [])
    if not isinstance(eps, list):
        return jsonify({"error": "episodes must be a list"}), 400

    total_frames = int(data.get("total_frames") or 0)
    normalized = []
    for i, ep in enumerate(eps):
        if not isinstance(ep, dict):
            return jsonify({"error": f"episode {i} must be an object"}), 400
        try:
            start = int(ep["start"]); end = int(ep["end"])
        except (KeyError, TypeError, ValueError):
            return jsonify({"error": f"episode {i} missing/invalid start/end"}), 400
        if start < 0 or end < start:
            return jsonify({"error": f"episode {i} invalid range {start}-{end}"}), 400
        if total_frames and end >= total_frames:
            return jsonify({"error": f"episode {i} end {end} >= total_frames {total_frames}"}), 400
        kfs_in = ep.get("keyframes") or []
        if not isinstance(kfs_in, list):
            return jsonify({"error": f"episode {i} keyframes must be a list"}), 400
        kfs_out = []
        seen_ids = set()
        for j, kf in enumerate(kfs_in):
            if not isinstance(kf, dict):
                return jsonify({"error": f"episode {i} keyframe {j} must be an object"}), 400
            try:
                frame = int(kf["frame"])
            except (KeyError, TypeError, ValueError):
                return jsonify({"error": f"episode {i} keyframe {j} missing/invalid frame"}), 400
            if frame < start or frame > end:
                return jsonify({"error": f"episode {i} keyframe {j} frame {frame} outside ep range {start}-{end}"}), 400
            kf_id = str(kf.get("id") or f"kf_{i}_{j}")
            if kf_id in seen_ids:
                kf_id = f"{kf_id}_{j}"
            seen_ids.add(kf_id)
            kfs_out.append({
                "id": kf_id,
                "frame": frame,
                "label": str(kf.get("label") or ""),
            })
        kfs_out.sort(key=lambda k: k["frame"])
        normalized.append({
            "id": str(ep.get("id") or f"ep_{i}"),
            "start": start,
            "end": end,
            "label": str(ep.get("label") or ""),
            "notes": str(ep.get("notes") or ""),
            "keyframes": kfs_out,
        })

    now = datetime.now(_tz.utc).isoformat(timespec="seconds")
    payload = {
        "version": 1,
        "source_path": rel,
        "total_frames": total_frames,
        "fps": float(data.get("fps") or 4),
        "episodes": normalized,
        "updated_at": now,
    }

    dest = full / "episodes.json"
    tmp = full / f".episodes.json.tmp.{os.getpid()}"
    try:
        tmp.write_text(_json.dumps(payload, indent=2))
        tmp.replace(dest)
    except OSError as e:
        return jsonify({"error": f"write failed: {e}"}), 500

    return jsonify({"status": "ok", "path": str(dest), "count": len(normalized), "updated_at": now})


@app.route("/api/data_viewer/list")
def data_viewer_list():
    """List directory contents under BROWSE_ROOT as JSON with natural sort."""
    rel = request.args.get("path", "").lstrip("/")
    limit = int(request.args.get("limit", "2000"))
    offset = int(request.args.get("offset", "0"))
    only = request.args.get("only", "")  # "dirs", "files", or ""

    full = _check_browse_path(rel) if rel else BROWSE_ROOT
    if full is None:
        return jsonify({"error": "forbidden"}), 403
    if not full.exists() or not full.is_dir():
        return jsonify({"error": "not a directory", "path": str(full)}), 404

    dirs, files = [], []
    try:
        for item in full.iterdir():
            name = item.name
            if name.startswith("."):
                continue
            try:
                if item.is_dir():
                    if only != "files":
                        dirs.append({"name": name, "type": "dir"})
                else:
                    if only != "dirs":
                        ext = item.suffix.lower()
                        files.append({
                            "name": name,
                            "type": "file",
                            "ext": ext,
                            "size": item.stat().st_size,
                        })
            except OSError:
                continue
    except PermissionError:
        return jsonify({"error": "permission denied"}), 403

    dirs.sort(key=lambda e: _natural_key(e["name"]))
    files.sort(key=lambda e: _natural_key(e["name"]))

    total_dirs = len(dirs)
    total_files = len(files)
    combined = dirs + files
    paged = combined[offset: offset + limit]

    return jsonify({
        "path": rel,
        "total_dirs": total_dirs,
        "total_files": total_files,
        "offset": offset,
        "limit": limit,
        "returned": len(paged),
        "entries": paged,
    })


# ---------------------------------------------------------------------------
# Mock-wandb run viewer (our_wandb agent) — filesystem scan of wandb run dirs.
# Heavy scan/parse logic lives in wandb_runs.py; these are thin JSON+static
# routes mirroring the data_viewer pattern above.
# ---------------------------------------------------------------------------
import wandb_runs as _wandb_runs


@app.route("/runs")
@app.route("/runs/")
@app.route("/runs/<path:filename>")
def wandb_viewer(filename="index.html"):
    viewer_dir = REPORTS_DIR / "runs"
    if viewer_dir.is_dir():
        return send_from_directory(viewer_dir, filename)
    return "Runs viewer not built yet.", 404


@app.route("/glasses")
@app.route("/glasses/")
@app.route("/glasses/<path:filename>")
def glasses_app(filename="index.html"):
    # Even Realities G2 fleet-hub app (built Vite bundle). Served same-origin
    # with /api/agents/* so the glasses WebView needs no CORS/proxy.
    app_dir = REPORTS_DIR / "glasses_app"
    if app_dir.is_dir():
        return send_from_directory(app_dir, filename)
    return "Glasses app not built yet.", 404


@app.route("/api/runs/list")
def api_runs_list():
    return jsonify(_wandb_runs.list_runs())


@app.route("/api/runs/<run_id>")
def api_runs_detail(run_id):
    detail = _wandb_runs.run_detail(run_id)
    if detail is None:
        return jsonify({"error": "run not found"}), 404
    return jsonify(detail)


@app.route("/api/runs/<run_id>/media/<path:filename>")
def api_runs_media(run_id, filename):
    # Resolve the run dir via the same scan used everywhere else, then confirm it
    # sits under a configured WANDB_ROOTS entry before serving. send_from_directory
    # rejects `..`/absolute paths within `filename`; the root check blocks escaping
    # `run_id` itself out of the FUSE mounts (see website_builder's note + _check_browse_path).
    run_dir = _wandb_runs.find_run(run_id)
    if run_dir is None:
        abort(404)
    resolved = run_dir.resolve()
    roots = [Path(r).resolve() for r in _wandb_runs.get_roots()]
    if not any(str(resolved).startswith(str(r) + "/") for r in roots):
        abort(403)
    return send_from_directory(run_dir / "files", filename)


@app.route("/figma")
@app.route("/figma/")
@app.route("/figma/<path:filename>")
def figma_exports(filename=None):
    figma_dir = Path("/data/cameron/para/paper/figs/figma")
    if not figma_dir.is_dir():
        return "<h1>No Figma exports yet</h1>"
    if filename:
        return send_from_directory(figma_dir, filename)
    files = sorted(figma_dir.iterdir())
    items = "".join(
        f'<li style="padding:0.5rem 0;border-bottom:1px solid #d8dee4;">'
        f'<a href="/figma/{f.name}" style="color:#0969da;">{f.name}</a>'
        f' <span style="color:#656d76;font-size:0.8rem;">({f.stat().st_size//1024}KB)</span></li>'
        for f in files if f.is_file()
    )
    return f"""<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Figma Exports</title>
<style>body{{font-family:-apple-system,sans-serif;max-width:800px;margin:2rem auto;padding:0 1rem;}}
a{{color:#0969da;}}</style></head>
<body><h1>Figma Exports</h1><p><a href="/">Dashboard</a></p>
<ul style="list-style:none;padding:0;">{items or '<li>No exports yet. Set up figma_config.yaml and run figma_export.py</li>'}</ul></body></html>"""


@app.route("/paper")
def paper_viewer():
    return """<!DOCTYPE html>
<html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PARA Paper</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #f6f8fa; font-family: -apple-system, sans-serif; height: 100vh; display: flex; flex-direction: column; }
.bar { padding: 0.5rem 1rem; background: #fff; border-bottom: 1px solid #d8dee4; display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; }
.bar h1 { font-size: 0.9rem; color: #0d1117; }
.bar .links { display: flex; gap: 1rem; font-size: 0.8rem; }
.bar a { color: #0969da; text-decoration: none; }
.status { font-size: 0.75rem; padding: 0.2rem 0.6rem; border-radius: 4px; }
.status.ok { background: #dafbe1; color: #1a7f37; }
.status.err { background: #ffebe9; color: #cf222e; }
.status.loading { background: #fff8c5; color: #9a6700; }
.err-detail { padding: 0.5rem 1rem; background: #ffebe9; border-bottom: 1px solid #cf222e; font-family: monospace; font-size: 0.75rem; color: #cf222e; max-height: 150px; overflow-y: auto; white-space: pre-wrap; display: none; }
iframe { flex: 1; border: none; width: 100%; }
</style></head><body>
<div class="bar">
    <h1>PARA Paper</h1>
    <span id="status" class="status loading">checking...</span>
    <div class="links"><a href="/">Dashboard</a><a href="/paper/main.pdf" target="_blank">PDF</a><a href="/agents">Agents</a></div>
</div>
<div id="errDetail" class="err-detail"></div>
<iframe id="pdf" src="/paper/main.pdf"></iframe>
<script>
let lastCompileTimestamp = '';
async function checkStatus() {
    try {
        const res = await fetch('/paper/compile_status.json?' + Date.now());
        const data = await res.json();
        const el = document.getElementById('status');
        const err = document.getElementById('errDetail');
        if (data.ok) {
            el.className = 'status ok';
            el.textContent = data.message + ' (' + data.timestamp.split(' ')[1] + ')';
            err.style.display = 'none';
            // Only reload PDF when timestamp changes (new compile)
            if (data.message === 'Compiled successfully' && data.timestamp !== lastCompileTimestamp) {
                lastCompileTimestamp = data.timestamp;
                document.getElementById('pdf').src = '/paper/main.pdf?' + Date.now();
            }
        } else {
            el.className = 'status err';
            el.textContent = 'Compile error';
            err.style.display = 'block';
            err.textContent = data.message;
        }
    } catch(e) {}
}
// Set initial timestamp so first load doesn't trigger a reload
fetch('/paper/compile_status.json?' + Date.now()).then(r => r.json()).then(d => { lastCompileTimestamp = d.timestamp; }).catch(() => {});
checkStatus();
setInterval(checkStatus, 5000);
</script>
</body></html>"""


@app.route("/paper/<path:filename>")
def paper_file(filename):
    paper_dir = Path("/data/cameron/para/paper")
    if paper_dir.is_dir():
        return send_from_directory(paper_dir, filename)
    return "Not found.", 404


@app.route("/old_para_website")
def old_para_website_redirect():
    return redirect("/old_para_website/")

@app.route("/old_para_website/")
@app.route("/old_para_website/<path:filename>")
def old_para_website(filename="index_goal.html"):
    site_dir = Path("/data/cameron/para/old_para_website")
    if site_dir.is_dir():
        return send_from_directory(site_dir, filename)
    return "Not found.", 404


@app.route("/para_website")
@app.route("/para_website/<path:filename>")
def para_website(filename="index.html"):
    site_dir = REPORTS_DIR / "project_site"
    if site_dir.is_dir():
        return send_from_directory(site_dir, filename)
    return "Project website not built yet.", 404


@app.route("/para_website_v1")
@app.route("/para_website_v1/<path:filename>")
def para_website_v1(filename="index.html"):
    site_dir = REPORTS_DIR / "project_site_v1"
    if site_dir.is_dir():
        return send_from_directory(site_dir, filename)
    return "Archived v1 site not found.", 404


@app.route("/chat")
def chat_page():
    return MOBILE_CHATTER_HTML


@app.route("/api/mobile_chatter/iframe_url")
def mobile_chatter_iframe_url():
    url_file = Path("/data/cameron/para/.agents/mobile_chatter/iframe_url.txt")
    if url_file.exists():
        return url_file.read_text().strip()
    return "/"


@app.route("/api/chat", methods=["POST"])
def api_chat():
    from chat import chat as do_chat
    data = request.get_json()
    user_msg = data.get("message", "")
    session_id = data.get("session_id", str(uuid.uuid4()))
    if not user_msg:
        return jsonify({"error": "No message provided"}), 400
    model = data.get("model", "claude-sonnet-4-20250514")
    try:
        response_text = do_chat(session_id, user_msg, model=model)
        return jsonify({"response": response_text, "session_id": session_id})
    except Exception as e:
        return jsonify({"error": str(e)}), 500


# ---------------------------------------------------------------------------
# Agent terminal interface
# ---------------------------------------------------------------------------

AGENTS_PASSWORD = "fabregas"


def _check_agents_auth():
    """Check password from header or query param."""
    pw = request.headers.get("X-Agents-Password") or request.args.get("pw")
    return pw == AGENTS_PASSWORD


@app.route("/agents")
@app.route("/agents/<agent_name>")
def agents_page(agent_name=None):
    return AGENTS_HTML


@app.route("/api/agents/list")
def api_agents_list():
    """List all agents with their tmux window status."""
    if not _check_agents_auth():
        return jsonify({"error": "unauthorized"}), 401
    import yaml
    config_path = REPORTS_DIR.parent / "config.yaml"
    if not config_path.exists():
        return jsonify([])
    with open(config_path) as f:
        config = yaml.safe_load(f)
    agents = []
    for name, info in config.get("agents", {}).items():
        status_file = REPORTS_DIR.parent / name / "status.md"
        status = status_file.read_text().strip() if status_file.exists() else "unknown"
        agents.append({"name": name, "description": info.get("description", ""), "status": status})
    return jsonify(agents)


@app.route("/api/agents/<agent_name>/pane")
def api_agent_pane(agent_name):
    """Capture tmux pane output for an agent."""
    if not _check_agents_auth():
        return jsonify({"error": "unauthorized"}), 401
    lines = request.args.get("lines", 200, type=int)
    target = _find_tmux_window(agent_name)
    if not target:
        return jsonify({"error": f"Window '{agent_name}' not found", "output": ""})
    result = subprocess.run(
        ["tmux", "capture-pane", "-t", target, "-p", "-S", f"-{lines}"],
        capture_output=True, text=True,
    )
    # Strip ANSI escape codes
    import re
    clean = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', result.stdout)
    clean = re.sub(r'\x1b\].*?\x07', '', clean)  # OSC sequences
    return jsonify({"output": clean})


@app.route("/api/agents/<agent_name>/interrupt", methods=["POST"])
def api_agent_interrupt(agent_name):
    """Send Escape key to interrupt Claude Code."""
    if not _check_agents_auth():
        return jsonify({"error": "unauthorized"}), 401
    target = _find_tmux_window(agent_name)
    if not target:
        return jsonify({"error": f"Window '{agent_name}' not found"}), 404
    subprocess.run(["tmux", "send-keys", "-t", target, "Escape"])
    # Small delay then press 'i' to return to INSERT mode (in case vim mode is on)
    import time as _t; _t.sleep(0.3)
    subprocess.run(["tmux", "send-keys", "-t", target, "i"])
    return jsonify({"status": "interrupted"})


@app.route("/api/agents/<agent_name>/send", methods=["POST"])
def api_agent_send(agent_name):
    """Send keystrokes to an agent's tmux pane."""
    if not _check_agents_auth():
        return jsonify({"error": "unauthorized"}), 401
    data = request.get_json()
    text = data.get("text", "")
    if not text:
        return jsonify({"error": "No text provided"}), 400
    target = _find_tmux_window(agent_name)
    if not target:
        return jsonify({"error": f"Window '{agent_name}' not found"}), 404
    subprocess.run(["tmux", "send-keys", "-t", target, text, "Enter"])
    import time as _time; _time.sleep(1)
    subprocess.run(["tmux", "send-keys", "-t", target, "Enter"])
    return jsonify({"status": "sent"})


def _spotify_lib():
    import sys as _sys
    gl = "/data/cameron/agents_stuff/agents/glasses"
    if gl not in _sys.path:
        _sys.path.insert(0, gl)
    import spotify_lib
    return spotify_lib


def _sp_force_refresh():
    """Signal the renderer to refresh now-playing immediately (glasses-initiated change)."""
    try:
        import pathlib, time as _t
        pathlib.Path("/tmp/glasses_spotify_force").write_text(str(_t.time()))
    except Exception:
        pass


@app.route("/api/glasses/spotify/playpause", methods=["POST"])
def api_glasses_spotify_playpause():
    if not _check_agents_auth():
        return jsonify({"error": "unauthorized"}), 401
    code = _spotify_lib().toggle_playback()
    _sp_force_refresh()
    return jsonify({"code": code})


@app.route("/api/glasses/spotify/next", methods=["POST"])
def api_glasses_spotify_next():
    if not _check_agents_auth():
        return jsonify({"error": "unauthorized"}), 401
    code = _spotify_lib().next_track()
    _sp_force_refresh()
    return jsonify({"code": code})


def _sp_find_tool(name, inp, lib):
    import json as _json
    try:
        if name == "search_spotify":
            return _json.dumps(lib.search_tracks(inp.get("query", "")))
        if name == "artist_albums":
            return _json.dumps(lib.artist_albums(inp.get("artist", "")))
        if name == "album_tracks":
            return _json.dumps(lib.album_tracks(inp.get("album_id", "")))
    except Exception as e:
        return "tool error: " + str(e)[:120]
    return "unknown tool"


@app.route("/api/glasses/spotify/find", methods=["POST"])
def api_glasses_spotify_find():
    """Pick the track to play for a spoken request, grounded in Spotify's LIVE
    catalog via tools (so 'newest album' is current, not guessed). Returns the
    candidate WITHOUT playing it — the glasses confirm first."""
    if not _check_agents_auth():
        return jsonify({"error": "unauthorized"}), 401
    text = (request.get_json() or {}).get("text", "").strip()
    if not text:
        return jsonify({"error": "No text provided"}), 400
    lib = _spotify_lib()
    import json as _json
    import re as _re
    tools = [
        {"name": "search_spotify", "description": "Search Spotify for tracks. Returns up to 5 {name,artist,album,uri}.",
         "input_schema": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}},
        {"name": "artist_albums", "description": "List an artist's albums/singles NEWEST-FIRST with release_date. Use this for 'newest/latest album' requests — it reflects Spotify's live catalog.",
         "input_schema": {"type": "object", "properties": {"artist": {"type": "string"}}, "required": ["artist"]}},
        {"name": "album_tracks", "description": "List an album's tracks {name,uri} given an album id from artist_albums.",
         "input_schema": {"type": "object", "properties": {"album_id": {"type": "string"}}, "required": ["album_id"]}},
    ]
    system = (
        "Pick the single Spotify track to play for the spoken request. ALWAYS ground your answer in the tool "
        "results (Spotify's live catalog) — never rely on your own memory of what albums/songs exist or are newest. "
        "For 'newest/latest album by X' call artist_albums (newest-first) and use the top album, then album_tracks. "
        "For a named song use search_spotify. When you've decided, reply with ONLY JSON: "
        '{"uri":"spotify:track:..","name":"..","artist":".."}  — or {"found":false} if nothing fits.')
    messages = [{"role": "user", "content": text}]
    try:
        import anthropic
        client = anthropic.Anthropic()
        for _ in range(8):
            r = client.messages.create(model="claude-sonnet-4-6", max_tokens=800, system=system, tools=tools, messages=messages)
            if r.stop_reason == "tool_use":
                messages.append({"role": "assistant", "content": r.content})
                messages.append({"role": "user", "content": [
                    {"type": "tool_result", "tool_use_id": b.id, "content": _sp_find_tool(b.name, b.input, lib)}
                    for b in r.content if b.type == "tool_use"]})
                continue
            txt = "".join(b.text for b in r.content if b.type == "text")
            m = _re.search(r"\{.*\}", txt, _re.S)
            d = _json.loads(m.group(0)) if m else {}
            if d.get("uri"):
                return jsonify({"found": True, "uri": d["uri"], "name": d.get("name", ""), "artist": d.get("artist", "")})
            return jsonify({"found": False})
    except Exception as e:
        # fall back to a plain search so voice-search still works if the agentic path fails
        t = lib.search(text)
        return jsonify({"found": True, **t}) if t else jsonify({"found": False, "error": str(e)[:120]})
    return jsonify({"found": False})


@app.route("/api/glasses/spotify/play_uri", methods=["POST"])
def api_glasses_spotify_play_uri():
    if not _check_agents_auth():
        return jsonify({"error": "unauthorized"}), 401
    uri = (request.get_json() or {}).get("uri", "").strip()
    if not uri:
        return jsonify({"error": "No uri provided"}), 400
    code = _spotify_lib().play_uri(uri)
    _sp_force_refresh()
    return jsonify({"code": code})


@app.route("/browse/para/.agents/reports/glasses_app/feed/module_spotify.json")
def glasses_spotify_feed():
    """The client fetching this feed == it's on the Spotify tab. Touch a marker
    so the renderer only polls Spotify while it's actually being viewed (avoids
    24/7 polling -> rate-limit bans). Then serve the renderer-written file."""
    try:
        import pathlib, time as _t
        pathlib.Path("/tmp/glasses_spotify_viewed").write_text(str(_t.time()))
    except Exception:
        pass
    return send_from_directory("/data/cameron/para/.agents/reports/glasses_app/feed", "module_spotify.json")


GLASSES_CHAT_LOG = "/data/cameron/agents_stuff/agents/glasses/chat_log.md"


VOICE_AGENT = "voice_interface"  # persistent voice-interface agent (tmux tab 0)


@app.route("/api/glasses/chat/send", methods=["POST"])
def api_glasses_chat_send():
    """Glasses Chat -> the persistent voice_interface agent. It observes/commands
    other agents per its own ROLE, then appends its reply to the chat log, which
    the glasses poll (so the reply shows up whenever it's done)."""
    if not _check_agents_auth():
        return jsonify({"error": "unauthorized"}), 401
    text = (request.get_json() or {}).get("text", "").strip()
    if not text:
        return jsonify({"error": "No text provided"}), 400
    with open(GLASSES_CHAT_LOG, "a") as f:
        f.write("> you: " + text + "\n")
    target = _find_tmux_window(VOICE_AGENT)
    if not target:
        with open(GLASSES_CHAT_LOG, "a") as f:
            f.write(f"voice: (agent '{VOICE_AGENT}' window not found)\n")
        return jsonify({"error": "voice agent not found"}), 404
    # ONE line (embedded newlines put Claude Code into multi-line mode and it won't submit on Enter).
    wrapped = (f"{text}  [glasses chat from Cameron -- when done, append ONE line "
               f"'voice: <concise 1-3 line answer>' to {GLASSES_CHAT_LOG}; observe agents by reading "
               f"their panes, only message one if Cameron explicitly asks]")
    subprocess.run(["tmux", "send-keys", "-t", target, wrapped, "Enter"])
    import time as _t; _t.sleep(1)
    subprocess.run(["tmux", "send-keys", "-t", target, "Enter"])
    return jsonify({"status": "dispatched"})


def _find_tmux_window(name):
    """Find tmux window target by name."""
    result = subprocess.run(
        ["tmux", "list-windows", "-a", "-F", "#{session_name}:#{window_index} #{window_name}"],
        capture_output=True, text=True,
    )
    for line in result.stdout.strip().split("\n"):
        parts = line.split(" ", 1)
        if len(parts) == 2 and parts[1] == name:
            return parts[0]
    return None


AGENTS_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>PARA Agents</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=Inter:wght@400;500;600&display=swap');

* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
    --bg: #ffffff; --bg-card: #f6f8fa; --bg-input: #f0f2f5;
    --border: #d8dee4; --border-focus: #0969da;
    --text: #1f2328; --text-bright: #0d1117; --text-dim: #656d76;
    --accent: #0969da; --accent-hover: #0550ae;
    --green: #1a7f37; --yellow: #9a6700; --red: #cf222e;
    --radius: 10px;
}

body {
    font-family: 'Inter', -apple-system, sans-serif;
    background: var(--bg); color: var(--text);
    height: 100vh; height: 100dvh;
    display: flex; flex-direction: column;
    overflow: hidden;
    -webkit-font-smoothing: antialiased;
}

/* ── Header ── */
.header {
    padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--border);
    display: flex; align-items: center; justify-content: space-between;
    background: var(--bg-card); flex-shrink: 0;
}
.header h1 {
    font-size: 0.85rem; color: var(--text-bright); font-weight: 600;
    letter-spacing: 0.04em;
}
.header .links { display: flex; gap: 0.75rem; font-size: 0.75rem; }
.header a { color: var(--text-dim); text-decoration: none; font-weight: 500; }
.header a:hover { color: var(--accent); }

/* ── Agent Tabs ── */
.agent-tabs {
    display: flex; gap: 0.25rem; padding: 0.4rem 0.5rem;
    overflow-x: auto; flex-shrink: 0;
    background: var(--bg-card); border-bottom: 1px solid var(--border);
    -webkit-overflow-scrolling: touch;
    scrollbar-width: none;
}
.agent-tabs::-webkit-scrollbar { display: none; }
.agent-tab {
    padding: 0.35rem 0.7rem; cursor: pointer; color: var(--text-dim);
    font-size: 0.72rem; font-weight: 500; font-family: 'Inter', sans-serif;
    white-space: nowrap; border-radius: 6px; border: 1px solid transparent;
    background: transparent; transition: all 0.15s;
    display: flex; align-items: center; gap: 0.35rem;
}
.agent-tab:hover { background: var(--bg-input); color: var(--text); }
.agent-tab.active {
    background: var(--accent); color: #fff; border-color: var(--accent);
}
.agent-tab .status-dot {
    width: 5px; height: 5px; border-radius: 50%; flex-shrink: 0;
}
.status-dot.idle { background: var(--text-dim); }
.status-dot.working { background: var(--yellow); }
.status-dot.done { background: var(--green); }
.status-dot.blocked { background: var(--red); }
.status-dot.unknown { background: var(--text-dim); }
.status-dot.task_pending { background: var(--yellow); }

/* ── Terminal ── */
.terminal-wrap { flex: 1; overflow: hidden; position: relative; }
.terminal {
    height: 100%; overflow-y: auto; padding: 0.6rem 0.75rem;
    background: #0d1117;
    font-family: 'JetBrains Mono', 'SF Mono', monospace;
    font-size: 0.7rem; line-height: 1.5;
    white-space: pre-wrap; word-wrap: break-word;
    color: #c9d1d9;
    -webkit-overflow-scrolling: touch;
}
.terminal .prompt { color: var(--accent); font-weight: 500; }
.terminal .tool { color: var(--text-dim); }
.refresh-indicator {
    position: absolute; top: 0.4rem; right: 0.6rem;
    width: 6px; height: 6px; border-radius: 50%; background: var(--green);
    opacity: 0.5; animation: pulse 3s infinite;
}
@keyframes pulse { 0%, 100% { opacity: 0.5; } 50% { opacity: 0.15; } }

/* ── Input Area ── */
.input-area {
    flex-shrink: 0; border-top: 1px solid var(--border);
    background: var(--bg-card); padding: 0.5rem 0.6rem;
    display: flex; flex-direction: column; gap: 0.4rem;
}
.input-area textarea {
    width: 100%; background: var(--bg-input); border: 1px solid var(--border);
    border-radius: var(--radius); color: var(--text-bright);
    padding: 0.55rem 0.75rem; font-size: 0.85rem;
    font-family: 'Inter', sans-serif; resize: none; outline: none;
    min-height: 42px; max-height: 120px; line-height: 1.4;
    transition: border-color 0.15s;
}
.input-area textarea:focus { border-color: var(--border-focus); }
.input-area textarea::placeholder { color: var(--text-dim); }
.btn-row {
    display: flex; gap: 0.35rem; justify-content: flex-end;
}
.btn-row button {
    font-family: 'Inter', sans-serif;
    font-size: 0.75rem; font-weight: 500;
    padding: 0.4rem 0.7rem; border-radius: 7px; cursor: pointer;
    border: 1px solid var(--border); background: var(--bg-input);
    color: var(--text); transition: all 0.15s;
    touch-action: manipulation;
}
.btn-row button:active { transform: scale(0.96); }
.btn-row button:hover { background: var(--border); }
.btn-row .send-btn {
    background: var(--accent); border-color: var(--accent); color: #fff;
    padding: 0.4rem 1rem;
}
.btn-row .send-btn:hover { background: var(--accent-hover); }
.btn-row .mic-active {
    background: var(--red) !important; border-color: var(--red) !important;
    color: #fff !important; animation: mic-pulse 1s infinite;
}
@keyframes mic-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } }

/* ── Mobile tweaks ── */
@media (max-width: 640px) {
    .header h1 { font-size: 0.8rem; }
    .header .links { gap: 0.5rem; font-size: 0.7rem; }
    .agent-tab { font-size: 0.68rem; padding: 0.3rem 0.55rem; }
    .terminal { font-size: 0.65rem; padding: 0.5rem; }
    .input-area textarea { font-size: 0.85rem; }
    .btn-row button { font-size: 0.72rem; padding: 0.35rem 0.6rem; }
}
</style>
</head>
<body>

<div class="header">
    <h1>AGENTS</h1>
    <div class="links">
        <a href="/">Dashboard</a>
        <a href="/chat">Chat</a>
        <a href="/browse/">Files</a>
    </div>
</div>

<div class="agent-tabs" id="agentTabs"></div>

<div class="terminal-wrap">
    <div class="refresh-indicator"></div>
    <div class="terminal" id="terminal"></div>
</div>

<div class="input-area">
    <textarea id="msgInput" placeholder="Message agent..." rows="1"
        onkeydown="if(event.key==='Enter' && !event.shiftKey){event.preventDefault();sendToAgent();}"
        oninput="autoResize(this)"></textarea>
    <div class="btn-row">
        <button onclick="interruptAgent()" style="color:#cf222e;">Esc</button>
        <button onclick="refreshPane()">Refresh</button>
        <button id="micBtn" onclick="toggleMic()">Mic</button>
        <button id="ttsBtn" onclick="readLatestResponse()">Read</button>
        <button class="send-btn" onclick="sendToAgent()">Send</button>
    </div>
</div>
<script>
let currentAgent = null;
let agents = [];
let refreshInterval = null;
let autoScroll = true;
let agentsPassword = null;

// Prompt for password on load
function checkPassword() {
    agentsPassword = prompt('Enter password to access agents:');
    if (!agentsPassword) {
        document.body.innerHTML = '<div style="text-align:center;padding:4rem;color:#8b949e;">Access denied.</div>';
        return false;
    }
    return true;
}
if (!checkPassword()) throw new Error('no auth');

const terminal = document.getElementById('terminal');
const tabsEl = document.getElementById('agentTabs');
const inputEl = document.getElementById('msgInput');

function autoResize(el) {
    el.style.height = 'auto';
    el.style.height = Math.min(el.scrollHeight, 120) + 'px';
}

// Track scroll position to auto-scroll only if at bottom
terminal.addEventListener('scroll', () => {
    autoScroll = terminal.scrollTop + terminal.clientHeight >= terminal.scrollHeight - 30;
});

async function loadAgents() {
    const res = await fetch('/api/agents/list', {headers:{'X-Agents-Password':agentsPassword}});
    if (res.status === 401) {
        agentsPassword = null;
        document.body.innerHTML = '<div style="text-align:center;padding:4rem;color:#f85149;">Wrong password. <a href="/agents" style="color:#58a6ff;">Try again</a></div>';
        return;
    }
    agents = await res.json();
    renderTabs();
    // Select agent from URL or first
    const path = window.location.pathname.split('/agents/')[1];
    const target = path ? decodeURIComponent(path) : (agents[0] && agents[0].name);
    if (target) selectAgent(target);
}

function renderTabs() {
    tabsEl.innerHTML = agents.map(a => {
        const active = a.name === currentAgent ? 'active' : '';
        return `<button class="agent-tab ${active}" onclick="selectAgent('${a.name}')">
            ${a.name}<span class="status-dot ${a.status}" title="${a.status}"></span>
        </button>`;
    }).join('');
}

function selectAgent(name) {
    currentAgent = name;
    renderTabs();
    history.replaceState(null, '', '/agents/' + name);
    inputEl.placeholder = 'Send message to ' + name + '...';
    refreshPane();
    if (refreshInterval) clearInterval(refreshInterval);
    refreshInterval = setInterval(refreshPane, 3000);
}

async function refreshPane() {
    if (!currentAgent) return;
    // Don't replace innerHTML while the user is selecting text in the pane —
    // wholesale DOM replacement destroys the selection anchor and makes the
    // selection jump across lines.
    const sel = window.getSelection();
    if (sel && sel.toString().length > 0 && terminal.contains(sel.anchorNode)) return;
    const res = await fetch(`/api/agents/${currentAgent}/pane?lines=300`, {headers:{'X-Agents-Password':agentsPassword}});
    const data = await res.json();
    if (data.error) {
        terminal.textContent = data.error;
        return;
    }
    // Highlight prompts and structure
    let html = data.output
        .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
        .replace(/^(❯ .*)$/gm, '<span class="prompt">$1</span>')
        .replace(/^(● .*)$/gm, '<span class="tool">$1</span>');
    terminal.innerHTML = html;
    if (autoScroll) terminal.scrollTop = terminal.scrollHeight;
}

async function interruptAgent() {
    if (!currentAgent || !agentsPassword) return;
    await fetch(`/api/agents/${currentAgent}/interrupt`, {
        method: 'POST', headers: {'X-Agents-Password': agentsPassword},
    });
    setTimeout(refreshPane, 500);
}

async function sendToAgent() {
    let text = inputEl.value.trim();
    if (!text || !currentAgent) return;
    if (isListening || inputEl.dataset.fromMic) {
        text = '(note this text is coming from dictation so interpret likely typos as you best see fit) ' + text;
    }
    inputEl.dataset.fromMic = '';
    inputEl.value = '';
    inputEl.style.height = 'auto';
    stopMic();
    await fetch(`/api/agents/${currentAgent}/send`, {
        method: 'POST',
        headers: {'Content-Type': 'application/json', 'X-Agents-Password': agentsPassword},
        body: JSON.stringify({text: text}),
    });
    // Quick refresh after sending
    setTimeout(refreshPane, 500);
    setTimeout(refreshPane, 2000);
}

// Speech-to-text via Whisper
let mediaRecorder = null;
let audioChunks = [];
let isListening = false;

async function toggleMic() {
    if (isListening) { stopMic(); return; }
    try {
        const stream = await navigator.mediaDevices.getUserMedia({audio: true});
        mediaRecorder = new MediaRecorder(stream, {mimeType: 'audio/webm;codecs=opus'});
        audioChunks = [];
        mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) audioChunks.push(e.data); };
        mediaRecorder.onstop = async () => {
            stream.getTracks().forEach(t => t.stop());
            if (audioChunks.length === 0) return;
            const btn = document.getElementById('micBtn');
            btn.textContent = '...';
            const blob = new Blob(audioChunks, {type: 'audio/webm'});
            const form = new FormData();
            form.append('audio', blob, 'recording.webm');
            try {
                const res = await fetch('/api/transcribe', {method: 'POST', body: form});
                const data = await res.json();
                if (data.text) {
                    inputEl.value = (inputEl.value ? inputEl.value + ' ' : '') + data.text;
                    inputEl.dataset.fromMic = '1';
                    autoResize(inputEl);
                } else if (data.error) {
                    alert('Transcription error: ' + data.error);
                }
            } catch(e) { alert('Transcription failed: ' + e.message); }
            btn.textContent = 'Mic'; btn.classList.remove('mic-active');
        };
        mediaRecorder.start(250);
        isListening = true;
        document.getElementById('micBtn').classList.add('mic-active');
        document.getElementById('micBtn').textContent = 'Stop';
    } catch(e) { alert('Microphone access denied: ' + e.message); }
}

function stopMic() {
    if (mediaRecorder && mediaRecorder.state !== 'inactive') mediaRecorder.stop();
    isListening = false;
    // Button update happens in onstop handler
}

// Text-to-speech with pre-generation
let ttsAudio = null;
let ttsPlaying = false;
let ttsCachedBlob = null;
let ttsCachedText = '';
let ttsGenerating = false;
let lastTerminalContent = '';

// Pre-generate TTS when terminal content changes
function checkAndPregenerate() {
    const text = terminal.textContent || '';
    if (text === lastTerminalContent) return;
    lastTerminalContent = text;

    // Extract latest response
    const responseText = extractLatestResponse(text);
    if (!responseText || responseText === ttsCachedText || responseText.length < 20) return;

    // Pre-generate in background
    ttsCachedText = responseText;
    ttsCachedBlob = null;
    ttsGenerating = true;
    const btn = document.getElementById('ttsBtn');
    btn.textContent = 'Read';

    fetch('/api/tts', {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({text: responseText}),
    }).then(res => {
        if (!res.ok) throw new Error('pre-gen failed');
        return res.blob();
    }).then(blob => {
        ttsCachedBlob = blob;
        ttsGenerating = false;
        btn.textContent = 'Read \\u25B6';
    }).catch(e => {
        ttsGenerating = false;
    });
}

// Run pre-generation check every 5 seconds
setInterval(checkAndPregenerate, 5000);

function extractLatestResponse(text) {
    const lines = text.split('\\n');
    let lastResponseStart = -1;
    for (let i = 0; i < lines.length; i++) {
        const line = lines[i].trim();
        if (line.startsWith('\\u25cf') && !line.match(/^\\u25cf (Bash|Read|Write|Edit|Glob|Grep|Search|Agent)\\(/)) {
            lastResponseStart = i;
        }
    }
    if (lastResponseStart === -1) return '';
    let responseLines = [];
    for (let i = lastResponseStart; i < lines.length; i++) {
        const line = lines[i].trim();
        if (i > lastResponseStart && (line.startsWith('\\u2771') || line.startsWith('\\u2500\\u2500'))) break;
        if (line.startsWith('\\u25cf Bash(') || line.startsWith('\\u25cf Read(') || line.startsWith('\\u25cf Write(') ||
            line.startsWith('\\u25cf Edit(') || line.startsWith('\\u23bf') || line.startsWith('\\u273b') ||
            line.startsWith('\\u2722') || line.match(/^\\s*$/) || line.includes('ctrl+o to expand') ||
            line.includes('auto-compact') || line.includes('bypass permissions')) continue;
        let clean = line.replace(/^\\u25cf /, '');
        if (clean) responseLines.push(clean);
    }
    return responseLines.join(' ');
}

function readLatestResponse() {
    const btn = document.getElementById('ttsBtn');

    // Stop if already playing
    if (ttsPlaying) {
        if (ttsAudio) { ttsAudio.pause(); ttsAudio = null; }
        ttsPlaying = false;
        btn.textContent = ttsCachedBlob ? 'Read \\u25B6' : 'Read';
        btn.style.background = ''; btn.style.borderColor = ''; btn.style.color = '';
        return;
    }

    // Use pre-cached audio if available
    if (ttsCachedBlob) {
        const url = URL.createObjectURL(ttsCachedBlob);
        ttsAudio = new Audio(url);
        ttsPlaying = true;
        btn.textContent = 'Stop'; btn.style.background = '#cf222e'; btn.style.borderColor = '#cf222e'; btn.style.color = '#fff';
        ttsAudio.onended = () => {
            ttsPlaying = false;
            btn.textContent = 'Read \\u25B6'; btn.style.background = ''; btn.style.borderColor = ''; btn.style.color = '';
        };
        ttsAudio.play();
        return;
    }

    // If still generating, wait
    if (ttsGenerating) {
        btn.textContent = '...';
        return;
    }

    // Fallback: generate on demand
    const responseText = extractLatestResponse(terminal.textContent || '');
    if (!responseText.trim()) { alert('No readable response.'); return; }

    btn.textContent = '...'; btn.style.background = '#9a6700'; btn.style.borderColor = '#9a6700'; btn.style.color = '#fff';
    ttsPlaying = true;

    fetch('/api/tts', {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({text: responseText}),
    }).then(res => {
        if (!res.ok) throw new Error('TTS failed: ' + res.status);
        return res.blob();
    }).then(blob => {
        ttsCachedBlob = blob;
        ttsCachedText = responseText;
        const url = URL.createObjectURL(blob);
        ttsAudio = new Audio(url);
        btn.textContent = 'Stop'; btn.style.background = '#cf222e'; btn.style.borderColor = '#cf222e'; btn.style.color = '#fff';
        ttsAudio.onended = () => {
            ttsPlaying = false;
            btn.textContent = 'Read \\u25B6'; btn.style.background = ''; btn.style.borderColor = ''; btn.style.color = '';
        };
        ttsAudio.play();
    }).catch(e => {
        ttsPlaying = false;
        btn.textContent = 'Read'; btn.style.background = ''; btn.style.borderColor = ''; btn.style.color = '';
        alert('TTS failed: ' + e.message);
    });
}

loadAgents();
</script>
</body>
</html>"""


# Whisper model (lazy-loaded)
_whisper_model = None

def _get_whisper():
    global _whisper_model
    if _whisper_model is None:
        from faster_whisper import WhisperModel
        _whisper_model = WhisperModel("small", device="cpu", compute_type="int8")
    return _whisper_model


@app.route("/api/transcribe", methods=["POST"])
def api_transcribe():
    """Transcribe audio using Whisper."""
    import tempfile
    if "audio" not in request.files:
        return jsonify({"error": "No audio file"}), 400
    audio = request.files["audio"]
    tmp = tempfile.NamedTemporaryFile(suffix=".webm", delete=False)
    audio.save(tmp.name)
    tmp.close()
    try:
        if os.path.getsize(tmp.name) < 1024:
            return jsonify({"text": "", "warning": "recording too short or empty"})
        model = _get_whisper()
        segments, _ = model.transcribe(tmp.name, language="en")
        text = " ".join(seg.text.strip() for seg in segments)
        return jsonify({"text": text})
    except Exception as e:
        return jsonify({"error": str(e)}), 500
    finally:
        os.unlink(tmp.name)


@app.route("/api/tts", methods=["POST"])
def api_tts():
    """Convert text to speech using Piper neural TTS."""
    import tempfile

    data = request.get_json()
    text = data.get("text", "")
    if not text:
        return jsonify({"error": "No text provided"}), 400

    # Truncate very long text to ~2000 chars to avoid timeouts
    if len(text) > 2000:
        text = text[:2000] + "... text truncated for speech."

    try:
        tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
        tmp.close()
        result = subprocess.run(
            ["python", "-m", "piper",
             "--model", "/data/cameron/.piper_voices/en_US-ryan-high.onnx",
             "--output_file", tmp.name],
            input=text, capture_output=True, text=True, timeout=120,
        )
        if result.returncode != 0:
            return jsonify({"error": result.stderr}), 500
        return send_file(tmp.name, mimetype="audio/wav")
    except subprocess.TimeoutExpired:
        return jsonify({"error": "TTS timed out — text too long"}), 500
    except Exception as e:
        return jsonify({"error": str(e)}), 500


@app.route("/api/chat/clear", methods=["POST"])
def api_chat_clear():
    from chat import clear_conversation
    data = request.get_json()
    session_id = data.get("session_id", "")
    clear_conversation(session_id)
    return jsonify({"status": "cleared"})


MOBILE_CHATTER_HTML = """<!DOCTYPE html>
<html><head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>PARA Mobile</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap');
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Inter', sans-serif; height: 100vh; height: 100dvh;
       display: flex; flex-direction: column; background: #fff; overflow: hidden; }
.header { padding: 0.4rem 0.6rem; border-bottom: 1px solid #d8dee4; background: #f6f8fa;
           display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; }
.header h1 { font-size: 0.8rem; font-weight: 600; color: #1f2328; }
.header .links { display: flex; gap: 0.6rem; font-size: 0.7rem; }
.header a { color: #656d76; text-decoration: none; }
.iframe-wrap { height: 35%; flex-shrink: 0; border-bottom: 1px solid #d8dee4; overflow: hidden; position: relative; }
.iframe-wrap iframe { width: 100%; height: 100%; border: none; }
.iframe-label { position: absolute; bottom: 0.3rem; right: 0.5rem; font-size: 0.6rem; color: #656d76;
                background: rgba(255,255,255,0.85); padding: 0.1rem 0.4rem; border-radius: 4px; }
.resize-handle { height: 6px; background: #f6f8fa; cursor: ns-resize; border-bottom: 1px solid #d8dee4;
                 flex-shrink: 0; display: flex; align-items: center; justify-content: center; }
.resize-handle::after { content: ''; width: 30px; height: 2px; background: #d8dee4; border-radius: 1px; }
.terminal-wrap { flex: 1; overflow: hidden; position: relative; }
.terminal { height: 100%; overflow-y: auto; padding: 0.5rem; background: #0d1117;
            font-family: 'JetBrains Mono', monospace; font-size: 0.65rem; line-height: 1.45;
            white-space: pre-wrap; word-wrap: break-word; color: #c9d1d9; -webkit-overflow-scrolling: touch; }
.terminal .prompt { color: #58a6ff; font-weight: 500; }
.terminal .tool { color: #8b949e; }
.refresh-dot { position: absolute; top: 0.3rem; right: 0.4rem; width: 5px; height: 5px;
               border-radius: 50%; background: #3fb950; opacity: 0.4; animation: pulse 3s infinite; }
@keyframes pulse { 0%,100% { opacity: 0.4; } 50% { opacity: 0.1; } }
.input-area { flex-shrink: 0; border-top: 1px solid #d8dee4; background: #f6f8fa; padding: 0.4rem 0.5rem;
              display: flex; flex-direction: column; gap: 0.3rem; }
.input-area textarea { width: 100%; background: #f0f2f5; border: 1px solid #d8dee4; border-radius: 8px;
                       color: #1f2328; padding: 0.5rem 0.65rem; font-size: 0.85rem; font-family: 'Inter', sans-serif;
                       resize: none; outline: none; min-height: 38px; max-height: 100px; line-height: 1.35; }
.input-area textarea:focus { border-color: #0969da; }
.btn-row { display: flex; gap: 0.3rem; justify-content: flex-end; }
.btn-row button { font-family: 'Inter', sans-serif; font-size: 0.72rem; font-weight: 500;
                  padding: 0.35rem 0.6rem; border-radius: 6px; cursor: pointer;
                  border: 1px solid #d8dee4; background: #f0f2f5; color: #1f2328; touch-action: manipulation; }
.btn-row button:active { transform: scale(0.96); }
.btn-row .send-btn { background: #0969da; border-color: #0969da; color: #fff; padding: 0.35rem 0.9rem; }
.btn-row .mic-active { background: #cf222e !important; border-color: #cf222e !important; color: #fff !important; }
.auth-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: #fff;
                display: flex; align-items: center; justify-content: center; z-index: 100; }
.auth-overlay.hidden { display: none; }
.auth-box { text-align: center; }
.auth-box input { padding: 0.6rem; border: 1px solid #d8dee4; border-radius: 8px; font-size: 1rem; width: 200px; }
.auth-box button { margin-top: 0.5rem; padding: 0.5rem 1.5rem; background: #0969da; color: #fff;
                   border: none; border-radius: 8px; font-size: 0.9rem; cursor: pointer; }
</style></head><body>
<div class="auth-overlay" id="authOverlay">
    <div class="auth-box">
        <h2 style="margin-bottom:1rem;font-size:1.1rem;">PARA Mobile</h2>
        <input type="password" id="authInput" placeholder="Password" onkeydown="if(event.key==='Enter')doAuth()">
        <br><button onclick="doAuth()">Enter</button>
    </div>
</div>
<div class="header">
    <h1>PARA Mobile</h1>
    <div class="links"><a href="/">Dashboard</a><a href="/agents">Agents</a></div>
</div>
<div class="iframe-wrap" id="iframeWrap">
    <iframe id="viewerIframe" src="/"></iframe>
    <span class="iframe-label" id="iframeLabel">/</span>
</div>
<div class="resize-handle" id="resizeHandle"></div>
<div class="terminal-wrap">
    <div class="refresh-dot"></div>
    <div class="terminal" id="terminal"></div>
</div>
<div class="input-area">
    <textarea id="msgInput" placeholder="Message..." rows="1"
        onkeydown="if(event.key==='Enter' && !event.shiftKey){event.preventDefault();sendMsg();}"
        oninput="this.style.height='auto';this.style.height=Math.min(this.scrollHeight,100)+'px';"></textarea>
    <div class="btn-row">
        <button onclick="interruptAgent()" style="color:#cf222e;">Esc</button>
        <button onclick="refreshPane()">Refresh</button>
        <button id="micBtn" onclick="toggleMic()">Mic</button>
        <button id="ttsBtn" onclick="readResponse()">Read</button>
        <button class="send-btn" onclick="sendMsg()">Send</button>
    </div>
</div>
<script>
let pw = null;
const AGENT = 'mobile_chat';
const terminal = document.getElementById('terminal');
const inputEl = document.getElementById('msgInput');
let autoScroll = true;
let lastIframeUrl = '/';

document.getElementById('authInput').focus();
terminal.addEventListener('scroll', () => {
    autoScroll = terminal.scrollTop + terminal.clientHeight >= terminal.scrollHeight - 30;
});

function doAuth() {
    pw = document.getElementById('authInput').value;
    fetch('/api/agents/list', {headers:{'X-Agents-Password': pw}}).then(r => {
        if (r.ok) { document.getElementById('authOverlay').classList.add('hidden'); init(); }
        else { alert('Wrong password'); }
    });
}

function init() { refreshPane(); setInterval(refreshPane, 3000); setInterval(checkIframeUrl, 3000); }

async function refreshPane() {
    if (!pw) return;
    // Skip refresh while user has an active selection inside the pane.
    const sel = window.getSelection();
    if (sel && sel.toString().length > 0 && terminal.contains(sel.anchorNode)) return;
    const res = await fetch('/api/agents/' + AGENT + '/pane?lines=200', {headers:{'X-Agents-Password': pw}});
    const data = await res.json();
    if (data.error) return;
    terminal.innerHTML = data.output
        .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
        .replace(/^(\\u2771 .*)$/gm, '<span class="prompt">$1</span>')
        .replace(/^(\\u25cf .*)$/gm, '<span class="tool">$1</span>');
    if (autoScroll) terminal.scrollTop = terminal.scrollHeight;
}

async function checkIframeUrl() {
    try {
        const res = await fetch('/api/mobile_chatter/iframe_url?' + Date.now());
        const url = await res.text();
        if (url && url !== lastIframeUrl) {
            lastIframeUrl = url;
            document.getElementById('viewerIframe').src = url;
            document.getElementById('iframeLabel').textContent = url;
        }
    } catch(e) {}
}

async function interruptAgent() {
    if (!pw) return;
    await fetch('/api/agents/' + AGENT + '/interrupt', {
        method: 'POST', headers: {'X-Agents-Password': pw},
    });
    setTimeout(refreshPane, 500);
}

async function sendMsg() {
    let text = inputEl.value.trim();
    if (!text || !pw) return;
    if (inputEl.dataset.fromMic) text = '(note this text is coming from dictation so interpret likely typos as you best see fit) ' + text;
    inputEl.dataset.fromMic = '';
    inputEl.value = '';
    inputEl.style.height = 'auto';
    stopMic();
    await fetch('/api/agents/' + AGENT + '/send', {
        method: 'POST', headers: {'Content-Type': 'application/json', 'X-Agents-Password': pw},
        body: JSON.stringify({text: text}),
    });
    setTimeout(refreshPane, 500);
    setTimeout(refreshPane, 2000);
}

// Mic (Whisper)
let mediaRecorder = null, audioChunks = [], isListening = false;
async function toggleMic() {
    if (isListening) { stopMic(); return; }
    try {
        const stream = await navigator.mediaDevices.getUserMedia({audio: true});
        mediaRecorder = new MediaRecorder(stream, {mimeType: 'audio/webm;codecs=opus'});
        audioChunks = [];
        mediaRecorder.ondataavailable = e => { if (e.data.size > 0) audioChunks.push(e.data); };
        mediaRecorder.onstop = async () => {
            stream.getTracks().forEach(t => t.stop());
            if (!audioChunks.length) return;
            document.getElementById('micBtn').textContent = '...';
            const form = new FormData();
            form.append('audio', new Blob(audioChunks, {type: 'audio/webm'}), 'rec.webm');
            try {
                const res = await fetch('/api/transcribe', {method: 'POST', body: form});
                const data = await res.json();
                if (data.text) { inputEl.value = (inputEl.value ? inputEl.value+' ':'')+data.text; inputEl.dataset.fromMic='1'; }
            } catch(e) { alert('Transcription failed'); }
            document.getElementById('micBtn').textContent = 'Mic';
            document.getElementById('micBtn').classList.remove('mic-active');
        };
        mediaRecorder.start(250); isListening = true;
        document.getElementById('micBtn').classList.add('mic-active');
        document.getElementById('micBtn').textContent = 'Stop';
    } catch(e) { alert('Mic denied'); }
}
function stopMic() { if (mediaRecorder && mediaRecorder.state !== 'inactive') mediaRecorder.stop(); isListening = false; }

// TTS
let ttsAudio = null, ttsPlaying = false;
function extractResponse(text) {
    const lines = text.split('\\n'); let start = -1;
    for (let i = 0; i < lines.length; i++) { const l = lines[i].trim();
        if (l.startsWith('\\u25cf') && !l.match(/^\\u25cf (Bash|Read|Write|Edit|Glob|Grep|Search|Agent)\\(/)) start = i; }
    if (start === -1) return '';
    let out = [];
    for (let i = start; i < lines.length; i++) { const l = lines[i].trim();
        if (i > start && (l.startsWith('\\u2771') || l.startsWith('\\u2500\\u2500'))) break;
        if (l.startsWith('\\u25cf Bash(') || l.startsWith('\\u25cf Read(') || l.startsWith('\\u25cf Write(') ||
            l.startsWith('\\u25cf Edit(') || l.startsWith('\\u23bf') || l.startsWith('\\u273b') || l.startsWith('\\u2722') ||
            l.match(/^\\s*$/) || l.includes('ctrl+o') || l.includes('auto-compact') || l.includes('bypass')) continue;
        let c = l.replace(/^\\u25cf /, ''); if (c) out.push(c); }
    return out.join(' ');
}
function readResponse() {
    const btn = document.getElementById('ttsBtn');
    if (ttsPlaying) { if (ttsAudio) { ttsAudio.pause(); ttsAudio = null; } ttsPlaying = false;
        btn.textContent = 'Read'; btn.style.background = ''; btn.style.borderColor = ''; btn.style.color = ''; return; }
    const text = extractResponse(terminal.textContent || '');
    if (!text.trim()) { alert('No response'); return; }
    btn.textContent = '...'; btn.style.background = '#9a6700'; btn.style.borderColor = '#9a6700'; btn.style.color = '#fff';
    ttsPlaying = true;
    fetch('/api/tts', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({text})})
    .then(r => { if(!r.ok) throw new Error(r.status); return r.blob(); })
    .then(blob => { ttsAudio = new Audio(URL.createObjectURL(blob));
        btn.textContent='Stop'; btn.style.background='#cf222e'; btn.style.borderColor='#cf222e';
        ttsAudio.onended = () => { ttsPlaying=false; btn.textContent='Read'; btn.style.background=''; btn.style.borderColor=''; btn.style.color=''; };
        ttsAudio.play(); })
    .catch(e => { ttsPlaying=false; btn.textContent='Read'; btn.style.background=''; btn.style.borderColor=''; btn.style.color=''; alert('TTS failed'); });
}

// Resize handle
const handle = document.getElementById('resizeHandle'), iframeWrap = document.getElementById('iframeWrap');
let startY, startH;
handle.addEventListener('mousedown', e => { startY=e.clientY; startH=iframeWrap.offsetHeight;
    document.addEventListener('mousemove', onDrag); document.addEventListener('mouseup', stopDrag); });
handle.addEventListener('touchstart', e => { startY=e.touches[0].clientY; startH=iframeWrap.offsetHeight;
    document.addEventListener('touchmove', onDragT); document.addEventListener('touchend', stopDrag); }, {passive:true});
function onDrag(e) { iframeWrap.style.height = Math.max(50, startH+e.clientY-startY)+'px'; }
function onDragT(e) { iframeWrap.style.height = Math.max(50, startH+e.touches[0].clientY-startY)+'px'; }
function stopDrag() { document.removeEventListener('mousemove',onDrag); document.removeEventListener('mouseup',stopDrag);
    document.removeEventListener('touchmove',onDragT); document.removeEventListener('touchend',stopDrag); }
</script>
</body>
</html>"""


@app.route("/paper_figures")
@app.route("/paper_figures/")
def paper_figures():
    """Render paper figures review page."""
    md_file = REPORTS_DIR / "paper_figures" / "notes.md"
    if not md_file.exists():
        return "Not found", 404
    md_content = md_file.read_text()
    return MEETING_NOTE_HTML.replace("{{NOTE_ID}}", "paper_figures").replace("{{MD_CONTENT}}", md_content.replace("`", "\\`").replace("${", "\\${"))


@app.route("/paper_figures/media/<path:filename>")
def paper_figures_media(filename):
    return send_from_directory(REPORTS_DIR / "paper_figures" / "media", filename)


@app.route("/para_presentation")
@app.route("/para_presentation/")
def para_presentation():
    """Render lab presentation deck (markdown-as-slides)."""
    md_file = REPORTS_DIR / "para_presentation" / "notes.md"
    if not md_file.exists():
        return "Not found", 404
    md_content = md_file.read_text()
    return MEETING_NOTE_HTML.replace("{{NOTE_ID}}", "para_presentation").replace("{{MD_CONTENT}}", md_content.replace("`", "\\`").replace("${", "\\${"))


@app.route("/para_presentation/media/<path:filename>")
def para_presentation_media(filename):
    return send_from_directory(REPORTS_DIR / "para_presentation" / "media", filename)


# ───────────────── TRI diary ─────────────────
TRI_DIARY_DIR = Path("/data/cameron/tri_diary")


@app.route("/tri_diaries")
@app.route("/tri_diaries/")
def tri_diaries():
    idx = TRI_DIARY_DIR / "index.html"
    if not idx.exists():
        return "TRI diary not built yet — run /data/cameron/tri_diary/build.py", 404
    return send_from_directory(TRI_DIARY_DIR, "index.html")


# ───────────────── SVG editor (SVG-Edit 7 wrapper) ─────────────────
SVG_EDITOR_DIR = REPORTS_DIR / "svg_editor"
SVG_SOURCE_DIR = Path("/data/cameron/para/paper/figs/svg")


@app.route("/svg_editor")
@app.route("/svg_editor/")
def svg_editor_index():
    return send_from_directory(SVG_EDITOR_DIR, "editor.html")


@app.route("/svg_editor/static/<path:sub>")
def svg_editor_static(sub):
    return send_from_directory(SVG_EDITOR_DIR / "svgedit", sub)


@app.route("/svg_editor/files")
def svg_editor_files():
    all_svgs = sorted(p.name for p in SVG_SOURCE_DIR.glob("*.svg"))
    originals = [f for f in all_svgs if not f.endswith("_edited.svg")]
    edited = [f for f in all_svgs if f.endswith("_edited.svg")]
    return jsonify({"originals": originals, "edited": edited})


@app.route("/svg_editor/load/<fname>")
def svg_editor_load(fname):
    if ".." in fname or "/" in fname or not fname.endswith(".svg"):
        return "bad name", 400
    path = SVG_SOURCE_DIR / fname
    if not path.exists():
        return "not found", 404
    return Response(path.read_text(), mimetype="image/svg+xml")


@app.route("/svg_editor/save/<fname>", methods=["POST"])
def svg_editor_save(fname):
    if ".." in fname or "/" in fname or not fname.endswith(".svg"):
        return jsonify({"error": "bad name"}), 400
    stem = fname[:-4]
    if stem.endswith("_edited"):
        out = SVG_SOURCE_DIR / fname  # re-save over the edited file
    else:
        out = SVG_SOURCE_DIR / f"{stem}_edited.svg"
    body = request.get_data(as_text=True)
    out.write_text(body)
    return jsonify({"saved": str(out), "size": out.stat().st_size})


@app.route("/paper_as_markdown")
@app.route("/paper_as_markdown/")
def paper_as_markdown():
    """Render the paper outline as a collapsible markdown page."""
    md_file = REPORTS_DIR / "paper_as_markdown" / "notes.md"
    if not md_file.exists():
        return "Not found", 404
    md_content = md_file.read_text()
    return MEETING_NOTE_HTML.replace("{{NOTE_ID}}", "paper_as_markdown").replace("{{MD_CONTENT}}", md_content.replace("`", "\\`").replace("${", "\\${"))


@app.route("/paper_as_markdown/media/<path:filename>")
def paper_as_markdown_media(filename):
    return send_from_directory(REPORTS_DIR / "paper_as_markdown" / "media", filename)


@app.route("/<agent>/<path:filename>")
def report_file(agent, filename):
    agent_dir = REPORTS_DIR / agent
    if agent_dir.is_dir():
        return send_from_directory(agent_dir, filename)
    return "Not found", 404


@app.route("/<agent>/")
def agent_index(agent):
    agent_dir = REPORTS_DIR / agent
    if not agent_dir.is_dir():
        return "Not found", 404
    reports = sorted(agent_dir.glob("*.html"), reverse=True)
    if reports:
        return redirect(f"/{agent}/{reports[0].name}")
    return f"<h1>{agent}</h1><p>No reports yet.</p>"


# ---------------------------------------------------------------------------
# File browser rooted at /data/cameron
# ---------------------------------------------------------------------------

BROWSE_STYLE = """
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
       background: #ffffff; color: #1f2328; padding: 1.5rem; line-height: 1.6; }
nav.breadcrumb { margin-bottom: 1rem; font-size: 0.9rem; }
nav.breadcrumb a { color: #0969da; text-decoration: none; }
nav.breadcrumb a:hover { text-decoration: underline; }
h1 { color: #0d1117; font-size: 1.4rem; margin-bottom: 1rem; }
.listing { list-style: none; }
.listing li { display: flex; align-items: center; gap: 0.75rem;
              padding: 0.4rem 0.6rem; border-bottom: 1px solid #d8dee4; }
.listing li:hover { background: #f6f8fa; border-radius: 4px; }
.listing a { color: #0969da; text-decoration: none; flex: 1; }
.listing a:hover { text-decoration: underline; }
.listing .icon { width: 1.2rem; text-align: center; flex-shrink: 0; }
.listing .size, .listing .date { color: #656d76; font-size: 0.8rem; white-space: nowrap; }
.media-preview { margin: 1.5rem 0; text-align: center; }
.media-preview video, .media-preview img { max-width: 100%; max-height: 80vh; border-radius: 6px; }
.media-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; margin-top: 1rem; }
.media-grid .card { background: #f6f8fa; border: 1px solid #d8dee4; border-radius: 8px;
                    overflow: hidden; }
.media-grid .card video, .media-grid .card img { width: 100%; display: block; }
.media-grid .card .label { padding: 0.4rem 0.6rem; font-size: 0.8rem; color: #656d76;
                           white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.media-grid .card .label a { color: #0969da; text-decoration: none; }
.back-link { display: inline-block; margin-bottom: 0.5rem; color: #0969da; text-decoration: none; }
.toggle-btn { background: #f6f8fa; color: #1f2328; border: 1px solid #d8dee4; padding: 0.3rem 0.8rem;
              border-radius: 4px; cursor: pointer; font-size: 0.85rem; margin-bottom: 1rem; }
.toggle-btn:hover { background: #eef1f5; }
</style>
"""

VIDEO_EXTS = {".mp4", ".webm", ".mov", ".avi", ".mkv"}
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"}

# ---------------------------------------------------------------------------
# On-demand thumbnail cache (used by the data viewer for fast scrubbing)
# Cache lives at REPORTS_DIR / _thumb_cache / <sha1>.jpg, keyed on source
# path + mtime + size so edits invalidate automatically.
# ---------------------------------------------------------------------------
import hashlib as _hashlib
_THUMB_CACHE_DIR = REPORTS_DIR / "_thumb_cache"
_THUMB_CACHE_DIR.mkdir(exist_ok=True)
_ALLOWED_THUMB_SIZES = (64, 96, 128, 192, 256, 320, 384, 512, 640, 768, 960, 1280, 1536)


def _thumb_cache_path(full_path: Path, size: int) -> Path:
    st = full_path.stat()
    key = f"{full_path}|{st.st_mtime_ns}|{size}"
    h = _hashlib.sha1(key.encode()).hexdigest()[:20]
    return _THUMB_CACHE_DIR / f"{h}.jpg"


def _ensure_thumb(full_path: Path, size: int):
    """Return a Path to a cached JPEG thumbnail (longest-edge = `size`), or None on failure."""
    tp = _thumb_cache_path(full_path, size)
    if tp.exists() and tp.stat().st_size > 0:
        return tp
    try:
        from PIL import Image
        with Image.open(full_path) as img:
            img = img.convert("RGB")
            img.thumbnail((size, size), Image.LANCZOS)
            # `progressive` interleaves scans so the browser can show a blurry
            # version before the full image finishes decoding.
            img.save(str(tp), "JPEG", quality=82, progressive=True, optimize=False)
        return tp
    except Exception as e:
        try:
            if tp.exists():
                tp.unlink()
        except OSError:
            pass
        app.logger.warning(f"thumb failed for {full_path} @ {size}: {e}")
        return None


def _needs_transcode(file_path: Path) -> bool:
    """Check if a video uses a codec browsers can't play (e.g. mpeg4 part 2)."""
    if file_path.suffix.lower() not in VIDEO_EXTS:
        return False
    try:
        r = subprocess.run(
            ["ffprobe", "-v", "quiet", "-select_streams", "v:0",
             "-show_entries", "stream=codec_name", "-of", "csv=p=0", str(file_path)],
            capture_output=True, text=True, timeout=5,
        )
        codec = r.stdout.strip().lower()
        # h264/h265/vp8/vp9/av1 are browser-safe; mpeg4/msmpeg4/etc are not
        return codec not in ("h264", "hevc", "h265", "vp8", "vp9", "av1")
    except Exception:
        return False


# Cache transcoded files to avoid re-encoding
TRANSCODE_CACHE = Path("/tmp/para_transcode_cache")
TRANSCODE_CACHE.mkdir(exist_ok=True)


def _get_transcoded(file_path: Path) -> Path:
    """Return path to an H.264 transcoded version, cached on disk."""
    import hashlib
    # Use file path + mtime as cache key
    key = f"{file_path}:{file_path.stat().st_mtime}"
    cache_name = hashlib.md5(key.encode()).hexdigest() + ".mp4"
    cached = TRANSCODE_CACHE / cache_name
    if not cached.exists():
        subprocess.run(
            ["ffmpeg", "-i", str(file_path), "-c:v", "libx264",
             "-preset", "ultrafast", "-crf", "23", "-movflags", "+faststart",
             "-an", "-f", "mp4", str(cached), "-y"],
            capture_output=True, timeout=120,
        )
    return cached


def _serve_file(file_path: Path) -> Response:
    """Serve a file with manual Range request support (works through tunnels/proxies)."""
    # Transcode browser-incompatible videos to H.264
    if _needs_transcode(file_path):
        file_path = _get_transcoded(file_path)

    file_size = file_path.stat().st_size
    mime_type = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream"

    range_header = request.headers.get("Range")
    if range_header:
        # Parse "bytes=START-END"
        byte_range = range_header.replace("bytes=", "").strip()
        parts = byte_range.split("-")
        start = int(parts[0]) if parts[0] else 0
        end = int(parts[1]) if parts[1] else file_size - 1
        end = min(end, file_size - 1)
        length = end - start + 1

        with open(file_path, "rb") as f:
            f.seek(start)
            data = f.read(length)

        resp = Response(data, status=206, mimetype=mime_type)
        resp.headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
        resp.headers["Content-Length"] = str(length)
        resp.headers["Accept-Ranges"] = "bytes"
        return resp

    # Full file response
    resp = make_response(send_file(file_path, mimetype=mime_type))
    resp.headers["Accept-Ranges"] = "bytes"
    resp.headers["Content-Length"] = str(file_size)
    return resp


def _video_player_page(file_path: Path, rel_path: str) -> str:
    """Serve an HTML page with an embedded video player instead of raw bytes."""
    name = file_path.name
    size = _human_size(file_path.stat().st_size)
    parent = "/".join(rel_path.rstrip("/").split("/")[:-1])
    parent_url = f"/browse/{parent}/" if parent else "/browse/"
    raw_url = f"/browse/{rel_path}?raw"
    return f"""<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{name}</title>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
       background: #0d1117; color: #c9d1d9; padding: 1.5rem; }}
a {{ color: #58a6ff; text-decoration: none; }}
a:hover {{ text-decoration: underline; }}
.player {{ max-width: 960px; margin: 1rem auto; text-align: center; }}
video {{ max-width: 100%; max-height: 80vh; border-radius: 6px; background: #000; }}
.info {{ margin-top: 0.75rem; font-size: 0.85rem; color: #8b949e; }}
.nav {{ margin-bottom: 1rem; }}
.download {{ display: inline-block; margin-top: 0.75rem; padding: 0.4rem 1rem;
             background: #21262d; border: 1px solid #30363d; border-radius: 4px; color: #c9d1d9; }}
</style></head><body>
<div class="nav"><a href="{parent_url}">&larr; Back</a></div>
<div class="player">
<video id="vid" controls autoplay preload="auto"></video>
<div class="info" id="status">Loading {name} ({size})...</div>
<a class="download" href="{raw_url}" download="{name}">Download</a>
</div>
<script>
// Fetch video as blob to bypass tunnel range-request issues
fetch("{raw_url}").then(r => {{
    if (!r.ok) throw new Error("Fetch failed: " + r.status);
    const total = +r.headers.get("content-length") || 0;
    const reader = r.body.getReader();
    const chunks = [];
    let loaded = 0;
    function pump() {{
        return reader.read().then(({{ done, value }}) => {{
            if (done) {{
                const blob = new Blob(chunks, {{ type: "video/mp4" }});
                document.getElementById("vid").src = URL.createObjectURL(blob);
                document.getElementById("status").textContent = "{name} \u00b7 {size}";
                return;
            }}
            chunks.push(value);
            loaded += value.length;
            if (total) {{
                const pct = Math.round(loaded / total * 100);
                document.getElementById("status").textContent = "Loading... " + pct + "%";
            }}
            return pump();
        }});
    }}
    return pump();
}}).catch(e => {{
    document.getElementById("status").textContent = "Error: " + e.message;
    // Fallback: try direct src
    document.getElementById("vid").src = "{raw_url}";
}});
</script>
</body></html>"""


def _image_viewer_page(file_path: Path, rel_path: str) -> str:
    """Serve an HTML page with an embedded image viewer."""
    name = file_path.name
    parent = "/".join(rel_path.rstrip("/").split("/")[:-1])
    parent_url = f"/browse/{parent}/" if parent else "/browse/"
    raw_url = f"/browse/{rel_path}?raw"
    return f"""<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{name}</title>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
       background: #0d1117; color: #c9d1d9; padding: 1.5rem; }}
a {{ color: #58a6ff; text-decoration: none; }}
a:hover {{ text-decoration: underline; }}
.viewer {{ max-width: 960px; margin: 1rem auto; text-align: center; }}
img {{ max-width: 100%; max-height: 85vh; border-radius: 6px; }}
.nav {{ margin-bottom: 1rem; }}
</style></head><body>
<div class="nav"><a href="{parent_url}">&larr; Back</a></div>
<div class="viewer">
<img src="{raw_url}">
</div>
</body></html>"""


def _human_size(nbytes: int) -> str:
    for unit in ("B", "KB", "MB", "GB"):
        if nbytes < 1024:
            return f"{nbytes:.1f} {unit}" if unit != "B" else f"{nbytes} B"
        nbytes /= 1024
    return f"{nbytes:.1f} TB"


def _breadcrumb(rel_path: str) -> str:
    parts = rel_path.strip("/").split("/") if rel_path.strip("/") else []
    crumbs = ['<a href="/browse/">/data/cameron</a>']
    for i, part in enumerate(parts):
        link = "/browse/" + "/".join(parts[: i + 1]) + "/"
        crumbs.append(f'<a href="{link}">{part}</a>')
    return '<nav class="breadcrumb">' + " / ".join(crumbs) + "</nav>"


_BROWSE_ALLOWED_ROOTS = [
    Path("/data/cameron").resolve(),
    Path("/data/libero").resolve(),
    # SSHFS mount of robot-lab via the school→dev (Tailscale) ProxyJump.
    # Exposed at /browse_yam and via the /data/cameron/yam_remote symlink.
    Path("/home/cameronsmith/mnt/robot-lab"),
    # SSHFS mount of yukon (rig host on tailscale).
    # Exposed via the /data/cameron/yukon_remote symlink → /browse/yukon_remote.
    Path("/home/cameronsmith/mnt/yukon"),
]


def _check_browse_path(subpath):
    """Resolve `subpath` under BROWSE_ROOT, rejecting `..` traversal.
    Symlinks that resolve into any allowlisted root (e.g. /data/cameron/libero -> /data/libero) are accepted."""
    if ".." in Path(subpath).parts:
        return None
    candidate = BROWSE_ROOT / subpath
    try:
        resolved = candidate.resolve()
    except Exception:
        return None
    for root in _BROWSE_ALLOWED_ROOTS:
        if str(resolved) == str(root) or str(resolved).startswith(str(root) + "/"):
            return resolved
    return None


@app.route("/browse_yam")
@app.route("/browse_yam/")
def browse_yam():
    """Land on the YAM calibration QA folder served through the SSHFS mount.

    The mount is school-server → dev (Tailscale ProxyJump) → robot-lab. If puget
    is offline, the mount stalls. Triggers a stat() to surface that quickly.
    """
    mount = Path("/home/cameronsmith/mnt/robot-lab")
    try:
        # Cheap probe: list the dir. If sshfs is dead this returns an OS error fast.
        list(mount.iterdir())
    except OSError as e:
        return (f"<h1>YAM mount unavailable</h1>"
                f"<p>SSHFS to robot-lab (via puget) returned: <code>{e}</code></p>"
                f"<p>On the school server run:<br>"
                f"<code>fusermount -u /home/cameronsmith/mnt/robot-lab && "
                f"sshfs robot-lab:/home/robot-lab /home/cameronsmith/mnt/robot-lab "
                f"-o reconnect,ServerAliveInterval=30</code></p>"), 503
    return redirect("/browse/yam_remote/cameron/yam_overlay/calibration_qa/")


@app.route("/browse/")
@app.route("/browse/<path:subpath>")
def browse(subpath=""):
    full_path = _check_browse_path(subpath)
    if full_path is None:
        abort(403)

    if not full_path.exists():
        abort(404)

    # Serve a file directly
    if full_path.is_file():
        ext = full_path.suffix.lower()
        # For video/image files accessed directly in browser, show a player page
        # Use ?raw to get the actual bytes (used by <video>/<img> src)
        if request.args.get("raw") is not None:
            # Optional ?size=N serves a cached JPEG thumbnail (longest edge = N).
            # Restricted to an allowlist so arbitrary callers can't create
            # unbounded cache entries.
            size = request.args.get("size", type=int)
            if size and ext in IMAGE_EXTS:
                if size not in _ALLOWED_THUMB_SIZES:
                    # snap to nearest allowed size
                    size = min(_ALLOWED_THUMB_SIZES, key=lambda s: abs(s - size))
                tp = _ensure_thumb(full_path, size)
                if tp is not None:
                    resp = _serve_file(tp)
                    resp.headers["Cache-Control"] = "public, max-age=86400"
                    return resp
                # fall through to full-res on error
            return _serve_file(full_path)
        if ext in VIDEO_EXTS:
            return _video_player_page(full_path, subpath)
        if ext in IMAGE_EXTS:
            return _image_viewer_page(full_path, subpath)
        return _serve_file(full_path)

    # Directory listing
    entries = []
    media_files = []
    try:
        for item in sorted(full_path.iterdir(), key=lambda p: (p.is_file(), p.name.lower())):
            name = item.name
            if name.startswith("."):
                continue
            rel = subpath.rstrip("/") + "/" + name if subpath else name
            stat = item.stat()
            date = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M")

            if item.is_dir():
                entries.append(
                    f'<li><span class="icon">&#128193;</span>'
                    f'<a href="/browse/{rel}/">{name}/</a>'
                    f'<span class="date">{date}</span></li>'
                )
            else:
                size = _human_size(stat.st_size)
                ext = item.suffix.lower()
                if ext in VIDEO_EXTS:
                    icon = "&#127910;"
                    media_files.append(("video", name, f"/browse/{rel}"))
                elif ext in IMAGE_EXTS:
                    icon = "&#128444;"
                    media_files.append(("image", name, f"/browse/{rel}"))
                else:
                    icon = "&#128196;"
                entries.append(
                    f'<li><span class="icon">{icon}</span>'
                    f'<a href="/browse/{rel}">{name}</a>'
                    f'<span class="size">{size}</span>'
                    f'<span class="date">{date}</span></li>'
                )
    except PermissionError:
        abort(403)

    # Build media grid for directories with videos/images
    media_html = ""
    if media_files:
        cards = []
        for idx, (mtype, mname, murl) in enumerate(media_files):
            raw = f"{murl}?raw"
            if mtype == "video":
                cards.append(
                    f'<div class="card">'
                    f'<video id="v{idx}" controls preload="none"></video>'
                    f'<div class="label"><a href="{murl}">{mname}</a></div></div>'
                )
            else:
                cards.append(
                    f'<div class="card">'
                    f'<img src="{raw}" loading="lazy">'
                    f'<div class="label"><a href="{murl}">{mname}</a></div></div>'
                )
        # Build JS to blob-load videos on visibility
        video_map = {idx: f"{murl}?raw" for idx, (mtype, mname, murl) in enumerate(media_files) if mtype == "video"}
        load_script = ""
        if video_map:
            entries_js = ",".join(f'["{vid_id}","{url}"]' for vid_id, url in
                                  ((f"v{idx}", url) for idx, url in video_map.items()))
            load_script = f"""<script>
const vids = [{entries_js}];
const obs = new IntersectionObserver((entries) => {{
    entries.forEach(e => {{
        if (e.isIntersecting) {{
            const el = e.target;
            if (!el.dataset.loaded) {{
                el.dataset.loaded = "1";
                fetch(el.dataset.src).then(r => r.blob()).then(b => {{
                    el.src = URL.createObjectURL(b);
                }});
            }}
            obs.unobserve(el);
        }}
    }});
}}, {{ rootMargin: "200px" }});
vids.forEach(([id, url]) => {{
    const el = document.getElementById(id);
    if (el) {{ el.dataset.src = url; obs.observe(el); }}
}});
</script>"""

        media_html = (
            '<button class="toggle-btn" onclick="document.getElementById(\'media-grid\').style.display='
            "document.getElementById('media-grid').style.display==='none'?'grid':'none'\">"
            f'Toggle media grid ({len(media_files)} items)</button>'
            f'<div id="media-grid" class="media-grid">{"".join(cards)}</div>'
            f'{load_script}'
        )

    dir_name = "/" + subpath.rstrip("/") if subpath else "/"
    html = f"""<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Browse {dir_name}</title>{BROWSE_STYLE}</head><body>
{_breadcrumb(subpath)}
<h1>{dir_name}</h1>
{media_html}
<ul class="listing">{"".join(entries)}</ul>
</body></html>"""

    return html


def start_tunnel(port: int, method: str = "localtunnel"):
    """Start a tunnel in background with auto-retry. Prints public URL."""
    import time

    while True:
        try:
            if method == "localtunnel":
                print("\nStarting localtunnel...")
                proc = subprocess.Popen(
                    ["npx", "-y", "localtunnel", "--port", str(port)],
                    stdout=subprocess.PIPE,
                    stderr=subprocess.STDOUT,
                    text=True,
                )
                for line in proc.stdout:
                    line = line.strip()
                    if "loca.lt" in line or "your url" in line.lower():
                        url = line.split()[-1] if "http" in line else line
                        print(f"\n{'='*60}")
                        print(f"  PUBLIC URL: {url}")
                        print(f"{'='*60}\n")
                proc.wait()

            elif method == "cloudflared":
                print("\nStarting cloudflared tunnel...")
                proc = subprocess.Popen(
                    ["cloudflared", "tunnel", "--url", f"http://localhost:{port}"],
                    stdout=subprocess.PIPE,
                    stderr=subprocess.STDOUT,
                    text=True,
                )
                for line in proc.stdout:
                    line = line.strip()
                    if ".trycloudflare.com" in line:
                        for word in line.split():
                            if "trycloudflare.com" in word:
                                url = word if word.startswith("http") else f"https://{word}"
                                print(f"\n{'='*60}")
                                print(f"  PUBLIC URL: {url}")
                                print(f"{'='*60}\n")
                                break
                proc.wait()

        except Exception as e:
            print(f"Tunnel error: {e}")

        print("Tunnel disconnected, retrying in 5s...")
        time.sleep(5)


# ── Inference / IK Sanity Check ──────────────────────────────────────────
PANDA_DATA_DIR = Path("/data/cameron/panda_data/single_demo_sanity")
_ik_cache_dir = PANDA_DATA_DIR / "ik_cache"
_ik_cache_dir.mkdir(exist_ok=True)

@app.route("/inference")
@app.route("/inference/")
@app.route("/inference/<path:filename>")
def inference_page(filename="index.html"):
    return send_from_directory(REPORTS_DIR / "inference", filename)

@app.route("/api/inference/episodes")
def inference_episodes():
    ep_file = PANDA_DATA_DIR / "episodes.json"
    if not ep_file.exists():
        return jsonify({"error": "no episodes.json"}), 404
    import json as _json
    with open(ep_file) as f:
        return jsonify(_json.load(f))

@app.route("/api/inference/run_ik", methods=["POST"])
def inference_run_ik():
    data = request.get_json()
    frame_idx = data.get("frame_idx", 0)
    result = _run_ik_for_frame(frame_idx)
    if result is None:
        return jsonify({"error": f"frame {frame_idx} not found"}), 404
    return jsonify(result)

@app.route("/api/inference/run_ik_episode", methods=["POST"])
def inference_run_ik_episode():
    data = request.get_json()
    ep_idx = data.get("episode_idx", 0)
    import json as _json
    with open(PANDA_DATA_DIR / "episodes.json") as f:
        episodes = _json.load(f)["episodes"]
    if ep_idx >= len(episodes):
        return jsonify({"error": "invalid episode"}), 400
    ep = episodes[ep_idx]
    results = []
    for idx in range(ep["start"], ep["end"] + 1, 2):  # stride 2 for speed
        r = _run_ik_for_frame(idx)
        if r:
            results.append(r)
    return jsonify({"results": results})

def _run_ik_for_frame(frame_idx):
    """Run IK for a single frame, cache the overlay images."""
    import numpy as np, cv2
    ts = f"{frame_idx:06d}"
    npy_path = PANDA_DATA_DIR / f"{ts}.npy"
    img_path = PANDA_DATA_DIR / f"{ts}.png"
    if not npy_path.exists() or not img_path.exists():
        return None

    # Check cache
    gt_cache = _ik_cache_dir / f"{ts}_gt.jpg"
    ik_cache = _ik_cache_dir / f"{ts}_ik.jpg"
    meta_cache = _ik_cache_dir / f"{ts}_meta.json"

    if gt_cache.exists() and ik_cache.exists() and meta_cache.exists():
        import json as _json
        with open(meta_cache) as f:
            meta = _json.load(f)
        _rel = str(PANDA_DATA_DIR.relative_to("/data/cameron"))
        meta["rgb_path"] = f"{_rel}/{ts}.png"
        meta["gt_overlay_path"] = f"{_rel}/ik_cache/{ts}_gt.jpg"
        meta["ik_overlay_path"] = f"{_rel}/ik_cache/{ts}_ik.jpg"
        return meta

    # Lazy import heavy deps
    try:
        import mujoco
        from scipy.spatial.transform import Rotation as Rot
        import sys
        sys.path.insert(0, "/data/cameron/para/panda_streaming")
        os.chdir("/data/cameron/para/panda_streaming")
        from ExoConfigs.panda_exo_handeye_4x2 import PANDA_HANDEYE_4X2_CONFIG
        from exo_utils import render_from_camera_pose, get_link_poses_from_robot, position_exoskeleton_meshes
    except Exception as e:
        return {"error": str(e)}

    T_CAM_WORLD = np.array([
        [0.94774869,0.29251588,0.12730624,-0.41554978],
        [0.24809099,-0.42493276,-0.87056476,0.33529506],
        [-0.20055743,0.85666015,-0.47530002,1.1555837],
        [0,0,0,1]])
    CAM_K = np.array([[1372.7,0,956.9],[0,1357.2,555.0],[0,0,1]], dtype=np.float64)
    FIXED_ROT = np.diag([1.0, -1.0, -1.0])

    model = mujoco.MjModel.from_xml_string(PANDA_HANDEYE_4X2_CONFIG.xml)
    mj_data = mujoco.MjData(model)
    hand_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY, "hand")

    js = np.load(str(npy_path)).astype(np.float64)
    rgb = cv2.cvtColor(cv2.imread(str(img_path)), cv2.COLOR_BGR2RGB)
    gw = js[7] if len(js) > 7 else 1.0

    # GT FK
    mj_data.qpos[:7] = js[:7]
    mj_data.qpos[7] = mj_data.qpos[8] = gw * 0.04
    mujoco.mj_forward(model, mj_data)
    gt_pos = mj_data.xpos[hand_id].copy()

    # IK from neutral/home pose (NOT GT joints)
    HOME_Q = np.array([0.0, -0.785, 0.0, -2.356, 0.0, 1.571, 0.785])
    mj_data.qpos[:7] = HOME_Q
    mj_data.qpos[7] = mj_data.qpos[8] = gw * 0.04
    mujoco.mj_forward(model, mj_data)

    for _ in range(200):
        cur_pos = mj_data.xpos[hand_id].copy()
        cur_rot = mj_data.xmat[hand_id].reshape(3,3).copy()
        pos_err = gt_pos - cur_pos
        R_err = FIXED_ROT @ cur_rot.T
        angle = np.arccos(np.clip((np.trace(R_err)-1)/2, -1, 1))
        rot_err = np.zeros(3) if angle < 1e-6 else angle/(2*np.sin(angle+1e-10)) * np.array([R_err[2,1]-R_err[1,2], R_err[0,2]-R_err[2,0], R_err[1,0]-R_err[0,1]])
        if np.linalg.norm(pos_err) < 5e-4 and np.linalg.norm(rot_err) < 1e-3:
            break
        jacp, jacr = np.zeros((3,model.nv)), np.zeros((3,model.nv))
        mujoco.mj_jacBody(model, mj_data, jacp, jacr, hand_id)
        err = np.concatenate([pos_err, 0.3*rot_err])
        J = np.vstack([jacp[:,:7], 0.3*jacr[:,:7]])
        dq = np.linalg.solve(J.T@J + 1e-4*np.eye(7), J.T@err)
        dq = np.clip(dq, -0.1, 0.1)
        mj_data.qpos[:7] += dq
        mujoco.mj_forward(model, mj_data)

    ik_q = mj_data.qpos[:7].copy()
    ik_pos = mj_data.xpos[hand_id].copy()
    pos_err_mm = float(np.linalg.norm(ik_pos - gt_pos) * 1000)

    # Render GT
    mj_data.qpos[:7] = js[:7]
    mj_data.qpos[7] = mj_data.qpos[8] = gw * 0.04
    mujoco.mj_forward(model, mj_data)
    position_exoskeleton_meshes(PANDA_HANDEYE_4X2_CONFIG, model, mj_data, get_link_poses_from_robot(PANDA_HANDEYE_4X2_CONFIG, model, mj_data))
    gt_render = render_from_camera_pose(model, mj_data, T_CAM_WORLD, CAM_K, 1080, 1920)
    gt_mask = np.any(gt_render > 10, axis=2)
    gt_overlay = rgb.copy()
    gt_overlay[gt_mask] = (rgb[gt_mask]*0.4 + gt_render[gt_mask]*0.6).astype(np.uint8)

    # Render IK
    mj_data.qpos[:7] = ik_q
    mj_data.qpos[7] = mj_data.qpos[8] = gw * 0.04
    mujoco.mj_forward(model, mj_data)
    position_exoskeleton_meshes(PANDA_HANDEYE_4X2_CONFIG, model, mj_data, get_link_poses_from_robot(PANDA_HANDEYE_4X2_CONFIG, model, mj_data))
    ik_render = render_from_camera_pose(model, mj_data, T_CAM_WORLD, CAM_K, 1080, 1920)
    ik_mask = np.any(ik_render > 10, axis=2)
    ik_overlay = rgb.copy()
    ik_overlay[ik_mask] = (rgb[ik_mask]*0.4 + ik_render[ik_mask]*0.6).astype(np.uint8)

    # Save cached
    cv2.imwrite(str(gt_cache), cv2.cvtColor(cv2.resize(gt_overlay, (640, 360)), cv2.COLOR_RGB2BGR), [cv2.IMWRITE_JPEG_QUALITY, 85])
    cv2.imwrite(str(ik_cache), cv2.cvtColor(cv2.resize(ik_overlay, (640, 360)), cv2.COLOR_RGB2BGR), [cv2.IMWRITE_JPEG_QUALITY, 85])

    import json as _json
    meta = {
        "frame_idx": frame_idx,
        "pos_error_mm": pos_err_mm,
        "gt_eef_pos": gt_pos.tolist(),
        "ik_eef_pos": ik_pos.tolist(),
        "gripper": float(gw),
        "gt_joints": js[:7].tolist(),
        "ik_joints": ik_q.tolist(),
    }
    with open(meta_cache, "w") as f:
        _json.dump(meta, f)

    meta["rgb_path"] = f"panda_data/data_20260420_115853_632_frames/{ts}.png"
    meta["gt_overlay_path"] = f"panda_data/data_20260420_115853_632_frames/ik_cache/{ts}_gt.jpg"
    meta["ik_overlay_path"] = f"panda_data/data_20260420_115853_632_frames/ik_cache/{ts}_ik.jpg"
    return meta


# ── Trajectory Execution API ─────────────────────────────────────────────

DATASET_PATHS = {
    "single_demo_sanity": Path("/data/cameron/panda_data/single_demo_sanity"),
    "fewdemo_bowl_pickup": Path("/data/cameron/panda_data/fewdemo_bowl_pickup"),
}

@app.route("/api/inference/trajectory/load", methods=["POST"])
def trajectory_load():
    """Load a GT trajectory: compute 2D keypoints + 3D positions for all steps."""
    import numpy as np, json as _json, cv2, base64
    data = request.get_json()
    dataset_id = data.get("dataset", "single_demo_sanity")
    ep_idx = data.get("episode", 0)
    window = data.get("window", 6)
    stride = data.get("stride", 8)

    dpath = DATASET_PATHS.get(dataset_id)
    if not dpath:
        return jsonify({"error": f"unknown dataset: {dataset_id}"}), 400

    ep_file = dpath / "episodes.json"
    if not ep_file.exists():
        return jsonify({"error": "no episodes.json"}), 404
    with open(ep_file) as f:
        episodes = _json.load(f)["episodes"]
    if ep_idx >= len(episodes):
        return jsonify({"error": "invalid episode"}), 400
    ep = episodes[ep_idx]

    # Compute FK for all frames in episode
    import mujoco, sys
    sys.path.insert(0, "/data/cameron/para/panda_streaming")
    os.chdir("/data/cameron/para/panda_streaming")
    from ExoConfigs.panda_exo_handeye_4x2 import PANDA_HANDEYE_4X2_CONFIG
    from exo_utils import render_from_camera_pose, get_link_poses_from_robot, position_exoskeleton_meshes

    T_cam_world = np.array([
        [0.94774869,0.29251588,0.12730624,-0.41554978],
        [0.24809099,-0.42493276,-0.87056476,0.33529506],
        [-0.20055743,0.85666015,-0.47530002,1.1555837],
        [0,0,0,1]])
    cam_K = np.array([[1372.7,0,956.9],[0,1357.2,555.0],[0,0,1]], dtype=np.float64)

    mj_model = mujoco.MjModel.from_xml_string(PANDA_HANDEYE_4X2_CONFIG.xml)
    mj_data = mujoco.MjData(mj_model)
    hand_id = mujoco.mj_name2id(mj_model, mujoco.mjtObj.mjOBJ_BODY, "hand")

    def project(pos):
        p_cam = T_cam_world[:3,:3] @ pos + T_cam_world[:3,3]
        if p_cam[2] <= 0: return None
        return [float(cam_K[0,0]*p_cam[0]/p_cam[2]+cam_K[0,2]),
                float(cam_K[1,1]*p_cam[1]/p_cam[2]+cam_K[1,2])]

    # Build steps: each step is a frame index with its window of future keypoints
    frame_indices = list(range(ep["start"], ep["end"] + 1, stride))
    steps = []

    for si, fidx in enumerate(frame_indices):
        # For this step, compute the window of future keypoints
        keypoints_2d = []
        keypoints_3d = []
        grippers = []
        for k in range(window):
            future_idx = fidx + k * stride
            if future_idx > ep["end"]:
                future_idx = ep["end"]
            npy_path = dpath / f"{future_idx:06d}.npy"
            if not npy_path.exists():
                break
            js = np.load(str(npy_path)).astype(np.float64)
            gw = float(js[7]) if len(js) > 7 else 1.0

            mj_data.qpos[:7] = js[:7]
            mj_data.qpos[7] = mj_data.qpos[8] = gw * 0.04
            mujoco.mj_forward(mj_model, mj_data)
            eef = mj_data.xpos[hand_id].copy()

            pix = project(eef)
            keypoints_2d.append(pix if pix else [0, 0])
            keypoints_3d.append(eef.tolist())
            grippers.append(gw)

        # Render keypoints on the RGB image for this step
        img_path = dpath / f"{fidx:06d}.png"
        if img_path.exists():
            rgb = cv2.imread(str(img_path))
            # Draw all keypoints in the window
            colors = [(255,100,100), (255,150,50), (255,200,0), (200,255,0), (0,255,100), (0,200,255)]
            for ki, (kp, gv) in enumerate(zip(keypoints_2d, grippers)):
                u, v = int(kp[0]), int(kp[1])
                color = colors[ki % len(colors)]
                if ki == 0:
                    # Next action: large highlighted circle
                    cv2.circle(rgb, (u, v), 20, color, 3)
                    cv2.circle(rgb, (u, v), 8, color, -1)
                    cv2.putText(rgb, f"NEXT g={gv:.2f}", (u+25, v-5),
                                cv2.FONT_HERSHEY_SIMPLEX, 0.7, color, 2)
                else:
                    cv2.circle(rgb, (u, v), 10, color, -1)
                    cv2.putText(rgb, f"t+{ki}", (u+12, v-3),
                                cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1)
                # Connect consecutive keypoints with lines
                if ki > 0:
                    pu, pv = int(keypoints_2d[ki-1][0]), int(keypoints_2d[ki-1][1])
                    cv2.line(rgb, (pu, pv), (u, v), color, 2)

            # Step label
            cv2.putText(rgb, f"Step {si+1}/{len(frame_indices)} (frame {fidx})",
                        (20, 40), cv2.FONT_HERSHEY_SIMPLEX, 1.0, (255,255,255), 2)

            _, buf = cv2.imencode('.jpg', rgb, [cv2.IMWRITE_JPEG_QUALITY, 80])
            kp_img_b64 = base64.b64encode(buf).decode('ascii')
        else:
            kp_img_b64 = ""

        # Render IK overlay for the NEXT action (first keypoint)
        if keypoints_3d:
            # IK for first keypoint
            FIXED_ROT = np.diag([1.0, -1.0, -1.0])
            HOME_Q = np.array([0.0, -0.785, 0.0, -2.356, 0.0, 1.571, 0.785])
            mj_data.qpos[:7] = HOME_Q
            mujoco.mj_forward(mj_model, mj_data)
            tgt = np.array(keypoints_3d[0])
            # Simple IK
            for _ in range(200):
                cur = mj_data.xpos[hand_id].copy()
                cur_rot = mj_data.xmat[hand_id].reshape(3,3)
                pe = tgt - cur
                Re = FIXED_ROT @ cur_rot.T
                ang = np.arccos(np.clip((np.trace(Re)-1)/2,-1,1))
                re = np.zeros(3) if ang<1e-6 else ang/(2*np.sin(ang+1e-10))*np.array([Re[2,1]-Re[1,2],Re[0,2]-Re[2,0],Re[1,0]-Re[0,1]])
                if np.linalg.norm(pe)<5e-4 and np.linalg.norm(re)<1e-3: break
                jp,jr = np.zeros((3,mj_model.nv)),np.zeros((3,mj_model.nv))
                mujoco.mj_jacBody(mj_model,mj_data,jp,jr,hand_id)
                J=np.vstack([jp[:,:7],0.3*jr[:,:7]])
                err=np.concatenate([pe,0.3*re])
                dq=np.linalg.solve(J.T@J+1e-4*np.eye(7),J.T@err)
                mj_data.qpos[:7]+=np.clip(dq,-0.1,0.1)
                mujoco.mj_forward(mj_model,mj_data)

            # Set gripper
            gw0 = grippers[0] if grippers else 1.0
            mj_data.qpos[7] = mj_data.qpos[8] = gw0 * 0.04
            mujoco.mj_forward(mj_model, mj_data)
            position_exoskeleton_meshes(PANDA_HANDEYE_4X2_CONFIG, mj_model, mj_data,
                get_link_poses_from_robot(PANDA_HANDEYE_4X2_CONFIG, mj_model, mj_data))
            rendered = render_from_camera_pose(mj_model, mj_data, T_cam_world, cam_K, 1080, 1920)

            # Overlay on RGB
            if img_path.exists():
                base_rgb = cv2.imread(str(img_path))
                mask = np.any(rendered > 10, axis=2)
                ik_vis = cv2.cvtColor(base_rgb, cv2.COLOR_BGR2RGB)
                ik_vis[mask] = (ik_vis[mask].astype(float)*0.4 + rendered[mask].astype(float)*0.6).astype(np.uint8)
                # Draw EEF keypoint
                ep_pos = mj_data.xpos[hand_id].copy()
                ep_pix = project(ep_pos)
                if ep_pix:
                    eu, ev = int(ep_pix[0]), int(ep_pix[1])
                    cv2.circle(ik_vis, (eu,ev), 15, (0,255,0), -1)
                cv2.putText(ik_vis, f"IK target (grip={gw0:.2f})", (20,40),
                            cv2.FONT_HERSHEY_SIMPLEX, 1.0, (0,255,0), 2)
                _, buf2 = cv2.imencode('.jpg', cv2.cvtColor(ik_vis, cv2.COLOR_RGB2BGR),
                                       [cv2.IMWRITE_JPEG_QUALITY, 80])
                ik_img_b64 = base64.b64encode(buf2).decode('ascii')
            else:
                ik_img_b64 = ""
        else:
            ik_img_b64 = ""

        steps.append({
            "step": si,
            "frame_idx": fidx,
            "keypoints_2d": keypoints_2d,
            "keypoints_3d": keypoints_3d,
            "grippers": grippers,
            "kp_img": kp_img_b64,
            "ik_img": ik_img_b64,
            "ik_joints": mj_data.qpos[:7].tolist() if keypoints_3d else None,
        })

    return jsonify({
        "dataset": dataset_id,
        "episode": ep_idx,
        "ep_start": ep["start"],
        "ep_end": ep["end"],
        "window": window,
        "stride": stride,
        "n_steps": len(steps),
        "steps": steps,
    })


# ── Live Inference Stream ────────────────────────────────────────────────
_live_stream = None  # singleton stream object

def _get_live_stream():
    global _live_stream
    if _live_stream is not None and _live_stream.is_alive():
        return _live_stream

    import numpy as np, cv2, base64, threading, time

    class LiveStream:
        def __init__(self):
            self.latest_frame_b64 = None
            self.latest_joints = None
            self.lock = threading.Lock()
            self._running = True
            self._thread = threading.Thread(target=self._run, daemon=True)
            self._thread.start()

        def is_alive(self):
            return self._thread.is_alive() and self._running

        def stop(self):
            self._running = False

        def _run(self):
            import roslibpy
            _lf = open("/tmp/livestream_debug.log", "w")
            def _log(msg):
                _lf.write(f"{msg}\n"); _lf.flush()
                print(f"[LiveStream] {msg}", flush=True)
            try:
                _log("Starting...")
                client = roslibpy.Ros(host='localhost', port=9090)
                client.run()
                if not client.is_connected:
                    _log("Failed to connect to rosbridge")
                    return
                _log("Connected to rosbridge")

                # Subscribe to joint states
                def on_joints(msg):
                    names = msg.get("name", [])
                    positions = msg.get("position", [])
                    pos_dict = dict(zip(names, positions))
                    q = [pos_dict.get(f"fr3_joint{j}", 0) for j in range(1, 8)]
                    with self.lock:
                        self.latest_joints = q

                joint_sub = roslibpy.Topic(client, '/joint_states',
                                            'sensor_msgs/msg/JointState')
                joint_sub.subscribe(on_joints)

                # Camera: fetch from HTTP camera server on robot box
                self._raw_frame = np.zeros((448, 448, 3), dtype=np.uint8)
                self._has_camera = False
                import urllib.request

                def camera_fetch_loop():
                    while self._running:
                        try:
                            resp = urllib.request.urlopen("http://localhost:8877/frame.jpg", timeout=1)
                            img_bytes = resp.read()
                            img_arr = np.frombuffer(img_bytes, dtype=np.uint8)
                            frame = cv2.imdecode(img_arr, cv2.IMREAD_COLOR)
                            if frame is not None:
                                with self.lock:
                                    self._raw_frame = frame
                                    self._has_camera = True
                        except Exception:
                            pass
                        time.sleep(0.05)  # ~20fps fetch

                cam_thread = threading.Thread(target=camera_fetch_loop, daemon=True)
                cam_thread.start()

                # MuJoCo setup for rendering overlay
                import mujoco
                import sys
                sys.path.insert(0, "/data/cameron/para/panda_streaming")
                os.chdir("/data/cameron/para/panda_streaming")
                from ExoConfigs.panda_exo_handeye_4x2 import PANDA_HANDEYE_4X2_CONFIG
                from exo_utils import render_from_camera_pose, get_link_poses_from_robot, position_exoskeleton_meshes
                # Stay in panda_streaming CWD so MuJoCo can find relative paths

                T_cam_world = np.array([
                    [0.94774869,0.29251588,0.12730624,-0.41554978],
                    [0.24809099,-0.42493276,-0.87056476,0.33529506],
                    [-0.20055743,0.85666015,-0.47530002,1.1555837],
                    [0,0,0,1]])
                # Intrinsics: original 1920x1080, center-crop to 1080x1080 (x0=420), resize to 448
                # cx shifts by crop offset, then both axes scale by 448/1080
                _crop_x0 = (1920 - 1080) / 2  # 420
                _s = 448.0 / 1080.0
                cam_K_448 = np.array([
                    [1372.7 * _s, 0, (956.9 - _crop_x0) * _s],
                    [0, 1357.2 * _s, 555.0 * _s],
                    [0, 0, 1]], dtype=np.float64)

                mj_model = mujoco.MjModel.from_xml_string(PANDA_HANDEYE_4X2_CONFIG.xml)
                mj_data = mujoco.MjData(mj_model)
                hand_id = mujoco.mj_name2id(mj_model, mujoco.mjtObj.mjOBJ_BODY, "hand")

                _log("MuJoCo loaded, streaming...")

                _frame_count = 0
                while self._running:
                    with self.lock:
                        joints = self.latest_joints
                        raw = self._raw_frame.copy()
                        has_cam = self._has_camera

                    if joints is None:
                        if _frame_count == 0:
                            _log("Waiting for joint states...")
                            _frame_count = -1
                        time.sleep(0.03)
                        continue
                    if _frame_count < 0:
                        _log(f"Got joints! has_camera={has_cam}")
                        _frame_count = 0

                    # FK
                    for j in range(7):
                        mj_data.qpos[j] = joints[j]
                    mujoco.mj_forward(mj_model, mj_data)
                    position_exoskeleton_meshes(
                        PANDA_HANDEYE_4X2_CONFIG, mj_model, mj_data,
                        get_link_poses_from_robot(PANDA_HANDEYE_4X2_CONFIG, mj_model, mj_data))

                    # Render
                    rendered = render_from_camera_pose(
                        mj_model, mj_data, T_cam_world, cam_K_448, 448, 448)

                    # Overlay
                    rgb = cv2.cvtColor(raw, cv2.COLOR_BGR2RGB)
                    mask = np.any(rendered > 10, axis=2)
                    overlay = rgb.copy()
                    overlay[mask] = (rgb[mask].astype(float)*0.4 + rendered[mask].astype(float)*0.6).astype(np.uint8)

                    # EEF keypoint
                    eef_pos = mj_data.xpos[hand_id].copy()
                    p_cam = T_cam_world[:3,:3] @ eef_pos + T_cam_world[:3,3]
                    if p_cam[2] > 0:
                        u = int(cam_K_448[0,0]*p_cam[0]/p_cam[2] + cam_K_448[0,2])
                        v = int(cam_K_448[1,1]*p_cam[1]/p_cam[2] + cam_K_448[1,2])
                        if 0 <= u < 448 and 0 <= v < 448:
                            cv2.circle(overlay, (u, v), 8, (0, 255, 0), -1)

                    # Encode
                    _, buf = cv2.imencode('.jpg', cv2.cvtColor(overlay, cv2.COLOR_RGB2BGR),
                                          [cv2.IMWRITE_JPEG_QUALITY, 75])
                    b64 = base64.b64encode(buf).decode('ascii')
                    with self.lock:
                        self.latest_frame_b64 = b64

                    time.sleep(0.05)  # ~20fps max

            except Exception as e:
                _log(f"Error: {e}")
                import traceback; traceback.print_exc(file=_lf); _lf.flush()

    _live_stream = LiveStream()
    return _live_stream


@app.route("/api/inference/live/start", methods=["POST"])
def live_start():
    stream = _get_live_stream()
    return jsonify({"status": "started" if stream.is_alive() else "failed"})


@app.route("/api/inference/live/frame")
def live_frame():
    if _live_stream is None or not _live_stream.is_alive():
        return jsonify({"error": "stream not started"}), 503
    with _live_stream.lock:
        b64 = _live_stream.latest_frame_b64
        joints = _live_stream.latest_joints
    if b64 is None:
        return jsonify({"error": "no frame yet"}), 503
    return jsonify({"frame": b64, "joints": joints})


@app.route("/api/inference/live/stop", methods=["POST"])
def live_stop():
    global _live_stream
    if _live_stream:
        _live_stream.stop()
        _live_stream = None
    return jsonify({"status": "stopped"})


# ---------------------------------------------------------------------------
# Rerun viewer reverse proxy (puget → omidlab.net)
# ---------------------------------------------------------------------------
# Cameron's deploy_yam.py on puget starts a rerun web viewer at puget:9092 and
# a gRPC-Web stream at puget:9093. To skip the SSH tunnel dance, expose them
# via this Flask app over the tailnet (school server hits puget at the tailnet
# IP, public sees them via nginx).
#
# Open https://omidlab.net/rerun_puget/?url=rerun%2Bhttps%3A%2F%2Fomidlab.net%2Frerun_proxy%2F
# in any browser — works without SSH or Tailscale on the client.

_RERUN_PUGET_HOST = "100.104.232.94"   # puget tailnet IP
_RERUN_VIEWER_PORT = 9092
_RERUN_STREAM_PORT = 9093


def _stream_proxy(target_url: str):
    """Stream a request to target_url and yield the response. Preserves headers,
    streams the body so gRPC-Web trailers/chunks pass through."""
    import requests  # local import — avoid global side-effects
    method = request.method
    headers = {k: v for k, v in request.headers.items()
               if k.lower() not in ("host", "content-length")}
    try:
        upstream = requests.request(
            method, target_url, headers=headers,
            data=request.get_data() if method != "GET" else None,
            stream=True, timeout=(5, None),
            allow_redirects=False,
        )
    except requests.exceptions.RequestException as e:
        return Response(
            f"Rerun proxy: puget not reachable at {target_url} ({e})",
            status=502, mimetype="text/plain",
        )
    excluded = {"content-encoding", "transfer-encoding", "connection", "keep-alive"}
    resp_headers = [(k, v) for k, v in upstream.raw.headers.items()
                    if k.lower() not in excluded]
    return Response(
        upstream.iter_content(chunk_size=8192),
        status=upstream.status_code,
        headers=resp_headers,
    )


@app.route("/rerun_puget", defaults={"path": ""}, methods=["GET", "POST", "OPTIONS"])
@app.route("/rerun_puget/", defaults={"path": ""}, methods=["GET", "POST", "OPTIONS"])
@app.route("/rerun_puget/<path:path>", methods=["GET", "POST", "OPTIONS"])
def rerun_viewer_proxy(path):
    """Proxy the rerun web viewer SPA from puget:9092 → omidlab.net/rerun_puget/*."""
    target = f"http://{_RERUN_PUGET_HOST}:{_RERUN_VIEWER_PORT}/{path}"
    if request.query_string:
        target += "?" + request.query_string.decode()
    return _stream_proxy(target)


@app.route("/rerun_proxy", defaults={"path": ""}, methods=["GET", "POST", "OPTIONS"])
@app.route("/rerun_proxy/", defaults={"path": ""}, methods=["GET", "POST", "OPTIONS"])
@app.route("/rerun_proxy/<path:path>", methods=["GET", "POST", "OPTIONS"])
def rerun_grpc_proxy(path):
    """Proxy the rerun gRPC-Web data stream from puget:9093 → omidlab.net/rerun_proxy/*.

    The rerun viewer SPA reads the ?url=rerun+<here>/ query param at load time
    and connects to this endpoint for the data stream.
    """
    target = f"http://{_RERUN_PUGET_HOST}:{_RERUN_STREAM_PORT}/{path}"
    if request.query_string:
        target += "?" + request.query_string.decode()
    return _stream_proxy(target)


# Rerun's URL parser is strict: it expects the path to be one of the endpoint
# types it knows about (proxy / catalog / entry / folder / dataset). Exposing
# the gRPC stream at a top-level /proxy so URLs like
# `rerun+https://omidlab.net/proxy` parse cleanly.
@app.route("/proxy", methods=["GET", "POST", "OPTIONS"])
@app.route("/proxy/", methods=["GET", "POST", "OPTIONS"])
@app.route("/proxy/<path:path>", methods=["GET", "POST", "OPTIONS"])
def rerun_grpc_proxy_root(path=""):
    target = f"http://{_RERUN_PUGET_HOST}:{_RERUN_STREAM_PORT}/proxy"
    if path:
        target += f"/{path}"
    if request.query_string:
        target += "?" + request.query_string.decode()
    return _stream_proxy(target)


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--port", type=int, default=8080)
    parser.add_argument("--public", action="store_true", help="Create public URL via tunnel")
    parser.add_argument("--tunnel", default="localtunnel", choices=["localtunnel", "cloudflared"])
    args = parser.parse_args()

    if args.public:
        tunnel_thread = threading.Thread(
            target=start_tunnel, args=(args.port, args.tunnel), daemon=True,
        )
        tunnel_thread.start()

    print(f"Serving reports at http://localhost:{args.port}")
    app.run(host="0.0.0.0", port=args.port, debug=False)
