package main

// DayZ Steam Workshop mod manager — install/uninstall/list endpoints.
//
// Architecture:
//
//   Controller (this file)                    Agent
//   ──────────────────────                    ─────
//   1. operator clicks Install Mod
//   2. run SteamCMD sidecar directly via
//      docker CLI (controller-side) ──→       shared volume panel-dayz-workshop
//      writing into that shared volume                      ↓
//   3. tell agent to create symlink           handleDayzModInstall
//      + copy bikeys into /game/keys
//      via DayzModInstallRequest envelope
//   4. update /game-saves/panel-mods.txt via
//      existing FsWrite RPC so the entrypoint
//      picks up the mod on next start.
//
// The Steam credential store persists across instances (same panel-wide
// encrypted row the DayZ install uses), so one login ceremony unlocks
// both the server download and every subsequent workshop item.

import (
	"bufio"
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"os"
	"os/exec"
	"regexp"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"

	"google.golang.org/protobuf/types/known/timestamppb"

	panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)

// dayzModListFile is the operator-readable file we write to on each
// install/uninstall/reorder. The DayZ module's entrypoint reads this file
// on every server start and builds the `-mod=@A;@B` argument.
const (
	dayzModListFile       = "/game-saves/panel-mods.txt"
	dayzServerModListFile = "/game-saves/panel-servermods.txt"
	dayzWorkshopAppID     = "221100" // DayZ main game — Workshop items live under this app
)

type dayzMod struct {
	WorkshopID string `json:"workshop_id,omitempty"`
	FolderName string `json:"folder_name"`
	ServerMod  bool   `json:"server_mod,omitempty"`
	Order      int    `json:"order"`
	// Optional metadata resolved from Steam:
	Title   string `json:"title,omitempty"`
	Size    int64  `json:"size,omitempty"`
	Updated int64  `json:"updated,omitempty"` // unix seconds
}

type dayzInstallReq struct {
	WorkshopID string `json:"workshop_id"`           // "1564026768" OR full URL; parsed below
	FolderName string `json:"folder_name,omitempty"` // optional; auto-detected from Workshop metadata
	ServerMod  bool   `json:"server_mod,omitempty"`
}

// handleDayzModsList scans the mod list files + returns the parsed set.
// Runs entirely on the controller side: reads panel-mods.txt + panel-servermods.txt
// via the existing FsRead RPC.
func (h *httpServer) handleDayzModsList(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	agentID, err := h.lookupInstance(r, id)
	if err != nil {
		writeError(w, http.StatusNotFound, "not_found", err.Error())
		return
	}
	conn := h.registry.get(agentID)
	if conn == nil {
		writeError(w, http.StatusNotFound, "agent_offline", fmt.Sprintf("agent %q not connected", agentID))
		return
	}
	mods := []dayzMod{}
	mods = append(mods, h.dayzReadModList(r.Context(), conn, id, dayzModListFile, false)...)
	mods = append(mods, h.dayzReadModList(r.Context(), conn, id, dayzServerModListFile, true)...)
	writeJSON(w, http.StatusOK, map[string]any{"mods": mods})
}

// handleDayzModInstall kicks off an async mod-install job and returns
// {job_id} immediately. The actual download + symlink + bikey copy runs
// in a background goroutine; the frontend polls GET /dayz/mods/jobs to
// render a live progress card. This keeps the browser request short and
// lets operators close the tab without aborting a 2 GB download.
func (h *httpServer) handleDayzModInstall(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	agentID, err := h.lookupInstance(r, id)
	if err != nil {
		writeError(w, http.StatusNotFound, "not_found", err.Error())
		return
	}
	conn := h.registry.get(agentID)
	if conn == nil {
		writeError(w, http.StatusNotFound, "agent_offline", fmt.Sprintf("agent %q not connected", agentID))
		return
	}
	var req dayzInstallReq
	if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "bad_json", err.Error())
		return
	}
	workshopID, perr := parseWorkshopID(req.WorkshopID)
	if perr != nil {
		writeError(w, http.StatusBadRequest, "bad_workshop_id", perr.Error())
		return
	}

	// Look up mod title via Steam's public GetPublishedFileDetails. Used
	// as the default folder name (sanitized to a DayZ-friendly "@Name").
	// Happens synchronously since it's fast (single API call, ~200ms).
	var title string
	var size, updated int64
	if d, derr := steamWorkshopDetail(workshopID); derr == nil && d != nil {
		title, size, updated = d.Title, d.Size, d.TimeUpdated
	}
	folder := req.FolderName
	if folder == "" {
		folder = sanitizeDayzFolder(title)
		if folder == "" {
			folder = "@WS" + workshopID
		}
	}
	if !strings.HasPrefix(folder, "@") {
		folder = "@" + folder
	}

	// Steam creds — fail fast on missing cache so the UI can pop the
	// login modal instead of wasting a job slot.
	user, _, ok := h.getSteamLoginForUpdate(r.Context())
	if !ok {
		writeJSON(w, http.StatusOK, map[string]any{
			"steam_login_required": true,
			"reason":               "DayZ Workshop downloads need a Steam account that owns the base game. Sign in once — we'll cache the token so you won't be prompted again.",
		})
		return
	}

	// Reject duplicate in-flight installs of the same mod to stop the
	// operator from double-clicking into concurrent SteamCMD runs.
	for _, existing := range h.dayzJobs.listForInstance(id) {
		existing.mu.Lock()
		dup := existing.WorkshopID == workshopID &&
			(existing.Phase == phaseQueued || existing.Phase == phaseDownloading ||
				existing.Phase == phaseLinking || existing.Phase == phaseFinalizing)
		existingID := existing.ID
		existing.mu.Unlock()
		if dup {
			writeJSON(w, http.StatusAccepted, map[string]any{
				"accepted": true,
				"job_id":   existingID,
				"duplicate": true,
			})
			return
		}
	}

	job := h.dayzJobs.create(id, workshopID, folder, req.ServerMod)
	job.setResult(title, size, updated)
	job.appendLine(fmt.Sprintf("queued: %s (id=%s) → %s", title, workshopID, folder))

	h.log.Info("dayz mod install queued",
		"job_id", job.ID, "instance_id", id, "workshop_id", workshopID, "folder", folder)

	// Detach: goroutine context lives beyond the HTTP request.
	go h.runDayzModInstallJob(context.Background(), job, agentID, user)

	writeJSON(w, http.StatusAccepted, map[string]any{
		"accepted":    true,
		"job_id":      job.ID,
		"workshop_id": workshopID,
		"folder_name": folder,
		"title":       title,
	})
}

// runDayzModInstallJob is the full install pipeline, run off the HTTP
// request. Uses a background context so browser disconnects don't cancel
// the download. Each phase updates job state for the UI polling loop.
func (h *httpServer) runDayzModInstallJob(ctx context.Context, job *dayzModJob, agentID, user string) {
	defer func() {
		if rec := recover(); rec != nil {
			h.log.Error("dayz mod install goroutine panic", "job_id", job.ID, "recover", rec)
			job.setError(fmt.Sprintf("internal error: %v", rec), false)
		}
	}()

	// ---- phase: download ----
	job.setPhase(phaseDownloading)
	err := runSteamCMDWorkshopDownloadStreaming(ctx, h.log, user, job, jobLineSink(job))
	if err != nil {
		if errors.Is(err, errTokenStale) || errors.Is(err, errTokenMissing) {
			job.setLoginNeeded("Your Steam session expired. Sign in again to refresh the cached token.")
			return
		}
		permanent := errors.Is(err, errWorkshopNotFound) ||
			strings.Contains(err.Error(), "Access Denied") ||
			strings.Contains(err.Error(), "own the DayZ base game")
		friendly := friendlyDownloadError(err)
		job.appendLine("download failed: " + friendly)
		job.setError(friendly, permanent)
		return
	}

	// ---- phase: linking ----
	job.setPhase(phaseLinking)
	job.appendLine("requesting agent: create symlink + copy bikeys")
	conn := h.registry.get(agentID)
	if conn == nil {
		job.setError("agent disconnected during download", false)
		return
	}
	corrID := newCorrelationID("dzmod_install")
	if err := conn.Send(&panelv1.ControllerEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.ControllerEnvelope_DayzModInstall{
			DayzModInstall: &panelv1.DayzModInstallRequest{
				InstanceId: job.InstanceID,
				WorkshopId: job.WorkshopID,
				FolderName: job.FolderName,
			},
		},
	}); err != nil {
		job.setError("forward to agent: "+err.Error(), false)
		return
	}
	awaitCtx, awaitCancel := context.WithTimeout(ctx, 60*time.Second)
	env, err := h.pending.Await(awaitCtx, corrID, 60*time.Second)
	awaitCancel()
	if err != nil {
		job.setError("agent didn't reply: "+err.Error(), false)
		return
	}
	res := env.GetDayzModInstallResult()
	if res == nil {
		job.setError("agent returned empty reply", false)
		return
	}
	if res.Error != "" {
		job.setError("agent: "+res.Error, false)
		return
	}
	job.setAgentResult(res.BikeysCopied, res.ResolvedModPath)
	if len(res.BikeysCopied) > 0 {
		job.appendLine(fmt.Sprintf("copied %d bikey%s: %s",
			len(res.BikeysCopied), plural(len(res.BikeysCopied)),
			strings.Join(res.BikeysCopied, ", ")))
	} else {
		job.appendLine("no bikeys shipped with this mod (common for admin/QoL mods)")
	}

	// ---- phase: finalizing ----
	job.setPhase(phaseFinalizing)
	listPath := dayzModListFile
	if job.ServerMod {
		listPath = dayzServerModListFile
	}
	if err := h.dayzAppendModList(ctx, conn, job.InstanceID, listPath, job.FolderName); err != nil {
		job.setError("append to mod list: "+err.Error(), false)
		return
	}
	job.appendLine(fmt.Sprintf("added %s to %s", job.FolderName, listPath))
	job.setPercent(100)
	job.setPhase(phaseDone)
	h.log.Info("dayz mod install done", "job_id", job.ID, "folder", job.FolderName)
}

func plural(n int) string {
	if n == 1 {
		return ""
	}
	return "s"
}

// friendlyDownloadError collapses SteamCMD's verbose tails into a short
// one-line message the UI can render directly. Keeps the full error in
// the job's log tail for operators who want the raw story.
func friendlyDownloadError(err error) string {
	if err == nil {
		return ""
	}
	switch {
	case errors.Is(err, errWorkshopNotFound):
		return "Workshop item not found — ID is wrong or the mod was delisted."
	case errors.Is(err, errTokenStale):
		return "Steam session expired — sign in again to refresh the cached token."
	case errors.Is(err, errTokenMissing):
		return "No Steam username configured — complete the Steam login first."
	case strings.Contains(err.Error(), "Access Denied"):
		return "Access denied — this mod is restricted for your Steam account (region-lock or private item)."
	case strings.Contains(err.Error(), "own the DayZ base game"):
		return "Your Steam account doesn't own DayZ — use an account that has the game."
	case strings.Contains(err.Error(), "timeout"):
		return "SteamCMD timed out after 4 retries. Try again in a few minutes — Steam CDN is flaky."
	}
	// Fallback: first line only, plus prefix hint.
	msg := err.Error()
	if i := strings.Index(msg, "\n"); i > 0 {
		msg = msg[:i]
	}
	if len(msg) > 200 {
		msg = msg[:200] + "…"
	}
	return msg
}

// jobLineSink returns a callback that pipes steamcmd lines into the job's
// log + parses percent-style progress hints. Kept here (next to the handler)
// so the filtering rules live close to the UI they feed.
func jobLineSink(job *dayzModJob) func(string) {
	return func(line string) {
		line = strings.TrimSpace(line)
		if line == "" {
			return
		}
		// Attempt number hint emitted by the retry loop — promote to state.
		if strings.HasPrefix(line, "__attempt__:") {
			if n, err := strconv.Atoi(strings.TrimPrefix(line, "__attempt__:")); err == nil {
				job.setAttempt(n)
				if n > 1 {
					job.appendLine(fmt.Sprintf("retry attempt #%d — resuming partial download", n))
				}
				return
			}
		}
		// Parse steamcmd progress lines of the form:
		//   " Update state (0x61) downloading, progress: 23.45 (51200000 / 218103808)"
		// Or:
		//   "[ 23%] progress..."
		if pct, ok := parseSteamCMDPercent(line); ok {
			job.setPercent(pct)
		}
		// Skip the boilerplate Valve prints on every run — we already
		// showed them in a prior install, and they're pure noise for
		// the UI log tail.
		if steamCMDBoilerplate(line) {
			return
		}
		job.appendLine(line)
	}
}

var progressRE = regexp.MustCompile(`progress:\s*([0-9.]+)`)
var percentBucketRE = regexp.MustCompile(`^\[\s*([0-9]+)\s*%\s*\]`)

func parseSteamCMDPercent(line string) (float64, bool) {
	if m := progressRE.FindStringSubmatch(line); len(m) > 1 {
		if v, err := strconv.ParseFloat(m[1], 64); err == nil {
			return v, true
		}
	}
	if m := percentBucketRE.FindStringSubmatch(line); len(m) > 1 {
		if v, err := strconv.Atoi(m[1]); err == nil {
			return float64(v), true
		}
	}
	return 0, false
}

func steamCMDBoilerplate(line string) bool {
	switch {
	case strings.HasPrefix(line, "Redirecting stderr"),
		strings.HasPrefix(line, "Logging directory:"),
		strings.HasPrefix(line, "UpdateUI:"),
		strings.HasPrefix(line, "Steam Console Client"),
		strings.HasPrefix(line, "-- type "),
		strings.HasPrefix(line, "Loading Steam API"),
		strings.HasPrefix(line, "IPC function call"),
		strings.HasPrefix(line, "Unloading Steam API"):
		return true
	}
	return false
}

// handleDayzModUninstall removes the symlink + bikeys + mod-list entry.
func (h *httpServer) handleDayzModUninstall(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	agentID, err := h.lookupInstance(r, id)
	if err != nil {
		writeError(w, http.StatusNotFound, "not_found", err.Error())
		return
	}
	conn := h.registry.get(agentID)
	if conn == nil {
		writeError(w, http.StatusNotFound, "agent_offline", fmt.Sprintf("agent %q not connected", agentID))
		return
	}
	folder := r.PathValue("folder")
	if folder == "" {
		writeError(w, http.StatusBadRequest, "bad_folder", "folder path segment is required")
		return
	}
	if !strings.HasPrefix(folder, "@") {
		folder = "@" + folder
	}

	// 1. Remove from BOTH mod list files (operator may have moved it between
	// client-mod / server-mod at some point).
	_ = h.dayzRemoveFromModList(r.Context(), conn, id, dayzModListFile, folder)
	_ = h.dayzRemoveFromModList(r.Context(), conn, id, dayzServerModListFile, folder)

	// 2. Agent-side symlink + bikey removal.
	corrID := newCorrelationID("dzmod_uninstall")
	if err := conn.Send(&panelv1.ControllerEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.ControllerEnvelope_DayzModUninstall{
			DayzModUninstall: &panelv1.DayzModUninstallRequest{
				InstanceId: id,
				FolderName: folder,
			},
		},
	}); err != nil {
		writeError(w, http.StatusBadGateway, "forward", err.Error())
		return
	}
	env, err := h.pending.Await(r.Context(), corrID, 30*time.Second)
	if err != nil {
		writeError(w, http.StatusGatewayTimeout, "timeout", err.Error())
		return
	}
	res := env.GetDayzModUninstallResult()
	if res == nil {
		writeError(w, http.StatusInternalServerError, "bad_reply", "agent returned no uninstall result")
		return
	}
	if res.Error != "" {
		writeError(w, http.StatusBadRequest, "agent", res.Error)
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"ok":             true,
		"folder":         folder,
		"bikeys_removed": res.BikeysRemoved,
	})
}

// handleDayzModJobList returns every tracked install job for an instance,
// newest first. Used by the Mods tab's polling loop to render progress
// cards — includes terminal jobs so the UI can show a recently-finished
// success or error briefly before auto-dismissing.
func (h *httpServer) handleDayzModJobList(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	if _, err := h.lookupInstance(r, id); err != nil {
		writeError(w, http.StatusNotFound, "not_found", err.Error())
		return
	}
	jobs := h.dayzJobs.listForInstance(id)
	snaps := make([]map[string]any, 0, len(jobs))
	for _, j := range jobs {
		snaps = append(snaps, j.snapshot())
	}
	writeJSON(w, http.StatusOK, map[string]any{"jobs": snaps})
}

// handleDayzModJobGet returns one job. Separate endpoint so a retry or
// "show log tail" action can fetch fresh state without re-listing.
func (h *httpServer) handleDayzModJobGet(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	if _, err := h.lookupInstance(r, id); err != nil {
		writeError(w, http.StatusNotFound, "not_found", err.Error())
		return
	}
	job := h.dayzJobs.get(r.PathValue("jobId"))
	if job == nil || job.InstanceID != id {
		writeError(w, http.StatusNotFound, "not_found", "no such job")
		return
	}
	writeJSON(w, http.StatusOK, job.snapshot())
}

// handleDayzModJobDelete drops a job from the manager — used by the UI's
// "✕ Dismiss" button on download cards. Safe on any phase; for in-flight
// jobs the goroutine orphans (its state updates go nowhere but the
// download already on disk persists so a future retry resumes).
func (h *httpServer) handleDayzModJobDelete(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	if _, err := h.lookupInstance(r, id); err != nil {
		writeError(w, http.StatusNotFound, "not_found", err.Error())
		return
	}
	jobID := r.PathValue("jobId")
	job := h.dayzJobs.get(jobID)
	if job == nil || job.InstanceID != id {
		writeError(w, http.StatusNotFound, "not_found", "no such job")
		return
	}
	removed := h.dayzJobs.remove(jobID)
	writeJSON(w, http.StatusOK, map[string]any{"ok": true, "removed": removed})
}

// handleDayzModXmlScan recursively finds every *.xml file inside an installed
// mod's workshop folder and returns metadata (path, size, inferred kind) so
// the UI can offer to auto-merge the snippets into the server's mission tree.
// The XML Mods tab already has the merge engine — this just surfaces what's
// available in the mod itself instead of making the operator find + paste.
func (h *httpServer) handleDayzModXmlScan(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	agentID, err := h.lookupInstance(r, id)
	if err != nil {
		writeError(w, http.StatusNotFound, "not_found", err.Error())
		return
	}
	conn := h.registry.get(agentID)
	if conn == nil {
		writeError(w, http.StatusNotFound, "agent_offline", fmt.Sprintf("agent %q not connected", agentID))
		return
	}
	folder := r.PathValue("folder")
	if !strings.HasPrefix(folder, "@") {
		folder = "@" + folder
	}
	root := "/game/" + folder
	// BFS walk capped at 4 levels deep — mod XML bundles live shallow.
	type entry struct {
		Path string `json:"path"`
		Size int64  `json:"size"`
		Kind string `json:"kind"`
	}
	var files []entry
	queue := []string{root}
	visited := 0
	for len(queue) > 0 && visited < 500 {
		cur := queue[0]
		queue = queue[1:]
		visited++
		entries, ferr := h.fsListAt(r.Context(), conn, id, cur)
		if ferr != nil {
			continue
		}
		for _, e := range entries {
			full := cur + "/" + e.Name
			if e.IsDir {
				// Don't descend into addons/ — those are .pbo bundles, no loose XML.
				if strings.EqualFold(e.Name, "addons") || strings.EqualFold(e.Name, "keys") {
					continue
				}
				// Cap depth by counting slashes in relative path
				rel := strings.TrimPrefix(full, root)
				if strings.Count(rel, "/") < 4 {
					queue = append(queue, full)
				}
				continue
			}
			if !strings.HasSuffix(strings.ToLower(e.Name), ".xml") {
				continue
			}
			rel := strings.TrimPrefix(full, root+"/")
			files = append(files, entry{
				Path: rel,
				Size: e.Size,
				Kind: guessDayzXmlKind(e.Name),
			})
		}
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"folder": folder,
		"root":   root,
		"files":  files,
	})
}

// fsListAt is a small helper that wraps the FsList RPC into a typed return.
// Existing filesList HTTP handler does the same plumbing inline; we want a
// reusable function for the recursive walk.
type fsEntry struct {
	Name  string
	IsDir bool
	Size  int64
}

func (h *httpServer) fsListAt(ctx context.Context, conn *agentConn, instanceID, path string) ([]fsEntry, error) {
	corrID := newCorrelationID("fslist_scan")
	if err := conn.Send(&panelv1.ControllerEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.ControllerEnvelope_FsList{
			FsList: &panelv1.FsListRequest{InstanceId: instanceID, Path: path},
		},
	}); err != nil {
		return nil, err
	}
	env, err := h.pending.Await(ctx, corrID, 10*time.Second)
	if err != nil {
		return nil, err
	}
	res := env.GetFsListResult()
	if res == nil || res.Error != "" {
		if res != nil {
			return nil, errors.New(res.Error)
		}
		return nil, errors.New("no reply")
	}
	out := make([]fsEntry, 0, len(res.Entries))
	for _, e := range res.Entries {
		out = append(out, fsEntry{Name: e.Name, IsDir: e.IsDir, Size: e.Size})
	}
	return out, nil
}

// guessDayzXmlKind infers which Central Economy file a snippet is meant to
// merge into, based on filename. The XML merger does its own root-element
// sniff; this is just a UX hint so we can label the button correctly.
func guessDayzXmlKind(name string) string {
	n := strings.ToLower(name)
	switch {
	case n == "types.xml", strings.HasSuffix(n, "_types.xml"):
		return "types"
	case n == "events.xml", strings.HasSuffix(n, "_events.xml"):
		return "events"
	case n == "cfgspawnabletypes.xml", strings.HasSuffix(n, "_cfgspawnabletypes.xml"), strings.HasSuffix(n, "_spawnabletypes.xml"):
		return "spawnabletypes"
	case n == "cfgeventspawns.xml", strings.HasSuffix(n, "_eventspawns.xml"):
		return "eventspawns"
	case n == "cfgrandompresets.xml", strings.HasSuffix(n, "_randompresets.xml"):
		return "randompresets"
	case n == "messages.xml", strings.HasSuffix(n, "_messages.xml"):
		return "messages"
	}
	return "" // unknown — merger will still try to sniff by root element
}

// handleDayzModsReorder replaces the ordering of client mods + server mods
// wholesale. Request body: {client: ["@A","@B"], server: ["@X"]}
func (h *httpServer) handleDayzModsReorder(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	agentID, err := h.lookupInstance(r, id)
	if err != nil {
		writeError(w, http.StatusNotFound, "not_found", err.Error())
		return
	}
	conn := h.registry.get(agentID)
	if conn == nil {
		writeError(w, http.StatusNotFound, "agent_offline", fmt.Sprintf("agent %q not connected", agentID))
		return
	}
	var req struct {
		Client []string `json:"client"`
		Server []string `json:"server"`
	}
	if err := json.NewDecoder(io.LimitReader(r.Body, 32*1024)).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "bad_json", err.Error())
		return
	}
	if err := h.dayzWriteModList(r.Context(), conn, id, dayzModListFile, normalizeFolders(req.Client)); err != nil {
		writeError(w, http.StatusBadGateway, "write_client", err.Error())
		return
	}
	if err := h.dayzWriteModList(r.Context(), conn, id, dayzServerModListFile, normalizeFolders(req.Server)); err != nil {
		writeError(w, http.StatusBadGateway, "write_server", err.Error())
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}

// ---- helpers ----

var workshopIDRE = regexp.MustCompile(`(?:[?&]id=|/filedetails/\?id=|^)(\d{6,20})`)

// parseWorkshopID accepts a raw workshop id or a full Steam Workshop URL.
func parseWorkshopID(input string) (string, error) {
	input = strings.TrimSpace(input)
	if input == "" {
		return "", errors.New("workshop_id is required")
	}
	if _, err := strconv.ParseInt(input, 10, 64); err == nil {
		return input, nil
	}
	m := workshopIDRE.FindStringSubmatch(input)
	if len(m) > 1 {
		return m[1], nil
	}
	return "", errors.New("couldn't extract a Steam Workshop item id from: " + input)
}

// sanitizeDayzFolder turns a Steam Workshop title into a DayZ-friendly
// @Folder name. Rules matching the wider DayZ community convention:
//   - strip non-alphanumeric (keeps underscore + dash)
//   - collapse multi-spaces
//   - prefix with @
func sanitizeDayzFolder(title string) string {
	if title == "" {
		return ""
	}
	// Replace anything that isn't safe with a dash, then trim dashes.
	var b strings.Builder
	for _, r := range title {
		switch {
		case r >= 'A' && r <= 'Z', r >= 'a' && r <= 'z', r >= '0' && r <= '9':
			b.WriteRune(r)
		case r == '_' || r == '-':
			b.WriteRune(r)
		}
	}
	name := strings.Trim(b.String(), "-_")
	if name == "" {
		return ""
	}
	return "@" + name
}

func normalizeFolders(in []string) []string {
	out := make([]string, 0, len(in))
	seen := map[string]bool{}
	for _, s := range in {
		s = strings.TrimSpace(s)
		if s == "" {
			continue
		}
		if !strings.HasPrefix(s, "@") {
			s = "@" + s
		}
		if strings.ContainsAny(s, "/\\ \t\n") {
			continue
		}
		if seen[s] {
			continue
		}
		seen[s] = true
		out = append(out, s)
	}
	return out
}

// dayzReadModList reads a plain-text mod list file from the instance
// and returns a slice of dayzMod entries keyed by their folder name.
// Missing file is treated as an empty list.
func (h *httpServer) dayzReadModList(ctx context.Context, conn *agentConn, instanceID, path string, serverMod bool) []dayzMod {
	res, err := h.dayzXmlReadFile(ctx, conn, instanceID, path)
	if err != nil || res == nil || res.Error != "" {
		return nil
	}
	out := []dayzMod{}
	order := 0
	scanner := bufio.NewScanner(bytes.NewReader(res.Content))
	for scanner.Scan() {
		line := strings.TrimSpace(scanner.Text())
		if line == "" || strings.HasPrefix(line, "#") {
			continue
		}
		out = append(out, dayzMod{
			FolderName: line,
			Order:      order,
			ServerMod:  serverMod,
		})
		order++
	}
	return out
}

// dayzAppendModList appends a folder name to the mod list file (idempotent).
func (h *httpServer) dayzAppendModList(ctx context.Context, conn *agentConn, instanceID, path, folder string) error {
	existing := h.dayzReadModList(ctx, conn, instanceID, path, false)
	for _, m := range existing {
		if m.FolderName == folder {
			return nil // already present
		}
	}
	folders := make([]string, 0, len(existing)+1)
	for _, m := range existing {
		folders = append(folders, m.FolderName)
	}
	folders = append(folders, folder)
	return h.dayzWriteModList(ctx, conn, instanceID, path, folders)
}

// dayzRemoveFromModList rewrites the list without the target folder.
func (h *httpServer) dayzRemoveFromModList(ctx context.Context, conn *agentConn, instanceID, path, folder string) error {
	existing := h.dayzReadModList(ctx, conn, instanceID, path, false)
	folders := make([]string, 0, len(existing))
	for _, m := range existing {
		if m.FolderName == folder {
			continue
		}
		folders = append(folders, m.FolderName)
	}
	return h.dayzWriteModList(ctx, conn, instanceID, path, folders)
}

// dayzWriteModList replaces the file with the supplied folder list.
func (h *httpServer) dayzWriteModList(ctx context.Context, conn *agentConn, instanceID, path string, folders []string) error {
	var body bytes.Buffer
	body.WriteString("# panel-managed mod list — one @Folder per line\n")
	body.WriteString("# blank lines and '#' comments ignored\n")
	for _, f := range folders {
		body.WriteString(f)
		body.WriteByte('\n')
	}
	return h.dayzXmlWriteFile(ctx, conn, instanceID, path, body.Bytes())
}

// runSteamCMDWorkshopDownload invokes a disposable SteamCMD sidecar to
// download one Workshop item into the shared panel-dayz-workshop volume.
// Wraps runSteamCMDWorkshopDownloadOnce in a retry loop so big mods
// (Expansion, 2+ GB) can resume past SteamCMD's internal depot-download
// timeout — SteamCMD hashes each on-disk chunk and skips what's already
// present, so a second invocation picks up where the first stopped.
//
// This mirrors what AMP's dayz-originalupdates.json relies on implicitly:
// the SteamCMD stage is marked SkipOnFailure=false for workshop pulls,
// and Valve's client itself retries internally via the depot-chunk queue.
// We just surface the retry at the job level so the panel's Install button
// doesn't return "timeout" for mods that would succeed on retry.
func runSteamCMDWorkshopDownload(ctx context.Context, log Logger, user, workshopID string) error {
	if user == "" {
		return errTokenMissing
	}
	const maxAttempts = 4
	var lastErr error
	for attempt := 1; attempt <= maxAttempts; attempt++ {
		if attempt > 1 {
			// Backoff: 10 s, 30 s, 60 s. Gives Steam CDN a breather and
			// keeps long-running HTTP requests within a sane window
			// (max cumulative wait ~100 s of pure backoff).
			backoff := time.Duration(attempt*attempt-1) * 10 * time.Second
			log.Info("steamcmd workshop_download retrying", "workshop_id", workshopID,
				"attempt", attempt, "backoff", backoff, "last_err", lastErr)
			select {
			case <-time.After(backoff):
			case <-ctx.Done():
				return ctx.Err()
			}
		}
		err := runSteamCMDWorkshopDownloadOnce(ctx, log, user, workshopID)
		if err == nil {
			return nil
		}
		// Stop immediately on non-retriable errors (bad auth, access denied,
		// subscription missing, stale token needs modal re-login, item gone).
		if errors.Is(err, errTokenStale) || errors.Is(err, errTokenMissing) ||
			errors.Is(err, errWorkshopNotFound) ||
			strings.Contains(err.Error(), "Access Denied") ||
			strings.Contains(err.Error(), "own the DayZ base game") {
			return err
		}
		lastErr = err
	}
	return fmt.Errorf("workshop download failed after %d attempts — last: %w", maxAttempts, lastErr)
}

// runSteamCMDWorkshopDownloadStreaming is the async-job equivalent: same
// retry-on-timeout semantics as runSteamCMDWorkshopDownload, but the
// sidecar's stdout/stderr is streamed line-by-line into `onLine` as it
// runs, so the UI can tail progress in real time. Receives the job so it
// can emit synthetic "__attempt__:N" marker lines for the UI.
func runSteamCMDWorkshopDownloadStreaming(ctx context.Context, log Logger, user string, job *dayzModJob, onLine func(string)) error {
	if user == "" {
		return errTokenMissing
	}
	const maxAttempts = 4
	var lastErr error
	for attempt := 1; attempt <= maxAttempts; attempt++ {
		if attempt > 1 {
			backoff := time.Duration(attempt*attempt-1) * 10 * time.Second
			onLine(fmt.Sprintf("retry in %s (attempt %d/%d) — last: %v", backoff, attempt, maxAttempts, lastErr))
			select {
			case <-time.After(backoff):
			case <-ctx.Done():
				return ctx.Err()
			}
		}
		onLine(fmt.Sprintf("__attempt__:%d", attempt))
		err := runSteamCMDWorkshopDownloadOnceStreaming(ctx, log, user, job.WorkshopID, onLine)
		if err == nil {
			return nil
		}
		if errors.Is(err, errTokenStale) || errors.Is(err, errTokenMissing) ||
			errors.Is(err, errWorkshopNotFound) ||
			strings.Contains(err.Error(), "Access Denied") ||
			strings.Contains(err.Error(), "own the DayZ base game") {
			return err
		}
		lastErr = err
	}
	return fmt.Errorf("workshop download failed after %d attempts — last: %w", maxAttempts, lastErr)
}

// runSteamCMDWorkshopDownloadOnceStreaming runs one SteamCMD sidecar and
// pipes its combined output to onLine. Classification (success / stale
// token / access denied / timeout) is done on the captured output after
// the process exits.
func runSteamCMDWorkshopDownloadOnceStreaming(ctx context.Context, log Logger, user, workshopID string, onLine func(string)) error {
	dockerBin := locateDockerBinary()
	cctx, cancel := context.WithTimeout(ctx, 15*time.Minute)
	defer cancel()
	args := []string{
		"run", "--rm",
		"-v", "panel-steamcmd-auth:/root/.local/share/Steam",
		"-v", "panel-dayz-workshop:/dayz-workshop/steamapps/workshop",
		"steamcmd/steamcmd:latest",
		"+force_install_dir", "/dayz-workshop",
		"+login", user,
		"+workshop_download_item", dayzWorkshopAppID, workshopID, "validate",
		"+quit",
	}
	cmd := exec.CommandContext(cctx, dockerBin, args...)

	// Tee combined stdout+stderr through a line-splitting writer so onLine
	// fires once per real line as it happens (rather than buffering all
	// of it until process exit). We also keep a bytes.Buffer copy so the
	// post-exit classifier can match on the full plain text.
	var full bytes.Buffer
	lw := &lineWriter{
		full: &full,
		cb: func(line string) {
			onLine(ansiEscapeRE.ReplaceAllString(line, ""))
		},
	}
	cmd.Stdout = lw
	cmd.Stderr = lw
	err := cmd.Run()
	lw.flush()
	plain := ansiEscapeRE.ReplaceAllString(full.String(), "")
	log.Info("steamcmd workshop_download output", "workshop_id", workshopID,
		"exit_err", err, "tail", lastFewLines(plain, 20))
	switch {
	case strings.Contains(plain, "Success. Downloaded item"):
		return nil
	case strings.Contains(plain, "failed (File Not Found)"),
		strings.Contains(plain, "failed (Invalid App ID)"):
		// Nonexistent or deleted workshop item — no point retrying.
		return errWorkshopNotFound
	case strings.Contains(plain, "Logon failure"),
		strings.Contains(plain, "Invalid Password"),
		strings.Contains(plain, "No cached credentials"),
		strings.Contains(plain, "cached credentials are invalid"),
		strings.Contains(plain, "FAILED (Rate Limit Exceeded)"),
		strings.Contains(plain, "FAILED (Access Denied)"):
		return errTokenStale
	case strings.Contains(plain, "failed (Access Denied)"):
		// Item exists but account can't download it (delisted, region-
		// locked, or marked for a different branch). Hard fail.
		return fmt.Errorf("Access Denied — this workshop item is restricted for your account: %s", lastFewLines(plain, 6))
	case strings.Contains(plain, "No subscription") || strings.Contains(plain, "Missing configuration"):
		return fmt.Errorf("Steam account doesn't own the DayZ base game (or item access denied): %s", lastFewLines(plain, 6))
	case strings.Contains(plain, "Timeout downloading item"):
		// Retriable — SteamCMD resumes from partial data on next attempt.
		return fmt.Errorf("steamcmd timeout mid-download (will retry)")
	case err != nil:
		return fmt.Errorf("steamcmd exec: %w — last output: %s", err, lastFewLines(plain, 6))
	}
	return fmt.Errorf("steamcmd: unknown result — tail:\n%s", lastFewLines(plain, 12))
}

// lineWriter is an io.Writer that splits on any of \n \r \r\n and invokes
// cb per line (stripped of the terminator). Also tees everything into a
// tail-accessible buffer. Safe for concurrent writes from cmd.Stdout and
// cmd.Stderr sharing the same pointer (exec spawns separate goroutines).
type lineWriter struct {
	mu   sync.Mutex
	buf  []byte
	full *bytes.Buffer
	cb   func(string)
}

func (w *lineWriter) Write(p []byte) (int, error) {
	w.mu.Lock()
	defer w.mu.Unlock()
	w.full.Write(p)
	w.buf = append(w.buf, p...)
	// Emit complete lines. Treat \r as line terminator too so SteamCMD's
	// progress-overwrite lines aren't stuck buffering for minutes.
	for {
		i := firstLineTerm(w.buf)
		if i < 0 {
			break
		}
		line := string(w.buf[:i])
		w.buf = w.buf[i+1:]
		if line != "" && w.cb != nil {
			w.cb(line)
		}
	}
	return len(p), nil
}

// flush emits any trailing partial line as its own callback. Safe to call
// multiple times.
func (w *lineWriter) flush() {
	w.mu.Lock()
	defer w.mu.Unlock()
	if len(w.buf) > 0 && w.cb != nil {
		w.cb(string(w.buf))
	}
	w.buf = w.buf[:0]
}

func firstLineTerm(b []byte) int {
	for i, c := range b {
		if c == '\n' || c == '\r' {
			return i
		}
	}
	return -1
}

func runSteamCMDWorkshopDownloadOnce(ctx context.Context, log Logger, user, workshopID string) error {
	dockerBin := locateDockerBinary()
	// Per-attempt timeout is generous: big mods (Community Framework,
	// Expansion) can be 2+ GB. Retries reset this clock.
	cctx, cancel := context.WithTimeout(ctx, 15*time.Minute)
	defer cancel()
	// The steamcmd:latest image uses /root/.local/share/Steam as its data
	// dir. Workshop items download to <install_dir>/steamapps/workshop/...
	// when +force_install_dir is set; we match that to the shared volume.
	args := []string{
		"run", "--rm",
		"-v", "panel-steamcmd-auth:/root/.local/share/Steam",
		"-v", "panel-dayz-workshop:/dayz-workshop/steamapps/workshop",
		"steamcmd/steamcmd:latest",
		"+force_install_dir", "/dayz-workshop",
		"+login", user, // no password — uses cached refresh token
		"+workshop_download_item", dayzWorkshopAppID, workshopID, "validate",
		"+quit",
	}
	cmd := exec.CommandContext(cctx, dockerBin, args...)
	var out bytes.Buffer
	cmd.Stdout = &out
	cmd.Stderr = &out
	err := cmd.Run()
	outStr := out.String()
	// ANSI color escapes splice into literal text and break substring matches.
	plain := ansiEscapeRE.ReplaceAllString(outStr, "")
	log.Info("steamcmd workshop_download output", "workshop_id", workshopID,
		"exit_err", err, "tail", lastFewLines(plain, 20))
	// Classify result. Keep in sync with runSteamCMDWorkshopDownloadOnceStreaming.
	switch {
	case strings.Contains(plain, "Success. Downloaded item"):
		return nil
	case strings.Contains(plain, "failed (File Not Found)"),
		strings.Contains(plain, "failed (Invalid App ID)"):
		return errWorkshopNotFound
	case strings.Contains(plain, "Logon failure"),
		strings.Contains(plain, "Invalid Password"),
		strings.Contains(plain, "No cached credentials"),
		strings.Contains(plain, "cached credentials are invalid"),
		strings.Contains(plain, "FAILED (Rate Limit Exceeded)"),
		strings.Contains(plain, "FAILED (Access Denied)"):
		return errTokenStale
	case strings.Contains(plain, "failed (Access Denied)"):
		return fmt.Errorf("Access Denied — this workshop item is restricted for your account: %s", lastFewLines(plain, 6))
	case strings.Contains(plain, "No subscription") || strings.Contains(plain, "Missing configuration"):
		return fmt.Errorf("Steam account doesn't own the DayZ base game (or item access denied): %s", lastFewLines(plain, 6))
	case strings.Contains(plain, "Timeout downloading item"):
		return fmt.Errorf("steamcmd timeout mid-download (will retry)")
	case err != nil:
		return fmt.Errorf("steamcmd exec: %w — last output: %s", err, lastFewLines(plain, 6))
	}
	return fmt.Errorf("steamcmd: unknown result — tail:\n%s", lastFewLines(plain, 12))
}

// errTokenStale / errTokenMissing distinguish "user needs to re-auth" from
// other download failures. The HTTP handler converts these into a
// steam_login_required response so the frontend can pop the Steam login
// modal, same flow as the Update button.
var (
	errTokenStale       = errors.New("steam refresh token expired — re-authentication required")
	errTokenMissing     = errors.New("no steam username configured — complete the Steam login ceremony first")
	errWorkshopNotFound = errors.New("workshop item not found — it may have been delisted or the ID is wrong")
)

// Logger is a minimal slog-ish interface so runSteamCMDWorkshopDownload can
// log without importing slog directly (httpServer already has one).
type Logger interface {
	Info(msg string, keysAndValues ...any)
}

func locateDockerBinary() string {
	candidates := []string{
		os.Getenv("DOCKER_BIN"),
		`C:\Program Files\Docker\Docker\resources\bin\docker.exe`,
		"/usr/bin/docker",
		"/usr/local/bin/docker",
	}
	for _, c := range candidates {
		if c == "" {
			continue
		}
		if fi, err := os.Stat(c); err == nil && !fi.IsDir() {
			return c
		}
	}
	return "docker"
}

func lastFewLines(s string, n int) string {
	lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
	if len(lines) > n {
		lines = lines[len(lines)-n:]
	}
	return strings.Join(lines, "\n")
}

// ---- Steam Workshop metadata (public, no auth required) ----

// workshopDetail carries everything the UI needs to render a mod info
// modal — title, description, preview image, author, size, timestamps,
// engagement stats. Filled from GetPublishedFileDetails (no API key
// required). ID is the published_file_id (decimal string) so callers
// fetching a batch can map results back to their input ids without
// guessing — GetPublishedFileDetails returns entries in input order
// only when every id is valid; missing/private items are silently
// dropped from the response, which would shift indices in a naïve
// match-by-position scheme.
type workshopDetail struct {
	ID            string   `json:"id"`
	Title         string   `json:"title"`
	Description   string   `json:"description"`
	PreviewURL    string   `json:"preview_url"`
	Creator       string   `json:"creator"`
	Size          int64    `json:"size"`
	TimeCreated   int64    `json:"time_created"`
	TimeUpdated   int64    `json:"time_updated"`
	Subscriptions int64    `json:"subscriptions"`
	Favorited     int64    `json:"favorited"`
	Views         int64    `json:"views"`
	Tags          []string `json:"tags"`
}

// steamWorkshopDetail calls GetPublishedFileDetails for a single item.
func steamWorkshopDetail(workshopID string) (*workshopDetail, error) {
	details, err := steamWorkshopDetails([]string{workshopID})
	if err != nil {
		return nil, err
	}
	if len(details) == 0 {
		return nil, errors.New("no workshop item found")
	}
	return details[0], nil
}

// steamWorkshopDetails batches multiple ids in one request.
func steamWorkshopDetails(ids []string) ([]*workshopDetail, error) {
	if len(ids) == 0 {
		return nil, nil
	}
	var form strings.Builder
	fmt.Fprintf(&form, "itemcount=%d", len(ids))
	for i, id := range ids {
		fmt.Fprintf(&form, "&publishedfileids%%5B%d%%5D=%s", i, id)
	}
	req, err := http.NewRequest("POST",
		"https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/",
		strings.NewReader(form.String()))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	client := &http.Client{Timeout: 15 * time.Second}
	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	var payload struct {
		Response struct {
			PublishedFileDetails []struct {
				PublishedFileID string `json:"publishedfileid"`
				Title           string `json:"title"`
				Description     string `json:"description"`
				PreviewURL      string `json:"preview_url"`
				Creator         string `json:"creator"`
				FileSize        any    `json:"file_size"`
				TimeCreated     int64  `json:"time_created"`
				TimeUpdated     int64  `json:"time_updated"`
				Subscriptions   int64  `json:"subscriptions"`
				Favorited       int64  `json:"favorited"`
				Views           int64  `json:"views"`
				Tags            []struct {
					Tag string `json:"tag"`
				} `json:"tags"`
				Result int `json:"result"`
			} `json:"publishedfiledetails"`
		} `json:"response"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
		return nil, err
	}
	out := make([]*workshopDetail, 0, len(payload.Response.PublishedFileDetails))
	for _, d := range payload.Response.PublishedFileDetails {
		if d.Result != 0 && d.Result != 1 {
			continue
		}
		var size int64
		switch v := d.FileSize.(type) {
		case float64:
			size = int64(v)
		case string:
			size, _ = strconv.ParseInt(v, 10, 64)
		}
		tags := make([]string, 0, len(d.Tags))
		for _, t := range d.Tags {
			if t.Tag != "" {
				tags = append(tags, t.Tag)
			}
		}
		out = append(out, &workshopDetail{
			ID:            d.PublishedFileID,
			Title:         d.Title,
			Description:   d.Description,
			PreviewURL:    d.PreviewURL,
			Creator:       d.Creator,
			Size:          size,
			TimeCreated:   d.TimeCreated,
			TimeUpdated:   d.TimeUpdated,
			Subscriptions: d.Subscriptions,
			Favorited:     d.Favorited,
			Views:         d.Views,
			Tags:          tags,
		})
	}
	return out, nil
}

// handleWorkshopDetail proxies the above for the UI's mod info modal.
func (h *httpServer) handleWorkshopDetail(w http.ResponseWriter, r *http.Request) {
	id := r.URL.Query().Get("id")
	if id == "" {
		writeError(w, http.StatusBadRequest, "bad_id", "id query param required")
		return
	}
	workshopID, perr := parseWorkshopID(id)
	if perr != nil {
		writeError(w, http.StatusBadRequest, "bad_id", perr.Error())
		return
	}
	d, err := steamWorkshopDetail(workshopID)
	if err != nil {
		writeError(w, http.StatusBadGateway, "steam", err.Error())
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"workshop_id":      workshopID,
		"title":            d.Title,
		"description":      d.Description,
		"preview_url":      d.PreviewURL,
		"creator":          d.Creator,
		"size":             d.Size,
		"time_created":     d.TimeCreated,
		"time_updated":     d.TimeUpdated,
		"subscriptions":    d.Subscriptions,
		"favorited":        d.Favorited,
		"views":            d.Views,
		"tags":             d.Tags,
		"suggested_folder": sanitizeDayzFolder(d.Title),
	})
}

// handleWorkshopSearch is a best-effort search of the Steam Workshop.
//
// Steam's current /workshop/browse page is a React SPA with hashed CSS class
// names — scraping item cards is no longer viable. BUT: the page's server-
// rendered anchor hrefs still carry `?id=NNN` for every hit, in ranked order.
// We extract those ids and batch-hydrate title/preview/size from the public
// GetPublishedFileDetails endpoint (same API steamWorkshopDetail uses).
//
// Query params:
//   q   — required search text
//   app — optional Steam appid to scope the search; defaults to 221100
//         (DayZ) for backward compat. Conan Exiles uses 440900.
//   tag — optional Steam Workshop tag filter. Each value becomes a
//         requiredtags[] URL parameter so Steam restricts results to
//         items with that tag. Repeatable as a CSV (`tag=Enhanced,Server`).
//         Conan Exiles uses "Enhanced" / "Legacy" to split UE5 / UE4 mods.
func (h *httpServer) handleWorkshopSearch(w http.ResponseWriter, r *http.Request) {
	q := strings.TrimSpace(r.URL.Query().Get("q"))
	if q == "" {
		writeError(w, http.StatusBadRequest, "bad_query", "q query param required")
		return
	}
	appID := strings.TrimSpace(r.URL.Query().Get("app"))
	if appID == "" {
		appID = dayzWorkshopAppID
	}
	// Defence-in-depth: only allow numeric appids so we can't be tricked
	// into building a URL pointing at a different host.
	for _, c := range appID {
		if c < '0' || c > '9' {
			writeError(w, http.StatusBadRequest, "bad_app", "app must be numeric")
			return
		}
	}
	// Parse tag filter — CSV-friendly so the UI can pass multiple in one
	// query param. Each unique non-empty token becomes a requiredtags[] URL
	// param. Steam's browse endpoint AND-combines them, so we drop dupes.
	tagParam := strings.TrimSpace(r.URL.Query().Get("tag"))
	tagFilter := ""
	if tagParam != "" {
		seen := map[string]bool{}
		for _, raw := range strings.Split(tagParam, ",") {
			t := strings.TrimSpace(raw)
			if t == "" || seen[strings.ToLower(t)] {
				continue
			}
			seen[strings.ToLower(t)] = true
			// Steam expects tags as PHP-array form: requiredtags%5B%5D=Enhanced
			tagFilter += "&requiredtags%5B%5D=" + urlEncode(t)
		}
	}
	u := fmt.Sprintf("https://steamcommunity.com/workshop/browse/?appid=%s&searchtext=%s&browsesort=textsearch&section=readytouseitems%s",
		appID, urlEncode(q), tagFilter)
	client := &http.Client{Timeout: 12 * time.Second}
	req, _ := http.NewRequest("GET", u, nil)
	// Use a real-browser UA — Steam returns a lighter/redirected page for
	// unknown UAs and that breaks id extraction.
	req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36")
	resp, err := client.Do(req)
	if err != nil {
		writeError(w, http.StatusBadGateway, "steam", err.Error())
		return
	}
	defer resp.Body.Close()
	body, err := io.ReadAll(io.LimitReader(resp.Body, 4*1024*1024))
	if err != nil {
		writeError(w, http.StatusBadGateway, "read", err.Error())
		return
	}
	ids := extractWorkshopIDs(string(body), 30)
	if len(ids) == 0 {
		writeJSON(w, http.StatusOK, map[string]any{"items": []workshopSearchItem{}, "query": q})
		return
	}
	items, err := hydrateWorkshopItems(ids)
	if err != nil {
		writeError(w, http.StatusBadGateway, "steam_details", err.Error())
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"items": items, "query": q})
}

var wsBrowseIDRE = regexp.MustCompile(`[?&]id=(\d{6,20})`)

// extractWorkshopIDs pulls unique published-file ids out of the Steam
// Workshop browse HTML, preserving rank order (which is Steam's popularity/
// relevance order for that query). Limit caps how many we keep.
func extractWorkshopIDs(body string, limit int) []string {
	seen := map[string]bool{}
	out := []string{}
	for _, m := range wsBrowseIDRE.FindAllStringSubmatch(body, -1) {
		id := m[1]
		if seen[id] {
			continue
		}
		seen[id] = true
		out = append(out, id)
		if len(out) >= limit {
			break
		}
	}
	return out
}

// hydrateWorkshopItems calls GetPublishedFileDetails for up to ~50 ids and
// returns search-result structs ready for the UI.
func hydrateWorkshopItems(ids []string) ([]workshopSearchItem, error) {
	if len(ids) == 0 {
		return nil, nil
	}
	var form strings.Builder
	fmt.Fprintf(&form, "itemcount=%d", len(ids))
	for i, id := range ids {
		fmt.Fprintf(&form, "&publishedfileids%%5B%d%%5D=%s", i, id)
	}
	req, err := http.NewRequest("POST",
		"https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/",
		strings.NewReader(form.String()))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	client := &http.Client{Timeout: 15 * time.Second}
	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	var payload struct {
		Response struct {
			PublishedFileDetails []struct {
				PublishedFileID string `json:"publishedfileid"`
				Title           string `json:"title"`
				PreviewURL      string `json:"preview_url"`
				Creator         string `json:"creator"`
				Result          int    `json:"result"`
			} `json:"publishedfiledetails"`
		} `json:"response"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
		return nil, err
	}
	// Steam returns details in the order we asked — preserve it.
	idOrder := map[string]int{}
	for i, id := range ids {
		idOrder[id] = i
	}
	out := make([]workshopSearchItem, 0, len(payload.Response.PublishedFileDetails))
	for _, d := range payload.Response.PublishedFileDetails {
		if d.Result != 0 && d.Result != 1 {
			// Result: 1 = OK; anything else (9 = not found, etc.) — skip.
			continue
		}
		if d.Title == "" {
			continue
		}
		out = append(out, workshopSearchItem{
			ID:         d.PublishedFileID,
			Title:      d.Title,
			PreviewURL: d.PreviewURL,
			Author:     d.Creator, // SteamID; UI renders "by <id>" — acceptable
		})
	}
	sort.SliceStable(out, func(i, j int) bool {
		return idOrder[out[i].ID] < idOrder[out[j].ID]
	})
	return out, nil
}

type workshopSearchItem struct {
	ID       string `json:"id"`
	Title    string `json:"title"`
	PreviewURL string `json:"preview_url,omitempty"`
	Author   string `json:"author,omitempty"`
}

func urlEncode(s string) string {
	var b strings.Builder
	for _, r := range s {
		switch {
		case r >= 'A' && r <= 'Z', r >= 'a' && r <= 'z', r >= '0' && r <= '9',
			r == '-' || r == '_' || r == '.' || r == '~':
			b.WriteRune(r)
		case r == ' ':
			b.WriteByte('+')
		default:
			fmt.Fprintf(&b, "%%%02X", r)
		}
	}
	return b.String()
}
