#!/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
from datetime import datetime, timezone

AGENT_VERSION = "1.0.0"
DEFAULT_CONFIG_PATH = "/etc/deathops/agent.conf"
DEFAULT_BACKEND_URL = "http://127.0.0.1:8088"
DEFAULT_INTERVAL = 300  # 5 minutes

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(),
    }

    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 "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 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 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):
    """Assembles the complete DeathOps host report."""
    os_info = get_os_release()
    uptime_sec, boot_time_iso = get_uptime_and_boot()
    cpu = get_cpu_metrics()
    mem = get_memory_metrics()
    disks = get_disk_metrics()
    failed_services = get_failed_services()
    reboot_required = check_reboot_required()
    updates = get_pending_updates(os_info["id"])

    installed_pkgs = []
    if cfg.get("collect_packages", True):
        installed_pkgs = get_installed_packages()

    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": platform.release(),
            "arch": platform.machine(),
            "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,
        "installed_packages_count": len(installed_pkgs),
        "installed_packages": installed_pkgs
    }

    return payload


def send_report(cfg, payload):
    """Transmits report to DeathOps backend API."""
    url = f"{cfg['backend_url']}/api/v1/agent/report"
    data_bytes = json.dumps(payload).encode("utf-8")

    req = urllib.request.Request(
        url,
        data=data_bytes,
        headers={
            "Content-Type": "application/json",
            "X-API-Key": cfg["api_key"],
            "User-Agent": f"deathops-agent/{AGENT_VERSION}"
        },
        method="POST"
    )

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

    try:
        with urllib.request.urlopen(req, timeout=30, context=ctx) as response:
            resp_body = response.read().decode("utf-8")
            status_code = response.getcode()
            try:
                res_json = json.loads(resp_body)
            except Exception:
                res_json = {"raw": resp_body}
            return True, status_code, res_json
    except urllib.error.HTTPError as e:
        err_msg = e.read().decode("utf-8", errors="ignore")
        return False, e.code, err_msg
    except Exception as e:
        return False, 0, str(e)


def run_daemon(cfg):
    """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")

    while True:
        try:
            logger.info("Collecting system telemetry and security updates...")
            payload = collect_telemetry(cfg)
            success, code, resp = send_report(cfg, payload)
            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!")
            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 {cfg['interval_seconds']} seconds...")
        time.sleep(cfg["interval_seconds"])


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("-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.test_connection:
        url = f"{cfg['backend_url']}/api/v1/agent/heartbeat"
        req = urllib.request.Request(
            url,
            data=json.dumps({"hostname": cfg["hostname"]}).encode("utf-8"),
            headers={"Content-Type": "application/json", "X-API-Key": cfg["api_key"]},
            method="POST"
        )
        try:
            with urllib.request.urlopen(req, timeout=10) as response:
                print(f"[OK] Backend reached at {cfg['backend_url']}! HTTP {response.getcode()}")
                sys.exit(0)
        except Exception as e:
            print(f"[FAIL] Could not reach backend at {cfg['backend_url']}: {e}")
            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)


if __name__ == "__main__":
    main()
