import asyncio
import os
import time
from collections import defaultdict, deque
from pathlib import Path
from typing import Any, Deque, Dict, List, Optional

from fastapi import FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse, HTMLResponse
from pydantic import BaseModel, Field

from common.runtime import load_root_env

load_root_env(__file__)

TELEMETRY_BIND_HOST = os.getenv('telemetry_bind_host', os.getenv('lochost', '0.0.0.0'))
TELEMETRY_BIND_PORT = int(os.getenv('telemetry_bind_port', os.getenv('telemetry_port', '5020')))
TELEMETRY_HISTORY_SEC = int(float(os.getenv('telemetry_history_sec', '900')))
TELEMETRY_MAX_POINTS_PER_FREQ = int(os.getenv('telemetry_max_points_per_freq', '5000'))
INFERENCE_HISTORY_SEC = int(float(os.getenv('inference_history_sec', str(TELEMETRY_HISTORY_SEC))))
INFERENCE_MAX_RESULTS_PER_FREQ = int(os.getenv('inference_max_results_per_freq', '100'))
INFERENCE_RESULT_DIR = Path(os.getenv('inference_result_dir', '/app/inference_result')).resolve()


def _new_buffer() -> Deque[Dict[str, Any]]:
    return deque(maxlen=TELEMETRY_MAX_POINTS_PER_FREQ)


def _new_inference_buffer() -> Deque[Dict[str, Any]]:
    return deque(maxlen=INFERENCE_MAX_RESULTS_PER_FREQ)


app = FastAPI(title='DroneDetector Telemetry Server')
_buffers: Dict[str, Deque[Dict[str, Any]]] = defaultdict(_new_buffer)
_ws_clients: List[WebSocket] = []
_inference_buffers: Dict[str, Deque[Dict[str, Any]]] = defaultdict(_new_inference_buffer)
_inference_ws_clients: List[WebSocket] = []
_state_lock = asyncio.Lock()


class TelemetryPoint(BaseModel):
    freq: str
    ts: float = Field(default_factory=lambda: time.time())
    dbfs_current: float
    dbfs_threshold: Optional[float] = None
    alarm: bool = False
    channel_idx: int = 0
    channels_total: int = 1
    channel_values: Optional[List[float]] = None
    channel_thresholds: Optional[List[Optional[float]]] = None
    alarm_channels: Optional[List[int]] = None


class InferenceResult(BaseModel):
    result_id: int
    ts: float = Field(default_factory=lambda: time.time())
    freq: str
    model: str
    prediction: str
    probability: float
    drone_probability: Optional[float] = None
    drone_threshold: Optional[str] = None
    images: List[str] = Field(default_factory=list)


def _prune_freq_locked(freq: str, now_ts: float) -> None:
    cutoff = now_ts - TELEMETRY_HISTORY_SEC
    buf = _buffers[freq]
    while buf and float(buf[0].get('ts', 0.0)) < cutoff:
        buf.popleft()


def _copy_series_locked(seconds: int, freq: Optional[str] = None) -> Dict[str, List[Dict[str, Any]]]:
    now_ts = time.time()
    cutoff = now_ts - seconds

    if freq is not None:
        data = [point for point in _buffers.get(freq, []) if float(point.get('ts', 0.0)) >= cutoff]
        return {freq: data}

    series: Dict[str, List[Dict[str, Any]]] = {}
    for key, buf in _buffers.items():
        series[key] = [point for point in buf if float(point.get('ts', 0.0)) >= cutoff]
    return series


def _prune_inference_freq_locked(freq: str, now_ts: float) -> None:
    cutoff = now_ts - INFERENCE_HISTORY_SEC
    buf = _inference_buffers[freq]
    while buf and float(buf[0].get('ts', 0.0)) < cutoff:
        buf.popleft()


def _copy_inference_series_locked(limit: int, freq: Optional[str] = None) -> Dict[str, List[Dict[str, Any]]]:
    now_ts = time.time()
    cutoff = now_ts - INFERENCE_HISTORY_SEC

    def _slice(buf: Deque[Dict[str, Any]]) -> List[Dict[str, Any]]:
        recent = [item for item in buf if float(item.get('ts', 0.0)) >= cutoff]
        return recent[-limit:]

    if freq is not None:
        return {freq: _slice(_inference_buffers.get(freq, deque()))}

    series: Dict[str, List[Dict[str, Any]]] = {}
    for key, buf in _inference_buffers.items():
        series[key] = _slice(buf)
    return series


def _sanitize_image_names(names: List[str]) -> List[str]:
    safe_names: List[str] = []
    for name in names:
        base = Path(str(name)).name
        if not base or not base.endswith('.png'):
            continue
        safe_names.append(base)
    return safe_names


async def _broadcast(message: Dict[str, Any]) -> None:
    dead: List[WebSocket] = []
    for ws in list(_ws_clients):
        try:
            await ws.send_json(message)
        except Exception:
            dead.append(ws)

    if dead:
        async with _state_lock:
            for ws in dead:
                if ws in _ws_clients:
                    _ws_clients.remove(ws)


async def _broadcast_inference(message: Dict[str, Any]) -> None:
    dead: List[WebSocket] = []
    for ws in list(_inference_ws_clients):
        try:
            await ws.send_json(message)
        except Exception:
            dead.append(ws)

    if dead:
        async with _state_lock:
            for ws in dead:
                if ws in _inference_ws_clients:
                    _inference_ws_clients.remove(ws)


@app.post('/telemetry')
async def ingest_telemetry(point: TelemetryPoint):
    payload = point.model_dump()
    freq = str(payload['freq'])
    now_ts = time.time()

    async with _state_lock:
        _buffers[freq].append(payload)
        _prune_freq_locked(freq, now_ts)

    await _broadcast({'type': 'point', 'data': payload})
    return {'ok': True}


@app.get('/telemetry/history')
async def telemetry_history(
    freq: Optional[str] = Query(default=None),
    seconds: int = Query(default=300, ge=10, le=86400),
):
    seconds = min(seconds, TELEMETRY_HISTORY_SEC)
    async with _state_lock:
        series = _copy_series_locked(seconds=seconds, freq=freq)
    return {'seconds': seconds, 'series': series}


@app.post('/inference/result')
async def ingest_inference_result(result: InferenceResult):
    payload = result.model_dump()
    payload['images'] = _sanitize_image_names(payload.get('images', []))
    freq = str(payload['freq'])
    now_ts = time.time()

    async with _state_lock:
        _inference_buffers[freq].append(payload)
        _prune_inference_freq_locked(freq, now_ts)

    await _broadcast_inference({'type': 'inference_result', 'data': payload})
    return {'ok': True}


@app.get('/inference/history')
async def inference_history(
    freq: Optional[str] = Query(default=None),
    limit: int = Query(default=20, ge=1, le=200),
):
    async with _state_lock:
        series = _copy_inference_series_locked(limit=limit, freq=freq)
    return {'limit': limit, 'series': series}


@app.websocket('/telemetry/ws')
async def telemetry_ws(websocket: WebSocket):
    await websocket.accept()
    async with _state_lock:
        _ws_clients.append(websocket)
        snapshot = _copy_series_locked(seconds=min(300, TELEMETRY_HISTORY_SEC), freq=None)

    await websocket.send_json({'type': 'snapshot', 'data': snapshot})

    try:
        while True:
            # Keepalive channel from browser; content is ignored.
            await websocket.receive_text()
    except WebSocketDisconnect:
        pass
    finally:
        async with _state_lock:
            if websocket in _ws_clients:
                _ws_clients.remove(websocket)


@app.websocket('/inference/ws')
async def inference_ws(websocket: WebSocket):
    await websocket.accept()
    async with _state_lock:
        _inference_ws_clients.append(websocket)
        snapshot = _copy_inference_series_locked(limit=20, freq=None)

    await websocket.send_json({'type': 'snapshot', 'data': snapshot})

    try:
        while True:
            await websocket.receive_text()
    except WebSocketDisconnect:
        pass
    finally:
        async with _state_lock:
            if websocket in _inference_ws_clients:
                _inference_ws_clients.remove(websocket)


MONITOR_HTML = """
<!doctype html>
<html>
<head>
  <meta charset=\"utf-8\" />
  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
  <title>DroneDetector Telemetry</title>
  <script src=\"https://cdn.plot.ly/plotly-2.35.2.min.js\"></script>
  <style>
    :root {
      --bg: #f6f8fb;
      --card: #ffffff;
      --line: #d9dde5;
      --text: #1c232e;
      --green: #12b76a;
      --red: #ef4444;
      --muted: #5b6574;
    }
    body { margin: 0; background: var(--bg); color: var(--text); font-family: system-ui, -apple-system, Segoe UI, sans-serif; }
    .wrap { max-width: 1800px; margin: 0 auto; padding: 14px; }
    .head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
    .meta { font-size: 13px; color: var(--muted); }
    .grid { display: flex; flex-direction: column; gap: 10px; }
    .card { width: 100%; background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 8px 8px 8px; }
    .title-row { display: flex; justify-content: space-between; align-items: center; margin: 4px 8px; }
    .title { font-size: 20px; font-weight: 700; }
    .ctrl { display: flex; align-items: center; gap: 6px; }
    .ctrl label { font-size: 12px; color: var(--muted); }
    .ctrl select { border: 1px solid var(--line); border-radius: 6px; padding: 2px 6px; }
    .plot { height: 260px; width: 100%; }
    .events-title { font-size: 12px; color: var(--muted); margin: 2px 8px 4px; }
    .events { max-height: 110px; overflow-y: auto; border-top: 1px dashed var(--line); margin: 0 8px; padding-top: 4px; }
    .ev { display: flex; justify-content: space-between; font-size: 12px; line-height: 1.4; color: var(--text); }
    .ev-t { color: var(--muted); }
    .ev-empty { color: var(--muted); font-size: 12px; }
  </style>
</head>
<body>
  <div class=\"wrap\">
    <div class=\"head\">
      <div>
        <h2 style=\"margin:0;\">DroneDetector Telemetry Monitor</h2>
        <div class=\"meta\">Green: dBFS current, Red: channel threshold, Red dots: alarm points</div>
      </div>
      <div class=\"meta\" id=\"status\">connecting...</div>
    </div>
    <div class=\"grid\" id=\"plots\"></div>
  </div>

<script>
const windowSec = 300;
const state = {}; // freq -> points[]
const selectedChannel = {}; // freq -> 'max' | channel index as string

function numericSortFreq(a, b) {
  return Number(a) - Number(b);
}

function formatTime(ts) {
  return new Date(Number(ts) * 1000).toLocaleTimeString('ru-RU', {hour12: false});
}

function getChannelCount(freq) {
  const pts = state[freq] || [];
  let maxCount = 1;
  for (const p of pts) {
    if (Number.isFinite(Number(p.channels_total))) {
      maxCount = Math.max(maxCount, Number(p.channels_total));
    }
    if (Array.isArray(p.channel_values)) {
      maxCount = Math.max(maxCount, p.channel_values.length);
    }
  }
  return maxCount;
}

function ensurePlot(freq) {
  if (document.getElementById(`plot-${freq}`)) return;

  const card = document.createElement('div');
  card.className = 'card';
  card.innerHTML = `
    <div class=\"title-row\">
      <div class=\"title\">${freq} MHz</div>
      <div class=\"ctrl\">
        <label for=\"chan-${freq}\">channel</label>
        <select id=\"chan-${freq}\"></select>
      </div>
    </div>
    <div class=\"plot\" id=\"plot-${freq}\"></div>
    <div class=\"events-title\">Alarms (time -> channel)</div>
    <div class=\"events\" id=\"events-${freq}\"></div>
  `;
  document.getElementById('plots').appendChild(card);

  selectedChannel[freq] = 'max';
  const sel = document.getElementById(`chan-${freq}`);
  sel.addEventListener('change', () => {
    selectedChannel[freq] = sel.value;
    render(freq);
  });
}

function updateChannelSelector(freq) {
  const sel = document.getElementById(`chan-${freq}`);
  if (!sel) return;

  const prev = selectedChannel[freq] ?? 'max';
  const count = getChannelCount(freq);

  const opts = ['max'];
  for (let i = 0; i < count; i += 1) opts.push(String(i));

  sel.innerHTML = '';
  for (const v of opts) {
    const option = document.createElement('option');
    option.value = v;
    option.textContent = v === 'max' ? 'max' : `ch ${v}`;
    sel.appendChild(option);
  }

  selectedChannel[freq] = opts.includes(prev) ? prev : 'max';
  sel.value = selectedChannel[freq];
}

function trimPoints(freq) {
  const arr = state[freq] || [];
  const cutoff = Date.now() / 1000 - windowSec;
  state[freq] = arr.filter(p => Number(p.ts) >= cutoff);
}

function getPointValueForSelection(point, selection) {
  if (selection === 'max') {
    return {
      y: point.dbfs_current ?? null,
      threshold: point.dbfs_threshold ?? null,
    };
  }

  const idx = Number(selection);
  if (!Number.isInteger(idx)) {
    return {y: null, threshold: null};
  }

  const y = Array.isArray(point.channel_values) && idx < point.channel_values.length
    ? point.channel_values[idx]
    : null;
  const threshold = Array.isArray(point.channel_thresholds) && idx < point.channel_thresholds.length
    ? point.channel_thresholds[idx]
    : null;

  return {y, threshold};
}

function isAlarmForSelection(point, selection) {
  if (point.alarm !== true) return false;
  if (selection === 'max') return true;

  const idx = Number(selection);
  if (!Number.isInteger(idx)) return false;

  if (Array.isArray(point.alarm_channels) && point.alarm_channels.length > 0) {
    return point.alarm_channels.includes(idx);
  }

  return Number(point.channel_idx) === idx;
}

function renderAlarmEvents(freq, pts) {
  const el = document.getElementById(`events-${freq}`);
  if (!el) return;

  const alarmPts = pts.filter(p => p.alarm === true);
  if (alarmPts.length === 0) {
    el.innerHTML = '<div class=\"ev-empty\">no alarms</div>';
    return;
  }

  const rows = alarmPts.slice(-20).reverse().map((p) => {
    const channels = Array.isArray(p.alarm_channels) && p.alarm_channels.length > 0
      ? p.alarm_channels.join(',')
      : String(p.channel_idx ?? '-');
    return `<div class=\"ev\"><span class=\"ev-t\">${formatTime(p.ts)}</span><span>ch ${channels}</span></div>`;
  });

  el.innerHTML = rows.join('');
}

function render(freq) {
  ensurePlot(freq);
  trimPoints(freq);
  updateChannelSelector(freq);

  const pts = state[freq] || [];
  const sel = selectedChannel[freq] ?? 'max';

  const x = [];
  const y = [];
  const thr = [];
  const alarmX = [];
  const alarmY = [];

  for (const p of pts) {
    const metric = getPointValueForSelection(p, sel);
    if (metric.y === null || metric.y === undefined) {
      continue;
    }

    const ts = new Date(Number(p.ts) * 1000);
    x.push(ts);
    y.push(metric.y);
    thr.push(metric.threshold);

    if (isAlarmForSelection(p, sel)) {
      alarmX.push(ts);
      alarmY.push(metric.y);
    }
  }

  const labelSuffix = sel === 'max' ? 'max' : `ch ${sel}`;

  const traces = [
    {
      x,
      y,
      mode: 'lines',
      name: `dBFS (${labelSuffix})`,
      line: {color: '#12b76a', width: 2},
    },
    {
      x,
      y: thr,
      mode: 'lines',
      name: `Threshold (${labelSuffix})`,
      line: {color: '#ef4444', width: 2, dash: 'dash'},
    },
    {
      x: alarmX,
      y: alarmY,
      mode: 'markers',
      name: 'Alarm',
      marker: {color: '#ef4444', size: 6, symbol: 'circle'},
    },
  ];

  Plotly.react(`plot-${freq}`, traces, {
    margin: {l: 40, r: 12, t: 12, b: 32},
    showlegend: true,
    legend: {orientation: 'h', y: 1.16},
    xaxis: {
      title: 'time',
      tickformat: '%H:%M:%S',
      hoverformat: '%H:%M:%S',
      range: [new Date(Date.now() - windowSec * 1000), new Date()],
    },
    yaxis: {title: 'dBFS'},
  }, {displayModeBar: false, responsive: true});

  renderAlarmEvents(freq, pts);
}

function renderAll() {
  const freqs = Object.keys(state).sort(numericSortFreq);
  freqs.forEach(render);
}

async function loadInitial() {
  const res = await fetch(`/telemetry/history?seconds=${windowSec}`);
  const payload = await res.json();
  const series = payload.series || {};
  for (const [freq, points] of Object.entries(series)) {
    state[freq] = points;
  }
  renderAll();
}

function connectWs() {
  const proto = location.protocol === 'https:' ? 'wss' : 'ws';
  const ws = new WebSocket(`${proto}://${location.host}/telemetry/ws`);

  ws.onopen = () => {
    document.getElementById('status').textContent = 'ws connected';
    setInterval(() => {
      if (ws.readyState === 1) ws.send('ping');
    }, 20000);
  };

  ws.onmessage = (event) => {
    const msg = JSON.parse(event.data);
    if (msg.type === 'snapshot' && msg.data) {
      for (const [freq, points] of Object.entries(msg.data)) {
        state[freq] = points;
      }
      renderAll();
      return;
    }

    if (msg.type !== 'point') return;

    const p = msg.data;
    const freq = String(p.freq);
    if (!state[freq]) state[freq] = [];
    state[freq].push(p);
    render(freq);
  };

  ws.onclose = () => {
    document.getElementById('status').textContent = 'ws disconnected, retrying...';
    setTimeout(connectWs, 1500);
  };

  ws.onerror = () => {
    document.getElementById('status').textContent = 'ws error';
  };
}

setInterval(() => {
  renderAll();
}, 1000);

loadInitial().then(connectWs).catch((e) => {
  document.getElementById('status').textContent = `init error: ${e}`;
  connectWs();
});
</script>
</body>
</html>
"""


INFERENCE_VIEWER_HTML = """
<!doctype html>
<html>
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>DroneDetector Inference Viewer</title>
  <style>
    :root {
      --bg: #f4f6f8;
      --card: #ffffff;
      --line: #d7dde5;
      --text: #1c232e;
      --muted: #647084;
      --ok: #16a34a;
      --warn: #b45309;
      --accent: #0f6fff;
    }
    * { box-sizing: border-box; }
    body { margin: 0; background: var(--bg); color: var(--text); font-family: system-ui, -apple-system, Segoe UI, sans-serif; }
    .wrap { max-width: 1800px; margin: 0 auto; padding: 16px; }
    .head { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-bottom: 16px; }
    .meta { color: var(--muted); font-size: 13px; }
    .actions { display: flex; gap: 8px; align-items: center; }
    button { border: 1px solid var(--line); background: var(--card); color: var(--text); border-radius: 8px; padding: 8px 12px; cursor: pointer; }
    button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
    .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(520px, 1fr)); gap: 14px; }
    .card { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 14px; }
    .card-head { display: flex; justify-content: space-between; align-items: start; gap: 12px; margin-bottom: 10px; }
    .freq { font-size: 28px; font-weight: 700; }
    .pill { display: inline-flex; align-items: center; gap: 6px; border-radius: 999px; padding: 4px 10px; font-size: 12px; }
    .pill.live { background: #dcfce7; color: #166534; }
    .pill.paused { background: #fef3c7; color: #92400e; }
    .summary { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 12px; margin-bottom: 12px; }
    .summary-item { border: 1px solid var(--line); border-radius: 10px; padding: 8px 10px; }
    .summary-label { color: var(--muted); font-size: 12px; margin-bottom: 4px; }
    .summary-value { font-size: 14px; font-weight: 600; word-break: break-word; }
    .images { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin-bottom: 12px; }
    .image-card { border: 1px solid var(--line); border-radius: 10px; overflow: hidden; background: #f8fafc; }
    .image-card img { width: 100%; height: 180px; object-fit: contain; display: block; background: #fff; }
    .image-card .cap { padding: 6px 8px; font-size: 12px; color: var(--muted); border-top: 1px solid var(--line); }
    .history-title { font-size: 13px; font-weight: 600; margin-bottom: 8px; }
    .history { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 4px; }
    .thumb { min-width: 110px; max-width: 110px; border: 1px solid var(--line); border-radius: 10px; background: #fff; padding: 6px; cursor: pointer; }
    .thumb.active { border-color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent); }
    .thumb img { width: 100%; height: 70px; object-fit: contain; display: block; background: #f8fafc; border-radius: 6px; }
    .thumb .t1 { font-size: 12px; font-weight: 600; margin-top: 6px; }
    .thumb .t2 { font-size: 11px; color: var(--muted); }
    .empty { color: var(--muted); font-size: 13px; padding: 16px 0; }
  </style>
</head>
<body>
  <div class="wrap">
    <div class="head">
      <div>
        <h2 style="margin:0 0 4px;">DroneDetector Inference Viewer</h2>
        <div class="meta">Latest inference card per frequency. Browser keeps last 20 results per frequency.</div>
      </div>
      <div class="actions">
        <div class="meta" id="ws-status">connecting...</div>
        <button id="pause-btn" class="primary">Pause updates</button>
      </div>
    </div>
    <div class="grid" id="cards"></div>
  </div>

<script>
const MAX_RESULTS = 20;
const state = {};
const selected = {};
let paused = false;
let wsKeepalive = null;

function freqSort(a, b) { return Number(a) - Number(b); }
function formatTs(ts) {
  return new Date(Number(ts) * 1000).toLocaleString('ru-RU', { hour12: false });
}
function escapeHtml(value) {
  return String(value ?? '').replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
}
function imageUrl(name) {
  return `/inference/images/${encodeURIComponent(name)}`;
}
function primaryImage(result) {
  return (result.images || [])[0] || '';
}
function trimResults(freq) {
  const arr = state[freq] || [];
  state[freq] = arr.slice(-MAX_RESULTS);
}
function ensureCard(freq) {
  if (document.getElementById(`card-${freq}`)) return;
  const card = document.createElement('div');
  card.className = 'card';
  card.id = `card-${freq}`;
  card.innerHTML = `
    <div class="card-head">
      <div class="freq">${escapeHtml(freq)} MHz</div>
      <div class="pill ${paused ? 'paused' : 'live'}" id="badge-${freq}">${paused ? 'paused' : 'live'}</div>
    </div>
    <div class="summary" id="summary-${freq}"></div>
    <div class="images" id="images-${freq}"></div>
    <div class="history-title">Recent results</div>
    <div class="history" id="history-${freq}"></div>
  `;
  document.getElementById('cards').appendChild(card);
}
function renderSummary(freq, result) {
  const el = document.getElementById(`summary-${freq}`);
  el.innerHTML = `
    <div class="summary-item"><div class="summary-label">Model</div><div class="summary-value">${escapeHtml(result.model)}</div></div>
    <div class="summary-item"><div class="summary-label">Prediction</div><div class="summary-value">${escapeHtml(result.prediction)}</div></div>
    <div class="summary-item"><div class="summary-label">Confidence</div><div class="summary-value">${Number(result.probability).toFixed(3)}</div></div>
    <div class="summary-item"><div class="summary-label">Time</div><div class="summary-value">${escapeHtml(formatTs(result.ts))}</div></div>
  `;
}
function renderImages(freq, result) {
  const el = document.getElementById(`images-${freq}`);
  const images = result.images || [];
  if (!images.length) {
    el.innerHTML = '<div class="empty">No images for this inference.</div>';
    return;
  }
  el.innerHTML = images.map((name) => `
    <div class="image-card">
      <img loading="lazy" src="${imageUrl(name)}" alt="${escapeHtml(name)}" />
      <div class="cap">${escapeHtml(name)}</div>
    </div>
  `).join('');
}
function renderHistory(freq) {
  const el = document.getElementById(`history-${freq}`);
  const results = state[freq] || [];
  if (!results.length) {
    el.innerHTML = '<div class="empty">No results yet.</div>';
    return;
  }
  el.innerHTML = results.slice().reverse().map((result) => {
    const active = String(selected[freq]) === String(result.result_id) ? 'active' : '';
    const image = primaryImage(result);
    return `
      <button class="thumb ${active}" data-freq="${escapeHtml(freq)}" data-result-id="${result.result_id}">
        ${image ? `<img loading="lazy" src="${imageUrl(image)}" alt="${escapeHtml(result.prediction)}" />` : '<div class="empty" style="padding:20px 0;">no image</div>'}
        <div class="t1">${escapeHtml(result.prediction)}</div>
        <div class="t2">p=${Number(result.probability).toFixed(2)}</div>
      </button>
    `;
  }).join('');
  el.querySelectorAll('.thumb').forEach((node) => {
    node.addEventListener('click', () => {
      selected[freq] = Number(node.dataset.resultId);
      render(freq);
    });
  });
}
function render(freq) {
  ensureCard(freq);
  trimResults(freq);
  const badge = document.getElementById(`badge-${freq}`);
  badge.textContent = paused ? 'paused' : 'live';
  badge.className = `pill ${paused ? 'paused' : 'live'}`;

  const results = state[freq] || [];
  if (!results.length) {
    document.getElementById(`summary-${freq}`).innerHTML = '<div class="empty">No inference results yet.</div>';
    document.getElementById(`images-${freq}`).innerHTML = '';
    document.getElementById(`history-${freq}`).innerHTML = '';
    return;
  }
  const active = results.find((item) => String(item.result_id) === String(selected[freq])) || results[results.length - 1];
  selected[freq] = active.result_id;
  renderSummary(freq, active);
  renderImages(freq, active);
  renderHistory(freq);
}
function renderAll() {
  Object.keys(state).sort(freqSort).forEach(render);
}
function applySnapshot(series) {
  const next = {};
  for (const [freq, results] of Object.entries(series || {})) {
    next[String(freq)] = Array.isArray(results) ? results.slice(-MAX_RESULTS) : [];
  }
  for (const freq of Object.keys(next)) {
    state[freq] = next[freq];
    if (state[freq].length) {
      selected[freq] = state[freq][state[freq].length - 1].result_id;
    }
  }
  renderAll();
}
function ingestResult(result) {
  const freq = String(result.freq);
  if (!state[freq]) state[freq] = [];
  state[freq].push(result);
  trimResults(freq);
  selected[freq] = result.result_id;
  render(freq);
}
async function loadHistory() {
  const res = await fetch(`/inference/history?limit=${MAX_RESULTS}`);
  const payload = await res.json();
  applySnapshot(payload.series || {});
}
function setPaused(nextPaused) {
  paused = nextPaused;
  const btn = document.getElementById('pause-btn');
  btn.textContent = paused ? 'Resume updates' : 'Pause updates';
  btn.className = paused ? '' : 'primary';
  renderAll();
}
function connectWs() {
  const proto = location.protocol === 'https:' ? 'wss' : 'ws';
  const ws = new WebSocket(`${proto}://${location.host}/inference/ws`);
  ws.onopen = () => {
    document.getElementById('ws-status').textContent = paused ? 'ws connected (paused)' : 'ws connected';
    if (wsKeepalive) clearInterval(wsKeepalive);
    wsKeepalive = setInterval(() => {
      if (ws.readyState === 1) ws.send('ping');
    }, 20000);
  };
  ws.onmessage = (event) => {
    const msg = JSON.parse(event.data);
    if (msg.type === 'snapshot' && msg.data) {
      if (!paused) applySnapshot(msg.data);
      return;
    }
    if (msg.type !== 'inference_result' || paused) return;
    ingestResult(msg.data);
  };
  ws.onclose = () => {
    document.getElementById('ws-status').textContent = 'ws disconnected, retrying...';
    if (wsKeepalive) clearInterval(wsKeepalive);
    setTimeout(connectWs, 1500);
  };
  ws.onerror = () => {
    document.getElementById('ws-status').textContent = 'ws error';
  };
}
document.getElementById('pause-btn').addEventListener('click', async () => {
  if (paused) {
    setPaused(false);
    document.getElementById('ws-status').textContent = 'syncing...';
    try {
      await loadHistory();
      document.getElementById('ws-status').textContent = 'ws connected';
    } catch (err) {
      document.getElementById('ws-status').textContent = `history error: ${err}`;
    }
  } else {
    setPaused(true);
    document.getElementById('ws-status').textContent = 'ws connected (paused)';
  }
});
loadHistory().then(connectWs).catch((err) => {
  document.getElementById('ws-status').textContent = `init error: ${err}`;
  connectWs();
});
</script>
</body>
</html>
"""


@app.get('/', response_class=HTMLResponse)
@app.get('/monitor', response_class=HTMLResponse)
async def monitor_page():
    return HTMLResponse(content=MONITOR_HTML)


@app.get('/inference-viewer', response_class=HTMLResponse)
async def inference_viewer_page():
    return HTMLResponse(content=INFERENCE_VIEWER_HTML)


@app.get('/inference/images/{filename}')
async def inference_image(filename: str):
    safe_name = Path(filename).name
    if safe_name != filename:
        raise HTTPException(status_code=404, detail='image not found')

    image_path = (INFERENCE_RESULT_DIR / safe_name).resolve()
    if image_path.parent != INFERENCE_RESULT_DIR or not image_path.is_file():
        raise HTTPException(status_code=404, detail='image not found')

    return FileResponse(image_path)


if __name__ == '__main__':
    import uvicorn

    uvicorn.run(app, host=TELEMETRY_BIND_HOST, port=TELEMETRY_BIND_PORT)
