//go:build linux
// +build linux

package temp

import (
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"strconv"
	"strings"

	"golang.org/x/sys/unix"

	"github.com/influxdata/telegraf"
	"github.com/influxdata/telegraf/internal"
)

const scalingFactor = float64(1000.0)

type temperatureStat struct {
	name        string
	label       string
	device      string
	temperature float64
	additional  map[string]interface{}
}

func (t *Temperature) Init() error {
	switch t.MetricFormat {
	case "":
		t.MetricFormat = "v2"
	case "v1", "v2":
		// Do nothing as those are valid
	default:
		return fmt.Errorf("invalid 'metric_format' %q", t.MetricFormat)
	}
	return nil
}

func (t *Temperature) Gather(acc telegraf.Accumulator) error {
	// Get all sensors and honor the HOST_SYS environment variable
	path := internal.GetSysPath()

	// Try to use the hwmon interface
	temperatures, err := t.gatherHwmon(path)
	if err != nil {
		return fmt.Errorf("getting temperatures failed: %w", err)
	}

	if len(temperatures) == 0 || t.ThermalZones {
		// There is no hwmon interface, fallback to thermal-zone parsing
		// Or append the thermal-zone parsing if set in the configuration
		temperaturesZones, err := t.gatherThermalZone(path)
		if err != nil {
			return fmt.Errorf("getting temperatures (via fallback) failed: %w", err)
		}
		temperatures = append(temperatures, temperaturesZones...)
	}

	switch t.MetricFormat {
	case "v1":
		t.createMetricsV1(acc, temperatures)
	case "v2":
		t.createMetricsV2(acc, temperatures)
	}

	return nil
}

func (t *Temperature) createMetricsV1(acc telegraf.Accumulator, temperatures []temperatureStat) {
	for _, temp := range temperatures {
		sensor := temp.name
		if temp.label != "" {
			sensor += "_" + strings.ReplaceAll(temp.label, " ", "")
		}

		// Mandatory measurement value
		tags := map[string]string{"sensor": sensor + "_input"}
		if t.DeviceTag {
			tags["device"] = temp.device
		}
		acc.AddFields("temp", map[string]interface{}{"temp": temp.temperature}, tags)

		// Optional values values
		for measurement, value := range temp.additional {
			tags := map[string]string{"sensor": sensor + "_" + measurement}
			if t.DeviceTag {
				tags["device"] = temp.device
			}
			acc.AddFields("temp", map[string]interface{}{"temp": value}, tags)
		}
	}
}

func (t *Temperature) createMetricsV2(acc telegraf.Accumulator, temperatures []temperatureStat) {
	for _, temp := range temperatures {
		sensor := temp.name
		if temp.label != "" {
			sensor += "_" + strings.ReplaceAll(temp.label, " ", "_")
		}

		// Mandatory measurement value
		tags := map[string]string{"sensor": sensor}
		if t.DeviceTag {
			tags["device"] = temp.device
		}
		acc.AddFields("temp", map[string]interface{}{"temp": temp.temperature}, tags)
	}
}

func (t *Temperature) gatherHwmon(syspath string) ([]temperatureStat, error) {
	// Get all hwmon devices
	sensors, err := filepath.Glob(filepath.Join(syspath, "class", "hwmon", "hwmon*", "temp*_input"))
	if err != nil {
		return nil, fmt.Errorf("getting sensors failed: %w", err)
	}

	// Handle CentOS special path containing an additional "device" directory
	// see https://github.com/shirou/gopsutil/blob/master/host/host_linux.go
	if len(sensors) == 0 {
		sensors, err = filepath.Glob(filepath.Join(syspath, "class", "hwmon", "hwmon*", "device", "temp*_input"))
		if err != nil {
			return nil, fmt.Errorf("getting sensors on CentOS failed: %w", err)
		}
	}

	// Exit early if we cannot find any device
	if len(sensors) == 0 {
		return nil, nil
	}

	// Collect the sensor information
	stats := make([]temperatureStat, 0, len(sensors))
	for _, s := range sensors {
		// Get the sensor directory and the temperature prefix from the path
		path := filepath.Dir(s)
		prefix := strings.SplitN(filepath.Base(s), "_", 2)[0]

		// Read the sensor and device name
		deviceName, err := os.Readlink(filepath.Join(path, "device"))
		if err == nil {
			deviceName = filepath.Base(deviceName)
		}

		// Read the sensor name and use the device name as fallback
		name := deviceName
		n, err := os.ReadFile(filepath.Join(path, "name"))
		if err == nil {
			name = strings.TrimSpace(string(n))
		}

		// Get the sensor label
		var label string
		if buf, err := os.ReadFile(filepath.Join(path, prefix+"_label")); err == nil {
			label = strings.TrimSpace(string(buf))
		}

		// Do the actual sensor readings
		temp := temperatureStat{
			name:       name,
			label:      strings.ToLower(label),
			device:     deviceName,
			additional: make(map[string]interface{}),
		}

		// Temperature (mandatory). Use a non-blocking read as the underlying
		// hardware might be unavailable (e.g. asleep) and a blocking read would
		// hang the gather cycle.
		fn := filepath.Join(path, prefix+"_input")
		buf, err := readFileNonblocking(fn)
		if err != nil {
			t.Log.Debugf("Couldn't read temperature from %q: %v", fn, err)
			continue
		}
		if v, err := strconv.ParseFloat(strings.TrimSpace(string(buf)), 64); err == nil {
			temp.temperature = v / scalingFactor
		}

		// Read all possible values of the sensor
		matches, err := filepath.Glob(filepath.Join(path, prefix+"_*"))
		if err != nil {
			t.Log.Warnf("Couldn't read files from %q: %v", filepath.Join(path, prefix+"_*"), err)
			continue
		}
		for _, fn := range matches {
			buf, err = os.ReadFile(fn)
			if err != nil {
				continue
			}
			parts := strings.SplitN(filepath.Base(fn), "_", 2)
			if len(parts) != 2 {
				continue
			}
			measurement := parts[1]

			// Skip already added values
			switch measurement {
			case "label", "input":
				continue
			}

			v, err := strconv.ParseFloat(strings.TrimSpace(string(buf)), 64)
			if err != nil {
				continue
			}
			temp.additional[measurement] = v / scalingFactor
		}

		stats = append(stats, temp)
	}

	return stats, nil
}

func (t *Temperature) gatherThermalZone(syspath string) ([]temperatureStat, error) {
	// For file layout see https://www.kernel.org/doc/Documentation/thermal/sysfs-api.txt
	zones, err := filepath.Glob(filepath.Join(syspath, "class", "thermal", "thermal_zone*"))
	if err != nil {
		return nil, fmt.Errorf("getting thermal zones failed: %w", err)
	}

	// Exit early if we cannot find any zone
	if len(zones) == 0 {
		return nil, nil
	}

	// Collect the sensor information
	stats := make([]temperatureStat, 0, len(zones))
	for _, path := range zones {
		// Type of the zone corresponding to the sensor name in our nomenclature
		buf, err := os.ReadFile(filepath.Join(path, "type"))
		if err != nil {
			t.Log.Errorf("Cannot read name of zone %q", path)
			continue
		}
		name := strings.TrimSpace(string(buf))

		// Actual temperature. On Linux EAGAIN and EWOULDBLOCK are the same errno.
		buf, err = readFileNonblocking(filepath.Join(path, "temp"))
		if err != nil {
			if errors.Is(err, unix.EAGAIN) {
				// Silently ignore this thermal-zone when the underlying hardware is unavailable
				t.Log.Tracef("Skipping zone %q as the hardware is unavailable: %v", path, err)
				continue
			}
			t.Log.Errorf("Cannot read temperature of zone %q: %v", path, err)
			continue
		}
		v, err := strconv.ParseFloat(strings.TrimSpace(string(buf)), 64)
		if err != nil {
			continue
		}

		temp := temperatureStat{name: name, temperature: v / scalingFactor}
		stats = append(stats, temp)
	}

	return stats, nil
}

func readFileNonblocking(path string) ([]byte, error) {
	fd, err := unix.Open(path, unix.O_RDONLY|unix.O_NONBLOCK, 0)
	if err != nil {
		return nil, err
	}
	defer unix.Close(fd)

	// The kernel specifies the temperatures are expressed in millidegrees
	// tempBuf of 8 bytes stores temperatures up to 99999.999°C
	var tempBuf [8]byte

	n, err := unix.Read(fd, tempBuf[:])
	if err != nil {
		return nil, err
	}
	return tempBuf[:n], nil
}
