#!/usr/bin/env python3
"""hard-monitor — агент GHard Monitor (Gnexus Hardware Monitor).

Stateless-сборщик: собирает факты о сервере и POST-ит их на hard-panel
(POST /api/v1/ingest, заголовок X-Server-Key). Вся логика — пороги, ивенты,
расчёт скорости сети — на панели.

Конфиг: переменные окружения или .env рядом со скриптом
  PANEL_URL   — адрес панели, напр. https://panel.example.com
  SERVER_KEY  — ключ сервера из панели
  INTERVAL    — период отправки, сек (по умолчанию 30)

Использование:
  hard_monitor.py            — бесконечный цикл (штатный режим, systemd)
  hard_monitor.py --once     — один пакет и выход (для теста)
  hard_monitor.py --dry-run  — собрать и напечатать пакет, не отправляя
"""

from __future__ import annotations

import argparse
import json
import logging
import os
import platform
import shutil
import subprocess
import sys
import time
from datetime import datetime, timezone
from pathlib import Path

import psutil
import requests

log = logging.getLogger("hard-monitor")

# Файловые системы/маунты, которые не показываем как «диски» панели
SKIP_FSTYPES = {"squashfs", "tmpfs", "devtmpfs", "overlay", "iso9660", "proc", "sysfs"}


# --- Конфиг -----------------------------------------------------------------

def load_env_file(path: Path) -> None:
    """Мини-парсер .env: KEY=VALUE в переменные окружения (env важнее файла)."""
    if not path.exists():
        return
    for line in path.read_text(encoding="utf-8").splitlines():
        line = line.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        key, _, value = line.partition("=")
        key, value = key.strip(), value.strip().strip("'\"")
        if key and key not in os.environ:
            os.environ[key] = value


def env(name: str, default: str = "") -> str:
    return os.environ.get(name, default)


# --- Сбор фактов -------------------------------------------------------------

def collect_identity() -> dict:
    os_name = platform.platform()
    pretty = Path("/etc/os-release")
    if pretty.exists():
        for line in pretty.read_text(encoding="utf-8").splitlines():
            if line.startswith("PRETTY_NAME="):
                os_name = line.partition("=")[2].strip('"')
                break
    return {
        "hostname": platform.node() or "unknown",
        "os": os_name,
        "kernel": platform.release(),
        "ips": collect_ips(),
    }


def collect_ips() -> list[str]:
    """IPv4-адреса активных интерфейсов.

    Виртуальные (docker/veth/мосты) не показываем — в карточке сервера
    нужен реальный адрес, а не 172.17.0.1 каждого docker-моста.
    """
    virtual = ("lo", "docker", "veth", "br-", "virbr")
    stats = psutil.net_if_stats()
    result = []
    for iface, addrs in psutil.net_if_addrs().items():
        if iface.startswith(virtual):
            continue
        if not stats.get(iface) or not stats[iface].isup:
            continue
        for addr in addrs:
            if addr.family.name == "AF_INET":
                result.append(addr.address)
    return sorted(set(result))


def collect_cpu() -> dict:
    # psutil считает % от предыдущего вызова — при старте сеем первое чтение
    load1, load5, load15 = os.getloadavg()
    return {
        "percent": psutil.cpu_percent(interval=None),
        "count": psutil.cpu_count() or 0,
        "load": [round(load1, 2), round(load5, 2), round(load15, 2)],
    }


def collect_memory() -> dict:
    ram = psutil.virtual_memory()
    swap = psutil.swap_memory()
    return {
        "ram": {"total": ram.total, "used": ram.used, "available": ram.available},
        "swap": {"total": swap.total, "used": swap.used},
    }


def collect_disks() -> list[dict]:
    disks = []
    seen = set()
    for part in psutil.disk_partitions(all=False):
        if part.fstype in SKIP_FSTYPES or "/snap/" in part.mountpoint or part.mountpoint in seen:
            continue
        seen.add(part.mountpoint)
        try:
            usage = psutil.disk_usage(part.mountpoint)
        except (PermissionError, OSError):
            continue
        disks.append(
            {
                "mount": part.mountpoint,
                "total": usage.total,
                "used": usage.used,
                "percent": round(usage.percent, 1),
            }
        )
    return disks


def collect_net() -> list[dict]:
    counters = psutil.net_io_counters(pernic=True)
    return [
        {
            "iface": iface,
            "bytes_sent": counters[iface].bytes_sent,
            "bytes_recv": counters[iface].bytes_recv,
        }
        for iface in sorted(counters)
    ]  # loopback панель отфильтрует сама


def collect_processes(limit: int = 5) -> list[dict]:
    """Топ процессов по CPU (с прошлого цикла)."""
    procs = []
    for proc in psutil.process_iter(["pid", "name", "cpu_percent", "memory_percent"]):
        try:
            info = proc.info
            procs.append(
                {
                    "name": info["name"] or "?",
                    "pid": info["pid"] or 0,
                    "cpu": info["cpu_percent"] or 0.0,
                    "mem": info["memory_percent"] or 0.0,
                }
            )
        except (psutil.NoSuchProcess, psutil.AccessDenied):
            continue
    procs.sort(key=lambda p: p["cpu"], reverse=True)
    return procs[:limit]


def collect_docker() -> list[dict]:
    """Все контейнеры (включая упавшие — по ним панель сделает ивенты)."""
    if not shutil.which("docker"):
        return []
    try:
        out = subprocess.run(
            ["docker", "ps", "-a", "--format", "{{.Names}}|{{.Image}}|{{.Status}}"],
            capture_output=True, text=True, timeout=10,
        )
    except (OSError, subprocess.TimeoutExpired):
        return []
    if out.returncode != 0:  # нет доступа к docker-сокету и т.п.
        return []
    containers = []
    for line in out.stdout.strip().splitlines():
        name, _, rest = line.partition("|")
        image, _, status = rest.partition("|")
        containers.append({"name": name, "image": image, "status": status})
    return containers


def collect_extra() -> dict:
    """Необязательные факты: температуры. Панель хранит их в extra."""
    extra: dict = {}
    try:
        temps = psutil.sensors_temperatures()
        if temps:
            extra["temps"] = {
                label: round(entry[0].current or 0, 1)
                for label, entries in temps.items()
                if entries
            }
    except (OSError, AttributeError, Exception):
        pass
    return extra


def collect() -> dict:
    return {
        **collect_identity(),
        "interval": int(env("INTERVAL", "30")),
        "ts": datetime.now(timezone.utc).isoformat(),
        "cpu": collect_cpu(),
        "memory": collect_memory(),
        "disks": collect_disks(),
        "uptime": int(time.time() - psutil.boot_time()),
        "net": collect_net(),
        "processes": collect_processes(),
        "docker": collect_docker(),
        "extra": collect_extra(),
    }


# --- Отправка ----------------------------------------------------------------

def send(panel_url: str, server_key: str, payload: dict) -> None:
    response = requests.post(
        panel_url.rstrip("/") + "/api/v1/ingest",
        json=payload,
        headers={"X-Server-Key": server_key},
        timeout=15,
    )
    response.raise_for_status()


def main() -> int:
    parser = argparse.ArgumentParser(description="hard-monitor agent")
    parser.add_argument("--once", action="store_true", help="отправить один пакет и выйти")
    parser.add_argument("--dry-run", action="store_true", help="напечатать пакет, не отправляя")
    parser.add_argument("--env", default=".env", help="путь к .env (по умолчанию рядом со скриптом)")
    args = parser.parse_args()

    load_env_file(Path(__file__).resolve().parent / args.env)

    panel_url = env("PANEL_URL")
    server_key = env("SERVER_KEY")
    interval = int(env("INTERVAL", "30"))

    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s %(levelname)s %(message)s",
        stream=sys.stdout,
    )

    if not args.dry_run:
        if not panel_url or not server_key:
            log.error("PANEL_URL и SERVER_KEY обязательны (env или .env)")
            return 1

    # сеем базовые точки, чтобы cpu_percent и process cpu были не нулевые
    psutil.cpu_percent(interval=None)
    list(psutil.process_iter(["cpu_percent"]))

    while True:
        started = time.monotonic()
        try:
            payload = collect()
            if args.dry_run:
                print(json.dumps(payload, ensure_ascii=False, indent=2))
                return 0
            send(panel_url, server_key, payload)
            log.info("sent: cpu=%.1f%% net_ifaces=%d", payload["cpu"]["percent"], len(payload["net"]))
        except requests.RequestException as exc:
            log.warning("send failed: %s", exc)
        except Exception:
            log.exception("collect failed")

        if args.once:
            return 0
        time.sleep(max(interval - (time.monotonic() - started), 0))


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