"""
Wildlife Monitoring Dashboard — Yellowstone National Park

Flask app with two pages:
  /        – fullscreen map with camera markers + togglable sidebar
  /det/<id> – detection detail page with all XAI visualisations

Run:  uv run python dashboard.py
"""

import csv
import json
import random
import threading
import uuid
from datetime import datetime
from pathlib import Path

import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.cm as mpl_cm
from PIL import Image, ImageDraw

import torch
from torch import nn
from torch.utils.data import Dataset, DataLoader
from torchvision import transforms
from torchvision.models import efficientnet_v2_s, EfficientNet_V2_S_Weights
from pytorch_grad_cam import ScoreCAM
from pytorch_grad_cam.utils.image import show_cam_on_image
from lime import lime_image
from skimage.segmentation import mark_boundaries
from flask import Flask, render_template_string, jsonify, send_from_directory, request

# ── constants ─────────────────────────────────────────────────────────────────

SEED = 42
random.seed(SEED)
np.random.seed(SEED)
torch.manual_seed(SEED)

CLASS_NAMES = ["bear", "deer", "fox", "hare", "moose", "person", "wolf"]
WEIGHTS_PATH = Path("efficientnet_v2_wild_forest_animals.pt")
DATASET_DIR = Path("wild-forest-animals-and-person-1")
XAI_DIR = Path("_xai_cache")
XAI_DIR.mkdir(exist_ok=True)

# Camera positions as percentages of the map image (adjust to match map.webp)
CAMERAS = {
    "CAM-01": {"name": "Lamar Valley",       "px": 65, "py": 17,
               "desc": "Northeast corridor, prime wolf and bison territory"},
    "CAM-02": {"name": "Hayden Valley",      "px": 48, "py": 38,
               "desc": "Central meadows between canyon and lake"},
    "CAM-03": {"name": "Mammoth Hot Springs", "px": 28, "py": 12,
               "desc": "Northern range, year-round elk habitat"},
    "CAM-04": {"name": "Old Faithful",        "px": 24, "py": 54,
               "desc": "Upper Geyser Basin, forested southwest"},
    "CAM-05": {"name": "Yellowstone Lake",    "px": 55, "py": 48,
               "desc": "Eastern shoreline, moose and waterfowl corridor"},
}

SPECIES_ICON = {
    "bear": "\U0001f43b", "deer": "\U0001f98c", "fox": "\U0001f98a",
    "hare": "\U0001f407", "moose": "\U0001f98c", "person": "\U0001f9d1",
    "wolf": "\U0001f43a",
}

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

# ── dataset ───────────────────────────────────────────────────────────────────

class WildForestAnimalsDataset(Dataset):
    def __init__(self, root, split, transform=None):
        self.root, self.transform = Path(root), transform
        split_dir = self.root / split
        self.samples = []
        with (split_dir / "_classes.csv").open(newline="") as f:
            for row in csv.DictReader(f):
                oh = [int(row[n]) for n in CLASS_NAMES]
                try:
                    label = oh.index(1)
                except ValueError:
                    continue
                self.samples.append((split_dir / row["filename"], label))

    def __len__(self):
        return len(self.samples)

    def __getitem__(self, idx):
        p, l = self.samples[idx]
        img = Image.open(p).convert("RGB")
        return self.transform(img) if self.transform else img, l


_eval_tf = transforms.Compose([
    transforms.ToTensor(),
    transforms.Resize((224, 224), antialias=True),
    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
])

# ── dataset auto-download ──────────────────────────────────────────────────

if not DATASET_DIR.exists():
    print("Dataset not found -- downloading from Roboflow ...")
    from roboflow import Roboflow
    rf = Roboflow(api_key="VCZWezdoCHQz7juipBdt")
    project = rf.workspace("forestanimals").project("wild-forest-animals-and-person")
    project.version(1).download("multiclass")
    if not DATASET_DIR.exists():
        raise RuntimeError(f"Download finished but {DATASET_DIR} not found.")

# ── model ─────────────────────────────────────────────────────────────────────

print("Loading model and data …")
model = efficientnet_v2_s(weights=EfficientNet_V2_S_Weights.IMAGENET1K_V1)
model.classifier[1] = nn.Linear(model.classifier[1].in_features, len(CLASS_NAMES))
model.load_state_dict(torch.load(WEIGHTS_PATH, map_location=device, weights_only=True))
model.to(device).eval()

test_ds = WildForestAnimalsDataset(DATASET_DIR, "test", transform=_eval_tf)
train_ds = WildForestAnimalsDataset(DATASET_DIR, "train", transform=_eval_tf)

_norm = transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
_unnorm = transforms.Normalize(
    mean=[-0.485 / 0.229, -0.456 / 0.224, -0.406 / 0.225],
    std=[1 / 0.229, 1 / 0.224, 1 / 0.225],
)
score_cam = ScoreCAM(model=model, target_layers=[model.features[-1]])
lime_exp = lime_image.LimeImageExplainer(random_state=SEED)


def _to_display(t):
    return torch.clamp(_unnorm(t), 0, 1).numpy().transpose(1, 2, 0)


def _lime_predict(images):
    batch = torch.stack([
        _norm(torch.tensor(im.transpose(2, 0, 1), dtype=torch.float32))
        for im in images
    ]).to(device)
    with torch.no_grad():
        return torch.nn.functional.softmax(model(batch), dim=1).cpu().numpy()


print("Pre-computing training features …")


def _extract_features(ds, bs=64):
    loader = DataLoader(ds, batch_size=bs, shuffle=False)
    feats, labs = [], []
    with torch.no_grad():
        for imgs, lbls in loader:
            x = torch.flatten(model.avgpool(model.features(imgs.to(device))), 1)
            feats.append(x.cpu())
            labs.extend(lbls.tolist())
    return torch.nn.functional.normalize(torch.cat(feats), dim=1), labs


train_feats, train_labels = _extract_features(train_ds)
print("Ready.")

# ── XAI helpers ───────────────────────────────────────────────────────────────


def _save_img(arr, path):
    if arr.dtype != np.uint8:
        arr = (np.clip(arr, 0, 1) * 255).astype(np.uint8)
    Image.fromarray(arr).save(path)


def _forward(inp):
    """Single split forward pass returning probs, pred_idx, and neighbour query vector."""
    with torch.no_grad():
        pooled = model.avgpool(model.features(inp))
        logits = model.classifier(torch.flatten(pooled, 1))
        probs = torch.nn.functional.softmax(logits, dim=1).cpu().squeeze()
        qf = torch.nn.functional.normalize(
            torch.flatten(pooled, 1).cpu(), dim=1,
        ).squeeze()
    return probs, probs.argmax().item(), qf


def compute_xai(det, pre=None):
    """Compute all XAI artefacts. `pre` is an optional dict with keys
    inp, raw, probs, pred_idx, qf to skip the duplicate forward pass."""
    out = XAI_DIR / det["id"]
    if (out / "meta.json").exists():
        return
    out.mkdir(exist_ok=True)

    if pre is not None:
        inp, raw, probs, pred_idx, qf = (
            pre["inp"], pre["raw"], pre["probs"], pre["pred_idx"], pre["qf"])
    else:
        idx = det["idx"]
        tensor, _ = test_ds[idx]
        img_path, _ = test_ds.samples[idx]
        raw = np.array(
            Image.open(img_path).convert("RGB").resize((224, 224)),
            dtype=np.float64,
        ) / 255.0
        inp = tensor.unsqueeze(0).to(device)
        _save_img(raw, out / "original.png")
        probs, pred_idx, qf = _forward(inp)

    entropy = -(probs * torch.log(probs + 1e-12)).sum().item()

    # ScoreCAM
    hm = score_cam(input_tensor=inp, targets=None)
    _save_img(
        show_cam_on_image(raw.astype(np.float32), hm.squeeze(), use_rgb=True, image_weight=0.75),
        out / "scorecam.png",
    )

    # LIME (500 samples)
    expl = lime_exp.explain_instance(raw, _lime_predict, top_labels=2, hide_color=0, num_samples=500)
    c1, c2 = expl.top_labels[0], expl.top_labels[1]
    t1, m1 = expl.get_image_and_mask(c1, positive_only=False, num_features=10, hide_rest=False)
    t2, m2 = expl.get_image_and_mask(c2, positive_only=False, num_features=10, hide_rest=False)
    _save_img(mark_boundaries(t1, m1), out / "lime1.png")
    _save_img(mark_boundaries(t2, m2), out / "lime2.png")

    segs = expl.segments
    w1, w2 = dict(expl.local_exp[c1]), dict(expl.local_exp[c2])
    diff = np.zeros(segs.shape, dtype=np.float64)
    for sid in np.unique(segs):
        diff[segs == sid] = w1.get(sid, 0) - w2.get(sid, 0)
    mx = max(np.abs(diff).max(), 1e-8)
    diff /= mx
    col = mpl_cm.RdBu_r((diff + 1) / 2)[:, :, :3]
    _save_img((0.6 * raw + 0.4 * col).clip(0, 1), out / "contrastive.png")

    # nearest neighbours — 2x2 composite with labels
    sims, idxs = (train_feats @ qf).topk(4)
    nbs = []
    nb_pils = []
    for ni, ns in zip(idxs, sims):
        arr = _to_display(train_ds[ni.item()][0])
        cls = CLASS_NAMES[train_labels[ni]]
        sim = f"{ns:.3f}"
        nbs.append({"cls": cls, "sim": sim})
        pil = Image.fromarray((np.clip(arr, 0, 1) * 255).astype(np.uint8))
        draw = ImageDraw.Draw(pil)
        label = f"{cls} ({sim})"
        draw.rectangle([(0, pil.height - 22), (pil.width, pil.height)], fill=(0, 0, 0, 180))
        draw.text((6, pil.height - 20), label, fill=(255, 255, 255))
        nb_pils.append(pil)
    w, h = nb_pils[0].size
    grid = Image.new("RGB", (w * 2, h * 2))
    for i, p in enumerate(nb_pils):
        grid.paste(p, ((i % 2) * w, (i // 2) * h))
    grid.save(out / "neighbours.png")

    meta = {
        "pred": CLASS_NAMES[pred_idx],
        "conf": round(probs[pred_idx].item() * 100, 1),
        "ppl": round(float(np.exp(entropy)), 2),
        "probs": {CLASS_NAMES[i]: round(float(p), 4) for i, p in enumerate(probs)},
        "lime1_cls": CLASS_NAMES[c1],
        "lime2_cls": CLASS_NAMES[c2],
        "contrast_leg": f"Blue = {CLASS_NAMES[c1]}  |  Red = {CLASS_NAMES[c2]}",
        "nbs": nbs,
    }
    (out / "meta.json").write_text(json.dumps(meta))


# ── Flask app ─────────────────────────────────────────────────────────────────

app = Flask(__name__)
detections: list[dict] = []
_xai_events: dict[str, threading.Event] = {}
_xai_lock = threading.Lock()


def _precompute_xai(det, pre=None):
    try:
        with _xai_lock:
            compute_xai(det, pre)
    finally:
        ev = _xai_events.get(det["id"])
        if ev:
            ev.set()


@app.route("/")
@app.route("/home")
def home():
    return render_template_string(HOME_HTML, cameras=CAMERAS, class_names=CLASS_NAMES)


@app.route("/det/<det_id>")
def detail(det_id):
    det = next((d for d in detections if d["id"] == det_id), None)
    if det is None:
        return "Detection not found", 404
    return render_template_string(DETAIL_HTML, det=det, cameras=CAMERAS, class_names=CLASS_NAMES)


@app.route("/api/simulate", methods=["POST"])
def api_simulate():
    idx = random.randint(0, len(test_ds) - 1)
    cam_id = random.choice(list(CAMERAS.keys()))
    tensor, _ = test_ds[idx]
    inp = tensor.unsqueeze(0).to(device)
    probs, pred_idx, qf = _forward(inp)

    img_path, _ = test_ds.samples[idx]
    raw = np.array(
        Image.open(img_path).convert("RGB").resize((224, 224)), dtype=np.float64,
    ) / 255.0

    det = {
        "id": uuid.uuid4().hex[:8],
        "idx": idx,
        "cam": cam_id,
        "cam_name": CAMERAS[cam_id]["name"],
        "pred": CLASS_NAMES[pred_idx],
        "conf": round(probs[pred_idx].item() * 100, 1),
        "time": datetime.now().strftime("%H:%M:%S"),
        "verified": False,
        "manual": False,
    }
    detections.append(det)
    out = XAI_DIR / det["id"]
    out.mkdir(exist_ok=True)
    _save_img(raw, out / "original.png")

    pre = {"inp": inp, "raw": raw, "probs": probs, "pred_idx": pred_idx, "qf": qf}
    ev = threading.Event()
    _xai_events[det["id"]] = ev
    threading.Thread(target=_precompute_xai, args=(det, pre), daemon=True).start()
    return jsonify(det)


@app.route("/api/xai/<det_id>")
def api_xai(det_id):
    det = next((d for d in detections if d["id"] == det_id), None)
    if det is None:
        return jsonify(error="not found"), 404
    ev = _xai_events.get(det_id)
    if ev:
        ev.wait()
    else:
        compute_xai(det)
    meta = json.loads((XAI_DIR / det_id / "meta.json").read_text())
    base = f"/xai/{det_id}"
    meta["urls"] = {
        k: f"{base}/{k}.png"
        for k in ["original", "scorecam", "lime1", "lime2", "contrastive",
                   "neighbours"]
    }
    return jsonify(meta)


@app.route("/cam/<cam_id>")
def camera(cam_id):
    if cam_id not in CAMERAS:
        return "Camera not found", 404
    cam_dets = [d for d in reversed(detections) if d["cam"] == cam_id]
    return render_template_string(CAM_HTML, cam_id=cam_id, cam=CAMERAS[cam_id], dets=cam_dets)


@app.route("/api/verify/<det_id>", methods=["POST"])
def api_verify(det_id):
    det = next((d for d in detections if d["id"] == det_id), None)
    if det is None:
        return jsonify(error="not found"), 404
    data = request.get_json()
    if data.get("action") == "correct":
        det["verified"] = True
    elif data.get("action") == "wrong":
        det["verified"] = True
        det["manual"] = True
        det["orig_pred"] = det["pred"]
        det["orig_conf"] = det["conf"]
        det["pred"] = data["true_class"]
        det["conf"] = 100.0
    return jsonify(det)


@app.route("/api/detections")
def api_detections():
    return jsonify(detections)


@app.route("/map.jpg")
def serve_map():
    return send_from_directory(".", "yellowstone-camping-map.jpg")


@app.route("/xai/<det_id>/<filename>")
def serve_xai(det_id, filename):
    return send_from_directory(str(XAI_DIR / det_id), filename)


# ── HTML: home page ──────────────────────────────────────────────────────────

HOME_HTML = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Yellowstone — Wildlife Monitor</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:system-ui,-apple-system,sans-serif;overflow:hidden;height:100vh;background:#0f172a}

/* ── map ── */
#map-wrap{position:relative;height:100vh;width:auto;margin:0 auto;display:inline-block}
#map-outer{display:flex;justify-content:center;width:100vw;height:100vh;overflow:hidden}
#map-img{height:100vh;width:auto;display:block;user-select:none;-webkit-user-drag:none}
.cam-marker{
  position:absolute;width:22px;height:22px;border-radius:50%;
  background:#2563eb;border:3px solid white;
  box-shadow:0 2px 10px rgba(0,0,0,.35);cursor:pointer;
  transform:translate(-50%,-50%);transition:all .3s;z-index:10;
  text-decoration:none;
}
.cam-marker.active{background:#ef4444;animation:pulse 1.4s infinite}
.cam-label{
  position:absolute;transform:translate(-50%,-100%);
  background:rgba(0,0,0,.75);color:#fff;padding:3px 10px;border-radius:5px;
  font-size:11px;white-space:nowrap;pointer-events:none;z-index:10;
  backdrop-filter:blur(4px);
}
@keyframes pulse{
  0%,100%{box-shadow:0 0 0 0 rgba(239,68,68,.45)}
  50%{box-shadow:0 0 0 14px rgba(239,68,68,0)}
}

.wolf-warn{
  position:absolute;width:120px;height:120px;border-radius:50%;
  border:3px solid rgba(239,68,68,.7);
  background:rgba(239,68,68,.1);
  transform:translate(-50%,-50%);z-index:5;pointer-events:none;
  animation:wolfPulse 2s ease-in-out infinite;
  transition:opacity 1.5s ease;
}
.wolf-warn.fade{animation:none;opacity:0}
.wolf-warn-label{
  position:absolute;transform:translate(-50%,64px);z-index:6;
  color:#ef4444;font-size:11px;font-weight:700;text-transform:uppercase;
  letter-spacing:.5px;pointer-events:none;white-space:nowrap;
  text-shadow:0 1px 4px rgba(0,0,0,.6);
  transition:opacity 1.5s ease;
}
.wolf-warn-label.fade{opacity:0}
@keyframes wolfPulse{
  0%,100%{transform:translate(-50%,-50%) scale(1);opacity:.8}
  50%{transform:translate(-50%,-50%) scale(1.15);opacity:.5}
}

/* ── title ── */
#title{
  position:fixed;top:16px;left:16px;z-index:50;
  background:rgba(15,23,42,.88);backdrop-filter:blur(10px);
  color:#fff;padding:14px 22px;border-radius:12px;
}
#title h1{font-size:17px;font-weight:700}
#title p{font-size:11px;opacity:.65;margin-top:2px}

/* ── toast ── */
#toast{
  position:fixed;top:20px;left:50%;z-index:200;
  transform:translateX(-50%) translateY(-120px);
  background:linear-gradient(135deg,#16a34a,#15803d);color:#fff;
  padding:14px 26px;border-radius:12px;box-shadow:0 4px 24px rgba(0,0,0,.35);
  font-size:14px;transition:transform .4s ease;white-space:nowrap;
  backdrop-filter:blur(8px);
}
#toast.show{transform:translateX(-50%) translateY(0)}

/* ── sidebar ── */
#sidebar-toggle{
  position:fixed;top:16px;right:16px;z-index:101;
  background:rgba(15,23,42,.88);backdrop-filter:blur(10px);color:#fff;border:none;
  padding:10px 18px;border-radius:8px;cursor:pointer;font-size:13px;font-weight:500;
  transition:right .3s ease;
}
#sidebar-toggle.shifted{right:376px}

#sidebar{
  position:fixed;top:0;right:0;width:360px;height:100vh;
  background:rgba(15,23,42,.96);backdrop-filter:blur(14px);color:#fff;
  transform:translateX(100%);transition:transform .3s ease;z-index:100;
  display:flex;flex-direction:column;
}
#sidebar.open{transform:translateX(0)}

.sb-header{padding:24px 20px;border-bottom:1px solid rgba(255,255,255,.08);text-align:center}
.sb-header h2{font-size:13px;opacity:.55;font-weight:400;margin-bottom:2px}
.sb-count{font-size:34px;font-weight:700}

.sb-chart{padding:16px 20px;border-bottom:1px solid rgba(255,255,255,.08)}
.sb-chart h3{font-size:11px;opacity:.45;margin-bottom:10px;text-transform:uppercase;letter-spacing:.5px}
.bar-row{display:flex;align-items:center;margin-bottom:5px;font-size:12px;cursor:pointer;
  padding:3px 0;border-radius:5px;transition:background .15s}
.bar-row:hover{background:rgba(255,255,255,.06)}
.bar-row.selected{background:rgba(59,130,246,.15)}
.bar-row.selected .bar-label{opacity:1;color:#60a5fa}
.bar-row.selected .bar-fill{background:#f59e0b}
.bar-label{width:58px;text-align:right;padding-right:8px;opacity:.7;transition:all .15s}
.bar-fill{height:16px;background:#3b82f6;border-radius:3px;transition:width .5s ease,background .2s;min-width:2px}
.bar-num{padding-left:6px;opacity:.45;font-size:11px}

.heatmap-dot{
  position:absolute;border-radius:50%;pointer-events:none;z-index:4;
  transform:translate(-50%,-50%);
  background:radial-gradient(circle,rgba(251,191,36,.75) 0%,rgba(251,191,36,.35) 50%,rgba(251,191,36,.08) 80%,transparent 100%);
  border:2px solid rgba(251,191,36,.6);
  animation:heatFade .5s ease;
}
.heatmap-label{
  position:absolute;transform:translate(-50%,0);z-index:6;pointer-events:none;
  color:#fbbf24;font-size:12px;font-weight:700;white-space:nowrap;
  text-shadow:0 1px 6px rgba(0,0,0,.9),0 0 3px rgba(0,0,0,.6);
}
@keyframes heatFade{from{opacity:0;transform:translate(-50%,-50%) scale(.5)}to{opacity:1;transform:translate(-50%,-50%) scale(1)}}

.sb-filters{padding:10px 20px;border-bottom:1px solid rgba(255,255,255,.08);display:flex;gap:6px}
.sb-filter{
  flex:1;padding:6px 0;border:none;border-radius:6px;cursor:pointer;
  font-size:11px;font-weight:600;text-align:center;
  background:rgba(255,255,255,.06);color:rgba(255,255,255,.5);transition:all .15s;
}
.sb-filter:hover{background:rgba(255,255,255,.1)}
.sb-filter.active{background:#3b82f6;color:#fff}

.sb-list{flex:1;overflow-y:auto;padding:6px 0}
.det-link{
  display:flex;align-items:center;padding:11px 20px;gap:12px;
  border-bottom:1px solid rgba(255,255,255,.04);cursor:pointer;
  text-decoration:none;color:inherit;transition:background .12s;
}
.det-link:hover{background:rgba(255,255,255,.07)}
.det-icon{font-size:22px;flex-shrink:0}
.det-info{flex:1}
.det-species{font-weight:600;font-size:13px}
.det-meta{font-size:11px;opacity:.45;margin-top:1px}
.det-arrow{opacity:.3;font-size:18px}
.det-badge{
  font-size:10px;padding:2px 6px;border-radius:4px;font-weight:600;flex-shrink:0;
}
.det-badge.verified{background:rgba(34,197,94,.2);color:#22c55e}
.det-badge.manual{background:rgba(234,179,8,.2);color:#eab308}
.det-badge.unverified{background:rgba(148,163,184,.15);color:#64748b}
.empty-state{padding:40px 20px;text-align:center;opacity:.4;font-size:13px;line-height:1.6}
</style>
</head>
<body>

<div id="title"><h1>Yellowstone National Park</h1><p>Wildlife Camera Monitoring</p></div>

<!-- map -->
<div id="map-outer">
<div id="map-wrap">
  <img id="map-img" src="/map.jpg" alt="Park map" draggable="false">
  {% for cid, c in cameras.items() %}
  <a href="/cam/{{ cid }}" class="cam-marker" id="mk-{{ cid }}" style="left:{{ c.px }}%;top:{{ c.py }}%"></a>
  <div class="cam-label" style="left:{{ c.px }}%;top:calc({{ c.py }}% - 18px)">{{ cid }} &middot; {{ c.name }}</div>
  {% endfor %}
</div>
</div>

<div id="toast"></div>
<button id="sidebar-toggle" onclick="toggleSidebar()" class="shifted">&#9664; Close</button>

<!-- sidebar -->
<div id="sidebar" class="open">
  <div class="sb-header">
    <h2>Detections Today</h2>
    <div class="sb-count" id="sb-count">0</div>
  </div>
  <div class="sb-chart">
    <h3>Detections by Species</h3>
    <div id="chart"></div>
  </div>
  <div class="sb-filters">
    <button class="sb-filter active" data-filter="all" onclick="setFilter('all')">All</button>
    <button class="sb-filter" data-filter="unverified" onclick="setFilter('unverified')">Unverified</button>
    <button class="sb-filter" data-filter="verified" onclick="setFilter('verified')">Verified</button>
    <button class="sb-filter" data-filter="corrected" onclick="setFilter('corrected')">Corrected</button>
  </div>
  <div class="sb-list" id="sb-list">
    <div class="empty-state">No detections yet.<br>Detections appear automatically.</div>
  </div>
</div>

<script>
const CN = {{ class_names | tojson }};
const CAMS = {{ cameras | tojson }};
const ICONS = {bear:'\u{1F43B}',deer:'\u{1F98C}',fox:'\u{1F98A}',hare:'\u{1F407}',
               moose:'\u{1F98C}',person:'\u{1F9D1}',wolf:'\u{1F43A}'};
let dets=[], sbOpen=true, activeFilter='all', selectedSpecies=null;

function toggleSidebar(){
  sbOpen=!sbOpen;
  document.getElementById('sidebar').classList.toggle('open',sbOpen);
  const b=document.getElementById('sidebar-toggle');
  b.classList.toggle('shifted',sbOpen);
  b.innerHTML=sbOpen?'\u25C0 Close':'Detections \u25B6';
}

function toast(msg){
  const t=document.getElementById('toast');
  t.innerHTML=msg; t.classList.add('show');
  setTimeout(()=>t.classList.remove('show'),3500);
}

function renderChart(){
  const counts={};CN.forEach(c=>counts[c]=0);
  dets.forEach(d=>counts[d.pred]=(counts[d.pred]||0)+1);
  const mx=Math.max(...Object.values(counts),1);
  let h='';
  for(const[sp,n]of Object.entries(counts)){
    const pct=(n/mx)*100;
    const sel=sp===selectedSpecies?' selected':'';
    h+=`<div class="bar-row${sel}" onclick="toggleHeatmap('${sp}')"><div class="bar-label">${sp}</div>
        <div class="bar-fill" style="width:${pct}%;${n?'':'opacity:.25'}"></div>
        <div class="bar-num">${n}</div></div>`;
  }
  document.getElementById('chart').innerHTML=h;
}

function toggleHeatmap(species){
  selectedSpecies = selectedSpecies===species ? null : species;
  renderChart();
  renderHeatmap();
}

function renderHeatmap(){
  document.querySelectorAll('.heatmap-dot,.heatmap-label').forEach(e=>e.remove());
  if(!selectedSpecies) return;
  const wrap=document.getElementById('map-wrap');
  const camCounts={};
  for(const cid in CAMS) camCounts[cid]=0;
  dets.filter(d=>d.pred===selectedSpecies).forEach(d=>{camCounts[d.cam]=(camCounts[d.cam]||0)+1;});
  const mx=Math.max(...Object.values(camCounts),1);
  for(const[cid,n]of Object.entries(camCounts)){
    if(!n) continue;
    const c=CAMS[cid];
    const size=60+Math.round((n/mx)*100);
    const dot=document.createElement('div');
    dot.className='heatmap-dot';
    dot.style.left=c.px+'%';dot.style.top=c.py+'%';
    dot.style.width=size+'px';dot.style.height=size+'px';
    wrap.appendChild(dot);
    const lbl=document.createElement('div');
    lbl.className='heatmap-label';
    lbl.style.left=c.px+'%';
    lbl.style.top=`calc(${c.py}% + ${size/2+4}px)`;
    lbl.textContent=`${n}\u00D7 ${selectedSpecies}`;
    wrap.appendChild(lbl);
  }
}

function setFilter(f){
  activeFilter=f;
  document.querySelectorAll('.sb-filter').forEach(b=>b.classList.toggle('active',b.dataset.filter===f));
  renderList();
}

function renderList(){
  document.getElementById('sb-count').textContent=dets.length;
  let filtered=[...dets];
  if(activeFilter==='unverified') filtered=filtered.filter(d=>!d.verified);
  else if(activeFilter==='verified') filtered=filtered.filter(d=>d.verified&&!d.manual);
  else if(activeFilter==='corrected') filtered=filtered.filter(d=>d.verified&&d.manual);
  if(!filtered.length){
    document.getElementById('sb-list').innerHTML=
      `<div class="empty-state">${dets.length?'No detections match this filter.':'No detections yet.<br>Detections appear automatically.'}</div>`;
    return;
  }
  let h='';
  for(const d of[...filtered].reverse()){
    const ic=ICONS[d.pred]||'';
    const badge=d.verified
      ?(d.manual?'<span class="det-badge manual">corrected</span>'
                :'<span class="det-badge verified">verified</span>')
      :'<span class="det-badge unverified">unverified</span>';
    h+=`<a class="det-link" href="/det/${d.id}">
      <div class="det-icon">${ic}</div>
      <div class="det-info">
        <div class="det-species">${d.pred[0].toUpperCase()+d.pred.slice(1)}
          <span style="opacity:.45;font-weight:400"> \u2014 ${d.conf.toFixed(0)}%</span></div>
        <div class="det-meta">${d.cam} \u00B7 ${d.cam_name} \u00B7 ${d.time}</div>
      </div>
      ${badge}
      <div class="det-arrow">\u203A</div></a>`;
  }
  document.getElementById('sb-list').innerHTML=h;
}

function flashCam(cid){
  document.querySelectorAll('.cam-marker').forEach(m=>m.classList.remove('active'));
  const mk=document.getElementById('mk-'+cid);
  if(mk){mk.classList.add('active');setTimeout(()=>mk.classList.remove('active'),6000);}
}

function showDangerWarning(cid,species){
  const c=CAMS[cid]; if(!c)return;
  const wrap=document.getElementById('map-wrap');
  const key=species+'-'+cid;
  const old=document.getElementById('warn-'+key);
  if(old)old.remove();
  const oldLbl=document.getElementById('warn-lbl-'+key);
  if(oldLbl)oldLbl.remove();
  const circle=document.createElement('div');
  circle.className='wolf-warn';circle.id='warn-'+key;
  circle.style.left=c.px+'%';circle.style.top=c.py+'%';
  const lbl=document.createElement('div');
  lbl.className='wolf-warn-label';lbl.id='warn-lbl-'+key;
  lbl.style.left=c.px+'%';lbl.style.top=c.py+'%';
  lbl.textContent='\u26A0 '+species[0].toUpperCase()+species.slice(1)+' detected';
  wrap.appendChild(circle);wrap.appendChild(lbl);
  setTimeout(()=>{circle.classList.add('fade');lbl.classList.add('fade');},10000);
  setTimeout(()=>{circle.remove();lbl.remove();},11500);
}

let simulating=false;
async function simulate(){
  if(simulating)return;
  simulating=true;
  try{
    const r=await fetch('/api/simulate',{method:'POST'});
    const d=await r.json();
    dets.push(d);
    flashCam(d.cam);
    if(d.pred==='wolf'||d.pred==='bear') showDangerWarning(d.cam,d.pred);
    toast(`<b>${ICONS[d.pred]||''} ${d.pred[0].toUpperCase()+d.pred.slice(1)}</b> detected at ${d.cam} (${d.cam_name}) \u2014 ${d.conf.toFixed(0)}%`);
    renderList();renderChart();renderHeatmap();
  }catch(e){}
  simulating=false;
}

function autoSimLoop(){
  const delay=5000+Math.random()*5000;
  setTimeout(async()=>{await simulate();autoSimLoop();},delay);
}

(async function init(){
  try{
    const r=await fetch('/api/detections');
    const existing=await r.json();
    dets=existing;
  }catch(e){}
  renderChart();renderList();
  autoSimLoop();
})();
</script>
</body></html>"""

# ── HTML: detail page ─────────────────────────────────────────────────────────

DETAIL_HTML = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Detection {{ det.id }} — Yellowstone</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:system-ui,-apple-system,sans-serif;background:#0f172a;color:#e2e8f0;line-height:1.5}

.topbar{
  position:sticky;top:0;z-index:50;
  background:rgba(15,23,42,.92);backdrop-filter:blur(12px);
  padding:14px 24px;display:flex;align-items:center;gap:16px;
  border-bottom:1px solid rgba(255,255,255,.06);
}
.back{
  background:rgba(255,255,255,.08);color:#e2e8f0;border:none;
  padding:8px 16px;border-radius:8px;cursor:pointer;font-size:13px;
  transition:background .15s;text-decoration:none;
}
.back:hover{background:rgba(255,255,255,.14)}
.topbar-info{flex:1}
.topbar-title{font-size:15px;font-weight:700}
.topbar-sub{font-size:12px;opacity:.55;margin-top:1px}

.container{max-width:1200px;margin:0 auto;padding:28px 24px 60px}

/* verification */
.verify-bar{
  margin-bottom:24px;padding:16px 22px;border-radius:12px;
  background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.06);
  display:flex;align-items:center;gap:14px;flex-wrap:wrap;
}
.verify-bar.done{border-color:rgba(34,197,94,.25)}
.vb-status{flex:1;font-size:14px}
.vb-btn{
  padding:8px 18px;border-radius:8px;border:none;cursor:pointer;
  font-size:13px;font-weight:600;transition:opacity .15s;
}
.vb-btn:hover{opacity:.85}
.vb-correct{background:#22c55e;color:#fff}
.vb-wrong{background:#ef4444;color:#fff}
.vb-submit{background:#3b82f6;color:#fff}
.vb-cancel{background:rgba(255,255,255,.1);color:#e2e8f0}
.vb-select{
  padding:8px 12px;border-radius:8px;border:1px solid rgba(255,255,255,.15);
  background:rgba(255,255,255,.08);color:#e2e8f0;font-size:13px;
}
.vb-badge{
  display:inline-block;padding:3px 10px;border-radius:6px;font-size:12px;font-weight:600;
}
.vb-badge.verified{background:rgba(34,197,94,.2);color:#22c55e}
.vb-badge.manual{background:rgba(234,179,8,.2);color:#eab308}

/* two-column layout */
.det-layout{display:grid;grid-template-columns:1fr 340px;gap:24px;align-items:start}
@media(max-width:860px){.det-layout{grid-template-columns:1fr}}

/* viewer */
.viewer{
  background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.06);
  border-radius:14px;overflow:hidden;
}
.viewer-head{
  display:flex;align-items:center;padding:14px 18px;gap:12px;
  border-bottom:1px solid rgba(255,255,255,.06);
}
.nav-btn{
  width:38px;height:38px;border-radius:8px;border:none;cursor:pointer;
  background:rgba(255,255,255,.08);color:#e2e8f0;font-size:18px;
  display:flex;align-items:center;justify-content:center;transition:background .15s;flex-shrink:0;
}
.nav-btn:hover{background:rgba(255,255,255,.16)}
.slide-title{flex:1;text-align:center;font-size:15px;font-weight:700}
.slide-counter{font-size:12px;opacity:.4;font-weight:400}
.viewer-img{position:relative;background:#000;min-height:300px;display:flex;align-items:center;justify-content:center}
.viewer-img img{width:100%;display:block}
.slide-cap{padding:12px 18px;font-size:13px;opacity:.6;min-height:42px}
.slide-dots{display:flex;gap:7px;justify-content:center;padding:0 18px 16px}
.dot{
  width:9px;height:9px;border-radius:50%;border:none;cursor:pointer;
  background:rgba(255,255,255,.15);transition:all .2s;padding:0;
}
.dot.active{background:#3b82f6;transform:scale(1.25)}
.dot:hover{background:rgba(255,255,255,.35)}

/* chart sidebar */
.chart-panel{
  background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.06);
  border-radius:14px;overflow:hidden;position:sticky;top:80px;
}
.chart-panel h3{
  padding:16px 18px 0;font-size:12px;opacity:.45;text-transform:uppercase;letter-spacing:.4px;
}
.prob-chart{padding:14px 18px 6px}
.prob-row{display:flex;align-items:center;margin-bottom:6px;font-size:12px}
.prob-label{width:54px;text-align:right;padding-right:10px;opacity:.7;text-transform:capitalize}
.prob-track{flex:1;height:20px;background:rgba(255,255,255,.06);border-radius:4px;overflow:hidden;position:relative}
.prob-fill{height:100%;border-radius:4px;transition:width .6s ease;display:flex;align-items:center;justify-content:flex-end;padding-right:6px;min-width:0}
.prob-fill span{font-size:10px;font-weight:700;color:#fff;opacity:.9}
.prob-fill.top{background:linear-gradient(90deg,#e8832a,#f59e0b)}
.prob-fill.other{background:linear-gradient(90deg,#3b82f6,#60a5fa)}
.pred-info{padding:0 18px 16px;font-size:13px;line-height:1.7}
.pred-info b{color:#fff}
.pred-info .tag{
  display:inline-block;padding:2px 8px;border-radius:5px;font-size:12px;font-weight:600;
  margin-right:4px;
}
.tag-pred{background:rgba(59,130,246,.15);color:#60a5fa}

.loading{
  display:flex;flex-direction:column;align-items:center;justify-content:center;
  padding:80px 0;gap:16px;
}
.spinner{
  width:36px;height:36px;border:3px solid rgba(255,255,255,.12);
  border-top-color:#3b82f6;border-radius:50%;animation:spin .7s linear infinite;
}
@keyframes spin{to{transform:rotate(360deg)}}
#xai{display:none}
</style>
</head>
<body>

<div class="topbar">
  <a class="back" href="/">&#8592; Dashboard</a>
  <div class="topbar-info">
    <div class="topbar-title">Detection {{ det.id }}</div>
    <div class="topbar-sub">
      {{ det.cam }} &middot; {{ cameras[det.cam].name }} &middot; {{ det.time }}
    </div>
  </div>
</div>

<div class="container">

<!-- verification -->
<div class="verify-bar {{ 'done' if det.verified else '' }}" id="verify-bar">
{% if det.verified %}
  {% if det.manual %}
  <div class="vb-status"><span class="vb-badge manual">Manually corrected</span>
    Model predicted <b>{{ det.orig_pred }}</b> &mdash; ranger corrected to <b>{{ det.pred }}</b></div>
  {% else %}
  <div class="vb-status"><span class="vb-badge verified">Verified correct</span>
    A ranger confirmed this detection is accurate.</div>
  {% endif %}
{% else %}
  <div class="vb-status" id="vb-prompt">Is this detection correct?</div>
  <div id="vb-actions">
    <button class="vb-btn vb-correct" onclick="verifyCorrect()">Correct</button>
    <button class="vb-btn vb-wrong" onclick="showCorrectionForm()">Wrong</button>
  </div>
  <div id="vb-form" style="display:none">
    <select class="vb-select" id="vb-class">
    {% for c in class_names %}
      <option value="{{ c }}">{{ c }}</option>
    {% endfor %}
    </select>
    <button class="vb-btn vb-submit" onclick="submitCorrection()">Submit</button>
    <button class="vb-btn vb-cancel" onclick="cancelCorrection()">Cancel</button>
  </div>
{% endif %}
</div>

<div id="loader" class="loading">
  <div class="spinner"></div>
  <div style="opacity:.5;font-size:14px">Computing explanations &hellip;</div>
</div>

<div id="xai">
<div class="det-layout">
  <!-- image viewer / carousel -->
  <div class="viewer">
    <div class="viewer-head">
      <button class="nav-btn" onclick="prev()">&lsaquo;</button>
      <div class="slide-title">
        <span id="slide-title">Loading&hellip;</span>
        <span class="slide-counter" id="slide-counter"></span>
      </div>
      <button class="nav-btn" onclick="next()">&rsaquo;</button>
    </div>
    <div class="viewer-img"><img id="slide-img" alt=""></div>
    <div class="slide-cap" id="slide-cap"></div>
    <div class="slide-dots" id="slide-dots"></div>
  </div>

  <!-- probability chart (always visible) -->
  <div class="chart-panel">
    <h3>Class Probabilities</h3>
    <div class="prob-chart" id="prob-chart"></div>
    <div class="pred-info" id="pred-info"></div>
  </div>
</div>
</div><!-- /xai -->
</div><!-- /container -->

<script>
let slides=[], cur=0;

function render(){
  const s=slides[cur];
  document.getElementById('slide-img').src=s.src;
  document.getElementById('slide-title').textContent=s.title;
  document.getElementById('slide-cap').textContent=s.cap;
  document.getElementById('slide-counter').textContent=`${cur+1} / ${slides.length}`;
  document.querySelectorAll('.dot').forEach((d,i)=>d.classList.toggle('active',i===cur));
}
function prev(){cur=(cur-1+slides.length)%slides.length;render();}
function next(){cur=(cur+1)%slides.length;render();}

document.addEventListener('keydown',e=>{
  if(e.key==='ArrowLeft')prev();
  else if(e.key==='ArrowRight')next();
});

(async function(){
  const r = await fetch('/api/xai/{{ det.id }}');
  const d = await r.json();
  const u = d.urls;

  slides=[
    {src:u.original, title:'Original Image',
     cap:`Predicted: ${d.pred} (${d.conf}% confidence, perplexity ${d.ppl})`},
    {src:u.scorecam, title:'ScoreCAM',
     cap:'Highlights image regions the model attends to for its prediction.'},
    {src:u.lime1, title:'LIME — '+d.lime1_cls,
     cap:'Green superpixels support this class; red superpixels oppose it.'},
    {src:u.lime2, title:'LIME — '+d.lime2_cls,
     cap:'Green superpixels support this class; red superpixels oppose it.'},
    {src:u.contrastive, title:'Contrastive Explanation',
     cap:d.contrast_leg},
    {src:u.neighbours, title:'Nearest Training Neighbours',
     cap:d.nbs.map((nb,i)=>`#${i+1}: ${nb.cls} (sim ${nb.sim})`).join('  |  ')},
  ];

  let dots='';
  slides.forEach((_,i)=>{dots+=`<button class="dot${i===0?' active':''}" onclick="cur=${i};render()"></button>`;});
  document.getElementById('slide-dots').innerHTML=dots;

  const probs=d.probs, mx=Math.max(...Object.values(probs),0.01);
  let ch='';
  for(const[cls,p] of Object.entries(probs)){
    const pct=(p/mx)*100, isTop=cls===d.pred;
    const lbl=p>=0.05?`<span>${(p*100).toFixed(1)}%</span>`:'';
    ch+=`<div class="prob-row"><div class="prob-label">${cls}</div>
      <div class="prob-track"><div class="prob-fill ${isTop?'top':'other'}" style="width:${pct}%">${lbl}</div></div></div>`;
  }
  document.getElementById('prob-chart').innerHTML=ch;
  document.getElementById('pred-info').innerHTML=
    `<span class="tag tag-pred">${d.pred}</span><br>`+
    `Confidence <b>${d.conf}%</b> &middot; Perplexity <b>${d.ppl}</b>`;

  document.getElementById('loader').style.display='none';
  document.getElementById('xai').style.display='block';
  render();
})();

async function verifyCorrect(){
  await fetch('/api/verify/{{ det.id }}',{
    method:'POST',headers:{'Content-Type':'application/json'},
    body:JSON.stringify({action:'correct'})
  });
  const bar=document.getElementById('verify-bar');
  bar.classList.add('done');
  bar.innerHTML='<div class="vb-status"><span class="vb-badge verified">Verified correct</span> A ranger confirmed this detection is accurate.</div>';
}

function showCorrectionForm(){
  document.getElementById('vb-actions').style.display='none';
  document.getElementById('vb-form').style.display='flex';
  document.getElementById('vb-prompt').textContent='Select the correct species:';
}

function cancelCorrection(){
  document.getElementById('vb-actions').style.display='';
  document.getElementById('vb-form').style.display='none';
  document.getElementById('vb-prompt').textContent='Is this detection correct?';
}

async function submitCorrection(){
  const cls=document.getElementById('vb-class').value;
  await fetch('/api/verify/{{ det.id }}',{
    method:'POST',headers:{'Content-Type':'application/json'},
    body:JSON.stringify({action:'wrong',true_class:cls})
  });
  const bar=document.getElementById('verify-bar');
  bar.classList.add('done');
  bar.innerHTML=`<div class="vb-status"><span class="vb-badge manual">Manually corrected</span> Model predicted <b>{{ det.pred }}</b> \u2014 ranger corrected to <b>${cls}</b></div>`;
}
</script>
</body></html>"""


# ── HTML: camera feed page ────────────────────────────────────────────────────

CAM_HTML = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{ cam_id }} — {{ cam.name }}</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:system-ui,-apple-system,sans-serif;background:#0f172a;color:#e2e8f0;line-height:1.5}

.topbar{
  position:sticky;top:0;z-index:50;
  background:rgba(15,23,42,.92);backdrop-filter:blur(12px);
  padding:14px 24px;display:flex;align-items:center;gap:16px;
  border-bottom:1px solid rgba(255,255,255,.06);
}
.back{
  background:rgba(255,255,255,.08);color:#e2e8f0;border:none;
  padding:8px 16px;border-radius:8px;cursor:pointer;font-size:13px;
  transition:background .15s;text-decoration:none;
}
.back:hover{background:rgba(255,255,255,.14)}
.topbar-info{flex:1}
.topbar-title{font-size:17px;font-weight:700}
.topbar-sub{font-size:12px;opacity:.55;margin-top:2px}

.container{max-width:1200px;margin:0 auto;padding:28px 24px 60px}

.grid{
  display:grid;
  grid-template-columns:repeat(auto-fill,minmax(200px,1fr));
  gap:16px;
}

.card{
  background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.06);
  border-radius:12px;overflow:hidden;text-decoration:none;color:inherit;
  transition:transform .15s,box-shadow .15s;
}
.card:hover{transform:translateY(-3px);box-shadow:0 8px 24px rgba(0,0,0,.3)}
.card img{width:100%;aspect-ratio:1;object-fit:cover;display:block}
.card-body{padding:10px 14px}
.card-species{font-weight:600;font-size:14px}
.card-conf{font-size:12px;opacity:.5;margin-top:1px}
.card-time{font-size:11px;opacity:.35;margin-top:3px}
.card-badge{
  display:inline-block;font-size:10px;padding:2px 6px;border-radius:4px;
  font-weight:600;margin-top:4px;
}
.card-badge.verified{background:rgba(34,197,94,.2);color:#22c55e}
.card-badge.manual{background:rgba(234,179,8,.2);color:#eab308}
.card-badge.unverified{background:rgba(148,163,184,.15);color:#64748b}

.empty{text-align:center;padding:60px 20px;opacity:.4;font-size:14px;line-height:1.6}
</style>
</head>
<body>

<div class="topbar">
  <a class="back" href="/">&#8592; Dashboard</a>
  <div class="topbar-info">
    <div class="topbar-title">{{ cam_id }} &mdash; {{ cam.name }}</div>
    <div class="topbar-sub">{{ cam.desc }}</div>
  </div>
</div>

<div class="container">
{% if dets %}
<div class="grid">
  {% for d in dets %}
  <a class="card" href="/det/{{ d.id }}">
    <img src="/xai/{{ d.id }}/original.png" alt="{{ d.pred }}">
    <div class="card-body">
      <div class="card-species">{{ d.pred | capitalize }}</div>
      <div class="card-conf">{{ d.conf }}% confidence</div>
      <div class="card-time">{{ d.time }}</div>
      {% if d.verified %}
        {% if d.manual %}
        <div class="card-badge manual">corrected</div>
        {% else %}
        <div class="card-badge verified">verified</div>
        {% endif %}
      {% else %}
      <div class="card-badge unverified">unverified</div>
      {% endif %}
    </div>
  </a>
  {% endfor %}
</div>
{% else %}
<div class="empty">No detections from this camera yet.<br>Detections will appear here automatically.</div>
{% endif %}
</div>

</body></html>"""


if __name__ == "__main__":
    app.run(debug=True, port=5000)
