package main

// Conan Exiles Steam Workshop mod manager — install/uninstall/list/reorder.
//
// Mirrors the DayZ pattern (controller/cmd/controller/dayzmods.go) closely
// but with three Conan-specific differences:
//
//   1. Workshop appid is 440900 (the Conan Exiles game), NOT 443030 (the
//      dedicated server, which doesn't have its own workshop).
//   2. Mods are .pak files; no bikeys to copy. Install = symlink the
//      workshop content directory into /game/ConanSandbox/Mods/<id> and
//      add a relative .pak path to /game/ConanSandbox/Mods/modlist.txt.
//   3. The shared-volume install dir is /conan-workshop (vs DayZ's
//      /dayz-workshop). Both the panel-conan-exiles container and the
//      transient SteamCMD sidecar mount it, so one download serves N
//      Conan instances on the same agent (dayzmods pattern).
//
// The Steam credential cache (panel-steamcmd-auth volume) is shared with
// every other workshop downloader, so a single sign-in unlocks DayZ and
// Conan together — provided the cached account owns the relevant base
// game. SteamCMD's `+workshop_download_item 440900 …` requires an account
// that owns Conan Exiles (or has access to the workshop item via free
// browsing — but most modders ship for owners only).
//
// Job lifecycle phases: queued → downloading → linking → finalizing → done
// (or → error / login_needed). Reuses the dayzModJob type and dayzModJobManager
// runner — the struct is generic enough that conan jobs ride on it without
// a parallel file. BikeysCopied stays empty for Conan jobs (no bikeys).

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

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

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

const (
	// conanWorkshopAppID is the Conan Exiles game's Steam appid (the
	// dedicated server (443030) does not have its own Workshop). Steam's
	// workshop_download_item, the GetPublishedFileDetails API, and the
	// /workshop/browse search all key off the GAME's appid for items.
	conanWorkshopAppID = "440900"

	// conanWorkshopVolume is the named Docker volume that holds workshop
	// content. Mounted in BOTH the SteamCMD sidecar (at
	// /conan-workshop/steamapps/workshop) and every panel-conan-exiles
	// container (at /game/steamapps/workshop) so the game can read what
	// SteamCMD wrote without copying bytes around. Mirrors the DayZ
	// pattern (modules/dayz/module.yaml + dayzmods.go) verbatim.
	conanWorkshopVolume = "panel-conan-workshop"

	// conanSteamCMDInstallDir is what SteamCMD's +force_install_dir is
	// pointed at INSIDE THE SIDECAR. With the volume mounted at
	// <conanSteamCMDInstallDir>/steamapps/workshop, SteamCMD's workshop
	// downloads (which always land at <install_dir>/steamapps/workshop/
	// content/<appid>/<id>/) land at the volume root.
	conanSteamCMDInstallDir = "/conan-workshop"

	// conanWorkshopGameMountIn is where the same volume mounts inside
	// the GAME container. Different mount path from the sidecar; same
	// volume content. Workshop content appears at
	//   /game/steamapps/workshop/content/<appid>/<workshop_id>/<file>.pak
	// — under /game (a declared browseable_root), so FsList/FsSymlink
	// don't need a separate /conan-workshop root declaration.
	conanWorkshopGameMountIn = "/game/steamapps/workshop"

	// conanModsDir is where Conan reads mod content from at boot time.
	// Per Funcom-aligned hosting docs (Akliz / XGamingServer / Nodecraft,
	// 2026), every .pak must live FLAT under this directory — Conan does
	// NOT walk subdirectories of /game/ConanSandbox/Mods/. We satisfy
	// that by symlinking each individual .pak from the shared workshop
	// volume into here at install time (one symlink per .pak file, not
	// per workshop_id).
	conanModsDir = "/game/ConanSandbox/Mods"

	// conanModlistFile is the operator-readable line-per-pak load order.
	// Format (verified 2026 against Akliz + XGamingServer + Funcom wiki):
	//   *Filename1.pak
	//   *Filename2.pak
	// Lines MUST start with `*` — without it Conan ignores the entry as
	// "subscribe-only marker" and the mod doesn't load. Path is just the
	// filename (relative to conanModsDir). One line per .pak; multi-pak
	// mods (Pippi ships Pippi.pak + Pippi_Editor.pak) get multiple lines.
	conanModlistFile = "/game/ConanSandbox/Mods/modlist.txt"

	// conanSidecarFile tracks workshop_id ↔ {title, preview, paks} on
	// disk so the panel can render the installed-mods list with rich
	// metadata (titles, sizes, thumbs) without re-fetching from Steam,
	// and so uninstall can find which symlinks belong to which workshop
	// item. Lives next to modlist.txt; ignored by Conan (the dot-prefix
	// keeps it out of pak loading and the file is hidden in most pak
	// scanners). Format is JSON; see conanSidecarEntry for the shape.
	conanSidecarFile = "/game/ConanSandbox/Mods/.panel-conan-mods.json"
)

// conanSidecarEntry is one workshop item's recorded metadata + the .pak
// filenames that belong to it. The sidecar map is keyed by workshop_id;
// every value is the entry for that id.
type conanSidecarEntry struct {
	Title       string   `json:"title,omitempty"`
	PreviewURL  string   `json:"preview_url,omitempty"`
	Size        int64    `json:"size,omitempty"`
	Updated     int64    `json:"updated,omitempty"`
	Description string   `json:"description,omitempty"`
	Paks        []string `json:"paks"`
	InstalledAt int64    `json:"installed_at,omitempty"` // unix seconds
}

type conanSidecar map[string]*conanSidecarEntry

// conanMod is one entry in the installed-mods list rendered by the UI.
// Same shape as dayzMod — separate type so callers don't conflate them
// even though they share most fields.
type conanMod struct {
	WorkshopID string `json:"workshop_id"`
	Filename   string `json:"filename"`        // e.g. "Savage_Wilds.pak" or "Pippi.pak"
	Order      int    `json:"order"`
	// Optional metadata hydrated from Steam's GetPublishedFileDetails:
	Title       string `json:"title,omitempty"`
	PreviewURL  string `json:"preview_url,omitempty"`
	Size        int64  `json:"size,omitempty"`
	Updated     int64  `json:"updated,omitempty"`     // unix seconds
	Description string `json:"description,omitempty"`
}

type conanInstallReq struct {
	WorkshopID string `json:"workshop_id"` // numeric id OR full Steam URL; parsed below
}

// ---- list ----

// handleConanModsList reads modlist.txt + the sidecar JSON, joins them so
// each .pak entry carries the workshop_id it belongs to, and returns the
// list in load order. The sidecar is the source of truth for grouping +
// metadata; modlist.txt is the source of truth for load order. If they
// drift (operator hand-edited modlist.txt), the modlist wins for ordering
// and any unknown .pak filenames are returned with empty workshop_id so
// the UI can flag them as "manually added".
func (h *httpServer) handleConanModsList(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
	}
	paks := h.conanReadModlist(r.Context(), conn, id)
	sidecar := h.conanReadSidecar(r.Context(), conn, id)

	// Build pak → workshop_id reverse index from the sidecar so we can
	// tag each modlist.txt entry with its owning workshop_id.
	pakToWS := map[string]string{}
	for wsID, e := range sidecar {
		if e == nil {
			continue
		}
		for _, p := range e.Paks {
			pakToWS[p] = wsID
		}
	}

	out := make([]conanMod, 0, len(paks))
	for i, p := range paks {
		entry := conanMod{
			Filename: p,
			Order:    i,
		}
		if wsID := pakToWS[p]; wsID != "" {
			entry.WorkshopID = wsID
			if e := sidecar[wsID]; e != nil {
				entry.Title = e.Title
				entry.PreviewURL = e.PreviewURL
				entry.Size = e.Size
				entry.Updated = e.Updated
				entry.Description = e.Description
			}
		}
		out = append(out, entry)
	}
	writeJSON(w, http.StatusOK, map[string]any{"mods": out})
}

// ---- install (async) ----

// handleConanModInstall kicks off an async install and returns {job_id}
// in <200 ms. UI polls /conan/jobs to track progress. Mirrors DayZ's
// async pattern so a 2 GB Pippi download doesn't tie up a browser
// request.
func (h *httpServer) handleConanModInstall(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 conanInstallReq
	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
	}

	// Pre-fetch metadata. Lets the UI render the friendly name + size on
	// the queued card before the download even starts.
	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
	}

	// Steam credential check up-front so the UI can pop the sign-in
	// modal without burning a job slot on a guaranteed-401.
	user, _, ok := h.getSteamLoginForUpdate(r.Context())
	if !ok {
		writeJSON(w, http.StatusOK, map[string]any{
			"steam_login_required": true,
			"reason":               "Conan Workshop downloads need a Steam account that owns Conan Exiles. Sign in once — the token is cached for next time.",
		})
		return
	}

	// Reject duplicate concurrent installs of the same mod on the same
	// instance — operator double-click guard.
	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
		}
	}

	// FolderName is unused for Conan (we symlink by workshop_id directly),
	// but the dayzModJob struct requires a non-empty value to render in
	// the UI. Stick the workshop_id there.
	job := h.dayzJobs.create(id, workshopID, workshopID, false)
	job.setResult(title, size, updated)
	job.appendLine(fmt.Sprintf("queued: %s (id=%s)", title, workshopID))

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

	// Detach goroutine — context.Background, NOT r.Context — so the HTTP
	// request can return immediately and the operator can close the tab.
	go h.runConanModInstallJob(context.Background(), job, agentID, user)

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

// runConanModInstallJob is the install pipeline. Three phases: download
// (SteamCMD sidecar), link (FsSymlinkRequest to agent), finalize (rewrite
// modlist.txt). Each phase updates job state for the UI poller.
func (h *httpServer) runConanModInstallJob(ctx context.Context, job *dayzModJob, agentID, user string) {
	defer func() {
		if rec := recover(); rec != nil {
			h.log.Error("conan 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 := runSteamCMDConanWorkshopDownloadStreaming(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 Conan Exiles")
		friendly := friendlyConanDownloadError(err)
		job.appendLine("download failed: " + friendly)
		job.setError(friendly, permanent)
		return
	}

	// ---- phase: linking ----
	job.setPhase(phaseLinking)
	job.appendLine("requesting agent: list workshop content + create per-pak symlinks")
	conn := h.registry.get(agentID)
	if conn == nil {
		job.setError("agent disconnected during download", false)
		return
	}
	// The workshop content dir, as seen from inside the GAME container,
	// is /game/steamapps/workshop/content/440900/<id>/ (the volume mount
	// point inside the game; see module.yaml). We send FsList there.
	// The SAME volume content is at /conan-workshop/steamapps/workshop/
	// content/440900/<id>/ from the SteamCMD sidecar's perspective —
	// that's where the download wrote to.
	workshopContentDir := path.Join(conanWorkshopGameMountIn, "content", conanWorkshopAppID, job.WorkshopID)
	pakFiles, lerr := h.conanListPakFiles(ctx, conn, job.InstanceID, workshopContentDir)
	if lerr != nil {
		job.setError("agent list workshop dir: "+lerr.Error(), false)
		return
	}
	if len(pakFiles) == 0 {
		job.setError("workshop item downloaded but contains no .pak files (is it really a Conan mod?)", true)
		return
	}
	job.appendLine(fmt.Sprintf("found %d pak file%s: %s",
		len(pakFiles), plural(len(pakFiles)), strings.Join(pakFiles, ", ")))

	// Conan reads .pak files FLAT from /game/ConanSandbox/Mods/ — it
	// does not walk subdirectories. So one symlink per .pak file:
	//   /game/ConanSandbox/Mods/<filename>.pak
	//     → /conan-workshop/steamapps/workshop/content/440900/<id>/<filename>.pak
	// On collision (two mods ship the same .pak filename) the second
	// install would overwrite the first; we surface this as a soft
	// warning rather than a hard error so the operator can rename one.
	for _, f := range pakFiles {
		linkPath := path.Join(conanModsDir, f)
		target := path.Join(workshopContentDir, f)
		corrID := newCorrelationID("conan_symlink")
		if err := conn.Send(&panelv1.ControllerEnvelope{
			CorrelationId: corrID,
			SentAt:        timestamppb.Now(),
			Payload: &panelv1.ControllerEnvelope_FsSymlink{
				FsSymlink: &panelv1.FsSymlinkRequest{
					InstanceId: job.InstanceID,
					Target:     target,
					LinkPath:   linkPath,
				},
			},
		}); err != nil {
			job.setError(fmt.Sprintf("forward symlink %s to agent: %s", f, err.Error()), false)
			return
		}
		awaitCtx, awaitCancel := context.WithTimeout(ctx, 30*time.Second)
		env, err := h.pending.Await(awaitCtx, corrID, 30*time.Second)
		awaitCancel()
		if err != nil {
			job.setError(fmt.Sprintf("agent didn't reply to symlink %s: %s", f, err.Error()), false)
			return
		}
		if res := env.GetFsSymlinkResult(); res == nil {
			job.setError(fmt.Sprintf("agent returned empty symlink reply for %s", f), false)
			return
		} else if res.Error != "" {
			job.setError(fmt.Sprintf("agent symlink %s: %s", f, res.Error), false)
			return
		}
	}
	job.appendLine(fmt.Sprintf("symlinked %d pak%s into %s", len(pakFiles), plural(len(pakFiles)), conanModsDir))
	job.setAgentResult(pakFiles, workshopContentDir)

	// ---- phase: finalizing ----
	job.setPhase(phaseFinalizing)

	// Update the sidecar JSON FIRST: list endpoint reads it, modlist
	// uses it for grouping. We pull current state, merge the new entry,
	// write it back.
	sidecar := h.conanReadSidecar(ctx, conn, job.InstanceID)
	if sidecar == nil {
		sidecar = conanSidecar{}
	}
	// Pull rich metadata. Title was set during queue but we may have
	// gotten less than full detail — fetching now gives us description
	// + preview for the UI without a separate Steam call later.
	title, previewURL, size, updated := "", "", int64(0), int64(0)
	description := ""
	if d, derr := steamWorkshopDetail(job.WorkshopID); derr == nil && d != nil {
		title, previewURL, size, updated = d.Title, d.PreviewURL, d.Size, d.TimeUpdated
		description = d.Description
	}
	sidecar[job.WorkshopID] = &conanSidecarEntry{
		Title:       title,
		PreviewURL:  previewURL,
		Size:        size,
		Updated:     updated,
		Description: description,
		Paks:        pakFiles,
		InstalledAt: time.Now().Unix(),
	}
	if err := h.conanWriteSidecar(ctx, conn, job.InstanceID, sidecar); err != nil {
		// Non-fatal: modlist.txt and symlinks still work; just the UI's
		// grouping/metadata fall back to "manually added" rendering.
		job.appendLine("warning: failed to update sidecar metadata: " + err.Error())
	}

	// Append entries to modlist.txt with the *Filename.pak format Conan
	// requires. Don't disturb existing entries (operator may have
	// hand-added paks); just add the new ones at the end if not already
	// present.
	if err := h.conanAppendModlist(ctx, conn, job.InstanceID, pakFiles); err != nil {
		job.setError("update modlist.txt: "+err.Error(), false)
		return
	}
	job.appendLine(fmt.Sprintf("added %d entr%s to modlist.txt (with required *prefix)",
		len(pakFiles), map[bool]string{true: "ies", false: "y"}[len(pakFiles) != 1]))
	job.setPercent(100)
	job.setPhase(phaseDone)
	h.log.Info("conan mod install done", "job_id", job.ID, "workshop_id", job.WorkshopID,
		"pak_count", len(pakFiles), "paks", pakFiles)
}

// ---- uninstall ----

func (h *httpServer) handleConanModUninstall(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
	}
	wsID := r.PathValue("workshopId")
	if wsID == "" {
		writeError(w, http.StatusBadRequest, "bad_id", "workshop_id path segment is required")
		return
	}

	// 1. Look up which .pak symlinks belong to this workshop_id (via the
	//    sidecar). If the sidecar lost track somehow, fall back to
	//    leaving the symlinks (operator can hand-clean) and just clear
	//    the modlist entries we know.
	sidecar := h.conanReadSidecar(r.Context(), conn, id)
	var paks []string
	if e := sidecar[wsID]; e != nil {
		paks = e.Paks
	}

	// 2. Remove each individual .pak symlink. Each symlink only points
	//    into the shared workshop volume; we never recurse into the
	//    workshop content itself (which other instances may still need).
	removed := []string{}
	for _, p := range paks {
		corrID := newCorrelationID("conan_unlink")
		linkPath := path.Join(conanModsDir, p)
		if err := conn.Send(&panelv1.ControllerEnvelope{
			CorrelationId: corrID,
			SentAt:        timestamppb.Now(),
			Payload: &panelv1.ControllerEnvelope_FsDelete{
				FsDelete: &panelv1.FsDeleteRequest{
					InstanceId: id,
					Path:       linkPath,
					Recursive:  false,
				},
			},
		}); err != nil {
			h.log.Warn("conan uninstall: forward delete failed", "pak", p, "err", err)
			continue
		}
		env, err := h.pending.Await(r.Context(), corrID, 15*time.Second)
		if err != nil {
			h.log.Warn("conan uninstall: agent reply timeout", "pak", p, "err", err)
			continue
		}
		if res := env.GetFsDeleteResult(); res != nil && res.Error != "" && !strings.Contains(res.Error, "no such file") {
			h.log.Warn("conan uninstall: agent delete error", "pak", p, "err", res.Error)
			continue
		}
		removed = append(removed, p)
	}

	// 3. Drop modlist.txt lines whose filename matches one of `paks`.
	//    If sidecar was empty we're a no-op here, which is correct.
	if len(paks) > 0 {
		if err := h.conanRemoveFromModlist(r.Context(), conn, id, paks); err != nil {
			h.log.Warn("conan mod uninstall: modlist update failed", "instance_id", id, "workshop_id", wsID, "err", err)
		}
	}

	// 4. Drop the workshop_id from the sidecar.
	if sidecar[wsID] != nil {
		delete(sidecar, wsID)
		if err := h.conanWriteSidecar(r.Context(), conn, id, sidecar); err != nil {
			h.log.Warn("conan mod uninstall: sidecar update failed", "instance_id", id, "err", err)
		}
	}

	writeJSON(w, http.StatusOK, map[string]any{
		"ok":          true,
		"workshop_id": wsID,
		"removed_paks": removed,
	})
}

// ---- reorder ----

type conanReorderReq struct {
	// Either WorkshopIDs (groups by mod) or full Entries (raw modlist
	// lines). UI sends WorkshopIDs; entries are derived from the current
	// file order so all .paks for one mod stay grouped.
	WorkshopIDs []string `json:"workshop_ids"`
}

func (h *httpServer) handleConanModReorder(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 conanReorderReq
	if err := json.NewDecoder(io.LimitReader(r.Body, 64*1024)).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "bad_json", err.Error())
		return
	}
	current := h.conanReadModlist(r.Context(), conn, id)
	sidecar := h.conanReadSidecar(r.Context(), conn, id)

	// Group current paks by workshop_id (via sidecar) so a multi-pak
	// mod's internal pak order is preserved when the operator reorders
	// at the workshop_id granularity. Paks not tied to any sidecar entry
	// (operator-hand-added mods) are bucketed under "" and appended at
	// the end.
	pakToWS := map[string]string{}
	for wsID, e := range sidecar {
		if e == nil {
			continue
		}
		for _, p := range e.Paks {
			pakToWS[p] = wsID
		}
	}
	byWS := map[string][]string{}
	for _, p := range current {
		wsID := pakToWS[p]
		byWS[wsID] = append(byWS[wsID], p)
	}

	out := []string{}
	seen := map[string]bool{}
	for _, wsID := range req.WorkshopIDs {
		if seen[wsID] {
			continue
		}
		seen[wsID] = true
		out = append(out, byWS[wsID]...)
		delete(byWS, wsID)
	}
	// Append any leftovers (mods in the file but not in the reorder
	// request — operator hand-added or sidecar-orphaned). Stable order
	// by workshop_id so reorders are deterministic; "" bucket last.
	leftoverKeys := make([]string, 0, len(byWS))
	for k := range byWS {
		if k != "" {
			leftoverKeys = append(leftoverKeys, k)
		}
	}
	sort.Strings(leftoverKeys)
	for _, k := range leftoverKeys {
		out = append(out, byWS[k]...)
	}
	out = append(out, byWS[""]...) // hand-added paks last

	if err := h.conanWriteModlist(r.Context(), conn, id, out); err != nil {
		writeError(w, http.StatusBadGateway, "write_modlist", err.Error())
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"ok": true, "entries": out})
}

// ---- job polling endpoints ----

func (h *httpServer) handleConanModJobList(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})
}

func (h *httpServer) handleConanModJobGet(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())
}

func (h *httpServer) handleConanModJobDelete(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
	}
	h.dayzJobs.remove(jobID)
	writeJSON(w, http.StatusOK, map[string]any{"ok": true, "id": jobID})
}

// ---- modlist.txt helpers ----

// conanReadModlist returns the .pak filenames in load order. Each line
// must be of the form "*Filename.pak"; the leading * is stripped before
// returning. Blank lines and # comments are skipped. We also tolerate
// (legacy / hand-edited) entries WITHOUT the asterisk and entries with
// a path prefix (which we strip down to the basename) — the panel will
// normalise on next write.
func (h *httpServer) conanReadModlist(ctx context.Context, conn *agentConn, instanceID string) []string {
	res, err := h.dayzXmlReadFile(ctx, conn, instanceID, conanModlistFile)
	if err != nil || res == nil || res.Error != "" {
		return nil
	}
	out := []string{}
	scanner := bufio.NewScanner(bytes.NewReader(res.Content))
	for scanner.Scan() {
		raw := strings.TrimSpace(scanner.Text())
		if raw == "" || strings.HasPrefix(raw, "#") {
			continue
		}
		line := strings.TrimPrefix(raw, "*")
		// Normalise path separators + strip directory prefix so a legacy
		// entry "*<workshop_id>/Mod.pak" still groups by the underlying
		// pak name. Conan-correct entries are bare filenames already.
		line = strings.ReplaceAll(line, "\\", "/")
		if i := strings.LastIndex(line, "/"); i >= 0 {
			line = line[i+1:]
		}
		line = strings.TrimSpace(line)
		if line == "" {
			continue
		}
		out = append(out, line)
	}
	return out
}

// conanAppendModlist adds each new pak filename in order, deduped.
func (h *httpServer) conanAppendModlist(ctx context.Context, conn *agentConn, instanceID string, newPaks []string) error {
	current := h.conanReadModlist(ctx, conn, instanceID)
	have := map[string]bool{}
	rendered := make([]string, 0, len(current)+len(newPaks))
	for _, p := range current {
		have[p] = true
		rendered = append(rendered, p)
	}
	for _, p := range newPaks {
		if have[p] {
			continue
		}
		have[p] = true
		rendered = append(rendered, p)
	}
	return h.conanWriteModlist(ctx, conn, instanceID, rendered)
}

// conanRemoveFromModlist drops every line whose filename appears in the
// supplied set (case-insensitive on the filename itself, since Wine on
// Linux normalises pak-file lookups case-insensitively).
func (h *httpServer) conanRemoveFromModlist(ctx context.Context, conn *agentConn, instanceID string, removePaks []string) error {
	current := h.conanReadModlist(ctx, conn, instanceID)
	drop := map[string]bool{}
	for _, p := range removePaks {
		drop[strings.ToLower(p)] = true
	}
	rendered := make([]string, 0, len(current))
	for _, p := range current {
		if drop[strings.ToLower(p)] {
			continue
		}
		rendered = append(rendered, p)
	}
	return h.conanWriteModlist(ctx, conn, instanceID, rendered)
}

// conanWriteModlist rewrites modlist.txt with the supplied .pak file
// names, each prefixed with `*` (REQUIRED by Conan — without it the
// dedicated server treats the entry as a no-op marker, see Funcom +
// Akliz + XGamingServer docs 2026). A header comment helps operators
// eyeballing the file recognise it as panel-managed.
func (h *httpServer) conanWriteModlist(ctx context.Context, conn *agentConn, instanceID string, paks []string) error {
	var body bytes.Buffer
	body.WriteString("# panel-managed mod list — one *Filename.pak per line.\n")
	body.WriteString("# The leading asterisk is REQUIRED — without it Conan ignores the entry.\n")
	body.WriteString("# Blank lines and '#' comments ignored. Edits via the panel UI Mods\n")
	body.WriteString("# tab will overwrite this file; manual edits survive only until then.\n")
	for _, p := range paks {
		body.WriteString("*")
		body.WriteString(p)
		body.WriteByte('\n')
	}
	return h.dayzXmlWriteFile(ctx, conn, instanceID, conanModlistFile, body.Bytes())
}

// conanReadSidecar loads the workshop_id ↔ {metadata, paks} map from
// /game/ConanSandbox/Mods/.panel-conan-mods.json. Returns an empty map
// on any error so callers can treat "fresh state" and "lost sidecar"
// the same way.
func (h *httpServer) conanReadSidecar(ctx context.Context, conn *agentConn, instanceID string) conanSidecar {
	res, err := h.dayzXmlReadFile(ctx, conn, instanceID, conanSidecarFile)
	if err != nil || res == nil || res.Error != "" || len(res.Content) == 0 {
		return conanSidecar{}
	}
	out := conanSidecar{}
	if err := json.Unmarshal(res.Content, &out); err != nil {
		h.log.Warn("conan sidecar parse failed", "instance_id", instanceID, "err", err)
		return conanSidecar{}
	}
	return out
}

// conanWriteSidecar persists the workshop_id map. Pretty-prints with
// 2-space indent so an operator inspecting the file by hand can read
// the layout.
func (h *httpServer) conanWriteSidecar(ctx context.Context, conn *agentConn, instanceID string, s conanSidecar) error {
	body, err := json.MarshalIndent(s, "", "  ")
	if err != nil {
		return err
	}
	body = append(body, '\n')
	return h.dayzXmlWriteFile(ctx, conn, instanceID, conanSidecarFile, body)
}

// ---- pak filename discovery ----

// conanListPakFiles asks the agent to list `dir` (inside the instance
// container) and returns just the .pak filenames. Sorted for stable load
// order across operators.
func (h *httpServer) conanListPakFiles(ctx context.Context, conn *agentConn, instanceID, dir string) ([]string, error) {
	corrID := newCorrelationID("conan_list_pak")
	if err := conn.Send(&panelv1.ControllerEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.ControllerEnvelope_FsList{
			FsList: &panelv1.FsListRequest{
				InstanceId: instanceID,
				Path:       dir,
			},
		},
	}); err != nil {
		return nil, fmt.Errorf("forward list to agent: %w", err)
	}
	awaitCtx, awaitCancel := context.WithTimeout(ctx, 15*time.Second)
	env, err := h.pending.Await(awaitCtx, corrID, 15*time.Second)
	awaitCancel()
	if err != nil {
		return nil, err
	}
	res := env.GetFsListResult()
	if res == nil {
		return nil, errors.New("agent returned empty fs_list reply")
	}
	if res.Error != "" {
		return nil, errors.New(res.Error)
	}
	out := []string{}
	for _, e := range res.Entries {
		if e == nil || e.IsDir {
			continue
		}
		if !strings.HasSuffix(strings.ToLower(e.Name), ".pak") {
			continue
		}
		out = append(out, e.Name)
	}
	sort.Strings(out)
	return out, nil
}

// ---- SteamCMD download (Conan-flavoured) ----

// runSteamCMDConanWorkshopDownloadStreaming streams stdout into onLine
// while running a SteamCMD sidecar to download one Conan workshop item
// into the shared panel-conan-workshop volume. Same retry-on-timeout
// pattern as the DayZ flavour, with Conan-specific error classification
// (the "doesn't own DayZ" line becomes "doesn't own Conan Exiles").
func runSteamCMDConanWorkshopDownloadStreaming(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 := runSteamCMDConanWorkshopDownloadOnceStreaming(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 Conan Exiles") {
			return err
		}
		lastErr = err
	}
	return fmt.Errorf("conan workshop download failed after %d attempts — last: %w", maxAttempts, lastErr)
}

func runSteamCMDConanWorkshopDownloadOnceStreaming(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",
		// Mount the workshop volume DEEP — at
		// <install_dir>/steamapps/workshop — so SteamCMD's standard
		// workshop write path (<install_dir>/steamapps/workshop/content/
		// <appid>/<id>/) lands at the volume root. The game container
		// mounts the same volume at /game/steamapps/workshop, which
		// makes the content visible there as
		// /game/steamapps/workshop/content/<appid>/<id>/. Verified
		// pattern — same as DayZ.
		"-v", conanWorkshopVolume + ":" + conanSteamCMDInstallDir + "/steamapps/workshop",
		"steamcmd/steamcmd:latest",
		"+force_install_dir", conanSteamCMDInstallDir,
		"+login", user,
		"+workshop_download_item", conanWorkshopAppID, workshopID, "validate",
		"+quit",
	}
	cmd := exec.CommandContext(cctx, dockerBin, args...)
	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 conan 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)"):
		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 Conan Exiles (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))
}

func friendlyConanDownloadError(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 Conan Exiles"):
		return "Your Steam account doesn't own Conan Exiles — 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."
	}
	msg := err.Error()
	if i := strings.Index(msg, "\n"); i > 0 {
		msg = msg[:i]
	}
	if len(msg) > 200 {
		msg = msg[:200] + "…"
	}
	return msg
}
