package main

// ValheimPlus — first-class mod integration (separate from the generic
// Thunderstore browser because V+ has a 1500-line config we expose in full).
//
//   POST   /api/instances/{id}/valheim/valheimplus/install   → install + enable
//   POST   /api/instances/{id}/valheim/valheimplus/config    → write valheim_plus.cfg from VP_* settings
//   DELETE /api/instances/{id}/valheim/valheimplus           → disable + remove
//
// Install: download Grantapher/ValheimPlus UnixServer.zip (bundles BepInEx +
// the ValheimPlus.dll + Doorstop loader) → extract into /game; download the
// stock valheim_plus.cfg → BepInEx/config. Then flip VALHEIM_MODS_ENABLED=1 +
// VALHEIM_PLUS_ENABLED=1 and recreate so Doorstop is injected.
//
// Config: the gamehost UI holds all ~345 V+ settings in config_values as VP_*
// keys (VP_<Section>__<key>). On save we render the FULL valheim_plus.cfg by
// merging those overrides onto the stock cfg, then fsWrite it to
// BepInEx/config/valheim_plus.cfg. (Done controller-side so we never need 345
// container env vars.)

import (
	"bufio"
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"regexp"
	"sort"
	"strings"
	"time"
)

const (
	// Grantapher's maintained ValheimPlus fork. Pin a known-good release.
	valheimPlusVersion   = "0.9.17.1"
	valheimPlusRepo      = "Grantapher/ValheimPlus"
	valheimPlusServerZip = "UnixServer.zip"
	valheimPlusCfgAsset  = "valheim_plus.cfg"
	valheimPlusCfgPath   = "/game/BepInEx/config/valheim_plus.cfg"
)

func valheimPlusAssetURL(asset string) string {
	return fmt.Sprintf("https://github.com/%s/releases/download/%s/%s",
		valheimPlusRepo, valheimPlusVersion, asset)
}

func (h *httpServer) handleValheimPlusInstall(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
	}
	if _, err := h.valheimRow(r.Context(), id); err != nil {
		writeError(w, http.StatusBadRequest, "bad_module", err.Error())
		return
	}
	// Mark install in-progress so the UI can show a spinner + poll. Then do the
	// heavy work (download + ~40 file writes + recreate) in the BACKGROUND and
	// return 202 immediately — the install far exceeds the public proxy's 60s
	// timeout, so a synchronous response would 504 even though it succeeds.
	if err := h.mergeValheimConfigNoRecreate(r.Context(), id, map[string]string{
		"VALHEIM_PLUS_INSTALLING": "1",
	}); err != nil {
		writeError(w, http.StatusInternalServerError, "save", err.Error())
		return
	}
	go h.installValheimPlusAsync(agentID, id)
	writeJSON(w, http.StatusAccepted, map[string]any{
		"accepted": true, "version": valheimPlusVersion,
		"warning": "Every player must also install ValheimPlus to join (enforceMod is on).",
		"note":    "installing — this takes a minute; the page will update when ready",
	})
}

// installValheimPlusAsync does the actual V+ install off the request path.
func (h *httpServer) installValheimPlusAsync(agentID, id string) {
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
	defer cancel()
	fail := func(stage string, err error) {
		h.log.Warn("valheimplus install failed", "instance_id", id, "stage", stage, "err", err)
		_ = h.mergeValheimConfigNoRecreate(ctx, id, map[string]string{
			"VALHEIM_PLUS_INSTALLING": "",
			"VALHEIM_PLUS_INSTALL_ERR": stage + ": " + err.Error(),
		})
	}

	zipRaw, err := thunderstoreFetch(ctx, valheimPlusAssetURL(valheimPlusServerZip), 90*time.Second)
	if err != nil {
		fail("download", err)
		return
	}
	if err := h.extractZipToInstance(ctx, agentID, id, zipRaw, ""); err != nil {
		fail("extract", err)
		return
	}
	cfgRaw, err := thunderstoreFetch(ctx, valheimPlusAssetURL(valheimPlusCfgAsset), 30*time.Second)
	if err != nil {
		fail("download_cfg", err)
		return
	}
	if err := h.fsWriteToAgent(ctx, agentID, id, valheimPlusCfgPath, cfgRaw); err != nil {
		fail("write_cfg", err)
		return
	}
	// Enable + clear the installing flag, then recreate so Doorstop is injected.
	if err := h.mergeValheimConfig(ctx, id, map[string]string{
		"VALHEIM_MODS_ENABLED":     "1",
		"VALHEIM_PLUS_ENABLED":     "1",
		"VALHEIM_PLUS_VERSION":     valheimPlusVersion,
		"VALHEIM_PLUS_INSTALLING":  "",
		"VALHEIM_PLUS_INSTALL_ERR": "",
	}); err != nil {
		fail("enable", err)
		return
	}
	h.log.Info("valheimplus install complete", "instance_id", id, "version", valheimPlusVersion)
}

func (h *httpServer) handleValheimPlusUninstall(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
	}
	ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
	defer cancel()
	// Remove the ValheimPlus plugin (leave BepInEx so other mods still work).
	_ = h.fsDeleteOnAgent(ctx, agentID, id, "/game/BepInEx/plugins/ValheimPlus.dll")
	_ = h.fsDeleteOnAgent(ctx, agentID, id, valheimPlusCfgPath)

	// Disable V+; only disable the whole loader if no other mods are installed.
	updates := map[string]string{"VALHEIM_PLUS_ENABLED": "0", "VALHEIM_PLUS_VERSION": ""}
	if row, err := h.valheimRow(ctx, id); err == nil {
		if len(readInstalledMods(row.ConfigValues)) == 0 {
			updates["VALHEIM_MODS_ENABLED"] = "0"
		}
	}
	if err := h.mergeValheimConfig(ctx, id, updates); err != nil {
		writeError(w, http.StatusInternalServerError, "save", err.Error())
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"uninstalled": "ValheimPlus", "note": "restart applied"})
}

// handleValheimPlusConfig regenerates valheim_plus.cfg from the instance's VP_*
// config_values and writes it to the volume. Does NOT recreate (the running V+
// reads the cfg at start; restart applies). The gamehost UI POSTs the VP_*
// values into config_values first (via env-config), then calls this.
func (h *httpServer) handleValheimPlusConfig(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
	}
	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
	defer cancel()

	// Optional body: {"updates": {VP_*: value}}. Persist these into config_values
	// WITHOUT a recreate (we recreate once at the end), so the whole save is a
	// single restart instead of two.
	var body struct {
		Updates map[string]string `json:"updates"`
	}
	_ = json.NewDecoder(r.Body).Decode(&body)
	if len(body.Updates) > 0 {
		if err := h.mergeValheimConfigNoRecreate(ctx, id, body.Updates); err != nil {
			writeError(w, http.StatusInternalServerError, "save", err.Error())
			return
		}
	}

	row, err := h.valheimRow(ctx, id)
	if err != nil {
		writeError(w, http.StatusBadRequest, "bad_module", err.Error())
		return
	}

	// Fetch the stock cfg as the base (preserves comments + any keys we don't
	// expose). Cache-friendly: GitHub release asset.
	base, err := thunderstoreFetch(ctx, valheimPlusAssetURL(valheimPlusCfgAsset), 30*time.Second)
	if err != nil {
		writeError(w, http.StatusBadGateway, "stock_cfg", err.Error())
		return
	}
	merged := renderValheimPlusCfg(base, row.ConfigValues)
	if err := h.fsWriteToAgent(ctx, agentID, id, valheimPlusCfgPath, merged); err != nil {
		writeError(w, http.StatusBadGateway, "write_cfg", err.Error())
		return
	}
	// Recreate so (a) the entrypoint's root-stage re-chowns /game to panel — the
	// cfg we just wrote via fsWrite is root-owned, and V+ (running as panel) gets
	// "Access denied" + falls back to a GitHub download that clobbers our
	// settings; and (b) V+ only reads valheim_plus.cfg at startup, so a restart
	// is required for the change to apply at all.
	if err := h.mergeValheimConfig(ctx, id, map[string]string{}); err != nil {
		writeError(w, http.StatusBadGateway, "recreate", err.Error())
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"ok": true, "restarted": true})
}

// --- cfg rendering ---------------------------------------------------------

var vpKeyRe = regexp.MustCompile(`^([A-Za-z0-9_]+)=`)
var vpSectionRe = regexp.MustCompile(`^\[(.+)\]\s*$`)

// renderValheimPlusCfg merges VP_<Section>__<key> overrides from config_values
// onto the stock cfg, preserving structure + comments. Unknown/un-set keys keep
// their stock value.
func renderValheimPlusCfg(stock []byte, cv map[string]string) []byte {
	// Index overrides by "Section\x1fkey".
	ov := map[string]string{}
	for k, v := range cv {
		if !strings.HasPrefix(k, "VP_") {
			continue
		}
		rest := strings.TrimPrefix(k, "VP_")
		i := strings.Index(rest, "__")
		if i < 0 {
			continue
		}
		sec, key := rest[:i], rest[i+2:]
		ov[sec+"\x1f"+key] = v
	}

	// FORCE disableConfigAutoUpdates=true regardless of the customer's setting.
	// V+ otherwise re-downloads the stock cfg from GitHub on every boot and
	// OVERWRITES our rendered settings (so e.g. disableServerPassword silently
	// reverts to false). The panel is the source of truth for the cfg.
	ov["ValheimPlus\x1fdisableConfigAutoUpdates"] = "true"

	var out bytes.Buffer
	sc := bufio.NewScanner(bytes.NewReader(stock))
	sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
	section := ""
	for sc.Scan() {
		line := sc.Text()
		if m := vpSectionRe.FindStringSubmatch(line); m != nil {
			section = m[1]
			out.WriteString(line)
			out.WriteByte('\n')
			continue
		}
		if m := vpKeyRe.FindStringSubmatch(line); m != nil {
			key := m[1]
			if val, ok := ov[section+"\x1f"+key]; ok {
				out.WriteString(key)
				out.WriteByte('=')
				out.WriteString(val)
				out.WriteByte('\n')
				continue
			}
		}
		out.WriteString(line)
		out.WriteByte('\n')
	}
	return out.Bytes()
}

// extractZipToInstance unzips raw into the instance volume under destPrefix
// (relative to /game). Skips meta files. Reused by the V+ installer.
func (h *httpServer) extractZipToInstance(ctx context.Context, agentID, instanceID string, raw []byte, destPrefix string) error {
	zr, err := zipReader(raw)
	if err != nil {
		return err
	}
	count := 0
	for _, f := range zr.File {
		if f.FileInfo().IsDir() {
			continue
		}
		// Absolute /game path — safeJoinAny on the agent matches the /game
		// browseable root. A relative path would resolve under /game-saves.
		dest := "/game/" + strings.TrimPrefix(f.Name, "/")
		if destPrefix != "" {
			dest = "/game/" + strings.Trim(destPrefix, "/") + "/" + strings.TrimPrefix(f.Name, "/")
		}
		content, err := readZipFile(f)
		if err != nil {
			return err
		}
		if err := h.fsWriteToAgent(ctx, agentID, instanceID, dest, content); err != nil {
			return fmt.Errorf("write %s: %w", dest, err)
		}
		count++
	}
	if count == 0 {
		return fmt.Errorf("zip contained no files")
	}
	return nil
}

// vpSectionNames returns the cfg's section names in file order (used by the UI
// generator). Not called at runtime; kept for tooling parity.
func vpSectionNames(stock []byte) []string {
	var secs []string
	seen := map[string]bool{}
	sc := bufio.NewScanner(bytes.NewReader(stock))
	for sc.Scan() {
		if m := vpSectionRe.FindStringSubmatch(sc.Text()); m != nil && !seen[m[1]] {
			seen[m[1]] = true
			secs = append(secs, m[1])
		}
	}
	sort.Strings(secs)
	return secs
}
