#!/usr/bin/env python3
"""
DeathOps Host Agent
Lightweight, zero-external-dependency monitoring & security telemetry agent for Linux systems.
Collects system health, pending security updates, installed package metadata, and reports to DeathOps backend.
"""

import os
import sys
import json
import time
import socket
import platform
import logging
import argparse
import configparser
import subprocess
import urllib.request
import urllib.error
import urllib.parse
from datetime import datetime, timezone

AGENT_VERSION = "1.3.0"
DEFAULT_CONFIG_PATH = "/etc/deathops/agent.conf"
DEFAULT_BACKEND_URL = "https://dev.death-ops.com"
DEFAULT_INTERVAL = 300  # 5 minutes
DEFAULT_STATE_PATH = "/var/lib/deathops/desired.json"

# Feature flags managed centrally (2). The backend sends the desired set in
# every report response; local config only provides the initial default.
DEFAULT_FEATURES = {
    "security": True,   # pending updates + installed packages + CVE scan + TLS certs
    "system": True,     # cpu + memory + systemd failed units + hardening facts
    "disk": True,       # mountpoint/disk telemetry
}

logging.basicConfig(
    format="%(asctime)s [%(levelname)s] [deathops-agent] %(message)s",
    level=logging.INFO
)
logger = logging.getLogger("deathops-agent")


def load_config(config_file):
    """Loads configuration from ini file or environment variables."""
    cfg = {
        "api_key": os.environ.get("DEATHOPS_API_KEY", ""),
        "backend_url": os.environ.get("DEATHOPS_BACKEND_URL", DEFAULT_BACKEND_URL).rstrip("/"),
        "interval_seconds": int(os.environ.get("DEATHOPS_INTERVAL_SECONDS", DEFAULT_INTERVAL)),
        "hostname": os.environ.get("DEATHOPS_HOSTNAME", socket.gethostname()),
        "tags": [t.strip() for t in os.environ.get("DEATHOPS_TAGS", "").split(",") if t.strip()],
        "verify_ssl": os.environ.get("DEATHOPS_VERIFY_SSL", "true").lower() in ("true", "1", "yes"),
        "collect_packages": os.environ.get("DEATHOPS_COLLECT_PACKAGES", "true").lower() in ("true", "1", "yes"),
        "log_level": os.environ.get("DEATHOPS_LOG_LEVEL", "INFO").upper(),
        "state_file": os.environ.get("DEATHOPS_STATE_FILE", DEFAULT_STATE_PATH),
        "features": dict(DEFAULT_FEATURES),
        # TLS monitoring: extra PEM paths (comma-separated globs) + endpoints
        # (host:port) checked via TLS handshake. Let's Encrypt live certs are
        # always scanned when the security feature is enabled.
        "tls_cert_paths": [p.strip() for p in os.environ.get("DEATHOPS_TLS_CERT_PATHS", "").split(",") if p.strip()],
        "tls_endpoints": [p.strip() for p in os.environ.get("DEATHOPS_TLS_ENDPOINTS", "").split(",") if p.strip()],
    }

    if os.path.exists(config_file):
        parser = configparser.ConfigParser()
        try:
            parser.read(config_file)
            if parser.has_section("agent"):
                agent_sec = parser["agent"]
                if "api_key" in agent_sec:
                    cfg["api_key"] = agent_sec["api_key"]
                if "backend_url" in agent_sec:
                    cfg["backend_url"] = agent_sec["backend_url"].rstrip("/")
                if "interval_seconds" in agent_sec:
                    cfg["interval_seconds"] = int(agent_sec["interval_seconds"])
                if "hostname" in agent_sec and agent_sec["hostname"]:
                    cfg["hostname"] = agent_sec["hostname"]
                if "tags" in agent_sec:
                    cfg["tags"] = [t.strip() for t in agent_sec["tags"].split(",") if t.strip()]
                if "verify_ssl" in agent_sec:
                    cfg["verify_ssl"] = agent_sec.getboolean("verify_ssl", fallback=True)
                if "collect_packages" in agent_sec:
                    cfg["collect_packages"] = agent_sec.getboolean("collect_packages", fallback=True)
                if "tls_cert_paths" in agent_sec and not cfg["tls_cert_paths"]:
                    cfg["tls_cert_paths"] = [p.strip() for p in agent_sec["tls_cert_paths"].split(",") if p.strip()]
                if "tls_endpoints" in agent_sec and not cfg["tls_endpoints"]:
                    cfg["tls_endpoints"] = [p.strip() for p in agent_sec["tls_endpoints"].split(",") if p.strip()]
                if "log_level" in agent_sec:
                    cfg["log_level"] = agent_sec["log_level"].upper()
        except Exception as e:
            logger.warning(f"Error reading configuration {config_file}: {e}")

    # Set log level
    log_lvl = getattr(logging, cfg["log_level"], logging.INFO)
    logger.setLevel(log_lvl)
    return cfg


def save_api_key(config_file, new_key):
    """Atomically rewrites api_key in the ini config (1: rotation)."""
    try:
        parser = configparser.ConfigParser()
        if os.path.exists(config_file):
            parser.read(config_file)
        if not parser.has_section("agent"):
            parser.add_section("agent")
        parser.set("agent", "api_key", new_key)
        tmp_path = config_file + ".tmp"
        with open(tmp_path, "w", encoding="utf-8") as f:
            parser.write(f)
        os.replace(tmp_path, config_file)
        try:
            os.chmod(config_file, 0o600)
        except Exception:
            pass
        return True
    except Exception as e:
        logger.error(f"Failed saving rotated API key to {config_file}: {e}")
        return False


def load_desired_state(state_file):
    """Loads the last backend-pushed desired config (2)."""
    try:
        if state_file and os.path.exists(state_file):
            with open(state_file, "r", encoding="utf-8") as f:
                data = json.load(f)
                if isinstance(data, dict):
                    return data
    except Exception as e:
        logger.debug(f"Failed loading desired state {state_file}: {e}")
    return {}


def save_desired_state(state_file, config):
    """Persists the backend-pushed desired config for the next run."""
    if not state_file:
        return
    try:
        parent = os.path.dirname(state_file)
        if parent:
            os.makedirs(parent, exist_ok=True)
        tmp_path = state_file + ".tmp"
        with open(tmp_path, "w", encoding="utf-8") as f:
            json.dump(config, f)
        os.replace(tmp_path, state_file)
        try:
            # May carry OTLP auth headers — same protection as api_key.
            os.chmod(state_file, 0o600)
        except Exception:
            pass
    except Exception as e:
        logger.debug(f"Failed saving desired state {state_file}: {e}")


def effective_features(cfg, desired):
    """Merges backend desired features over local defaults (backend wins)."""
    features = dict(cfg.get("features") or DEFAULT_FEATURES)
    remote = (desired or {}).get("features") if isinstance(desired, dict) else None
    if isinstance(remote, dict):
        for key in ("security", "system", "disk"):
            if key in remote:
                features[key] = bool(remote[key])
    features["telemetry"] = True
    return features


def effective_interval(cfg, desired):
    """Backend-managed interval wins, clamped to 60..3600s."""
    try:
        remote = int((desired or {}).get("interval_seconds", 0)) if isinstance(desired, dict) else 0
    except (TypeError, ValueError):
        remote = 0
    if remote:
        return max(60, min(3600, remote))
    try:
        local = int(cfg.get("interval_seconds") or DEFAULT_INTERVAL)
    except (TypeError, ValueError):
        local = DEFAULT_INTERVAL
    return max(60, min(3600, local))


def effective_otlp(cfg, desired):
    """OTLP export target (opt-in per tenant key). Backend wins, env is fallback.

    Returns {"endpoint": str, "headers": dict} or None when disabled.
    """
    endpoint, headers = "", {}
    if isinstance(desired, dict):
        otlp = desired.get("otlp") or {}
        if isinstance(otlp, dict):
            endpoint = (otlp.get("endpoint") or "").strip()
            if isinstance(otlp.get("headers"), dict):
                headers = {str(k): str(v) for k, v in otlp["headers"].items()
                           if k and isinstance(v, (str, int, float))}
    if not endpoint:
        endpoint = (os.environ.get("DEATHOPS_OTLP_ENDPOINT") or "").strip()
        if endpoint:
            try:
                parsed = json.loads(os.environ.get("DEATHOPS_OTLP_HEADERS", "{}") or "{}")
                if isinstance(parsed, dict):
                    headers = {str(k): str(v) for k, v in parsed.items()
                               if k and isinstance(v, (str, int, float))}
            except Exception:
                pass
    if not endpoint:
        return None
    return {"endpoint": endpoint, "headers": headers}


def _otlp_attr(key, value):
    if isinstance(value, bool):
        return {"key": key, "value": {"boolValue": value}}
    if isinstance(value, int):
        return {"key": key, "value": {"intValue": str(value)}}
    if isinstance(value, float):
        return {"key": key, "value": {"doubleValue": value}}
    return {"key": key, "value": {"stringValue": str(value)}}


def _otlp_gauge(name, description, unit, points):
    """points: list of (value, {attr: val}); ints stay ints, floats doubles."""
    dps = []
    for value, attrs in points:
        if isinstance(value, bool):
            continue
        if isinstance(value, int) and not isinstance(value, float):
            field = {"asInt": str(value)}
        else:
            try:
                field = {"asDouble": float(value)}
            except (TypeError, ValueError):
                continue
        field["timeUnixNano"] = str(int(time.time() * 1e9))
        field["attributes"] = [_otlp_attr(k, v) for k, v in (attrs or {}).items()]
        dps.append(field)
    if not dps:
        return None
    m = {"name": name, "gauge": {"dataPoints": dps}}
    if description:
        m["description"] = description
    if unit:
        m["unit"] = unit
    return m


def build_otlp_metrics(payload):
    """Maps the collected telemetry onto OTel semantic conventions.

    Stdlib-only OTLP/HTTP JSON — no SDK needed, no extra collection cost:
    it only re-encodes what collect_telemetry already measured.
    """
    host = payload.get("host", {}) if isinstance(payload.get("host"), dict) else {}
    metrics = payload.get("metrics", {}) if isinstance(payload.get("metrics"), dict) else {}
    cpu = metrics.get("cpu", {}) if isinstance(metrics.get("cpu"), dict) else {}
    mem = metrics.get("memory", {}) if isinstance(metrics.get("memory"), dict) else {}
    disks = metrics.get("disks", []) if isinstance(metrics.get("disks"), list) else []

    resource_attrs = [
        _otlp_attr("host.name", host.get("hostname", "")),
        _otlp_attr("host.arch", host.get("arch", "")),
        _otlp_attr("os.type", "linux"),
        _otlp_attr("os.name", host.get("os_id", "")),
        _otlp_attr("os.version", host.get("os_version", "")),
        _otlp_attr("service.name", "deathops-agent"),
        _otlp_attr("service.version", payload.get("agent_version", AGENT_VERSION)),
    ]

    out = []
    try:
        cores = int(cpu.get("cores", 0) or 0)
    except (TypeError, ValueError):
        cores = 0
    if cores and "cores" in cpu:
        out.append(_otlp_gauge("system.cpu.logical_count", "Number of logical CPU cores", "{cpu}", [(cores, {})]))
    for key, field in (("load_1m", "1m"), ("load_5m", "5m"), ("load_15m", "15m")):
        if key not in cpu:
            continue
        try:
            out.append(_otlp_gauge("system.cpu.load_average." + field,
                                   "Average CPU load over " + field, "1", [(float(cpu.get(key, 0) or 0), {})]))
        except (TypeError, ValueError):
            pass
    if "usage_pct" in cpu:
        try:
            out.append(_otlp_gauge("system.cpu.utilization", "CPU usage ratio", "1",
                                   [(float(cpu.get("usage_pct", 0) or 0) / 100.0, {})]))
        except (TypeError, ValueError):
            pass

    def _mb_to_b(v):
        try:
            return int(float(v or 0) * 1024 * 1024)
        except (TypeError, ValueError):
            return 0

    mem_total = _mb_to_b(mem.get("total_mb"))
    mem_used = _mb_to_b(mem.get("used_mb"))
    if mem_total > 0:
        out.append(_otlp_gauge("system.memory.usage", "Memory usage by state", "By",
                               [(mem_used, {"state": "used"}),
                                (max(0, mem_total - mem_used), {"state": "free"})]))
        try:
            out.append(_otlp_gauge("system.memory.utilization", "Memory usage ratio", "1",
                                   [(float(mem.get("used_pct", 0) or 0) / 100.0, {})]))
        except (TypeError, ValueError):
            pass
    swap_total = _mb_to_b(mem.get("swap_total_mb"))
    if swap_total > 0:
        swap_used = _mb_to_b(mem.get("swap_used_mb"))
        out.append(_otlp_gauge("system.swap.usage", "Swap usage by state", "By",
                               [(swap_used, {"state": "used"}),
                                (max(0, swap_total - swap_used), {"state": "free"})]))

    for d in disks:
        if not isinstance(d, dict):
            continue
        mp = d.get("mountpoint", "")
        base = {"system.filesystem.mountpoint": mp, "system.device": d.get("filesystem", ""),
                "system.filesystem.type": d.get("fstype", "")}
        try:
            total_b = int(float(d.get("total_gb", 0) or 0) * 1024 ** 3)
            used_b = int(float(d.get("used_gb", 0) or 0) * 1024 ** 3)
        except (TypeError, ValueError):
            continue
        if total_b <= 0:
            continue
        attrs_used = dict(base, **{"state": "used"})
        attrs_free = dict(base, **{"state": "free"})
        out.append(_otlp_gauge("system.filesystem.usage", "Filesystem usage by state", "By",
                               [(used_b, attrs_used), (max(0, total_b - used_b), attrs_free)]))
        try:
            out.append(_otlp_gauge("system.filesystem.utilization", "Filesystem usage ratio", "1",
                                   [(float(d.get("used_pct", 0) or 0) / 100.0, base)]))
        except (TypeError, ValueError):
            pass

    try:
        uptime = int(host.get("uptime_seconds") or 0)
        if uptime > 0 and "uptime_seconds" in host:
            out.append(_otlp_gauge("system.uptime", "Time since boot", "s", [(uptime, {})]))
    except (TypeError, ValueError):
        pass

    out = [m for m in out if m]
    return {
        "resourceMetrics": [{
            "resource": {"attributes": resource_attrs},
            "scopeMetrics": [{
                "scope": {"name": "deathops.agent", "version": payload.get("agent_version", AGENT_VERSION)},
                "metrics": out,
            }],
        }]
    }


def send_otlp(cfg, otlp_cfg, otlp_payload):
    """POSTs OTLP/HTTP JSON to the tenant's endpoint. Fail-soft by design."""
    url = (otlp_cfg or {}).get("endpoint", "")
    if not url:
        return False, 0, "OTLP disabled"
    try:
        data = json.dumps(otlp_payload).encode("utf-8")
    except Exception as e:
        return False, 0, f"encode failed: {e}"
    headers = {"Content-Type": "application/json", "User-Agent": f"deathops-agent/{AGENT_VERSION}"}
    for k, v in ((otlp_cfg or {}).get("headers") or {}).items():
        if k and str(k).lower() not in ("content-type", "content-length", "host"):
            headers[str(k)] = str(v)
    ctx = None
    if not cfg.get("verify_ssl", True):
        import ssl
        ctx = ssl._create_unverified_context()
    try:
        req = urllib.request.Request(url, data=data, headers=headers, method="POST")
        if ctx is not None:
            with urllib.request.urlopen(req, timeout=10, context=ctx) as response:
                return True, response.getcode(), "ok"
        with urllib.request.urlopen(req, timeout=10) as response:
            return True, response.getcode(), "ok"
    except urllib.error.HTTPError as e:
        return False, e.code, f"HTTP {e.code}"
    except Exception as e:
        return False, 0, str(e)[:200]


def maybe_send_otlp(cfg, payload, desired):
    """Best-effort OTLP fan-out of the just-collected telemetry. Never raises."""
    try:
        otlp_cfg = effective_otlp(cfg, desired)
        if not otlp_cfg:
            return
        metrics = build_otlp_metrics(payload)
        n = len(metrics["resourceMetrics"][0]["scopeMetrics"][0]["metrics"])
        ok, code, info = send_otlp(cfg, otlp_cfg, metrics)
        if ok:
            logger.info(f"OTLP export successful ({n} metrics, HTTP {code})")
        else:
            logger.warning(f"OTLP export failed (HTTP {code}): {info}")
    except Exception as e:
        logger.debug(f"OTLP export skipped: {e}")


def get_os_release():
    """Parses /etc/os-release to detect distribution and version."""
    info = {
        "id": "linux",
        "name": "Linux",
        "version_id": "",
        "pretty_name": "Linux",
    }
    if os.path.exists("/etc/os-release"):
        try:
            with open("/etc/os-release", "r", encoding="utf-8") as f:
                for line in f:
                    line = line.strip()
                    if not line or line.startswith("#") or "=" not in line:
                        continue
                    k, v = line.split("=", 1)
                    v = v.strip("\"'")
                    if k == "ID":
                        info["id"] = v.lower()
                    elif k == "NAME":
                        info["name"] = v
                    elif k == "VERSION_ID":
                        info["version_id"] = v
                    elif k == "PRETTY_NAME":
                        info["pretty_name"] = v
        except Exception as e:
            logger.debug(f"Failed parsing /etc/os-release: {e}")
    return info


def get_uptime_and_boot():
    """Returns uptime in seconds and estimated boot datetime."""
    uptime_sec = 0.0
    try:
        with open("/proc/uptime", "r", encoding="utf-8") as f:
            uptime_sec = float(f.readline().split()[0])
    except Exception:
        pass
    boot_timestamp = time.time() - uptime_sec
    boot_time_iso = datetime.fromtimestamp(boot_timestamp, tz=timezone.utc).isoformat()
    return int(uptime_sec), boot_time_iso


def get_cpu_metrics():
    """Calculates CPU usage and load averages."""
    load_1m, load_5m, load_15m = 0.0, 0.0, 0.0
    try:
        load_1m, load_5m, load_15m = os.getloadavg()
    except Exception:
        pass

    cores = os.cpu_count() or 1

    # Approximate instantaneous CPU usage via /proc/stat
    cpu_pct = 0.0
    try:
        def read_stat():
            with open("/proc/stat", "r") as f:
                fields = [float(column) for column in f.readline().strip().split()[1:]]
            idle = fields[3] + fields[4]  # idle + iowait
            total = sum(fields)
            return idle, total

        idle1, total1 = read_stat()
        time.sleep(0.1)
        idle2, total2 = read_stat()
        idle_delta = idle2 - idle1
        total_delta = total2 - total1
        if total_delta > 0:
            cpu_pct = round(100.0 * (1.0 - idle_delta / total_delta), 1)
    except Exception:
        # Fallback to loadavg ratio
        cpu_pct = round(min(100.0, (load_1m / cores) * 100.0), 1)

    return {
        "cores": cores,
        "load_1m": round(load_1m, 2),
        "load_5m": round(load_5m, 2),
        "load_15m": round(load_15m, 2),
        "usage_pct": cpu_pct
    }


def get_memory_metrics():
    """Parses /proc/meminfo for precise RAM and Swap utilization."""
    mem_info = {}
    try:
        with open("/proc/meminfo", "r", encoding="utf-8") as f:
            for line in f:
                parts = line.split(":")
                if len(parts) == 2:
                    key = parts[0].strip()
                    val = parts[1].strip().split()[0]
                    mem_info[key] = int(val)  # values in kB
    except Exception as e:
        logger.debug(f"Failed to read /proc/meminfo: {e}")

    total_kb = mem_info.get("MemTotal", 0)
    avail_kb = mem_info.get("MemAvailable", mem_info.get("MemFree", 0))
    used_kb = max(0, total_kb - avail_kb)
    mem_used_pct = round((used_kb / total_kb * 100.0), 1) if total_kb > 0 else 0.0

    swap_total_kb = mem_info.get("SwapTotal", 0)
    swap_free_kb = mem_info.get("SwapFree", 0)
    swap_used_kb = max(0, swap_total_kb - swap_free_kb)
    swap_used_pct = round((swap_used_kb / swap_total_kb * 100.0), 1) if swap_total_kb > 0 else 0.0

    return {
        "total_mb": round(total_kb / 1024.0, 1),
        "used_mb": round(used_kb / 1024.0, 1),
        "free_mb": round(avail_kb / 1024.0, 1),
        "used_pct": mem_used_pct,
        "swap_total_mb": round(swap_total_kb / 1024.0, 1),
        "swap_used_mb": round(swap_used_kb / 1024.0, 1),
        "swap_used_pct": swap_used_pct
    }


def get_disk_metrics():
    """Collects real filesystem disks, excluding virtual mountpoints."""
    disks = []
    ignored_fstypes = {
        "tmpfs", "devtmpfs", "squashfs", "overlay", "proc", "sysfs", "cgroup",
        "cgroup2", "nsfs", "pstore", "securityfs", "bpf", "debugfs", "autofs",
        "mqueue", "hugetlbfs", "fusectl", "configfs", "ramfs"
    }

    seen_mounts = set()
    try:
        with open("/proc/mounts", "r", encoding="utf-8") as f:
            for line in f:
                parts = line.split()
                if len(parts) >= 3:
                    device = parts[0]
                    mountpoint = parts[1]
                    fstype = parts[2]

                    if fstype in ignored_fstypes:
                        continue
                    if mountpoint.startswith(("/proc", "/sys", "/dev", "/run")) and mountpoint != "/":
                        continue
                    if mountpoint in seen_mounts:
                        continue
                    seen_mounts.add(mountpoint)

                    try:
                        stat = os.statvfs(mountpoint)
                        total_bytes = stat.f_blocks * stat.f_frsize
                        free_bytes = stat.f_bavail * stat.f_frsize
                        used_bytes = total_bytes - (stat.f_bfree * stat.f_frsize)

                        if total_bytes <= 0:
                            continue

                        total_gb = round(total_bytes / (1024.0 ** 3), 2)
                        used_gb = round(used_bytes / (1024.0 ** 3), 2)
                        free_gb = round(free_bytes / (1024.0 ** 3), 2)
                        used_pct = round((used_bytes / total_bytes) * 100.0, 1)

                        disks.append({
                            "mountpoint": mountpoint,
                            "filesystem": device,
                            "fstype": fstype,
                            "total_gb": total_gb,
                            "used_gb": used_gb,
                            "free_gb": free_gb,
                            "used_pct": used_pct
                        })
                    except Exception as err:
                        logger.debug(f"statvfs failed on {mountpoint}: {err}")
    except Exception as e:
        logger.debug(f"Failed to read /proc/mounts: {e}")

    # Sort so root filesystem / comes first
    disks.sort(key=lambda d: (d["mountpoint"] != "/", d["mountpoint"]))
    return disks


def get_failed_services():
    """Detects failed systemd units."""
    failed = []
    try:
        res = subprocess.run(
            ["systemctl", "--failed", "--no-legend", "--plain"],
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
            timeout=10
        )
        if res.returncode == 0:
            for line in res.stdout.strip().splitlines():
                parts = line.split()
                if parts:
                    unit_name = parts[0]
                    failed.append(unit_name)
    except Exception as e:
        logger.debug(f"Failed checking systemd failed units: {e}")
    return failed


def check_reboot_required():
    """Checks if package updates require a system reboot."""
    indicators = [
        "/var/run/reboot-required",
        "/run/reboot-required"
    ]
    return any(os.path.exists(p) for p in indicators)


def check_reboot_required():
    """Checks if package updates require a system reboot."""
    indicators = [
        "/var/run/reboot-required",
        "/run/reboot-required"
    ]
    return any(os.path.exists(p) for p in indicators)


def _parse_cert_date(raw):
    """Parses openssl/notAfter dates like 'Nov  8 12:00:00 2027 GMT'."""
    if not raw:
        return None
    normalized = " ".join(str(raw).split())
    for fmt in ("%b %d %H:%M:%S %Y %Z", "%b %d %H:%M:%S %Y %z"):
        try:
            dt = datetime.strptime(normalized, fmt)
            if dt.tzinfo is None:
                dt = dt.replace(tzinfo=timezone.utc)
            return dt
        except Exception:
            continue
    return None


def _cert_entry(source, subject, issuer, not_after):
    now = datetime.now(timezone.utc)
    days_left = int((not_after - now).total_seconds() // 86400) if not_after else None
    return {
        "source": source,
        "subject": subject or "",
        "issuer": issuer or "",
        "not_after": not_after.isoformat() if not_after else "",
        "days_left": days_left,
    }


def _cert_from_pem_file(path):
    """Reads subject/issuer/expiry via openssl CLI (handles chains)."""
    try:
        res = subprocess.run(
            ["openssl", "x509", "-in", path, "-noout", "-subject", "-issuer", "-enddate"],
            stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=10
        )
        if res.returncode != 0:
            return None
        subject = issuer = not_after_raw = ""
        for line in res.stdout.splitlines():
            if line.startswith("subject="):
                subject = line[len("subject="):].strip()
            elif line.startswith("issuer="):
                issuer = line[len("issuer="):].strip()
            elif line.startswith("notAfter="):
                not_after_raw = line[len("notAfter="):].strip()
        not_after = _parse_cert_date(not_after_raw)
        if not not_after:
            return None
        return _cert_entry(path, subject, issuer, not_after)
    except Exception as e:
        logger.debug(f"Failed parsing cert {path}: {e}")
        return None


def _cert_from_endpoint(target):
    """TLS handshake against host[:port] (default 443) with SNI."""
    import socket
    import ssl as sslmod
    host, sep, port_raw = target.partition(":")
    host = host.strip()
    try:
        port = int(port_raw) if sep else 443
    except ValueError:
        return None
    if not host:
        return None
    try:
        ctx = sslmod.create_default_context()
        ctx.check_hostname = False
        ctx.verify_mode = sslmod.CERT_NONE
        with socket.create_connection((host, port), timeout=5) as sock:
            with ctx.wrap_socket(sock, server_hostname=host) as ssock:
                peer = ssock.getpeercert()
        if not peer:
            return None
        not_after = _parse_cert_date(peer.get("notAfter", ""))
        if not not_after:
            return None
        subject = ", ".join("=".join(pair) for rdn in peer.get("subject", []) for pair in rdn)
        issuer = ", ".join("=".join(pair) for rdn in peer.get("issuer", []) for pair in rdn)
        return _cert_entry(f"{host}:{port}", subject, issuer, not_after)
    except Exception as e:
        logger.debug(f"TLS endpoint check failed for {target}: {e}")
        return {"source": f"{host}:{port}", "subject": "", "issuer": "",
                "not_after": "", "days_left": None, "error": str(e)[:120]}


def get_tls_certs(cfg):
    """Collects TLS certificate expiries (security feature).

    Always scans Let's Encrypt live certs; extra PEM globs come from
    tls_cert_paths, live handshakes from tls_endpoints (agent.conf).
    """
    import glob
    certs = []
    seen = set()

    def add(entry):
        if not entry or entry.get("days_left") is None:
            return
        key = (entry.get("subject"), entry.get("not_after"))
        if key in seen:
            return
        seen.add(key)
        certs.append(entry)

    le_base = "/etc/letsencrypt/live"
    try:
        if os.path.isdir(le_base):
            for name in sorted(os.listdir(le_base)):
                for cand in (f"{le_base}/{name}/cert.pem", f"{le_base}/{name}/fullchain.pem"):
                    if os.path.isfile(cand):
                        add(_cert_from_pem_file(cand))
                        break
    except Exception as e:
        logger.debug(f"Let's Encrypt scan failed: {e}")

    for pattern in cfg.get("tls_cert_paths", []):
        try:
            for path in sorted(glob.glob(pattern)):
                if os.path.isfile(path):
                    add(_cert_from_pem_file(path))
        except Exception as e:
            logger.debug(f"TLS cert glob failed for {pattern}: {e}")

    for target in (cfg.get("tls_endpoints", []) or [])[:10]:
        add(_cert_from_endpoint(target))

    certs.sort(key=lambda c: (c.get("days_left") is None, c.get("days_left") or 0))
    return {"certs": certs, "checked_at": datetime.now(timezone.utc).isoformat()}


def _read_sshd_option(path, option, default):
    try:
        value = default
        with open(path, "r", encoding="utf-8", errors="ignore") as f:
            for line in f:
                stripped = line.strip()
                if not stripped or stripped.startswith("#"):
                    continue
                parts = stripped.split(None, 1)
                if len(parts) == 2 and parts[0].lower() == option.lower():
                    value = parts[1].strip().strip("\"'")
        return value
    except Exception:
        return default


def get_hardening():
    """Collects hardening facts (system feature); scoring happens backend-side."""
    checks = []

    sshd_cfg = "/etc/ssh/sshd_config"
    if os.path.exists(sshd_cfg):
        root_login = _read_sshd_option(sshd_cfg, "PermitRootLogin", "prohibit-password")
        checks.append({
            "id": "ssh_root_login",
            "label": "SSH Root-Login deaktiviert",
            "passed": root_login.lower() in ("no", "prohibit-password", "forced-commands-only"),
            "detail": f"PermitRootLogin {root_login}",
        })
        pw_auth = _read_sshd_option(sshd_cfg, "PasswordAuthentication", "yes")
        checks.append({
            "id": "ssh_password_auth",
            "label": "SSH Passwort-Auth deaktiviert",
            "passed": pw_auth.lower() == "no",
            "detail": f"PasswordAuthentication {pw_auth}",
        })
    else:
        checks.append({"id": "ssh_root_login", "label": "SSH Root-Login deaktiviert",
                       "passed": True, "detail": "kein SSH-Daemon installiert"})
        checks.append({"id": "ssh_password_auth", "label": "SSH Passwort-Auth deaktiviert",
                       "passed": True, "detail": "kein SSH-Daemon installiert"})

    fw_detail, fw_active = "keine aktive Firewall erkannt", False
    try:
        res = subprocess.run(["ufw", "status"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=10)
        if res.returncode == 0 and "Status: active" in res.stdout:
            fw_active, fw_detail = True, "ufw active"
    except Exception:
        pass
    if not fw_active:
        try:
            res = subprocess.run(["firewall-cmd", "--state"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=10)
            if res.returncode == 0 and "running" in res.stdout.strip().lower():
                fw_active, fw_detail = True, "firewalld running"
        except Exception:
            pass
    if not fw_active:
        try:
            res = subprocess.run(["nft", "list", "ruleset"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=10)
            if res.returncode == 0 and res.stdout.strip():
                fw_active, fw_detail = True, "nftables ruleset aktiv"
        except Exception:
            pass
    checks.append({"id": "firewall", "label": "Host-Firewall aktiv",
                   "passed": fw_active, "detail": fw_detail})

    auto_detail, auto_active = "keine automatischen Security-Updates erkannt", False
    try:
        if os.path.exists("/usr/bin/dpkg-query"):
            res = subprocess.run(["dpkg-query", "-W", "-f=${Status}", "unattended-upgrades"],
                                 stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=10)
            if "install ok installed" in res.stdout:
                auto_active, auto_detail = True, "unattended-upgrades installiert"
        if os.path.exists("/usr/bin/rpm"):
            res = subprocess.run(["rpm", "-q", "dnf-automatic"],
                                 stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=10)
            if res.returncode == 0:
                auto_active, auto_detail = True, "dnf-automatic installiert"
    except Exception as e:
        logger.debug(f"Auto-update check failed: {e}")
    checks.append({"id": "auto_updates", "label": "Automatische Security-Updates",
                   "passed": auto_active, "detail": auto_detail})

    return {"checks": checks, "checked_at": datetime.now(timezone.utc).isoformat()}


def get_pending_updates(os_id):
    """
    Detects pending package updates, distinguishing security updates from regular updates.
    Supports APT (Debian/Ubuntu), DNF/YUM (RHEL/Fedora/Rocky/Alma), Zypper (SUSE).
    """
    updates = {
        "package_manager": "unknown",
        "total_upgradable": 0,
        "security_updates_count": 0,
        "packages": []
    }

    # 1. Debian / Ubuntu (APT)
    if os.path.exists("/usr/bin/apt-get") and os.path.exists("/usr/bin/dpkg"):
        updates["package_manager"] = "apt"
        try:
            cmd = ["apt-get", "-s", "dist-upgrade"]
            res = subprocess.run(
                cmd,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=True,
                timeout=30
            )
            # Lines format:
            # Inst openssh-server [1:9.2p1-2+deb12u3] (1:9.2p1-2+deb12u4 Debian-Security:12/stable-security [amd64])
            security_keywords = ["security", "-security", "debian-security", "ubuntu-security"]
            for line in res.stdout.splitlines():
                if line.startswith("Inst "):
                    parts = line.split()
                    if len(parts) >= 2:
                        pkg_name = parts[1]
                        current_ver = ""
                        cand_ver = ""
                        repo = ""
                        # extract current version in brackets [x.y]
                        rest = " ".join(parts[2:])
                        if "[" in rest and "]" in rest:
                            current_ver = rest[rest.find("[") + 1 : rest.find("]")]
                        if "(" in rest and ")" in rest:
                            inside_paren = rest[rest.find("(") + 1 : rest.find(")")]
                            paren_parts = inside_paren.split()
                            if paren_parts:
                                cand_ver = paren_parts[0]
                            if len(paren_parts) > 1:
                                repo = paren_parts[1]

                        is_sec = any(sec in repo.lower() for sec in security_keywords)
                        # Also flag kernel updates as security-sensitive
                        if "linux-image" in pkg_name or "kernel" in pkg_name:
                            is_sec = True

                        updates["packages"].append({
                            "name": pkg_name,
                            "current_version": current_ver,
                            "candidate_version": cand_ver,
                            "repository": repo,
                            "is_security": is_sec
                        })
            updates["total_upgradable"] = len(updates["packages"])
            updates["security_updates_count"] = sum(1 for p in updates["packages"] if p["is_security"])
            return updates
        except Exception as e:
            logger.warning(f"Error checking APT updates: {e}")

    # 2. RHEL / Rocky / Alma / Fedora (DNF or YUM)
    dnf_bin = "/usr/bin/dnf" if os.path.exists("/usr/bin/dnf") else "/usr/bin/yum" if os.path.exists("/usr/bin/yum") else None
    if dnf_bin and os.path.exists("/usr/bin/rpm"):
        updates["package_manager"] = "dnf"
        try:
            # Check security updates first
            sec_res = subprocess.run(
                [dnf_bin, "updateinfo", "list", "sec", "-q"],
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=True,
                timeout=40
            )
            sec_packages = set()
            for line in sec_res.stdout.splitlines():
                parts = line.split()
                if len(parts) >= 3:
                    sec_packages.add(parts[2].split(".")[0])

            # Check general updates
            res = subprocess.run(
                [dnf_bin, "check-update", "-q"],
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=True,
                timeout=40
            )
            # Exit code 100 means updates available
            for line in res.stdout.splitlines():
                parts = line.split()
                if len(parts) >= 3:
                    pkg_raw = parts[0]
                    cand_ver = parts[1]
                    repo = parts[2]
                    pkg_name = pkg_raw.split(".")[0]
                    is_sec = pkg_name in sec_packages or "security" in repo.lower()
                    updates["packages"].append({
                        "name": pkg_name,
                        "current_version": "",
                        "candidate_version": cand_ver,
                        "repository": repo,
                        "is_security": is_sec
                    })
            updates["total_upgradable"] = len(updates["packages"])
            updates["security_updates_count"] = sum(1 for p in updates["packages"] if p["is_security"])
            return updates
        except Exception as e:
            logger.warning(f"Error checking DNF updates: {e}")

    # 3. openSUSE (Zypper)
    if os.path.exists("/usr/bin/zypper"):
        updates["package_manager"] = "zypper"
        try:
            res = subprocess.run(
                ["zypper", "--non-interactive", "list-updates"],
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=True,
                timeout=40
            )
            for line in res.stdout.splitlines():
                if "|" in line:
                    parts = [p.strip() for p in line.split("|")]
                    if len(parts) >= 5 and parts[0] != "S":
                        pkg_name = parts[2]
                        curr_ver = parts[3]
                        cand_ver = parts[4]
                        updates["packages"].append({
                            "name": pkg_name,
                            "current_version": curr_ver,
                            "candidate_version": cand_ver,
                            "repository": "zypper",
                            "is_security": False
                        })
            updates["total_upgradable"] = len(updates["packages"])
            return updates
        except Exception as e:
            logger.warning(f"Error checking Zypper updates: {e}")

    return updates


def get_installed_packages():
    """
    Collects installed packages for deep CVE vulnerability scanning.
    Limits to core / network / server / library packages or top 2000 to keep payload efficient.
    """
    packages = []
    # Debian/Ubuntu
    if os.path.exists("/usr/bin/dpkg-query"):
        try:
            res = subprocess.run(
                ["dpkg-query", "-W", "-f=${Package}\\t${Version}\\t${Architecture}\\n"],
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=True,
                timeout=20
            )
            for line in res.stdout.splitlines():
                parts = line.split("\t")
                if len(parts) >= 2:
                    packages.append({
                        "name": parts[0],
                        "version": parts[1],
                        "arch": parts[2] if len(parts) > 2 else ""
                    })
            return packages
        except Exception as e:
            logger.debug(f"Error running dpkg-query: {e}")

    # RPM-based
    if os.path.exists("/usr/bin/rpm"):
        try:
            res = subprocess.run(
                ["rpm", "-qa", "--qf", "%{NAME}\\t%{VERSION}-%{RELEASE}\\t%{ARCH}\\n"],
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=True,
                timeout=20
            )
            for line in res.stdout.splitlines():
                parts = line.split("\t")
                if len(parts) >= 2:
                    packages.append({
                        "name": parts[0],
                        "version": parts[1],
                        "arch": parts[2] if len(parts) > 2 else ""
                    })
            return packages
        except Exception as e:
            logger.debug(f"Error running rpm -qa: {e}")

    return packages


def collect_telemetry(cfg, features=None, desired=None):
    """Assembles the complete DeathOps host report.

    Sections are gated by the backend-managed feature flags (2); per-section
    timings go into client_stats so the backend can show the real overhead
    next to its virtual cost model.
    """
    feats = features or effective_features(cfg, desired or load_desired_state(cfg.get("state_file")))
    section_ms = {}
    sections = {}

    def _mark(name, status, detail="", ms=0):
        section_ms[name] = ms
        sections[name] = {"status": status, "detail": detail, "ms": ms}

    t0 = time.perf_counter()
    os_info = get_os_release()
    uptime_sec, boot_time_iso = get_uptime_and_boot()
    kernel = platform.release()
    arch = platform.machine()
    _mark("host", "covered", "", int((time.perf_counter() - t0) * 1000))

    if feats.get("system", True):
        t0 = time.perf_counter()
        cpu = get_cpu_metrics()
        cpu_ms = int((time.perf_counter() - t0) * 1000)
        _mark("cpu", "covered" if cpu.get("cores") else "failed",
              "" if cpu.get("cores") else "keine CPU-Daten lesbar", cpu_ms)
        t0 = time.perf_counter()
        mem = get_memory_metrics()
        mem_ms = int((time.perf_counter() - t0) * 1000)
        _mark("memory", "covered" if mem.get("total_mb") else "failed",
              "" if mem.get("total_mb") else "kein /proc/meminfo lesbar", mem_ms)
        t0 = time.perf_counter()
        failed_services = get_failed_services()
        _mark("services", "covered", "", int((time.perf_counter() - t0) * 1000))
    else:
        cores = os.cpu_count() or 1
        cpu = {"cores": cores, "load_1m": 0.0, "load_5m": 0.0, "load_15m": 0.0, "usage_pct": 0.0}
        mem = {"total_mb": 0.0, "used_mb": 0.0, "free_mb": 0.0, "used_pct": 0.0,
               "swap_total_mb": 0.0, "swap_used_mb": 0.0, "swap_used_pct": 0.0}
        failed_services = []
        for _n in ("cpu", "memory", "services"):
            _mark(_n, "skipped", "Feature 'system' deaktiviert", 0)

    if feats.get("disk", True):
        t0 = time.perf_counter()
        disks = get_disk_metrics()
        disk_ms = int((time.perf_counter() - t0) * 1000)
        _mark("disk", "covered" if disks else "failed",
              f"{len(disks)} Mountpoints" if disks else "keine Mountpoints lesbar", disk_ms)
    else:
        disks = []
        _mark("disk", "skipped", "Feature 'disk' deaktiviert", 0)

    t0 = time.perf_counter()
    reboot_required = check_reboot_required()
    _mark("reboot", "covered", "", int((time.perf_counter() - t0) * 1000))

    if feats.get("security", True):
        t0 = time.perf_counter()
        tls_info = get_tls_certs(cfg)
        tls_ms = int((time.perf_counter() - t0) * 1000)
        _mark("tls", "covered", f"{len(tls_info.get('certs', []))} Zertifikate", tls_ms)
    else:
        tls_info = {"certs": [], "checked_at": datetime.now(timezone.utc).isoformat()}
        _mark("tls", "skipped", "Feature 'security' deaktiviert", 0)

    if feats.get("system", True):
        t0 = time.perf_counter()
        hardening = get_hardening()
        _mark("hardening", "covered", f"{len(hardening.get('checks', []))} Checks",
              int((time.perf_counter() - t0) * 1000))
    else:
        hardening = {"checks": [], "checked_at": datetime.now(timezone.utc).isoformat()}
        _mark("hardening", "skipped", "Feature 'system' deaktiviert", 0)

    if feats.get("security", True):
        t0 = time.perf_counter()
        updates = get_pending_updates(os_info["id"])
        updates_ms = int((time.perf_counter() - t0) * 1000)
        pm = updates.get("package_manager", "unknown")
        _mark("updates", "covered" if pm not in ("unknown", "disabled") else "failed",
              pm if pm not in ("unknown", "disabled") else "kein Paketmanager erkannt", updates_ms)
    else:
        updates = {"package_manager": "disabled", "total_upgradable": 0,
                   "security_updates_count": 0, "packages": []}
        _mark("updates", "skipped", "Feature 'security' deaktiviert", 0)

    installed_pkgs = []
    if feats.get("security", True) and cfg.get("collect_packages", True):
        t0 = time.perf_counter()
        installed_pkgs = get_installed_packages()
        pkgs_ms = int((time.perf_counter() - t0) * 1000)
        _mark("packages", "covered" if installed_pkgs else "failed",
              f"{len(installed_pkgs)} Pakete" if installed_pkgs else "Paketliste leer", pkgs_ms)
    elif not feats.get("security", True):
        _mark("packages", "skipped", "Feature 'security' deaktiviert", 0)
    else:
        _mark("packages", "skipped", "collect_packages deaktiviert", 0)

    collect_duration_ms = sum(section_ms.values())

    payload = {
        "api_key": cfg["api_key"],
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "agent_version": AGENT_VERSION,
        "host": {
            "hostname": cfg["hostname"],
            "os_id": os_info["id"],
            "os_name": os_info["pretty_name"],
            "os_version": os_info["version_id"],
            "kernel": kernel,
            "arch": arch,
            "uptime_seconds": uptime_sec,
            "boot_time": boot_time_iso,
            "tags": cfg["tags"]
        },
        "metrics": {
            "cpu": cpu,
            "memory": mem,
            "disks": disks,
            "reboot_required": reboot_required
        },
        "services": {
            "failed_units": failed_services
        },
        "updates": updates,
        "tls": tls_info,
        "hardening": hardening,
        "installed_packages_count": len(installed_pkgs),
        "installed_packages": installed_pkgs,
        "client_stats": {
            "collect_duration_ms": collect_duration_ms,
            "section_ms": section_ms,
            "sections": sections,
            "features": feats,
            "payload_bytes": 0,  # filled by send_report after serialization
        },
    }

    return payload


def _post_json(url, data_bytes, headers, timeout, ctx=None, max_redirects=3):
    """POST helper that follows 301/302/303/307/308 redirects.

    Plain urllib does not follow 308 for POST on all Python versions, and the
    ingress answers plain-http API calls with a 308 to https. Instead of
    failing the report, re-POST once to the Location header (same body).
    """
    current_url = url
    for _ in range(max_redirects + 1):
        req = urllib.request.Request(
            current_url,
            data=data_bytes,
            headers=headers,
            method="POST"
        )
        try:
            if ctx is not None:
                with urllib.request.urlopen(req, timeout=timeout, context=ctx) as response:
                    return response.read().decode("utf-8"), response.getcode(), None
            else:
                with urllib.request.urlopen(req, timeout=timeout) as response:
                    return response.read().decode("utf-8"), response.getcode(), None
        except urllib.error.HTTPError as e:
            if e.code in (301, 302, 303, 307, 308):
                location = e.headers.get("Location")
                if location:
                    current_url = urllib.parse.urljoin(current_url, location)
                    continue
            raise
    raise urllib.error.HTTPError(url, 310, "Too many redirects", {}, None)


def send_report(cfg, payload, desired=None):
    """Transmits report to DeathOps backend API."""
    # Payload size is part of the overhead stats (2) — measure before send.
    try:
        data_bytes = json.dumps(payload).encode("utf-8")
        if isinstance(payload.get("client_stats"), dict):
            payload["client_stats"]["payload_bytes"] = len(data_bytes)
            data_bytes = json.dumps(payload).encode("utf-8")
    except Exception:
        data_bytes = json.dumps(payload).encode("utf-8")
    url = f"{cfg['backend_url']}/api/v1/agent/report"
    headers = {
        "Content-Type": "application/json",
        "X-API-Key": cfg["api_key"],
        "User-Agent": f"deathops-agent/{AGENT_VERSION}"
    }

    ctx = None
    if not cfg.get("verify_ssl", True):
        import ssl
        ctx = ssl._create_unverified_context()

    try:
        resp_body, status_code, _ = _post_json(url, data_bytes, headers, 30, ctx)
        try:
            res_json = json.loads(resp_body)
        except Exception:
            res_json = {"raw": resp_body}
        # Persist backend-pushed desired config (2) for the next run.
        if isinstance(res_json, dict) and isinstance(res_json.get("config"), dict):
            save_desired_state(cfg.get("state_file"), res_json["config"])
        return True, status_code, res_json
    except urllib.error.HTTPError as e:
        try:
            err_msg = e.read().decode("utf-8", errors="ignore")
        except Exception:
            err_msg = str(e)
        if e.code in (301, 302, 303, 307, 308):
            err_msg = (
                f"{err_msg} (Redirect not followed — check backend_url is https://, "
                f"current: {cfg['backend_url']})"
            )
        return False, e.code, err_msg
    except Exception as e:
        return False, 0, str(e)


def rotate_key(cfg, config_file):
    """Self-service key rotation (1): swaps the key and stores it atomically."""
    url = f"{cfg['backend_url']}/api/v1/agent/rotate"
    headers = {
        "Content-Type": "application/json",
        "X-API-Key": cfg["api_key"],
        "User-Agent": f"deathops-agent/{AGENT_VERSION}"
    }
    try:
        resp_body, status_code, _ = _post_json(url, b"{}", headers, 30)
        try:
            res_json = json.loads(resp_body)
        except Exception:
            res_json = {"raw": resp_body}
        if status_code != 200:
            return False, status_code, res_json
        new_key = (res_json or {}).get("new_key", "")
        if not new_key:
            return False, status_code, "Rotation response without new_key"
        if save_api_key(config_file, new_key):
            cfg["api_key"] = new_key
            action = "synced" if (res_json or {}).get("status") == "synced" else "rotated"
            logger.info(f"Key {action} successful, current key stored (grace {res_json.get('grace_hours', 48)}h)")
            return True, status_code, res_json
        return False, status_code, "Could not persist new key to config"
    except urllib.error.HTTPError as e:
        try:
            err_msg = e.read().decode("utf-8", errors="ignore")
        except Exception:
            err_msg = str(e)
        return False, e.code, err_msg
    except Exception as e:
        return False, 0, str(e)


def run_daemon(cfg, config_file=DEFAULT_CONFIG_PATH):
    """Continuous agent loop with configured interval."""
    logger.info(f"Starting DeathOps Agent v{AGENT_VERSION} (Host: {cfg['hostname']}, Interval: {cfg['interval_seconds']}s)")
    if not cfg["api_key"]:
        logger.warning("No API Key configured! Please configure api_key in /etc/deathops/agent.conf or DEATHOPS_API_KEY")

    desired = load_desired_state(cfg.get("state_file"))

    while True:
        try:
            features = effective_features(cfg, desired)
            interval = effective_interval(cfg, desired)
            logger.info(f"Collecting system telemetry and security updates... (features: {', '.join(k for k, v in features.items() if v)})")
            payload = collect_telemetry(cfg, features, desired)
            success, code, resp = send_report(cfg, payload, desired)
            if success:
                logger.info(f"Report sent successfully! (HTTP {code}) Backend response: {resp.get('status', 'ok')}")
                if resp.get("acute_issues_count", 0) > 0:
                    logger.warning(f"Backend flagged {resp['acute_issues_count']} acute issue(s) on this host!")
                if isinstance(resp.get("config"), dict):
                    desired = resp["config"]
                    interval = effective_interval(cfg, desired)
                # Key sync (1): this agent still uses the previous key — e.g.
                # after a dashboard-initiated rotation. Sync the current key
                # now instead of dying when grace expires.
                if resp.get("used_previous_key"):
                    logger.info("Backend reports key was rotated elsewhere — syncing current key...")
                    ok, rcode, rresp = rotate_key(cfg, config_file)
                    if not ok:
                        logger.error(f"Key sync failed (HTTP {rcode}): {rresp}")
                    elif isinstance(rresp, dict) and rresp.get("status") == "synced":
                        logger.info("Key sync successful, current key stored.")
                # Auto-rotation (1): backend signals rotation_due per interval.
                elif resp.get("rotation_due"):
                    logger.info("Backend requests key rotation — rotating now...")
                    ok, rcode, rresp = rotate_key(cfg, config_file)
                    if not ok:
                        logger.error(f"Auto-rotation failed (HTTP {rcode}): {rresp}")
                # OTLP fan-out (opt-in per tenant key): ship the same payload
                # to the customer's observability stack. Best effort only.
                maybe_send_otlp(cfg, payload, desired)
            else:
                logger.error(f"Failed sending report to backend ({cfg['backend_url']}): HTTP {code} - {resp}")
        except Exception as e:
            logger.exception(f"Unexpected error in agent loop: {e}")

        logger.debug(f"Sleeping for {interval} seconds...")
        time.sleep(interval)


def main():
    parser = argparse.ArgumentParser(description="DeathOps Linux Host Agent")
    parser.add_argument("-c", "--config", default=DEFAULT_CONFIG_PATH, help=f"Path to config file (default: {DEFAULT_CONFIG_PATH})")
    parser.add_argument("--run-once", action="store_true", help="Execute single collection and send report, then exit")
    parser.add_argument("--print-json", action="store_true", help="Collect and print telemetry JSON to stdout, without sending")
    parser.add_argument("--test-connection", action="store_true", help="Test backend connectivity and API key validity")
    parser.add_argument("--rotate-key", action="store_true", help="Rotate the API key via backend and store it atomically, then exit")
    parser.add_argument("--print-otlp", action="store_true", help="Collect telemetry and print the OTLP/HTTP JSON payload without sending")
    parser.add_argument("-v", "--version", action="version", version=f"deathops-agent {AGENT_VERSION}")

    args = parser.parse_args()
    cfg = load_config(args.config)

    if args.print_json:
        payload = collect_telemetry(cfg)
        # Avoid flooding stdout with full installed packages list unless requested
        preview = dict(payload)
        preview["installed_packages"] = preview["installed_packages"][:10]  # preview first 10
        print(json.dumps(preview, indent=2))
        sys.exit(0)

    if args.print_otlp:
        payload = collect_telemetry(cfg)
        print(json.dumps(build_otlp_metrics(payload), indent=2))
        sys.exit(0)

    if args.test_connection:
        url = f"{cfg['backend_url']}/api/v1/agent/heartbeat"
        try:
            body, code, _ = _post_json(
                url,
                json.dumps({"hostname": cfg["hostname"]}).encode("utf-8"),
                {"Content-Type": "application/json", "X-API-Key": cfg["api_key"]},
                10,
            )
            print(f"[OK] Backend reached at {cfg['backend_url']}! HTTP {code}")
            sys.exit(0)
        except Exception as e:
            print(f"[FAIL] Could not reach backend at {cfg['backend_url']}: {e}")
            sys.exit(1)

    if args.rotate_key:
        logger.info("Requesting key rotation...")
        ok, code, resp = rotate_key(cfg, args.config)
        if ok:
            logger.info(f"Key rotation successful! (HTTP {code}) Old key valid for {resp.get('grace_hours', 48)}h grace.")
            sys.exit(0)
        else:
            logger.error(f"Key rotation failed: HTTP {code} - {resp}")
            sys.exit(1)

    if args.run_once:
        logger.info("Executing single collection run...")
        payload = collect_telemetry(cfg)
        success, code, resp = send_report(cfg, payload)
        if success:
            logger.info(f"Report transmitted successfully! (HTTP {code}) {resp}")
            sys.exit(0)
        else:
            logger.error(f"Report failed: HTTP {code} - {resp}")
            sys.exit(1)

    # Normal daemon mode
    run_daemon(cfg, args.config)


if __name__ == "__main__":
    main()
