package main

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

// The card is an AMD 7900 GRE with 16GB, driven by amdgpu and ROCm. Everything
// here reads sysfs and forks nothing: rocm-smi is not even installed on the
// workstation, and a poll that costs a subprocess every second is a poll that
// gets tuned down until it is useless.

// gpuProc — one process holding the compute engine.
type gpuProc struct {
	PID  int
	Comm string
	VRAM int64 // bytes, as the kernel accounts them to this process
}

// probe reads the two sysfs trees the supervisor decides from.
//
// kfdRoot is /sys/class/kfd/kfd/proc, one directory per ROCm process. The
// directory appears when the process initialises HIP, which is well before it
// allocates anything large. That is the whole reason this works: the job that
// is about to want the card announces itself while it is still starting up,
// so we see the contender rather than only the winner of an allocation race.
//
// drmDev is /sys/class/drm/cardN/device, which reports total and used VRAM for
// the card as a whole.
type probe struct {
	kfdRoot string
	drmDev  string
}

// foreign lists every ROCm process that is not ours. selfPID is the supervisor's
// llama-server child, or 0 when it is not running.
//
// An unreadable kfd tree returns no processes and no error. That is deliberate
// and it is the safe direction only because startVRAM also has to agree before
// anything launches: a supervisor that cannot see the KFD never sees free VRAM
// either, because the CPT run holding the card shows up in the drm totals.
func (p probe) foreign(selfPID int) []gpuProc {
	entries, err := os.ReadDir(p.kfdRoot)
	if err != nil {
		return nil
	}
	var out []gpuProc
	for _, e := range entries {
		pid, err := strconv.Atoi(e.Name())
		if err != nil || pid == selfPID {
			continue
		}
		out = append(out, gpuProc{
			PID:  pid,
			Comm: readComm(pid),
			VRAM: p.procVRAM(e.Name()),
		})
	}
	return out
}

// procVRAM sums the per-node vram_* files under one process directory. The
// suffix is the KFD topology node id (vram_35881 on this card), so it is
// globbed rather than named, and a machine with two cards sums both.
func (p probe) procVRAM(pid string) int64 {
	matches, err := filepath.Glob(filepath.Join(p.kfdRoot, pid, "vram_*"))
	if err != nil {
		return 0
	}
	var total int64
	for _, m := range matches {
		total += readInt(m)
	}
	return total
}

// freeVRAM reports the bytes the card has left. Used only to decide whether to
// start: a shortfall here means llama-server would refuse to load anyway. It is
// never used to decide to stop, because by the time free VRAM has dropped the
// other job has already failed its allocation, which is exactly the outcome
// yielding exists to prevent.
func (p probe) freeVRAM() int64 {
	total := readInt(filepath.Join(p.drmDev, "mem_info_vram_total"))
	used := readInt(filepath.Join(p.drmDev, "mem_info_vram_used"))
	if total <= 0 {
		return 0
	}
	if free := total - used; free > 0 {
		return free
	}
	return 0
}

func readInt(path string) int64 {
	b, err := os.ReadFile(path)
	if err != nil {
		return 0
	}
	n, err := strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64)
	if err != nil {
		return 0
	}
	return n
}

// readComm names the contender for the log. The log is the instrument for the
// open question in Vikunja #488: whether a process can want this card without
// ever registering on the KFD, which a Vulkan or video-decode job would.
func readComm(pid int) string {
	b, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "comm"))
	if err != nil {
		return "?"
	}
	return strings.TrimSpace(string(b))
}
