#!/usr/bin/env python3
"""
ble_alerter.py — BLE presence alerter daemon.
Passively scans for BLE devices by name, sends arrival/departure alerts to Matrix.
Replaces net_alerter's physical presence detection via passive BLE instead of ARP.

Zero active scanning. No SCAN_REQ packets transmitted. No DNS lookups.
"""

import asyncio
import json
import logging
import os
import re
import signal
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from bleak import BleakScanner, BleakClient


# --- Global config (loaded from .env) ---
MODE = "open"  # open | known
KNOWN_DEVICES = []  # Only used if MODE=="known"
DEPARTURE_TIMEOUT = 60
RSSI_MIN = -100
MATRIX_HOMESERVER = ""
MATRIX_ACCESS_TOKEN = ""
MATRIX_ROOM_ID = ""
LOG_FILE = "/opt/ble_alerter/ble_alerter.log"
LOG_LEVEL = "INFO"
GATT_PROBE = False  # Enable active GATT Device Name probing for Apple devices
GATT_NAMES_FILE = "/opt/net_alerter/ble_names.json"

APPLE_COMPANY_ID = 76  # 0x004C

# --- State ---
tracked = {}  # {device_key: {name, mac, first_seen, last_seen, rssi}}
tracked_lock = asyncio.Lock()
shutdown_event = asyncio.Event()
gatt_cache = {}        # {mac: {name, last_seen}} — persists discovered GATT Device Names
gatt_probed = set()    # MACs already probed this session (avoid repeat attempts)
gatt_lock = asyncio.Lock()
apple_last_seen = {}   # {mac: float} — last time each Apple BLE device was seen


def setup_logging():
    """Configure logging to file and stderr."""
    log_format = "%(asctime)s [%(levelname)s] %(message)s"
    log_level = getattr(logging, LOG_LEVEL.upper(), logging.INFO)
    
    handlers = [logging.StreamHandler(sys.stderr)]
    try:
        handlers.append(logging.FileHandler(LOG_FILE, mode='a'))
    except Exception as e:
        logging.warning(f"Could not open {LOG_FILE}: {e}")
    
    logging.basicConfig(
        level=log_level,
        format=log_format,
        handlers=handlers
    )


def load_env():
    """Parse .env file manually (key=value), no dependency required."""
    global MODE, KNOWN_DEVICES, DEPARTURE_TIMEOUT, RSSI_MIN
    global MATRIX_HOMESERVER, MATRIX_ACCESS_TOKEN, MATRIX_ROOM_ID, LOG_LEVEL
    global GATT_PROBE
    
    env_path = Path(os.getenv("BLE_ALERTER_ENV", "/opt/ble_alerter/.env"))

    if not env_path.exists():
        logging.warning(f".env not found at {env_path}, using env vars only")
        MODE = os.getenv("MODE", "open")
        KNOWN_DEVICES = [d.strip() for d in os.getenv("KNOWN_DEVICES", "").split(",") if d.strip()]
        DEPARTURE_TIMEOUT = int(os.getenv("DEPARTURE_TIMEOUT", "60"))
        RSSI_MIN = int(os.getenv("RSSI_MIN", "-100"))
        MATRIX_HOMESERVER = os.getenv("MATRIX_HOMESERVER", "")
        MATRIX_ACCESS_TOKEN = os.getenv("MATRIX_ACCESS_TOKEN", "")
        MATRIX_ROOM_ID = os.getenv("MATRIX_ROOM_ID", "")
        LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
        GATT_PROBE = os.getenv("GATT_PROBE", "0") == "1"
        return
    
    try:
        for line in env_path.read_text().splitlines():
            line = line.strip()
            if not line or line.startswith("#"):
                continue
            if "=" not in line:
                continue
            key, val = line.split("=", 1)
            val = val.strip()
            
            if key == "MODE":
                MODE = val
            elif key == "KNOWN_DEVICES":
                KNOWN_DEVICES = [d.strip() for d in val.split(",") if d.strip()]
            elif key == "DEPARTURE_TIMEOUT":
                try:
                    DEPARTURE_TIMEOUT = int(val)
                except ValueError:
                    logging.warning(f"Invalid DEPARTURE_TIMEOUT: {val}, using default 60")
                    DEPARTURE_TIMEOUT = 60
            elif key == "RSSI_MIN":
                try:
                    RSSI_MIN = int(val)
                except ValueError:
                    logging.warning(f"Invalid RSSI_MIN: {val}, using default -100")
                    RSSI_MIN = -100
            elif key == "MATRIX_HOMESERVER":
                MATRIX_HOMESERVER = val
            elif key == "MATRIX_ACCESS_TOKEN":
                MATRIX_ACCESS_TOKEN = val
            elif key == "MATRIX_ROOM_ID":
                MATRIX_ROOM_ID = val
            elif key == "LOG_LEVEL":
                LOG_LEVEL = val
            elif key == "GATT_PROBE":
                GATT_PROBE = val == "1"

        logging.info(f"Loaded config from {env_path}")
    except Exception as e:
        logging.error(f"Failed to load .env: {e}")


_MAC_RE = re.compile(r'^[0-9A-Fa-f]{2}[:\-][0-9A-Fa-f]{2}[:\-][0-9A-Fa-f]{2}[:\-][0-9A-Fa-f]{2}[:\-][0-9A-Fa-f]{2}[:\-][0-9A-Fa-f]{2}$')


def is_generic_name(name):
    """
    Filter out unhelpful device names (generic manufacturer defaults, hex-only, too short).
    Return True if name should be ignored.
    """
    if not name or not isinstance(name, str):
        return True

    name = name.strip()

    # Too short
    if len(name) < 3:
        return True

    # All hex characters (looks like MAC address fragment)
    if all(c in "0123456789ABCDEFabcdef" for c in name):
        return True

    # MAC address format with colons or dashes (e.g. 45-48-39-7F-73-C7 or 45:48:39:7F:73:C7)
    if _MAC_RE.match(name):
        return True

    # Common generic patterns
    generic_patterns = [
        "LE-", "BLE-", "BT-", "BT ", "Bluetooth", "Unknown",
        "Device", "Object", "Beacon", "Sensor", "Module"
    ]
    for pattern in generic_patterns:
        if pattern.lower() in name.lower():
            return True

    return False


def fmt_duration(secs):
    """Format duration in seconds to human-readable format."""
    if secs < 60:
        return f"{int(secs)}s"
    elif secs < 3600:
        m = int(secs // 60)
        s = int(secs % 60)
        return f"{m}m {s}s" if s else f"{m}m"
    else:
        h = int(secs // 3600)
        m = int((secs % 3600) // 60)
        return f"{h}h {m}m" if m else f"{h}h"


def send_alert(msg):
    """Send alert to Matrix room. Retries up to 3 times with exponential backoff on 429/5xx."""
    if not MATRIX_ACCESS_TOKEN:
        logging.warning("No MATRIX_ACCESS_TOKEN set, skipping alert")
        return

    url = (
        f"{MATRIX_HOMESERVER}/_matrix/client/v3/rooms/"
        f"{urllib.parse.quote(MATRIX_ROOM_ID, safe='')}/send/m.room.message"
    )
    payload = json.dumps({"msgtype": "m.text", "body": msg}).encode()

    for attempt in range(3):
        req = urllib.request.Request(
            url,
            data=payload,
            headers={
                "Authorization": f"Bearer {MATRIX_ACCESS_TOKEN}",
                "Content-Type": "application/json",
                "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36",
            },
            method="POST",
        )
        try:
            with urllib.request.urlopen(req, timeout=10) as r:
                pass
            logging.debug("Alert sent to Matrix")
            return
        except urllib.error.HTTPError as e:
            if e.code in (429, 500, 502, 503, 504) and attempt < 2:
                wait = 2 ** attempt  # 1s, 2s
                logging.warning(f"Matrix HTTP {e.code}, retrying in {wait}s")
                time.sleep(wait)
            else:
                logging.error(f"Matrix send failed with HTTP {e.code}")
                return
        except Exception as e:
            logging.error(f"Matrix send exception: {e}")
            return


async def _probe_gatt_name(mac: str) -> str:
    """Try to read Device Name (0x2A00) via GATT. Returns name or '' on failure."""
    try:
        async with BleakClient(mac, timeout=5.0) as client:
            data = await client.read_gatt_char("00002a00-0000-1000-8000-00805f9b34fb")
            return data.decode("utf-8", errors="replace").strip()
    except Exception:
        return ""


def _write_gatt_file() -> None:
    """Persist gatt_cache to GATT_NAMES_FILE. Caller must hold gatt_lock."""
    try:
        Path(GATT_NAMES_FILE).parent.mkdir(parents=True, exist_ok=True)
        Path(GATT_NAMES_FILE).write_text(json.dumps(gatt_cache, indent=2))
    except Exception as e:
        logging.debug(f"Could not write {GATT_NAMES_FILE}: {e}")


async def _maybe_probe_apple(device, adv_data) -> None:
    """Track Apple device last_seen; if GATT_PROBE enabled, attempt Device Name read."""
    mac = device.address
    if APPLE_COMPANY_ID not in adv_data.manufacturer_data:
        return

    now = time.time()
    async with gatt_lock:
        # Update last_seen for all Apple devices regardless of GATT
        entry = gatt_cache.get(mac)
        if entry:
            entry["last_seen"] = now
        else:
            gatt_cache[mac] = {"name": "", "last_seen": now}

        if not GATT_PROBE or mac in gatt_probed:
            return
        gatt_probed.add(mac)

    name = await _probe_gatt_name(mac)
    if not name:
        return

    logging.info(f"[BLE-GATT] {mac} → {name!r}")
    async with gatt_lock:
        gatt_cache[mac]["name"] = name
        _write_gatt_file()


async def on_arrival(name, mac, rssi):
    """Handle BLE device arrival."""
    async with tracked_lock:
        now = time.time()
        key = name if name else mac
        
        if key not in tracked:
            # New device
            tracked[key] = {
                "name": name,
                "mac": mac,
                "first_seen": now,
                "last_seen": now,
                "rssi": rssi,
            }
            msg = f"[BLE] ARRIVED: {name} (RSSI: {rssi})" if name else f"[BLE] ARRIVED: {mac} (RSSI: {rssi})"
            logging.info(msg)
            send_alert(msg)
        else:
            # Update existing device
            tracked[key]["last_seen"] = now
            tracked[key]["rssi"] = rssi


async def on_departure(device_key):
    """Handle BLE device departure."""
    async with tracked_lock:
        if device_key in tracked:
            device = tracked[device_key]
            duration = time.time() - device["first_seen"]
            duration_str = fmt_duration(duration)
            name = device.get("name") or device.get("mac")
            msg = f"[BLE] DEPARTED: {name} — present {duration_str}"
            logging.info(msg)
            send_alert(msg)
            del tracked[device_key]


async def scan_loop():
    """Passive BLE scanner. Continuously monitors for new advertisements."""
    logging.info(f"Starting BLE scan loop (mode={MODE}, timeout={DEPARTURE_TIMEOUT}s)")
    
    async def detection_callback(device, advertisement_data):
        """Called on each BLE advertisement."""
        # Get device name (from device or advertisement)
        name = device.name or advertisement_data.local_name
        mac = device.address
        rssi = device.rssi

        # Always try GATT probe on Apple devices (fire-and-forget, rate limited)
        asyncio.ensure_future(_maybe_probe_apple(device, advertisement_data))

        # Apply detection filters
        if MODE == "known":
            # Only track devices in KNOWN_DEVICES list
            if not name or name not in KNOWN_DEVICES:
                return
        else:  # open mode
            # Filter generic names
            if is_generic_name(name):
                return

        # Apply RSSI filter (if specified)
        if rssi < RSSI_MIN:
            logging.debug(f"RSSI too weak: {name or mac} ({rssi})")
            return

        await on_arrival(name, mac, rssi)
    
    # Active scanner — explicitly required; bleak 0.20 BlueZ backend defaults to passive
    try:
        async with BleakScanner(detection_callback=detection_callback, scanning_mode="active") as scanner:
            try:
                while not shutdown_event.is_set():
                    await asyncio.sleep(1)
            except asyncio.CancelledError:
                logging.info("BLE scanner cancelled")
            except Exception as e:
                logging.error(f"BLE scanner exception: {e}")
    except Exception as e:
        # Catch no-adapter or other initialization errors
        if "No Bluetooth adapters found" in str(e) or "No default Bluetooth adapter" in str(e):
            logging.warning("No Bluetooth adapter found — BLE scanning disabled.")
            shutdown_event.set()
        elif "No discovery started" in str(e):
            # BlueZ teardown race: SIGTERM cancelled the scanner before BleakScanner.__aexit__
            # ran stop(). BlueZ already stopped the scan, so this is harmless.
            logging.debug(f"BlueZ teardown race on shutdown (ignored): {e}")
        else:
            logging.error(f"Failed to initialize BLE scanner: {e}")
            raise


async def timeout_checker():
    """Check for departed devices (no advertisements for DEPARTURE_TIMEOUT seconds)."""
    logging.info(f"Starting timeout checker (interval={DEPARTURE_TIMEOUT}s)")
    
    check_interval = max(10, DEPARTURE_TIMEOUT // 6)  # Check 6x per timeout
    
    while not shutdown_event.is_set():
        try:
            await asyncio.sleep(check_interval)
            
            async with tracked_lock:
                now = time.time()
                departed = []
                
                for device_key, device in list(tracked.items()):
                    time_since_last_seen = now - device["last_seen"]
                    if time_since_last_seen > DEPARTURE_TIMEOUT:
                        departed.append(device_key)
                
                # Send departure alerts (outside lock to avoid contention)
                for device_key in departed:
                    await on_departure(device_key)
        
        except asyncio.CancelledError:
            logging.info("Timeout checker cancelled")
        except Exception as e:
            logging.error(f"Timeout checker exception: {e}")


async def main():
    """Main entry point. Run scanner and timeout checker concurrently."""
    setup_logging()
    load_env()
    
    logging.info(f"BLE Alerter starting (mode={MODE}, departure_timeout={DEPARTURE_TIMEOUT}s)")
    
    # Set up signal handlers for graceful shutdown
    def signal_handler(signum, frame):
        logging.info(f"Received signal {signum}, shutting down")
        shutdown_event.set()
    
    signal.signal(signal.SIGTERM, signal_handler)
    signal.signal(signal.SIGINT, signal_handler)
    
    # Run scanner and timeout checker together
    try:
        scan_task = asyncio.create_task(scan_loop())
        timeout_task = asyncio.create_task(timeout_checker())
        
        await shutdown_event.wait()
        
        # Cancel tasks and wait for cleanup
        scan_task.cancel()
        timeout_task.cancel()
        
        try:
            await asyncio.gather(scan_task, timeout_task)
        except asyncio.CancelledError:
            pass
        
        logging.info("BLE Alerter shutdown complete")
    except Exception as e:
        logging.error(f"Fatal error: {e}", exc_info=True)
        sys.exit(1)


if __name__ == "__main__":
    asyncio.run(main())
