#!/usr/bin/env bash
#
# Inventario de equipos - agente Linux.
#
# Configurar por parametros o variables de entorno:
#   INTRANET_INVENTARIO_URL
#   INTRANET_INVENTARIO_TOKEN
#   INTRANET_INVENTARIO_SKIP_SOFTWARE=1
#   INTRANET_INVENTARIO_IGNORE_TLS_ERRORS=1
#   INTRANET_INVENTARIO_RUN_DEPLOYMENTS=1
#   INTRANET_INVENTARIO_AGENT_URL
#
# Ejemplo cron cada 12 horas:
#   0 */12 * * * INTRANET_INVENTARIO_URL="https://servidor/intranet/apps/inventario/api.php" INTRANET_INVENTARIO_TOKEN="TOKEN" /opt/intranet/inventtory_linux.sh --skip-software

set -euo pipefail

usage() {
    cat <<'EOF'
Uso: inventtory_linux.sh [opciones]

Opciones:
  --endpoint URL          URL de apps/inventario/api.php
  --token TOKEN           Token de inventario
  --skip-software         No enumerar paquetes instalados
  --ignore-tls-errors     Ignorar errores de certificado TLS
  --run-deployments       Ejecutar trabajos remotos pendientes
  --agent-url URL         URL de apps/inventario/agent.php
  --timeout SEGUNDOS      Timeout HTTP, por defecto 60
  -h, --help              Mostrar ayuda
EOF
}

while [ "$#" -gt 0 ]; do
    case "$1" in
        --endpoint)
            export INTRANET_INVENTARIO_URL="${2:-}"
            shift 2
            ;;
        --token)
            export INTRANET_INVENTARIO_TOKEN="${2:-}"
            shift 2
            ;;
        --skip-software)
            export INTRANET_INVENTARIO_SKIP_SOFTWARE=1
            shift
            ;;
        --ignore-tls-errors)
            export INTRANET_INVENTARIO_IGNORE_TLS_ERRORS=1
            shift
            ;;
        --run-deployments)
            export INTRANET_INVENTARIO_RUN_DEPLOYMENTS=1
            shift
            ;;
        --agent-url)
            export INTRANET_INVENTARIO_AGENT_URL="${2:-}"
            shift 2
            ;;
        --timeout)
            export INTRANET_INVENTARIO_TIMEOUT="${2:-60}"
            shift 2
            ;;
        -h|--help)
            usage
            exit 0
            ;;
        *)
            echo "Opcion no reconocida: $1" >&2
            usage >&2
            exit 2
            ;;
    esac
done

if ! command -v python3 >/dev/null 2>&1; then
    echo "python3 es obligatorio para generar y enviar el JSON de inventario." >&2
    exit 1
fi

python3 - <<'PY'
import json
import os
import platform
import pwd
import shutil
import socket
import ssl
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path


def env_bool(name):
    return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "si", "on"}


ENDPOINT = os.environ.get("INTRANET_INVENTARIO_URL", "").strip()
TOKEN = os.environ.get("INTRANET_INVENTARIO_TOKEN", "").strip()
AGENT_ENDPOINT = os.environ.get("INTRANET_INVENTARIO_AGENT_URL", "").strip()
SKIP_SOFTWARE = env_bool("INTRANET_INVENTARIO_SKIP_SOFTWARE")
IGNORE_TLS = env_bool("INTRANET_INVENTARIO_IGNORE_TLS_ERRORS")
RUN_DEPLOYMENTS = env_bool("INTRANET_INVENTARIO_RUN_DEPLOYMENTS")
TIMEOUT = int(os.environ.get("INTRANET_INVENTARIO_TIMEOUT", "60") or "60")

if not ENDPOINT:
    raise SystemExit("Configura INTRANET_INVENTARIO_URL o usa --endpoint.")
if not TOKEN:
    raise SystemExit("Configura INTRANET_INVENTARIO_TOKEN o usa --token.")


def read_text(path):
    try:
        return Path(path).read_text(encoding="utf-8", errors="ignore").strip()
    except OSError:
        return ""


def run(args, timeout=6):
    try:
        completed = subprocess.run(args, check=False, capture_output=True, text=True, timeout=timeout)
        if completed.returncode != 0:
            return ""
        return completed.stdout.strip()
    except (OSError, subprocess.SubprocessError):
        return ""


def bytes_to_gb(value):
    try:
        return round(float(value) / (1024 ** 3), 2)
    except (TypeError, ValueError):
        return None


def mem_gb():
    for line in read_text("/proc/meminfo").splitlines():
        if line.startswith("MemTotal:"):
            parts = line.split()
            if len(parts) >= 2 and parts[1].isdigit():
                return round(int(parts[1]) * 1024 / (1024 ** 3), 2)
    return None


def root_disk():
    usage = shutil.disk_usage("/")
    return {
        "device_id": "/",
        "volume_name": "root",
        "total_gb": bytes_to_gb(usage.total),
        "free_gb": bytes_to_gb(usage.free),
    }


def os_release():
    data = {}
    for line in read_text("/etc/os-release").splitlines():
        if "=" not in line:
            continue
        key, value = line.split("=", 1)
        data[key] = value.strip().strip('"')
    return data


def synology_info():
    return {
        "model": read_text("/proc/sys/kernel/syno_hw_version") or read_text("/proc/sys/kernel/syno_hw_revision"),
        "serial": read_text("/proc/sys/kernel/syno_serial"),
        "version": read_text("/etc.defaults/VERSION") or read_text("/etc/VERSION"),
    }


def synology_version_label(raw):
    data = {}
    for line in raw.splitlines():
        if "=" not in line:
            continue
        key, value = line.split("=", 1)
        data[key.strip()] = value.strip().strip('"')
    product = data.get("productversion")
    build = data.get("buildnumber")
    smallfix = data.get("smallfixnumber")
    if product and build:
        suffix = f"-{smallfix}" if smallfix and smallfix != "0" else ""
        return f"DSM {product}-{build}{suffix}"
    return ""


def read_lsb_description():
    if Path("/etc/lsb-release").is_file():
        data = {}
        for line in read_text("/etc/lsb-release").splitlines():
            if "=" not in line:
                continue
            key, value = line.split("=", 1)
            data[key.strip()] = value.strip().strip('"')
        if data.get("DISTRIB_DESCRIPTION"):
            return data["DISTRIB_DESCRIPTION"]
    return ""


def system_profile(release):
    syno = synology_info()
    syno_label = synology_version_label(syno["version"])
    if syno_label or syno["model"]:
        return {
            "kind": "synology-dsm",
            "caption": syno_label or "Synology DSM",
            "version": syno_label.replace("DSM ", "", 1) if syno_label else None,
            "manufacturer": "Synology",
            "model": syno["model"] or None,
            "serial": syno["serial"] or None,
        }

    pve_version = run(["pveversion"], timeout=5).splitlines()[0] if shutil.which("pveversion") else ""
    if pve_version:
        return {
            "kind": "proxmox-pve",
            "caption": f"Proxmox VE {pve_version}",
            "version": pve_version,
            "manufacturer": read_text("/sys/class/dmi/id/sys_vendor") or None,
            "model": read_text("/sys/class/dmi/id/product_name") or None,
            "serial": read_text("/sys/class/dmi/id/product_serial") or None,
        }

    caption = release.get("PRETTY_NAME") or read_lsb_description() or platform.platform()
    return {
        "kind": "linux",
        "caption": caption,
        "version": release.get("VERSION_ID") or None,
        "manufacturer": read_text("/sys/class/dmi/id/sys_vendor") or None,
        "model": read_text("/sys/class/dmi/id/product_name") or None,
        "serial": read_text("/sys/class/dmi/id/product_serial") or None,
    }


def cpu_name():
    for line in read_text("/proc/cpuinfo").splitlines():
        if "model name" in line:
            return line.split(":", 1)[1].strip()
        if line.lower().startswith("hardware"):
            return line.split(":", 1)[1].strip()
    value = run(["lscpu"])
    for line in value.splitlines():
        if line.lower().startswith("model name:"):
            return line.split(":", 1)[1].strip()
    return platform.processor() or None


def machine_domain():
    fqdn = socket.getfqdn()
    host = socket.gethostname()
    if fqdn and fqdn != host and "." in fqdn:
        return fqdn.split(".", 1)[1]
    domain = run(["dnsdomainname"])
    return domain or None


def ethtool_wol(iface):
    if not shutil.which("ethtool"):
        return "Desconocido"
    output = run(["ethtool", iface])
    for line in output.splitlines():
        stripped = line.strip()
        if stripped.lower().startswith("wake-on:"):
            value = stripped.split(":", 1)[1].strip().lower()
            return "Enabled" if "g" in value else "Disabled"
    return "Desconocido"


def network_inventory():
    adapters = []
    ip_json = run(["ip", "-j", "addr", "show"])
    if ip_json:
        try:
            entries = json.loads(ip_json)
        except json.JSONDecodeError:
            entries = []
        for item in entries:
            iface = str(item.get("ifname") or "")
            if not iface or iface == "lo":
                continue
            mac = read_text(f"/sys/class/net/{iface}/address")
            if not mac or mac == "00:00:00:00:00:00":
                continue
            ipv4 = []
            ipv6 = []
            for addr in item.get("addr_info") or []:
                if addr.get("family") == "inet" and addr.get("local"):
                    ipv4.append(str(addr["local"]))
                elif addr.get("family") == "inet6" and addr.get("local"):
                    ipv6.append(str(addr["local"]))
            speed = read_text(f"/sys/class/net/{iface}/speed")
            if speed and speed.lstrip("-").isdigit() and int(speed) > 0:
                speed_label = f"{speed} Mbps"
            else:
                speed_label = ""
            adapters.append({
                "name": iface,
                "interface_description": read_text(f"/sys/class/net/{iface}/device/uevent") or iface,
                "status": read_text(f"/sys/class/net/{iface}/operstate") or "",
                "mac_address": mac.upper(),
                "link_speed": speed_label,
                "ipv4": ipv4,
                "ipv6": ipv6,
                "wake_on_magic_packet": ethtool_wol(iface),
            })
        return adapters

    # Fallback minimal si no existe iproute2.
    for net_path in Path("/sys/class/net").glob("*"):
        iface = net_path.name
        if iface == "lo":
            continue
        mac = read_text(net_path / "address")
        if not mac or mac == "00:00:00:00:00:00":
            continue
        adapters.append({
            "name": iface,
            "interface_description": iface,
            "status": read_text(net_path / "operstate") or "",
            "mac_address": mac.upper(),
            "link_speed": "",
            "ipv4": [],
            "ipv6": [],
            "wake_on_magic_packet": ethtool_wol(iface),
        })
    return adapters


def installed_software():
    items = []
    if SKIP_SOFTWARE:
        return items
    if shutil.which("dpkg-query"):
        output = run(["dpkg-query", "-W", "-f=${binary:Package}\t${Version}\t${Maintainer}\n"], timeout=30)
        for line in output.splitlines():
            parts = line.split("\t")
            if len(parts) >= 2 and parts[0]:
                items.append({
                    "name": parts[0],
                    "version": parts[1] or None,
                    "publisher": parts[2] if len(parts) > 2 and parts[2] else None,
                    "install_date": None,
                    "uninstall_key": parts[0],
                })
    elif shutil.which("rpm"):
        output = run(["rpm", "-qa", "--qf", "%{NAME}\t%{VERSION}-%{RELEASE}\t%{VENDOR}\n"], timeout=30)
        for line in output.splitlines():
            parts = line.split("\t")
            if len(parts) >= 2 and parts[0]:
                items.append({
                    "name": parts[0],
                    "version": parts[1] or None,
                    "publisher": parts[2] if len(parts) > 2 and parts[2] else None,
                    "install_date": None,
                    "uninstall_key": parts[0],
                })
    seen = set()
    unique = []
    for item in sorted(items, key=lambda x: (x["name"].lower(), x.get("version") or "")):
        key = (item["name"].lower(), item.get("version") or "")
        if key in seen:
            continue
        seen.add(key)
        unique.append(item)
        if len(unique) >= 2500:
            break
    return unique


def current_user():
    try:
        return pwd.getpwuid(os.getuid()).pw_name
    except KeyError:
        return os.environ.get("USER") or None


def resolve_agent_endpoint(endpoint, configured):
    if configured:
        return configured
    if endpoint.endswith("api.php"):
        return endpoint[:-7] + "agent.php"
    marker = "api.php?"
    if marker in endpoint:
        return endpoint.split(marker, 1)[0] + "agent.php"
    return endpoint.rstrip("/") + "/agent.php"


def post_json(url, payload_value, agent_label):
    data = json.dumps(payload_value, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
    request = urllib.request.Request(
        url,
        data=data,
        headers={
            "Content-Type": "application/json; charset=utf-8",
            "X-Inventory-Token": TOKEN,
            "X-Inventory-Agent": agent_label,
        },
        method="POST",
    )
    context = ssl._create_unverified_context() if IGNORE_TLS else None
    try:
        with urllib.request.urlopen(request, timeout=TIMEOUT, context=context) as response:
            raw_response = response.read().decode("utf-8", errors="replace")
    except urllib.error.HTTPError as exc:
        detail = exc.read().decode("utf-8", errors="replace")
        raise SystemExit(f"Peticion a la intranet fallida: HTTP {exc.code} {detail}")
    except urllib.error.URLError as exc:
        raise SystemExit(f"Peticion a la intranet fallida: {exc.reason}")

    try:
        parsed_response = json.loads(raw_response)
    except json.JSONDecodeError:
        raise SystemExit(f"Respuesta no JSON de la intranet: {raw_response[:300]}")
    return parsed_response


def limit_text(value, size=20000):
    text = "" if value is None else str(value)
    if len(text) > size:
        return text[-size:]
    return text


def run_deployments(hostname_value):
    agent_url = resolve_agent_endpoint(ENDPOINT, AGENT_ENDPOINT)
    poll = post_json(
        agent_url,
        {"action": "poll", "equipment_name": hostname_value, "limit": 3},
        "intranet-inventory-linux/1.1",
    )
    if not poll.get("ok"):
        raise SystemExit(f"La intranet rechazo la consulta de trabajos: {poll.get('error')}")

    jobs = poll.get("jobs") or []
    if not jobs:
        print("Sin trabajos remotos pendientes.")
        return

    for job in jobs:
        command = str(job.get("command") or "")
        timeout = int(job.get("timeout_sec") or 1800)
        timeout = max(30, min(14400, timeout))
        print(f"Ejecutando trabajo remoto #{job.get('id')}: {job.get('package_name')}")
        try:
            completed = subprocess.run(
                command,
                shell=True,
                executable="/bin/sh",
                check=False,
                capture_output=True,
                text=True,
                timeout=timeout,
            )
            exit_code = int(completed.returncode)
            stdout = completed.stdout
            stderr = completed.stderr
            error_message = ""
        except subprocess.TimeoutExpired as exc:
            exit_code = 124
            stdout = exc.stdout or ""
            stderr = exc.stderr or ""
            error_message = f"Timeout tras {timeout} segundos"
        except OSError as exc:
            exit_code = 1
            stdout = ""
            stderr = ""
            error_message = str(exc)

        report = {
            "action": "result",
            "equipment_name": hostname_value,
            "job_id": int(job.get("id") or 0),
            "status": "succeeded" if exit_code == 0 else "failed",
            "exit_code": exit_code,
            "stdout": limit_text(stdout),
            "stderr": limit_text(stderr),
            "error_message": limit_text(error_message),
        }
        response = post_json(agent_url, report, "intranet-inventory-linux/1.1")
        if not response.get("ok"):
            raise SystemExit(f"No se pudo registrar el resultado del trabajo #{job.get('id')}: {response.get('error')}")
        print(f"Trabajo remoto #{job.get('id')} finalizado con salida {exit_code}.")


release = os_release()
profile = system_profile(release)
hostname = socket.gethostname()
payload = {
    "inventory_version": "linux-1.1",
    "collected_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
    "computer": {
        "name": hostname,
        "domain": machine_domain(),
        "manufacturer": profile["manufacturer"],
        "model": profile["model"],
        "serial_number": profile["serial"],
        "bios_version": read_text("/sys/class/dmi/id/bios_version") or None,
    },
    "os": {
        "caption": profile["caption"],
        "version": profile["version"],
        "build": platform.release(),
        "architecture": platform.machine(),
        "install_date": None,
        "last_boot_up_time": None,
    },
    "hardware": {
        "processor": cpu_name(),
        "ram_gb": mem_gb(),
        "disks": [root_disk()],
    },
    "users": {
        "current_user": current_user(),
        "last_logged_on": current_user(),
        "previous_domain_users": [],
    },
    "network": network_inventory(),
    "software": installed_software(),
}

parsed = post_json(ENDPOINT, payload, "intranet-inventory-linux/1.1")

if not parsed.get("ok"):
    raise SystemExit(f"La intranet rechazo el inventario: {parsed.get('error')}")

print(f"Inventario enviado: {parsed.get('equipo', hostname)} ({parsed.get('software_count', 0)} programas).")
if RUN_DEPLOYMENTS:
    run_deployments(hostname)
PY
