//go:generate -command fetchsmartctl go run ./tools/fetchsmartctl
//go:generate fetchsmartctl -out ./smartmontools/smartctl.exe -url https://static.beszel.dev/bin/smartctl/smartctl-nc.exe -sha 3912249c3b329249aa512ce796fd1b64d7cbd8378b68ad2756b39163d9c30b47

package agent

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/henrygd/beszel/agent/utils"
	"github.com/henrygd/beszel/internal/entities/smart"
)

// SmartManager manages data collection for SMART devices
type SmartManager struct {
	sync.Mutex
	SmartDataMap       map[string]*smart.SmartData
	SmartDevices       []*DeviceInfo
	refreshMutex       sync.Mutex
	lastScanTime       time.Time
	smartctlPath       string
	excludedDevices    map[string]struct{}
	darwinNvmeOnce     sync.Once
	darwinNvmeCapacity map[string]uint64      // serial → bytes cache, written once via darwinNvmeOnce
	darwinNvmeProvider func() ([]byte, error) // overridable for testing
}

type scanOutput struct {
	Devices []struct {
		Name     string `json:"name"`
		Type     string `json:"type"`
		InfoName string `json:"info_name"`
		Protocol string `json:"protocol"`
	} `json:"devices"`
}

type DeviceInfo struct {
	Name     string `json:"name"`
	Type     string `json:"type"`
	InfoName string `json:"info_name"`
	Protocol string `json:"protocol"`
	// typeVerified reports whether we have already parsed SMART data for this device
	// with the stored parserType. When true we can skip re-running the detection logic.
	typeVerified bool
	// parserType holds the parser type (nvme, sat, scsi) that last succeeded.
	parserType string
	// explicitType reports whether Type came from an explicit ":type" hint in
	// SMART_DEVICES. Such a type is a deliberate user override and must always be
	// passed to smartctl via -d, even for scsi/ata where a scan-detected type is
	// otherwise left off (see smartctlArgs and issue #1345).
	explicitType bool
}

// deviceKey is a composite key for a device, used to identify a device uniquely.
type deviceKey struct {
	name       string
	deviceType string
}

var errNoValidSmartData = fmt.Errorf("no valid SMART data found") // Error for missing data

// Refresh updates SMART data for all known devices and reports whether every
// discovered device was collected successfully.
func (sm *SmartManager) Refresh(forceScan bool) (bool, error) {
	sm.refreshMutex.Lock()
	defer sm.refreshMutex.Unlock()

	scanErr := sm.ScanDevices(false)
	if scanErr != nil {
		slog.Debug("smartctl scan failed", "err", scanErr)
	}

	devices := sm.devicesSnapshot()
	var collectErr error
	for _, deviceInfo := range devices {
		if deviceInfo == nil {
			continue
		}
		if err := sm.CollectSmart(deviceInfo); err != nil {
			slog.Debug("smartctl collect failed", "device", deviceInfo.Name, "err", err)
			collectErr = err
		}
	}

	return scanErr == nil && collectErr == nil, sm.resolveRefreshError(scanErr, collectErr)
}

// devicesSnapshot returns a copy of the current device slice to avoid iterating
// while holding the primary mutex for longer than necessary.
func (sm *SmartManager) devicesSnapshot() []*DeviceInfo {
	sm.Lock()
	defer sm.Unlock()

	devices := make([]*DeviceInfo, len(sm.SmartDevices))
	copy(devices, sm.SmartDevices)
	return devices
}

// hasSmartData reports whether any SMART data has been collected.
// func (sm *SmartManager) hasSmartData() bool {
// 	sm.Lock()
// 	defer sm.Unlock()

// 	return len(sm.SmartDataMap) > 0
// }

// resolveRefreshError determines the proper error to return after a refresh.
func (sm *SmartManager) resolveRefreshError(scanErr, collectErr error) error {
	sm.Lock()
	noDevices := len(sm.SmartDevices) == 0
	noData := len(sm.SmartDataMap) == 0
	sm.Unlock()

	if noDevices {
		if scanErr != nil {
			return scanErr
		}
	}

	if !noData {
		return nil
	}

	if collectErr != nil {
		return collectErr
	}
	if scanErr != nil {
		return scanErr
	}
	return errNoValidSmartData
}

// GetCurrentData returns the current SMART data
func (sm *SmartManager) GetCurrentData() map[string]smart.SmartData {
	sm.Lock()
	defer sm.Unlock()
	result := make(map[string]smart.SmartData, len(sm.SmartDataMap))
	for key, value := range sm.SmartDataMap {
		if value != nil {
			result[key] = *value
		}
	}
	return result
}

// ScanDevices scans for SMART devices
// Scan devices using `smartctl --scan -j`
// If scan fails, return error
// If scan succeeds, parse the output and update the SmartDevices slice
func (sm *SmartManager) ScanDevices(force bool) error {
	if !force && time.Since(sm.lastScanTime) < 30*time.Minute {
		return nil
	}
	sm.lastScanTime = time.Now()
	currentDevices := sm.devicesSnapshot()

	var configuredDevices []*DeviceInfo
	if configuredRaw, ok := utils.GetEnv("SMART_DEVICES"); ok {
		slog.Info("SMART_DEVICES", "value", configuredRaw)
		config := strings.TrimSpace(configuredRaw)
		if config == "" {
			return errNoValidSmartData
		}

		parsedDevices, err := sm.parseConfiguredDevices(config)
		if err != nil {
			return err
		}
		configuredDevices = parsedDevices
	}

	var (
		scanErr        error
		scannedDevices []*DeviceInfo
		hasValidScan   bool
	)

	if sm.smartctlPath != "" {
		ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
		defer cancel()

		cmd := exec.CommandContext(ctx, sm.smartctlPath, "--scan", "-j")
		output, err := cmd.Output()
		if err != nil {
			scanErr = err
		} else {
			scannedDevices, hasValidScan = sm.parseScan(output)
			if !hasValidScan {
				scanErr = errNoValidSmartData
			}
		}
	}

	// Add eMMC devices (Linux only) by reading sysfs health fields. This does not
	// require smartctl and does not scan the whole device.
	if emmcDevices := scanEmmcDevices(); len(emmcDevices) > 0 {
		scannedDevices = append(scannedDevices, emmcDevices...)
		hasValidScan = true
	}

	// Add Linux mdraid arrays by reading sysfs health fields. This does not
	// require smartctl and does not scan the whole device.
	if raidDevices := scanMdraidDevices(); len(raidDevices) > 0 {
		scannedDevices = append(scannedDevices, raidDevices...)
		hasValidScan = true
	}

	finalDevices := mergeDeviceLists(currentDevices, scannedDevices, configuredDevices)
	finalDevices = sm.filterExcludedDevices(finalDevices)
	sm.updateSmartDevices(finalDevices)

	if len(finalDevices) == 0 {
		if scanErr != nil {
			slog.Debug("smartctl scan failed", "err", scanErr)
			return scanErr
		}
		return errNoValidSmartData
	}

	return nil
}

func (sm *SmartManager) parseConfiguredDevices(config string) ([]*DeviceInfo, error) {
	splitChar, _ := utils.GetEnv("SMART_DEVICES_SEPARATOR")
	if splitChar == "" {
		splitChar = ","
	}
	entries := strings.Split(config, splitChar)
	devices := make([]*DeviceInfo, 0, len(entries))
	for _, entry := range entries {
		entry = strings.TrimSpace(entry)
		if entry == "" {
			continue
		}

		parts := strings.SplitN(entry, ":", 2)

		name := strings.TrimSpace(parts[0])
		if name == "" {
			return nil, fmt.Errorf("invalid SMART_DEVICES entry %q", entry)
		}

		devType := ""
		if len(parts) == 2 {
			devType = strings.ToLower(strings.TrimSpace(parts[1]))
		}

		devices = append(devices, &DeviceInfo{
			Name:         name,
			Type:         devType,
			explicitType: devType != "",
		})
	}

	if len(devices) == 0 {
		return nil, errNoValidSmartData
	}

	return devices, nil
}

func (sm *SmartManager) refreshExcludedDevices() {
	rawValue, _ := utils.GetEnv("EXCLUDE_SMART")
	sm.excludedDevices = make(map[string]struct{})

	for entry := range strings.SplitSeq(rawValue, ",") {
		device := strings.TrimSpace(entry)
		if device == "" {
			continue
		}
		sm.excludedDevices[device] = struct{}{}
	}
}

func (sm *SmartManager) isExcludedDevice(deviceName string) bool {
	_, exists := sm.excludedDevices[deviceName]
	return exists
}

func (sm *SmartManager) filterExcludedDevices(devices []*DeviceInfo) []*DeviceInfo {
	if devices == nil {
		return []*DeviceInfo{}
	}

	excluded := sm.excludedDevices
	if len(excluded) == 0 {
		return devices
	}

	filtered := make([]*DeviceInfo, 0, len(devices))
	for _, device := range devices {
		if device == nil || device.Name == "" {
			continue
		}
		if _, skip := excluded[device.Name]; skip {
			continue
		}
		filtered = append(filtered, device)
	}
	return filtered
}

// detectSmartOutputType inspects sections that are unique to each smartctl
// JSON schema (NVMe, ATA/SATA, SCSI) to determine which parser should be used
// when the reported device type is ambiguous or missing.
func detectSmartOutputType(output []byte) string {
	var hints struct {
		AtaSmartAttributes            json.RawMessage `json:"ata_smart_attributes"`
		NVMeSmartHealthInformationLog json.RawMessage `json:"nvme_smart_health_information_log"`
		ScsiErrorCounterLog           json.RawMessage `json:"scsi_error_counter_log"`
	}

	if err := json.Unmarshal(output, &hints); err != nil {
		return ""
	}

	switch {
	case hasJSONValue(hints.NVMeSmartHealthInformationLog):
		return "nvme"
	case hasJSONValue(hints.AtaSmartAttributes):
		return "sat"
	case hasJSONValue(hints.ScsiErrorCounterLog):
		return "scsi"
	default:
		return "sat"
	}
}

// hasJSONValue reports whether a JSON payload contains a concrete value. The
// smartctl output often emits "null" for sections that do not apply, so we
// only treat non-null content as a hint.
func hasJSONValue(raw json.RawMessage) bool {
	if len(raw) == 0 {
		return false
	}
	trimmed := strings.TrimSpace(string(raw))
	return trimmed != "" && trimmed != "null"
}

func normalizeParserType(value string) string {
	switch strings.ToLower(strings.TrimSpace(value)) {
	case "nvme", "sntasmedia", "sntrealtek":
		return "nvme"
	case "sat", "ata":
		return "sat"
	case "scsi":
		return "scsi"
	default:
		return strings.ToLower(strings.TrimSpace(value))
	}
}

// makeDeviceKey creates a composite key from device name and type.
// This allows multiple drives under the same device path (e.g., RAID controllers)
// to be tracked separately.
func makeDeviceKey(name, deviceType string) deviceKey {
	return deviceKey{name: name, deviceType: deviceType}
}

// parseSmartOutput attempts each SMART parser, optionally detecting the type when
// it is not provided, and updates the device info when a parser succeeds.
func (sm *SmartManager) parseSmartOutput(deviceInfo *DeviceInfo, output []byte) bool {
	parsers := []struct {
		Type  string
		Parse func([]byte) (bool, int)
	}{
		{Type: "nvme", Parse: func(output []byte) (bool, int) {
			return sm.parseSmartForNvme(output, deviceInfo.Type)
		}},
		{Type: "sat", Parse: func(output []byte) (bool, int) {
			return sm.parseSmartForSata(output, deviceInfo.Type)
		}},
		{Type: "scsi", Parse: func(output []byte) (bool, int) {
			return sm.parseSmartForScsi(output, deviceInfo.Type)
		}},
	}

	deviceType := normalizeParserType(deviceInfo.parserType)
	if deviceType == "" {
		deviceType = normalizeParserType(deviceInfo.Type)
	}
	if deviceInfo.parserType == "" {
		switch deviceType {
		case "nvme", "sat", "scsi":
			deviceInfo.parserType = deviceType
		}
	}

	// Only run the type detection when we do not yet know which parser works
	// or the previous attempt failed.
	needsDetection := deviceType == "" || !deviceInfo.typeVerified
	if needsDetection {
		structureType := detectSmartOutputType(output)
		if deviceType != structureType {
			deviceType = structureType
			deviceInfo.parserType = structureType
			deviceInfo.typeVerified = false
		}
		if deviceInfo.Type == "" || strings.EqualFold(deviceInfo.Type, structureType) {
			deviceInfo.Type = structureType
		}
	}

	// Try the most likely parser first, but keep the remaining parsers in reserve
	// so an incorrect hint never leaves the device unparsed.
	selectedParsers := make([]struct {
		Type  string
		Parse func([]byte) (bool, int)
	}, 0, len(parsers))
	if deviceType != "" {
		for _, parser := range parsers {
			if parser.Type == deviceType {
				selectedParsers = append(selectedParsers, parser)
				break
			}
		}
	}
	for _, parser := range parsers {
		alreadySelected := false
		for _, selected := range selectedParsers {
			if selected.Type == parser.Type {
				alreadySelected = true
				break
			}
		}
		if alreadySelected {
			continue
		}
		selectedParsers = append(selectedParsers, parser)
	}

	// Try the selected parsers in order until we find one that succeeds.
	for _, parser := range selectedParsers {
		hasData, _ := parser.Parse(output)
		if hasData {
			deviceInfo.parserType = parser.Type
			if deviceInfo.Type == "" || strings.EqualFold(deviceInfo.Type, parser.Type) {
				deviceInfo.Type = parser.Type
			}
			// Remember that this parser is valid so future refreshes can bypass
			// detection entirely.
			deviceInfo.typeVerified = true
			return true
		}
		slog.Debug("parser failed", "device", deviceInfo.Name, "parser", parser.Type)
	}

	// Leave verification false so the next pass will attempt detection again.
	deviceInfo.typeVerified = false
	slog.Debug("parsing failed", "device", deviceInfo.Name)
	return false
}

// CollectSmart collects SMART data for a device
// Collect data using `smartctl -d <type> -aj /dev/<device>` when device type is known
// Always attempts to parse output even if command fails, as some data may still be available
// If collect fails, return error
// If collect succeeds, parse the output and update the SmartDataMap
// Uses -n standby to avoid waking up sleeping disks, but bypasses standby mode
// for initial data collection when no cached data exists
func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
	if deviceInfo != nil && sm.isExcludedDevice(deviceInfo.Name) {
		return errNoValidSmartData
	}

	// mdraid health is not exposed via SMART; Linux exposes array state in sysfs.
	if deviceInfo != nil {
		if ok, err := sm.collectMdraidHealth(deviceInfo); ok {
			return err
		}
	}
	// eMMC health is not exposed via SMART on Linux, but the kernel provides
	// wear / EOL indicators via sysfs. Prefer that path when available.
	if deviceInfo != nil {
		if ok, err := sm.collectEmmcHealth(deviceInfo); ok {
			return err
		}
	}

	if sm.smartctlPath == "" {
		return errNoValidSmartData
	}

	// slog.Info("collecting SMART data", "device", deviceInfo.Name, "type", deviceInfo.Type, "has_existing_data", sm.hasDataForDevice(deviceInfo))

	// Check if we have existing data for this exact device identity. Multiple
	// bridge slots can share a path, so a name-only match is not sufficient.
	hasExistingData := sm.hasDataForDevice(deviceInfo)

	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
	defer cancel()

	// Try with -n standby first if we have existing data
	args := sm.smartctlArgs(deviceInfo, hasExistingData)
	cmd := exec.CommandContext(ctx, sm.smartctlPath, args...)
	output, err := cmd.CombinedOutput()

	// Check if device is in standby (exit status 2)
	if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && exitErr.ExitCode() == 2 {
		if hasExistingData {
			// Device is in standby and we have cached data, keep using cache
			return nil
		}
		// No cached data, need to collect initial data by bypassing standby
		ctx2, cancel2 := context.WithTimeout(context.Background(), 15*time.Second)
		defer cancel2()
		args = sm.smartctlArgs(deviceInfo, false)
		cmd = exec.CommandContext(ctx2, sm.smartctlPath, args...)
		output, err = cmd.CombinedOutput()
	}

	hasValidData := sm.parseSmartOutput(deviceInfo, output)

	// If NVMe controller path failed, try namespace path as fallback.
	// NVMe controllers (/dev/nvme0) don't always support SMART queries. See github.com/henrygd/beszel/issues/1504
	if !hasValidData && err != nil && isNvmeControllerPath(deviceInfo.Name) {
		controllerPath := deviceInfo.Name
		namespacePath := controllerPath + "n1"
		if !sm.isExcludedDevice(namespacePath) {
			deviceInfo.Name = namespacePath

			ctx3, cancel3 := context.WithTimeout(context.Background(), 15*time.Second)
			defer cancel3()
			args = sm.smartctlArgs(deviceInfo, false)
			cmd = exec.CommandContext(ctx3, sm.smartctlPath, args...)
			output, err = cmd.CombinedOutput()
			hasValidData = sm.parseSmartOutput(deviceInfo, output)

			// Auto-exclude the controller path so future scans don't re-add it
			if hasValidData {
				sm.Lock()
				if sm.excludedDevices == nil {
					sm.excludedDevices = make(map[string]struct{})
				}
				sm.excludedDevices[controllerPath] = struct{}{}
				sm.Unlock()
				slog.Debug("auto-excluded NVMe controller path", "path", controllerPath)
			}
		}
	}

	if !hasValidData {
		if err != nil {
			slog.Info("smartctl failed", "device", deviceInfo.Name, "err", err)
			return err
		}
		slog.Info("no valid SMART data found", "device", deviceInfo.Name)
		return errNoValidSmartData
	}

	return nil
}

// smartctlArgs returns the arguments for the smartctl command
// based on the device type and whether to include standby mode
func (sm *SmartManager) smartctlArgs(deviceInfo *DeviceInfo, includeStandby bool) []string {
	args := make([]string, 0, 9)
	var deviceType, parserType string

	if deviceInfo != nil {
		deviceType = strings.ToLower(deviceInfo.Type)
		parserType = strings.ToLower(deviceInfo.parserType)
		// types sometimes misidentified in scan; see github.com/henrygd/beszel/issues/1345
		// An explicit SMART_DEVICES ":type" hint is a deliberate override, so always
		// pass it through; otherwise scsi/ata are left off so smartctl can auto-detect.
		if deviceType != "" && (deviceInfo.explicitType || (deviceType != "scsi" && deviceType != "ata")) {
			args = append(args, "-d", deviceInfo.Type)
		}
	}

	args = append(args, "-a", "--json=c")
	effectiveType := parserType
	if effectiveType == "" {
		effectiveType = deviceType
	}
	if effectiveType == "sat" || effectiveType == "ata" {
		args = append(args, "-l", "devstat")
	}

	if includeStandby {
		args = append(args, "-n", "standby")
	}

	if deviceInfo != nil {
		args = append(args, deviceInfo.Name)
	}

	return args
}

// hasDataForDevice checks if we have cached SMART data for a specific device identity.
func (sm *SmartManager) hasDataForDevice(deviceInfo *DeviceInfo) bool {
	if deviceInfo == nil {
		return false
	}

	sm.Lock()
	defer sm.Unlock()

	deviceKey := makeDeviceKey(deviceInfo.Name, deviceInfo.Type)
	for _, data := range sm.SmartDataMap {
		if data != nil && makeDeviceKey(data.DiskName, data.DiskType) == deviceKey {
			return true
		}
	}
	return false
}

// parseScan parses the output of smartctl --scan -j and returns the discovered devices.
func (sm *SmartManager) parseScan(output []byte) ([]*DeviceInfo, bool) {
	scan := &scanOutput{}

	if err := json.Unmarshal(output, scan); err != nil {
		return nil, false
	}

	if len(scan.Devices) == 0 {
		slog.Debug("no devices found in smartctl scan")
		return nil, false
	}

	devices := make([]*DeviceInfo, 0, len(scan.Devices))
	for _, device := range scan.Devices {
		slog.Debug("smartctl scan", "name", device.Name, "type", device.Type, "protocol", device.Protocol)
		devices = append(devices, &DeviceInfo{
			Name:     device.Name,
			Type:     device.Type,
			InfoName: device.InfoName,
			Protocol: device.Protocol,
		})
	}

	return devices, true
}

// mergeDeviceLists combines scanned and configured SMART devices, preferring
// configured SMART_DEVICES when both sources reference the same device.
func mergeDeviceLists(existing, scanned, configured []*DeviceInfo) []*DeviceInfo {
	if len(scanned) == 0 && len(configured) == 0 {
		return existing
	}

	// buildUniqueNameIndex returns devices that appear exactly once by name.
	// It is used to safely apply name-only fallbacks without RAID ambiguity.
	buildUniqueNameIndex := func(devices []*DeviceInfo) map[string]*DeviceInfo {
		counts := make(map[string]int, len(devices))
		for _, dev := range devices {
			if dev == nil || dev.Name == "" {
				continue
			}
			counts[dev.Name]++
		}
		unique := make(map[string]*DeviceInfo, len(counts))
		for _, dev := range devices {
			if dev == nil || dev.Name == "" {
				continue
			}
			if counts[dev.Name] == 1 {
				unique[dev.Name] = dev
			}
		}
		return unique
	}

	// preserveVerifiedType copies the verified type/parser metadata from an existing
	// device record so that subsequent scans/config updates never downgrade a
	// previously verified device.
	preserveVerifiedType := func(target, prev *DeviceInfo) {
		if prev == nil || !prev.typeVerified {
			return
		}
		target.Type = prev.Type
		target.typeVerified = true
		target.parserType = prev.parserType
		if prev.explicitType {
			target.explicitType = true
		}
	}

	// applyConfiguredMetadata updates a matched device with any configured
	// overrides, preserving verified type data when present.
	applyConfiguredMetadata := func(existingDev, configuredDev *DeviceInfo) {
		// Only update the type if it has not been verified yet; otherwise we
		// keep the existing verified metadata intact.
		if configuredDev.Type != "" && !existingDev.typeVerified {
			newType := strings.TrimSpace(configuredDev.Type)
			existingDev.Type = newType
			existingDev.typeVerified = false
			existingDev.parserType = normalizeParserType(newType)
		}
		if configuredDev.explicitType {
			existingDev.explicitType = true
		}
		if configuredDev.InfoName != "" {
			existingDev.InfoName = configuredDev.InfoName
		}
		if configuredDev.Protocol != "" {
			existingDev.Protocol = configuredDev.Protocol
		}
	}

	existingIndex := make(map[deviceKey]*DeviceInfo, len(existing))
	for _, dev := range existing {
		if dev == nil || dev.Name == "" {
			continue
		}
		existingIndex[makeDeviceKey(dev.Name, dev.Type)] = dev
	}
	existingByName := buildUniqueNameIndex(existing)

	finalDevices := make([]*DeviceInfo, 0, len(scanned)+len(configured))
	deviceIndex := make(map[deviceKey]*DeviceInfo, len(scanned)+len(configured))

	// Start with the newly scanned devices so we always surface fresh metadata,
	// but ensure we retain any previously verified parser assignment.
	for _, scannedDevice := range scanned {
		if scannedDevice == nil || scannedDevice.Name == "" {
			continue
		}

		// Work on a copy so we can safely adjust metadata without mutating the
		// input slices that may be reused elsewhere.
		copyDev := *scannedDevice
		key := makeDeviceKey(copyDev.Name, copyDev.Type)
		if prev := existingIndex[key]; prev != nil {
			preserveVerifiedType(&copyDev, prev)
		} else if prev := existingByName[copyDev.Name]; prev != nil {
			preserveVerifiedType(&copyDev, prev)
		}

		finalDevices = append(finalDevices, &copyDev)
		copyKey := makeDeviceKey(copyDev.Name, copyDev.Type)
		deviceIndex[copyKey] = finalDevices[len(finalDevices)-1]
	}
	deviceIndexByName := buildUniqueNameIndex(finalDevices)

	// Merge configured devices on top so users can override scan results (except
	// for verified type information).
	for _, configuredDevice := range configured {
		if configuredDevice == nil || configuredDevice.Name == "" {
			continue
		}

		key := makeDeviceKey(configuredDevice.Name, configuredDevice.Type)
		if existingDev, ok := deviceIndex[key]; ok {
			applyConfiguredMetadata(existingDev, configuredDevice)
			continue
		}
		if existingDev := deviceIndexByName[configuredDevice.Name]; existingDev != nil {
			oldKey := makeDeviceKey(existingDev.Name, existingDev.Type)
			if prev := existingIndex[key]; prev != nil {
				preserveVerifiedType(existingDev, prev)
			}
			applyConfiguredMetadata(existingDev, configuredDevice)
			delete(deviceIndex, oldKey)
			deviceIndex[makeDeviceKey(existingDev.Name, existingDev.Type)] = existingDev
			delete(deviceIndexByName, configuredDevice.Name)
			continue
		}

		copyDev := *configuredDevice
		key = makeDeviceKey(copyDev.Name, copyDev.Type)
		if prev := existingIndex[key]; prev != nil {
			preserveVerifiedType(&copyDev, prev)
		} else if prev := existingByName[copyDev.Name]; prev != nil {
			preserveVerifiedType(&copyDev, prev)
		} else if copyDev.Type != "" {
			copyDev.parserType = normalizeParserType(copyDev.Type)
		}

		finalDevices = append(finalDevices, &copyDev)
		copyKey := makeDeviceKey(copyDev.Name, copyDev.Type)
		deviceIndex[copyKey] = finalDevices[len(finalDevices)-1]
	}

	return finalDevices
}

// updateSmartDevices replaces the cached device list and prunes SMART data
// entries whose backing device no longer exists.
func (sm *SmartManager) updateSmartDevices(devices []*DeviceInfo) {
	sm.Lock()
	defer sm.Unlock()

	sm.SmartDevices = devices

	if len(sm.SmartDataMap) == 0 {
		return
	}

	validKeys := make(map[deviceKey]struct{}, len(devices))
	nameCounts := make(map[string]int, len(devices))
	for _, device := range devices {
		if device == nil || device.Name == "" {
			continue
		}
		validKeys[makeDeviceKey(device.Name, device.Type)] = struct{}{}
		nameCounts[device.Name]++
	}

	for key, data := range sm.SmartDataMap {
		if data == nil {
			delete(sm.SmartDataMap, key)
			continue
		}

		if data.DiskType == "" {
			if nameCounts[data.DiskName] == 1 {
				continue
			}
		} else if _, ok := validKeys[makeDeviceKey(data.DiskName, data.DiskType)]; ok {
			continue
		}

		delete(sm.SmartDataMap, key)
	}
}

// isVirtualDevice checks if a device is a virtual disk that should be filtered out
func (sm *SmartManager) isVirtualDevice(data *smart.SmartInfoForSata) bool {
	vendorUpper := strings.ToUpper(data.ScsiVendor)
	productUpper := strings.ToUpper(data.ScsiProduct)
	modelUpper := strings.ToUpper(data.ModelName)

	return sm.isVirtualDeviceFromStrings(vendorUpper, productUpper, modelUpper)
}

// isVirtualDeviceNvme checks if an NVMe device is a virtual disk that should be filtered out
func (sm *SmartManager) isVirtualDeviceNvme(data *smart.SmartInfoForNvme) bool {
	modelUpper := strings.ToUpper(data.ModelName)

	return sm.isVirtualDeviceFromStrings(modelUpper)
}

// isVirtualDeviceScsi checks if a SCSI device is a virtual disk that should be filtered out
func (sm *SmartManager) isVirtualDeviceScsi(data *smart.SmartInfoForScsi) bool {
	vendorUpper := strings.ToUpper(data.ScsiVendor)
	productUpper := strings.ToUpper(data.ScsiProduct)
	modelUpper := strings.ToUpper(data.ScsiModelName)

	return sm.isVirtualDeviceFromStrings(vendorUpper, productUpper, modelUpper)
}

// isVirtualDeviceFromStrings checks if any of the provided strings indicate a virtual device
func (sm *SmartManager) isVirtualDeviceFromStrings(fields ...string) bool {
	for _, field := range fields {
		fieldUpper := strings.ToUpper(field)
		switch {
		case strings.Contains(fieldUpper, "IET"), // iSCSI Enterprise Target
			strings.Contains(fieldUpper, "VIRTUAL"),
			strings.Contains(fieldUpper, "QEMU"),
			strings.Contains(fieldUpper, "VBOX"),
			strings.Contains(fieldUpper, "VMWARE"),
			strings.Contains(fieldUpper, "MSFT"): // Microsoft Hyper-V
			return true
		}
	}
	return false
}

// parseSmartForSata parses the output of smartctl --all -j for SATA/ATA devices and updates the SmartDataMap.
// deviceType is the exact type used to identify and query the device; when set,
// it takes precedence over the generic type reported by smartctl.
// Returns hasValidData and exitStatus
func (sm *SmartManager) parseSmartForSata(output []byte, deviceType string) (bool, int) {
	var data smart.SmartInfoForSata

	if err := json.Unmarshal(output, &data); err != nil {
		return false, 0
	}

	if data.SerialNumber == "" {
		slog.Debug("no serial number", "device", data.Device.Name)
		return false, data.Smartctl.ExitStatus
	}

	// Skip virtual devices (e.g., Kubernetes PVCs, QEMU, VirtualBox, etc.)
	if sm.isVirtualDevice(&data) {
		slog.Debug("skipping smart", "device", data.Device.Name, "model", data.ModelName)
		return false, data.Smartctl.ExitStatus
	}

	sm.Lock()
	defer sm.Unlock()

	keyName := data.SerialNumber

	// if device does not exist in SmartDataMap, initialize it
	if _, ok := sm.SmartDataMap[keyName]; !ok {
		sm.SmartDataMap[keyName] = &smart.SmartData{}
	}

	// update SmartData
	smartData := sm.SmartDataMap[keyName]
	// smartData.ModelFamily = data.ModelFamily
	smartData.ModelName = data.ModelName
	smartData.SerialNumber = data.SerialNumber
	smartData.FirmwareVersion = data.FirmwareVersion
	smartData.Capacity = data.UserCapacity.Bytes
	smartData.Temperature = data.Temperature.Current
	smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
	smartData.DiskName = data.Device.Name
	smartData.DiskType = data.Device.Type
	if deviceType != "" {
		smartData.DiskType = deviceType
	}

	// get values from ata_device_statistics if necessary
	var ataDeviceStats smart.AtaDeviceStatistics
	if smartData.Temperature == 0 {
		if temp := findAtaDeviceStatisticsValue(&data, &ataDeviceStats, 5, "Current Temperature", 0, 255); temp != nil {
			smartData.Temperature = uint8(*temp)
		}
	}

	// update SmartAttributes
	smartData.Attributes = make([]*smart.SmartAttribute, 0, len(data.AtaSmartAttributes.Table))
	for _, attr := range data.AtaSmartAttributes.Table {
		rawValue := uint64(attr.Raw.Value)
		if parsed, ok := smart.ParseSmartRawValueString(attr.Raw.String); ok {
			rawValue = parsed
		}
		smartAttr := &smart.SmartAttribute{
			ID:         attr.ID,
			Name:       attr.Name,
			Value:      attr.Value,
			Worst:      attr.Worst,
			Threshold:  attr.Thresh,
			RawValue:   rawValue,
			RawString:  attr.Raw.String,
			WhenFailed: attr.WhenFailed,
		}
		smartData.Attributes = append(smartData.Attributes, smartAttr)
	}
	sm.SmartDataMap[keyName] = smartData

	return true, data.Smartctl.ExitStatus
}

func getSmartStatus(temperature uint8, passed bool) string {
	if passed {
		return "PASSED"
	} else if temperature > 0 {
		return "FAILED"
	} else {
		return "UNKNOWN"
	}
}

// findAtaDeviceStatisticsEntry centralizes ATA devstat lookups so additional
// metrics can be pulled from the same structure in the future.
func findAtaDeviceStatisticsValue(data *smart.SmartInfoForSata, ataDeviceStats *smart.AtaDeviceStatistics, entryNumber uint8, entryName string, minValue, maxValue int64) *int64 {
	if len(ataDeviceStats.Pages) == 0 {
		if len(data.AtaDeviceStatistics) == 0 {
			return nil
		}
		if err := json.Unmarshal(data.AtaDeviceStatistics, ataDeviceStats); err != nil {
			return nil
		}
	}
	for pageIdx := range ataDeviceStats.Pages {
		page := &ataDeviceStats.Pages[pageIdx]
		if page.Number != entryNumber {
			continue
		}
		for entryIdx := range page.Table {
			entry := &page.Table[entryIdx]
			if !strings.EqualFold(entry.Name, entryName) {
				continue
			}
			if entry.Value == nil || *entry.Value < minValue || *entry.Value > maxValue {
				return nil
			}
			return entry.Value
		}
	}
	return nil
}

func (sm *SmartManager) parseSmartForScsi(output []byte, deviceType string) (bool, int) {
	var data smart.SmartInfoForScsi

	if err := json.Unmarshal(output, &data); err != nil {
		return false, 0
	}

	if data.SerialNumber == "" {
		slog.Debug("no serial number", "device", data.Device.Name)
		return false, data.Smartctl.ExitStatus
	}

	// Skip virtual devices (e.g., Kubernetes PVCs, QEMU, VirtualBox, etc.)
	if sm.isVirtualDeviceScsi(&data) {
		slog.Debug("skipping smart", "device", data.Device.Name, "model", data.ScsiModelName)
		return false, data.Smartctl.ExitStatus
	}

	sm.Lock()
	defer sm.Unlock()

	keyName := data.SerialNumber
	if _, ok := sm.SmartDataMap[keyName]; !ok {
		sm.SmartDataMap[keyName] = &smart.SmartData{}
	}

	smartData := sm.SmartDataMap[keyName]
	smartData.ModelName = data.ScsiModelName
	smartData.SerialNumber = data.SerialNumber
	smartData.FirmwareVersion = data.ScsiRevision
	smartData.Capacity = data.UserCapacity.Bytes
	smartData.Temperature = data.Temperature.Current
	smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
	smartData.DiskName = data.Device.Name
	smartData.DiskType = data.Device.Type
	if deviceType != "" {
		smartData.DiskType = deviceType
	}

	attributes := make([]*smart.SmartAttribute, 0, 10)
	attributes = append(attributes, &smart.SmartAttribute{Name: "PowerOnHours", RawValue: data.PowerOnTime.Hours})
	attributes = append(attributes, &smart.SmartAttribute{Name: "PowerOnMinutes", RawValue: data.PowerOnTime.Minutes})
	attributes = append(attributes, &smart.SmartAttribute{Name: "GrownDefectList", RawValue: data.ScsiGrownDefectList})
	attributes = append(attributes, &smart.SmartAttribute{Name: "StartStopCycles", RawValue: data.ScsiStartStopCycleCounter.AccumulatedStartStopCycles})
	attributes = append(attributes, &smart.SmartAttribute{Name: "LoadUnloadCycles", RawValue: data.ScsiStartStopCycleCounter.AccumulatedLoadUnloadCycles})
	attributes = append(attributes, &smart.SmartAttribute{Name: "StartStopSpecified", RawValue: data.ScsiStartStopCycleCounter.SpecifiedCycleCountOverDeviceLifetime})
	attributes = append(attributes, &smart.SmartAttribute{Name: "LoadUnloadSpecified", RawValue: data.ScsiStartStopCycleCounter.SpecifiedLoadUnloadCountOverDeviceLifetime})

	readStats := data.ScsiErrorCounterLog.Read
	writeStats := data.ScsiErrorCounterLog.Write
	verifyStats := data.ScsiErrorCounterLog.Verify

	attributes = append(attributes, &smart.SmartAttribute{Name: "ReadTotalErrorsCorrected", RawValue: readStats.TotalErrorsCorrected})
	attributes = append(attributes, &smart.SmartAttribute{Name: "ReadTotalUncorrectedErrors", RawValue: readStats.TotalUncorrectedErrors})
	attributes = append(attributes, &smart.SmartAttribute{Name: "ReadCorrectionAlgorithmInvocations", RawValue: readStats.CorrectionAlgorithmInvocations})
	if val := parseScsiGigabytesProcessed(readStats.GigabytesProcessed); val >= 0 {
		attributes = append(attributes, &smart.SmartAttribute{Name: "ReadGigabytesProcessed", RawValue: uint64(val)})
	}
	attributes = append(attributes, &smart.SmartAttribute{Name: "WriteTotalErrorsCorrected", RawValue: writeStats.TotalErrorsCorrected})
	attributes = append(attributes, &smart.SmartAttribute{Name: "WriteTotalUncorrectedErrors", RawValue: writeStats.TotalUncorrectedErrors})
	attributes = append(attributes, &smart.SmartAttribute{Name: "WriteCorrectionAlgorithmInvocations", RawValue: writeStats.CorrectionAlgorithmInvocations})
	if val := parseScsiGigabytesProcessed(writeStats.GigabytesProcessed); val >= 0 {
		attributes = append(attributes, &smart.SmartAttribute{Name: "WriteGigabytesProcessed", RawValue: uint64(val)})
	}
	attributes = append(attributes, &smart.SmartAttribute{Name: "VerifyTotalErrorsCorrected", RawValue: verifyStats.TotalErrorsCorrected})
	attributes = append(attributes, &smart.SmartAttribute{Name: "VerifyTotalUncorrectedErrors", RawValue: verifyStats.TotalUncorrectedErrors})
	attributes = append(attributes, &smart.SmartAttribute{Name: "VerifyCorrectionAlgorithmInvocations", RawValue: verifyStats.CorrectionAlgorithmInvocations})
	if val := parseScsiGigabytesProcessed(verifyStats.GigabytesProcessed); val >= 0 {
		attributes = append(attributes, &smart.SmartAttribute{Name: "VerifyGigabytesProcessed", RawValue: uint64(val)})
	}

	smartData.Attributes = attributes
	sm.SmartDataMap[keyName] = smartData

	return true, data.Smartctl.ExitStatus
}

func parseScsiGigabytesProcessed(value string) int64 {
	if value == "" {
		return -1
	}
	normalized := strings.ReplaceAll(value, ",", "")
	parsed, err := strconv.ParseInt(normalized, 10, 64)
	if err != nil {
		return -1
	}
	return parsed
}

// lookupDarwinNvmeCapacity returns the capacity in bytes for a given NVMe serial number on Darwin.
// It uses system_profiler SPNVMeDataType to get capacity since Apple SSDs don't report user_capacity
// via smartctl. Results are cached after the first call via sync.Once.
func (sm *SmartManager) lookupDarwinNvmeCapacity(serial string) uint64 {
	sm.darwinNvmeOnce.Do(func() {
		sm.darwinNvmeCapacity = make(map[string]uint64)

		provider := sm.darwinNvmeProvider
		if provider == nil {
			provider = func() ([]byte, error) {
				ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
				defer cancel()
				return exec.CommandContext(ctx, "system_profiler", "SPNVMeDataType", "-json").Output()
			}
		}

		out, err := provider()
		if err != nil {
			slog.Debug("system_profiler NVMe lookup failed", "err", err)
			return
		}

		var result struct {
			SPNVMeDataType []struct {
				Items []struct {
					DeviceSerial string `json:"device_serial"`
					SizeInBytes  uint64 `json:"size_in_bytes"`
				} `json:"_items"`
			} `json:"SPNVMeDataType"`
		}
		if err := json.Unmarshal(out, &result); err != nil {
			slog.Debug("system_profiler NVMe parse failed", "err", err)
			return
		}

		for _, controller := range result.SPNVMeDataType {
			for _, item := range controller.Items {
				if item.DeviceSerial != "" && item.SizeInBytes > 0 {
					sm.darwinNvmeCapacity[item.DeviceSerial] = item.SizeInBytes
				}
			}
		}
	})
	return sm.darwinNvmeCapacity[serial]
}

// parseSmartForNvme parses the output of smartctl --all -j /dev/nvmeX and updates the SmartDataMap.
// deviceType is the exact type used to identify and query the device; when set,
// it takes precedence over the generic type reported by smartctl.
// Returns hasValidData and exitStatus
func (sm *SmartManager) parseSmartForNvme(output []byte, deviceType string) (bool, int) {
	data := &smart.SmartInfoForNvme{}

	if err := json.Unmarshal(output, &data); err != nil {
		return false, 0
	}

	if data.SerialNumber == "" {
		slog.Debug("no serial number", "device", data.Device.Name)
		return false, data.Smartctl.ExitStatus
	}

	// Skip virtual devices (e.g., Kubernetes PVCs, QEMU, VirtualBox, etc.)
	if sm.isVirtualDeviceNvme(data) {
		slog.Debug("skipping smart", "device", data.Device.Name, "model", data.ModelName)
		return false, data.Smartctl.ExitStatus
	}

	sm.Lock()
	defer sm.Unlock()

	keyName := data.SerialNumber

	// if device does not exist in SmartDataMap, initialize it
	if _, ok := sm.SmartDataMap[keyName]; !ok {
		sm.SmartDataMap[keyName] = &smart.SmartData{}
	}

	// update SmartData
	smartData := sm.SmartDataMap[keyName]
	smartData.ModelName = data.ModelName
	smartData.SerialNumber = data.SerialNumber
	smartData.FirmwareVersion = data.FirmwareVersion
	smartData.Capacity = data.UserCapacity.Bytes
	if smartData.Capacity == 0 {
		smartData.Capacity = data.NVMeTotalCapacity
	}
	if smartData.Capacity == 0 && (runtime.GOOS == "darwin" || sm.darwinNvmeProvider != nil) {
		smartData.Capacity = sm.lookupDarwinNvmeCapacity(data.SerialNumber)
	}
	smartData.Temperature = data.NVMeSmartHealthInformationLog.Temperature
	smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
	smartData.DiskName = data.Device.Name
	smartData.DiskType = data.Device.Type
	if deviceType != "" {
		smartData.DiskType = deviceType
	}

	// nvme attributes does not follow the same format as ata attributes,
	// so we manually map each field to SmartAttributes
	log := data.NVMeSmartHealthInformationLog
	smartData.Attributes = []*smart.SmartAttribute{
		{Name: "CriticalWarning", RawValue: uint64(log.CriticalWarning)},
		{Name: "Temperature", RawValue: uint64(log.Temperature)},
		{Name: "AvailableSpare", RawValue: uint64(log.AvailableSpare)},
		{Name: "AvailableSpareThreshold", RawValue: uint64(log.AvailableSpareThreshold)},
		{Name: "PercentageUsed", RawValue: uint64(log.PercentageUsed)},
		{Name: "DataUnitsRead", RawValue: log.DataUnitsRead},
		{Name: "DataUnitsWritten", RawValue: log.DataUnitsWritten},
		{Name: "HostReads", RawValue: uint64(log.HostReads)},
		{Name: "HostWrites", RawValue: uint64(log.HostWrites)},
		{Name: "ControllerBusyTime", RawValue: uint64(log.ControllerBusyTime)},
		{Name: "PowerCycles", RawValue: uint64(log.PowerCycles)},
		{Name: "PowerOnHours", RawValue: uint64(log.PowerOnHours)},
		{Name: "UnsafeShutdowns", RawValue: uint64(log.UnsafeShutdowns)},
		{Name: "MediaErrors", RawValue: uint64(log.MediaErrors)},
		{Name: "NumErrLogEntries", RawValue: uint64(log.NumErrLogEntries)},
		{Name: "WarningTempTime", RawValue: uint64(log.WarningTempTime)},
		{Name: "CriticalCompTime", RawValue: uint64(log.CriticalCompTime)},
	}

	sm.SmartDataMap[keyName] = smartData

	return true, data.Smartctl.ExitStatus
}

// detectSmartctl checks if smartctl is installed, returns an error if not
func (sm *SmartManager) detectSmartctl() (string, error) {
	if runtime.GOOS == "windows" {
		// Load embedded smartctl.exe for Windows amd64 builds.
		if runtime.GOARCH == "amd64" {
			if path, err := ensureEmbeddedSmartctl(); err == nil {
				return path, nil
			}
		}
		// Try to find smartctl in the default installation location
		const location = "C:\\Program Files\\smartmontools\\bin\\smartctl.exe"
		if _, err := os.Stat(location); err == nil {
			return location, nil
		}
	}

	return utils.LookPathHomebrew("smartctl")
}

// isNvmeControllerPath checks if the path matches an NVMe controller pattern
// like /dev/nvme0, /dev/nvme1, etc. (without namespace suffix like n1)
func isNvmeControllerPath(path string) bool {
	base := filepath.Base(path)
	if !strings.HasPrefix(base, "nvme") {
		return false
	}
	suffix := strings.TrimPrefix(base, "nvme")
	if suffix == "" {
		return false
	}
	// Controller paths are just "nvme" + digits (e.g., nvme0, nvme1)
	// Namespace paths have "n" after the controller number (e.g., nvme0n1)
	for _, c := range suffix {
		if c < '0' || c > '9' {
			return false
		}
	}
	return true
}

// NewSmartManager creates and initializes a new SmartManager
func NewSmartManager() (*SmartManager, error) {
	sm := &SmartManager{
		SmartDataMap: make(map[string]*smart.SmartData),
	}
	sm.refreshExcludedDevices()
	path, err := sm.detectSmartctl()
	slog.Debug("smartctl", "path", path, "err", err)
	if err != nil {
		// Keep the previous fail-fast behavior unless this Linux host exposes
		// eMMC or mdraid health via sysfs, in which case smartctl is optional.
		if runtime.GOOS == "linux" {
			if len(scanEmmcDevices()) > 0 || len(scanMdraidDevices()) > 0 {
				return sm, nil
			}
		}
		return nil, err
	}
	sm.smartctlPath = path
	return sm, nil
}
