#!/usr/bin/env python3
"""Checkmk special agent for VMware vCSA and vSphere cluster health.

Target: Checkmk 2.5, vSphere Automation API 8.x and pyVmomi for SOAP cluster data.
Credentials are resolved through the Checkmk password store integration.
"""

from __future__ import annotations

import argparse
import base64
import json
import ssl
import sys
import urllib.error
import urllib.request
from dataclasses import dataclass
from typing import Any, Iterable

from cmk.password_store.v1_unstable import parser_add_secret_option, resolve_secret_option

PASSWORD_OPTION = "password"

HEALTH_ENDPOINTS: tuple[tuple[str, str], ...] = (
    ("applmgmt", "/api/appliance/health/applmgmt"),
    ("database", "/api/appliance/health/database"),
    ("databasestorage", "/api/appliance/health/database-storage"),
    ("load", "/api/appliance/health/load"),
    ("mem", "/api/appliance/health/mem"),
    ("softwarepackages", "/api/appliance/health/software-packages"),
    ("storage", "/api/appliance/health/storage"),
    ("swap", "/api/appliance/health/swap"),
    ("system", "/api/appliance/health/system"),
)

# Try the current API endpoint first, then the historical /rest endpoint used by the original MKP.
VCENTER_SERVICE_ENDPOINTS: tuple[str, ...] = (
    "/api/vcenter/services",
    "/rest/vcenter/services",
)

APPLIANCE_SERVICES_ENDPOINT = "/api/appliance/services"

# Appliance/systemd services kept when using /api/appliance/services.
# The endpoint returns many low-value units; do not output them by default.
APPLIANCE_SERVICE_ALLOWLIST: set[str] = {
    "auditd",
    "cap-lighttpd",
    "cap-workflow-engine",
    "crond",
    "dbus",
    "lsassd",
    "lwsmd",
    "ntpd",
    "observability",
    "rsyslog",
    "sshd",
    "vgauthd",
    "vmafdd",
    "vmcad",
    "vmdird",
    "vmtoolsd",
    "vmware-firewall",
    "vmware-pod",
    "vmware-vdtc",
    "vmware-vmon",
}


class VsphereApiError(RuntimeError):
    """Raised when the vSphere REST API request cannot be completed."""


@dataclass(frozen=True)
class ApiResponse:
    status: int
    payload: Any


@dataclass(frozen=True)
class ServiceRow:
    service_id: str
    source: str
    startup: str
    state: str
    health: str
    description: str


@dataclass(frozen=True)
class StorageRow:
    disk: str
    partition: str
    description: str


@dataclass(frozen=True)
class ClusterRow:
    name: str
    overall_status: str
    config_status: str
    ha_enabled: str
    ha_admission_control: str
    ha_host_monitoring: str
    drs_enabled: str
    drs_default_vm_behavior: str
    drs_vmotion_rate: str
    dpm_enabled: str
    dpm_default_behavior: str
    hosts_total: str
    hosts_connected: str
    hosts_maintenance: str
    effective_cpu_mhz: str
    effective_memory_mb: str
    alarms: str
    cpu_used_mhz: str
    cpu_capacity_mhz: str
    mem_used_mb: str
    mem_capacity_mb: str
    hosts_unavailable: str
    unavailable_host_names: str
    maintenance_host_names: str


@dataclass(frozen=True)
class CollectionStatus:
    component: str
    state: str
    message: str


def _clean(value: Any) -> str:
    """Return a Checkmk sep(59)-safe one-line string."""
    if value is None:
        return "-"
    if isinstance(value, dict):
        for key in ("default_message", "localized", "message", "id"):
            if key in value:
                return _clean(value[key])
        return _clean(json.dumps(value, sort_keys=True, ensure_ascii=False))
    if isinstance(value, (list, tuple)):
        return ", ".join(_clean(item) for item in value)
    return str(value).replace(";", ",").replace("\n", " ").replace("\r", " ").strip() or "-"


def _unwrap_value(payload: Any) -> Any:
    """Handle both native /api responses and old wrapped {'value': ...} payloads."""
    if isinstance(payload, dict) and set(payload) == {"value"}:
        return payload["value"]
    return payload


class VsphereClient:
    def __init__(
        self,
        hostname: str,
        username: str,
        password: str,
        port: int,
        verify_ssl: bool,
        timeout: int,
    ) -> None:
        self._base_url = f"https://{hostname}:{port}"
        self._username = username
        self._password = password
        self._timeout = timeout
        self._ssl_context = ssl.create_default_context() if verify_ssl else ssl._create_unverified_context()
        self._session_id: str | None = None
        self._optional_errors: dict[str, str] = {}

    def login(self) -> None:
        credentials = f"{self._username}:{self._password}".encode("utf-8")
        auth_header = base64.b64encode(credentials).decode("ascii")
        response = self._request(
            "POST",
            "/api/session",
            headers={"Authorization": f"Basic {auth_header}"},
            authenticated=False,
        )
        if not isinstance(response.payload, str) or not response.payload:
            raise VsphereApiError("Authentication did not return a vmware-api-session-id token")
        self._session_id = response.payload

    def get_json(self, path: str) -> Any:
        return self._request("GET", path, authenticated=True).payload

    def try_get_json(self, path: str, *, debug: bool = False) -> Any:
        try:
            return self.get_json(path)
        except Exception as exc:  # optional endpoints must not break all monitoring
            self._optional_errors[path] = _clean(exc)
            if debug:
                print(f"WARN: optional endpoint {path} failed: {exc}", file=sys.stderr)
            return None

    def optional_error_summary(self, paths: Iterable[str]) -> str:
        return "; ".join(
            f"{path}: {self._optional_errors[path]}"
            for path in paths
            if path in self._optional_errors
        )

    def _request(
        self,
        method: str,
        path: str,
        *,
        headers: dict[str, str] | None = None,
        authenticated: bool,
    ) -> ApiResponse:
        request_headers = {"Accept": "application/json"}
        if headers:
            request_headers.update(headers)
        if authenticated:
            if not self._session_id:
                raise VsphereApiError("Missing vSphere API session token")
            request_headers["vmware-api-session-id"] = self._session_id

        request = urllib.request.Request(
            url=f"{self._base_url}{path}",
            method=method,
            headers=request_headers,
        )
        try:
            with urllib.request.urlopen(
                request,
                timeout=self._timeout,
                context=self._ssl_context,
            ) as response:
                raw_payload = response.read().decode("utf-8", errors="replace")
                payload = json.loads(raw_payload) if raw_payload else None
                return ApiResponse(status=response.status, payload=payload)
        except urllib.error.HTTPError as exc:
            body = exc.read().decode("utf-8", errors="replace")
            raise VsphereApiError(f"HTTP {exc.code} for {method} {path}: {body or exc.reason}") from exc
        except urllib.error.URLError as exc:
            raise VsphereApiError(f"Connection error for {method} {path}: {exc.reason}") from exc
        except json.JSONDecodeError as exc:
            raise VsphereApiError(f"Invalid JSON response for {method} {path}: {exc}") from exc


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Checkmk special agent for vCSA service and appliance health")
    parser.add_argument("--hostname", required=True, help="vCenter/VCSA hostname or IP address")
    parser.add_argument("--username", required=True, help="vSphere API username")
    parser_add_secret_option(
        parser,
        short="-p",
        long=f"--{PASSWORD_OPTION}",
        help="vSphere API password",
        required=True,
    )
    parser.add_argument("--port", type=int, default=443, help="HTTPS port, default: 443")
    parser.add_argument("--timeout", type=int, default=30, help="HTTP timeout in seconds, default: 30")
    parser.add_argument("--no-cert-check", action="store_true", help="Disable TLS certificate verification")
    parser.add_argument("--debug", action="store_true", help="Do not hide optional endpoint errors")
    return parser.parse_args()


def _as_iterable_entries(payload: Any) -> Iterable[Any]:
    services = _unwrap_value(payload)
    if isinstance(services, list):
        yield from services
    elif isinstance(services, dict):
        yield from services.items()
    else:
        raise VsphereApiError("Unexpected services response format")


def _service_row_from_entry(entry: Any, *, source: str) -> ServiceRow | None:
    # /rest/vcenter/services historical format:
    # {"key": "vpxd", "value": {"startup_type": "AUTOMATIC", "state": "STARTED", ...}}
    if isinstance(entry, dict) and "key" in entry and isinstance(entry.get("value"), dict):
        service_id = entry.get("key")
        info = entry["value"]
    # Some /api formats use explicit service/name/id fields.
    elif isinstance(entry, dict):
        service_id = entry.get("service") or entry.get("name") or entry.get("id") or entry.get("key")
        info = entry
    # Dict mapping format: service_id -> info.
    elif isinstance(entry, tuple) and len(entry) == 2:
        service_id, info = entry
        if not isinstance(info, dict):
            info = {"state": info}
    else:
        return None

    if not service_id:
        return None

    return ServiceRow(
        service_id=_clean(service_id),
        source=source,
        startup=_clean(info.get("startup_type") or info.get("startup") or "-"),
        state=_clean(info.get("state") or info.get("svcstate") or "UNKNOWN").upper(),
        health=_clean(info.get("health") or "-").upper(),
        description=_clean(info.get("description") or "-"),
    )


def _parse_service_rows(payload: Any, *, source: str) -> list[ServiceRow]:
    rows: list[ServiceRow] = []
    for entry in _as_iterable_entries(payload):
        row = _service_row_from_entry(entry, source=source)
        if row is not None:
            rows.append(row)
    return sorted(rows, key=lambda row: row.service_id)


def _fetch_vcenter_service_rows(client: VsphereClient, *, debug: bool) -> list[ServiceRow]:
    for endpoint in VCENTER_SERVICE_ENDPOINTS:
        payload = client.try_get_json(endpoint, debug=debug)
        if payload is None:
            continue
        try:
            rows = _parse_service_rows(payload, source="vcenter")
        except Exception as exc:
            if debug:
                print(f"WARN: could not parse {endpoint}: {exc}", file=sys.stderr)
            continue
        if rows:
            return rows
    return []


def _fetch_appliance_service_rows(client: VsphereClient, *, debug: bool) -> list[ServiceRow]:
    payload = client.try_get_json(APPLIANCE_SERVICES_ENDPOINT, debug=debug)
    if payload is None:
        return []
    try:
        rows = _parse_service_rows(payload, source="appliance")
    except Exception as exc:
        if debug:
            print(f"WARN: could not parse {APPLIANCE_SERVICES_ENDPOINT}: {exc}", file=sys.stderr)
        return []
    return [row for row in rows if row.service_id in APPLIANCE_SERVICE_ALLOWLIST]


def _health_value(payload: Any) -> str:
    value = _unwrap_value(payload)
    if isinstance(value, str):
        return value.lower()
    if isinstance(value, dict):
        for key in ("status", "health", "value"):
            if key in value:
                return _clean(value[key]).lower()
    return _clean(value).lower()


def _print_service_rows(section_name: str, rows: list[ServiceRow]) -> None:
    if not rows:
        return
    print(f"<<<{section_name}:sep(59)>>>")
    for row in rows:
        print(
            ";".join(
                (
                    row.service_id,
                    row.source,
                    row.startup,
                    row.state,
                    row.health,
                    row.description,
                )
            )
        )


def _print_health(client: VsphereClient, *, debug: bool) -> tuple[int, int]:
    print("<<<vcsa_appliance_health:sep(59)>>>")
    available = 0
    for item, endpoint in HEALTH_ENDPOINTS:
        payload = client.try_get_json(endpoint, debug=debug)
        if payload is None:
            print(f"{item};unknown")
            continue
        available += 1
        print(f"{item};{_health_value(payload)}")

    lastcheck = client.try_get_json("/api/appliance/health/system/lastcheck", debug=debug)
    if lastcheck is not None:
        available += 1
        print("<<<vcsa_health_lastcheck:sep(59)>>>")
        print(f"system;{_clean(_unwrap_value(lastcheck))}")
    return available, len(HEALTH_ENDPOINTS) + 1


def _print_uptime(client: VsphereClient, *, debug: bool) -> bool:
    uptime = client.try_get_json("/api/appliance/system/uptime", debug=debug)
    if uptime is None:
        return False
    value = _unwrap_value(uptime)
    if isinstance(value, dict):
        value = value.get("uptime") or value.get("seconds") or value.get("value")
    try:
        seconds = int(float(value))
    except (TypeError, ValueError):
        return False
    print("<<<vcsa_appliance_uptime>>>")
    print(seconds)
    return True


def _print_version(client: VsphereClient, *, debug: bool) -> bool:
    version = client.try_get_json("/api/appliance/system/version", debug=debug)
    if not isinstance(_unwrap_value(version), dict):
        return False
    info = _unwrap_value(version)
    print("<<<vcsa_version:sep(59)>>>")
    print(
        ";".join(
            _clean(info.get(field))
            for field in ("product", "summary", "releasedate", "version", "build", "install_time", "type", "name")
        )
    )
    return True


def _print_update(client: VsphereClient, *, debug: bool) -> bool:
    update = client.try_get_json("/api/appliance/update", debug=debug)
    if not isinstance(_unwrap_value(update), dict):
        return False
    info = _unwrap_value(update)
    print("<<<vcsa_update:sep(59)>>>")
    print(
        ";".join(
            (
                "vcenter-update",
                _clean(info.get("state", "UNKNOWN")).upper(),
                _clean(info.get("version", "-")),
                _clean(info.get("latest_query_time") or info.get("last_check_time") or info.get("check_time") or "-"),
            )
        )
    )
    return True


def _print_timesync(client: VsphereClient, *, debug: bool) -> tuple[bool, bool]:
    timesync = client.try_get_json("/api/appliance/timesync", debug=debug)
    timesync_ok = timesync is not None
    if timesync is not None:
        print("<<<vcsa_timesync:sep(59)>>>")
        print(f"mode;{_clean(_unwrap_value(timesync)).upper()}")

    ntp = client.try_get_json("/api/appliance/ntp", debug=debug)
    if ntp is None:
        return timesync_ok, False
    value = _unwrap_value(ntp)
    if isinstance(value, list):
        servers = [_clean(server) for server in value if _clean(server) != "-"]
    else:
        servers = [_clean(value)] if _clean(value) != "-" else []
    print("<<<vcsa_ntp:sep(59)>>>")
    print(f"servers;{','.join(servers) if servers else '-'}")
    return timesync_ok, True


def _parse_storage_rows(payload: Any) -> list[StorageRow]:
    value = _unwrap_value(payload)
    if not isinstance(value, list):
        return []
    rows: list[StorageRow] = []
    for entry in value:
        if not isinstance(entry, dict):
            continue
        rows.append(
            StorageRow(
                disk=_clean(entry.get("disk")),
                partition=_clean(entry.get("partition")),
                description=_clean(entry.get("description")),
            )
        )
    return sorted(rows, key=lambda row: (int(row.disk) if row.disk.isdigit() else 9999, row.partition))


def _print_storage_layout(client: VsphereClient, *, debug: bool) -> bool:
    storage = client.try_get_json("/api/appliance/system/storage", debug=debug)
    if storage is None:
        return False
    rows = _parse_storage_rows(storage)
    if not rows:
        return False
    print("<<<vcsa_storage_layout:sep(59)>>>")
    for row in rows:
        print(f"{row.disk};{row.partition};{row.description}")
    return True



def _safe_attr(obj: Any, name: str, default: Any = None) -> Any:
    try:
        return getattr(obj, name, default)
    except Exception:
        return default


def _bool_text(value: Any) -> str:
    if value is None:
        return "unknown"
    if isinstance(value, str):
        normalized = value.strip().lower()
        if normalized in {"true", "yes", "1", "enabled"}:
            return "true"
        if normalized in {"false", "no", "0", "disabled"}:
            return "false"
        return normalized or "unknown"
    return "true" if bool(value) else "false"


def _alarm_summary(triggered_alarms: Any) -> str:
    if not triggered_alarms:
        return "-"
    counts: dict[str, int] = {}
    for alarm_state in triggered_alarms:
        status = _clean(_safe_attr(alarm_state, "overallStatus", "unknown")).lower()
        counts[status] = counts.get(status, 0) + 1
    ordered = ["red", "yellow", "orange", "green", "gray", "unknown"]
    parts = [f"{status}={counts[status]}" for status in ordered if counts.get(status)]
    for status in sorted(counts):
        if status not in ordered:
            parts.append(f"{status}={counts[status]}")
    return ",".join(parts) if parts else "-"


def _nested_attr(obj: Any, names: tuple[str, ...], default: Any = None) -> Any:
    current = obj
    for name in names:
        if current is None:
            return default
        current = _safe_attr(current, name, default)
    return current


def _fetch_vsphere_cluster_rows(
    *,
    hostname: str,
    username: str,
    password: str,
    port: int,
    verify_ssl: bool,
    timeout: int,
    debug: bool,
) -> tuple[list[ClusterRow], str | None]:
    """Collect ClusterComputeResource data via the vSphere SOAP API.

    The vSphere REST API exposes basic cluster inventory, but HA/DRS/DPM and
    ClusterComputeResource runtime/config states are SOAP managed-object data.
    Keep this optional so the existing vCSA REST checks continue to work even
    when pyVmomi is not available in the Checkmk site Python environment.
    """
    try:
        from pyVim.connect import Disconnect, SmartConnect  # type: ignore[import-not-found]
        from pyVmomi import vim  # type: ignore[import-not-found]
    except Exception as exc:
        if debug:
            print(f"WARN: pyVmomi is required for ClusterComputeResource monitoring: {exc}", file=sys.stderr)
        return [], f"pyVmomi is unavailable: {_clean(exc)}"

    ssl_context = ssl.create_default_context() if verify_ssl else ssl._create_unverified_context()
    service_instance = None
    view = None
    try:
        service_instance = SmartConnect(
            host=hostname,
            user=username,
            pwd=password,
            port=port,
            sslContext=ssl_context,
            httpConnectionTimeout=timeout,
        )
        content = service_instance.RetrieveContent()
        view = content.viewManager.CreateContainerView(content.rootFolder, [vim.ClusterComputeResource], True)
        rows: list[ClusterRow] = []
        warnings: list[str] = []
        for cluster in view.view:
            cluster_name = _clean(_safe_attr(cluster, "name", "unknown-cluster"))
            config = _safe_attr(cluster, "configurationEx") or _safe_attr(cluster, "configuration")
            das_config = _safe_attr(config, "dasConfig")
            drs_config = _safe_attr(config, "drsConfig")
            dpm_config = _safe_attr(config, "dpmConfig")
            summary = _safe_attr(cluster, "summary")
            hosts = list(_safe_attr(cluster, "host", []) or [])

            hosts_total = len(hosts)
            hosts_connected = 0
            hosts_maintenance = 0
            hosts_unavailable = 0
            unavailable_host_names: list[str] = []
            maintenance_host_names: list[str] = []
            for host in hosts:
                host_name = _clean(_safe_attr(host, "name", "unknown-host"))
                runtime = _safe_attr(host, "runtime")
                connection_state = _clean(_safe_attr(runtime, "connectionState", "unknown")).lower()
                in_maintenance = bool(_safe_attr(runtime, "inMaintenanceMode", False))
                if connection_state == "connected":
                    hosts_connected += 1
                if in_maintenance:
                    hosts_maintenance += 1
                    maintenance_host_names.append(host_name)
                elif connection_state != "connected":
                    hosts_unavailable += 1
                    unavailable_host_names.append(host_name)

            resource_usage = None
            try:
                resource_usage = cluster.GetResourceUsage()
            except Exception as exc:
                warnings.append(f"{cluster_name} resource usage: {_clean(exc)}")
                if debug:
                    print(f"WARN: could not collect resource usage for cluster {cluster_name}: {exc}", file=sys.stderr)

            rows.append(
                ClusterRow(
                    name=cluster_name,
                    overall_status=_clean(_safe_attr(cluster, "overallStatus", "unknown")).lower(),
                    config_status=_clean(_safe_attr(cluster, "configStatus", "unknown")).lower(),
                    ha_enabled=_bool_text(_safe_attr(das_config, "enabled")),
                    ha_admission_control=_bool_text(_safe_attr(das_config, "admissionControlEnabled")),
                    ha_host_monitoring=_clean(_safe_attr(das_config, "hostMonitoring", "unknown")).lower(),
                    drs_enabled=_bool_text(_safe_attr(drs_config, "enabled")),
                    drs_default_vm_behavior=_clean(_safe_attr(drs_config, "defaultVmBehavior", "unknown")).lower(),
                    drs_vmotion_rate=_clean(_safe_attr(drs_config, "vmotionRate", "-")),
                    dpm_enabled=_bool_text(_safe_attr(dpm_config, "enabled")),
                    dpm_default_behavior=_clean(_safe_attr(dpm_config, "defaultDpmBehavior", "unknown")).lower(),
                    hosts_total=str(hosts_total),
                    hosts_connected=str(hosts_connected),
                    hosts_maintenance=str(hosts_maintenance),
                    effective_cpu_mhz=_clean(_safe_attr(summary, "effectiveCpu", "-")),
                    effective_memory_mb=_clean(_safe_attr(summary, "effectiveMemory", "-")),
                    alarms=_alarm_summary(_safe_attr(cluster, "triggeredAlarmState", [])),
                    cpu_used_mhz=_clean(_safe_attr(resource_usage, "cpuUsedMHz", "-")),
                    cpu_capacity_mhz=_clean(_safe_attr(resource_usage, "cpuCapacityMHz", _safe_attr(summary, "effectiveCpu", "-"))),
                    mem_used_mb=_clean(_safe_attr(resource_usage, "memUsedMB", "-")),
                    mem_capacity_mb=_clean(_safe_attr(resource_usage, "memCapacityMB", _safe_attr(summary, "effectiveMemory", "-"))),
                    hosts_unavailable=str(hosts_unavailable),
                    unavailable_host_names=",".join(sorted(unavailable_host_names)) or "-",
                    maintenance_host_names=",".join(sorted(maintenance_host_names)) or "-",
                )
            )
        return sorted(rows, key=lambda row: row.name.lower()), "; ".join(warnings) or None
    except Exception as exc:
        if debug:
            print(f"WARN: could not collect ClusterComputeResource data: {exc}", file=sys.stderr)
        return [], f"SOAP cluster collection failed: {_clean(exc)}"
    finally:
        if view is not None:
            try:
                view.Destroy()
            except Exception:
                pass
        if service_instance is not None:
            try:
                Disconnect(service_instance)
            except Exception:
                pass


def _print_vsphere_clusters(
    *,
    hostname: str,
    username: str,
    password: str,
    port: int,
    verify_ssl: bool,
    timeout: int,
    debug: bool,
) -> tuple[int, str | None]:
    rows, error = _fetch_vsphere_cluster_rows(
        hostname=hostname,
        username=username,
        password=password,
        port=port,
        verify_ssl=verify_ssl,
        timeout=timeout,
        debug=debug,
    )
    if not rows:
        return 0, error
    print("<<<vsphere_clusters:sep(59)>>>")
    for row in rows:
        print(
            ";".join(
                (
                    row.name,
                    row.overall_status,
                    row.config_status,
                    row.ha_enabled,
                    row.ha_admission_control,
                    row.ha_host_monitoring,
                    row.drs_enabled,
                    row.drs_default_vm_behavior,
                    row.drs_vmotion_rate,
                    row.dpm_enabled,
                    row.dpm_default_behavior,
                    row.hosts_total,
                    row.hosts_connected,
                    row.hosts_maintenance,
                    row.effective_cpu_mhz,
                    row.effective_memory_mb,
                    row.alarms,
                    row.cpu_used_mhz,
                    row.cpu_capacity_mhz,
                    row.mem_used_mb,
                    row.mem_capacity_mb,
                    row.hosts_unavailable,
                    row.unavailable_host_names,
                    row.maintenance_host_names,
                )
            )
        )
    return len(rows), error


def _print_collection_status(statuses: list[CollectionStatus]) -> None:
    print("<<<vcsa_collection_status:sep(59)>>>")
    for status in statuses:
        print(f"{_clean(status.component)};{_clean(status.state)};{_clean(status.message)}")


def main() -> int:
    args = parse_args()
    password = resolve_secret_option(args, PASSWORD_OPTION).reveal()
    client = VsphereClient(
        hostname=args.hostname,
        username=args.username,
        password=password,
        port=args.port,
        verify_ssl=not args.no_cert_check,
        timeout=args.timeout,
    )
    try:
        client.login()
        statuses = [CollectionStatus("REST session", "0", "Authentication successful")]

        vcenter_services = _fetch_vcenter_service_rows(client, debug=args.debug)
        _print_service_rows("vcsa_services", vcenter_services)
        vcenter_error = client.optional_error_summary(VCENTER_SERVICE_ENDPOINTS)
        statuses.append(
            CollectionStatus(
                "vCenter services",
                "0" if vcenter_services else "1",
                f"{len(vcenter_services)} service(s) collected"
                if vcenter_services
                else f"No service collected{': ' + vcenter_error if vcenter_error else ''}",
            )
        )

        appliance_services = _fetch_appliance_service_rows(client, debug=args.debug)
        _print_service_rows("vcsa_appliance_services", appliance_services)
        appliance_error = client.optional_error_summary((APPLIANCE_SERVICES_ENDPOINT,))
        statuses.append(
            CollectionStatus(
                "Appliance services",
                "0" if appliance_services else "1",
                f"{len(appliance_services)} filtered service(s) collected"
                if appliance_services
                else f"No filtered service collected{': ' + appliance_error if appliance_error else ''}",
            )
        )

        health_available, health_total = _print_health(client, debug=args.debug)
        statuses.append(
            CollectionStatus(
                "Appliance health",
                "0" if health_available == health_total else "1",
                f"{health_available}/{health_total} endpoint(s) collected",
            )
        )

        detail_results = [
            _print_uptime(client, debug=args.debug),
            _print_version(client, debug=args.debug),
            _print_update(client, debug=args.debug),
        ]
        timesync_ok, ntp_ok = _print_timesync(client, debug=args.debug)
        detail_results.extend((timesync_ok, ntp_ok, _print_storage_layout(client, debug=args.debug)))
        details_available = sum(detail_results)
        statuses.append(
            CollectionStatus(
                "Appliance details",
                "0" if details_available == len(detail_results) else "1",
                f"{details_available}/{len(detail_results)} endpoint group(s) collected",
            )
        )

        cluster_count, cluster_error = _print_vsphere_clusters(
            hostname=args.hostname,
            username=args.username,
            password=password,
            port=args.port,
            verify_ssl=not args.no_cert_check,
            timeout=args.timeout,
            debug=args.debug,
        )
        statuses.append(
            CollectionStatus(
                "vSphere clusters",
                "0" if cluster_error is None else "1",
                (
                    f"{cluster_count} cluster(s) collected"
                    if cluster_error is None
                    else f"{cluster_count} cluster(s) collected, {cluster_error}"
                ),
            )
        )
        _print_collection_status(statuses)
    except Exception as exc:
        if args.debug:
            raise
        print(f"ERROR: {exc}", file=sys.stderr)
        return 2
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
