package discover

import (
	"bufio"
	"context"
	"fmt"
	"io"
	"log/slog"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"runtime"
	"strconv"
	"strings"
	"time"

	"github.com/ollama/ollama/llm"
	"github.com/ollama/ollama/logutil"
	"github.com/ollama/ollama/ml"
)

// llamaServerDiscoveryWaitDelay bounds how long Wait can hang after we stop
// the short-lived discovery subprocess.
const llamaServerDiscoveryWaitDelay = 5 * time.Second

// llamaServerDiscoverDevices spawns llama-server briefly (without a model) to
// discover GPU devices and their capabilities. The server prints device info
// and system_info (including compiled CUDA architectures) on startup before
// any model load, then we kill it.
//
// Captured from combined stderr output:
//
//	Device 0: NVIDIA GeForce RTX 4060 Ti, compute capability 8.9, VMM: yes, VRAM: 16379 MiB
//	Device 0: AMD Radeon RX 6700 XT, gfx1031 (0x1031), VMM: no, Wave Size: 32, VRAM: 12272 MiB
//
// Captured from stdout device list:
//
//	CUDA0: NVIDIA GeForce RTX 4060 Ti (16379 MiB, 14900 MiB free)
//	Metal: Apple M3 Max (98304 MiB, 98303 MiB free)

func llamaServerDiscoverDevices(ctx context.Context, libDirs []string, extraEnvs map[string]string) ([]ml.DeviceInfo, *llm.StatusWriter, error) {
	status := llm.NewStatusWriter(llamaServerDiscoveryOutput(ctx))
	llamaServer, err := llm.FindLlamaServer()
	if err != nil {
		slog.Debug("llama-server not available for device discovery", "error", err)
		return nil, status, err
	}

	start := time.Now()
	defer func() {
		slog.Debug("llama-server device discovery took", "duration", time.Since(start), "libDirs", libDirs)
	}()

	// Use a random port to avoid conflicts. The server may start listening
	// before it emits system_info, but we stop it as soon as we have the GPU
	// discovery output we need.
	port := 49152 + time.Now().UnixNano()%16383
	cmd := exec.CommandContext(ctx, llamaServer,
		"--port", strconv.FormatInt(port, 10),
		"--host", "127.0.0.1",
		"--no-webui",
		"--offline",
		"--verbose",
	)
	cmd.WaitDelay = llamaServerDiscoveryWaitDelay
	cmd.Env = os.Environ()

	llm.SetupLlamaServerCommandEnv(cmd, llamaServer, libDirs, extraEnvs)

	logutil.Trace("running llama-server for discovery", "cmd", cmd.Path, "libDirs", libDirs)

	// Capture stderr (device info + system_info) via pipe so we can
	// read it line-by-line and kill the server as soon as we have what we need.
	stderrPipe, err := cmd.StderrPipe()
	if err != nil {
		slog.Debug("llama-server discovery: failed to create stderr pipe", "error", err)
		return nil, status, err
	}
	// Forward stdout through the same status writer so trace logging captures
	// all llama-server discovery output.
	cmd.Stdout = status

	if err := cmd.Start(); err != nil {
		slog.Debug("llama-server discovery: failed to start", "error", err)
		return nil, status, err
	}

	// Read stderr until we see system_info or timeout
	var stderrLines []string
	gotSystemInfo := false
	done := make(chan struct{})
	go func() {
		scanner := bufio.NewScanner(stderrPipe)
		for scanner.Scan() {
			line := scanner.Text()
			_, _ = status.Write([]byte(line + "\n"))
			stderrLines = append(stderrLines, line)
			if strings.Contains(line, "system_info:") {
				gotSystemInfo = true
				break
			}
		}
		close(done)
	}()

	select {
	case <-done:
	case <-ctx.Done():
	}

	// Kill the server - we have what we need, or timed out.
	stoppedForDiscovery := false
	if cmd.Process != nil {
		stoppedForDiscovery = cmd.Process.Kill() == nil
	}
	waitErr := cmd.Wait()
	if waitErr != nil {
		exit := llm.ExitStatusFromError(waitErr)
		if stoppedForDiscovery {
			slog.Debug("llama-server discovery: stopped subprocess after collecting GPU info", "exit", exit, "libDirs", libDirs)
		}
		if !stoppedForDiscovery {
			slog.Debug("llama-server discovery: server startup exited", "error", waitErr, "exit", exit, "libDirs", libDirs)
		}
	}
	<-done

	if ctx.Err() != nil {
		slog.Warn("llama-server discovery: timed out waiting for server startup", "error", ctx.Err(), "libDirs", libDirs, "lines_captured", len(stderrLines))
		return nil, status, ctx.Err()
	}

	if !gotSystemInfo {
		slog.Warn("llama-server discovery: system_info line not found in output - "+
			"CUDA architecture filtering will be disabled. If GPU inference fails, "+
			"this may indicate an incompatible llama-server version.",
			"libDirs", libDirs, "lines_captured", len(stderrLines))
	}

	// Also run --list-devices to get the stdout device list with free memory
	// (the brief server startup doesn't print that)
	cmd2 := exec.CommandContext(ctx, llamaServer, "--list-devices", "--offline", "--verbose")
	cmd2.WaitDelay = llamaServerDiscoveryWaitDelay
	cmd2.Env = cmd.Env // reuse same environment
	listOutput, err := cmd2.CombinedOutput()
	_, _ = status.Write(listOutput)
	if err != nil {
		exit := llm.ExitStatusFromError(err)
		slog.Debug("llama-server --list-devices failed", "error", err, "exit", exit)
		if exit.Known() {
			return nil, status, fmt.Errorf("llama-server --list-devices failed: %s", exit)
		}
		return nil, status, fmt.Errorf("llama-server --list-devices failed: %w", err)
	}

	nativeDevices, nativeStderr, nativeErr := discoverNativeDevices(ctx, llamaServer, libDirs, extraEnvs)
	_, _ = status.Write([]byte(nativeStderr))
	if nativeErr != nil {
		logNativeProbeFailure(nativeErr, nativeStderr, libDirs)
	}

	llamaOutput := string(listOutput) + "\n" + strings.Join(stderrLines, "\n")
	return parseLlamaServerDevicesWithNative(llamaOutput, nativeStderr, libDirs, nativeDevices), status, nil
}

func llamaServerDiscoveryOutput(ctx context.Context) io.Writer {
	if slog.Default().Enabled(ctx, logutil.LevelTrace) {
		return os.Stderr
	}
	return io.Discard
}

// deviceLineRegex matches stdout lines like:
//
//	CUDA0: NVIDIA GeForce RTX 4060 Ti (16379 MiB, 14900 MiB free)
//	Metal: Apple M3 Max (98304 MiB, 98303 MiB free)
var deviceLineRegex = regexp.MustCompile(
	`^\s+(.+?):\s+(.+?)\s+\((\d+)\s+MiB,\s+(\d+)\s+MiB\s+free\)`,
)

// cudaCCRegex matches CUDA stderr lines like:
//
//	Device 0: NVIDIA GeForce GTX 1060 6GB, compute capability 6.1, VMM: yes, VRAM: 6063 MiB
var cudaCCRegex = regexp.MustCompile(
	`Device\s+(\d+):.*compute capability\s+(\d+)\.(\d+)`,
)

// cudaArchsRegex matches the CUDA architecture list from system_info like:
//
//	CUDA : ARCHS = 750,800,860,890,900,1000,1030,1100,1200,1210
var cudaArchsRegex = regexp.MustCompile(
	`CUDA\s*:\s*ARCHS\s*=\s*([\d,]+)`,
)

var (
	cudaRuntimeSORegex  = regexp.MustCompile(`^libcudart\.so\.(\d+)(?:\.(\d+))?`)
	cudaRuntimeDLLRegex = regexp.MustCompile(`^cudart64_(\d{2})(\d)\.dll$`)
	cudaRuntimeDirRegex = regexp.MustCompile(`^cuda_v(\d+)$`)
)

// parseLlamaServerDevices parses the combined output of llama-server discovery.
// It extracts device info, ROCm gfx targets, CUDA compute capabilities, and
// CUDA compiled architecture lists.
func parseLlamaServerDevices(output string, libDirs []string) []ml.DeviceInfo {
	return parseLlamaServerDevicesWithNative(output, "", libDirs, nil)
}

func parseLlamaServerDevicesWithNative(output, nativeOutput string, libDirs []string, nativeDevices []nativeProbeDevice) []ml.DeviceInfo {
	combined := output
	if nativeOutput != "" {
		combined += "\n" + nativeOutput
	}
	// Extract per-device metadata from stderr
	gfxByIndex := parseROCmGFXTargets(combined)
	rocmGFXOverride := hsaOverrideGFXTarget()
	// The native probe enumerates Vulkan devices in its own order, which can
	// differ from llama-server's, so its ggml_vulkan uma lines must not key
	// into llama-server's device indexes.
	integratedByIndex := parseVulkanUMA(output)
	ccByIndex := make(map[int]cudaComputeCapability)
	var cudaArchs []string // compiled architectures for this variant
	nativeByIndex := nativeProbeByLibraryIndex(nativeDevices)
	for idx, dev := range nativeByIndex["ROCm"] {
		if rocmGFXOverride != "" {
			gfxByIndex[idx] = rocmGFXOverride
		} else if dev.GFXTarget != "" {
			gfxByIndex[idx] = dev.GFXTarget
		}
	}

	scanner := bufio.NewScanner(strings.NewReader(combined))
	for scanner.Scan() {
		line := scanner.Text()
		if matches := cudaCCRegex.FindStringSubmatch(line); matches != nil {
			idx, _ := strconv.Atoi(matches[1])
			major, _ := strconv.Atoi(matches[2])
			minor, _ := strconv.Atoi(matches[3])
			ccByIndex[idx] = cudaComputeCapability{
				major: major,
				minor: minor,
				arch:  fmt.Sprintf("%d%d0", major, minor),
			}
		}
		if matches := cudaArchsRegex.FindStringSubmatch(line); matches != nil {
			cudaArchs = strings.Split(matches[1], ",")
		}
	}
	if cudaDevices := nativeByIndex["CUDA"]; len(cudaDevices) > 0 {
		for idx, dev := range cudaDevices {
			if dev.ComputeMajor <= 0 {
				continue
			}
			ccByIndex[idx] = cudaComputeCapability{
				major: dev.ComputeMajor,
				minor: dev.ComputeMinor,
				arch:  fmt.Sprintf("%d%d0", dev.ComputeMajor, dev.ComputeMinor),
			}
		}
	}

	// Validate CUDA devices against compiled architectures
	cudaArchSet := make(map[string]bool, len(cudaArchs))
	for _, arch := range cudaArchs {
		cudaArchSet[strings.TrimSpace(arch)] = true
	}
	cudaRuntimeMajor, cudaRuntimeMinor, hasCUDARuntime := cudaRuntimeVersion(libDirs)

	// Parse stdout device lines
	var devices []ml.DeviceInfo
	deviceIndex := 0
	scanner = bufio.NewScanner(strings.NewReader(output))
	for scanner.Scan() {
		matches := deviceLineRegex.FindStringSubmatch(scanner.Text())
		if matches == nil {
			continue
		}

		name := matches[1]
		description := matches[2]
		totalMiB, _ := strconv.ParseUint(matches[3], 10, 64)
		freeMiB, _ := strconv.ParseUint(matches[4], 10, 64)
		library := inferLibrary(name, description)

		// Skip pseudo-devices like BLAS/Accelerate that report zero memory.
		// These are CPU math libraries, not real GPUs — they shouldn't appear
		// as inference compute devices or inflate the scheduler's GPU count.
		if totalMiB == 0 {
			slog.Debug("skipping pseudo-device with zero memory", "name", name, "description", description)
			deviceIndex++
			continue
		}

		// For CUDA devices, check if this variant supports the device's CC
		if library == "CUDA" {
			cc, ok := ccByIndex[deviceIndex]
			if ok && len(cudaArchSet) > 0 {
				if !cudaArchSet[cc.arch] {
					slog.Info("skipping CUDA device — compute capability not in compiled architectures",
						"device", description, "cc", cc.arch, "archs", cudaArchs,
						"libDirs", libDirs)
					deviceIndex++
					continue
				}
			} else if !ok {
				slog.Warn("llama-server discovery: could not determine compute capability for CUDA device — "+
					"architecture filtering disabled for this device. If inference crashes, "+
					"check that the CUDA backend supports this GPU.",
					"device", description, "libDirs", libDirs)
			} else if len(cudaArchSet) == 0 {
				slog.Warn("llama-server discovery: could not determine compiled CUDA architectures — "+
					"architecture filtering disabled. If inference crashes on older GPUs, "+
					"check llama-server system_info output for ARCHS.",
					"device", description, "libDirs", libDirs)
			}
		}

		nativeDevice, hasNativeDevice := nativeByIndex[library][deviceIndex]
		totalBytes := totalMiB * 1024 * 1024
		if hasNativeDevice && !nativeProbeMatchesLlamaServerDevice(library, description, totalBytes, nativeDevice) {
			hasNativeDevice = false
		}
		computeMajor, computeMinor := computeVersion(library, deviceIndex, gfxByIndex, ccByIndex)
		dev := ml.DeviceInfo{
			DeviceID: ml.DeviceID{
				ID:      strconv.Itoa(deviceIndex),
				Library: library,
			},
			Name:         name,
			Description:  description,
			TotalMemory:  totalBytes,
			FreeMemory:   freeMiB * 1024 * 1024,
			ComputeMajor: computeMajor,
			ComputeMinor: computeMinor,
			LibraryPath:  libDirs,
			GFXTarget:    gfxByIndex[deviceIndex],
			Integrated:   isIntegratedLlamaServerDevice(library, deviceIndex, integratedByIndex),
		}
		if hasNativeDevice {
			if nativeDevice.DeviceID != "" {
				dev.PCIID = nativeDevice.DeviceID
			}
			if nativeDevice.IntegratedKnown {
				dev.Integrated = nativeDevice.Integrated
			} else {
				dev.Integrated = dev.Integrated || nativeDevice.Integrated
			}
			if dev.ComputeMajor == 0 && nativeDevice.ComputeMajor > 0 {
				dev.ComputeMajor = nativeDevice.ComputeMajor
				dev.ComputeMinor = nativeDevice.ComputeMinor
			}
			if nativeDevice.CUDADriverMajor > 0 {
				dev.DriverMajor = nativeDevice.CUDADriverMajor
				dev.DriverMinor = nativeDevice.CUDADriverMinor
			}
			if nativeDevice.NVIDIADriverMajor > 0 {
				dev.NVIDIADriverMajor = nativeDevice.NVIDIADriverMajor
			}
			setROCmGFXTarget(&dev, nativeDevice.GFXTarget)
		}
		setROCmGFXTarget(&dev, rocmGFXOverride)
		if library == "CUDA" && dev.DriverMajor == 0 && hasCUDARuntime {
			dev.DriverMajor = cudaRuntimeMajor
			dev.DriverMinor = cudaRuntimeMinor
		}

		devices = append(devices, dev)
		deviceIndex++
	}

	return refineLlamaServerDevices(devices, libDirs)
}

func nativeProbeMatchesLlamaServerDevice(library, description string, totalBytes uint64, nativeDevice nativeProbeDevice) bool {
	if library != "Vulkan" {
		return true
	}

	nativeDescription := nativeDevice.Description
	if nativeDescription == "" {
		nativeDescription = nativeDevice.Name
	}
	if nativeDescription == "" || !ml.SimilarDeviceDescription(description, nativeDescription) {
		slog.Debug("skipping Vulkan native metadata with mismatched device name",
			"llama_server_name", description,
			"native_name", nativeDescription)
		return false
	}
	if nativeDevice.TotalMemory != 0 && !ml.SimilarDeviceMemory(totalBytes, nativeDevice.TotalMemory) {
		slog.Debug("skipping Vulkan native metadata with mismatched memory",
			"llama_server_name", description,
			"llama_server_total", totalBytes,
			"native_total", nativeDevice.TotalMemory)
		return false
	}

	return true
}

func cudaRuntimeVersion(libDirs []string) (int, int, bool) {
	bestMajor, bestMinor := -1, -1
	update := func(major, minor int) {
		if major > bestMajor || (major == bestMajor && minor > bestMinor) {
			bestMajor, bestMinor = major, minor
		}
	}

	for _, dir := range libDirs {
		for _, entry := range readDirNames(dir) {
			if matches := cudaRuntimeSORegex.FindStringSubmatch(entry); matches != nil {
				major, _ := strconv.Atoi(matches[1])
				minor := 0
				if matches[2] != "" {
					minor, _ = strconv.Atoi(matches[2])
				}
				update(major, minor)
			}
			if matches := cudaRuntimeDLLRegex.FindStringSubmatch(entry); matches != nil {
				major, _ := strconv.Atoi(matches[1])
				minor, _ := strconv.Atoi(matches[2])
				update(major, minor)
			}
		}

		if matches := cudaRuntimeDirRegex.FindStringSubmatch(filepath.Base(dir)); matches != nil {
			major, _ := strconv.Atoi(matches[1])
			update(major, 0)
		}
	}

	if bestMajor < 0 {
		return 0, 0, false
	}
	return bestMajor, bestMinor, true
}

func readDirNames(dir string) []string {
	entries, err := os.ReadDir(dir)
	if err != nil {
		return nil
	}
	names := make([]string, 0, len(entries))
	for _, entry := range entries {
		names = append(names, entry.Name())
	}
	return names
}

type cudaComputeCapability struct {
	major int
	minor int
	arch  string
}

func computeVersion(library string, deviceIndex int, gfxByIndex map[int]string, ccByIndex map[int]cudaComputeCapability) (int, int) {
	switch library {
	case "CUDA":
		if cc, ok := ccByIndex[deviceIndex]; ok {
			return cc.major, cc.minor
		}
	case "ROCm":
		return parseGFXTarget(gfxByIndex[deviceIndex])
	}
	return 0, 0
}

// inferLibrary determines the GPU library type from the llama-server device name and description.
func inferLibrary(name, description string) string {
	combined := strings.ToLower(name + " " + description)
	switch {
	case strings.Contains(combined, "cuda"):
		return "CUDA"
	case strings.Contains(combined, "rocm") || strings.Contains(combined, "hip"):
		return "ROCm"
	case strings.Contains(combined, "metal") || strings.Contains(combined, "apple"):
		return "Metal"
	case strings.Contains(combined, "vulkan"):
		return "Vulkan"
	default:
		return description
	}
}

func isIntegratedLlamaServerDevice(library string, deviceIndex int, integratedByIndex map[int]bool) bool {
	if library == "Vulkan" && integratedByIndex[deviceIndex] {
		return true
	}

	// llama-server discovery does not expose a stable backend device-type field,
	// so we only infer "integrated" here for cases where the contract is strong:
	// explicit Vulkan UMA metadata, or the single Apple Silicon Metal device.
	//
	// Other backends stay unclassified unless discovery provides a stronger
	// signal. That keeps scheduling conservative instead of guessing from
	// device names or backend-specific heuristics.
	return library == "Metal" && runtime.GOOS == "darwin" && runtime.GOARCH == "arm64"
}

func llamaServerBootstrapDevicesWithStatus(ctx context.Context, ollamaLibDirs []string, extraEnvs map[string]string) ([]ml.DeviceInfo, *llm.StatusWriter, error) {
	devices, status, err := llamaServerDiscoverDevices(ctx, ollamaLibDirs, extraEnvs)
	if err != nil {
		return devices, status, err
	}

	hasROCm := false
	for _, d := range devices {
		if d.Library == "ROCm" {
			hasROCm = true
			break
		}
	}
	if !hasROCm {
		return devices, status, nil
	}

	return filterUnsupportedROCmDevices(devices, ollamaLibDirs), status, nil
}

// Ensure stderrPipe is fully consumed to avoid blocking
var _ io.Reader
