#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# =============================================================================
# Louver live-read testbed  (regs 42824 front / 42826 rear, DSE slave 10)
#
# Purpose: watch a gen's louver feedback voltage in real time while a field tech
# physically works the louver, to resolve whether HIGH voltage (~8V) = OPEN or
# CLOSED. Marker buttons + CSV log give a timestamped physical-action-vs-voltage
# record.
#
#   *** READ-ONLY. This page NEVER writes any Modbus register. ***
#   (Control writes go to reg 4104 and actuate the gen. Nothing here touches it.)
#
# Modes:
#   (no args)                -> HTML page (phone friendly, auto-polls)
#   ?json=1&gen=<id>         -> one throttled live reading + recent log tail (JSON)
#   ?json=1&gen=<id>&mark=.. -> log a physical-action marker, return same JSON
# =============================================================================
import cgi, sys, os, json, time, fcntl, inspect
from datetime import datetime, timezone

sys.path.insert(0, '/opt/ngon/apps')
from managers.auth_manager import AuthManager, generate_login_page_html

PROBE_DIR    = '/opt/ngon/data/louver_probe'
STATUS_API   = 'http://localhost:5050/api/status'
SLAVE        = 10
MODBUS_PORT  = 502
MIN_INTERVAL = 1.2      # min seconds between real Modbus reads (shared, all viewers)
IP_TTL       = 300      # re-resolve bridge IP from status_api at most this often
DEFAULT_GEN  = '22-24-304'
LOG_TAIL     = 24

os.makedirs(PROBE_DIR, exist_ok=True)


def _now_iso():
    return datetime.now(timezone.utc).astimezone().strftime('%H:%M:%S')


# ---- paths -----------------------------------------------------------------
def _safe(gen):
    return ''.join(c for c in gen if c.isalnum() or c in '-_')


def _state_path(gen): return os.path.join(PROBE_DIR, f'state_{_safe(gen)}.json')
def _lock_path(gen):  return os.path.join(PROBE_DIR, f'lock_{_safe(gen)}')
def _csv_path(gen):   return os.path.join(PROBE_DIR, f'louver_{_safe(gen)}.csv')


def _load_state(gen):
    try:
        with open(_state_path(gen)) as f:
            return json.load(f)
    except Exception:
        return {}


def _save_state(gen, st):
    tmp = _state_path(gen) + '.tmp'
    with open(tmp, 'w') as f:
        json.dump(st, f)
    os.replace(tmp, _state_path(gen))


def _append_log(gen, row):
    new = not os.path.exists(_csv_path(gen))
    row = dict(row)
    row.setdefault('epoch', round(row.get('ts', 0), 2))
    with open(_csv_path(gen), 'a') as f:
        if new:
            f.write('iso,epoch,gen,ip,front_v,rear_v,coolant_c,rpm,mac_ok,note\n')
        f.write(','.join(str(row.get(k, '')) for k in
                ('iso', 'epoch', 'gen', 'ip', 'front', 'rear', 'coolant', 'rpm', 'mac_ok', 'note')) + '\n')


def _log_tail(gen, n=LOG_TAIL):
    try:
        with open(_csv_path(gen)) as f:
            lines = f.readlines()
    except Exception:
        return []
    out = []
    for ln in lines[-n:]:
        p = ln.rstrip('\n').split(',')
        if len(p) >= 10 and p[0] != 'iso':
            out.append({'iso': p[0], 'front': p[4], 'rear': p[5],
                        'coolant': p[6], 'rpm': p[7], 'note': p[9]})
    return list(reversed(out))


# ---- gen roster / IP resolution -------------------------------------------
def _fetch_status():
    import urllib.request
    with urllib.request.urlopen(STATUS_API, timeout=6) as r:
        return json.load(r)


def _resolve_ip(gen):
    """Return (ip, expected_mac) for gen from status_api live_stats."""
    d = _fetch_status()
    for s in d.get('sites', {}).values():
        for g in (s.get('generator_groups') or {}).values():
            gens = g.get('generators') or {}
            if gen in gens:
                ls = gens[gen].get('live_stats') or {}
                return ls.get('live_bridge_ip'), (gens[gen].get('mac') or '').upper()
    return None, None


def _roster():
    """[(site, gen_id, has_live_ip)] for the picker."""
    try:
        d = _fetch_status()
    except Exception:
        return []
    rows = []
    for sname, s in d.get('sites', {}).items():
        for g in (s.get('generator_groups') or {}).values():
            for gid, gen in (g.get('generators') or {}).items():
                ls = gen.get('live_stats') or {}
                rows.append((sname, gid, bool(ls.get('live_bridge_ip'))))
    return sorted(rows)


# ---- Modbus (READ ONLY) ----------------------------------------------------
def _modbus_read(ip):
    from pymodbus.client import ModbusTcpClient
    kw = ('device_id'
          if 'device_id' in inspect.signature(ModbusTcpClient.read_holding_registers).parameters
          else 'slave')
    c = ModbusTcpClient(ip, port=MODBUS_PORT, timeout=2)
    if not c.connect():
        raise RuntimeError(f'connect {ip}:{MODBUS_PORT} failed')
    try:
        def rd(a, n):
            rr = c.read_holding_registers(a, count=n, **{kw: SLAVE})
            if rr.isError():
                raise RuntimeError(f'read {a} err: {rr}')
            return rr.registers

        blk = rd(42814, 14)   # 42814..42827 in one shot
        macr = rd(18020, 4)
        base = 42814
        def R(a): return blk[a - base]

        def s16(v): return v - 65536 if v >= 32768 else v
        def u32(hi, lo): return (hi << 16) | lo
        def s32(hi, lo):
            v = (hi << 16) | lo
            return v - (1 << 32) if v >= (1 << 31) else v

        val = 0
        for r in macr:
            val = (val << 16) | r
        mac = f'{val & 0xFFFFFFFFFFFF:012X}'

        return {
            'front':   round(u32(R(42824), R(42825)) * 0.001, 3),
            'rear':    round(u32(R(42826), R(42827)) * 0.001, 3),
            'coolant': s16(R(42820)),
            'rpm':     s16(R(42814)),
            'oil_kpa': s16(R(42816)),
            'batt_v':  round(s16(R(42818)) * 0.1, 1),
            'intake':  s32(R(42822), R(42823)),
            'mac':     mac,
        }
    finally:
        c.close()


def _read_throttled(gen, mark=None):
    """One shared, throttled, MAC-verified read. Logs real reads + markers."""
    st = _load_state(gen)
    now = time.time()

    # serve cache if fresh and this isn't a marker
    if mark is None and st and now - st.get('ts', 0) < MIN_INTERVAL and not st.get('err'):
        st['cached'] = True
        return {'reading': st, 'recent': _log_tail(gen)}

    lock = open(_lock_path(gen), 'w')
    try:
        try:
            fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
        except BlockingIOError:
            if mark is None and st:
                st['cached'] = True
                return {'reading': st, 'recent': _log_tail(gen)}
            fcntl.flock(lock, fcntl.LOCK_EX)

        st = _load_state(gen)
        now = time.time()
        if mark is None and st and now - st.get('ts', 0) < MIN_INTERVAL and not st.get('err'):
            st['cached'] = True
            return {'reading': st, 'recent': _log_tail(gen)}

        ip = st.get('ip')
        mac_exp = st.get('mac_exp')
        need_ip = (not ip) or (now - st.get('ip_ts', 0) > IP_TTL) or st.get('err')
        if need_ip:
            try:
                rip, rmac = _resolve_ip(gen)
                if rip:
                    ip = rip
                if rmac:
                    mac_exp = rmac
            except Exception:
                pass

        try:
            if not ip:
                raise RuntimeError('no bridge IP known for this gen (not currently live?)')
            r = _modbus_read(ip)
            r['ip'] = ip
            r['ip_ts'] = now if need_ip else st.get('ip_ts', now)
            r['mac_exp'] = mac_exp
            r['mac_ok'] = (r['mac'] == mac_exp) if mac_exp else None
            r['ts'] = now
            r['iso'] = _now_iso()
            r['gen'] = gen
            r['err'] = None
            r['cached'] = False
            _save_state(gen, r)
            _append_log(gen, {**r, 'note': (mark or '')})
            return {'reading': r, 'recent': _log_tail(gen)}
        except Exception as e:
            err = f'{type(e).__name__}: {e}'
            out = dict(st) if st else {}
            out.update({'err': err, 'ts': st.get('ts', 0), 'ip': ip,
                        'ip_ts': 0, 'iso': _now_iso(), 'gen': gen})
            _save_state(gen, out)
            if mark:  # still record the marker even if read failed
                _append_log(gen, {**out, 'note': mark})
            return {'reading': out, 'recent': _log_tail(gen)}
    finally:
        fcntl.flock(lock, fcntl.LOCK_UN)
        lock.close()


# =============================================================================
# request handling
# =============================================================================
form = cgi.FieldStorage()
auth = AuthManager('status')
auth_required, should_exit, headers = auth.require_auth(form)

if should_exit:
    print("Content-Type: application/json")
    if headers:
        print(headers)
    print("")
    print('{"success": false, "error": "Authentication required"}'
          if auth_required else '{"success": true}')
    sys.exit(0)

if auth_required:
    print("Content-Type: text/html\n")
    print(generate_login_page_html("Louver Test"))
    sys.exit(0)

gen = (form.getvalue('gen') or DEFAULT_GEN).strip()

if form.getvalue('json'):
    mark = form.getvalue('mark')
    payload = _read_throttled(gen, mark=mark)
    print("Content-Type: application/json\n")
    print(json.dumps(payload))
    sys.exit(0)

# ---- HTML ------------------------------------------------------------------
roster = _roster()
opts = []
last_site = None
for site, gid, live in roster:
    if site != last_site:
        if last_site is not None:
            opts.append('</optgroup>')
        opts.append(f'<optgroup label="{site}">')
        last_site = site
    sel = ' selected' if gid == gen else ''
    dot = '' if live else ' ○'  # hollow circle = no live IP right now
    opts.append(f'<option value="{gid}"{sel}>{gid}{dot}</option>')
if last_site is not None:
    opts.append('</optgroup>')
options_html = ''.join(opts)

print("Content-Type: text/html\n")
print(f"""<!DOCTYPE html>
<html lang="en"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>Louver Test</title>
<style>
  * {{ box-sizing: border-box; -webkit-tap-highlight-color: transparent; }}
  body {{ margin:0; background:#111; color:#eee; font-family:-apple-system,Segoe UI,Roboto,sans-serif; }}
  .header {{ margin:10px; padding:12px 16px; background:#1c1c1c; border:1px solid #2a2a2a;
            border-radius:12px; display:flex; align-items:center; gap:12px; flex-wrap:wrap; }}
  .header h1 {{ font-size:16px; margin:0; font-weight:600; }}
  .ro {{ font-size:11px; color:#4a9eff; border:1px solid #4a9eff; border-radius:6px; padding:2px 6px; }}
  select {{ background:#111; color:#eee; border:1px solid #333; border-radius:8px; padding:8px 10px; font-size:15px; }}
  .wrap {{ padding:0 10px 40px; max-width:640px; margin:0 auto; }}
  .big {{ display:flex; gap:10px; margin-top:6px; }}
  .card {{ flex:1; background:#1c1c1c; border:1px solid #2a2a2a; border-radius:12px; padding:14px; text-align:center; }}
  .card .lbl {{ font-size:12px; color:#888; text-transform:uppercase; letter-spacing:.5px; }}
  .card .val {{ font-size:44px; font-weight:700; line-height:1.1; margin-top:4px; font-variant-numeric:tabular-nums; }}
  .card .unit {{ font-size:16px; color:#888; }}
  .bar {{ height:14px; background:#000; border:1px solid #333; border-radius:8px; margin-top:10px; overflow:hidden; }}
  .bar > i {{ display:block; height:100%; width:0; background:linear-gradient(90deg,#4a9eff,#00ff00); transition:width .3s; }}
  .sub {{ display:flex; gap:10px; margin-top:10px; }}
  .sub .card .val {{ font-size:26px; }}
  .marks {{ display:grid; grid-template-columns:1fr 1fr; gap:8px; margin-top:14px; }}
  .marks button {{ padding:16px 8px; font-size:15px; font-weight:600; border:none; border-radius:12px; color:#fff; }}
  .m-close {{ background:#b8860b; }} .m-closed {{ background:#c0392b; }}
  .m-open {{ background:#1f6f3d; }} .m-opened {{ background:#00863a; }}
  .m-note {{ grid-column:1/3; background:#333; }}
  .status {{ text-align:center; font-size:13px; color:#888; margin-top:12px; min-height:18px; }}
  .stale {{ color:#ff6600 !important; }}
  .err {{ color:#ff4444 !important; }}
  table {{ width:100%; border-collapse:collapse; margin-top:14px; font-size:13px; }}
  th, td {{ padding:5px 6px; border-bottom:1px solid #222; text-align:right; font-variant-numeric:tabular-nums; }}
  th {{ color:#888; font-weight:500; text-align:right; }}
  td.note, th.note {{ text-align:left; color:#ffcf6b; }}
  .mac-bad {{ color:#ff4444; font-weight:700; }}
</style></head>
<body>
  <div class="header">
    <h1>Louver Test</h1>
    <span class="ro">READ-ONLY</span>
    <select id="gen" onchange="switchGen()">{options_html}</select>
  </div>
  <div class="wrap">
    <div class="big">
      <div class="card"><div class="lbl">Front louver</div>
        <div class="val" id="front">--<span class="unit">V</span></div>
        <div class="bar"><i id="frontbar"></i></div></div>
      <div class="card"><div class="lbl">Rear louver</div>
        <div class="val" id="rear">--<span class="unit">V</span></div>
        <div class="bar"><i id="rearbar"></i></div></div>
    </div>
    <div class="sub">
      <div class="card"><div class="lbl">Coolant</div><div class="val" id="coolant">--<span class="unit">&deg;C</span></div></div>
      <div class="card"><div class="lbl">RPM</div><div class="val" id="rpm">--</div></div>
      <div class="card"><div class="lbl">Intake</div><div class="val" id="intake">--<span class="unit">&deg;C</span></div></div>
    </div>

    <div class="marks">
      <button class="m-close"  onclick="mark('START closing')">&#9660; Start CLOSING</button>
      <button class="m-open"   onclick="mark('START opening')">&#9650; Start OPENING</button>
      <button class="m-closed" onclick="mark('FULLY CLOSED')">Fully CLOSED</button>
      <button class="m-opened" onclick="mark('FULLY OPEN')">Fully OPEN</button>
      <button class="m-note"   onclick="customMark()">+ note</button>
    </div>

    <div class="status" id="status">connecting&hellip;</div>

    <table><thead><tr><th class="note">event / time</th><th>front</th><th>rear</th><th>&deg;C</th><th>rpm</th></tr></thead>
      <tbody id="log"></tbody></table>
  </div>

<script>
const FL_MIN=1.9, FL_MAX=8.0;
let gen = document.getElementById('gen').value;
let timer=null;

function pct(v,lo,hi){{ return Math.max(0,Math.min(100,(v-lo)/(hi-lo)*100)); }}

function render(d){{
  const r=d.reading||{{}}, s=document.getElementById('status');
  if(r.front!=null && !r.err){{
    document.getElementById('front').innerHTML   = r.front.toFixed(2)+'<span class="unit">V</span>';
    document.getElementById('rear').innerHTML    = (r.rear!=null?r.rear.toFixed(2):'--')+'<span class="unit">V</span>';
    document.getElementById('coolant').innerHTML = (r.coolant!=null?r.coolant:'--')+'<span class="unit">&deg;C</span>';
    document.getElementById('rpm').textContent   = (r.rpm!=null?r.rpm:'--');
    document.getElementById('intake').innerHTML  = (r.intake!=null?r.intake:'--')+'<span class="unit">&deg;C</span>';
    document.getElementById('frontbar').style.width = pct(r.front,FL_MIN,FL_MAX)+'%';
    document.getElementById('rearbar').style.width  = pct(r.rear,7.5,9.0)+'%';
    const age = r.ts? Math.round(Date.now()/1000 - r.ts):0;
    let msg = 'live · '+(r.iso||'')+(r.cached?' (cached)':'')+' · '+r.ip;
    s.className='status'; if(age>6){{ msg+=' · '+age+'s old'; s.className='status stale'; }}
    if(r.mac_ok===false){{ msg+=' · MAC MISMATCH!'; s.className='status err'; }}
    s.innerHTML=msg;
  }} else {{
    s.className='status err'; s.textContent = 'read error: '+(r.err||'no data');
  }}
  const tb=document.getElementById('log'); tb.innerHTML='';
  (d.recent||[]).forEach(e=>{{
    const tr=document.createElement('tr');
    const noteCell = e.note? '<b>'+e.note+'</b> · '+e.iso : e.iso;
    tr.innerHTML='<td class="note">'+noteCell+'</td><td>'+e.front+'</td><td>'+e.rear+'</td><td>'+e.coolant+'</td><td>'+e.rpm+'</td>';
    if(e.note) tr.style.background='#241d05';
    tb.appendChild(tr);
  }});
}}

async function poll(){{
  try{{
    const res=await fetch('?json=1&gen='+encodeURIComponent(gen),{{cache:'no-store'}});
    render(await res.json());
  }}catch(e){{ document.getElementById('status').textContent='network error'; }}
}}
async function mark(txt){{
  try{{
    const res=await fetch('?json=1&gen='+encodeURIComponent(gen)+'&mark='+encodeURIComponent(txt),{{cache:'no-store'}});
    render(await res.json());
    if(navigator.vibrate) navigator.vibrate(40);
  }}catch(e){{}}
}}
function customMark(){{ const t=prompt('Note to log:'); if(t) mark(t); }}
function switchGen(){{ gen=document.getElementById('gen').value; poll(); }}

poll(); timer=setInterval(poll,1300);
document.addEventListener('visibilitychange',()=>{{
  if(document.hidden){{ clearInterval(timer); }} else {{ poll(); timer=setInterval(poll,1300); }}
}});
</script>
</body></html>""")
