#!/usr/bin/env python3
"""C1812J + Raspberry Pi monitoring; Python stdlib + net-snmp snmpget."""
import csv
import datetime as dt
import json
import os
from pathlib import Path
import re
import shutil
import smtplib
import socket
import ssl
import subprocess
import sys
import time
from email.message import EmailMessage

ROUTER = os.getenv('ROUTER_IP', '192.168.20.1')
COMMUNITY = os.environ['SNMP_COMMUNITY']
BASE = Path('/var/lib/pi-monitor')
LOG = Path('/var/log/pi-monitor')
STATE_FILE = BASE / 'state.json'
CSV_FILE = LOG / 'metrics.csv'
INTERFACES = {1:'Fa0', 6:'Fa2', 8:'Fa4', 16:'Tunnel1', 21:'Vlan20', 23:'Vlan40'}
PHYSICAL = {'Fa0','Fa2','Fa4'}
OIDS = {'uptime':'1.3.6.1.2.1.1.3.0',
        'cpu':'1.3.6.1.4.1.9.9.109.1.1.1.1.8.1'}
for pool in (1,2):
    OIDS[f'mem{pool}_used'] = f'1.3.6.1.4.1.9.9.48.1.1.1.5.{pool}'
    OIDS[f'mem{pool}_free'] = f'1.3.6.1.4.1.9.9.48.1.1.1.6.{pool}'
for index,name in INTERFACES.items():
    OIDS[f'{name}_status'] = f'1.3.6.1.2.1.2.2.1.8.{index}'
    OIDS[f'{name}_in'] = f'1.3.6.1.2.1.31.1.1.1.6.{index}'
    OIDS[f'{name}_out'] = f'1.3.6.1.2.1.31.1.1.1.10.{index}'

FIELDS = ['timestamp','router_ok','router_cpu_pct','router_mem1_pct',
          'router_mem2_pct','pi_load_per_core','pi_mem_pct','pi_disk_pct',
          'pi_temp_c','pi_throttled_hex']
for n in INTERFACES.values():
    FIELDS += [f'{n}_status', f'{n}_in_bps', f'{n}_out_bps']
FIELDS += ['active_alerts']


def atomic_json(path, obj):
    tmp = path.with_suffix('.tmp')
    tmp.write_text(json.dumps(obj, ensure_ascii=False))
    tmp.replace(path)


def load_state():
    try:
        state = json.loads(STATE_FILE.read_text())
        if isinstance(state, dict):
            return state
    except (OSError, ValueError):
        pass
    return {}


def parse_snmp(line, key):
    # -On produces numeric OID and typed values; Timeticks has a parenthesized count.
    m = re.match(r'^\.?(?:\d+\.)+\d+\s+=\s+([\w-]+):\s*(.*)$', line.strip())
    if not m:
        raise ValueError(f'Unexpected SNMP output ({key}): {line!r}')
    typ, val = m.groups()
    if typ.lower() == 'timeticks':
        match = re.search(r'\((\d+)\)', val)
    else:
        match = re.match(r'(-?\d+)\b', val)
    if not match:
        raise ValueError(f'Non-numeric SNMP value ({key}): {line!r}')
    return int(match.group(1))


def poll_router():
    items = list(OIDS.items())
    result = {}
    # Avoid oversized UDP responses on older IOS.
    for start in range(0, len(items), 10):
        batch = items[start:start+10]
        cmd = ['snmpget','-v2c','-c',COMMUNITY,'-t','2','-r','1',
               '-On', ROUTER] + [oid for _,oid in batch]
        proc = subprocess.run(cmd, capture_output=True, text=True, timeout=25)
        if proc.returncode:
            raise RuntimeError(proc.stderr.strip() or 'snmpget failed')
        lines = proc.stdout.strip().splitlines()
        if len(lines) != len(batch):
            raise RuntimeError('SNMP returned unexpected number of values')
        for (key,_), line in zip(batch,lines):
            result[key] = parse_snmp(line,key)
    return result


def pi_metrics():
    info = {}
    for line in Path('/proc/meminfo').read_text().splitlines():
        k,_,v = line.partition(':')
        info[k] = int(v.strip().split()[0])
    disk = shutil.disk_usage('/')
    try:
        temp = round(int(Path('/sys/class/thermal/thermal_zone0/temp').read_text())/1000,1)
    except (OSError, ValueError):
        temp = None
    try:
        proc = subprocess.run(['vcgencmd','get_throttled'],capture_output=True,
                              text=True,timeout=5)
        throttled = int(proc.stdout.strip().split('=')[1],16) if proc.returncode == 0 else None
    except (OSError,ValueError,IndexError,subprocess.TimeoutExpired):
        throttled = None
    return {'pi_load_per_core':round(os.getloadavg()[0]/(os.cpu_count() or 1),2),
            'pi_mem_pct':round(100*(1-info['MemAvailable']/info['MemTotal']),1),
            'pi_disk_pct':round(100*disk.used/disk.total,1),
            'pi_temp_c':temp,
            'pi_throttled_hex':hex(throttled) if throttled is not None else ''},throttled


def severity(value, warning, critical):
    return 'CRITICAL' if value >= critical else 'WARNING' if value >= warning else None


def send_mail(subject, body):
    msg = EmailMessage()
    msg['From'] = os.environ['SMTP_USER']
    msg['To'] = os.environ['ALERT_TO']
    msg['Subject'] = subject
    msg.set_content(body)
    with smtplib.SMTP(os.getenv('SMTP_HOST','smtp.gmail.com'),
                      int(os.getenv('SMTP_PORT','587')), timeout=20) as smtp:
        smtp.ehlo()
        smtp.starttls(context=ssl.create_default_context())
        smtp.ehlo()
        smtp.login(os.environ['SMTP_USER'],os.environ['SMTP_APP_PASSWORD'])
        smtp.send_message(msg)


def main():
    BASE.mkdir(parents=True,exist_ok=True)
    LOG.mkdir(parents=True,exist_ok=True)
    state = load_state()
    counts = state.get('counts',{})
    active = state.get('active',{})
    notified = state.get('notified',{})
    previous = state.get('router_counters',{})
    now = time.time()
    row = {k:'' for k in FIELDS}
    row['timestamp'] = dt.datetime.now(dt.timezone.utc).astimezone().isoformat()
    pi,throttled = pi_metrics()
    row.update(pi)
    desired = dict(active)

    def set_alert(key, level, detail):
        if level:
            desired[key] = {'level':level,'detail':detail}
        else:
            desired.pop(key,None)

    for metric, warn, crit in [('pi_mem_pct',85,95),('pi_disk_pct',80,90)]:
        val = pi[metric]
        set_alert(metric,severity(val,warn,crit),f'{metric}={val}%')
    if pi['pi_temp_c'] is not None:
        set_alert('pi_temp',severity(pi['pi_temp_c'],70,80),f"CPU temperature={pi['pi_temp_c']} C")
    if throttled is not None:
        set_alert('pi_undervoltage','WARNING' if throttled & 1 else None,
                  f'get_throttled={hex(throttled)} (current undervoltage)')
    # Load is not CPU utilization; sustained overload is only a warning.
    counts['pi_load'] = counts.get('pi_load',0)+1 if pi['pi_load_per_core']>=1.5 else 0
    set_alert('pi_load','WARNING' if counts['pi_load']>=5 else None,
              f"1m load/core={pi['pi_load_per_core']}")

    try:
        data = poll_router()
        router_error = None
    except (OSError,RuntimeError,ValueError,subprocess.TimeoutExpired) as e:
        data = None
        router_error = str(e)
    row['router_ok'] = int(data is not None)
    counts['snmp_fail'] = counts.get('snmp_fail',0)+1 if data is None else 0
    set_alert('router_snmp', 'CRITICAL' if counts['snmp_fail']>=3 else
              'WARNING' if counts['snmp_fail']>=2 else None,
              f'SNMP failed {counts["snmp_fail"]} consecutive checks; {router_error or ""}')

    if data is not None:
        row['router_cpu_pct'] = data['cpu']
        for pool in (1,2):
            used,free = data[f'mem{pool}_used'],data[f'mem{pool}_free']
            pct = round(100*used/(used+free),1) if used+free else None
            row[f'router_mem{pool}_pct'] = pct if pct is not None else ''
            if pct is not None:
                set_alert(f'router_mem{pool}',severity(pct,85,95),f'Pool {pool} memory={pct}%')
        counts['router_cpu_warn'] = counts.get('router_cpu_warn',0)+1 if data['cpu']>=80 else 0
        counts['router_cpu_crit'] = counts.get('router_cpu_crit',0)+1 if data['cpu']>=90 else 0
        cpu_level = ('CRITICAL' if counts['router_cpu_crit']>=5 else
                     'WARNING' if counts['router_cpu_warn']>=5 else None)
        set_alert('router_cpu',cpu_level,f'5m CPU average={data["cpu"]}%')

        # Only calculate rates across a reasonable sample interval with continuous uptime.
        elapsed = now-previous.get('timestamp',now)
        uptime_prev = previous.get('uptime')
        valid = (uptime_prev is not None and data['uptime']>uptime_prev
                 and 0<elapsed<=600)
        for index,name in INTERFACES.items():
            status = data[f'{name}_status']
            row[f'{name}_status'] = status
            k = f'{name}_down'
            counts[k] = counts.get(k,0)+1 if status != 1 else 0
            set_alert(k, 'CRITICAL' if counts[k]>=3 else
                      'WARNING' if counts[k]>=1 else None,
                      f'{name} ifOperStatus={status}; consecutive={counts[k]}')
            for direction in ('in','out'):
                key = f'{name}_{direction}'
                old = previous.get(key)
                new = data[key]
                rate = None
                if valid and old is not None and new>=old:
                    rate = round((new-old)*8/elapsed,1)
                row[f'{key}_bps'] = rate if rate is not None else ''
        state['router_counters'] = {'timestamp':now,'uptime':data['uptime'],
             **{f'{name}_{d}':data[f'{name}_{d}']
                for name in INTERFACES.values() for d in ('in','out')}}
    # When router is unreachable, retain device alarms until reachability returns.
    row['active_alerts'] = ';'.join(sorted(desired))
    state.update(counts=counts,active=desired,notified=notified)
    atomic_json(STATE_FILE,state)

    # Use a fixed CSV schema. Roll aside old file from earlier script versions.
    if CSV_FILE.exists() and CSV_FILE.stat().st_size:
        with CSV_FILE.open(newline='') as f:
            header = next(csv.reader(f),[])
        if header != FIELDS:
            raise RuntimeError('CSV header mismatch; rename old metrics.csv first')
    fresh = not CSV_FILE.exists() or CSV_FILE.stat().st_size==0
    with CSV_FILE.open('a',newline='') as f:
        writer = csv.DictWriter(f,fieldnames=FIELDS)
        if fresh: writer.writeheader()
        writer.writerow(row)

    changes = []
    for key in sorted(set(desired)|set(notified)):
        before,after = notified.get(key),desired.get(key)
        if before != after:
            if after is None:
                changes.append(f'[RECOVERY] {key}: cleared (previous: {before["detail"]})')
            else:
                changes.append(f'[{after["level"]}] {key}: {after["detail"]}')
    if changes:
        subject = '[MONITOR] C1812J / Raspberry Pi: ' + str(len(changes)) + ' change(s)'
        body = f'Time: {row["timestamp"]}\nRouter: {ROUTER}\nHost: {socket.gethostname()}\n\n' + '\n'.join(changes)
        try:
            send_mail(subject,body)
        except Exception as exc:
            print(f'EMAIL FAILED (will retry): {exc}',file=sys.stderr)
            sys.exit(1)
        state['notified'] = dict(desired)
        atomic_json(STATE_FILE,state)
    print(json.dumps(row,ensure_ascii=False))

if __name__ == '__main__':
    main()
