#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
# License: GNU General Public License v2
#
###############################################################################
# agent_dell_powervault_me5 - Special agent for Dell PowerVault ME5 storage
###############################################################################
# Author: Sher Zaman (sher_zaman@outlook.com), FirmaTrust
###############################################################################
#
# Collects health, capacity and hardware data from Dell PowerVault ME5 series
# storage systems through the controller HTTPS management API (the same CLI
# "show" interface used by PowerVault Manager). One agent section is emitted
# per show command, each carrying the JSON object array returned by the array.
#
# Authentication uses the documented ME5 local-account flow: SHA-256 of
# "user_password", GET /api/login/<hash>. The returned session key is sent in
# the sessionKey header on every request and reused for the whole run.
#
from __future__ import annotations

import argparse
import hashlib
import json
import sys
from typing import Any

try:
    import requests
    from requests.packages.urllib3.exceptions import InsecureRequestWarning  # type: ignore
    requests.packages.urllib3.disable_warnings(InsecureRequestWarning)  # type: ignore
except Exception as exc:  # pragma: no cover
    sys.stderr.write(f"python 'requests' module is required: {exc}\n")
    sys.exit(1)


# section suffix -> (api path after /api/, top-level JSON key to emit)
COMMANDS: list[tuple[str, str, str]] = [
    ("system", "show/system", "system"),
    ("controllers", "show/controllers", "controllers"),
    ("disk_groups", "show/disk-groups", "disk-groups"),
    ("pools", "show/pools", "pools"),
    ("volumes", "show/volumes", "volumes"),
    ("disks", "show/disks", "drives"),
    ("power_supplies", "show/power-supplies", "power-supplies"),
    ("sensors", "show/sensor-status", "sensors"),
    ("unwritable_cache", "show/unwritable-cache", "unwritable-cache"),
    ("snapshots", "show/snapshots", "snapshots"),
    ("initiators", "show/initiators", "initiator"),
    ("alerts", "show/alerts", "alerts"),
    ("enclosures", "show/enclosures", "enclosures"),
    ("schedules", "show/schedules", "schedules"),
    ("host_port_statistics", "show/host-port-statistics", "host-port-statistics"),
    ("volume_statistics", "show/volume-statistics", "volume-statistics"),
    ("disk_statistics", "show/disk-statistics", "disk-statistics"),
    ("controller_statistics", "show/controller-statistics", "controller-statistics"),
]

# Sections that are not emitted on their own: the agent joins them onto the
# object they belong to, so each check reads a single section.
JOIN_ONLY = {
    "snapshots",
    "schedules",
    "host_port_statistics",
    "volume_statistics",
    "disk_statistics",
    "controller_statistics",
    "volumes",
    "disks",
}

SECTION_PREFIX = "dell_powervault_me5"


def _parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser("agent_dell_powervault_me5")
    parser.add_argument("--host", required=True, help="Controller management IP or hostname")
    parser.add_argument("--user", required=True, help="API user")
    parser.add_argument("--password", required=True, help="API password")
    parser.add_argument(
        "--auth-method",
        choices=["sha256", "basic"],
        default="sha256",
        help="Login method (sha256 for local accounts)",
    )
    parser.add_argument("--protocol", choices=["https", "http"], default="https")
    parser.add_argument("--timeout", type=int, default=30, help="Per-request timeout (s)")
    parser.add_argument(
        "--verify-ssl",
        action="store_true",
        default=False,
        help="Verify the TLS certificate (off by default; ME5 ships a self-signed cert)",
    )
    parser.add_argument("--cacert", default=None, help="Path to a CA bundle for verification")
    return parser.parse_args()


def _base_url(args: argparse.Namespace) -> str:
    return f"{args.protocol}://{args.host}"


def _verify(args: argparse.Namespace) -> Any:
    if not args.verify_ssl:
        return False
    return args.cacert if args.cacert else True


def _login(session: requests.Session, args: argparse.Namespace, password: str) -> str:
    base = _base_url(args)
    headers = {"datatype": "json"}
    verify = _verify(args)

    if args.auth_method == "basic":
        resp = session.get(
            f"{base}/api/login",
            auth=(args.user, password),
            headers=headers,
            verify=verify,
            timeout=args.timeout,
        )
    else:
        auth_hash = hashlib.sha256(f"{args.user}_{password}".encode("utf-8")).hexdigest()
        resp = session.get(
            f"{base}/api/login/{auth_hash}",
            headers=headers,
            verify=verify,
            timeout=args.timeout,
        )

    resp.raise_for_status()
    try:
        payload = resp.json()
    except ValueError:
        payload = json.loads(resp.content.decode("utf-8", errors="replace"))

    status = payload.get("status", [{}])[0]
    if status.get("response-type-numeric") != 0:
        raise RuntimeError(f"login failed: {status.get('response', 'unknown error')}")

    session_key = status.get("response")
    if not session_key:
        raise RuntimeError("login succeeded but no session key was returned")
    return session_key


def _fetch(
    session: requests.Session,
    args: argparse.Namespace,
    session_key: str,
    api_path: str,
) -> dict[str, Any] | None:
    base = _base_url(args)
    headers = {"sessionKey": session_key, "datatype": "json"}
    resp = session.get(
        f"{base}/api/{api_path}",
        headers=headers,
        verify=_verify(args),
        timeout=args.timeout,
    )
    resp.raise_for_status()
    try:
        return resp.json()
    except ValueError:
        return json.loads(resp.content.decode("utf-8", errors="replace"))


def _return_code_ok(payload: dict[str, Any]) -> bool:
    # 0 = success, -1000..-1999 = warning (data still usable), other = failure
    status = payload.get("status", [{}])
    if not status:
        return True
    rc = status[-1].get("return-code", 0)
    if rc == 0:
        return True
    if -1999 <= rc <= -1000:
        return True
    return False


def _logout(session: requests.Session, args: argparse.Namespace, session_key: str) -> None:
    try:
        session.get(
            f"{_base_url(args)}/api/exit",
            headers={"sessionKey": session_key, "datatype": "json"},
            verify=_verify(args),
            timeout=args.timeout,
        )
    except Exception:
        pass


def _emit(suffix: str, data: Any) -> None:
    print(f"<<<{SECTION_PREFIX}_{suffix}:sep(0)>>>")
    print(json.dumps(data, separators=(",", ":")))


def _index_by(rows: Any, key: str, strip_prefix: str | None = None) -> dict[str, Any]:
    """Index a list of API objects by one of their fields."""
    result: dict[str, Any] = {}
    if not isinstance(rows, list):
        return result
    for row in rows:
        value = row.get(key)
        if value is None:
            continue
        text = str(value)
        if strip_prefix and text.startswith(strip_prefix):
            text = text[len(strip_prefix):]
        result[text] = row
    return result


def _emit_joined(collected: dict[str, Any]) -> None:
    """Emit the sections that combine two API commands.

    Joining in the agent keeps every check on a single section, which makes
    discovery simple and avoids depending on multi-section resolution.
    """
    # ---- host ports: controller port objects + per-port statistics ----
    controllers = collected.get("controllers")
    port_stats = _index_by(collected.get("host_port_statistics"), "durable-id", "hostport_")
    if isinstance(controllers, list):
        ports_out = []
        for ctrl in controllers:
            for port in ctrl.get("port", []) or []:
                pid = port.get("port")
                if not pid:
                    continue
                enriched = dict(port)
                enriched["statistics"] = port_stats.get(str(pid), {})
                ports_out.append(enriched)
        if ports_out:
            _emit("host_ports", ports_out)

    # ---- volumes: volume objects + per-volume statistics ----
    volumes = collected.get("volumes")
    volume_stats = _index_by(collected.get("volume_statistics"), "volume-name")
    if isinstance(volumes, list):
        volumes_out = []
        for vol in volumes:
            enriched = dict(vol)
            enriched["statistics"] = volume_stats.get(str(vol.get("volume-name", "")), {})
            volumes_out.append(enriched)
        if volumes_out:
            _emit("volumes", volumes_out)

    # ---- disks: disk objects + per-disk statistics ----
    disks = collected.get("disks")
    disk_stats = _index_by(collected.get("disk_statistics"), "durable-id")
    if isinstance(disks, list):
        disks_out = []
        for disk in disks:
            enriched = dict(disk)
            enriched["statistics"] = disk_stats.get(str(disk.get("durable-id", "")), {})
            disks_out.append(enriched)
        if disks_out:
            _emit("disks", disks_out)

    # ---- snapshots + schedules, grouped per source volume ----
    # A schedule's retention task names the volume it protects, so the two
    # commands are joined into one view of each volume's snapshot protection.
    snapshots = collected.get("snapshots")
    schedules = collected.get("schedules")
    if isinstance(snapshots, list) or isinstance(schedules, list):
        per_volume: dict[str, dict[str, Any]] = {}

        for snap in snapshots or []:
            parent = (
                snap.get("volume-parent")
                or snap.get("master-volume-name")
                or snap.get("base-volume")
            )
            if not parent:
                continue
            entry = per_volume.setdefault(str(parent), {"snapshots": [], "schedules": []})
            entry["snapshots"].append(snap)

        orphans: dict[str, Any] = {}
        for schedule in schedules or []:
            volumes_covered = set()
            for task in schedule.get("tasks", []) or []:
                for retention in task.get("snapshot-with-retention-tasks", []) or []:
                    master = retention.get("master-volume-name")
                    if master:
                        volumes_covered.add(str(master))
            if volumes_covered:
                for master in volumes_covered:
                    entry = per_volume.setdefault(master, {"snapshots": [], "schedules": []})
                    entry["schedules"].append(schedule)
            else:
                # A schedule that does not map to a volume (for example a
                # replication schedule) keeps its own service.
                name = schedule.get("name")
                if name:
                    orphans[str(name)] = schedule

        if per_volume or orphans:
            _emit("snapshots", {"volumes": per_volume, "orphan_schedules": orphans})

    # ---- system performance: controller statistics + port response times ----
    ctrl_stats = collected.get("controller_statistics")
    all_port_stats = collected.get("host_port_statistics")
    if isinstance(ctrl_stats, list) or isinstance(all_port_stats, list):
        _emit(
            "system_performance",
            {
                "controllers": ctrl_stats if isinstance(ctrl_stats, list) else [],
                "ports": all_port_stats if isinstance(all_port_stats, list) else [],
            },
        )


def main() -> int:
    args = _parse_args()

    password = args.password.strip()
    if not password:
        sys.stderr.write("no password supplied\n")
        return 1

    session = requests.Session()

    try:
        session_key = _login(session, args, password)
    except Exception as exc:
        sys.stderr.write(f"authentication error: {exc}\n")
        return 1

    exit_code = 0
    collected: dict[str, Any] = {}
    try:
        for suffix, api_path, key in COMMANDS:
            try:
                payload = _fetch(session, args, session_key, api_path)
            except Exception as exc:
                # A single command failing (e.g. unsupported on this firmware)
                # must not abort the whole run.
                sys.stderr.write(f"warning: {api_path} request failed: {exc}\n")
                continue

            if payload is None or not _return_code_ok(payload):
                sys.stderr.write(f"warning: {api_path} returned an error status\n")
                continue

            data = payload.get(key)
            if data is None:
                continue

            collected[suffix] = data
            if suffix in JOIN_ONLY:
                continue

            _emit(suffix, data)

        _emit_joined(collected)
    finally:
        _logout(session, args, session_key)

    return exit_code


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