from html import escape

from bullseye.render import render_transcript
from bullseye.status import render_status


def render_page(messages, doctor_json, refresh_secs=10) -> str:
    # The status widget is decoration; the transcript is the product. A malformed or partial
    # doctor report must never be able to take the room view down with it (it did: one missing
    # "ts" key blanked the entire page behind "Status page unavailable").
    try:
        status_html = render_status(doctor_json)
    except Exception as exc:
        status_html = ('<div style="font-family:monospace;font-size:12px;color:#a00;">'
                       f'status unavailable ({escape(type(exc).__name__)})</div>')
    transcript_html = render_transcript(messages)
    last_id = max((m.get("id", 0) for m in messages), default=0)
    poll_ms = max(1, int(refresh_secs)) * 1000

    return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>bullpen</title>
<style>
:root {{ --box-alpha: 0.70; }}
/* black lives on <html> only: a fixed z-index:-1 canvas paints in the ROOT stacking context,
   BEFORE the body's normal-flow render — an opaque body background would paint over (hide) it. */
html {{ margin: 0; padding: 0; background: #000; overflow-x: hidden; }}
body {{ font-family: 'Courier New', Courier, monospace; color: #c8ffc8; margin: 0; padding: 1em; padding-bottom: 140px; background: transparent; overflow-x: hidden; max-width: 100%; }}
#matrix {{ position: fixed; inset: 0; width: 100%; height: 100%; z-index: -1; }}
#status {{ background: rgba(0, 20, 0, var(--box-alpha)); border: 1px solid #0a4; border-radius: 4px; padding: 8px; margin-bottom: 10px; overflow-x: auto; }}
#status table, #status td, #status th {{ color: #c8ffc8; }}
a {{ color: #7fffa0; }}
.grind-trace {{ color: #6a6a7a; font-style: italic; display: block; opacity: 0.85; }}
.think {{ color: #6a6a7a; font-style: italic; display: block; white-space: pre-wrap; opacity: 0.8; margin-top: 4px; padding-left: 8px; border-left: 2px solid rgba(106,106,122,0.35); }}
input {{ background: rgba(0, 0, 0, 0.6); color: #c8ffc8; border: 1px solid #0a3; padding: 4px; }}
input::placeholder {{ color: #6a9; }}
select {{ background: rgba(0, 0, 0, 0.6); color: #c8ffc8; border: 1px solid #0a3; padding: 3px; }}
button {{ background: #0c5; color: #001; border: none; padding: 4px 12px; cursor: pointer; font-weight: bold; }}
#dock {{ position: fixed; left: 0; right: 0; bottom: 0; z-index: 10; background: rgba(0, 15, 0, 0.90); border-top: 1px solid #0a4; padding: 8px 12px; }}
/* flex + wrap: rain toggle, translucency slider and the FROM-filter share one row on desktop and
   wrap onto extra lines on narrow screens — the dock must never widen the page past the viewport */
#controls {{ display: flex; flex-wrap: wrap; align-items: center; gap: 2px 6px; margin-bottom: 6px; font-size: 0.85em; opacity: 0.9; }}
#controls input[type=range] {{ vertical-align: middle; }}
#compose {{ display: flex; gap: 6px; align-items: center; flex-wrap: wrap; margin: 0; }}
#compose #msg {{ flex: 1 1 20em; min-width: 0; }}
/* long tokens (urls, sic commands, code) must wrap, not force the page wider than the viewport */
#transcript {{ overflow-wrap: break-word; word-break: break-word; }}
#transcript pre {{ white-space: pre-wrap; overflow-x: auto; max-width: 100%; }}
/* mobile: double the base font (relies on the viewport meta above); stack the compose so the
   wide inputs never overflow (which was pinning the transcript into a narrow left column) */
@media (max-width: 768px) {{
  body {{ font-size: 100%; padding-bottom: 260px; }}
  #compose #to {{ width: 6em; }}
  #compose #msg {{ flex-basis: 100%; }}
}}
</style>
</head>
<body>
<canvas id="matrix"></canvas>
<div id="status">{status_html}</div>
<div id="thinking" class="think" style="display:none"></div>
<div id="transcript">{transcript_html}</div>
<div id="dock">
<div id="controls">
<label><input type="checkbox" id="raintoggle" checked> rain</label>
&nbsp;&nbsp;<label>translucency <input type="range" id="alpha" min="0" max="100" value="70"></label>
&nbsp;&nbsp;<label>from <select id="fromfilter"><option value="" selected>all</option></select></label>
</div>
<form id="compose">
<input id="to" placeholder="@nick (e.g. @architect)" style="width:12em;">
<input id="msg" placeholder="message as markus…">
<button type="submit">post</button>
<span id="posterr" style="color:#f88;"></span>
</form>
</div>
<script>
// Matrix rain + UI controls (rain toggle, translucency slider, FROM-filter) with localStorage persistence
(function() {{
  var c = document.getElementById('matrix'), x = c.getContext('2d');
  var glyphs = 'アイウエオカキクケコサシスセソタチツテトナニヌネ0123456789'.split('');
  var fs = 14, drops = [], timer = null;
  function resize() {{
    c.width = window.innerWidth; c.height = window.innerHeight;
    var cols = Math.floor(c.width / fs); drops = [];
    for (var i = 0; i < cols; i++) drops[i] = Math.random() * (c.height / fs);
  }}
  resize(); window.addEventListener('resize', resize);
  function draw() {{
    if (document.hidden) return;   // don't burn CPU repainting when the page isn't visible
    x.fillStyle = 'rgba(0, 0, 0, 0.08)'; x.fillRect(0, 0, c.width, c.height);
    x.fillStyle = '#00ff41'; x.font = fs + "px monospace";
    for (var i = 0; i < drops.length; i++) {{
      x.fillText(glyphs[Math.floor(Math.random() * glyphs.length)], i * fs, drops[i] * fs);
      if (drops[i] * fs > c.height && Math.random() > 0.975) drops[i] = 0;
      drops[i]++;
    }}
  }}
  var reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  function start() {{ if (!timer && !reduce) timer = setInterval(draw, 50); }}
  function stop() {{ if (timer) {{ clearInterval(timer); timer = null; }} x.clearRect(0, 0, c.width, c.height); }}
  // storage-safe helpers: a blocked localStorage (strict-privacy browsers) must NOT throw and
  // abort the rest of this <script> — that would leave compose + polling unwired (page wedged).
  function lsGet(k) {{ try {{ return localStorage.getItem(k); }} catch (e) {{ return null; }} }}
  function lsSet(k, v) {{ try {{ localStorage.setItem(k, v); }} catch (e) {{}} }}
  // rain toggle (persisted)
  var rt = document.getElementById('raintoggle');
  var savedRain = lsGet('bp_rain');
  rt.checked = (savedRain === null) ? true : (savedRain === '1');
  function applyRain() {{ if (rt.checked) start(); else stop(); lsSet('bp_rain', rt.checked ? '1' : '0'); }}
  rt.addEventListener('change', applyRain);
  applyRain();
  // translucency slider (persisted) -> drives --box-alpha on every message box + panel
  var sl = document.getElementById('alpha');
  var savedA = lsGet('bp_alpha');
  if (savedA !== null) sl.value = savedA;
  function applyAlpha() {{ document.documentElement.style.setProperty('--box-alpha', (sl.value / 100).toFixed(2)); lsSet('bp_alpha', sl.value); }}
  sl.addEventListener('input', applyAlpha);
  applyAlpha();
  // FROM-filter (persisted in bp_from_filter). Options are DERIVED from the data-from attribute
  // of the rows actually rendered in #transcript — never a hardcoded roster — so a sender that
  // first appears in a /api/since AJAX append joins the list at runtime without a reload.
  var ff = document.getElementById('fromfilter');
  var tc = document.getElementById('transcript');
  function ffRows() {{ return tc.querySelectorAll('[data-from]'); }}
  function syncFromSenders() {{
    var seen = {{}}, i, f, o;
    for (i = 0; i < ff.options.length; i++) seen[ff.options[i].value] = true;
    var rows = ffRows();
    for (i = 0; i < rows.length; i++) {{
      f = rows[i].getAttribute('data-from');
      if (f !== null && f !== '' && !seen[f]) {{
        seen[f] = true;
        o = document.createElement('option');
        o.value = f; o.textContent = f;
        ff.appendChild(o);
      }}
    }}
  }}
  function applyFromFilter() {{
    var want = ff.value, rows = ffRows();
    for (var i = 0; i < rows.length; i++) {{
      rows[i].style.display = (want === '' || rows[i].getAttribute('data-from') === want) ? '' : 'none';
    }}
  }}
  function saveFromFilter() {{ lsSet('bp_from_filter', ff.value); applyFromFilter(); }}
  ff.addEventListener('input', saveFromFilter);
  ff.addEventListener('change', saveFromFilter);
  syncFromSenders();
  var savedFrom = lsGet('bp_from_filter');
  if (savedFrom) ff.value = savedFrom;   // preselect the stored sender (if still in the list)
  applyFromFilter();                     // and APPLY it to the rows, not just the select
  // the /api/since poller appends rows with insertAdjacentHTML: re-derive senders and re-apply
  // the active filter to the new rows via a MutationObserver (client-side only, no payload change)
  if (window.MutationObserver) {{
    new MutationObserver(function() {{ syncFromSenders(); applyFromFilter(); }})
      .observe(tc, {{ childList: true }});
  }}
}})();
// auto-provision the post token on the trusted network: /bp-token is caddy-gated to LAN/VPN
// client IPs, so this silently populates localStorage there and no-ops (403) for external clients.
(function() {{
  // refresh the post token from the LAN/VPN-gated endpoint on EVERY load, so a server-side token
  // rotation is picked up seamlessly. External clients get 403 here (r.ok false) and keep whatever
  // token they entered by hand — we only overwrite on a real 200.
  fetch('/bp-token').then(function(r) {{ return r.ok ? r.json() : null; }})
    .then(function(d) {{ if (d && d.token) {{ try {{ localStorage.setItem('bp_ptoken', d.token); }} catch (e) {{}} }} }})
    .catch(function() {{}});
}})();
document.getElementById('compose').addEventListener('submit', function(e) {{
  e.preventDefault();
  var to = document.getElementById('to').value;
  var body = document.getElementById('msg').value;
  // posting is authenticated (shared Bearer secret, kept only in this browser's localStorage —
  // never in the served HTML, since reads are public). Prompt once, cache, clear on a 401.
  var tok = ''; try {{ tok = localStorage.getItem('bp_ptoken') || ''; }} catch (e) {{}}
  if (!tok) {{ tok = prompt('bullseye post token:') || ''; try {{ localStorage.setItem('bp_ptoken', tok); }} catch (e) {{}} }}
  fetch('/post', {{method: 'POST', headers: {{'Content-Type': 'application/json', 'Authorization': 'Bearer ' + tok}}, body: JSON.stringify({{to: to, body: body}})}})
    .then(function(r) {{
      if (r.status === 401) {{ try {{ localStorage.removeItem('bp_ptoken'); }} catch (e) {{}} document.getElementById('posterr').textContent = 'bad/missing post token — retry to re-enter'; return null; }}
      if (r.status === 503) {{ document.getElementById('posterr').textContent = 'posting disabled (server has no token configured)'; return null; }}
      return r.json();
    }})
    .then(function(d) {{
      if (!d) return;
      if (d.ok) {{ document.getElementById('msg').value = ''; document.getElementById('posterr').textContent = ''; }}
      else {{ document.getElementById('posterr').textContent = (d.error) || 'post failed'; }}
    }})
    .catch(function() {{ document.getElementById('posterr').textContent = 'post failed'; }});
}});
(function() {{
  // THINKING REVEAL #3 (idea #155): live SSE stream of the reasoning side-file instead of
  // polling. EventSource auto-reconnects on a dropped connection natively, so no manual
  // retry/backoff is needed here (simpler than the poll loop it replaces, not just faster).
  var te = document.getElementById('thinking');
  function render(d) {{
    var active = d && d.id;
    if (active) {{ te.textContent = '💭 ' + (d.text || 'thinking...'); te.style.display = 'block'; }}
    else {{ te.style.display = 'none'; te.textContent = ''; }}
  }}
  if (window.EventSource) {{
    var es = new EventSource('/api/thinking/stream');
    es.onmessage = function(ev) {{ try {{ render(JSON.parse(ev.data)); }} catch (e) {{}} }};
  }} else {{
    // THINKING REVEAL #2 fallback (progressing view): poll the live reasoning side-file for
    // browsers without EventSource support. Same cadence as before: 1.5s active, 3s idle.
    function pollThink() {{
      fetch('/api/thinking').then(function(r) {{ return r.json(); }})
        .then(function(d) {{ render(d); setTimeout(pollThink, (d && d.id) ? 1500 : 3000); }})
        .catch(function() {{ setTimeout(pollThink, 3000); }});
    }}
    pollThink();
  }}
}})();
(function() {{
  var lastId = {last_id};
  function tick() {{
    fetch('/api/since/' + lastId)
      .then(function(r) {{ return r.json(); }})
      .then(function(d) {{
        if (d && d.rows_html) document.getElementById('transcript').insertAdjacentHTML('beforeend', d.rows_html);
        if (d && d.status_html) document.getElementById('status').innerHTML = d.status_html;
        if (d && typeof d.last_id === 'number' && d.last_id > lastId) lastId = d.last_id;
      }})
      .catch(function() {{}})
      .finally(function() {{ setTimeout(tick, {poll_ms}); }});
  }}
  setTimeout(tick, {poll_ms});
}})();
</script>
</body>
</html>"""
