package webui

import (
	"bufio"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"strings"
	"sync/atomic"
	"syscall"
	"time"

	"bee/audit/internal/app"
	"bee/audit/internal/platform"
)

var ansiEscapeRE = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b[()][A-Z0-9]|\x1b[DABC]`)

// ── Job ID counter ────────────────────────────────────────────────────────────

var jobCounter atomic.Uint64

func newJobID(prefix string) string {
	return fmt.Sprintf("%s-%d", prefix, jobCounter.Add(1))
}

// ── SSE helpers ───────────────────────────────────────────────────────────────

func sseWrite(w http.ResponseWriter, event, data string) bool {
	f, ok := w.(http.Flusher)
	if !ok {
		return false
	}
	if event != "" {
		fmt.Fprintf(w, "event: %s\n", event)
	}
	fmt.Fprintf(w, "data: %s\n\n", data)
	f.Flush()
	return true
}

func sseStart(w http.ResponseWriter) bool {
	_, ok := w.(http.Flusher)
	if !ok {
		http.Error(w, "streaming not supported", http.StatusInternalServerError)
		return false
	}
	w.Header().Set("Content-Type", "text/event-stream")
	w.Header().Set("Cache-Control", "no-cache")
	w.Header().Set("Connection", "keep-alive")
	w.Header().Set("Access-Control-Allow-Origin", "*")
	return true
}

// streamJob streams lines from a jobState to a SSE response.
func streamJob(w http.ResponseWriter, r *http.Request, j *jobState) {
	if !sseStart(w) {
		return
	}
	existing, ch := j.subscribe()
	for _, line := range existing {
		sseWrite(w, "", line)
	}
	if ch == nil {
		// Job already finished
		sseWrite(w, "done", j.err)
		return
	}
	for {
		select {
		case line, ok := <-ch:
			if !ok {
				sseWrite(w, "done", j.err)
				return
			}
			sseWrite(w, "", line)
		case <-r.Context().Done():
			return
		}
	}
}

// streamCmdJob runs an exec.Cmd and streams stdout+stderr lines into j.
func streamCmdJob(j *jobState, cmd *exec.Cmd) error {
	pr, pw := io.Pipe()
	cmd.Stdout = pw
	cmd.Stderr = pw

	if err := cmd.Start(); err != nil {
		_ = pw.Close()
		_ = pr.Close()
		return err
	}
	// Lower the CPU scheduling priority of stress/audit subprocesses to nice+10
	// so the X server and kernel interrupt handling remain responsive under load
	// (prevents KVM/IPMI graphical console from freezing during GPU stress tests).
	if cmd.Process != nil {
		_ = syscall.Setpriority(syscall.PRIO_PROCESS, cmd.Process.Pid, 10)
	}

	scanDone := make(chan error, 1)
	go func() {
		scanner := bufio.NewScanner(pr)
		scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
		for scanner.Scan() {
			// Split on \r to handle progress-bar style output (e.g. \r overwrites)
			// and strip ANSI escape codes so logs are readable in the browser.
			parts := strings.Split(scanner.Text(), "\r")
			for _, part := range parts {
				line := ansiEscapeRE.ReplaceAllString(part, "")
				if line != "" {
					j.append(line)
				}
			}
		}
		if err := scanner.Err(); err != nil && !errors.Is(err, io.ErrClosedPipe) {
			scanDone <- err
			return
		}
		scanDone <- nil
	}()

	err := cmd.Wait()
	_ = pw.Close()
	scanErr := <-scanDone
	_ = pr.Close()
	if err != nil {
		return err
	}
	return scanErr
}

// ── Audit ─────────────────────────────────────────────────────────────────────

func (h *handler) handleAPIAuditRun(w http.ResponseWriter, _ *http.Request) {
	if h.opts.App == nil {
		writeError(w, http.StatusServiceUnavailable, "app not configured")
		return
	}
	t := &Task{
		ID:        newJobID("audit"),
		Name:      "Audit",
		Target:    "audit",
		Status:    TaskPending,
		CreatedAt: time.Now(),
	}
	globalQueue.enqueue(t)
	writeJSON(w, map[string]string{"task_id": t.ID, "job_id": t.ID})
}

func (h *handler) handleAPIAuditStream(w http.ResponseWriter, r *http.Request) {
	id := r.URL.Query().Get("job_id")
	if id == "" {
		id = r.URL.Query().Get("task_id")
	}
	// Try task queue first, then legacy job manager
	if j, ok := globalQueue.findJob(id); ok {
		streamJob(w, r, j)
		return
	}
	if j, ok := globalJobs.get(id); ok {
		streamJob(w, r, j)
		return
	}
	http.Error(w, "job not found", http.StatusNotFound)
}

// ── SAT ───────────────────────────────────────────────────────────────────────

func (h *handler) handleAPISATRun(target string) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		if h.opts.App == nil {
			writeError(w, http.StatusServiceUnavailable, "app not configured")
			return
		}

		var body struct {
			Duration           int      `json:"duration"`
			DiagLevel          int      `json:"diag_level"`
			GPUIndices         []int    `json:"gpu_indices"`
			ExcludeGPUIndices  []int    `json:"exclude_gpu_indices"`
			Loader             string   `json:"loader"`
			Profile            string   `json:"profile"`
			DisplayName        string   `json:"display_name"`
			PlatformComponents []string `json:"platform_components"`
		}
		if r.Body != nil {
			if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
				writeError(w, http.StatusBadRequest, "invalid request body")
				return
			}
		}

		name := taskDisplayName(target, body.Profile, body.Loader)
		t := &Task{
			ID:        newJobID("sat-" + target),
			Name:      name,
			Target:    target,
			Status:    TaskPending,
			CreatedAt: time.Now(),
			params: taskParams{
				Duration:           body.Duration,
				DiagLevel:          body.DiagLevel,
				GPUIndices:         body.GPUIndices,
				ExcludeGPUIndices:  body.ExcludeGPUIndices,
				Loader:             body.Loader,
				BurnProfile:        body.Profile,
				DisplayName:        body.DisplayName,
				PlatformComponents: body.PlatformComponents,
			},
		}
		if strings.TrimSpace(body.DisplayName) != "" {
			t.Name = body.DisplayName
		}
		globalQueue.enqueue(t)
		writeJSON(w, map[string]string{"task_id": t.ID, "job_id": t.ID})
	}
}

func (h *handler) handleAPISATStream(w http.ResponseWriter, r *http.Request) {
	id := r.URL.Query().Get("job_id")
	if id == "" {
		id = r.URL.Query().Get("task_id")
	}
	if j, ok := globalQueue.findJob(id); ok {
		streamJob(w, r, j)
		return
	}
	if j, ok := globalJobs.get(id); ok {
		streamJob(w, r, j)
		return
	}
	http.Error(w, "job not found", http.StatusNotFound)
}

func (h *handler) handleAPISATAbort(w http.ResponseWriter, r *http.Request) {
	id := r.URL.Query().Get("job_id")
	if id == "" {
		id = r.URL.Query().Get("task_id")
	}
	if t, ok := globalQueue.findByID(id); ok {
		globalQueue.mu.Lock()
		switch t.Status {
		case TaskPending:
			t.Status = TaskCancelled
			now := time.Now()
			t.DoneAt = &now
		case TaskRunning:
			if t.job != nil {
				t.job.abort()
			}
			t.Status = TaskCancelled
			now := time.Now()
			t.DoneAt = &now
		}
		globalQueue.mu.Unlock()
		writeJSON(w, map[string]string{"status": "aborted"})
		return
	}
	if j, ok := globalJobs.get(id); ok {
		if j.abort() {
			writeJSON(w, map[string]string{"status": "aborted"})
		} else {
			writeJSON(w, map[string]string{"status": "not_running"})
		}
		return
	}
	http.Error(w, "job not found", http.StatusNotFound)
}

// ── Services ──────────────────────────────────────────────────────────────────

func (h *handler) handleAPIServicesList(w http.ResponseWriter, r *http.Request) {
	if h.opts.App == nil {
		writeError(w, http.StatusServiceUnavailable, "app not configured")
		return
	}
	names, err := h.opts.App.ListBeeServices()
	if err != nil {
		writeError(w, http.StatusInternalServerError, err.Error())
		return
	}
	type serviceInfo struct {
		Name  string `json:"name"`
		State string `json:"state"`
		Body  string `json:"body"`
	}
	result := make([]serviceInfo, 0, len(names))
	for _, name := range names {
		state := h.opts.App.ServiceState(name)
		body, _ := h.opts.App.ServiceStatus(name)
		result = append(result, serviceInfo{Name: name, State: state, Body: body})
	}
	writeJSON(w, result)
}

func (h *handler) handleAPIServicesAction(w http.ResponseWriter, r *http.Request) {
	if h.opts.App == nil {
		writeError(w, http.StatusServiceUnavailable, "app not configured")
		return
	}
	var req struct {
		Name   string `json:"name"`
		Action string `json:"action"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "invalid request body")
		return
	}
	var action platform.ServiceAction
	switch req.Action {
	case "start":
		action = platform.ServiceStart
	case "stop":
		action = platform.ServiceStop
	case "restart":
		action = platform.ServiceRestart
	default:
		writeError(w, http.StatusBadRequest, "action must be start|stop|restart")
		return
	}
	result, err := h.opts.App.ServiceActionResult(req.Name, action)
	if err != nil {
		writeError(w, http.StatusInternalServerError, err.Error())
		return
	}
	writeJSON(w, map[string]string{"status": "ok", "output": result.Body})
}

// ── Network ───────────────────────────────────────────────────────────────────

func (h *handler) handleAPINetworkStatus(w http.ResponseWriter, r *http.Request) {
	if h.opts.App == nil {
		writeError(w, http.StatusServiceUnavailable, "app not configured")
		return
	}
	ifaces, err := h.opts.App.ListInterfaces()
	if err != nil {
		writeError(w, http.StatusInternalServerError, err.Error())
		return
	}
	writeJSON(w, map[string]any{
		"interfaces":     ifaces,
		"default_route":  h.opts.App.DefaultRoute(),
		"pending_change": h.hasPendingNetworkChange(),
		"rollback_in":    h.pendingNetworkRollbackIn(),
	})
}

func (h *handler) handleAPINetworkDHCP(w http.ResponseWriter, r *http.Request) {
	if h.opts.App == nil {
		writeError(w, http.StatusServiceUnavailable, "app not configured")
		return
	}
	var req struct {
		Interface string `json:"interface"`
	}
	_ = json.NewDecoder(r.Body).Decode(&req)

	result, err := h.applyPendingNetworkChange(func() (app.ActionResult, error) {
		if req.Interface == "" || req.Interface == "all" {
			return h.opts.App.DHCPAllResult()
		}
		return h.opts.App.DHCPOneResult(req.Interface)
	})
	if err != nil {
		writeError(w, http.StatusInternalServerError, err.Error())
		return
	}
	writeJSON(w, map[string]any{
		"status":      "ok",
		"output":      result.Body,
		"rollback_in": int(netRollbackTimeout.Seconds()),
	})
}

func (h *handler) handleAPINetworkStatic(w http.ResponseWriter, r *http.Request) {
	if h.opts.App == nil {
		writeError(w, http.StatusServiceUnavailable, "app not configured")
		return
	}
	var req struct {
		Interface string   `json:"interface"`
		Address   string   `json:"address"`
		Prefix    string   `json:"prefix"`
		Gateway   string   `json:"gateway"`
		DNS       []string `json:"dns"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "invalid request body")
		return
	}
	cfg := platform.StaticIPv4Config{
		Interface: req.Interface,
		Address:   req.Address,
		Prefix:    req.Prefix,
		Gateway:   req.Gateway,
		DNS:       req.DNS,
	}
	result, err := h.applyPendingNetworkChange(func() (app.ActionResult, error) {
		return h.opts.App.SetStaticIPv4Result(cfg)
	})
	if err != nil {
		writeError(w, http.StatusInternalServerError, err.Error())
		return
	}
	writeJSON(w, map[string]any{
		"status":      "ok",
		"output":      result.Body,
		"rollback_in": int(netRollbackTimeout.Seconds()),
	})
}

// ── Export ────────────────────────────────────────────────────────────────────

func (h *handler) handleAPIExportList(w http.ResponseWriter, r *http.Request) {
	entries, err := listExportFiles(h.opts.ExportDir)
	if err != nil {
		writeError(w, http.StatusInternalServerError, err.Error())
		return
	}
	writeJSON(w, entries)
}

func (h *handler) handleAPIExportBundle(w http.ResponseWriter, r *http.Request) {
	if globalQueue.hasActiveTarget("support-bundle") {
		writeError(w, http.StatusConflict, "support bundle task is already pending or running")
		return
	}
	t := &Task{
		ID:        newJobID("support-bundle"),
		Name:      "Support Bundle",
		Target:    "support-bundle",
		Status:    TaskPending,
		CreatedAt: time.Now(),
	}
	globalQueue.enqueue(t)
	writeJSON(w, map[string]string{
		"status":  "queued",
		"task_id": t.ID,
		"job_id":  t.ID,
		"url":     "/export/support.tar.gz",
	})
}

func (h *handler) handleAPIExportUSBTargets(w http.ResponseWriter, _ *http.Request) {
	if h.opts.App == nil {
		writeError(w, http.StatusServiceUnavailable, "app not configured")
		return
	}
	targets, err := h.opts.App.ListRemovableTargets()
	if err != nil {
		writeError(w, http.StatusInternalServerError, err.Error())
		return
	}
	if targets == nil {
		targets = []platform.RemovableTarget{}
	}
	writeJSON(w, targets)
}

func (h *handler) handleAPIExportUSBAudit(w http.ResponseWriter, r *http.Request) {
	if h.opts.App == nil {
		writeError(w, http.StatusServiceUnavailable, "app not configured")
		return
	}
	var target platform.RemovableTarget
	if err := json.NewDecoder(r.Body).Decode(&target); err != nil || target.Device == "" {
		writeError(w, http.StatusBadRequest, "device is required")
		return
	}
	result, err := h.opts.App.ExportLatestAuditResult(target)
	if err != nil {
		writeError(w, http.StatusInternalServerError, err.Error())
		return
	}
	writeJSON(w, map[string]string{"status": "ok", "message": result.Body})
}

func (h *handler) handleAPIExportUSBBundle(w http.ResponseWriter, r *http.Request) {
	if h.opts.App == nil {
		writeError(w, http.StatusServiceUnavailable, "app not configured")
		return
	}
	var target platform.RemovableTarget
	if err := json.NewDecoder(r.Body).Decode(&target); err != nil || target.Device == "" {
		writeError(w, http.StatusBadRequest, "device is required")
		return
	}
	result, err := h.opts.App.ExportSupportBundleResult(target)
	if err != nil {
		writeError(w, http.StatusInternalServerError, err.Error())
		return
	}
	writeJSON(w, map[string]string{"status": "ok", "message": result.Body})
}

// ── GPU presence ──────────────────────────────────────────────────────────────

func (h *handler) handleAPIGPUPresence(w http.ResponseWriter, r *http.Request) {
	if h.opts.App == nil {
		writeError(w, http.StatusServiceUnavailable, "app not configured")
		return
	}
	gp := h.opts.App.DetectGPUPresence()
	w.Header().Set("Content-Type", "application/json")
	_ = json.NewEncoder(w).Encode(map[string]bool{
		"nvidia": gp.Nvidia,
		"amd":    gp.AMD,
	})
}

// ── GPU tools ─────────────────────────────────────────────────────────────────

func (h *handler) handleAPIGPUTools(w http.ResponseWriter, _ *http.Request) {
	type toolEntry struct {
		ID        string `json:"id"`
		Available bool   `json:"available"`
		Vendor    string `json:"vendor"` // "nvidia" | "amd"
	}
	_, nvidiaErr := os.Stat("/dev/nvidia0")
	_, amdErr := os.Stat("/dev/kfd")
	nvidiaUp := nvidiaErr == nil
	amdUp := amdErr == nil
	writeJSON(w, []toolEntry{
		{ID: "bee-gpu-burn", Available: nvidiaUp, Vendor: "nvidia"},
		{ID: "john", Available: nvidiaUp, Vendor: "nvidia"},
		{ID: "nccl", Available: nvidiaUp, Vendor: "nvidia"},
		{ID: "rvs", Available: amdUp, Vendor: "amd"},
	})
}

// ── System ────────────────────────────────────────────────────────────────────

func (h *handler) handleAPIRAMStatus(w http.ResponseWriter, r *http.Request) {
	if h.opts.App == nil {
		writeError(w, http.StatusServiceUnavailable, "app not configured")
		return
	}
	inRAM := h.opts.App.IsLiveMediaInRAM()
	w.Header().Set("Content-Type", "application/json")
	_ = json.NewEncoder(w).Encode(map[string]bool{"in_ram": inRAM})
}

func (h *handler) handleAPIInstallToRAM(w http.ResponseWriter, r *http.Request) {
	if h.opts.App == nil {
		writeError(w, http.StatusServiceUnavailable, "app not configured")
		return
	}
	if globalQueue.hasActiveTarget("install") {
		writeError(w, http.StatusConflict, "install to disk is already running")
		return
	}
	t := &Task{
		ID:        newJobID("install-to-ram"),
		Name:      "Install to RAM",
		Target:    "install-to-ram",
		Priority:  10,
		Status:    TaskPending,
		CreatedAt: time.Now(),
	}
	globalQueue.enqueue(t)
	w.Header().Set("Content-Type", "application/json")
	_ = json.NewEncoder(w).Encode(map[string]string{"task_id": t.ID})
}

// ── Tools ─────────────────────────────────────────────────────────────────────

var standardTools = []string{
	"dmidecode", "smartctl", "nvme", "lspci", "ipmitool",
	"nvidia-smi", "memtester", "stress-ng", "nvtop",
	"mstflint", "qrencode",
}

func (h *handler) handleAPIToolsCheck(w http.ResponseWriter, r *http.Request) {
	if h.opts.App == nil {
		writeError(w, http.StatusServiceUnavailable, "app not configured")
		return
	}
	statuses := h.opts.App.CheckTools(standardTools)
	writeJSON(w, statuses)
}

// ── Preflight ─────────────────────────────────────────────────────────────────

func (h *handler) handleAPIPreflight(w http.ResponseWriter, r *http.Request) {
	data, err := loadSnapshot(filepath.Join(h.opts.ExportDir, "runtime-health.json"))
	if err != nil {
		writeError(w, http.StatusNotFound, "runtime health not found")
		return
	}
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.Header().Set("Cache-Control", "no-store")
	_, _ = w.Write(data)
}

// ── Install ───────────────────────────────────────────────────────────────────

func (h *handler) handleAPIInstallDisks(w http.ResponseWriter, r *http.Request) {
	if h.opts.App == nil {
		writeError(w, http.StatusServiceUnavailable, "app not configured")
		return
	}
	disks, err := h.opts.App.ListInstallDisks()
	if err != nil {
		writeError(w, http.StatusInternalServerError, err.Error())
		return
	}
	type diskJSON struct {
		Device       string   `json:"device"`
		Model        string   `json:"model"`
		Size         string   `json:"size"`
		SizeBytes    int64    `json:"size_bytes"`
		MountedParts []string `json:"mounted_parts"`
		Warnings     []string `json:"warnings"`
	}
	result := make([]diskJSON, 0, len(disks))
	for _, d := range disks {
		result = append(result, diskJSON{
			Device:       d.Device,
			Model:        d.Model,
			Size:         d.Size,
			SizeBytes:    d.SizeBytes,
			MountedParts: d.MountedParts,
			Warnings:     platform.DiskWarnings(d),
		})
	}
	writeJSON(w, result)
}

func (h *handler) handleAPIInstallRun(w http.ResponseWriter, r *http.Request) {
	if h.opts.App == nil {
		writeError(w, http.StatusServiceUnavailable, "app not configured")
		return
	}
	var req struct {
		Device string `json:"device"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Device == "" {
		writeError(w, http.StatusBadRequest, "device is required")
		return
	}

	// Whitelist: only allow devices that ListInstallDisks() returns.
	disks, err := h.opts.App.ListInstallDisks()
	if err != nil {
		writeError(w, http.StatusInternalServerError, err.Error())
		return
	}
	allowed := false
	for _, d := range disks {
		if d.Device == req.Device {
			allowed = true
			break
		}
	}
	if !allowed {
		writeError(w, http.StatusBadRequest, "device not in install candidate list")
		return
	}
	if globalQueue.hasActiveTarget("install-to-ram") {
		writeError(w, http.StatusConflict, "install to RAM task is already pending or running")
		return
	}
	if globalQueue.hasActiveTarget("install") {
		writeError(w, http.StatusConflict, "install task is already pending or running")
		return
	}
	t := &Task{
		ID:        newJobID("install"),
		Name:      "Install to Disk",
		Target:    "install",
		Priority:  20,
		Status:    TaskPending,
		CreatedAt: time.Now(),
		params: taskParams{
			Device: req.Device,
		},
	}
	globalQueue.enqueue(t)
	writeJSON(w, map[string]string{"task_id": t.ID, "job_id": t.ID})
}

// ── Metrics SSE ───────────────────────────────────────────────────────────────

func (h *handler) handleAPIMetricsLatest(w http.ResponseWriter, r *http.Request) {
	sample, ok := h.latestMetric()
	if !ok {
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte("{}"))
		return
	}
	b, err := json.Marshal(sample)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	_, _ = w.Write(b)
}

func (h *handler) handleAPIMetricsStream(w http.ResponseWriter, r *http.Request) {
	if !sseStart(w) {
		return
	}
	ticker := time.NewTicker(1 * time.Second)
	defer ticker.Stop()
	for {
		select {
		case <-r.Context().Done():
			return
		case <-ticker.C:
			sample, ok := h.latestMetric()
			if !ok {
				continue
			}
			b, err := json.Marshal(sample)
			if err != nil {
				continue
			}
			if !sseWrite(w, "metrics", string(b)) {
				return
			}
		}
	}
}

// feedRings pushes one sample into all in-memory ring buffers.
func (h *handler) feedRings(sample platform.LiveMetricSample) {
	for _, t := range sample.Temps {
		switch t.Group {
		case "cpu":
			h.pushNamedMetricRing(&h.cpuTempRings, t.Name, t.Celsius)
		case "ambient":
			h.pushNamedMetricRing(&h.ambientTempRings, t.Name, t.Celsius)
		}
	}
	h.ringPower.push(sample.PowerW)
	h.ringCPULoad.push(sample.CPULoadPct)
	h.ringMemLoad.push(sample.MemLoadPct)

	h.ringsMu.Lock()
	h.pushFanRings(sample.Fans)
	for _, gpu := range sample.GPUs {
		idx := gpu.GPUIndex
		for len(h.gpuRings) <= idx {
			h.gpuRings = append(h.gpuRings, &gpuRings{
				Temp:    newMetricsRing(120),
				Util:    newMetricsRing(120),
				MemUtil: newMetricsRing(120),
				Power:   newMetricsRing(120),
			})
		}
		h.gpuRings[idx].Temp.push(gpu.TempC)
		h.gpuRings[idx].Util.push(gpu.UsagePct)
		h.gpuRings[idx].MemUtil.push(gpu.MemUsagePct)
		h.gpuRings[idx].Power.push(gpu.PowerW)
	}
	h.ringsMu.Unlock()
}

func (h *handler) pushFanRings(fans []platform.FanReading) {
	if len(fans) == 0 && len(h.ringFans) == 0 {
		return
	}
	fanValues := make(map[string]float64, len(fans))
	for _, fan := range fans {
		if fan.Name == "" {
			continue
		}
		fanValues[fan.Name] = fan.RPM
		found := false
		for i, name := range h.fanNames {
			if name == fan.Name {
				found = true
				if i >= len(h.ringFans) {
					h.ringFans = append(h.ringFans, newMetricsRing(120))
				}
				break
			}
		}
		if !found {
			h.fanNames = append(h.fanNames, fan.Name)
			h.ringFans = append(h.ringFans, newMetricsRing(120))
		}
	}
	for i, ring := range h.ringFans {
		if ring == nil {
			continue
		}
		name := ""
		if i < len(h.fanNames) {
			name = h.fanNames[i]
		}
		if rpm, ok := fanValues[name]; ok {
			ring.push(rpm)
			continue
		}
		if last, ok := ring.latest(); ok {
			ring.push(last)
			continue
		}
		ring.push(0)
	}
}

func (h *handler) pushNamedMetricRing(dst *[]*namedMetricsRing, name string, value float64) {
	if name == "" {
		return
	}
	for _, item := range *dst {
		if item != nil && item.Name == name && item.Ring != nil {
			item.Ring.push(value)
			return
		}
	}
	*dst = append(*dst, &namedMetricsRing{
		Name: name,
		Ring: newMetricsRing(120),
	})
	(*dst)[len(*dst)-1].Ring.push(value)
}

// ── Network toggle ────────────────────────────────────────────────────────────

const netRollbackTimeout = 60 * time.Second

func (h *handler) handleAPINetworkToggle(w http.ResponseWriter, r *http.Request) {
	if h.opts.App == nil {
		writeError(w, http.StatusServiceUnavailable, "app not configured")
		return
	}
	var req struct {
		Iface string `json:"iface"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Iface == "" {
		writeError(w, http.StatusBadRequest, "iface is required")
		return
	}

	wasUp, err := h.opts.App.GetInterfaceState(req.Iface)
	if err != nil {
		writeError(w, http.StatusInternalServerError, err.Error())
		return
	}

	if _, err := h.applyPendingNetworkChange(func() (app.ActionResult, error) {
		err := h.opts.App.SetInterfaceState(req.Iface, !wasUp)
		return app.ActionResult{}, err
	}); err != nil {
		writeError(w, http.StatusInternalServerError, err.Error())
		return
	}

	newState := "up"
	if wasUp {
		newState = "down"
	}
	writeJSON(w, map[string]any{
		"iface":       req.Iface,
		"new_state":   newState,
		"rollback_in": int(netRollbackTimeout.Seconds()),
	})
}

func (h *handler) applyPendingNetworkChange(apply func() (app.ActionResult, error)) (app.ActionResult, error) {
	if h.opts.App == nil {
		return app.ActionResult{}, fmt.Errorf("app not configured")
	}

	if err := h.rollbackPendingNetworkChange(); err != nil && err.Error() != "no pending network change" {
		return app.ActionResult{}, err
	}

	snapshot, err := h.opts.App.CaptureNetworkSnapshot()
	if err != nil {
		return app.ActionResult{}, err
	}

	result, err := apply()
	if err != nil {
		return result, err
	}

	pnc := &pendingNetChange{
		snapshot: snapshot,
		deadline: time.Now().Add(netRollbackTimeout),
	}
	pnc.timer = time.AfterFunc(netRollbackTimeout, func() {
		_ = h.opts.App.RestoreNetworkSnapshot(snapshot)
		h.pendingNetMu.Lock()
		if h.pendingNet == pnc {
			h.pendingNet = nil
		}
		h.pendingNetMu.Unlock()
	})

	h.pendingNetMu.Lock()
	h.pendingNet = pnc
	h.pendingNetMu.Unlock()

	return result, nil
}

func (h *handler) hasPendingNetworkChange() bool {
	h.pendingNetMu.Lock()
	defer h.pendingNetMu.Unlock()
	return h.pendingNet != nil
}

func (h *handler) pendingNetworkRollbackIn() int {
	h.pendingNetMu.Lock()
	defer h.pendingNetMu.Unlock()
	if h.pendingNet == nil {
		return 0
	}
	remaining := int(time.Until(h.pendingNet.deadline).Seconds())
	if remaining < 1 {
		return 1
	}
	return remaining
}

func (h *handler) handleAPINetworkConfirm(w http.ResponseWriter, _ *http.Request) {
	h.pendingNetMu.Lock()
	pnc := h.pendingNet
	h.pendingNet = nil
	h.pendingNetMu.Unlock()
	if pnc != nil {
		pnc.mu.Lock()
		pnc.timer.Stop()
		pnc.mu.Unlock()
	}
	writeJSON(w, map[string]string{"status": "confirmed"})
}

func (h *handler) handleAPINetworkRollback(w http.ResponseWriter, _ *http.Request) {
	if err := h.rollbackPendingNetworkChange(); err != nil {
		if err.Error() == "no pending network change" {
			writeError(w, http.StatusConflict, err.Error())
			return
		}
		writeError(w, http.StatusInternalServerError, err.Error())
		return
	}
	writeJSON(w, map[string]string{"status": "rolled back"})
}

func (h *handler) rollbackPendingNetworkChange() error {
	h.pendingNetMu.Lock()
	pnc := h.pendingNet
	h.pendingNet = nil
	h.pendingNetMu.Unlock()
	if pnc == nil {
		return fmt.Errorf("no pending network change")
	}
	pnc.mu.Lock()
	pnc.timer.Stop()
	pnc.mu.Unlock()
	if h.opts.App != nil {
		return h.opts.App.RestoreNetworkSnapshot(pnc.snapshot)
	}
	return nil
}

// ── Display / Screen Resolution ───────────────────────────────────────────────

type displayMode struct {
	Output  string `json:"output"`
	Mode    string `json:"mode"`
	Current bool   `json:"current"`
}

type displayInfo struct {
	Output  string        `json:"output"`
	Modes   []displayMode `json:"modes"`
	Current string        `json:"current"`
}

var xrandrOutputRE = regexp.MustCompile(`^(\S+)\s+connected`)
var xrandrModeRE = regexp.MustCompile(`^\s{3}(\d+x\d+)\s`)
var xrandrCurrentRE = regexp.MustCompile(`\*`)

func parseXrandrOutput(out string) []displayInfo {
	var infos []displayInfo
	var cur *displayInfo
	for _, line := range strings.Split(out, "\n") {
		if m := xrandrOutputRE.FindStringSubmatch(line); m != nil {
			if cur != nil {
				infos = append(infos, *cur)
			}
			cur = &displayInfo{Output: m[1]}
			continue
		}
		if cur == nil {
			continue
		}
		if m := xrandrModeRE.FindStringSubmatch(line); m != nil {
			isCurrent := xrandrCurrentRE.MatchString(line)
			mode := displayMode{Output: cur.Output, Mode: m[1], Current: isCurrent}
			cur.Modes = append(cur.Modes, mode)
			if isCurrent {
				cur.Current = m[1]
			}
		}
	}
	if cur != nil {
		infos = append(infos, *cur)
	}
	return infos
}

func xrandrCommand(args ...string) *exec.Cmd {
	cmd := exec.Command("xrandr", args...)
	env := append([]string{}, os.Environ()...)
	hasDisplay := false
	hasXAuthority := false
	for _, kv := range env {
		if strings.HasPrefix(kv, "DISPLAY=") && strings.TrimPrefix(kv, "DISPLAY=") != "" {
			hasDisplay = true
		}
		if strings.HasPrefix(kv, "XAUTHORITY=") && strings.TrimPrefix(kv, "XAUTHORITY=") != "" {
			hasXAuthority = true
		}
	}
	if !hasDisplay {
		env = append(env, "DISPLAY=:0")
	}
	if !hasXAuthority {
		env = append(env, "XAUTHORITY=/home/bee/.Xauthority")
	}
	cmd.Env = env
	return cmd
}

func (h *handler) handleAPIDisplayResolutions(w http.ResponseWriter, _ *http.Request) {
	out, err := xrandrCommand().Output()
	if err != nil {
		writeError(w, http.StatusInternalServerError, "xrandr: "+err.Error())
		return
	}
	writeJSON(w, parseXrandrOutput(string(out)))
}

func (h *handler) handleAPIDisplaySet(w http.ResponseWriter, r *http.Request) {
	var req struct {
		Output string `json:"output"`
		Mode   string `json:"mode"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Output == "" || req.Mode == "" {
		writeError(w, http.StatusBadRequest, "output and mode are required")
		return
	}
	// Validate mode looks like WxH to prevent injection
	if !regexp.MustCompile(`^\d+x\d+$`).MatchString(req.Mode) {
		writeError(w, http.StatusBadRequest, "invalid mode format")
		return
	}
	// Validate output name (no special chars)
	if !regexp.MustCompile(`^[A-Za-z0-9_\-]+$`).MatchString(req.Output) {
		writeError(w, http.StatusBadRequest, "invalid output name")
		return
	}
	if out, err := xrandrCommand("--output", req.Output, "--mode", req.Mode).CombinedOutput(); err != nil {
		writeError(w, http.StatusInternalServerError, "xrandr: "+strings.TrimSpace(string(out)))
		return
	}
	writeJSON(w, map[string]string{"status": "ok", "output": req.Output, "mode": req.Mode})
}
