#!/usr/bin/env python3
import concurrent.futures as cf
import gzip
import ipaddress
import json
import os
import re
import socket
import ssl
import subprocess
import sys
import time
import urllib.request
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Dict, List

LOG_DIR = Path(os.environ.get("WIFI_PRESENCE_LOG_DIR", "/home/sebas/runtime/agent-logs/wifi-presence"))
STATE_PATH = Path(os.environ.get("WIFI_PRESENCE_STATE", str(LOG_DIR / "state.json")))
LOG_PATH = Path(os.environ.get("WIFI_PRESENCE_LOG", str(LOG_DIR / "activity.jsonl")))
ALIASES_PATH = Path(os.environ.get("WIFI_PRESENCE_ALIASES", str(LOG_DIR / "aliases.json")))
FINGERPRINTS_PATH = Path(os.environ.get("WIFI_PRESENCE_FINGERPRINTS", str(LOG_DIR / "fingerprints.json")))
ALERT_PREFS_PATH = Path(os.environ.get("WIFI_PRESENCE_ALERT_PREFS", str(LOG_DIR / "alert-prefs.json")))
INVENTORY_PATH = Path(os.environ.get("WIFI_PRESENCE_INVENTORY", str(LOG_DIR / "inventory.json")))
DAILY_SUMMARY_PATH = Path(os.environ.get("WIFI_PRESENCE_DAILY_SUMMARY", str(LOG_DIR / "daily-summary.json")))
SUMMARY_PATH = Path(os.environ.get("WIFI_PRESENCE_SUMMARY", "/var/www/wifi-tracker/summary.json"))
PING_WORKERS = int(os.environ.get("WIFI_PRESENCE_PING_WORKERS", "32"))
HOSTNAME_TIMEOUT = float(os.environ.get("WIFI_PRESENCE_HOSTNAME_TIMEOUT", "0.35"))
SUMMARY_TAIL_LINES = int(os.environ.get("WIFI_PRESENCE_SUMMARY_TAIL_LINES", "20000"))
ROTATE_MAX_BYTES = int(os.environ.get("WIFI_PRESENCE_ROTATE_MAX_BYTES", str(4 * 1024 * 1024)))
ROTATE_KEEP = int(os.environ.get("WIFI_PRESENCE_ROTATE_KEEP", "0"))
OFFLINE_GAP_MINUTES = int(os.environ.get("WIFI_PRESENCE_OFFLINE_GAP_MINUTES", "5"))


def sh(cmd: str) -> str:
    return subprocess.check_output(cmd, shell=True, text=True, stderr=subprocess.DEVNULL).strip()


def now() -> str:
    return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())


def parse_ts(ts: str) -> datetime:
    return datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)


def ensure_paths() -> None:
    LOG_DIR.mkdir(parents=True, exist_ok=True)
    SUMMARY_PATH.parent.mkdir(parents=True, exist_ok=True)
    if not STATE_PATH.exists():
        STATE_PATH.write_text('{"devices": {}}\n')
    if not ALIASES_PATH.exists():
        ALIASES_PATH.write_text('{}\n')
    if not FINGERPRINTS_PATH.exists():
        FINGERPRINTS_PATH.write_text('{}\n')
    if not ALERT_PREFS_PATH.exists():
        ALERT_PREFS_PATH.write_text('{"new_device_alerts": true, "devices": {}}\n')
    if not INVENTORY_PATH.exists():
        INVENTORY_PATH.write_text('{"devices": {}}\n')
    if not DAILY_SUMMARY_PATH.exists():
        DAILY_SUMMARY_PATH.write_text('{"updated_at": null, "devices": {}}\n')


def rotate_logs() -> None:
    if not LOG_PATH.exists() or LOG_PATH.stat().st_size < ROTATE_MAX_BYTES:
        return
    ts = time.strftime("%Y%m%d-%H%M%S", time.gmtime())
    rotated = LOG_DIR / f"activity-{ts}.jsonl"
    compressed = LOG_DIR / f"activity-{ts}.jsonl.gz"
    LOG_PATH.replace(rotated)
    with rotated.open("rb") as src, gzip.open(compressed, "wb", compresslevel=6) as dst:
        dst.writelines(src)
    rotated.unlink(missing_ok=True)
    if ROTATE_KEEP > 0:
        old = sorted(list(LOG_DIR.glob("activity-*.jsonl")) + list(LOG_DIR.glob("activity-*.jsonl.gz")))
        for path in old[:-ROTATE_KEEP]:
            try:
                path.unlink()
            except FileNotFoundError:
                pass


def load_json(path: Path, default):
    try:
        return json.loads(path.read_text())
    except Exception:
        return default


def load_state() -> Dict:
    return load_json(STATE_PATH, {"devices": {}})


def load_aliases() -> Dict:
    data = load_json(ALIASES_PATH, {})
    return {k.lower(): v for k, v in data.items() if isinstance(v, str)}


def load_fingerprints() -> Dict:
    return load_json(FINGERPRINTS_PATH, {})


def load_alert_prefs() -> Dict:
    return load_json(ALERT_PREFS_PATH, {"new_device_alerts": True, "devices": {}})


def load_inventory() -> Dict:
    return load_json(INVENTORY_PATH, {"devices": {}})


def load_daily_summary() -> Dict:
    return load_json(DAILY_SUMMARY_PATH, {"updated_at": None, "devices": {}})


def save_inventory(data: Dict):
    write_json_atomic(INVENTORY_PATH, data)


def save_daily_summary(data: Dict):
    write_json_atomic(DAILY_SUMMARY_PATH, data)


def save_state(state: Dict) -> None:
    tmp = STATE_PATH.with_suffix(".tmp")
    tmp.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n")
    tmp.replace(STATE_PATH)


def write_json_atomic(path: Path, data) -> None:
    tmp = path.with_suffix(".tmp")
    tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n")
    tmp.replace(path)


def log_event(event: Dict) -> None:
    rotate_logs()
    event.setdefault("ts", now())
    with LOG_PATH.open("a") as f:
        f.write(json.dumps(event, sort_keys=True) + "\n")


def default_gateway() -> str:
    return sh("ip route | awk '/default/ {print $3; exit}'")


def default_iface() -> str:
    return sh("ip route | awk '/default/ {print $5; exit}'")


def iface_cidr(iface: str) -> str:
    return sh(f"ip -o -f inet addr show dev {iface} | awk '{{print $4; exit}}'")


def network_cidr(cidr: str) -> str:
    return str(ipaddress.ip_interface(cidr).network)


def local_ip(iface: str) -> str:
    out = sh(f"ip -o -4 addr show dev {iface}")
    return out.split()[3].split("/")[0]


def ping_host(ip: str) -> None:
    subprocess.run(["ping", "-c", "1", "-W", "1", ip], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)


def sweep_network(cidr: str, self_ip: str) -> None:
    net = ipaddress.ip_network(cidr, strict=False)
    hosts: List[str] = [str(h) for h in net.hosts() if str(h) != self_ip]
    with cf.ThreadPoolExecutor(max_workers=PING_WORKERS) as ex:
        list(ex.map(ping_host, hosts))


def arp_entries(iface: str) -> List[Dict]:
    out = sh(f"ip neigh show dev {iface} || true")
    rows = []
    for line in out.splitlines():
        parts = line.split()
        if len(parts) < 4:
            continue
        row = {"ip": parts[0], "mac": None, "state": parts[-1], "iface": iface}
        if "lladdr" in parts:
            i = parts.index("lladdr")
            if i + 1 < len(parts):
                row["mac"] = parts[i + 1].lower()
        rows.append(row)
    return rows


def reverse_name(ip: str):
    try:
        socket.setdefaulttimeout(HOSTNAME_TIMEOUT)
        name, _, _ = socket.gethostbyaddr(ip)
        return name
    except Exception:
        return None


def probe_router(gateway: str) -> Dict:
    out = {"gateway": gateway}
    try:
        with urllib.request.urlopen(f"http://{gateway}/", timeout=4) as r:
            html = r.read(65536).decode("utf-8", "ignore")
        if "Huawei" in html:
            out["vendor"] = "Huawei"
        m = re.search(r"ProductName\s*=\s*'([^']+)'", html)
        if m:
            out["model"] = m.group(1).replace("\\x2d", "-")
    except Exception as e:
        out["probe_error"] = str(e)
    return out


def tcp_open(ip: str, port: int, timeout: float = 0.5) -> bool:
    s = socket.socket()
    s.settimeout(timeout)
    try:
        s.connect((ip, port))
        return True
    except Exception:
        return False
    finally:
        s.close()


def http_sniff(ip: str, port: int = 80, https: bool = False):
    try:
        ctx = ssl._create_unverified_context() if https else None
        with urllib.request.urlopen(("https" if https else "http") + f"://{ip}:{port}/", timeout=1.5, context=ctx) as r:
            data = r.read(1024).decode("utf-8", "ignore")
            return data
    except Exception:
        return None


def private_mac(mac: str) -> bool:
    try:
        return bool(int(mac.split(":")[0], 16) & 0b10)
    except Exception:
        return False


def discover_mdns() -> Dict:
    try:
        out = sh("timeout 8s avahi-browse -arpk 2>/dev/null || true")
    except Exception:
        return {}
    by_ip = defaultdict(list)
    for line in out.splitlines():
        if not line.startswith("="):
            continue
        parts = line.split(";")
        if len(parts) < 9:
            continue
        service_name = parts[3].replace("\\032", " ")
        service_type = parts[4]
        host = parts[6]
        addr = parts[7]
        port = parts[8]
        txt = parts[9:]
        if re.fullmatch(r"\d+\.\d+\.\d+\.\d+", addr):
            by_ip[addr].append({
                "service_name": service_name,
                "service_type": service_type,
                "host": host,
                "port": port,
                "txt": txt,
            })
    return dict(by_ip)


def fingerprint_device(dev: Dict, gw: str, discoveries: Dict) -> Dict:
    ip = dev.get("ip")
    mac = (dev.get("mac") or "").lower()
    prefix = dev.get("oui_prefix") or ""
    ev = {
        "ip": ip,
        "mac": mac,
        "oui_prefix": prefix,
        "private_mac": private_mac(mac),
        "ports_open": [],
        "sources": [],
        "confidence": 0.0,
        "guess_alias": None,
        "evidence": {"mdns": discoveries.get(ip, [])},
    }
    if ip == gw:
        ev["guess_alias"] = None
        return ev
    ports_to_probe = [80, 3000, 49152, 5540, 62078, 6668, 7000, 8008, 8009, 8443]
    for port in ports_to_probe:
        if tcp_open(ip, port):
            ev["ports_open"].append(port)
    mdns = discoveries.get(ip, [])
    mdns_types = [x.get("service_type") for x in mdns]
    mdns_names = [x.get("service_name") for x in mdns]
    if any(t == '_matterc._udp' for t in mdns_types) and prefix == "CC:40:85":
        ev["sources"].append("mdns-matter")
        ev["evidence"]["mdns_types"] = mdns_types
        ev["guess_alias"] = "WiZ / Matter smart light con mDNS Matter [from AI]"
        ev["confidence"] = max(ev["confidence"], 0.98)
    if any(t == '_googlecast._tcp' for t in mdns_types):
        ev["sources"].append("mdns-googlecast")
        ev["evidence"]["mdns_names"] = mdns_names
        if any('Google Home Mini' in n for n in mdns_names):
            ev["guess_alias"] = "Google Home Mini / Google Cast con mDNS [from AI]"
            ev["confidence"] = max(ev["confidence"], 0.99)
        elif any('onn.' in n or 'TV' in n for n in mdns_names):
            ev["guess_alias"] = "Android TV / Google Cast con mDNS [from AI]"
            ev["confidence"] = max(ev["confidence"], 0.97)
    if any(t == '_androidtvremote2._tcp' for t in mdns_types):
        ev["sources"].append("mdns-androidtv")
        ev["guess_alias"] = "Android TV con mDNS remote [from AI]"
        ev["confidence"] = max(ev["confidence"], 0.98)
    if any(t in ('_ssh._tcp','_sftp-ssh._tcp','_companion-link._tcp') for t in mdns_types):
        ev["sources"].append("mdns-apple-mac")
        if any('MacBook' in n or 'Mac' in n for n in mdns_names):
            ev["guess_alias"] = "Apple MacBook con SSH / companion-link [from AI]"
            ev["confidence"] = max(ev["confidence"], 0.96)
    if prefix == "CC:40:85":
        ev["sources"].append("oui-wiz")
        ev["confidence"] = max(ev["confidence"], 0.92)
        ev["guess_alias"] = "WiZ / Matter smart light con vendor WiZ [from AI]"
        ev["evidence"]["vendor"] = "WiZ"
        if 5540 in ev["ports_open"]:
            ev["sources"].append("matter")
            ev["evidence"]["matter_port"] = 5540
            ev["guess_alias"] = "WiZ / Matter smart light con vendor WiZ y Matter [from AI]"
            ev["confidence"] = max(ev["confidence"], 0.97)
    if 6668 in ev["ports_open"]:
        ev["sources"].append("tuya-6668")
        if prefix == "C4:82:E1":
            ev["guess_alias"] = "Tuya smart controller / smart infrared con puerto 6668 [from AI]"
            ev["confidence"] = max(ev["confidence"], 0.93)
        else:
            ev["guess_alias"] = "Tuya smart device con puerto 6668 [from AI]"
            ev["confidence"] = max(ev["confidence"], 0.88)
    if 8008 in ev["ports_open"] and 8009 in ev["ports_open"]:
        data = http_sniff(ip, 8008) or ""
        ev["sources"].append("google-cast")
        ev["evidence"]["http_8008"] = data[:180]
        if "Google Home Mini" in data or "Google-Home-Mini" in data:
            ev["guess_alias"] = "Google Home Mini / Google Cast con eureka_info [from AI]"
            ev["confidence"] = max(ev["confidence"], 0.98)
        elif "build_version" in data or "cast_build_revision" in data or "name" in data:
            ev["guess_alias"] = "Android TV / Google Cast con eureka_info [from AI]"
            ev["confidence"] = max(ev["confidence"], 0.95)
    if prefix == "74:E6:B8" and 3000 in ev["ports_open"]:
        ev["sources"].append("lg-tv")
        ev["guess_alias"] = "LG Smart TV con puertos webOS / AirPlay [from AI]"
        ev["confidence"] = max(ev["confidence"], 0.98)
    if 62078 in ev["ports_open"] and 49152 in ev["ports_open"]:
        ev["sources"].append("apple-lockdownd")
        if ev["private_mac"]:
            ev["guess_alias"] = "Apple iPhone con MAC privada y puertos lockdownd [from AI]"
            ev["confidence"] = max(ev["confidence"], 0.95)
        else:
            ev["guess_alias"] = "Apple iPhone / iOS con puertos lockdownd [from AI]"
            ev["confidence"] = max(ev["confidence"], 0.9)
    if 7000 in ev["ports_open"] and not ev["guess_alias"]:
        ev["sources"].append("airplay")
        ev["guess_alias"] = "Apple / AirPlay device con puerto 7000 [from AI]"
        ev["confidence"] = max(ev["confidence"], 0.75)
    if 80 in ev["ports_open"] and not ev["guess_alias"]:
        data = http_sniff(ip, 80) or ""
        if data:
            ev["sources"].append("http-title")
            ev["evidence"]["http_80"] = data[:180]
    if not ev["guess_alias"] and ev["private_mac"]:
        ev["sources"].append("private-mac-only")
        ev["guess_alias"] = "Dispositivo con MAC privada / probable móvil [from AI]"
        ev["confidence"] = max(ev["confidence"], 0.45)
    return ev


def maybe_autoname(devices: List[Dict], aliases: Dict, gw: str, discoveries: Dict) -> Dict:
    changed = False
    fingerprints = load_fingerprints()
    for dev in devices:
        mac = (dev.get("mac") or "").lower()
        if not mac:
            continue
        fp = fingerprint_device(dev, gw, discoveries)
        existing_alias = aliases.get(mac)
        source = "manual" if existing_alias and "[from AI]" not in existing_alias else "ai"
        fingerprints[mac] = {
            "last_probe_ts": now(),
            "guess_alias": fp.get("guess_alias"),
            "confidence": fp.get("confidence"),
            "sources": fp.get("sources"),
            "evidence": fp.get("evidence"),
            "ports_open": fp.get("ports_open"),
            "ip": dev.get("ip"),
            "oui_prefix": dev.get("oui_prefix"),
            "private_mac": fp.get("private_mac"),
            "alias_source": source,
        }
        if mac in aliases:
            continue
        guessed = fp.get("guess_alias")
        if guessed:
            aliases[mac] = guessed
            changed = True
    write_json_atomic(FINGERPRINTS_PATH, fingerprints)
    if changed:
        write_json_atomic(ALIASES_PATH, aliases)
    return aliases


def normalize_device(row: Dict, gateway: str, aliases: Dict) -> Dict:
    ip = row["ip"]
    mac = row.get("mac")
    hostname = reverse_name(ip)
    roles = ["gateway"] if ip == gateway else []
    state = row.get("state")
    reachable = state in {"REACHABLE", "STALE", "DELAY", "PROBE", "PERMANENT"}
    if state == "FAILED":
        reachable = False
    return {
        "ip": ip,
        "mac": mac,
        "alias": aliases.get(mac),
        "display_name": aliases.get(mac) or hostname or mac,
        "hostname": hostname,
        "iface": row.get("iface"),
        "neighbor_state": state,
        "reachable": reachable,
        "oui_prefix": mac.upper()[0:8] if mac else None,
        "roles": roles,
    }


def build_sessions(events: List[Dict], mac: str) -> List[Dict]:
    mac = mac.lower()
    snapshots = []
    for ev in events:
        if ev.get("event") != "device_snapshot":
            continue
        dev = ev.get("device") or {}
        if (dev.get("mac") or "").lower() != mac:
            continue
        ts = ev.get("ts")
        if ts:
            snapshots.append(parse_ts(ts))
    snapshots.sort()
    if not snapshots:
        return []
    sessions = []
    start = prev = snapshots[0]
    gap = timedelta(minutes=OFFLINE_GAP_MINUTES)
    for dt in snapshots[1:]:
        if dt - prev > gap:
            sessions.append({"start": start, "end": prev})
            start = dt
        prev = dt
    sessions.append({"start": start, "end": prev})
    return sessions


def fill_bucket_series(start: datetime, count: int, step: timedelta, key_fmt: str, values: Dict[str, float]) -> List[Dict]:
    out = []
    for i in range(count):
        dt = start + step * i
        key = dt.strftime(key_fmt)
        out.append({"bucket": key, "count": round(values.get(key, 0), 2)})
    return out


def summarize_presence_timeline(events: List[Dict], mac: str, now_dt: datetime, hours: int = 24, step_minutes: int = 2, offline_misses: int = 3) -> Dict:
    start = (now_dt - timedelta(hours=hours)).replace(second=0, microsecond=0)
    slots = []
    observed = set()
    for ev in events:
        if ev.get("event") != "device_snapshot":
            continue
        dev = ev.get("device") or {}
        if (dev.get("mac") or "").lower() != mac.lower():
            continue
        ts = ev.get("ts")
        if not ts:
            continue
        dt = parse_ts(ts)
        if dt < start or dt > now_dt + timedelta(minutes=step_minutes):
            continue
        minute = (dt.minute // step_minutes) * step_minutes
        bucket = dt.replace(minute=minute, second=0, microsecond=0)
        observed.add(bucket)
    cursor = start.replace(minute=(start.minute // step_minutes) * step_minutes, second=0, microsecond=0)
    state = False
    miss_count = offline_misses
    while cursor <= now_dt:
        seen = cursor in observed
        if seen:
            state = True
            miss_count = 0
        else:
            if state:
                miss_count += 1
                if miss_count >= offline_misses:
                    state = False
        slots.append({"ts": cursor.strftime("%Y-%m-%dT%H:%M:%SZ"), "observed": seen, "online": state})
        cursor += timedelta(minutes=step_minutes)
    segments = []
    seg_start = None
    for slot in slots:
        if slot["online"] and seg_start is None:
            seg_start = slot["ts"]
        if not slot["online"] and seg_start is not None:
            segments.append({"start": seg_start, "end": slot["ts"]})
            seg_start = None
    if seg_start is not None and slots:
        segments.append({"start": seg_start, "end": slots[-1]["ts"]})
    return {"start": start.strftime("%Y-%m-%dT%H:%M:%SZ"), "end": now_dt.strftime("%Y-%m-%dT%H:%M:%SZ"), "step_minutes": step_minutes, "offline_misses": offline_misses, "segments": segments, "slots": slots}


def summarize_activity(events: List[Dict], mac: str, now_dt: datetime) -> Dict:
    hour_values = defaultdict(float)
    day_values = defaultdict(float)
    week_values = defaultdict(float)
    hour_start = (now_dt - timedelta(hours=23)).replace(minute=0, second=0, microsecond=0)
    day_start = (now_dt - timedelta(days=13)).replace(hour=0, minute=0, second=0, microsecond=0)
    week_anchor = (now_dt - timedelta(days=now_dt.weekday())).replace(hour=0, minute=0, second=0, microsecond=0)
    week_start = week_anchor - timedelta(weeks=11)
    sessions = build_sessions(events, mac)

    for session in sessions:
        start = session["start"]
        end = session["end"] + timedelta(minutes=2)
        cursor = max(start, hour_start)
        while cursor < end:
            nxt = min((cursor.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)), end)
            key = cursor.replace(minute=0, second=0, microsecond=0).strftime("%Y-%m-%dT%H:00Z")
            hour_values[key] += max((nxt - cursor).total_seconds() / 3600, 0)
            cursor = nxt
        cursor = max(start, day_start)
        while cursor < end:
            nxt = min((cursor.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1)), end)
            key = cursor.strftime("%Y-%m-%d")
            day_values[key] += max((nxt - cursor).total_seconds() / 3600, 0)
            cursor = nxt
        cursor = max(start, week_start)
        while cursor < end:
            wk = (cursor - timedelta(days=cursor.weekday())).replace(hour=0, minute=0, second=0, microsecond=0)
            nxt = min(wk + timedelta(weeks=1), end)
            key = wk.strftime("%Y-%m-%d")
            week_values[key] += max((nxt - cursor).total_seconds() / 3600, 0)
            cursor = nxt

    return {
        "hour": fill_bucket_series(hour_start, 24, timedelta(hours=1), "%Y-%m-%dT%H:00Z", hour_values),
        "day": fill_bucket_series(day_start, 14, timedelta(days=1), "%Y-%m-%d", day_values),
        "week": fill_bucket_series(week_start, 12, timedelta(weeks=1), "%Y-%m-%d", week_values),
        "sessions": len(sessions),
    }


def event_files() -> List[Path]:
    return sorted(list(LOG_DIR.glob("activity-*.jsonl.gz")) + list(LOG_DIR.glob("activity-*.jsonl")) + ([LOG_PATH] if LOG_PATH.exists() else []))


def iter_events(paths: List[Path]):
    for path in paths:
        opener = gzip.open if path.suffix == ".gz" else open
        try:
            with opener(path, "rt") as fh:
                for line in fh:
                    try:
                        yield json.loads(line)
                    except Exception:
                        continue
        except FileNotFoundError:
            continue


def read_recent_events(limit: int = SUMMARY_TAIL_LINES) -> List[Dict]:
    if not LOG_PATH.exists():
        return []
    try:
        lines = LOG_PATH.read_text().splitlines()[-limit:]
    except Exception:
        return []
    events = []
    for line in lines:
        try:
            events.append(json.loads(line))
        except Exception:
            continue
    return events


def hydrate_inventory_from_events(inventory: Dict, aliases: Dict):
    inv_devices = inventory.setdefault("devices", {})
    for ev in read_recent_events():
        dev = ev.get("device") or {}
        mac = (dev.get("mac") or "").lower()
        if not mac:
            continue
        prev_inv = inv_devices.get(mac, {})
        merged = dict(prev_inv)
        merged.update({k: v for k, v in dev.items() if v is not None})
        merged["alias"] = aliases.get(mac, merged.get("alias"))
        merged["display_name"] = merged.get("alias") or merged.get("hostname") or merged.get("mac") or merged.get("ip")
        merged["first_seen_ts"] = prev_inv.get("first_seen_ts") or ev.get("ts") or dev.get("first_seen_ts")
        merged["last_seen_ts"] = ev.get("ts") or dev.get("last_seen_ts") or prev_inv.get("last_seen_ts")
        inv_devices[mac] = merged
    return inventory


def update_daily_summary(all_events: List[Dict], aliases: Dict, inventory: Dict) -> Dict:
    out = {"updated_at": now(), "devices": {}}
    grouped = defaultdict(list)
    for ev in all_events:
        if ev.get("event") != "device_snapshot":
            continue
        dev = ev.get("device") or {}
        mac = (dev.get("mac") or "").lower()
        ts = ev.get("ts")
        if not mac or not ts:
            continue
        grouped[mac].append(parse_ts(ts))
    inv_devices = inventory.get("devices") or {}
    for mac, times in grouped.items():
        times.sort()
        alias = aliases.get(mac) or (inv_devices.get(mac) or {}).get("alias") or (inv_devices.get(mac) or {}).get("display_name") or mac
        sessions = build_sessions([{"event": "device_snapshot", "device": {"mac": mac}, "ts": t.strftime("%Y-%m-%dT%H:%M:%SZ")} for t in times], mac)
        by_day = defaultdict(lambda: {"hours": 0.0, "sessions": 0, "first_seen_ts": None, "last_seen_ts": None})
        for session in sessions:
            start = session["start"]
            end = session["end"] + timedelta(minutes=2)
            first_day = start.strftime("%Y-%m-%d")
            by_day[first_day]["sessions"] += 1
            cursor = start
            while cursor < end:
                day_start = cursor.replace(hour=0, minute=0, second=0, microsecond=0)
                nxt = min(day_start + timedelta(days=1), end)
                key = day_start.strftime("%Y-%m-%d")
                row = by_day[key]
                row["hours"] += max((nxt - cursor).total_seconds() / 3600, 0)
                ts_start = cursor.strftime("%Y-%m-%dT%H:%M:%SZ")
                ts_end = nxt.strftime("%Y-%m-%dT%H:%M:%SZ")
                row["first_seen_ts"] = min(filter(None, [row["first_seen_ts"], ts_start])) if row["first_seen_ts"] else ts_start
                row["last_seen_ts"] = max(filter(None, [row["last_seen_ts"], ts_end])) if row["last_seen_ts"] else ts_end
                cursor = nxt
        out["devices"][mac] = {
            "alias": alias,
            "first_seen_ts": times[0].strftime("%Y-%m-%dT%H:%M:%SZ"),
            "last_seen_ts": times[-1].strftime("%Y-%m-%dT%H:%M:%SZ"),
            "days": {day: {**vals, "hours": round(vals["hours"], 2)} for day, vals in sorted(by_day.items())},
        }
    save_daily_summary(out)
    return out


def build_summary(devices: List[Dict], state: Dict, inventory: Dict, iface: str, gw: str, cidr: str, router: Dict, aliases: Dict, alert_prefs: Dict, daily_summary: Dict) -> Dict:
    now_ts = now()
    now_dt = parse_ts(now_ts)
    active_total = len([d for d in devices if d.get("online")])
    named = sum(1 for d in devices if d.get("hostname"))
    gateway_mac = next((d.get("mac") for d in devices if "gateway" in d.get("roles", [])), None)
    vendors = {}
    timeline = []
    alert_events = []
    events = read_recent_events()
    for ev in events:
        et = ev.get("event")
        ts = ev.get("ts")
        dev = ev.get("device") or {}
        mac = (dev.get("mac") or "").lower() or None
        if et in {"device_online", "device_offline", "new_device_detected"} and ts:
            inv = (inventory.get("devices") or {}).get(mac or "", {})
            row = {
                "ts": ts,
                "event": et,
                "ip": dev.get("ip") or inv.get("ip"),
                "mac": mac,
                "alias": aliases.get(mac) if mac else inv.get("alias"),
                "display_name": aliases.get(mac) if mac else None,
            }
            row["display_name"] = row["alias"] or inv.get("display_name") or dev.get("display_name") or dev.get("hostname") or mac or row.get("ip")
            timeline.append(row)
            enabled = alert_prefs.get("devices", {}).get(mac, {}).get("alerts_enabled", True) if mac else True
            if et == 'new_device_detected':
                if alert_prefs.get("new_device_alerts", True):
                    alert_events.append(row)
            elif et in {'device_online', 'device_offline'} and enabled:
                alert_events.append(row)
    for d in devices:
        prefix = d.get("oui_prefix") or "unknown"
        vendors[prefix] = vendors.get(prefix, 0) + 1
        d["activity"] = summarize_activity(events, d["mac"], now_dt)
        d["presence_timeline"] = summarize_presence_timeline(events, d["mac"], now_dt)
    devices.sort(key=lambda d: (not d.get("online", False), tuple(int(x) for x in d.get("ip", "255.255.255.255").split(".")) if "." in d.get("ip", "") else (255,255,255,255)))
    summary = {
        "updated_at": now_ts,
        "iface": iface,
        "gateway": gw,
        "cidr": cidr,
        "router": router,
        "totals": {
            "devices_now": active_total,
            "named_devices": named,
            "known_devices": len((inventory.get("devices") or {})),
            "aliased_devices": len([k for k, v in aliases.items() if v]),
        },
        "aliases": aliases,
        "devices": devices,
        "vendors": [{"name": k, "count": v} for k, v in sorted(vendors.items(), key=lambda x: (-x[1], x[0]))],
        "recent_events": timeline[-100:],
        "recent_alerts": alert_events[-100:],
        "daily_summary": daily_summary,
        "retention": {
            "rotate_max_bytes": ROTATE_MAX_BYTES,
            "rotate_keep": ROTATE_KEEP,
            "rotated_compression": "gzip",
        },
        "highlights": {"gateway_mac": gateway_mac},
    }
    write_json_atomic(SUMMARY_PATH, summary)
    return summary


def main() -> int:
    ensure_paths()
    started = time.time()
    iface = default_iface()
    gw = default_gateway()
    iface_addr = iface_cidr(iface)
    cidr = network_cidr(iface_addr)
    self_ip = local_ip(iface)
    aliases = load_aliases()

    log_event({"event": "cycle_start", "iface": iface, "gateway": gw, "cidr": cidr, "iface_addr": iface_addr, "self_ip": self_ip, "ping_workers": PING_WORKERS})
    router = probe_router(gw)
    alert_prefs = load_alert_prefs()
    discoveries = discover_mdns()
    log_event({"event": "router_probe", "router": router})

    sweep_network(cidr, self_ip)
    rows = arp_entries(iface)
    raw_devices = [normalize_device(r, gw, aliases) for r in rows if r.get("mac") and "." in r.get("ip", "")]
    aliases = maybe_autoname(raw_devices, aliases, gw, discoveries)
    devices = [normalize_device(r, gw, aliases) for r in rows if r.get("mac") and "." in r.get("ip", "")]
    devices.sort(key=lambda d: tuple(int(x) for x in d["ip"].split(".")))

    state = load_state()
    inventory = hydrate_inventory_from_events(load_inventory(), aliases)
    prev = state.get("devices", {})
    current = {}

    for dev in devices:
        mac = dev["mac"]
        current[mac] = dev
        dev_state = prev.get(mac, {})
        event_base = {"iface": iface, "gateway": gw, "device": dev}
        log_event({"event": "device_snapshot", **event_base})
        if mac not in prev and alert_prefs.get("new_device_alerts", True):
            log_event({"event": "new_device_detected", **event_base})
        if not dev_state.get("online") and alert_prefs.get("devices", {}).get(mac, {}).get("alerts_enabled", True):
            log_event({"event": "device_online", **event_base})

    for mac, old in prev.items():
        if mac not in current and old.get("online"):
            if alert_prefs.get("devices", {}).get(mac, {}).get("alerts_enabled", True):
                log_event({"event": "device_offline", "iface": iface, "gateway": gw, "device": old})

    new_state = {"devices": {}}
    seen_ts = now()
    all_devices = []
    for mac, dev in current.items():
        dev["online"] = True
        dev["last_seen_ts"] = seen_ts
        dev["first_seen_ts"] = prev.get(mac, {}).get("first_seen_ts") or seen_ts
        new_state["devices"][mac] = dev
        all_devices.append(dev)
    for mac, old in prev.items():
        if mac in current:
            continue
        offline = dict(old)
        offline["online"] = False
        offline["alias"] = aliases.get(mac, offline.get("alias"))
        offline["display_name"] = offline["alias"] or offline.get("hostname") or offline.get("mac")
        new_state["devices"][mac] = offline
        all_devices.append(offline)
    inv_devices = inventory.setdefault("devices", {})
    for dev in all_devices:
        mac = dev.get("mac")
        if not mac:
            continue
        prev_inv = inv_devices.get(mac, {})
        merged = dict(prev_inv)
        merged.update({k: v for k, v in dev.items() if v is not None})
        merged["alias"] = aliases.get(mac, merged.get("alias"))
        merged["display_name"] = merged.get("alias") or merged.get("hostname") or merged.get("mac") or merged.get("ip")
        merged["first_seen_ts"] = prev_inv.get("first_seen_ts") or dev.get("first_seen_ts") or now()
        if dev.get("last_seen_ts"):
            merged["last_seen_ts"] = dev.get("last_seen_ts")
        inv_devices[mac] = merged
    save_state(new_state)
    save_inventory(inventory)
    all_events = list(iter_events(event_files()))
    daily_summary = update_daily_summary(all_events, aliases, inventory)
    inventory_devices = list(inv_devices.values())
    inventory_devices.sort(key=lambda d: (not d.get("online", False), tuple(int(x) for x in d.get("ip", "255.255.255.255").split(".")) if "." in d.get("ip", "") else (255,255,255,255)))
    build_summary(inventory_devices, new_state, inventory, iface, gw, cidr, router, aliases, alert_prefs, daily_summary)

    log_event({"event": "cycle_end", "iface": iface, "gateway": gw, "seen_devices": len(current), "duration_sec": round(time.time() - started, 2)})
    return 0


if __name__ == "__main__":
    sys.exit(main())
