#!/usr/bin/env python3
# pylint: disable=too-many-lines
"""AMD-specific GPU monitoring implementation for AMD-SMI and ROCm-SMI."""

import json
import re
import shutil
import subprocess
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple

from .base import BaseGPUMonitor, GPUInfo


class AMDGPUMonitor(BaseGPUMonitor):
    """AMD GPU monitoring implementation with AMD-SMI preferred.

    Includes batch query optimization for improved performance.
    """

    def __init__(self) -> None:
        """Initialize AMD GPU monitor with caching for batch queries."""
        super().__init__()
        self._cached_driver_version = ""
        self._driver_version_initialized = False
        self._cached_rocm_version = ""
        self._rocm_version_initialized = False

        # Batch query cache similar to NVIDIA implementation
        self._data_cache: Dict[str, Any] = {
            "quick_metrics": {},  # Utilization + memory
            "thermal_power": {},  # Temperature + power
            "processes": [],  # Process list
        }

        # Update intervals for differential caching (seconds)
        self._update_intervals = {
            "quick_metrics": 1.0,  # Update frequently
            "thermal_power": 2.0,  # Less frequent
            "processes": 1.5,  # Moderate
        }

        # Last update timestamps
        self._last_updates = {
            "quick_metrics": 0.0,
            "thermal_power": 0.0,
            "processes": 0.0,
        }

        # Track JSON support (fallback to text if JSON fails)
        self._json_supported = True

    def get_quick_metrics(self) -> Dict[int, Dict[str, float]]:
        """Get only essential metrics (utilization, memory) for quick updates.

        Returns:
            Dictionary mapping GPU indices to metric dictionaries
        """
        metrics: Dict[int, Dict[str, float]] = {}

        if not self.smi_path:
            self.logger.warning("Cannot get quick metrics - no AMD SMI tool available")
            return metrics

        if self._uses_amd_smi():
            return self._get_quick_metrics_amd_smi()

        # Get quick GPU metrics for all devices
        self.logger.debug("Getting quick GPU metrics")

        try:
            devices = self._get_device_info()

            # For each device, get minimal info needed for quick metrics
            for device in devices:
                try:
                    # Get device-specific index
                    index = int(device.get("index", "0"))

                    # Get memory information
                    memory_used, memory_total = self._get_memory_info(index)

                    # Get utilization (only need percentage, not other metrics)
                    utilization = self._get_gpu_use(index)

                    # Add to metrics dictionary
                    shared_used_mb, shared_total_mb = self._get_shared_gtt_memory_info(
                        index
                    )
                    if memory_total <= 0 and shared_total_mb > 0:
                        memory_used = shared_used_mb
                        memory_total = shared_total_mb

                    metrics[index] = {
                        "utilization": utilization,
                        "memory_used": memory_used,
                        "memory_total": memory_total,
                        "memory_percent": (memory_used / memory_total * 100.0)
                        if memory_total > 0
                        else 0.0,
                    }

                    self.logger.debug(
                        f"Quick metrics for GPU {index}: {metrics[index]}"
                    )

                except Exception as e:
                    device_idx = device.get("index", "unknown")
                    self.logger.warning(
                        f"Error getting quick metrics for device {device_idx}: {e}"
                    )
                    continue

            return metrics

        except Exception as e:
            self.logger.error(f"Error collecting quick metrics: {e}")
            return metrics

    def _find_smi(self) -> Optional[Path]:
        """Find AMD monitoring CLI, preferring rocm-smi for metric stability."""
        candidates = [
            (
                "rocm-smi",
                [
                    "/usr/bin/rocm-smi",
                    "/opt/rocm/bin/rocm-smi",
                    "/usr/local/bin/rocm-smi",
                ],
                [["--version"], ["-h"]],
            ),
            (
                "amd-smi",
                [
                    "/usr/bin/amd-smi",
                    "/opt/rocm/bin/amd-smi",
                    "/usr/local/bin/amd-smi",
                ],
                [["version"], ["--help"]],
            ),
        ]

        for tool_name, common_paths, probes in candidates:
            locations: List[Path] = []
            which_path = shutil.which(tool_name)
            if which_path:
                locations.append(Path(which_path))

            for path in common_paths:
                locations.append(Path(path))

            seen: Set[str] = set()
            for location in locations:
                key = str(location)
                if key in seen:
                    continue
                seen.add(key)

                if not location.exists():
                    continue

                self.logger.debug("Found %s candidate at %s", tool_name, location)

                for probe in probes:
                    try:
                        subprocess.run(
                            [str(location), *probe],
                            capture_output=True,
                            check=True,
                            timeout=2.0,
                        )
                        self.logger.info(
                            "Verified working %s at %s", tool_name, location
                        )
                        return location
                    except subprocess.SubprocessError as exc:
                        self.logger.debug(
                            "%s probe failed at %s (%s): %s",
                            tool_name,
                            location,
                            " ".join(probe),
                            exc,
                        )
                    except (PermissionError, OSError) as exc:
                        self.logger.debug(
                            "%s probe failed at %s (%s): %s",
                            tool_name,
                            location,
                            " ".join(probe),
                            exc,
                        )

        self.logger.warning("No working amd-smi or rocm-smi found")
        return None

    def _uses_amd_smi(self) -> bool:
        """Return True when the active backend is amd-smi."""
        return bool(self.smi_path and self.smi_path.name == "amd-smi")

    def _get_driver_version(self) -> str:
        """Get AMD driver version (cached forever as it doesn't change)."""
        if self._driver_version_initialized:
            return self._cached_driver_version

        if not self.smi_path:
            self._cached_driver_version = ""
            self._driver_version_initialized = True
            return ""

        if self._uses_amd_smi():
            for args in (["version"], ["static", "--driver"]):
                data = self._run_amd_smi_json(args)
                if not data:
                    continue

                candidate = self._find_string_by_keys(
                    data,
                    includes=[
                        "driver version",
                        "driver_version",
                        "amdgpu driver version",
                    ],
                    excludes=[
                        "library",
                        "tool",
                        "firmware",
                    ],
                )
                if candidate:
                    match = re.search(r"(\d+\.\d+(?:\.\d+)?)", candidate)
                    if match:
                        version = match.group(1)
                        self._cached_driver_version = version
                        self._driver_version_initialized = True
                        self.logger.info(f"Detected AMD driver version: {version}")
                        return version

        try:
            # Fallback: rocm-smi --showdriverversion
            result = subprocess.run(
                [str(self.smi_path), "--showdriverversion"],
                capture_output=True,
                text=True,
                timeout=2.0,
            )
            if result.returncode == 0 and result.stdout.strip():
                # Parse driver version from output
                for line in result.stdout.split("\n"):
                    if "Driver version" in line or "amdgpu version" in line:
                        # Extract version number
                        match = re.search(r"(\d+\.\d+(?:\.\d+)?)", line)
                        if match:
                            version = match.group(1)
                            self._cached_driver_version = version
                            self._driver_version_initialized = True
                            self.logger.info(f"Detected AMD driver version: {version}")
                            return version
        except Exception as e:
            self.logger.debug(f"Failed to get AMD driver version: {e}")

        self._cached_driver_version = ""
        self._driver_version_initialized = True
        return ""

    def _get_rocm_version(self) -> str:
        """Get ROCm platform version (cached forever as it doesn't change).

        Detects the actual ROCm platform version, not rocm-smi tool version.
        Uses multiple detection methods with priority order for compatibility
        across ROCm 4.x, 5.x, 6.x, and 7.x.
        """
        if self._rocm_version_initialized:
            return self._cached_rocm_version

        version = ""

        # Method 1: Package manager (most reliable across all versions)
        if not version:
            try:
                # Try dpkg (Debian/Ubuntu)
                result = subprocess.run(
                    ["dpkg-query", "--showformat=${Version}", "--show", "rocm-core"],
                    capture_output=True,
                    text=True,
                    timeout=2.0,
                )
                if result.returncode == 0 and result.stdout.strip():
                    # Extract version from package version string
                    # (e.g., "6.3.0-1" -> "6.3.0")
                    match = re.search(r"(\d+\.\d+\.\d+)", result.stdout)
                    if match:
                        version = match.group(1)
                        self.logger.debug(
                            f"Method 1a (dpkg rocm-core) found: {version}"
                        )
            except FileNotFoundError:
                # dpkg not available, try rpm
                try:
                    result = subprocess.run(
                        ["rpm", "-q", "--queryformat", "%{VERSION}", "rocm-core"],
                        capture_output=True,
                        text=True,
                        timeout=2.0,
                    )
                    if result.returncode == 0 and result.stdout.strip():
                        match = re.search(r"(\d+\.\d+\.\d+)", result.stdout)
                        if match:
                            version = match.group(1)
                            self.logger.debug(
                                f"Method 1b (rpm rocm-core) found version: {version}"
                            )
                except Exception as e:
                    self.logger.debug(f"Method 1 (package manager) failed: {e}")
            except Exception as e:
                self.logger.debug(f"Method 1a (dpkg) failed: {e}")

        # Method 2: rocm-core/rocm_version.h header (modern standard, ROCm 5.2+)
        if not version:
            try:
                version_header = Path("/opt/rocm/include/rocm-core/rocm_version.h")
                if version_header.exists():
                    content = version_header.read_text()
                    # Parse C preprocessor macros
                    major = re.search(r"#define\s+ROCM_VERSION_MAJOR\s+(\d+)", content)
                    minor = re.search(r"#define\s+ROCM_VERSION_MINOR\s+(\d+)", content)
                    patch = re.search(r"#define\s+ROCM_VERSION_PATCH\s+(\d+)", content)
                    if major and minor and patch:
                        version = f"{major.group(1)}.{minor.group(1)}.{patch.group(1)}"
                        self.logger.debug(
                            f"Method 2 (rocm_version.h) found version: {version}"
                        )
            except Exception as e:
                self.logger.debug(f"Method 2 (rocm_version.h) failed: {e}")

        # Method 3: update-alternatives (for multi-version installations)
        if not version:
            try:
                result = subprocess.run(
                    ["update-alternatives", "--display", "rocm"],
                    capture_output=True,
                    text=True,
                    timeout=2.0,
                )
                if result.returncode == 0:
                    # Extract version from path like "/opt/rocm-6.3.0"
                    match = re.search(r"/opt/rocm-(\d+\.\d+\.\d+)", result.stdout)
                    if match:
                        version = match.group(1)
                        self.logger.debug(
                            f"Method 3 (update-alternatives) found version: {version}"
                        )
            except Exception as e:
                self.logger.debug(f"Method 3 (update-alternatives) failed: {e}")

        # Method 4: /opt/rocm/.info/version file (legacy, ROCm 4.x - 5.6)
        if not version:
            try:
                version_file = Path("/opt/rocm/.info/version")
                if version_file.exists():
                    content = version_file.read_text().strip()
                    match = re.search(r"(\d+\.\d+\.\d+)", content)
                    if match:
                        version = match.group(1)
                        self.logger.debug(
                            f"Method 4 (.info/version) found version: {version}"
                        )
            except Exception as e:
                self.logger.debug(f"Method 4 (.info/version) failed: {e}")

        # Method 5: /opt/rocm/.info/version-dev file (older legacy)
        if not version:
            try:
                version_file = Path("/opt/rocm/.info/version-dev")
                if version_file.exists():
                    content = version_file.read_text().strip()
                    match = re.search(r"(\d+\.\d+\.\d+)", content)
                    if match:
                        version = match.group(1)
                        self.logger.debug(
                            f"Method 5 (.info/version-dev) found version: {version}"
                        )
            except Exception as e:
                self.logger.debug(f"Method 5 (.info/version-dev) failed: {e}")

        # Method 6: /opt/rocm/VERSION file (oldest legacy, ROCm 4.x and earlier)
        if not version:
            try:
                version_file = Path("/opt/rocm/VERSION")
                if version_file.exists():
                    content = version_file.read_text().strip()
                    match = re.search(r"(\d+\.\d+(?:\.\d+)?)", content)
                    if match:
                        version = match.group(1)
                        self.logger.debug(
                            f"Method 6 (VERSION) found version: {version}"
                        )
            except Exception as e:
                self.logger.debug(f"Method 6 (VERSION) failed: {e}")

        self._cached_rocm_version = version
        self._rocm_version_initialized = True
        if version:
            self.logger.info(f"Detected ROCm platform version: {version}")
        else:
            self.logger.warning("Could not detect ROCm platform version")
        return version

    def _run_amd_smi_json(self, args: List[str]) -> Optional[Any]:
        """Run amd-smi with JSON output and return decoded payload."""
        if not self.smi_path or not self._uses_amd_smi():
            return None

        cmd = [str(self.smi_path), *args]
        if "--json" not in cmd and "-j" not in cmd:
            cmd.append("--json")

        try:
            result = subprocess.run(
                cmd,
                capture_output=True,
                text=True,
                check=True,
                timeout=5,
            )
            return json.loads(result.stdout)
        except json.JSONDecodeError as exc:
            self.logger.warning("Failed to parse amd-smi JSON output: %s", exc)
            return None
        except (
            subprocess.SubprocessError,
            FileNotFoundError,
            subprocess.TimeoutExpired,
            PermissionError,
            OSError,
        ) as exc:
            self.logger.debug("amd-smi command failed (%s): %s", " ".join(cmd), exc)
            return None

    @staticmethod
    def _normalize_key(value: str) -> str:
        """Normalize metric keys for resilient matching across tool versions."""
        return re.sub(r"[^a-z0-9]+", "", value.lower())

    def _extract_gpu_index(self, value: Any) -> Optional[int]:
        """Extract GPU index from values like 'gpu0', 'card1', 'GPU[2]'."""
        if isinstance(value, int):
            return value
        if isinstance(value, float) and value.is_integer():
            return int(value)
        if not isinstance(value, str):
            return None

        text = value.strip()
        if text.isdigit():
            return int(text)

        match = re.search(r"(?:gpu|card)\s*[\[_-]?(\d+)\]?", text, re.IGNORECASE)
        if match:
            return int(match.group(1))

        return None

    def _parse_numeric(self, value: Any) -> Optional[float]:
        """Best-effort conversion of CLI/JSON values to float."""
        if isinstance(value, (int, float)):
            return float(value)

        if isinstance(value, dict):
            # AMD-SMI JSON often wraps metrics as {"value": X, "unit": "..."}.
            for preferred_key in ("value", "current", "average", "avg"):
                if preferred_key in value:
                    parsed = self._parse_numeric(value[preferred_key])
                    if parsed is not None:
                        return parsed

            for nested_value in value.values():
                parsed = self._parse_numeric(nested_value)
                if parsed is not None:
                    return parsed
            return None

        if isinstance(value, list):
            for item in value:
                parsed = self._parse_numeric(item)
                if parsed is not None:
                    return parsed
            return None

        if isinstance(value, str):
            match = re.search(r"-?\d+(?:\.\d+)?", value.replace(",", ""))
            if match:
                try:
                    return float(match.group(0))
                except ValueError:
                    return None

        return None

    def _to_mb(self, value: Any, default_unit: str = "MB") -> Optional[float]:
        """Convert values with optional units into MB."""
        unit = default_unit.upper()
        raw_value: Any = value

        if isinstance(value, dict):
            if "unit" in value and value["unit"] is not None:
                unit = str(value["unit"]).upper()
            if "value" in value:
                raw_value = value["value"]
            else:
                raw_value = value

        if isinstance(raw_value, str):
            text = raw_value.strip()
            unit_match = re.search(r"([KMGT]?I?B)\s*$", text, re.IGNORECASE)
            if unit_match:
                unit = unit_match.group(1).upper()

        numeric = self._parse_numeric(raw_value)
        if numeric is None:
            return None

        if unit in {"B", "BYTE", "BYTES"}:
            return numeric / (1024 * 1024)
        if unit in {"KB", "KIB"}:
            return numeric / 1024
        if unit in {"MB", "MIB"}:
            return numeric
        if unit in {"GB", "GIB"}:
            return numeric * 1024
        if unit in {"TB", "TIB"}:
            return numeric * 1024 * 1024
        return numeric

    def _iter_key_values(self, data: Any) -> List[Tuple[str, Any]]:
        """Flatten nested dict/list structures into key-value pairs."""
        pairs: List[Tuple[str, Any]] = []

        if isinstance(data, dict):
            for key, value in data.items():
                pairs.append((str(key), value))
                pairs.extend(self._iter_key_values(value))
        elif isinstance(data, list):
            for value in data:
                pairs.extend(self._iter_key_values(value))

        return pairs

    def _find_numeric_by_keys(
        self, data: Any, includes: List[str], excludes: Optional[List[str]] = None
    ) -> Optional[float]:
        """Find first numeric value whose key matches include/exclude patterns."""
        include_keys = [self._normalize_key(key) for key in includes]
        exclude_keys = [self._normalize_key(key) for key in excludes or []]

        for raw_key, value in self._iter_key_values(data):
            normalized = self._normalize_key(raw_key)
            if not any(include in normalized for include in include_keys):
                continue
            if any(exclude in normalized for exclude in exclude_keys):
                continue

            parsed = self._parse_numeric(value)
            if parsed is not None:
                return parsed

        return None

    def _find_value_by_keys(
        self, data: Any, includes: List[str], excludes: Optional[List[str]] = None
    ) -> Any:
        """Find first raw value whose key matches include/exclude patterns."""
        include_keys = [self._normalize_key(key) for key in includes]
        exclude_keys = [self._normalize_key(key) for key in excludes or []]

        for raw_key, value in self._iter_key_values(data):
            normalized = self._normalize_key(raw_key)
            if not any(include in normalized for include in include_keys):
                continue
            if any(exclude in normalized for exclude in exclude_keys):
                continue
            return value

        return None

    def _find_string_by_keys(
        self, data: Any, includes: List[str], excludes: Optional[List[str]] = None
    ) -> str:
        """Find first string-ish value whose key matches include/exclude patterns."""
        include_keys = [self._normalize_key(key) for key in includes]
        exclude_keys = [self._normalize_key(key) for key in excludes or []]

        for raw_key, value in self._iter_key_values(data):
            normalized = self._normalize_key(raw_key)
            if not any(include in normalized for include in include_keys):
                continue
            if any(exclude in normalized for exclude in exclude_keys):
                continue
            if value is None:
                continue
            return str(value).strip()

        return ""

    def _extract_gpu_sections(self, data: Any) -> Dict[int, Any]:
        """Extract per-GPU sections from nested JSON payloads."""
        sections: Dict[int, Any] = {}

        def walk(node: Any, current_gpu: Optional[int] = None) -> None:
            if isinstance(node, dict):
                gpu_index = current_gpu
                if gpu_index is None:
                    for key in ("gpu", "gpu_id", "gpu index", "gpu_index", "index"):
                        if key in node:
                            gpu_index = self._extract_gpu_index(node[key])
                            if gpu_index is not None:
                                break

                if gpu_index is not None:
                    sections[gpu_index] = node

                for key, value in node.items():
                    key_gpu = self._extract_gpu_index(key)
                    walk(value, key_gpu if key_gpu is not None else gpu_index)
            elif isinstance(node, list):
                for item in node:
                    walk(item, current_gpu)

        walk(data)
        return sections

    def _get_gpu_json_scope(self, json_data: Any, gpu_index: int) -> Optional[Any]:
        """Get best-matching JSON scope for one GPU index."""
        if isinstance(json_data, dict):
            for key in (
                f"card{gpu_index}",
                f"gpu{gpu_index}",
                f"gpu_{gpu_index}",
                f"gpu[{gpu_index}]",
                f"GPU[{gpu_index}]",
            ):
                if key in json_data:
                    return json_data[key]

        sections = self._extract_gpu_sections(json_data)
        if gpu_index in sections:
            return sections[gpu_index]

        return None

    def _get_device_info_amd_smi(self) -> List[Dict[str, str]]:
        """Get basic device information for AMD-SMI backends."""
        list_data = self._run_amd_smi_json(["list"])
        static_data = self._run_amd_smi_json(["static", "--asic"])

        sections: Dict[int, Any] = {}
        for payload in (list_data, static_data):
            if payload:
                sections.update(self._extract_gpu_sections(payload))

        devices: List[Dict[str, str]] = []
        for index in sorted(sections.keys()):
            section = sections[index]
            name = (
                self._find_string_by_keys(
                    section,
                    includes=[
                        "device name",
                        "market name",
                        "product name",
                        "asic name",
                        "name",
                    ],
                )
                or f"AMD GPU {index}"
            )

            device_id = self._find_string_by_keys(
                section,
                includes=[
                    "device id",
                    "asic id",
                    "pci device id",
                    "pci id",
                ],
            )

            guid = self._find_string_by_keys(
                section,
                includes=["guid", "uuid", "serial number", "serial"],
            )

            device = {"index": str(index), "name": name}
            if device_id:
                device["device_id"] = device_id
            if guid:
                device["guid"] = guid
            devices.append(device)

        if devices:
            return devices

        # Last-resort text parsing if JSON shape changes unexpectedly.
        text_output = self._run_smi_command([str(self.smi_path), "list"])
        if not text_output:
            return []

        found_indices: Set[int] = set()
        for match in re.finditer(
            r"(?:gpu|card)\s*[\[_-]?(\d+)\]?", text_output, re.IGNORECASE
        ):
            try:
                found_indices.add(int(match.group(1)))
            except ValueError:
                continue

        return [
            {"index": str(index), "name": f"AMD GPU {index}"}
            for index in sorted(found_indices)
        ]

    def _get_quick_metrics_amd_smi(self) -> Dict[int, Dict[str, float]]:
        """Fast-path quick metrics collection for AMD-SMI."""
        metrics: Dict[int, Dict[str, float]] = {}
        data = self._run_amd_smi_json(["metric", "--usage", "--vram-usage"])

        if data:
            sections = self._extract_gpu_sections(data)
            for index in sorted(sections.keys()):
                memory_used, memory_total = self._parse_json_memory(data, index)
                utilization = self._parse_json_utilization(data, index)
                shared_used_mb, shared_total_mb = self._get_shared_gtt_memory_info(
                    index
                )
                if memory_total <= 0 and shared_total_mb > 0:
                    memory_used = shared_used_mb
                    memory_total = shared_total_mb
                metrics[index] = {
                    "utilization": utilization,
                    "memory_used": memory_used,
                    "memory_total": memory_total,
                    "memory_percent": (memory_used / memory_total * 100.0)
                    if memory_total > 0
                    else 0.0,
                }
            if metrics:
                return metrics

        self.logger.debug(
            "amd-smi quick metric query failed, falling back to per-GPU requests"
        )
        for device in self._get_device_info_amd_smi():
            try:
                index = int(device.get("index", "0"))
            except ValueError:
                continue

            batch = self._get_batch_metrics_json(index)
            memory_used, memory_total = self._parse_json_memory(batch, index)
            utilization = self._parse_json_utilization(batch, index)
            shared_used_mb, shared_total_mb = self._get_shared_gtt_memory_info(index)
            if memory_total <= 0 and shared_total_mb > 0:
                memory_used = shared_used_mb
                memory_total = shared_total_mb
            metrics[index] = {
                "utilization": utilization,
                "memory_used": memory_used,
                "memory_total": memory_total,
                "memory_percent": (memory_used / memory_total * 100.0)
                if memory_total > 0
                else 0.0,
            }

        return metrics

    def _get_device_info(self) -> List[Dict[str, str]]:
        """Get basic device information for all AMD GPUs."""
        self.logger.debug("Starting device info collection")
        if not self.smi_path:
            self.logger.warning("Cannot get device info - no AMD SMI tool available")
            return []

        if self._uses_amd_smi():
            devices = self._get_device_info_amd_smi()
            self.logger.debug("Retrieved %d devices via amd-smi backend", len(devices))
            return devices

        cmd = [str(self.smi_path), "-i"]
        self.logger.debug("Executing rocm-smi command: " + " ".join(cmd))
        result = self._run_smi_command(cmd)
        if not result:
            self.logger.error("Failed to get device information from rocm-smi")
            return []

        self.logger.debug("Successfully retrieved device information")

        devices = []
        # Split output into per-device sections
        sections = result.split("========================")
        self.logger.debug("Found " + str(len(sections)) + " device sections to parse")

        for section in sections:
            if not section.strip():
                continue

            device = {}
            patterns = {
                "name": r"Device Name:\s*([^\n]+)",
                "device_id": r"Device ID:\s*([^\n]+)",
                "guid": r"GUID:\s*([^\n]+)",
                "index": r"GPU\[(\d+)\]",  # Extract GPU index
            }

            for key, pattern in patterns.items():
                match = re.search(pattern, section)
                if match:
                    device[key] = match.group(1).strip()

            if device:  # Only add if we found device info
                self.logger.debug("Found device: " + str(device))
                devices.append(device)

        return devices

    def _get_memory_info(self, gpu_index: int) -> Tuple[float, float]:
        """Get current memory usage information for specific GPU."""
        self.logger.debug("Getting memory info for GPU " + str(gpu_index))
        if not self.smi_path:
            self.logger.warning(
                "Cannot get memory info for GPU "
                + str(gpu_index)
                + " - no AMD SMI tool available"
            )
            return 0.0, 0.0

        if self._uses_amd_smi():
            batch_data = self._get_batch_metrics_json(gpu_index)
            if batch_data:
                return self._parse_json_memory(batch_data, gpu_index)
            return 0.0, 0.0

        cmd = [str(self.smi_path), "-d", str(gpu_index), "--showmeminfo", "vram"]
        self.logger.debug("Executing memory info command: " + " ".join(cmd))
        result = self._run_smi_command(cmd)
        if not result:
            self.logger.error("Failed to get memory info for GPU " + str(gpu_index))
            return 0.0, 0.0

        try:
            # Parse VRAM usage from the new format
            total_match = re.search(r"VRAM Total Memory \(B\):\s*(\d+)", result)
            used_match = re.search(r"VRAM Total Used Memory \(B\):\s*(\d+)", result)

            total = (
                float(total_match.group(1)) / (1024 * 1024) if total_match else 0.0
            )  # Convert to MB
            used = (
                float(used_match.group(1)) / (1024 * 1024) if used_match else 0.0
            )  # Convert to MB

            self.logger.debug(
                "GPU "
                + str(gpu_index)
                + " memory: "
                + str(used)
                + "MB used / "
                + str(total)
                + "MB total"
            )
            return used, total
        except (ValueError, AttributeError) as e:
            self.logger.error(
                "Failed to parse memory info for GPU " + str(gpu_index) + ": " + str(e)
            )
            return 0.0, 0.0

    @staticmethod
    def _read_sysfs_int(path: Path) -> Optional[int]:
        """Read integer value from sysfs."""
        try:
            raw = path.read_text(encoding="utf-8", errors="ignore").strip()
        except (OSError, UnicodeError):
            return None
        if not raw:
            return None
        try:
            return int(raw, 0)
        except ValueError:
            return None

    def _get_shared_gtt_memory_info(self, gpu_index: int) -> Tuple[float, float]:
        """Get shared GTT memory stats (MB) for integrated/hybrid AMD GPUs."""
        device_root = Path(f"/sys/class/drm/card{gpu_index}/device")
        total_bytes = self._read_sysfs_int(device_root / "mem_info_gtt_total")
        used_bytes = self._read_sysfs_int(device_root / "mem_info_gtt_used")
        if not total_bytes or total_bytes <= 0:
            return 0.0, 0.0
        total_mb = float(total_bytes) / (1024.0 * 1024.0)
        used_mb = (
            float(used_bytes) / (1024.0 * 1024.0)
            if used_bytes is not None and used_bytes >= 0
            else 0.0
        )
        return used_mb, total_mb

    def _get_temperature_info(self, gpu_index: int) -> Dict[str, float]:
        """Get temperature information from all available sensors for specific GPU."""
        self.logger.debug("Getting temperature info for GPU " + str(gpu_index))
        if not self.smi_path:
            return {}

        if self._uses_amd_smi():
            batch_data = self._get_batch_metrics_json(gpu_index)
            temperature = (
                self._parse_json_temperature(batch_data, gpu_index)
                if batch_data
                else 0.0
            )
            return {"junction": temperature} if temperature > 0.0 else {}

        cmd = [str(self.smi_path), "-d", str(gpu_index), "-t"]
        self.logger.debug("Executing temperature command: " + " ".join(cmd))
        result = self._run_smi_command(cmd)
        if not result:
            return {}

        temps = {}
        patterns = {
            "edge": r"Temperature \(Sensor edge\) \(C\):\s*([\d.]+)",
            "junction": r"Temperature \(Sensor junction\) \(C\):\s*([\d.]+)",
            "memory": r"Temperature \(Sensor memory\) \(C\):\s*([\d.]+)",
        }

        for sensor, pattern in patterns.items():
            match = re.search(pattern, result)
            if match:
                try:
                    temps[sensor] = float(match.group(1))
                    self.logger.debug(
                        "GPU "
                        + str(gpu_index)
                        + " "
                        + sensor
                        + " temperature: "
                        + str(temps[sensor])
                        + "°C"
                    )
                except ValueError:
                    temps[sensor] = 0.0

        return temps

    def _get_power_info(self, gpu_index: int) -> float:
        """Get current power consumption for specific GPU."""
        self.logger.debug("Getting power info for GPU " + str(gpu_index))
        if not self.smi_path:
            return 0.0

        if self._uses_amd_smi():
            batch_data = self._get_batch_metrics_json(gpu_index)
            return self._parse_json_power(batch_data, gpu_index) if batch_data else 0.0

        cmd = [str(self.smi_path), "-d", str(gpu_index), "--showpower"]
        self.logger.debug("Executing power command: " + " ".join(cmd))
        result = self._run_smi_command(cmd)
        if not result:
            return 0.0

        try:
            match = re.search(
                r"Average Graphics Package Power \(W\):\s*([\d.]+)", result
            )
            power = float(match.group(1)) if match else 0.0
            self.logger.debug(
                "GPU " + str(gpu_index) + " power draw: " + str(power) + "W"
            )
            return power
        except (ValueError, AttributeError):
            return 0.0

    def _get_gpu_use(self, gpu_index: int) -> float:
        """Get current GPU utilization percentage for specific GPU."""
        self.logger.debug("Getting utilization for GPU " + str(gpu_index))
        if not self.smi_path:
            return 0.0

        if self._uses_amd_smi():
            batch_data = self._get_batch_metrics_json(gpu_index)
            return (
                self._parse_json_utilization(batch_data, gpu_index)
                if batch_data
                else 0.0
            )

        cmd = [str(self.smi_path), "-d", str(gpu_index), "--showuse"]
        self.logger.debug("Executing utilization command: " + " ".join(cmd))
        result = self._run_smi_command(cmd)
        if not result:
            return 0.0

        try:
            match = re.search(r"GPU use \(%\):\s*(\d+)", result)
            util = float(match.group(1)) if match else 0.0
            self.logger.debug(
                "GPU " + str(gpu_index) + " utilization: " + str(util) + "%"
            )
            return util
        except (ValueError, AttributeError):
            return 0.0

    def _run_smi_command(self, cmd: List[str]) -> Optional[str]:
        """Run an SMI command and return its output."""
        try:
            result = subprocess.run(
                cmd,
                capture_output=True,
                text=True,
                check=True,
                timeout=5,  # 5 second timeout for commands
            )
            return result.stdout
        except (
            subprocess.SubprocessError,
            FileNotFoundError,
            subprocess.TimeoutExpired,
        ) as e:
            self.logger.error(
                "Failed to run command: " + " ".join(cmd) + " - Error: " + str(e)
            )
            return None

    def _parse_processes(self, output: str) -> List[Dict[str, Any]]:
        """Parse process information from rocm-smi output."""
        self.logger.debug("Parsing process information")
        if not output:
            return []

        processes = []
        lines = output.splitlines()
        header_found = False

        for line in lines:
            # Skip empty lines and headers
            if not line.strip() or line.startswith("===") or "KFD process" in line:
                continue

            # Check for the process listing header
            if "PID" in line and "PROCESS NAME" in line:
                header_found = True
                continue

            # Parse process lines
            if header_found and line.strip():
                try:
                    parts = line.split()
                    if len(parts) >= 4:  # PID, name, GPU(s), VRAM
                        pid = int(parts[0])
                        name = parts[1]
                        gpu_ids = parts[2]

                        # Handle VRAM value that might be "0" as string
                        try:
                            vram = float(parts[3])
                        except ValueError:
                            vram = 0.0

                        # Handle SDMA value that might be "0" as string
                        try:
                            sdma = float(parts[4]) if len(parts) > 4 else 0.0
                        except ValueError:
                            sdma = 0.0

                        # Get CU occupancy if available
                        cu_occupancy = None
                        if len(parts) > 5:
                            if parts[5] != "UNKNOWN":
                                try:
                                    # Try to parse as percentage
                                    cu_occupancy = float(parts[5].rstrip("%"))
                                except ValueError:
                                    cu_occupancy = None

                        # Try to get ppid using psutil
                        ppid = None
                        try:
                            import psutil

                            proc = psutil.Process(pid)
                            ppid = proc.ppid()
                        except (psutil.NoSuchProcess, psutil.AccessDenied, ImportError):
                            pass  # ppid will remain None

                        process_info = {
                            "pid": pid,
                            "ppid": ppid,
                            "name": name,
                            "gpu_ids": [
                                int(gpu_id)
                                for gpu_id in gpu_ids.split(",")
                                if gpu_id.isdigit()
                            ],
                            "memory": vram,  # Keep memory key for compatibility
                            "vram_used": vram,
                            "sdma_used": sdma,
                            "cu_occupancy": cu_occupancy,
                        }
                        self.logger.debug(
                            "Found process: PID="
                            + str(pid)
                            + ", Name="
                            + name
                            + ", VRAM="
                            + str(vram)
                            + "MB"
                        )
                        processes.append(process_info)
                except (ValueError, IndexError) as e:
                    self.logger.debug(
                        "Failed to parse process line: " + line + " - Error: " + str(e)
                    )
                    continue

        self.logger.debug("Found " + str(len(processes)) + " GPU processes")
        return processes

    def _parse_amd_smi_processes(
        self, json_data: Any, gpu_index: Optional[int] = None
    ) -> List[Dict[str, Any]]:
        """Parse process information from amd-smi JSON output."""
        if not json_data:
            return []

        processes: List[Dict[str, Any]] = []
        seen: Set[Tuple[int, Tuple[int, ...]]] = set()

        for node in self._iter_dict_nodes(json_data):
            pid_value = self._find_numeric_by_keys(node, includes=["pid"])
            if pid_value is None:
                continue

            pid = int(pid_value)
            gpu_ids = self._extract_gpu_ids_from_process_node(node)
            if gpu_index is not None:
                if gpu_ids and gpu_index not in gpu_ids:
                    continue
                if not gpu_ids:
                    gpu_ids = [gpu_index]

            dedupe_key = (pid, tuple(sorted(gpu_ids)))
            if dedupe_key in seen:
                continue
            seen.add(dedupe_key)

            name = (
                self._find_string_by_keys(
                    node,
                    includes=["process name", "name", "command", "cmdline", "cmd"],
                )
                or "unknown"
            )

            memory_mb = self._find_numeric_by_keys(
                node,
                includes=[
                    "vram used mb",
                    "vram_used_mb",
                    "used vram mb",
                    "vram usage mb",
                ],
            )
            if memory_mb is None:
                memory_bytes = self._find_numeric_by_keys(
                    node,
                    includes=[
                        "vram used b",
                        "vram_used_b",
                        "used vram b",
                        "vram usage b",
                    ],
                )
                if memory_bytes is not None:
                    memory_mb = memory_bytes / (1024 * 1024)

            if memory_mb is None:
                memory_raw = self._find_value_by_keys(
                    node,
                    includes=[
                        "mem usage",
                        "mem_usage",
                        "memory usage",
                        "vram used",
                        "vram_used",
                    ],
                    excludes=["percent", "usage percent"],
                )
                if memory_raw is not None:
                    memory_mb = self._to_mb(memory_raw)

            if memory_mb is None:
                memory_mb = 0.0

            cu_occupancy = self._find_numeric_by_keys(
                node,
                includes=[
                    "cu occupancy",
                    "cu_occupancy",
                    "gpu use",
                    "gpu usage",
                    "gpu_usage",
                    "gpu utilization",
                    "gfx activity",
                ],
                excludes=["memory", "vram"],
            )

            ppid = None
            try:
                import psutil

                proc = psutil.Process(pid)
                ppid = proc.ppid()
            except (psutil.NoSuchProcess, psutil.AccessDenied, ImportError):
                pass

            processes.append(
                {
                    "pid": pid,
                    "ppid": ppid,
                    "name": name,
                    "gpu_ids": gpu_ids,
                    "memory": float(memory_mb),
                    "vram_used": float(memory_mb),
                    "sdma_used": 0.0,
                    "cu_occupancy": float(cu_occupancy)
                    if cu_occupancy is not None
                    else None,
                }
            )

        return processes

    def _iter_dict_nodes(self, data: Any) -> List[Dict[str, Any]]:
        """Collect all dictionary nodes from nested payload structures."""
        nodes: List[Dict[str, Any]] = []
        if isinstance(data, dict):
            nodes.append(data)
            for value in data.values():
                nodes.extend(self._iter_dict_nodes(value))
        elif isinstance(data, list):
            for item in data:
                nodes.extend(self._iter_dict_nodes(item))
        return nodes

    def _extract_gpu_ids_from_process_node(self, node: Dict[str, Any]) -> List[int]:
        """Extract one or more GPU IDs from an amd-smi process record."""
        gpu_ids: List[int] = []
        for key, value in node.items():
            normalized = self._normalize_key(str(key))
            if "gpu" not in normalized:
                continue

            if isinstance(value, list):
                for item in value:
                    gpu_index = self._extract_gpu_index(item)
                    if gpu_index is not None:
                        gpu_ids.append(gpu_index)
                continue

            if isinstance(value, str):
                for part in re.split(r"[,\s]+", value):
                    if not part:
                        continue
                    gpu_index = self._extract_gpu_index(part)
                    if gpu_index is not None:
                        gpu_ids.append(gpu_index)
                continue

            gpu_index = self._extract_gpu_index(value)
            if gpu_index is not None:
                gpu_ids.append(gpu_index)

        return sorted(set(gpu_ids))

    def _get_processes_for_gpu(self, gpu_index: int) -> List[Dict[str, Any]]:
        """Collect process metrics for one GPU across both AMD backends."""
        if not self.smi_path:
            return []

        if self._uses_amd_smi():
            data = self._run_amd_smi_json(
                [
                    "process",
                    "--gpu",
                    str(gpu_index),
                    "--general",
                    "--memory",
                    "--engine",
                    "--name",
                    "--pid",
                ]
            )
            if data is None:
                data = self._run_amd_smi_json(["process", "--gpu", str(gpu_index)])
            return self._parse_amd_smi_processes(data, gpu_index=gpu_index)

        cmd = [str(self.smi_path), "-d", str(gpu_index), "--showpids", "verbose"]
        self.logger.debug("Executing process query: " + " ".join(cmd))
        result = self._run_smi_command(cmd)
        return self._parse_processes(result or "")

    def _get_batch_metrics_json(self, gpu_index: int) -> Any:
        """Get multiple metrics in a single JSON query (70% faster than separate calls).

        Combines memory, power, temperature, and utilization in one subprocess call.
        Falls back to text parsing if JSON not supported.

        Args:
            gpu_index: GPU device index

        Returns:
            Dictionary with parsed metrics
        """
        if not self.smi_path or not self._json_supported:
            return {}

        if self._uses_amd_smi():
            data = self._run_amd_smi_json(
                [
                    "metric",
                    "--gpu",
                    str(gpu_index),
                    "--usage",
                    "--vram-usage",
                    "--power",
                    "--temperature",
                ]
            )
            if data is not None:
                return data

            # Older AMD-SMI versions may not support all switches together.
            fallback_data = self._run_amd_smi_json(["metric", "--gpu", str(gpu_index)])
            return fallback_data if fallback_data is not None else {}

        # Batch query: memory + power + temp + utilization in ONE call
        cmd = [
            str(self.smi_path),
            "-d",
            str(gpu_index),
            "--showmeminfo",
            "vram",
            "--showpower",
            "-t",
            "--showuse",
            "--json",
        ]

        self.logger.debug(f"Executing batch JSON query for GPU {gpu_index}")

        try:
            result = subprocess.run(
                cmd, capture_output=True, text=True, check=True, timeout=5
            )

            # Parse JSON output
            data = json.loads(result.stdout)
            if isinstance(data, dict):
                self.logger.debug(
                    f"Successfully parsed JSON metrics for GPU {gpu_index}"
                )
                return data
            return {}

        except json.JSONDecodeError as e:
            self.logger.warning(f"JSON parsing failed for GPU {gpu_index}: {e}")
            self.logger.warning("Disabling JSON queries, falling back to text parsing")
            self._json_supported = False
            return {}

        except (
            subprocess.SubprocessError,
            FileNotFoundError,
            subprocess.TimeoutExpired,
        ) as e:
            self.logger.error(f"Batch query failed for GPU {gpu_index}: {e}")
            return {}

    def _parse_json_memory(self, json_data: Any, gpu_index: int) -> Tuple[float, float]:
        """Parse memory info from JSON batch query.

        Best-effort parsing for rocm-smi and amd-smi payloads.
        """
        scope = self._get_gpu_json_scope(json_data, gpu_index)
        sections = self._extract_gpu_sections(json_data)
        if scope is None:
            if sections:
                return 0.0, 0.0
            scope = json_data

        total_raw = self._find_value_by_keys(
            scope,
            includes=[
                "vram total memory",
                "vram_total_memory",
                "total vram",
                "vram total",
                "vram_total",
            ],
            excludes=["used", "usage", "percent"],
        )
        if total_raw is None:
            total_raw = self._find_value_by_keys(
                json_data,
                includes=[
                    "vram total memory",
                    "vram_total_memory",
                    "total vram",
                    "vram total",
                    "vram_total",
                ],
                excludes=["used", "usage", "percent"],
            )

        used_raw = self._find_value_by_keys(
            scope,
            includes=[
                "vram total used memory",
                "vram_total_used_memory",
                "used vram",
                "vram used",
                "vram_used",
                "mem usage",
                "mem_usage",
                "memory usage",
            ],
            excludes=["percent", "usage percent", "vram_usage"],
        )
        if used_raw is None:
            used_raw = self._find_value_by_keys(
                json_data,
                includes=[
                    "vram total used memory",
                    "vram_total_used_memory",
                    "used vram",
                    "vram used",
                    "vram_used",
                    "mem usage",
                    "mem_usage",
                    "memory usage",
                ],
                excludes=["percent", "usage percent", "vram_usage"],
            )

        total_mb_value = self._to_mb(total_raw) if total_raw is not None else 0.0
        used_mb_value = self._to_mb(used_raw) if used_raw is not None else 0.0
        total_mb = total_mb_value if total_mb_value is not None else 0.0
        used_mb = used_mb_value if used_mb_value is not None else 0.0

        # If unit metadata is missing and numbers look like raw bytes, normalize.
        if total_mb and total_mb > 1024 * 1024:
            total_mb = total_mb / (1024 * 1024)
        if used_mb and used_mb > 1024 * 1024:
            used_mb = used_mb / (1024 * 1024)

        total_bytes = self._find_numeric_by_keys(
            scope,
            includes=[
                "vram total memory b",
                "vram_total_memory_b",
                "total vram bytes",
                "vram size bytes",
            ],
        )
        if total_bytes is None:
            total_bytes = self._find_numeric_by_keys(
                json_data,
                includes=[
                    "vram total memory b",
                    "vram_total_memory_b",
                    "total vram bytes",
                    "vram size bytes",
                ],
            )

        used_bytes = self._find_numeric_by_keys(
            scope,
            includes=[
                "vram total used memory b",
                "vram_total_used_memory_b",
                "used vram bytes",
                "vram used b",
            ],
        )
        if used_bytes is None:
            used_bytes = self._find_numeric_by_keys(
                json_data,
                includes=[
                    "vram total used memory b",
                    "vram_total_used_memory_b",
                    "used vram bytes",
                    "vram used b",
                ],
            )

        if total_mb <= 0.0 and total_bytes is not None:
            total_mb = total_bytes / (1024 * 1024)
        if used_mb <= 0.0 and used_bytes is not None:
            used_mb = used_bytes / (1024 * 1024)

        if total_mb <= 0.0:
            total_mb_candidate = self._find_numeric_by_keys(
                scope,
                includes=[
                    "vram total memory mb",
                    "vram_total_memory_mb",
                    "vram size mb",
                    "total vram mb",
                ],
            )
            if total_mb_candidate is None:
                total_mb_candidate = self._find_numeric_by_keys(
                    json_data,
                    includes=[
                        "vram total memory mb",
                        "vram_total_memory_mb",
                        "vram size mb",
                        "total vram mb",
                    ],
                )
            if total_mb_candidate is not None and total_mb_candidate > 0.0:
                total_mb = total_mb_candidate

        if used_mb <= 0.0:
            used_mb_candidate = self._find_numeric_by_keys(
                scope,
                includes=[
                    "vram total used memory mb",
                    "vram_total_used_memory_mb",
                    "vram used mb",
                    "used vram mb",
                ],
            )
            if used_mb_candidate is None:
                used_mb_candidate = self._find_numeric_by_keys(
                    json_data,
                    includes=[
                        "vram total used memory mb",
                        "vram_total_used_memory_mb",
                        "vram used mb",
                        "used vram mb",
                    ],
                )
            if used_mb_candidate is not None and used_mb_candidate > 0.0:
                used_mb = used_mb_candidate

        if used_mb <= 0.0:
            if total_mb > 0.0:
                vram_percent = self._find_numeric_by_keys(
                    scope,
                    includes=["vram usage", "vram_usage", "used vram percent"],
                )
                if vram_percent is None:
                    vram_percent = self._find_numeric_by_keys(
                        json_data,
                        includes=["vram usage", "vram_usage", "used vram percent"],
                    )
                if vram_percent is not None:
                    if 0.0 <= vram_percent <= 1.0:
                        vram_percent *= 100.0
                    if 0.0 <= vram_percent <= 100.0:
                        used_mb = total_mb * (vram_percent / 100.0)

        # Some payloads report fractional utilization (0.0-1.0). Normalize if needed.
        if 0.0 <= used_mb <= 1.0:
            if total_mb > 1.0:
                scaled_used_mb = used_mb * total_mb
                if scaled_used_mb <= total_mb:
                    used_mb = scaled_used_mb

        # Avoid returning impossible numbers.
        if total_mb > 0.0:
            if used_mb > total_mb:
                if used_mb <= total_mb * 1.05:
                    used_mb = total_mb
                elif used_mb > 1024 * 1024:
                    normalized_used = used_mb / (1024 * 1024)
                    if normalized_used <= total_mb:
                        used_mb = normalized_used
                elif used_mb > total_mb:
                    used_mb = total_mb

        return used_mb, total_mb

    def _parse_json_temperature(self, json_data: Any, gpu_index: int) -> float:
        """Parse temperature from JSON batch query (best-effort)."""
        scope = self._get_gpu_json_scope(json_data, gpu_index)
        sections = self._extract_gpu_sections(json_data)
        if scope is None:
            if sections:
                return 0.0
            scope = json_data

        temp = self._find_numeric_by_keys(
            scope,
            includes=[
                "temperature sensor junction c",
                "junction temperature",
                "temperature sensor edge c",
                "edge temperature",
                "temperature c",
                "temperature",
            ],
        )
        if temp is None:
            temp = self._find_numeric_by_keys(
                json_data,
                includes=[
                    "temperature sensor junction c",
                    "junction temperature",
                    "temperature sensor edge c",
                    "edge temperature",
                    "temperature c",
                    "temperature",
                ],
            )

        return temp if temp is not None else 0.0

    def _parse_json_power(self, json_data: Any, gpu_index: int) -> float:
        """Parse power from JSON batch query (best-effort)."""
        scope = self._get_gpu_json_scope(json_data, gpu_index)
        sections = self._extract_gpu_sections(json_data)
        if scope is None:
            if sections:
                return 0.0
            scope = json_data

        power = self._find_numeric_by_keys(
            scope,
            includes=[
                "average graphics package power w",
                "average socket power w",
                "power draw w",
                "current power w",
                "gpu power w",
                "power",
            ],
            excludes=["limit", "cap", "max", "min"],
        )
        if power is None:
            power = self._find_numeric_by_keys(
                json_data,
                includes=[
                    "average graphics package power w",
                    "average socket power w",
                    "power draw w",
                    "current power w",
                    "gpu power w",
                    "power",
                ],
                excludes=["limit", "cap", "max", "min"],
            )

        return power if power is not None else 0.0

    def _parse_json_utilization(self, json_data: Any, gpu_index: int) -> float:
        """Parse utilization from JSON batch query (best-effort)."""
        scope = self._get_gpu_json_scope(json_data, gpu_index)
        sections = self._extract_gpu_sections(json_data)
        if scope is None:
            if sections:
                return 0.0
            scope = json_data

        util_raw = self._find_value_by_keys(
            scope,
            includes=[
                "gpu use",
                "gpu usage",
                "gpu_usage",
                "gpu utilization",
                "average gfx activity",
                "gfx activity",
                "busy percent",
            ],
            excludes=["memory", "vram", "encode", "decode"],
        )
        if util_raw is None:
            util_raw = self._find_value_by_keys(
                json_data,
                includes=[
                    "gpu use",
                    "gpu usage",
                    "gpu_usage",
                    "gpu utilization",
                    "average gfx activity",
                    "gfx activity",
                    "busy percent",
                ],
                excludes=["memory", "vram", "encode", "decode"],
            )

        util = self._parse_numeric(util_raw)
        if util is None:
            return 0.0

        # Scale only when payload explicitly declares ratio-like units.
        if isinstance(util_raw, dict):
            unit = str(util_raw.get("unit", "")).strip().lower()
            if unit in {"ratio", "fraction", "unitless"} and 0.0 <= util <= 1.0:
                util *= 100.0

        # Guard against malformed values.
        if util < 0.0:
            return 0.0
        if util > 100.0:
            return 100.0
        return util

    def get_gpu_info(self) -> List[GPUInfo]:
        """Get comprehensive AMD GPU information for all devices.

        Uses optimized batch queries for improved performance.
        """
        self.logger.debug("Starting AMD GPU info collection")
        if not self.smi_path:
            self.logger.warning("Cannot get GPU info - no AMD SMI tool available")
            return []

        try:
            # Get versions once (cached forever)
            driver_version = self._get_driver_version()
            rocm_version = self._get_rocm_version()

            # Get information for all devices
            devices = self._get_device_info()
            if not devices:
                self.logger.warning("No AMD devices found")
                return []

            self.logger.debug("Found " + str(len(devices)) + " AMD devices")

            gpus = []
            for device in devices:
                # Get device-specific index
                try:
                    index = int(device.get("index", "0"))
                except ValueError:
                    continue

                self.logger.debug("Processing GPU " + str(index))

                # Try batch JSON query first (70% faster - 1 call instead of 4)
                batch_data = (
                    self._get_batch_metrics_json(index) if self._json_supported else {}
                )

                if batch_data and self._json_supported:
                    # Parse from JSON batch query
                    self.logger.debug(f"Using JSON batch data for GPU {index}")
                    # Note: JSON structure varies by ROCm version, this is best-effort
                    # If parsing fails, will fall back to individual calls
                    try:
                        # Extract metrics from JSON (structure may vary)
                        memory_used, memory_total = self._parse_json_memory(
                            batch_data, index
                        )
                        temperature = self._parse_json_temperature(batch_data, index)
                        power_draw = self._parse_json_power(batch_data, index)
                        utilization = self._parse_json_utilization(batch_data, index)
                    except (KeyError, ValueError, TypeError) as e:
                        self.logger.warning(
                            f"JSON parsing incomplete for GPU {index}: {e}, "
                            "falling back to text"
                        )
                        # Fallback to individual queries
                        memory_used, memory_total = self._get_memory_info(index)
                        temps = self._get_temperature_info(index)
                        temperature = temps.get("junction", temps.get("edge", 0.0))
                        power_draw = self._get_power_info(index)
                        utilization = self._get_gpu_use(index)
                else:
                    # Fallback: use original individual queries (4 separate calls)
                    self.logger.debug(f"Using text-based queries for GPU {index}")
                    memory_used, memory_total = self._get_memory_info(index)
                    temps = self._get_temperature_info(index)
                    temperature = temps.get("junction", temps.get("edge", 0.0))
                    power_draw = self._get_power_info(index)
                    utilization = self._get_gpu_use(index)

                shared_used_mb, shared_total_mb = self._get_shared_gtt_memory_info(
                    index
                )
                memory_model = "dedicated"
                memory_source = "amd-smi" if self._uses_amd_smi() else "rocm-smi"
                memory_confidence = "direct"
                if memory_total <= 0 and shared_total_mb > 0:
                    memory_used = shared_used_mb
                    memory_total = shared_total_mb
                    memory_model = "shared"
                    memory_source = "sysfs:mem_info_gtt"
                elif memory_total > 0 and shared_total_mb > 0:
                    memory_model = "hybrid"
                    memory_source = f"{memory_source}+sysfs:mem_info_gtt"
                    memory_confidence = "partial"

                # Get process information for specific GPU
                processes = self._get_processes_for_gpu(index)

                self.logger.debug("Creating GPUInfo object for GPU " + str(index))
                gpus.append(
                    GPUInfo(
                        index=index,
                        name=device.get("name", "AMD GPU"),
                        utilization=utilization,
                        memory_used=memory_used,
                        memory_total=memory_total,
                        temperature=temperature,
                        power_draw=power_draw,
                        # Power limit not available in current ROCm-SMI version
                        power_limit=0.0,
                        processes=processes,
                        driver_version=driver_version,
                        rocm_version=rocm_version,
                        memory_model=memory_model,
                        memory_source=memory_source,
                        memory_confidence=memory_confidence,
                    )
                )

            self.logger.debug(
                "Successfully collected information for " + str(len(gpus)) + " AMD GPUs"
            )
            return gpus
        except Exception as e:
            self.logger.error("Failed to get GPU information: " + str(e))
            return []
