"""
Unified biosync app shell (Phase 5 packaging).

One Flask server that ties the whole tool together into a single application:

  /                  landing page — record panel, session list, navigation
  /builder/          the Study Builder (Dash, mounted here)
  /view/dashboard    the analysis dashboard for a chosen session (rendered report)
  /view/replay       the interactive timeline replay for a chosen session
  /api/record  POST  start a synchronized recording (background job)
  /api/status  GET   poll the running/last recording
  /api/sessions GET  list recorded sessions in the data directory

This is what gets wrapped in a native window (see app/desktop.py) and frozen into
a single .app/.exe (see packaging/). It reuses the already-tested report and replay
generators, so the shell stays thin.
"""

from __future__ import annotations

import os
import threading
import time
from pathlib import Path

from flask import Flask, request, jsonify, send_file, abort

# built-in synthetic "test signals" so the app is usable with no hardware.
_TEST_SIGNALS = {
    "gaze": ("sources.synthetic", "SyntheticGaze"),
    "eda": ("sources.synthetic", "SyntheticEDA"),
    "ecg": ("sources.synthetic", "SyntheticECG"),
    "eeg": ("sources.synthetic", "SyntheticEEG"),
}


class _Recorder:
    """Runs one recording at a time in a background thread, tracks status."""

    def __init__(self, data_dir: Path):
        self.data_dir = data_dir
        self.lock = threading.Lock()
        self.status = {"state": "idle", "session": None, "message": ""}

    def start(self, seconds, signals, study, participant=None) -> bool:
        if not self.lock.acquire(blocking=False):
            return False
        threading.Thread(target=self._run, args=(seconds, signals, study, participant),
                         daemon=True).start()
        return True

    def _run(self, seconds, signals, study, participant=None):
        import importlib
        from ..recorder import Recorder
        # which stream names we expect, so we can flag any that fail to resolve
        _EXPECT = {"gaze": "EyeTracker", "eda": "GSR", "ecg": "ECG", "eeg": "EEG"}
        try:
            self.status = {"state": "recording", "session": None,
                           "message": f"recording {seconds:.0f}s"}
            sources = []
            expect = set()
            for key in signals:
                mod, cls = _TEST_SIGNALS[key]
                Cls = getattr(importlib.import_module(f"biosync.{mod}"), cls)
                sources.append(Cls())
                if key in _EXPECT:
                    expect.add(_EXPECT[key])
            if study:
                from ..studybuilder import StudyPlan
                from ..stimulus.study import StudySource
                plan_path = self.data_dir / "study.json"
                plan = (StudyPlan.from_json(str(plan_path)) if plan_path.exists()
                        else __import__("biosync.studybuilder", fromlist=["example_plan"]).example_plan())
                sources.append(StudySource(plan.to_trials(), loop=True))
                expect.add("Markers")
            for s in sources:
                s.start()
            time.sleep(0.4)
            fname = time.strftime("session_%Y%m%d_%H%M%S.h5")
            rec = Recorder(autosave_every=5.0)
            rec.record(seconds, str(self.data_dir / fname), expect=expect, settle=2.0)
            self.last_summary = rec.summary
            for s in sources:
                s.stop()
            # tag the session to a participant in the project manifest
            if participant and participant.get("id"):
                from ..participants import Project
                Project(str(self.data_dir)).attach_session(
                    participant["id"], fname, name=participant.get("name", ""),
                    group=participant.get("group", ""))
            summ = getattr(self, "last_summary", {}) or {}
            warn = ""
            if summ.get("missing"):
                warn = " ⚠ missing: " + ", ".join(summ["missing"])
            elif summ.get("empty_streams"):
                warn = " ⚠ no data: " + ", ".join(summ["empty_streams"])
            self.status = {"state": "done", "session": fname,
                           "message": f"saved {fname}{warn}",
                           "summary": summ}
        except Exception as e:                       # surface errors to the UI
            self.status = {"state": "error", "session": None, "message": str(e)}
        finally:
            self.lock.release()


def create_app(data_dir: str = "sessions") -> Flask:
    data = Path(data_dir)
    data.mkdir(parents=True, exist_ok=True)
    app = Flask(__name__)
    rec = _Recorder(data)

    from ..licensing import LicenseManager
    lic = LicenseManager(str(data))

    @app.route("/license")
    def license_page():
        return _license_html()

    @app.route("/api/license/status")
    def license_status():
        return jsonify(lic.status())

    @app.route("/api/license/activate", methods=["POST"])
    def license_activate():
        body = request.get_json(force=True, silent=True) or {}
        return jsonify(lic.activate(body.get("key", "")))

    @app.route("/api/license/deactivate", methods=["POST"])
    def license_deactivate():
        lic.deactivate()
        return jsonify({"ok": True})

    # mount the Study Builder Dash app at /builder/
    from ..studybuilder.builder_app import build_app as build_builder
    build_builder(server=app, url_base_pathname="/builder/")

    @app.route("/")
    def index():
        return _landing_html()

    @app.route("/api/sessions")
    def sessions():
        files = sorted((f.name for f in data.glob("*.h5")), reverse=True)
        return jsonify(files)

    @app.route("/api/record", methods=["POST"])
    def record():
        body = request.get_json(force=True, silent=True) or {}
        seconds = float(body.get("seconds", 20))
        signals = [s for s in body.get("signals", ["gaze", "eda", "ecg"])
                   if s in _TEST_SIGNALS]
        study = bool(body.get("study", True))
        if not lic.allowed():
            return jsonify({"started": False, "licensed": False,
                            "error": "Your trial has ended. Enter a license key to keep recording.",
                            "license": lic.status()}), 402
        ok = rec.start(seconds, signals, study, participant=body.get("participant"))
        return jsonify({"started": ok, "status": rec.status})

    @app.route("/api/status")
    def status():
        return jsonify(rec.status)

    @app.route("/participants")
    def participants_page():
        return _participants_html()

    @app.route("/api/participants")
    def api_participants():
        from ..participants import Project
        return jsonify(Project(str(data)).to_dict())

    @app.route("/api/export.<fmt>")
    def api_export(fmt):
        from ..participants import Project
        from ..analysis import export as EX
        proj = Project(str(data))
        if not proj.all():
            abort(404, "no participants to export yet")
        if fmt == "xlsx":
            out = data / "biosync_export.xlsx"
            EX.to_excel(proj, str(out))
            return send_file(str(out), as_attachment=True,
                             download_name="biosync_export.xlsx")
        if fmt == "zip":
            out = data / "biosync_export.zip"
            EX.to_csv_zip(proj, str(out))
            return send_file(str(out), as_attachment=True,
                             download_name="biosync_export_csv.zip")
        abort(404, "use export.xlsx or export.zip")

    @app.route("/view/aggregate")
    def view_aggregate():
        from ..participants import Project
        from ..dashboard import export_group_html
        group = request.args.get("group")
        sess = Project(str(data)).sessions(group=group if group != "(all)" else None)
        if not sess:
            abort(404, "no sessions for this group yet")
        out = data / "_aggregate_view.html"
        export_group_html(sess, str(out), back_href="/participants",
                          subtitle=f"Group: {group or 'all'} · n={len(sess)}")
        return send_file(str(out))

    from .live import LiveGaze
    live = LiveGaze()

    from .monitor import LiveMonitor
    monitor = LiveMonitor()

    @app.route("/monitor")
    def monitor_page():
        return _monitor_html()

    @app.route("/api/monitor/start", methods=["POST"])
    def monitor_start():
        return jsonify(monitor.start())

    @app.route("/api/monitor/status")
    def monitor_status():
        return jsonify(monitor.quality())

    @app.route("/api/monitor/stop", methods=["POST"])
    def monitor_stop():
        return jsonify(monitor.stop())

    @app.route("/validate")
    def validate_page():
        return _validate_html()

    @app.route("/api/validate", methods=["POST"])
    def api_validate():
        import json as _json
        from .. import validation as V
        from ..analysis import load as A
        body = request.get_json(force=True, silent=True) or {}
        sess_path = _safe_session(data, body.get("session"))
        device = body.get("device", "")
        session = A.load_session(str(sess_path))
        type_mod = {"Gaze": "gaze", "ECG": "ecg", "EEG": "eeg", "GSR": "eda",
                    "EMG": "emg", "Respiration": "rsp", "NIRS": "fnirs"}
        reports = []
        for name, s in session["streams"].items():
            if s["type"] == "Markers":
                continue
            mod = type_mod.get(s["type"], "generic")
            rep = V.validate_stream(session, device=device or name, stream=name,
                                    modality=mod, min_channels=1)
            reports.append(rep.dict())
        overall = bool(reports) and all(r["passed"] for r in reports)
        out = _jsonable({"session": sess_path.name, "overall": overall,
                         "reports": reports})
        _json.dump(out, open(data / "validation_report.json", "w"), indent=2)
        return jsonify(out)

    from ..sources.webcam_eye import WebcamEyeSource
    webcam = {"src": None}

    @app.route("/webcam")
    def webcam_page():
        return _webcam_html()

    @app.route("/api/webcam/start", methods=["POST"])
    def webcam_start():
        if webcam["src"] is None:
            webcam["src"] = WebcamEyeSource()
            webcam["src"].start()
        return jsonify({"running": True})

    @app.route("/api/webcam/gaze", methods=["POST"])
    def webcam_gaze():
        body = request.get_json(force=True, silent=True) or {}
        src = webcam["src"]
        if src is None:
            return jsonify({"ok": False, "error": "webcam source not started"}), 400
        if "samples" in body:
            n = src.push_batch(body["samples"])
        else:
            src.push(body.get("x", 0.5), body.get("y", 0.5), body.get("conf", 1.0))
            n = 1
        return jsonify({"ok": True, "received": n})

    @app.route("/api/webcam/stop", methods=["POST"])
    def webcam_stop():
        if webcam["src"] is not None:
            webcam["src"].stop()
            webcam["src"] = None
        return jsonify({"running": False})

    @app.route("/screen")
    def screen_page():
        return _screen_html()

    @app.route("/api/screen/start", methods=["POST"])
    def screen_start():
        from pylsl import local_clock
        return jsonify({"t0": local_clock()})   # video start on the shared LSL clock

    @app.route("/api/screen/upload", methods=["POST"])
    def screen_upload():
        import json as _json
        f = request.files.get("video")
        if f is None:
            return jsonify({"saved": False, "error": "no video"}), 400
        t0 = float(request.form.get("t0", 0))
        stem = _latest_session_stem(data, request.form.get("session"))
        if not stem:
            return jsonify({"saved": False, "error": "no session to attach to"}), 400
        sdir = data / "screens"; sdir.mkdir(exist_ok=True)
        f.save(str(sdir / f"{stem}.webm"))
        _json.dump({"t0_lsl": t0}, open(sdir / f"{stem}.screen.json", "w"))
        return jsonify({"saved": True, "session": stem + ".h5"})

    @app.route("/media/screen/<path:name>")
    def media_screen(name):
        p = (data / "screens" / name).resolve()
        if p.parent != (data / "screens").resolve() or not p.exists():
            abort(404)
        return send_file(str(p))

    @app.route("/calibrate")
    def calibrate_page():
        return _calibrate_html()

    @app.route("/gaze")
    def gaze_page():
        return _gaze_html()

    @app.route("/api/gaze/start", methods=["POST"])
    def gaze_start():
        body = request.get_json(force=True, silent=True) or {}
        slug = body.get("slug") or None
        try:
            return jsonify(live.start(slug))
        except Exception as e:
            return jsonify({"running": False, "error": str(e)}), 500

    @app.route("/api/gaze/stop", methods=["POST"])
    def gaze_stop():
        return jsonify(live.stop())

    @app.route("/api/gaze/latest")
    def gaze_latest():
        return jsonify(live.latest)

    @app.route("/api/gaze/stream")
    def gaze_stream():
        from flask import Response

        def gen():
            import json as _json
            while live.running:
                yield f"data: {_json.dumps(live.latest)}\n\n"
                time.sleep(1 / 60)
        return Response(gen(), mimetype="text/event-stream")

    @app.route("/api/calibration/evaluate", methods=["POST"])
    def calibration_eval():
        from ..analysis.calibration import evaluate
        from ..analysis.eyetracking import Screen
        import numpy as np
        body = request.get_json(force=True, silent=True) or {}
        pts = {}
        for key, samples in (body.get("points") or {}).items():
            tx, ty = (float(v) for v in key.split(","))
            pts[(tx, ty)] = np.array(samples, dtype=float)
        screen = Screen(**(body.get("screen") or {}))
        res = evaluate(pts, screen=screen, threshold_deg=float(body.get("threshold", 1.0)))
        return jsonify({**res.summary(),
                        "per_point": [{"target": list(p.target),
                                       "accuracy_deg": round(p.accuracy_deg, 3)
                                       if p.accuracy_deg == p.accuracy_deg else None,
                                       "precision_deg": round(p.precision_deg, 3)
                                       if p.precision_deg == p.precision_deg else None,
                                       "n": p.n_samples, "valid": p.valid}
                                      for p in res.points]})

    from .present import Presenter, save_response, load_responses, flatten_trials
    presenter = Presenter()

    @app.route("/present")
    def present_page():
        return _present_html()

    @app.route("/api/present/start", methods=["POST"])
    def present_start():
        from ..studybuilder import StudyPlan, example_plan
        body = request.get_json(force=True, silent=True) or {}
        presenter.start(body.get("participant"))
        p = _study_path(data)
        plan = StudyPlan.from_json(str(p)) if p.exists() else example_plan()
        return jsonify({"running": True, "trials": flatten_trials(plan)})

    @app.route("/api/present/marker", methods=["POST"])
    def present_marker():
        body = request.get_json(force=True, silent=True) or {}
        presenter.marker(body.get("label", ""))
        return jsonify({"ok": True})

    @app.route("/api/present/respond", methods=["POST"])
    def present_respond():
        body = request.get_json(force=True, silent=True) or {}
        save_response(str(data), (body.get("participant") or {}).get("id", ""),
                      body.get("stimulus", ""), body.get("kind", "survey"),
                      body.get("data", {}))
        return jsonify({"saved": True})

    @app.route("/api/present/stop", methods=["POST"])
    def present_stop():
        return jsonify(presenter.stop())

    @app.route("/api/responses")
    def api_responses():
        return jsonify(load_responses(str(data)))

    @app.route("/flow")
    def flow_page():
        return _flow_html()

    @app.route("/api/stimuli/upload", methods=["POST"])
    def stimuli_upload():
        import re as _re
        f = request.files.get("file")
        if f is None:
            return jsonify({"ok": False, "error": "no file"}), 400
        sdir = data / "stimuli"; sdir.mkdir(exist_ok=True)
        safe = _re.sub(r"[^A-Za-z0-9._-]", "_", f.filename or "stimulus")
        f.save(str(sdir / safe))
        return jsonify({"ok": True, "name": safe, "url": f"/media/stimuli/{safe}"})

    @app.route("/media/stimuli/<path:name>")
    def media_stimuli(name):
        p = (data / "stimuli" / name).resolve()
        if p.parent != (data / "stimuli").resolve() or not p.exists():
            abort(404)
        return send_file(str(p))

    @app.route("/aoi-editor")
    def aoi_editor_page():
        return _aoi_editor_html()

    @app.route("/api/aoi", methods=["GET", "POST"])
    def api_aoi():
        import json as _json
        p = data / "aois.json"
        if request.method == "POST":
            body = request.get_json(force=True, silent=True) or {}
            _json.dump(body, open(p, "w"), indent=2)
            n = sum(len(v) for v in body.values())
            return jsonify({"saved": True, "aois": n})
        return jsonify(_json.load(open(p)) if p.exists() else {})

    @app.route("/api/video-stimuli")
    def api_video_stimuli():
        # list video stimuli in the current study (name -> media url)
        from ..studybuilder import StudyPlan, example_plan
        p = _study_path(data)
        plan = StudyPlan.from_json(str(p)) if p.exists() else example_plan()
        out = []
        for b in plan.blocks:
            for leaf in b.leaves():
                if leaf.kind == "video" and leaf.image:
                    out.append({"name": leaf.name, "url": leaf.image,
                                "duration": leaf.duration})
        return jsonify(out)

    @app.route("/api/study", methods=["GET", "POST"])
    def api_study():
        from ..studybuilder import StudyPlan, example_plan
        p = _study_path(data)
        if request.method == "POST":
            body = request.get_json(force=True, silent=True) or {}
            plan = StudyPlan.from_dict(body)            # validate by round-trip
            errs = plan.validate()
            plan.to_json(str(p))
            return jsonify({"saved": True, "errors": errs,
                            "trials": len(plan.to_trials()),
                            "duration": round(plan.estimated_duration(), 1)})
        plan = StudyPlan.from_json(str(p)) if p.exists() else example_plan()
        return jsonify(plan.to_dict())

    @app.route("/sensors")
    def sensors_page():
        return _sensors_html(_current_sensors(data))

    @app.route("/api/sensors", methods=["GET", "POST"])
    def api_sensors():
        if request.method == "POST":
            body = request.get_json(force=True, silent=True) or {}
            sel = [s for s in body.get("sensors", []) if s in _ALL_SLUGS]
            _save_sensors(data, sel)
            return jsonify({"saved": True, "sensors": sel})
        return jsonify(_current_sensors(data))

    @app.route("/view/dashboard")
    def view_dashboard():
        sess = _safe_session(data, request.args.get("session"))
        from ..dashboard import export_html
        out = data / "_dashboard_view.html"
        export_html(str(sess), str(out), back_href="/", subtitle=sess.name)
        return send_file(str(out))

    @app.route("/view/overlay")
    def view_overlay():
        from ..studybuilder import StudyPlan, example_plan
        from ..analysis import load as A, overlay as OV
        from .ui import THEME_CSS, topbar
        sess = _safe_session(data, request.args.get("session"))
        p = _study_path(data)
        plan = StudyPlan.from_json(str(p)) if p.exists() else example_plan()
        imgs = OV.stimulus_images(plan, str(data / "stimuli"))
        session = A.load_session(str(sess))
        cards, first = [], True
        for name, ipath in imgs.items():
            gx, gy = OV.gaze_for_stimulus(session, name)
            if not len(gx):
                continue
            for fig in (OV.heatmap_over_image(session, name, ipath),
                        OV.scanpath_over_image(session, name, ipath)):
                cards.append('<div class="card plot">' + fig.to_html(
                    full_html=False, include_plotlyjs=("cdn" if first else False),
                    config={"displayModeBar": False}) + "</div>")
                first = False
        if not cards:
            cards = ['<div class="card"><p class="note">No gaze-on-image data yet. '
                     'Upload image stimuli in the Flow Designer, then record a session '
                     'so gaze is captured while those images are shown.</p></div>']
        head = (f'<!doctype html><html lang="en"><head><meta charset="utf-8">'
                f'<meta name="viewport" content="width=device-width, initial-scale=1">'
                f'<title>biosync — gaze maps</title>'
                f'<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">'
                f'<style>{THEME_CSS}</style></head><body>')
        return (head + topbar(subtitle=f"Gaze maps · {sess.name}", back_href="/")
                + '<div class="wrap">' + "\n".join(cards) + "</div></body></html>")

    @app.route("/view/replay")
    def view_replay():
        sess = _safe_session(data, request.args.get("session"))
        from ..studybuilder.replay import export_replay_html
        out = data / "_replay_view.html"
        screen = _screen_for(data, sess)
        export_replay_html(str(sess), str(out), back_href="/", subtitle=sess.name,
                           screen=screen)
        return send_file(str(out))

    return app


def _study_path(data: Path) -> Path:
    return data / "study.json"


def _current_sensors(data: Path) -> list:
    p = _study_path(data)
    if p.exists():
        import json
        try:
            return list(json.load(open(p)).get("sensors", []))
        except Exception:
            return []
    return []


def _save_sensors(data: Path, sensors: list):
    from ..studybuilder import StudyPlan
    p = _study_path(data)
    plan = StudyPlan.from_json(str(p)) if p.exists() else StudyPlan()
    plan.sensors = sensors
    plan.to_json(str(p))


def _sensor_groups() -> dict:
    """Group the device catalog by modality for the Select-Sensors screen."""
    from ..drivers.catalog import DEVICES
    groups: dict = {}
    for d in DEVICES.values():
        groups.setdefault(d.modality, []).append(d)
    for v in groups.values():
        v.sort(key=lambda d: (d.brand, d.model))
    return groups


from ..drivers.catalog import DEVICES as _CATALOG
_ALL_SLUGS = set(_CATALOG)

_MOD_LABEL = {"gaze": "Eye tracking", "eeg": "EEG", "eda": "EDA / GSR",
              "ecg": "ECG", "emg": "EMG", "rsp": "Respiration", "fnirs": "fNIRS",
              "fea": "Facial expression (AFFDEX → py-feat)", "voice": "Voice"}


def _sensors_html(selected: list) -> str:
    from .ui import THEME_CSS, topbar
    groups = _sensor_groups()
    sel = set(selected)

    # eye trackers render as a dropdown (like iMotions); everything else as cards
    gaze = groups.pop("gaze", [])
    opts = "".join(
        f'<option value="{d.slug}"{" selected" if d.slug in sel else ""}>'
        f'{d.brand} {d.model}</option>' for d in gaze)
    eye = (f'<div class="card"><h2>Eye tracking</h2>'
           f'<select id="eye" style="width:100%"><option value="">— none —</option>{opts}</select>'
           f'<p class="note" style="margin-top:8px">'
           f'{len(gaze)} supported trackers. LSL-tier models need no extra driver.</p></div>')

    cards = []
    order = ["fea", "voice", "eeg", "eda", "ecg", "emg", "rsp", "fnirs"]
    for mod in order:
        ds = groups.get(mod)
        if not ds:
            continue
        items = "".join(
            f'<label class="navcard" style="cursor:pointer">'
            f'<input type="checkbox" class="sensor" value="{d.slug}"'
            f'{" checked" if d.slug in sel else ""} style="margin-top:4px">'
            f'<div><b>{d.brand} {d.model}</b><small>{d.tier.upper()} · {d.stype or d.modality}</small></div>'
            f'</label>' for d in ds)
        cards.append(f'<div class="card"><h2>{_MOD_LABEL.get(mod, mod)}</h2>'
                     f'<div class="grid">{items}</div></div>')

    return f"""<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"><title>biosync — sensors</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>{THEME_CSS}</style></head><body>
{topbar(subtitle="Select sensors", back_href="/")}
<div class="wrap">
  <div class="hero" style="padding:18px 22px"><h1 style="font-size:20px">Select sensors</h1>
    <p>Which sensors collect data for this study? Powered by the {len(_ALL_SLUGS)}-device catalog.</p></div>
  {eye}
  {''.join(cards)}
  <div style="display:flex;gap:12px;align-items:center">
    <button class="btn" id="save">Save selection</button>
    <span class="statline" id="stat"></span>
  </div>
</div>
<script>
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
$("#save").onclick=()=>{{
  const sensors=$$(".sensor").filter(c=>c.checked).map(c=>c.value);
  const eye=$("#eye").value; if(eye) sensors.unshift(eye);
  fetch("/api/sensors",{{method:"POST",headers:{{"Content-Type":"application/json"}},
    body:JSON.stringify({{sensors}})}}).then(r=>r.json()).then(d=>{{
      $("#stat").textContent="saved "+d.sensors.length+" sensors";}});
}};
</script></body></html>"""


def _flow_html() -> str:
    from .ui import THEME_CSS, topbar
    from .onboarding import tour, help_button
    flow_tour = tour("flow", [
        {"sel": "#palettecard", "title": "Add stimuli",
         "body": "Click a type to add it, or drag it onto the flow. You can also drag an image/video file from your computer right onto the canvas."},
        {"sel": "#root", "title": "Build the flow",
         "body": "Reorder by dragging. Drop a stimulus onto a Block to nest it. Use the gear on each item to set duration, advance, webcam, mouse, or upload a file."},
        {"sel": "#save", "title": "Save & run",
         "body": "Save your flow, then Save & record to run it. Sensors, calibration and recording all read this same study."},
    ])
    return (r"""<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"><title>biosync — Flow Designer</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>__THEME__
 .palette{display:flex;gap:10px;flex-wrap:wrap}
 .chip{display:flex;align-items:center;gap:7px;border:1px solid var(--border);background:var(--surface);
   border-radius:10px;padding:9px 13px;cursor:grab;font-weight:550;box-shadow:var(--shadow-sm)}
 .chip:hover{border-color:var(--accent);background:var(--accent-50)}
 .chip .ico{font-size:16px;color:var(--primary)}
 .canvas{min-height:120px}
 .node{border:1px solid var(--border);border-radius:11px;background:var(--surface);margin:8px 0;
   box-shadow:var(--shadow-sm)}
 .node.group{background:var(--surface-2);border-color:#cfe0e3}
 .node.dragover{border-color:var(--accent);box-shadow:0 0 0 3px var(--ring)}
 .nrow{display:flex;align-items:center;gap:9px;padding:10px 12px}
 .grip{cursor:grab;color:var(--faint);font-size:15px}
 .nico{width:30px;height:30px;border-radius:8px;display:grid;place-items:center;
   background:var(--accent-50);color:var(--primary);flex:0 0 auto}
 .nname{font-weight:600;border:1px solid transparent;background:transparent;border-radius:6px;
   padding:3px 6px;min-width:120px;font-size:15px}
 .nname:hover,.nname:focus{border-color:var(--border);background:#fff}
 .tag{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em}
 .spacer{flex:1}
 .mini{border:1px solid var(--border);background:#fff;border-radius:7px;height:30px;width:32px;
   cursor:pointer;font-size:14px}.mini:hover{border-color:var(--accent);background:var(--accent-50)}
 .settings{display:none;gap:10px;flex-wrap:wrap;padding:0 12px 12px 51px;align-items:center}
 .settings.open{display:flex}
 .settings label{font-size:13px;color:var(--muted);display:flex;align-items:center;gap:5px}
 .upbtn{cursor:pointer;border:1px solid var(--border);border-radius:7px;padding:5px 9px;
   background:var(--surface);color:var(--primary)!important}.upbtn:hover{background:var(--accent-50)}
 .srcok{color:var(--ok);font-size:12px}
 .kids{padding:4px 10px 10px 30px}
 .dropzone{border:1.5px dashed var(--border);border-radius:9px;color:var(--faint);
   text-align:center;padding:12px;font-size:13px;margin:6px 0}
 .dropzone.dragover{border-color:var(--accent);color:var(--primary);background:var(--accent-50)}
 .savebar{display:flex;gap:10px;align-items:center;position:sticky;bottom:0;background:var(--bg);
   padding:10px 0}
</style></head><body>
"""
    + topbar(subtitle="Flow Designer", back_href="/", extra=help_button())
    + r"""
<div class="wrap">
  <div class="card" id="palettecard">
    <h2>Add a stimulus — click a type, or drag it onto the flow</h2>
    <div class="palette" id="palette">
      <div class="chip" draggable="true" data-new="block" title="A container that groups stimuli; can repeat &amp; randomize its children"><span class="ico">&#9635;</span>Block</div>
      <div class="chip" draggable="true" data-new="image" title="Show an image (upload your own or set a path)"><span class="ico">&#9650;</span>Image</div>
      <div class="chip" draggable="true" data-new="video" title="Play a video clip"><span class="ico">&#9658;</span>Video</div>
      <div class="chip" draggable="true" data-new="text" title="Show a text slide"><span class="ico">&#9776;</span>Text</div>
      <div class="chip" draggable="true" data-new="survey" title="Ask questions and record answers"><span class="ico">&#9745;</span>Survey</div>
      <div class="chip" draggable="true" data-new="website" title="Show a live web page in the study"><span class="ico">&#9883;</span>Website</div>
      <div class="chip" draggable="true" data-new="instructions" title="A welcome/instructions screen"><span class="ico">&#9432;</span>Instructions</div>
    </div>
    <p class="note" style="margin:12px 0 0">Tip: you can also <b>drag an image or video file</b>
      from your computer straight onto the flow to add it as a stimulus.</p>
  </div>
  <div class="card">
    <div style="display:flex;align-items:center;gap:10px;margin-bottom:8px">
      <input class="nname" id="title" style="font-size:18px;min-width:260px">
      <span class="spacer"></span><span class="tag" id="meta"></span>
    </div>
    <div class="canvas" id="root"></div>
  </div>
  <div class="savebar">
    <button class="btn" id="save">Save flow</button>
    <button class="iconbtn" id="run">&#9679; Save &amp; record</button>
    <span class="statline" id="stat"></span>
  </div>
</div>
<script>
const ICONS={block:"&#9635;",image:"&#9650;",video:"&#9658;",text:"&#9776;",
  survey:"&#9745;",website:"&#9883;",instructions:"&#9432;"};
let plan={title:"New study",blocks:[],randomize:false,repeats:1,sensors:[]};

function newNode(kind){
  const n=Math.floor(Math.random()*900+100);
  if(kind==="block") return {name:"block_"+n,kind:"block",children:[],randomize:false,repeats:1,
    duration:0,isi:0,advance:"automatic",record_webcam:false,track_mouse:false};
  return {name:kind+"_"+n,kind:kind,image:null,text:null,duration:2.0,isi:1.0,
    advance:"automatic",record_webcam:false,track_mouse:false,children:[]};
}
const isGroup=n=>n.kind==="block"||(n.children&&n.children.length>0);

// path helpers: path is array of child indices from root
function getList(path){ let list=plan.blocks; for(const i of path){ list=list[i].children; } return list; }
function getNode(path){ const p=path.slice(),i=p.pop(); return getList(p)[i]; }
function removeAt(path){ const p=path.slice(),i=p.pop(); return getList(p).splice(i,1)[0]; }
function insertAt(path,index,node){ getList(path).splice(index,0,node); }
function isPrefix(a,b){ if(a.length>b.length)return false; return a.every((v,i)=>v===b[i]); }

// ---- render ----
function render(){
  document.getElementById("title").value=plan.title;
  const root=document.getElementById("root"); root.innerHTML="";
  root.appendChild(renderList(plan.blocks, []));
  updateMeta();
}
function renderList(list, path){
  const wrap=document.createElement("div");
  list.forEach((node,i)=> wrap.appendChild(renderNode(node, path.concat(i))));
  const dz=document.createElement("div");
  dz.className="dropzone"; dz.textContent="Drag stimuli and files here";
  dz.dataset.drop=JSON.stringify(path); dz.dataset.index=list.length;
  wireDrop(dz); wrap.appendChild(dz);
  return wrap;
}
function renderNode(node, path){
  const el=document.createElement("div");
  el.className="node"+(isGroup(node)?" group":""); el.draggable=true;
  el.dataset.path=JSON.stringify(path);
  const grp=isGroup(node);
  el.innerHTML=`<div class="nrow">
    <span class="grip">&#8942;&#8942;</span>
    <span class="nico">${ICONS[node.kind]||"&#9679;"}</span>
    <input class="nname" value="${node.name}">
    <span class="tag">${grp?"group":node.kind}</span>
    <span class="spacer"></span>
    <button class="mini" data-act="gear" title="settings">&#9881;</button>
    <button class="mini" data-act="del" title="delete">&#10005;</button>
  </div>
  <div class="settings">${settingsHTML(node,grp)}</div>`;
  if(grp){ const kids=document.createElement("div"); kids.className="kids";
    kids.appendChild(renderList(node.children, path)); el.appendChild(kids); }
  wireNode(el, node, path);
  return el;
}
function settingsHTML(node,grp){
  if(grp) return `<label>repeats <input type="number" data-f="repeats" value="${node.repeats}" style="width:60px"></label>
    <label><input type="checkbox" data-f="randomize" ${node.randomize?"checked":""}> randomize children</label>`;
  return `<label>duration <input type="number" step="0.1" data-f="duration" value="${node.duration}" style="width:70px"> s</label>
    <label>ISI <input type="number" step="0.1" data-f="isi" value="${node.isi}" style="width:60px"> s</label>
    <label>advance <select data-f="advance">
      <option ${node.advance==="automatic"?"selected":""}>automatic</option>
      <option ${node.advance==="manual"?"selected":""}>manual</option>
      <option ${node.advance==="key"?"selected":""}>key</option></select></label>
    <label><input type="checkbox" data-f="record_webcam" ${node.record_webcam?"checked":""}> webcam</label>
    <label><input type="checkbox" data-f="track_mouse" ${node.track_mouse?"checked":""}> mouse</label>
    <label>src <input data-f="image" value="${node.image||""}" placeholder="path/URL" style="width:150px"></label>
    <label class="upbtn">&#8682; Upload file<input type="file" class="uploader" accept="image/*,video/*" hidden></label>
    ${node.image?`<span class="srcok" title="${node.image}">&#10003; ${shortName(node.image)}</span>`:""}`;
}
function shortName(u){ return (u||"").split("/").pop().slice(0,22); }
function updateMeta(){
  const count=plan.blocks.reduce((a,b)=>a+countLeaves(b),0);
  document.getElementById("meta").textContent=count+" stimuli";
}
function countLeaves(n){ return isGroup(n)?n.children.reduce((a,c)=>a+countLeaves(c),0):1; }

// ---- interactions ----
function wireNode(el, node, path){
  el.querySelector(".nname").addEventListener("input",e=>{ node.name=e.target.value; });
  el.querySelector('[data-act="gear"]').onclick=e=>{ e.stopPropagation();
    el.querySelector(".settings").classList.toggle("open"); };
  el.querySelector('[data-act="del"]').onclick=e=>{ e.stopPropagation();
    removeAt(path); render(); };
  el.querySelectorAll(".settings [data-f]").forEach(inp=>{
    inp.addEventListener("change",e=>{ const f=inp.dataset.f;
      let v = inp.type==="checkbox"?inp.checked : (inp.type==="number"?parseFloat(inp.value):inp.value);
      node[f]=v; if(f==="repeats")updateMeta(); });
  });
  const up=el.querySelector(".uploader");
  if(up) up.addEventListener("change", async e=>{ const f=e.target.files[0]; if(!f)return;
    const fd=new FormData(); fd.append("file",f);
    const r=await (await fetch("/api/stimuli/upload",{method:"POST",body:fd})).json();
    if(r.ok){ node.image=r.url; if(node.kind!=="website") node.kind=f.type.startsWith("video")?"video":"image"; render(); } });
  el.addEventListener("dragstart",e=>{ e.stopPropagation();
    e.dataTransfer.setData("text/plain", JSON.stringify({move:path})); });
  el.addEventListener("dragover",e=>{ e.preventDefault(); e.stopPropagation(); el.classList.add("dragover"); });
  el.addEventListener("dragleave",e=>{ el.classList.remove("dragover"); });
  el.addEventListener("drop",e=>{ e.preventDefault(); e.stopPropagation(); el.classList.remove("dragover");
    const p=path.slice(), idx=p.pop();
    const dest = isGroup(node) ? [path, node.children.length] : [p, idx+1];
    if(e.dataTransfer.files && e.dataTransfer.files.length){ uploadFiles(e.dataTransfer.files, dest[0], dest[1]); return; }
    dropInto(JSON.parse(e.dataTransfer.getData("text/plain")), dest[0], dest[1]);
  });
}
async function uploadFiles(files, destPath, index){
  for(const f of files){ if(!/^(image|video)\//.test(f.type)) continue;
    const fd=new FormData(); fd.append("file",f);
    const r=await (await fetch("/api/stimuli/upload",{method:"POST",body:fd})).json();
    if(r.ok){ const kind=f.type.startsWith("video")?"video":"image"; const n=newNode(kind);
      n.image=r.url; n.name=r.name.replace(/\.[^.]+$/,""); insertAt(destPath,index,n); index++; } }
  render();
}
function wireDrop(dz){
  dz.addEventListener("dragover",e=>{ e.preventDefault(); e.stopPropagation(); dz.classList.add("dragover"); });
  dz.addEventListener("dragleave",e=>dz.classList.remove("dragover"));
  dz.addEventListener("drop",e=>{ e.preventDefault(); e.stopPropagation(); dz.classList.remove("dragover");
    const destPath=JSON.parse(dz.dataset.drop), index=parseInt(dz.dataset.index);
    if(e.dataTransfer.files && e.dataTransfer.files.length){ uploadFiles(e.dataTransfer.files, destPath, index); return; }
    dropInto(JSON.parse(e.dataTransfer.getData("text/plain")), destPath, index); });
}
function dropInto(data, destPath, index){
  if(data.new){ insertAt(destPath, index, newNode(data.new)); render(); return; }
  const from=data.move;
  if(isPrefix(from, destPath)){ flash("can't drop a group into itself"); return; } // no cycles
  const node=removeAt(from);
  // adjust index if removing shifted the destination list
  if(from.length===destPath.length+1 && isPrefix(destPath, from)){
     const removedIdx=from[from.length-1]; if(removedIdx<index) index--; }
  insertAt(destPath, index, node); render();
}
// palette: drag OR click to add
document.querySelectorAll("#palette .chip").forEach(ch=>{
  ch.addEventListener("dragstart",e=> e.dataTransfer.setData("text/plain",
    JSON.stringify({new:ch.dataset.new})));
  ch.addEventListener("click",()=>{ insertAt([], plan.blocks.length, newNode(ch.dataset.new));
    render(); flash("added "+ch.dataset.new); });
});
document.getElementById("title").addEventListener("input",e=>plan.title=e.target.value);

function flash(m){ const s=document.getElementById("stat"); s.textContent=m; setTimeout(()=>s.textContent="",2500); }
async function save(){ const r=await fetch("/api/study",{method:"POST",
  headers:{"Content-Type":"application/json"},body:JSON.stringify(plan)});
  const d=await r.json(); flash(`saved · ${d.trials} trials · ~${d.duration}s`+
    (d.errors&&d.errors.length?` · ${d.errors.length} warning(s)`:"")); return d; }
document.getElementById("save").onclick=save;
document.getElementById("run").onclick=async()=>{ await save();
  await fetch("/api/record",{method:"POST",headers:{"Content-Type":"application/json"},
    body:JSON.stringify({seconds:20,signals:["gaze","eda","ecg"],study:true})});
  flash("recording started — see Sessions on Home"); };

// load existing study
fetch("/api/study").then(r=>r.json()).then(d=>{ plan=d; if(!plan.sensors)plan.sensors=[]; render(); });
</script>
__TOUR__
</body></html>""").replace("__THEME__", THEME_CSS).replace("__TOUR__", flow_tour)


def _present_html() -> str:
    from .ui import THEME_CSS
    return r"""<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"><title>biosync — present</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>__THEME__
 html,body{height:100%;overflow:hidden;background:#0e1417;color:#eef}
 #stage{position:fixed;inset:0;display:grid;place-items:center;padding:4vh 6vw}
 .slide{max-width:900px;width:100%;text-align:center}
 .slide img{max-width:90vw;max-height:80vh;border-radius:8px}
 .slide .big{font-size:30px;line-height:1.4}
 iframe{width:90vw;height:80vh;border:0;border-radius:10px;background:#fff}
 .surveycard{background:var(--surface);color:var(--ink);border-radius:14px;padding:22px;
   max-height:86vh;overflow:auto;text-align:left;box-shadow:var(--shadow)}
 .q{margin:0 0 18px;padding-bottom:14px;border-bottom:1px solid var(--border)}
 .qtext{font-weight:600;margin-bottom:10px}
 .scale{display:flex;align-items:center;gap:12px}.pts{display:flex;gap:8px;flex:1;justify-content:center}
 .pt{display:flex;flex-direction:column;align-items:center;font-size:12px;color:var(--muted);cursor:pointer}
 .end{font-size:12px;color:var(--muted);max-width:120px}
 .opts,.opt{display:flex}.opts{flex-direction:column;gap:8px}.opt{gap:8px;align-items:center}
 a.exit{position:fixed;top:14px;left:16px;z-index:10}
 #intro{color:#eef;text-align:center}
 .btn.big{font-size:17px;padding:12px 26px}
 #prog{position:fixed;top:16px;right:18px;color:#9fb;font-size:13px}
</style></head><body>
<a class="exit iconbtn" href="/">&larr; Exit</a>
<div id="prog"></div>
<div id="stage"><div id="intro" class="slide">
  <h1>Present study</h1>
  <p class="note" style="color:#9ab">Walks through the current study's stimuli and
     pushes markers. Run a recording at the same time to capture event-locked data.</p>
  <input id="rid" placeholder="Participant ID" style="margin:8px 0">
  <div><button class="btn big" id="go">Start presentation</button></div>
</div></div>
<script>
const $=s=>document.querySelector(s);
let trials=[], idx=0, participant={};
const stage=$("#stage");
function marker(label){ fetch("/api/present/marker",{method:"POST",
  headers:{"Content-Type":"application/json"},body:JSON.stringify({label})}); }
function sleep(ms){return new Promise(r=>setTimeout(r,ms));}
function keyNext(){ return new Promise(r=>{ const h=e=>{ if(e.key===" "||e.key==="Enter"){
  removeEventListener("keydown",h); r(); } }; addEventListener("keydown",h); }); }

async function start(){
  participant={id:$("#rid").value.trim()};
  const r=await fetch("/api/present/start",{method:"POST",headers:{"Content-Type":"application/json"},
    body:JSON.stringify({participant})});
  trials=(await r.json()).trials; idx=0; await runNext();
}
async function runNext(){
  if(idx>=trials.length){ await finish(); return; }
  const t=trials[idx]; $("#prog").textContent=`${idx+1} / ${trials.length}`;
  marker("stim_on:"+t.name);
  if(t.kind==="survey"){ await showSurvey(t); }
  else if(t.kind==="website"){ await showWebsite(t); }
  else { await showSlide(t); }
  marker("stim_off:"+t.name);
  idx++; if(t.isi) await sleep(t.isi*1000); await runNext();
}
async function showSlide(t){
  stage.innerHTML=`<div class="slide">${ t.kind==="image"&&t.image
    ? `<img src="${t.image}">` : `<div class="big">${t.text||t.name}</div>` }</div>`;
  if(t.advance==="key"||t.advance==="manual") await keyNext();
  else await sleep((t.duration||2)*1000);
}
async function showWebsite(t){
  stage.innerHTML=`<div class="slide"><iframe src="${t.url}"></iframe></div>`;
  await sleep((t.duration||10)*1000);
  await fetch("/api/present/respond",{method:"POST",headers:{"Content-Type":"application/json"},
    body:JSON.stringify({participant,stimulus:t.name,kind:"website",data:{dwell_s:t.duration}})});
}
function showSurvey(t){ return new Promise(resolve=>{
  stage.innerHTML=`<div class="slide"><div class="surveycard"><h2>${t.name}</h2>
    <form id="sform">${t.survey_html||"<p>(no questions)</p>"}
    <button type="submit" class="btn">Submit</button></form></div></div>`;
  $("#sform").addEventListener("submit", async e=>{
    e.preventDefault(); const fd=new FormData(e.target); const data={};
    for(const [k,v] of fd.entries()){ if(data[k]){ data[k]=[].concat(data[k],v);} else data[k]=v; }
    await fetch("/api/present/respond",{method:"POST",headers:{"Content-Type":"application/json"},
      body:JSON.stringify({participant,stimulus:t.name,kind:"survey",data})});
    resolve();
  });
});}
async function finish(){ await fetch("/api/present/stop",{method:"POST"});
  stage.innerHTML=`<div class="slide"><h1>Done</h1>
    <p class="note" style="color:#9ab">Responses saved. <a href="/" style="color:#7cf">Home</a></p></div>`;
  $("#prog").textContent=""; }
$("#go").onclick=start;
</script></body></html>""".replace("__THEME__", THEME_CSS)


def _screen_html() -> str:
    from .ui import THEME_CSS, topbar
    return (r"""<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"><title>biosync — screen recording</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>__THEME__ video{width:100%;border-radius:10px;background:#000;margin-top:10px}
 .dot{display:inline-block;width:10px;height:10px;border-radius:50%;background:var(--danger);
   margin-right:6px;animation:pulse 1.1s infinite}@keyframes pulse{50%{opacity:.3}}</style></head><body>
"""
    + topbar(subtitle="Screen recording", back_href="/")
    + r"""
<div class="wrap"><div class="card">
  <h2>Screen recording</h2>
  <p class="note" style="margin-top:0">Capture the screen during a session. The video
    is timestamped on the shared clock and attached to your latest recording, so the
    Timeline Replay shows what the participant saw, synced to the gaze and signals.</p>
  <div style="display:flex;gap:10px;align-items:center;margin-top:8px">
    <button class="btn" id="start">Start screen capture</button>
    <button class="iconbtn" id="stop" disabled>Stop &amp; attach</button>
    <span class="statline" id="stat"></span>
  </div>
  <video id="preview" muted playsinline></video>
</div></div>
<script>
const $=s=>document.querySelector(s);
let rec=null, chunks=[], t0=0, stream=null;
$("#start").onclick=async()=>{
  try{ stream=await navigator.mediaDevices.getDisplayMedia({video:{frameRate:15},audio:false}); }
  catch(e){ $("#stat").textContent="capture cancelled"; return; }
  $("#preview").srcObject=stream;
  const r=await (await fetch("/api/screen/start",{method:"POST"})).json(); t0=r.t0;
  chunks=[]; rec=new MediaRecorder(stream,{mimeType:"video/webm"});
  rec.ondataavailable=e=>{ if(e.data.size) chunks.push(e.data); };
  rec.onstop=upload; rec.start(1000);
  $("#start").disabled=true; $("#stop").disabled=false;
  $("#stat").innerHTML='<span class="dot"></span>recording';
};
$("#stop").onclick=()=>{ rec&&rec.stop(); stream&&stream.getTracks().forEach(t=>t.stop());
  $("#stop").disabled=true; $("#stat").textContent="uploading…"; };
async function upload(){
  const blob=new Blob(chunks,{type:"video/webm"});
  const fd=new FormData(); fd.append("video",blob,"screen.webm"); fd.append("t0",t0);
  const r=await (await fetch("/api/screen/upload",{method:"POST",body:fd})).json();
  $("#stat").textContent = r.saved ? ("attached to "+r.session) : ("error: "+(r.error||""));
  $("#start").disabled=false;
}
</script></body></html>""").replace("__THEME__", THEME_CSS)


def _webcam_html() -> str:
    from .ui import THEME_CSS
    return r"""<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"><title>biosync — webcam eye tracking</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<script src="https://webgazer.cs.brown.edu/webgazer.js"></script>
<style>__THEME__
 html,body{height:100%;overflow:hidden;background:#0e1417;color:#eef}
 #stage{position:fixed;inset:0}
 .dot{position:absolute;width:24px;height:24px;border-radius:50%;transform:translate(-50%,-50%);
   background:radial-gradient(circle,#fff 0 3px,#3a9aa8 4px 9px,transparent 10px);cursor:pointer}
 #cursor{position:absolute;width:20px;height:20px;border-radius:50%;background:rgba(245,133,24,.85);
   transform:translate(-50%,-50%);pointer-events:none;box-shadow:0 0 14px rgba(245,133,24,.6);display:none}
 #panel{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);max-width:520px;width:92%}
 a.exit{position:fixed;top:14px;left:16px;z-index:10}
 #hud{position:fixed;top:14px;right:16px;background:rgba(255,255,255,.92);color:#222;border-radius:10px;
   padding:8px 12px;font-size:13px}
</style></head><body>
<a class="exit iconbtn" href="/">&larr; Home</a>
<div id="hud">webcam <b id="rate">0</b>/s · <span id="phase">idle</span></div>
<div id="stage">
  <div id="panel"><div class="card">
    <h2>Webcam eye tracking</h2>
    <p class="note">Uses your laptop camera (WebGazer). Calibrate by clicking each
      dot while looking at it; gaze then streams into biosync as a Gaze source —
      record it, build heatmaps/AOIs, exactly like a hardware tracker.</p>
    <div style="display:flex;gap:10px;margin-top:8px">
      <button class="btn" id="calib">Calibrate (click 9 dots)</button>
      <button class="iconbtn" id="track" disabled>Start tracking</button>
    </div>
  </div></div>
  <div id="cursor"></div>
</div>
<script>
const $=s=>document.querySelector(s);
const PTS=[[0.1,0.1],[0.5,0.1],[0.9,0.1],[0.1,0.5],[0.5,0.5],[0.9,0.5],[0.1,0.9],[0.5,0.9],[0.9,0.9]];
let buf=[], sending=false, count=0, t0=Date.now();

function norm(x,y){ return [x/innerWidth, y/innerHeight]; }
async function flush(){ if(sending||!buf.length)return; sending=true;
  const samples=buf.splice(0,buf.length);
  try{ await fetch("/api/webcam/gaze",{method:"POST",headers:{"Content-Type":"application/json"},
    body:JSON.stringify({samples})}); }catch(e){} sending=false; }
setInterval(flush,120);
setInterval(()=>{ $("#rate").textContent=count; count=0; },1000);

function onGaze(data){ if(!data)return; const [nx,ny]=norm(data.x,data.y);
  const c=$("#cursor"); c.style.display="block"; c.style.left=data.x+"px"; c.style.top=data.y+"px";
  if(tracking){ buf.push({x:nx,y:ny,conf:1.0}); count++; } }

let tracking=false;
async function initGazer(){ await webgazer.setGazeListener((d)=>onGaze(d)).begin();
  webgazer.showVideoPreview(true).showPredictionPoints(false); }
$("#calib").onclick=async()=>{ $("#panel").style.display="none"; $("#phase").textContent="calibrating";
  await initGazer();
  for(const [nx,ny] of PTS){ await clickDot(nx*innerWidth, ny*innerHeight); }
  $("#phase").textContent="calibrated"; $("#track").disabled=false;
  $("#panel").style.display="block"; };
function clickDot(x,y){ return new Promise(res=>{ const d=document.createElement("div");
  d.className="dot"; d.style.left=x+"px"; d.style.top=y+"px"; let n=0;
  d.onclick=()=>{ n++; if(n>=3){ d.remove(); res(); } d.style.opacity=1-n*0.25; };
  document.getElementById("stage").appendChild(d); }); }
$("#track").onclick=async()=>{ await fetch("/api/webcam/start",{method:"POST"});
  tracking=true; $("#phase").textContent="tracking → recording-ready";
  $("#panel").innerHTML='<div class="card"><h2>Streaming gaze</h2>'+
   '<p class="note">Gaze is now an LSL source. Go to Home and Record (uncheck Gaze '+
   'so it uses the webcam stream), or it is captured automatically.</p>'+
   '<a class="iconbtn" href="/">Home</a></div>'; };
addEventListener("beforeunload",()=>{ try{webgazer.end();}catch(e){} fetch("/api/webcam/stop",{method:"POST"});});
</script></body></html>""".replace("__THEME__", THEME_CSS)


def _validate_html() -> str:
    from .ui import THEME_CSS, topbar
    return (r"""<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"><title>biosync — hardware validation</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>__THEME__
 .check{display:flex;align-items:center;gap:10px;padding:7px 0;border-bottom:1px solid var(--border);font-size:14px}
 .check:last-child{border-bottom:0}
 .tick{width:20px;text-align:center;font-weight:700}
 .ok{color:var(--ok)}.no{color:var(--danger)}
 .devhead{display:flex;align-items:center;gap:10px;margin-bottom:6px}
 .badge{padding:2px 10px;border-radius:999px;font-weight:700;font-size:12px}
 .pass{background:#1f7a4d;color:#fff}.fail{background:#b23b2e;color:#fff}</style></head><body>
"""
    + topbar(subtitle="Hardware validation", back_href="/")
    + r"""
<div class="wrap">
  <div class="card">
    <h2>Validate a device</h2>
    <p class="note" style="margin-top:0">Connect a device so it streams (built-in test
      signals work too), give it a name, then run a short recording and an automatic
      check: stream present, channels, sample rate, signal quality, and a known-signal
      test per modality (gaze validity/accuracy, plausible HR, EEG band power, EDA range).
      The result is saved as a validation report.</p>
    <div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center">
      <input id="dev" placeholder="device name (e.g. Tobii Pro Nano)" style="width:230px">
      <label class="fld"><input type="checkbox" class="sig" value="gaze" checked> Gaze</label>
      <label class="fld"><input type="checkbox" class="sig" value="eda" checked> EDA</label>
      <label class="fld"><input type="checkbox" class="sig" value="ecg" checked> ECG</label>
      <label class="fld"><input type="checkbox" class="sig" value="eeg"> EEG</label>
      <label class="fld">seconds <input type="number" id="secs" value="12" min="6"></label>
      <button class="btn" id="run">Run validation</button>
      <span class="statline" id="stat"></span>
    </div>
  </div>
  <div id="results"></div>
</div>
<script>
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
function wait(){ return new Promise(res=>{ const i=setInterval(async()=>{
  const s=await (await fetch("/api/status")).json();
  if(s.state==="done"||s.state==="error"){ clearInterval(i); res(s);} },400); }); }
$("#run").onclick=async()=>{
  const signals=$$(".sig").filter(c=>c.checked).map(c=>c.value);
  $("#stat").textContent="recording…"; $("#results").innerHTML="";
  await fetch("/api/record",{method:"POST",headers:{"Content-Type":"application/json"},
    body:JSON.stringify({seconds:+$("#secs").value, signals, study:false})});
  await wait();
  $("#stat").textContent="validating…";
  const r=await (await fetch("/api/validate",{method:"POST",headers:{"Content-Type":"application/json"},
    body:JSON.stringify({device:$("#dev").value})})).json();
  $("#stat").textContent = r.overall ? "all checks passed" : "some checks failed";
  $("#results").innerHTML = r.reports.map(rep=>`<div class="card">
    <div class="devhead"><b>${rep.device}</b> <span class="qtype">${rep.stream} · ${rep.modality}</span>
      <span class="spacer" style="flex:1"></span>
      <span class="badge ${rep.passed?'pass':'fail'}">${rep.passed?'PASS':'FAIL'} ${rep.n_pass}/${rep.n}</span></div>
    ${rep.checks.map(c=>`<div class="check"><span class="tick ${c.passed?'ok':'no'}">${c.passed?'✓':'✕'}</span>
      <span style="min-width:170px">${c.name}</span><span class="qtype">${c.detail}</span></div>`).join("")}
  </div>`).join("") || '<div class="card"><p class="empty">No streams to validate.</p></div>';
};
</script></body></html>""").replace("__THEME__", THEME_CSS)


def _aoi_editor_html() -> str:
    from .ui import THEME_CSS, topbar
    return (r"""<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"><title>biosync — AOI editor</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>__THEME__
 #stage{position:relative;background:#000;border-radius:10px;overflow:hidden;max-width:100%}
 #vid{display:block;width:100%}
 #ov{position:absolute;inset:0;cursor:crosshair}
 .ctl{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin:10px 0}
 #scrub{flex:1;min-width:160px;accent-color:var(--accent)}
 .kf{display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--border);font-size:13px}
 .aoiitem{display:flex;align-items:center;gap:8px;padding:8px;border:1px solid var(--border);
   border-radius:8px;margin:6px 0;cursor:pointer}.aoiitem.sel{border-color:var(--accent);background:var(--accent-50)}
 .swatch{width:12px;height:12px;border-radius:3px;background:var(--accent)}
 .pill{font-size:11px;color:var(--muted)}</style></head><body>
"""
    + topbar(subtitle="Dynamic AOI editor", back_href="/")
    + r"""
<div class="wrap">
  <div class="card">
    <h2>Dynamic AOI editor</h2>
    <p class="note" style="margin-top:0">Pick a video stimulus, scrub to a moment, drag a box
      over the object, and <b>Add keyframe</b>. Repeat at later moments — the AOI moves between
      keyframes automatically. Scrub to preview. Save to use it in AOI metrics.</p>
    <div class="ctl">
      <select id="stim" style="min-width:200px"></select>
      <input id="aoiname" placeholder="AOI name (e.g. product)" style="width:170px">
      <button class="iconbtn" id="newaoi">+ New AOI</button>
    </div>
    <div id="stage"><video id="vid" crossorigin="anonymous"></video>
      <canvas id="ov"></canvas></div>
    <div class="ctl">
      <button class="iconbtn" id="play">&#9654;/&#10073;&#10073;</button>
      <input id="scrub" type="range" min="0" max="100" value="0" step="0.01">
      <span id="clock" class="pill">0.00s</span>
      <button class="btn" id="addkf">Add keyframe</button>
      <button class="iconbtn" id="save">Save AOIs</button>
      <span class="statline" id="stat"></span>
    </div>
  </div>
  <div class="card"><h2>AOIs on this stimulus</h2><div id="aois"></div>
    <div id="kfs"></div></div>
</div>
<script>
const $=s=>document.querySelector(s);
const vid=$("#vid"), ov=$("#ov"), ctx=ov.getContext("2d");
let store={}, stim=null, sel=null, box=null, drag=null;

function fit(){ ov.width=vid.clientWidth; ov.height=vid.clientHeight; draw(); }
vid.addEventListener("loadedmetadata",()=>{ $("#scrub").max=vid.duration||10; fit(); });
addEventListener("resize",fit);

async function loadStimuli(){ const v=await (await fetch("/api/video-stimuli")).json();
  $("#stim").innerHTML=v.map(s=>`<option value="${s.name}" data-url="${s.url}">${s.name}</option>`).join("")
    || '<option value="">(no video stimuli — add a Video in Flow Designer)</option>';
  store=await (await fetch("/api/aoi")).json();
  if(v.length) selectStim(v[0].name, v[0].url); }
function selectStim(name,url){ stim=name; vid.src=url; sel=null;
  store[stim]=store[stim]||[]; renderAOIs(); }
$("#stim").onchange=e=>{ const o=e.target.selectedOptions[0]; selectStim(o.value,o.dataset.url); };

$("#newaoi").onclick=()=>{ const n=$("#aoiname").value.trim()||("aoi_"+((store[stim]||[]).length+1));
  store[stim].push({name:n,kind:"rect",keyframes:[]}); sel=store[stim].length-1; $("#aoiname").value="";
  renderAOIs(); };
function curAOI(){ return sel!=null ? store[stim][sel] : null; }

// draw box on overlay
function norm(e){ const r=ov.getBoundingClientRect();
  return [(e.clientX-r.left)/r.width, (e.clientY-r.top)/r.height]; }
ov.addEventListener("mousedown",e=>{ const [x,y]=norm(e); drag=[x,y]; box=[x,y,x,y]; });
ov.addEventListener("mousemove",e=>{ if(!drag)return; const [x,y]=norm(e); box=[drag[0],drag[1],x,y]; draw(); });
addEventListener("mouseup",()=>{ drag=null; });

function interp(aoi,t){ const k=aoi.keyframes; if(!k.length)return null;
  if(t<=k[0][0])return k[0][1]; if(t>=k[k.length-1][0])return k[k.length-1][1];
  for(let i=0;i<k.length-1;i++){ const [t0,r0]=k[i],[t1,r1]=k[i+1];
    if(t0<=t&&t<=t1){ const f=(t-t0)/((t1-t0)||1e-9);
      return r0.map((a,j)=>a+(r1[j]-a)*f); } } return k[k.length-1][1]; }
function draw(){ ctx.clearRect(0,0,ov.width,ov.height);
  // preview every AOI's interpolated box at the current time
  (store[stim]||[]).forEach((a,i)=>{ const r=interp(a,vid.currentTime); if(!r)return;
    rect(r, i===sel?"#3a9aa8":"rgba(245,133,24,.9)", a.name); });
  if(box && sel!=null) rect(box,"#fff"); }
function rect(r,color,label){ const x=Math.min(r[0],r[2])*ov.width, y=Math.min(r[1],r[3])*ov.height,
  w=Math.abs(r[2]-r[0])*ov.width, h=Math.abs(r[3]-r[1])*ov.height;
  ctx.strokeStyle=color; ctx.lineWidth=2; ctx.strokeRect(x,y,w,h);
  if(label){ ctx.fillStyle=color; ctx.font="12px Inter"; ctx.fillText(label,x+3,y-4); } }

$("#addkf").onclick=()=>{ const a=curAOI(); if(!a){ flash("make a New AOI first"); return; }
  if(!box){ flash("drag a box on the video first"); return; }
  const r=[Math.min(box[0],box[2]),Math.min(box[1],box[3]),Math.max(box[0],box[2]),Math.max(box[1],box[3])];
  a.keyframes.push([+vid.currentTime.toFixed(2), r]); a.keyframes.sort((p,q)=>p[0]-q[0]);
  box=null; renderAOIs(); draw(); flash("keyframe @ "+vid.currentTime.toFixed(2)+"s"); };

$("#scrub").addEventListener("input",e=>{ vid.currentTime=parseFloat(e.target.value);
  $("#clock").textContent=vid.currentTime.toFixed(2)+"s"; });
vid.addEventListener("timeupdate",()=>{ $("#scrub").value=vid.currentTime;
  $("#clock").textContent=vid.currentTime.toFixed(2)+"s"; draw(); });
$("#play").onclick=()=>{ vid.paused?vid.play():vid.pause(); };

function renderAOIs(){ const list=store[stim]||[];
  $("#aois").innerHTML=list.map((a,i)=>`<div class="aoiitem ${i===sel?'sel':''}" data-i="${i}">
    <span class="swatch"></span><b>${a.name}</b>
    <span class="pill">${a.keyframes.length} keyframe(s)</span>
    <span class="spacer" style="flex:1"></span><button class="iconbtn" data-del="${i}">✕</button></div>`).join("")
    || '<div class="empty">No AOIs yet — New AOI, draw a box, Add keyframe.</div>';
  $$("#aois .aoiitem").forEach(el=>el.onclick=e=>{ if(e.target.dataset.del!=null){
      store[stim].splice(+e.target.dataset.del,1); sel=null; renderAOIs(); draw(); return; }
    sel=+el.dataset.i; renderAOIs(); draw(); });
  const a=curAOI();
  $("#kfs").innerHTML = a ? a.keyframes.map((k,j)=>`<div class="kf">
    <span class="pill">#${j+1}</span> t=${k[0]}s
    <span class="pill">[${k[1].map(v=>v.toFixed(2)).join(", ")}]</span>
    <span class="spacer" style="flex:1"></span>
    <button class="iconbtn" data-kf="${j}">remove</button></div>`).join("") : "";
  $$("#kfs [data-kf]").forEach(b=>b.onclick=()=>{ curAOI().keyframes.splice(+b.dataset.kf,1); renderAOIs(); draw(); });
}
const $$=s=>[...document.querySelectorAll(s)];
function flash(m){ $("#stat").textContent=m; setTimeout(()=>$("#stat").textContent="",2500); }
$("#save").onclick=async()=>{ const r=await (await fetch("/api/aoi",{method:"POST",
  headers:{"Content-Type":"application/json"},body:JSON.stringify(store)})).json();
  flash("saved "+r.aois+" AOIs"); };
loadStimuli();
</script></body></html>""").replace("__THEME__", THEME_CSS)


def _license_html() -> str:
    from .ui import THEME_CSS, topbar
    portal = os.environ.get("BIOSYNC_PORTAL", "https://account.biosync-lab.co.za")
    return (r"""<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"><title>biosync — license</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>__THEME__
 .lic{font-size:15px} .big{font-size:30px;font-weight:800;color:var(--primary)}
 .pill{display:inline-block;padding:3px 12px;border-radius:999px;font-weight:700;font-size:13px}
 .ok{background:#1f7a4d;color:#fff}.trial{background:#2f6470;color:#fff}.exp{background:#b23b2e;color:#fff}
 textarea{width:100%;height:88px;font-family:ui-monospace,Menlo,monospace;font-size:12px;
   border:1px solid var(--border);border-radius:8px;padding:10px}</style></head><body>
"""
    + topbar(subtitle="License", back_href="/")
    + r"""
<div class="wrap">
  <div class="card"><h2>License status</h2>
    <div id="status" class="lic">checking…</div>
  </div>
  <div class="card"><h2>Activate a license key</h2>
    <p class="note" style="margin-top:0">Paste the key from your account. It is verified
      securely on this computer — no internet required.</p>
    <textarea id="key" placeholder="BSX1.xxxxxxxx.xxxxxxxx"></textarea>
    <div style="display:flex;gap:10px;align-items:center;margin-top:10px">
      <button class="btn" id="activate">Activate</button>
      <button class="iconbtn" id="deactivate">Remove license</button>
      <span class="statline" id="stat"></span>
    </div>
  </div>
  <div class="card"><h2>Need a license?</h2>
    <p class="note" style="margin-top:0">Licenses are issued with an annual subscription.
      Create an account, subscribe, and your key appears in your account dashboard.</p>
    <a class="btn" href="__PORTAL__" target="_blank">Get a license &rarr;</a>
  </div>
</div>
<script>
const $=s=>document.querySelector(s);
function render(s){
  const cls=s.mode==='licensed'?'ok':(s.mode==='trial'?'trial':'exp');
  let html=`<span class="pill ${cls}">${s.mode.toUpperCase()}</span> `;
  if(s.mode==='licensed'){ html+=`<div style="margin-top:10px">Licensed to <b>${s.email}</b> · `+
    `plan <b>${s.plan}</b> · <span class="big">${s.days_left}</span> days left (expires ${s.expires}).</div>`; }
  else if(s.mode==='trial'){ html+=`<div style="margin-top:10px"><span class="big">${s.days_left}</span> of `+
    `${s.trial_days} trial days remaining. Recording is enabled during the trial.</div>`; }
  else { html+=`<div style="margin-top:10px">Your trial has ended. Enter a license key to keep recording.</div>`; }
  $("#status").innerHTML=html;
}
async function refresh(){ render(await (await fetch("/api/license/status")).json()); }
$("#activate").onclick=async()=>{ const r=await (await fetch("/api/license/activate",{method:"POST",
  headers:{"Content-Type":"application/json"},body:JSON.stringify({key:$("#key").value})})).json();
  $("#stat").textContent = r.ok ? ("activated — licensed to "+r.email) : ("invalid key: "+(r.reason||""));
  refresh(); };
$("#deactivate").onclick=async()=>{ await fetch("/api/license/deactivate",{method:"POST"});
  $("#stat").textContent="license removed"; refresh(); };
refresh();
</script></body></html>""").replace("__THEME__", THEME_CSS).replace("__PORTAL__", portal)


def _monitor_html() -> str:
    from .ui import THEME_CSS, topbar
    return (r"""<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"><title>biosync — signal quality</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>__THEME__
 .qcard{display:flex;align-items:center;gap:14px;padding:14px 16px}
 .light{width:14px;height:14px;border-radius:50%;flex:0 0 auto}
 .good{background:#2e8b57;box-shadow:0 0 8px rgba(46,139,87,.6)}
 .warn{background:#c9772a;box-shadow:0 0 8px rgba(201,119,42,.6)}
 .bad{background:#d65745;box-shadow:0 0 8px rgba(214,87,69,.6);animation:pulse 1s infinite}
 @keyframes pulse{50%{opacity:.4}}
 .qname{font-weight:600;min-width:120px}.qtype{color:var(--muted);font-size:12px}
 .qrate{font-variant-numeric:tabular-nums;color:var(--muted);font-size:13px;min-width:90px}
 .qbar{flex:1;height:8px;border-radius:5px;background:var(--surface-2);overflow:hidden;max-width:220px}
 .qfill{height:100%;background:var(--accent)}
 .qreason{color:var(--danger);font-size:13px}</style></head><body>
"""
    + topbar(subtitle="Signal quality", back_href="/")
    + r"""
<div class="wrap">
  <div class="card">
    <h2>Live signal quality</h2>
    <p class="note" style="margin-top:0">Start your sensors (or a recording), then watch
      each stream's health here. Green = good, amber = warning, red = bad
      (flatline, low rate, clipping, or poor gaze validity).</p>
    <div style="display:flex;gap:10px;align-items:center">
      <button class="btn" id="start">Start monitoring</button>
      <button class="iconbtn" id="stop" disabled>Stop</button>
      <span class="statline" id="stat"></span>
    </div>
  </div>
  <div id="streams"></div>
</div>
<script>
const $=s=>document.querySelector(s);
let timer=null;
function bar(label,frac){ return `<div class="qbar"><div class="qfill" style="width:${Math.round(frac*100)}%"></div></div>`; }
async function poll(){ const q=await (await fetch("/api/monitor/status")).json();
  const names=Object.keys(q);
  if(!names.length){ $("#streams").innerHTML='<div class="card"><p class="empty">Waiting for streams… start your sensors or a recording.</p></div>'; return; }
  $("#streams").innerHTML=names.map(n=>{ const r=q[n], m=r.metrics||{};
    const frac = m.validity!=null ? m.validity : (m.rate_ratio!=null?Math.min(1,m.rate_ratio):1);
    return `<div class="card qcard">
      <span class="light ${r.status}"></span>
      <span class="qname">${n}<div class="qtype">${r.status.toUpperCase()}</div></span>
      <span class="qrate">${r.rate_hz} Hz</span>
      ${bar(n,frac)}
      <span class="qreason">${(r.reasons||[]).join(", ")}</span></div>`; }).join("");
}
$("#start").onclick=async()=>{ await fetch("/api/monitor/start",{method:"POST"});
  $("#start").disabled=true; $("#stop").disabled=false; $("#stat").textContent="monitoring…";
  timer=setInterval(poll,500); poll(); };
$("#stop").onclick=async()=>{ await fetch("/api/monitor/stop",{method:"POST"});
  clearInterval(timer); $("#start").disabled=false; $("#stop").disabled=true; $("#stat").textContent="stopped"; };
addEventListener("beforeunload",()=>fetch("/api/monitor/stop",{method:"POST"}));
</script></body></html>""").replace("__THEME__", THEME_CSS)


def _participants_html() -> str:
    from .ui import THEME_CSS, topbar
    return (r"""<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"><title>biosync — participants</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>__THEME__ table{width:100%;border-collapse:collapse;font-size:14px}
 td,th{padding:8px 10px;border-bottom:1px solid var(--border);text-align:left}
 th{color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.04em}
 .gpill{display:inline-block;padding:2px 9px;border-radius:999px;background:var(--accent-50);
   color:var(--primary);font-weight:600;font-size:12px}</style></head><body>
"""
    + topbar(subtitle="Participants", back_href="/")
    + r"""
<div class="wrap">
  <div class="card"><h2>Groups</h2><div class="grid" id="groups"></div></div>
  <div class="card"><h2>Participants</h2>
    <table><thead><tr><th>ID</th><th>Name</th><th>Group</th><th>Sessions</th><th></th></tr></thead>
    <tbody id="rows"></tbody></table>
  </div>
  <div class="card"><h2>Statistical export</h2>
    <p class="note" style="margin-top:0">Tidy tables for R / Python / SPSS / Excel:
      per-participant (wide), AOI + events (long), group summary.</p>
    <div style="display:flex;gap:10px">
      <a class="btn" href="/api/export.xlsx">Download Excel (.xlsx)</a>
      <a class="iconbtn" href="/api/export.zip">CSV bundle (.zip)</a>
    </div>
  </div>
</div>
<script>
const $=s=>document.querySelector(s);
fetch("/api/participants").then(r=>r.json()).then(d=>{
  $("#groups").innerHTML = (d.groups||[]).map(g=>
    `<a class="navcard" href="/view/aggregate?group=${encodeURIComponent(g)}">
      <div class="ico">&#9650;</div><div><b>Group ${g}</b>
      <small>aggregate dashboard</small></div></a>`).join("") || '<div class="empty">No groups yet.</div>';
  $("#rows").innerHTML = (d.participants||[]).map(r=>
    `<tr><td>${r.id}</td><td>${r.name||""}</td><td><span class="gpill">${r.group||"(all)"}</span></td>
     <td>${(r.sessions||[]).length}</td>
     <td><a href="/view/dashboard?session=${(r.sessions||[]).slice(-1)[0]||""}">latest →</a></td></tr>`).join("")
     || '<tr><td colspan=5 class="empty">No participants yet — record a session with a participant ID.</td></tr>';
});
</script></body></html>""").replace("__THEME__", THEME_CSS)


def _calibrate_html() -> str:
    from .ui import THEME_CSS
    return r"""<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"><title>biosync — calibration</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>__THEME__
 html,body{height:100%;overflow:hidden;background:#11181b}
 #stage{position:fixed;inset:0;background:#11181b}
 #target{position:absolute;width:26px;height:26px;border-radius:50%;
   background:radial-gradient(circle at 50% 50%,#fff 0 3px,#3a9aa8 4px 9px,transparent 10px);
   transform:translate(-50%,-50%);transition:left .5s ease,top .5s ease;box-shadow:0 0 0 2px rgba(255,255,255,.15)}
 #target.collect{animation:pulse .7s infinite}
 @keyframes pulse{0%,100%{transform:translate(-50%,-50%) scale(1)}50%{transform:translate(-50%,-50%) scale(.6)}}
 #cursor{position:absolute;width:18px;height:18px;border-radius:50%;background:rgba(245,133,24,.85);
   transform:translate(-50%,-50%);pointer-events:none;box-shadow:0 0 12px rgba(245,133,24,.6)}
 #panel{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);max-width:560px;width:92%}
 .center{display:grid;place-items:center;height:100%}
 #results{display:none} table{width:100%;border-collapse:collapse;font-size:13px}
 td,th{padding:5px 8px;border-bottom:1px solid var(--border);text-align:left}
 .big{font-size:30px;font-weight:700}
 .pill{display:inline-block;padding:3px 10px;border-radius:999px;font-weight:600;font-size:13px}
 .pass{background:#1f7a4d;color:#fff}.fail{background:#b23b2e;color:#fff}
 a.exit{position:fixed;top:14px;left:16px;z-index:10}
</style></head><body>
<a class="exit iconbtn" href="/">&larr; Home</a>
<div id="stage">
  <div id="panel"><div class="card">
    <h2>12-point calibration</h2>
    <p class="note">Fix your eyes on each dot as it appears. Uses the live gaze
      source (synthetic by default; pick a tracker on the Sensors screen).</p>
    <div style="display:flex;gap:10px;align-items:center;margin-top:8px">
      <button class="btn" id="startBtn">Start calibration</button>
      <span class="statline" id="msg">ready</span>
    </div>
  </div></div>
  <div id="target" style="display:none"></div>
  <div id="cursor" style="display:none"></div>
  <div id="panel" style="display:none" class="resultsPanel"></div>
</div>
<script>
const $=s=>document.querySelector(s);
const TARGETS=(()=>{const xs=[0.08,0.36,0.64,0.92],ys=[0.12,0.5,0.88];const a=[];
  for(const y of ys)for(const x of xs)a.push([x,y]);return a;})();
const SETTLE=600, COLLECT=900;           // ms per target
let latestTimer=null, collecting=null, perTarget={};

function px(nx,ny){return [nx*innerWidth, ny*innerHeight];}
async function poll(){ try{const r=await fetch("/api/gaze/latest");const g=await r.json();
  const c=$("#cursor"); if(g.valid){const [x,y]=px(g.x,g.y);c.style.left=x+"px";c.style.top=y+"px";c.style.display="block";}
  if(collecting){perTarget[collecting].push([g.x,g.y]);}
}catch(e){} }

function sleep(ms){return new Promise(r=>setTimeout(r,ms));}
async function run(){
  $("#panel").style.display="none";
  await fetch("/api/gaze/start",{method:"POST",headers:{"Content-Type":"application/json"},body:"{}"});
  $("#target").style.display="block"; $("#cursor").style.display="block";
  latestTimer=setInterval(poll,1000/60);
  for(let i=0;i<TARGETS.length;i++){
    const [nx,ny]=TARGETS[i]; const [x,y]=px(nx,ny);
    const t=$("#target"); t.classList.remove("collect"); t.style.left=x+"px"; t.style.top=y+"px";
    $("#msg").textContent=`point ${i+1}/${TARGETS.length}`;
    await sleep(SETTLE);
    const key=nx+","+ny; perTarget[key]=[]; collecting=key; t.classList.add("collect");
    await sleep(COLLECT); collecting=null; t.classList.remove("collect");
  }
  clearInterval(latestTimer); $("#target").style.display="none";
  await fetch("/api/gaze/stop",{method:"POST"});
  const res=await fetch("/api/calibration/evaluate",{method:"POST",
    headers:{"Content-Type":"application/json"},
    body:JSON.stringify({points:perTarget, threshold:1.0})});
  show(await res.json());
}
function show(r){
  const rows=r.per_point.map((p,i)=>`<tr><td>${i+1}</td><td>${p.target[0]}, ${p.target[1]}</td>`+
    `<td>${p.accuracy_deg??"—"}&deg;</td><td>${p.precision_deg??"—"}&deg;</td></tr>`).join("");
  const panel=document.querySelector(".resultsPanel");
  panel.style.display="block";
  panel.innerHTML=`<div class="card"><h2>Calibration result</h2>
    <div style="display:flex;gap:18px;align-items:baseline">
      <div><div class="big">${r.accuracy_deg}&deg;</div><small>accuracy</small></div>
      <div><div class="big">${r.precision_deg}&deg;</div><small>precision</small></div>
      <div><span class="pill ${r.passed?'pass':'fail'}">${r.passed?'PASS':'FAIL'}</span>
        <small> &lt; ${r.threshold_deg}&deg;</small></div></div>
    <table style="margin-top:12px"><tr><th>#</th><th>target</th><th>accuracy</th><th>precision</th></tr>${rows}</table>
    <div style="margin-top:12px;display:flex;gap:10px">
      <button class="btn" onclick="location.reload()">Recalibrate</button>
      <a class="iconbtn" href="/gaze">Open gaze cursor &rarr;</a></div></div>`;
  $("#cursor").style.display="none";
}
$("#startBtn").onclick=run;
</script></body></html>""".replace("__THEME__", THEME_CSS)


def _gaze_html() -> str:
    from .ui import THEME_CSS
    return r"""<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"><title>biosync — live gaze</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>__THEME__
 html,body{height:100%;overflow:hidden;background:#0e1417}
 #stage{position:fixed;inset:0}
 canvas{position:fixed;inset:0}
 #cursor{position:absolute;width:34px;height:34px;border-radius:50%;
   border:3px solid rgba(245,133,24,.95);transform:translate(-50%,-50%);
   box-shadow:0 0 22px rgba(245,133,24,.55);pointer-events:none}
 #hud{position:fixed;top:14px;right:16px;background:rgba(255,255,255,.92);border-radius:10px;
   padding:8px 12px;font-size:13px;box-shadow:var(--shadow)}
 a.exit{position:fixed;top:14px;left:16px;z-index:10}
 #invalid{position:fixed;left:50%;bottom:20px;transform:translateX(-50%);color:#f58518;
   font-weight:600;display:none}
</style></head><body>
<a class="exit iconbtn" href="/">&larr; Home</a>
<div id="stage">
  <canvas id="trail"></canvas>
  <div id="cursor" style="display:none"></div>
  <div id="hud">gaze <b id="fps">0</b> Hz · <span id="val">—</span></div>
  <div id="invalid">gaze lost</div>
</div>
<script>
const cv=document.getElementById("trail"),ctx=cv.getContext("2d");
function resize(){cv.width=innerWidth;cv.height=innerHeight;} resize(); addEventListener("resize",resize);
const cur=document.getElementById("cursor");
let trail=[];
async function start(){ await fetch("/api/gaze/start",{method:"POST",headers:{"Content-Type":"application/json"},body:"{}"});
  const es=new EventSource("/api/gaze/stream");
  es.onmessage=e=>{const g=JSON.parse(e.data); paint(g);};
  es.onerror=()=>{ setTimeout(()=>{}, 500); };
}
function paint(g){
  document.getElementById("fps").textContent=g.fps;
  document.getElementById("val").textContent=g.valid?"tracking":"lost";
  document.getElementById("invalid").style.display=g.valid?"none":"block";
  if(!g.valid){cur.style.display="none";return;}
  const x=g.x*innerWidth, y=g.y*innerHeight;
  cur.style.display="block"; cur.style.left=x+"px"; cur.style.top=y+"px";
  trail.push([x,y,performance.now()]); if(trail.length>90)trail.shift();
}
function draw(){ ctx.clearRect(0,0,cv.width,cv.height);
  for(let i=1;i<trail.length;i++){const a=trail[i-1],b=trail[i];
    ctx.strokeStyle=`rgba(58,154,168,${i/trail.length*0.7})`;ctx.lineWidth=3;
    ctx.beginPath();ctx.moveTo(a[0],a[1]);ctx.lineTo(b[0],b[1]);ctx.stroke();}
  requestAnimationFrame(draw);}
draw(); start();
addEventListener("beforeunload",()=>navigator.sendBeacon&&fetch("/api/gaze/stop",{method:"POST"}));
</script></body></html>""".replace("__THEME__", THEME_CSS)


def _jsonable(obj):
    """Recursively coerce numpy scalars/arrays to native types for jsonify."""
    import numpy as np
    if isinstance(obj, dict):
        return {k: _jsonable(v) for k, v in obj.items()}
    if isinstance(obj, (list, tuple)):
        return [_jsonable(v) for v in obj]
    if isinstance(obj, np.integer):
        return int(obj)
    if isinstance(obj, np.floating):
        return float(obj)
    if isinstance(obj, (np.bool_,)):
        return bool(obj)
    if isinstance(obj, np.ndarray):
        return _jsonable(obj.tolist())
    return obj


def _latest_session_stem(data: Path, name: str | None) -> str | None:
    if name:
        return name[:-3] if name.endswith(".h5") else name
    files = sorted(data.glob("*.h5"), key=lambda p: p.stat().st_mtime)
    return files[-1].stem if files else None


def _screen_for(data: Path, session_path: Path):
    """Return (video_url, offset_s) if a screen recording exists for this session.

    offset maps the replay time axis (0 = first overlapping sample) to video time:
    video.currentTime = offset + playhead.
    """
    import json as _json
    from ..analysis import load as A
    stem = session_path.stem
    side = data / "screens" / f"{stem}.screen.json"
    vid = data / "screens" / f"{stem}.webm"
    if not (side.exists() and vid.exists()):
        return None
    try:
        t0_video = float(_json.load(open(side))["t0_lsl"])
        t0_cw = A.common_window(A.load_session(str(session_path)))[0]
        return (f"/media/screen/{stem}.webm", t0_cw - t0_video)
    except Exception:
        return None


def _safe_session(data: Path, name: str | None) -> Path:
    if not name:
        files = sorted(data.glob("*.h5"))
        if not files:
            abort(404, "no sessions recorded yet")
        return files[-1]
    p = (data / name).resolve()
    if p.parent != data.resolve() or not p.exists():
        abort(404, "session not found")
    return p


def _landing_html() -> str:
    from .ui import THEME_CSS, topbar
    from .onboarding import tour, help_button
    landing_tour = tour("home", [
        {"sel": "#getstarted", "title": "Welcome to biosync",
         "body": "Your home base. These six numbered steps are the full study flow — from choosing sensors to exporting stats. This tour points out every feature; you can replay it anytime with the '?' top-right."},
        {"sel": "#reccard", "title": "Record a session",
         "body": "Pick signals, enter a Participant ID + group, set a duration, and hit Record. Built-in test signals work with NO hardware, so you can try the whole app today."},
        {"sel": 'a[href="/sensors"]', "title": "1 · Select Sensors",
         "body": "Choose which devices collect data — eye trackers, EEG, GSR, ECG and more, from a 42-device catalog. Your choice is saved with the study."},
        {"sel": 'a[href="/calibrate"]', "title": "2 · Calibrate",
         "body": "Run a 12-point eye-tracker calibration and get accuracy + precision in degrees, with a PASS/FAIL gate — before you record."},
        {"sel": 'a[href="/gaze"]', "title": "Live Gaze",
         "body": "A real-time gaze cursor so you can confirm the eye tracker is following the eyes before a study."},
        {"sel": 'a[href="/webcam"]', "title": "Webcam Eye Tracking",
         "body": "No eye tracker? Estimate gaze from the laptop camera. It streams into biosync exactly like a hardware tracker."},
        {"sel": 'a[href="/flow"]', "title": "3 · Flow Designer",
         "body": "Build the study by dragging (or clicking) blocks and stimuli — images, video, text, surveys, websites. Upload your own files, nest blocks, set timing."},
        {"sel": 'a[href="/present"]', "title": "4 · Present & record",
         "body": "Run the study for a participant: it shows each stimulus and pushes markers so everything is event-locked to the sensor data."},
        {"sel": 'a[href="/monitor"]', "title": "Signal Quality",
         "body": "Watch each sensor's health live while recording — green/amber/red for rate, gaze validity, flatline and clipping. Catch a bad electrode before the session is wasted."},
        {"sel": 'a[href="/validate"]', "title": "Validate Hardware",
         "body": "Before a study, run an automatic device check: stream present, sample rate, signal quality, and a known-signal test per modality. Saves a pass/fail report."},
        {"sel": 'a[href="/screen"]', "title": "Screen Recording",
         "body": "Capture the screen during a session; it's timestamped and shown in the Replay, synced to the gaze and signals."},
        {"sel": "#dash", "title": "5 · Dashboard",
         "body": "Per-session analysis: physiology (HR/HRV, EDA), EEG/ERP, eye-movement metrics, gaze heatmap & scanpath, AOI, and an affect circumplex."},
        {"sel": "#replay", "title": "Timeline Replay",
         "body": "Scrub or play back the whole session — every signal moves together with the stimulus markers and the screen recording."},
        {"sel": "#gazemaps", "title": "Gaze Maps",
         "body": "Heatmap and scanpath drawn on the ACTUAL stimulus image the participant saw — the credibility view for eye tracking."},
        {"sel": 'a[href="/aoi-editor"]', "title": "AOI Editor",
         "body": "Draw Areas of Interest on a video and keyframe them so they move with the object. Dwell, TTFF and coverage then track the moving AOI."},
        {"sel": 'a[href="/participants"]', "title": "5–6 · Participants, groups & export",
         "body": "Every recording is tagged to a participant and group. Open a group for an aggregate dashboard, and download tidy CSV (R-ready) or Excel for stats."},
        {"sel": "#sesscard", "title": "Your sessions",
         "body": "Recordings appear here — open the Dashboard, Replay or Gaze Maps for any of them. That's the whole platform. Press '?' anytime to see this tour again."},
    ])
    return f"""<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>biosync</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>{THEME_CSS}</style></head><body>
{topbar(subtitle="acquisition · sync · analysis", extra=help_button())}
<div class="wrap">
  <a id="licbar" href="/license" style="display:none;text-decoration:none"></a>
  <div class="hero">
    <h1>Human-behavior research platform</h1>
    <p>Synchronize eye tracking, physiology, EEG/fNIRS, facial &amp; voice on one
       clock — record, analyze event-locked, replay. An open iMotions-class lab tool.</p>
  </div>

  <div class="card" id="getstarted">
    <h2>Get started — the whole flow in 6 steps</h2>
    <div class="grid" style="grid-template-columns:repeat(3,1fr)">
      <a class="navcard" href="/sensors"><div class="ico">1</div>
        <div><b>Choose sensors</b><small>pick your devices</small></div></a>
      <a class="navcard" href="/calibrate"><div class="ico">2</div>
        <div><b>Calibrate</b><small>eye-tracker accuracy</small></div></a>
      <a class="navcard" href="/flow"><div class="ico">3</div>
        <div><b>Build the study</b><small>blocks, trials, your stimuli</small></div></a>
      <a class="navcard" href="/present"><div class="ico">4</div>
        <div><b>Run &amp; record</b><small>present + capture data</small></div></a>
      <a class="navcard" href="/participants"><div class="ico">5</div>
        <div><b>Analyze</b><small>dashboards &amp; groups</small></div></a>
      <a class="navcard" href="/participants"><div class="ico">6</div>
        <div><b>Export</b><small>CSV / Excel for stats</small></div></a>
    </div>
  </div>

  <div class="card" id="reccard">
    <h2>Record a session</h2>
    <div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-bottom:10px">
      <input id="rid" placeholder="Participant ID (e.g. R01)" style="width:180px">
      <input id="rname" placeholder="name (optional)" style="width:150px">
      <input id="rgroup" placeholder="group (e.g. A)" style="width:110px">
    </div>
    <div style="display:flex;flex-wrap:wrap;gap:10px;align-items:center">
      <label class="fld"><input type="checkbox" class="sig" value="gaze" checked> Gaze</label>
      <label class="fld"><input type="checkbox" class="sig" value="eda" checked> EDA</label>
      <label class="fld"><input type="checkbox" class="sig" value="ecg" checked> ECG</label>
      <label class="fld"><input type="checkbox" class="sig" value="eeg"> EEG</label>
      <label class="fld"><input type="checkbox" id="study" checked> Study markers</label>
      <label class="fld">Duration <input type="number" id="secs" value="20" min="3"> s</label>
      <button class="btn rec" id="rec">&#9679;&nbsp; Record</button>
      <span class="statline" id="stat"><span class="dot" id="dot"></span><span id="msg">ready</span></span>
    </div>
    <p class="note" style="margin:12px 0 0">Built-in test signals run with no hardware.
      Real devices are added via <code>drivers.make(slug)</code> — see HARDWARE.md.</p>
  </div>

  <div class="card">
    <h2>Open</h2>
    <div class="grid" id="navgrid">
      <a class="navcard" href="/sensors">
        <div class="ico">&#9737;</div>
        <div><b>Select Sensors</b><small>choose devices for the study</small></div></a>
      <a class="navcard" href="/calibrate">
        <div class="ico">&#9678;</div>
        <div><b>Calibrate</b><small>12-point eye-tracker calibration</small></div></a>
      <a class="navcard" href="/gaze">
        <div class="ico">&#9673;</div>
        <div><b>Live Gaze</b><small>real-time gaze cursor</small></div></a>
      <a class="navcard" href="/webcam">
        <div class="ico">&#9716;</div>
        <div><b>Webcam Eye Tracking</b><small>gaze from the laptop camera</small></div></a>
      <a class="navcard" href="/screen">
        <div class="ico">&#9114;</div>
        <div><b>Screen Recording</b><small>capture the screen, synced in replay</small></div></a>
      <a class="navcard" href="/monitor">
        <div class="ico">&#9209;</div>
        <div><b>Signal Quality</b><small>live sensor health while recording</small></div></a>
      <a class="navcard" href="/validate">
        <div class="ico">&#10003;</div>
        <div><b>Validate Hardware</b><small>check a device before a study</small></div></a>
      <a class="navcard" href="/flow">
        <div class="ico">&#9635;</div>
        <div><b>Flow Designer</b><small>drag-drop blocks &amp; trials</small></div></a>
      <a class="navcard" href="/present">
        <div class="ico">&#9655;</div>
        <div><b>Present</b><small>run the study (image/text/survey/website)</small></div></a>
      <a class="navcard" href="#" id="dash">
        <div class="ico">&#9650;</div>
        <div><b>Dashboard</b><small>physiology, gaze, AOI, circumplex</small></div></a>
      <a class="navcard" href="#" id="replay">
        <div class="ico">&#9658;</div>
        <div><b>Timeline Replay</b><small>scrub the synchronized streams</small></div></a>
      <a class="navcard" href="#" id="gazemaps">
        <div class="ico">&#9678;</div>
        <div><b>Gaze Maps</b><small>heatmap &amp; scanpath on your stimuli</small></div></a>
      <a class="navcard" href="/aoi-editor">
        <div class="ico">&#9974;</div>
        <div><b>AOI Editor</b><small>draw moving AOIs on video</small></div></a>
      <a class="navcard" href="/participants">
        <div class="ico">&#9823;</div>
        <div><b>Participants</b><small>group analysis across participants</small></div></a>
    </div>
  </div>

  <div class="card" id="sesscard">
    <h2>Sessions</h2>
    <div class="rows" id="sessions"><div class="empty">No sessions yet — record one above.</div></div>
  </div>
</div>
{landing_tour}
<script>
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
let latest=null;
function refresh(){{ fetch("/api/sessions").then(r=>r.json()).then(list=>{{
  latest=list[0]||null;
  $("#sessions").innerHTML = list.length ? list.map(f=>
    `<div class="row"><span class="nm">${{f}}</span><span class="acts">`+
    `<a href="/view/dashboard?session=${{f}}">Dashboard</a>`+
    `<a href="/view/replay?session=${{f}}">Replay</a></span></div>`).join("")
    : '<div class="empty">No sessions yet — record one above.</div>';
}}); }}
function setStat(state,msg){{ const d=$("#dot");
  d.className="dot"+(state==="recording"?" live":state==="done"?" done":"");
  $("#msg").textContent=msg||state; }}
$("#rec").onclick=()=>{{
  const signals=$$(".sig").filter(c=>c.checked).map(c=>c.value);
  const participant={{id:$("#rid").value.trim(), name:$("#rname").value.trim(), group:$("#rgroup").value.trim()}};
  const body={{seconds:+$("#secs").value, signals, study:$("#study").checked, participant}};
  setStat("recording","starting…");
  fetch("/api/record",{{method:"POST",headers:{{"Content-Type":"application/json"}},
    body:JSON.stringify(body)}}).then(r=>r.json()).then(()=>poll());
}};
function poll(){{ fetch("/api/status").then(r=>r.json()).then(s=>{{
  setStat(s.state,s.message);
  if(s.state==="recording"){{ setTimeout(poll,700); }} else {{ refresh(); }}
}}); }}
function goLatest(view){{ if(latest){{ location.href="/view/"+view+"?session="+latest; }}
  else {{ setStat("","record a session first"); }} }}
fetch("/api/license/status").then(r=>r.json()).then(s=>{{ const b=$("#licbar");
  if(s.mode==="licensed") return;
  const exp = s.mode==="expired";
  b.style.display="block"; b.style.padding="11px 16px"; b.style.borderRadius="12px";
  b.style.marginBottom="14px"; b.style.fontWeight="600"; b.style.fontSize="14px";
  b.style.background = exp ? "#fdecea" : "#eef6f8"; b.style.color = exp ? "#b23b2e" : "#2f6470";
  b.style.border = "1px solid " + (exp ? "#f3c7c1" : "#cfe3e6");
  b.textContent = exp ? "⚠ Your trial has ended — click to enter a license key and keep recording."
    : ("Trial: " + s.days_left + " of " + s.trial_days + " days left — click to manage your license.");
}});
$("#dash").onclick=e=>{{e.preventDefault(); goLatest("dashboard");}};
$("#replay").onclick=e=>{{e.preventDefault(); goLatest("replay");}};
$("#gazemaps").onclick=e=>{{e.preventDefault(); goLatest("overlay");}};
refresh();
</script></body></html>"""
