package hub

import (
	"context"
	"os"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
)

// TempReader reads the host CPU/chassis temperature in whole °C, returning nil when no usable
// sensor is exposed. It is the slice-9 collector seam — graceful-null is the contract: a missing
// sensor, an unsupported board, or any read error all degrade to nil rather than failing the
// host report (mirrors the nullable disk SmartSummary.TemperatureC). The collector is nil-safe
// (a nil TempReader yields nil temp).
type TempReader interface {
	CPUTempC(ctx context.Context) *int
}

// SysfsTempReader reads the CPU package temperature straight from sysfs (hwmon coretemp/k10temp/
// cpu_thermal, then the thermal zones). No external binary and no privilege is needed — these
// nodes are world-readable — so it never shells out (keeping the agent's root-CLI fence intact).
// It prefers the CPU-package hwmon sensor; only if hwmon yields nothing does it fall back to a
// CPU-ish thermal zone. Every failure path returns nil ("n/a").
type SysfsTempReader struct {
	// Root overrides the sysfs root ("" → "/sys"); set by tests to a fake layout.
	Root string
}

func (r SysfsTempReader) root() string {
	if r.Root != "" {
		return r.Root
	}
	return "/sys"
}

// CPUTempC returns the CPU/chassis temperature in whole °C, or nil if nothing usable is exposed.
// ctx is accepted for interface symmetry (the reads are local sysfs and effectively instant).
func (r SysfsTempReader) CPUTempC(ctx context.Context) *int {
	if t := r.fromHwmon(); t != nil {
		return t
	}
	if t := r.fromThermalZones(); t != nil {
		return t
	}
	return nil
}

// cpuHwmonNames are the kernel hwmon driver names that expose a CPU temperature: coretemp
// (Intel — e.g. the demo N100), k10temp/zenpower (AMD), cpu_thermal (ARM SoCs).
var cpuHwmonNames = map[string]bool{
	"coretemp":    true,
	"k10temp":     true,
	"zenpower":    true,
	"cpu_thermal": true,
}

// fromHwmon scans /sys/class/hwmon/hwmon*/ for a CPU driver and returns its package temperature.
// For a multi-core coretemp it prefers the "Package id 0" labelled input; otherwise it takes the
// first readable tempN_input. Returns nil when no CPU hwmon is present/readable.
func (r SysfsTempReader) fromHwmon() *int {
	dirs, err := filepath.Glob(filepath.Join(r.root(), "class", "hwmon", "hwmon*"))
	if err != nil {
		return nil
	}
	sort.Strings(dirs) // deterministic device ordering (hwmon0, hwmon1, …)
	for _, dir := range dirs {
		name := strings.TrimSpace(readFileTrim(filepath.Join(dir, "name")))
		if !cpuHwmonNames[name] {
			continue
		}
		if t := readHwmonPackageTemp(dir); t != nil {
			return t
		}
	}
	return nil
}

// readHwmonPackageTemp returns a CPU hwmon device's package temperature, preferring a
// tempN_input whose tempN_label is "Package id 0", else the lowest-numbered readable input.
func readHwmonPackageTemp(dir string) *int {
	inputs, err := filepath.Glob(filepath.Join(dir, "temp*_input"))
	if err != nil || len(inputs) == 0 {
		return nil
	}
	sort.Strings(inputs) // temp1_input < temp10_input lexically is wrong, but the package is temp1
	var firstReadable *int
	for _, in := range inputs {
		milli, ok := readMilliC(in)
		if !ok {
			continue
		}
		c := milli / 1000
		if firstReadable == nil {
			v := c
			firstReadable = &v
		}
		labelPath := strings.TrimSuffix(in, "_input") + "_label"
		if strings.EqualFold(strings.TrimSpace(readFileTrim(labelPath)), "Package id 0") {
			v := c
			return &v
		}
	}
	return firstReadable
}

// cpuZoneTypes are thermal-zone `type` values that name a CPU sensor, in preference order.
var cpuZoneTypes = []string{"x86_pkg_temp", "coretemp", "cpu-thermal", "cpu_thermal", "soc_thermal"}

// fromThermalZones scans /sys/class/thermal/thermal_zone*/ and returns the best CPU-ish zone's
// temperature. It prefers a zone whose `type` matches a known CPU sensor (in cpuZoneTypes order);
// if none match it falls back to an acpitz zone, then the first readable zone. nil when none read.
func (r SysfsTempReader) fromThermalZones() *int {
	zones, err := filepath.Glob(filepath.Join(r.root(), "class", "thermal", "thermal_zone*"))
	if err != nil {
		return nil
	}
	sort.Strings(zones)
	byType := map[string]*int{}
	var acpitz, firstAny *int
	for _, z := range zones {
		zType := strings.TrimSpace(readFileTrim(filepath.Join(z, "type")))
		milli, ok := readMilliC(filepath.Join(z, "temp"))
		if !ok {
			continue
		}
		c := milli / 1000
		if firstAny == nil {
			v := c
			firstAny = &v
		}
		if zType == "acpitz" && acpitz == nil {
			v := c
			acpitz = &v
		}
		if _, seen := byType[zType]; !seen {
			v := c
			byType[zType] = &v
		}
	}
	for _, want := range cpuZoneTypes {
		if t := byType[want]; t != nil {
			return t
		}
	}
	if acpitz != nil {
		return acpitz
	}
	return firstAny
}

// readMilliC reads a sysfs temperature file (millidegrees Celsius as an integer) and returns it.
// A sane sanity bound rejects obviously bogus values (sensors occasionally report 0 or huge
// numbers when not yet initialised) so "n/a" is reported instead of a garbage temperature.
func readMilliC(path string) (int, bool) {
	s := readFileTrim(path)
	if s == "" {
		return 0, false
	}
	milli, err := strconv.Atoi(s)
	if err != nil {
		return 0, false
	}
	// Plausible CPU/chassis range: 5°C..150°C. Outside that → treat as unavailable.
	if milli < 5000 || milli > 150000 {
		return 0, false
	}
	return milli, true
}

// readFileTrim reads a small sysfs file and trims it; "" on any error (graceful-null).
func readFileTrim(path string) string {
	b, err := os.ReadFile(path)
	if err != nil {
		return ""
	}
	return strings.TrimSpace(string(b))
}
