package main

// 7 Days to Die mod browser — Nexus Mods backend.
//
// Endpoints:
//   GET  /api/7dtd/mods/search?q=...                          → Nexus instant-search
//   GET  /api/7dtd/mods/detail?id=...                          → A single mod's metadata (page scrape)
//   GET  /api/7dtd/nexus/check                                 → Is auto-install configured / Premium?
//   GET  /api/7dtd/nexus/files/{modId}                         → File versions for a mod (needs key)
//   POST /api/instances/{id}/7dtd/nexus/install                → Download zip + extract → /game/Mods/<modname>/
//
// Auth model:
//   - Search + detail are authless (unofficial endpoints / page scrape).
//   - Files-list + install require the panel admin to set
//     PANEL_NEXUS_API_KEY in env. The key MUST be from a Premium Nexus
//     account because the /download_link API is gated to Premium for
//     non-NXM-URL flows. Free-tier users can fall back to manual
//     download via the existing "Open on Nexus" button.
//
// Install hack: instead of standing up an agent-side download/extract
// sidecar (which would need new proto messages + agent rebuild), the
// controller does ALL the work — fetches the zip from Nexus, unzips
// in memory, then streams each file to the agent via the existing
// FsWriteRequest RPC. No agent changes required.

import (
	"archive/zip"
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"path"
	"regexp"
	"strconv"
	"strings"
	"sync"
	"sync/atomic"
	"time"

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

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

// Nexus's game id for 7 Days to Die. Stable since the site launched.
const sevenDTDNexusGameID = "1059"

// sevenDTDNexusGameSlug is the URL slug used to compose mod permalinks.
const sevenDTDNexusGameSlug = "7daystodie"

// nexusSearchResult mirrors the JSON shape returned by the unofficial
// Nexus instant-search endpoint. Fields not used by the UI are dropped.
type nexusSearchResult struct {
	ModID     int    `json:"mod_id"`
	GameName  string `json:"game_name"`
	Name      string `json:"name"`
	Username  string `json:"username"`
	Image     string `json:"image"`
	Downloads int64  `json:"downloads"`
	URL       string `json:"url"` // sometimes populated by Nexus, sometimes not
}

type nexusSearchResp struct {
	Results []nexusSearchResult `json:"results"`
	Total   int                 `json:"total"`
}

// handleSevenDTDModSearch — proxies a Nexus instant-search query to
// 7DTD. The unofficial endpoint at search.nexusmods.com is what the
// website's instant-search dropdown calls, so it's stable and authless.
func (h *httpServer) handleSevenDTDModSearch(w http.ResponseWriter, r *http.Request) {
	q := strings.TrimSpace(r.URL.Query().Get("q"))
	if q == "" {
		writeError(w, http.StatusBadRequest, "bad_query", "q is required")
		return
	}
	u, _ := url.Parse("https://search.nexusmods.com/mods")
	qs := u.Query()
	qs.Set("terms", q)
	qs.Set("game_id", sevenDTDNexusGameID)
	u.RawQuery = qs.Encode()

	body, err := nexusFetch(u.String(), 12*time.Second)
	if err != nil {
		writeError(w, http.StatusBadGateway, "nexus", err.Error())
		return
	}
	var resp nexusSearchResp
	if err := json.Unmarshal(body, &resp); err != nil {
		writeError(w, http.StatusBadGateway, "decode", err.Error())
		return
	}

	items := make([]map[string]any, 0, len(resp.Results))
	for _, r := range resp.Results {
		mod := map[string]any{
			"id":             strconv.Itoa(r.ModID),
			"name":           r.Name,
			"author":         r.Username,
			"summary":        "",
			"logo_url":       r.Image,
			"download_count": r.Downloads,
			"website":        nexusModURL(r.ModID),
			"slug":           strconv.Itoa(r.ModID),
		}
		items = append(items, mod)
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"items":  items,
		"query":  q,
		"source": "nexus",
		"total":  resp.Total,
	})
}

// handleSevenDTDModDetail — fetches a Nexus mod page and extracts the
// open-graph / JSON-LD metadata so we can show a richer preview card
// without requiring an API key.
func (h *httpServer) handleSevenDTDModDetail(w http.ResponseWriter, r *http.Request) {
	id := strings.TrimSpace(r.URL.Query().Get("id"))
	if id == "" {
		writeError(w, http.StatusBadRequest, "bad_id", "id is required")
		return
	}
	if _, err := strconv.Atoi(id); err != nil {
		writeError(w, http.StatusBadRequest, "bad_id", "id must be numeric")
		return
	}
	pageURL := nexusModURL(parseInt(id))
	html, err := nexusFetch(pageURL, 15*time.Second)
	if err != nil {
		writeError(w, http.StatusBadGateway, "nexus", err.Error())
		return
	}
	out := nexusExtractMeta(string(html))
	out["id"] = id
	out["website"] = pageURL
	writeJSON(w, http.StatusOK, out)
}

// ---- helpers ----

func nexusModURL(modID int) string {
	return fmt.Sprintf("https://www.nexusmods.com/%s/mods/%d", sevenDTDNexusGameSlug, modID)
}

func parseInt(s string) int { n, _ := strconv.Atoi(s); return n }

// nexusFetch — small HTTP wrapper with a sane User-Agent. Nexus
// occasionally 403s requests with no UA or default Go UA.
func nexusFetch(u string, timeout time.Duration) ([]byte, error) {
	req, err := http.NewRequest("GET", u, nil)
	if err != nil {
		return nil, err
	}
	req.Header.Set("User-Agent", version.UserAgent())
	req.Header.Set("Accept", "application/json, text/html;q=0.8")
	cli := &http.Client{Timeout: timeout}
	res, err := cli.Do(req)
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()
	if res.StatusCode >= 400 {
		body, _ := io.ReadAll(io.LimitReader(res.Body, 2048))
		return nil, fmt.Errorf("nexus %d: %s", res.StatusCode, strings.TrimSpace(string(body)))
	}
	return io.ReadAll(io.LimitReader(res.Body, 2*1024*1024))
}

// nexusExtractMeta — pulls title / description / image / author /
// download count out of a Nexus mod page's HTML head + body. We use
// regex over hand-rolling an HTML parser; the markup is stable enough.
var (
	reOGTitle  = regexp.MustCompile(`<meta\s+property="og:title"\s+content="([^"]+)"`)
	reOGDesc   = regexp.MustCompile(`<meta\s+property="og:description"\s+content="([^"]+)"`)
	reOGImage  = regexp.MustCompile(`<meta\s+property="og:image"\s+content="([^"]+)"`)
	reAuthor   = regexp.MustCompile(`<a [^>]*?class="[^"]*?\bmember-icon\b[^"]*"[^>]*>([^<]+)</a>`)
	reUploaded = regexp.MustCompile(`<dt>Uploaded\s*</dt>\s*<dd>([^<]+)</dd>`)
	reUpdated  = regexp.MustCompile(`<dt>Last update\s*</dt>\s*<dd>([^<]+)</dd>`)
	reDLs      = regexp.MustCompile(`<div [^>]*class="[^"]*?\bsideitem\b[^"]*"[^>]*>\s*<dl>\s*<dt>(?:Total|Unique) downloads</dt>\s*<dd>([0-9,]+)</dd>`)
	reSize     = regexp.MustCompile(`<dt>File size</dt>\s*<dd>([^<]+)</dd>`)
)

func nexusExtractMeta(html string) map[string]any {
	out := map[string]any{}
	if m := reOGTitle.FindStringSubmatch(html); len(m) > 1 {
		// Strip the trailing " at 7 Days to Die Nexus - Mods and community" Nexus appends.
		t := m[1]
		if idx := strings.LastIndex(t, " at 7 Days to Die"); idx > 0 {
			t = t[:idx]
		}
		out["name"] = htmlUnescape(t)
	}
	if m := reOGDesc.FindStringSubmatch(html); len(m) > 1 {
		out["summary"] = htmlUnescape(m[1])
	}
	if m := reOGImage.FindStringSubmatch(html); len(m) > 1 {
		out["logo_url"] = m[1]
	}
	if m := reAuthor.FindStringSubmatch(html); len(m) > 1 {
		out["author"] = htmlUnescape(strings.TrimSpace(m[1]))
	}
	if m := reUploaded.FindStringSubmatch(html); len(m) > 1 {
		out["released"] = strings.TrimSpace(m[1])
	}
	if m := reUpdated.FindStringSubmatch(html); len(m) > 1 {
		out["updated"] = strings.TrimSpace(m[1])
	}
	if m := reDLs.FindStringSubmatch(html); len(m) > 1 {
		clean := strings.ReplaceAll(m[1], ",", "")
		if n, err := strconv.ParseInt(clean, 10, 64); err == nil {
			out["download_count"] = n
		}
	}
	if m := reSize.FindStringSubmatch(html); len(m) > 1 {
		out["file_size_human"] = strings.TrimSpace(m[1])
	}
	return out
}

func htmlUnescape(s string) string {
	r := strings.NewReplacer(
		"&amp;", "&",
		"&lt;", "<",
		"&gt;", ">",
		"&quot;", `"`,
		"&#39;", "'",
		"&apos;", "'",
		"&nbsp;", " ",
	)
	return r.Replace(s)
}

// =============== DRAG-AND-DROP MOD UPLOAD ===============

// handleSevenDTDModUpload — multipart upload of one or more zip files.
//
// What it does, end to end:
//   1. Parses the multipart body — each "files" field is a zip.
//   2. Recursively extracts every zip up to 3 levels deep (handles
//      "multiple zips inside one zip" — common when authors bundle mods
//      together with a wrapper zip).
//   3. Walks the resulting virtual filesystem looking for ModInfo.xml
//      files. Every directory containing one is a mod.
//   4. Reads the existing /game/Mods/ folder list from the agent so we
//      can detect already-installed mods (the operator's main concern:
//      "don't double-install 0-Harmony"). Conflicts default to SKIP
//      unless the request includes ?overwrite=true.
//   5. Streams every file in each accepted mod to the agent via
//      FsWriteRequest — same pattern as the Nexus auto-install path,
//      no agent rebuild required.
//
// Returns a JSON summary with per-mod status: installed / skipped /
// failed, plus version metadata pulled from each ModInfo.xml.
func (h *httpServer) handleSevenDTDModUpload(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
	}
	overwrite := r.URL.Query().Get("overwrite") == "true"

	// 256 MB upload ceiling; per-zip files can run up to ~200 MB for big
	// overhauls so leave headroom. Multipart parsing reads into memory.
	if err := r.ParseMultipartForm(256 << 20); err != nil {
		writeError(w, http.StatusBadRequest, "bad_form", err.Error())
		return
	}
	form := r.MultipartForm
	if form == nil || len(form.File["files"]) == 0 {
		writeError(w, http.StatusBadRequest, "no_files", "expected one or more zip files in the 'files' field")
		return
	}

	// Aggregate every file we find across every uploaded zip into one
	// virtual filesystem keyed by full path. Later passes scan it for
	// ModInfo.xml entries to identify mods.
	virt := map[string][]byte{}
	for _, hdr := range form.File["files"] {
		f, err := hdr.Open()
		if err != nil {
			writeError(w, http.StatusBadRequest, "open_upload", hdr.Filename+": "+err.Error())
			return
		}
		raw, err := io.ReadAll(io.LimitReader(f, 512<<20))
		f.Close()
		if err != nil {
			writeError(w, http.StatusBadRequest, "read_upload", hdr.Filename+": "+err.Error())
			return
		}
		// Treat each upload as a zip; recursive extract handles nesting.
		got, err := extractZipRecursive(raw, 3)
		if err != nil {
			writeError(w, http.StatusBadRequest, "bad_zip", hdr.Filename+": "+err.Error())
			return
		}
		// Merge into the aggregate. Prefix with the upload filename
		// (without ext) when ModInfo.xml ends up at root, so synthetic
		// names are unique across multiple top-level uploads.
		for k, v := range got {
			virt[k] = v
		}
	}

	// Find every mod (directory containing ModInfo.xml).
	mods := detectModsFromVirtFS(virt)
	if len(mods) == 0 {
		writeJSON(w, http.StatusOK, map[string]any{
			"ok":           true,
			"mods_found":   0,
			"results":      []any{},
			"warning":      "No ModInfo.xml found in the uploaded archive(s) — check that the zip contains 7DTD-format mods.",
		})
		return
	}

	// Read the existing /game/Mods/ folder list to detect conflicts.
	existing := map[string]bool{}
	{
		// Fall back gracefully — if /game/Mods/ doesn't exist yet we
		// treat the install as the first.
		conn := h.registry.get(agentID)
		if conn != nil {
			corrID := newCorrelationID("fslist")
			_ = conn.Send(&panelv1.ControllerEnvelope{
				CorrelationId: corrID,
				SentAt:        timestamppb.Now(),
				Payload: &panelv1.ControllerEnvelope_FsList{
					FsList: &panelv1.FsListRequest{InstanceId: id, Path: "/game/Mods"},
				},
			})
			if env, err := h.pending.Await(r.Context(), corrID, 8*time.Second); err == nil {
				if res := env.GetFsListResult(); res != nil && res.Error == "" {
					for _, e := range res.Entries {
						if e.IsDir {
							existing[e.Name] = true
						}
					}
				}
			}
		}
	}

	// Log entry — every upload is interesting in journalctl.
	totalFiles := 0
	for _, m := range mods {
		totalFiles += len(m.Files)
	}
	h.log.Info("7dtd mod upload started",
		"instance_id", id,
		"agent_id", agentID,
		"mods_detected", len(mods),
		"total_files", totalFiles,
	)

	// Detach the install work from the HTTP request lifecycle. A user
	// closing the browser, hitting refresh, or a flaky proxy must NOT
	// cancel in-flight FS writes — when r.Context() cancels mid-stream
	// the gRPC bidi Send returns ctx-canceled and the *agent* drops its
	// connection too, leaving mods half-installed and the file browser
	// flashing "agent not connected" for 30s while the agent reconnects.
	// 10-minute ceiling is plenty for the 256 MB upload cap.
	installCtx, installCancel := context.WithTimeout(context.Background(), 10*time.Minute)
	defer installCancel()

	results := make([]map[string]any, 0, len(mods))
	tStart := time.Now()
	for name, m := range mods {
		entry := map[string]any{
			"name":         name,
			"display_name": m.Info.DisplayName,
			"version":      m.Info.Version,
			"author":       m.Info.Author,
			"file_count":   len(m.Files),
		}
		if existing[name] && !overwrite {
			entry["status"] = "skipped"
			entry["note"] = "already installed at /game/Mods/" + name
			results = append(results, entry)
			h.log.Info("7dtd mod skipped (already installed)", "instance_id", id, "mod", name)
			continue
		}
		// Parallelized write — fire `fsWriteWorkers` FsWrite RPCs at
		// once. The agent dispatches each envelope in its own goroutine
		// and the FS sidecar's exec calls are independent. Worker count
		// kept low so we don't flood the bidi stream's flow-control
		// window — too many in-flight FsWrites with multi-MB payloads
		// triggered keepalive timeouts that crashed the agent stream.
		modStart := time.Now()
		writeErr := installModParallel(installCtx, h, agentID, id, name, m.Files)
		dur := time.Since(modStart).Round(time.Millisecond)
		if writeErr != nil {
			entry["status"] = "failed"
			entry["error"] = writeErr.Error()
			h.log.Warn("7dtd mod install failed", "instance_id", id, "mod", name, "err", writeErr)
		} else {
			entry["status"] = "installed"
			entry["duration_ms"] = dur.Milliseconds()
			h.log.Info("7dtd mod installed", "instance_id", id, "mod", name, "files", len(m.Files), "took", dur)
		}
		results = append(results, entry)
	}
	h.log.Info("7dtd mod upload finished",
		"instance_id", id,
		"mods_detected", len(mods),
		"total_files", totalFiles,
		"took", time.Since(tStart).Round(time.Millisecond),
	)

	writeJSON(w, http.StatusOK, map[string]any{
		"ok":         true,
		"mods_found": len(mods),
		"results":    results,
	})
}

// installModParallel — fans out FsWrite calls across `fsWriteWorkers`
// goroutines so a single mod with thousands of files installs in
// seconds instead of minutes. First error short-circuits the rest;
// outstanding writers are cancelled via the shared context.
//
// Worker count picked deliberately: at 10 the bidi stream's flow-
// control window saturated under multi-MB envelopes and the gRPC
// keepalive timed the agent out mid-upload. 4 keeps the throughput
// win without flooding the wire.
const fsWriteWorkers = 4

func installModParallel(ctx context.Context, h *httpServer, agentID, instanceID, modName string, files map[string][]byte) error {
	if len(files) == 0 {
		return nil
	}
	jobs := make(chan struct {
		rel     string
		content []byte
	}, len(files))
	for rel, content := range files {
		jobs <- struct {
			rel     string
			content []byte
		}{rel, content}
	}
	close(jobs)

	wctx, cancel := context.WithCancel(ctx)
	defer cancel()

	var firstErr atomic.Value // error
	var wg sync.WaitGroup
	for i := 0; i < fsWriteWorkers; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for job := range jobs {
				if wctx.Err() != nil {
					return
				}
				dest := path.Join("/game/Mods", modName, job.rel)
				if err := h.fsWriteToAgent(wctx, agentID, instanceID, dest, job.content); err != nil {
					if firstErr.Load() == nil {
						firstErr.Store(fmt.Errorf("write %s: %w", dest, err))
						cancel()
					}
					return
				}
			}
		}()
	}
	wg.Wait()
	if e := firstErr.Load(); e != nil {
		return e.(error)
	}
	return nil
}

// detectedMod captures one mod's payload after zip extraction.
type detectedMod struct {
	Files map[string][]byte // path-relative-to-mod-root → contents
	Info  modInfo
}

type modInfo struct {
	Name        string
	DisplayName string
	Version     string
	Author      string
}

// extractZipRecursive — flattens one zip into a {path: content} map,
// recursively descending into any nested .zip entries up to maxDepth.
// Stops if a single archive exceeds 1 GB extracted to keep memory
// usage bounded against zip-bomb attempts.
func extractZipRecursive(raw []byte, maxDepth int) (map[string][]byte, error) {
	out := map[string][]byte{}
	const totalCap = int64(1 << 30) // 1 GB
	var totalBytes int64
	var rec func(payload []byte, prefix string, depth int) error
	rec = func(payload []byte, prefix string, depth int) error {
		if depth > maxDepth {
			return fmt.Errorf("zip nesting too deep (>%d levels)", maxDepth)
		}
		zr, err := zip.NewReader(bytes.NewReader(payload), int64(len(payload)))
		if err != nil {
			return err
		}
		for _, f := range zr.File {
			if f.FileInfo().IsDir() {
				continue
			}
			rc, err := f.Open()
			if err != nil {
				return err
			}
			data, err := io.ReadAll(io.LimitReader(rc, totalCap))
			rc.Close()
			if err != nil {
				return err
			}
			totalBytes += int64(len(data))
			if totalBytes > totalCap {
				return fmt.Errorf("upload exceeded 1 GB extraction cap")
			}
			fullPath := f.Name
			if prefix != "" {
				fullPath = prefix + "/" + fullPath
			}
			fullPath = strings.TrimPrefix(fullPath, "/")
			// Nested zip? Recurse — keep its contents at the same
			// relative depth as the outer zip's directory.
			if strings.HasSuffix(strings.ToLower(f.Name), ".zip") {
				nestedPrefix := path.Dir(fullPath)
				if nestedPrefix == "." {
					nestedPrefix = ""
				}
				if err := rec(data, nestedPrefix, depth+1); err != nil {
					// Don't fail the whole upload on a bad inner zip;
					// keep the original .zip as a passthrough so it's
					// at least visible to the operator.
					out[fullPath] = data
				}
				continue
			}
			out[fullPath] = data
		}
		return nil
	}
	if err := rec(raw, "", 0); err != nil {
		return nil, err
	}
	return out, nil
}

// detectModsFromVirtFS — walks the post-extraction virtual filesystem
// for files named "ModInfo.xml" (case-insensitive). Each one's parent
// directory becomes a mod; all files under that directory form the
// mod's payload. The mod's identifier is the parent directory name
// (which is what 7DTD uses as the mod folder name in /game/Mods/).
//
// Special-cases ModInfo.xml at the zip's root — that means the entire
// zip IS one mod. Synthesize a name from the ModInfo's <Name> attribute.
func detectModsFromVirtFS(files map[string][]byte) map[string]detectedMod {
	mods := map[string]detectedMod{}
	for fp, content := range files {
		base := path.Base(fp)
		if !strings.EqualFold(base, "ModInfo.xml") {
			continue
		}
		parent := path.Dir(fp)
		info := parseModInfo(content)
		var modName string
		var prefix string
		if parent == "." || parent == "" {
			// ModInfo.xml at zip root → entire zip is one mod, synthesize.
			modName = sanitizeModFolderName(info.Name)
			if modName == "" {
				modName = "ImportedMod"
			}
			prefix = ""
		} else {
			modName = path.Base(parent)
			prefix = parent + "/"
		}
		// Collect every file under prefix. When prefix == "" (the zip
		// has ModInfo.xml at its root, i.e. the entire archive is one
		// mod) we still want subdirectories like Config/, ClaimCreator/,
		// WebMod/ etc. — anything that ISN'T inside another mod's
		// directory. A "another mod's directory" is one whose own
		// ModInfo.xml lives below it. Build a set of those mod-root
		// directories so we can exclude them, then take everything else.
		modFiles := map[string][]byte{}
		if prefix == "" {
			otherRoots := map[string]bool{}
			for k := range files {
				if strings.EqualFold(path.Base(k), "ModInfo.xml") {
					p := path.Dir(k)
					if p != "." && p != "" {
						// Top-level segment of this other mod's path.
						top := p
						if i := strings.Index(top, "/"); i >= 0 {
							top = top[:i]
						}
						otherRoots[top] = true
					}
				}
			}
			for k, v := range files {
				top := k
				if i := strings.Index(top, "/"); i >= 0 {
					top = top[:i]
				}
				if otherRoots[top] {
					continue
				}
				modFiles[k] = v
			}
		} else {
			for k, v := range files {
				if strings.HasPrefix(k, prefix) {
					modFiles[strings.TrimPrefix(k, prefix)] = v
				}
			}
		}
		mods[modName] = detectedMod{Files: modFiles, Info: info}
	}
	return mods
}

// parseModInfo — pulls Name/DisplayName/Version/Author from a 7DTD
// ModInfo.xml. The format has gone through revisions:
//   <Name value="X" />              ← classic
//   <Name>X</Name>                  ← alternate
// Both are handled.
var (
	reMIName        = regexp.MustCompile(`<Name\s+value="([^"]+)"`)
	reMINameAlt     = regexp.MustCompile(`<Name>\s*([^<]+?)\s*</Name>`)
	reMIDisplayName = regexp.MustCompile(`<(?:DisplayName|Display)\s+value="([^"]+)"`)
	reMIDisplayAlt  = regexp.MustCompile(`<DisplayName>\s*([^<]+?)\s*</DisplayName>`)
	reMIVersion     = regexp.MustCompile(`<Version\s+value="([^"]+)"`)
	reMIVersionAlt  = regexp.MustCompile(`<Version>\s*([^<]+?)\s*</Version>`)
	reMIAuthor      = regexp.MustCompile(`<Author\s+value="([^"]+)"`)
	reMIAuthorAlt   = regexp.MustCompile(`<Author>\s*([^<]+?)\s*</Author>`)
)

func parseModInfo(content []byte) modInfo {
	out := modInfo{}
	pick := func(re, alt *regexp.Regexp) string {
		if m := re.FindSubmatch(content); len(m) > 1 {
			return strings.TrimSpace(string(m[1]))
		}
		if m := alt.FindSubmatch(content); len(m) > 1 {
			return strings.TrimSpace(string(m[1]))
		}
		return ""
	}
	out.Name = pick(reMIName, reMINameAlt)
	out.DisplayName = pick(reMIDisplayName, reMIDisplayAlt)
	out.Version = pick(reMIVersion, reMIVersionAlt)
	out.Author = pick(reMIAuthor, reMIAuthorAlt)
	return out
}

// =============== AUTO-INSTALL via Nexus Premium API key ===============

// nexusAPIKey returns the panel-admin API key from env. Empty when not
// configured — callers should fall back to the manual download flow.
func nexusAPIKey() string {
	return strings.TrimSpace(os.Getenv("PANEL_NEXUS_API_KEY"))
}

// handleSevenDTDNexusCheck — small probe the frontend uses to decide
// whether to render the "Install" button. Returns whether a key is
// configured + (best effort) whether the user behind it is Premium.
func (h *httpServer) handleSevenDTDNexusCheck(w http.ResponseWriter, r *http.Request) {
	key := nexusAPIKey()
	if key == "" {
		writeJSON(w, http.StatusOK, map[string]any{"configured": false, "premium": false})
		return
	}
	// Validate via /v1/users/validate.json — tiny call, also tells us
	// premium status which gates the /download_link endpoint.
	body, err := nexusAPICall(r.Context(), "/v1/users/validate.json", key, 6*time.Second)
	if err != nil {
		writeJSON(w, http.StatusOK, map[string]any{"configured": true, "premium": false, "error": err.Error()})
		return
	}
	var v struct {
		Name      string `json:"name"`
		Email     string `json:"email"`
		IsPremium bool   `json:"is_premium"`
		IsKeyValid bool  `json:"is_key_valid"`
	}
	_ = json.Unmarshal(body, &v)
	writeJSON(w, http.StatusOK, map[string]any{
		"configured": true,
		"premium":    v.IsPremium,
		"name":       v.Name,
	})
}

// handleSevenDTDNexusFiles — lists file versions for a mod.
func (h *httpServer) handleSevenDTDNexusFiles(w http.ResponseWriter, r *http.Request) {
	key := nexusAPIKey()
	if key == "" {
		writeError(w, http.StatusBadRequest, "no_key", "PANEL_NEXUS_API_KEY env var not set on the controller")
		return
	}
	modID := strings.TrimSpace(r.PathValue("modId"))
	if _, err := strconv.Atoi(modID); err != nil {
		writeError(w, http.StatusBadRequest, "bad_id", "mod id must be numeric")
		return
	}
	body, err := nexusAPICall(r.Context(),
		fmt.Sprintf("/v1/games/%s/mods/%s/files.json?category=main", sevenDTDNexusGameSlug, modID),
		key, 12*time.Second)
	if err != nil {
		writeError(w, http.StatusBadGateway, "nexus", err.Error())
		return
	}
	var resp struct {
		Files []struct {
			FileID         int    `json:"file_id"`
			Name           string `json:"name"`
			Version        string `json:"version"`
			Category       string `json:"category_name"`
			IsPrimary      bool   `json:"is_primary"`
			Size           int64  `json:"size_in_bytes"`
			UploadedTime   string `json:"uploaded_time"`
			Description    string `json:"description"`
			ContentPreview string `json:"content_preview_link"`
		} `json:"files"`
	}
	if err := json.Unmarshal(body, &resp); err != nil {
		writeError(w, http.StatusBadGateway, "decode", err.Error())
		return
	}
	out := make([]map[string]any, 0, len(resp.Files))
	for _, f := range resp.Files {
		out = append(out, map[string]any{
			"file_id":      f.FileID,
			"name":         f.Name,
			"version":      f.Version,
			"category":     f.Category,
			"is_primary":   f.IsPrimary,
			"size":         f.Size,
			"uploaded_at":  f.UploadedTime,
			"description":  f.Description,
		})
	}
	writeJSON(w, http.StatusOK, map[string]any{"files": out})
}

// handleSevenDTDNexusInstall — the install endpoint. Body shape:
//
//	{ "mod_id": <int>, "file_id": <int>, "name": "<optional folder name>" }
//
// Pipeline: fetch download_link → download zip → unzip in memory →
// for each non-empty file, FsWrite to /game/Mods/<name>/<relpath>.
// No agent changes; uses existing FsWriteRequest infra.
func (h *httpServer) handleSevenDTDNexusInstall(w http.ResponseWriter, r *http.Request) {
	key := nexusAPIKey()
	if key == "" {
		writeError(w, http.StatusBadRequest, "no_key", "PANEL_NEXUS_API_KEY env var not set on the controller")
		return
	}
	id := r.PathValue("id")
	agentID, err := h.lookupInstance(r, id)
	if err != nil {
		writeError(w, http.StatusNotFound, "not_found", err.Error())
		return
	}
	var req struct {
		ModID  int    `json:"mod_id"`
		FileID int    `json:"file_id"`
		Name   string `json:"name"` // optional override for the /game/Mods/<name>/ folder
	}
	if err := json.NewDecoder(io.LimitReader(r.Body, 4*1024)).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "bad_json", err.Error())
		return
	}
	if req.ModID == 0 || req.FileID == 0 {
		writeError(w, http.StatusBadRequest, "missing_fields", "mod_id and file_id are required")
		return
	}

	// 1. Resolve the actual download URL via Nexus's API.
	dlBody, err := nexusAPICall(r.Context(),
		fmt.Sprintf("/v1/games/%s/mods/%d/files/%d/download_link.json", sevenDTDNexusGameSlug, req.ModID, req.FileID),
		key, 12*time.Second)
	if err != nil {
		// 401/403 here usually means "you're not Premium and you didn't
		// supply NXM key/expires/user_id query params" — surface plainly.
		writeError(w, http.StatusBadGateway, "no_download_link",
			"Nexus refused the download URL request — your API key probably doesn't belong to a Premium account: "+err.Error())
		return
	}
	var dl []struct {
		Name string `json:"name"`
		URI  string `json:"URI"`
	}
	if err := json.Unmarshal(dlBody, &dl); err != nil || len(dl) == 0 {
		writeError(w, http.StatusBadGateway, "decode", "download_link response empty or unparseable")
		return
	}
	zipURL := dl[0].URI

	// 2. Download the zip server-side. Cap at 1 GB to avoid runaway mem.
	zipBytes, err := nexusFetchAuth(r.Context(), zipURL, "", 5*time.Minute, 1<<30)
	if err != nil {
		writeError(w, http.StatusBadGateway, "download", err.Error())
		return
	}

	// 3. Unzip in memory. Determine the target folder name: explicit
	// override > zip's top-level dir > "mod-<modid>".
	zr, err := zip.NewReader(bytes.NewReader(zipBytes), int64(len(zipBytes)))
	if err != nil {
		writeError(w, http.StatusBadGateway, "unzip", err.Error())
		return
	}
	target := strings.TrimSpace(req.Name)
	if target == "" {
		target = guessZipTopFolder(zr)
	}
	if target == "" {
		target = fmt.Sprintf("mod-%d", req.ModID)
	}
	target = sanitizeModFolderName(target)

	// 4. Stream every entry to the agent via FsWrite. The agent's
	// FsWriteRequest takes (instance_id, path, content) and writes via
	// the lazily-spawned FS sidecar. We strip a single shared top-level
	// directory if every entry has one (so unzipping "MyMod-v2/MyMod/..."
	// doesn't double-nest).
	stripPrefix := commonZipPrefix(zr)
	written := 0
	totalBytes := int64(0)
	for _, f := range zr.File {
		rel := f.Name
		if stripPrefix != "" && strings.HasPrefix(rel, stripPrefix) {
			rel = rel[len(stripPrefix):]
		}
		if rel == "" || strings.HasSuffix(rel, "/") {
			continue // skip directories — FsWrite creates parents on its own
		}
		// Defensive: refuse path traversal.
		if strings.Contains(rel, "..") || strings.HasPrefix(rel, "/") {
			writeError(w, http.StatusBadRequest, "bad_zip_entry", "rejected suspicious zip entry: "+f.Name)
			return
		}
		rc, err := f.Open()
		if err != nil {
			writeError(w, http.StatusBadGateway, "zip_read", err.Error())
			return
		}
		content, err := io.ReadAll(rc)
		rc.Close()
		if err != nil {
			writeError(w, http.StatusBadGateway, "zip_read", err.Error())
			return
		}
		dest := path.Join("/game/Mods", target, rel)
		if err := h.fsWriteToAgent(r.Context(), agentID, id, dest, content); err != nil {
			writeError(w, http.StatusBadGateway, "fs_write", "write "+dest+": "+err.Error())
			return
		}
		written++
		totalBytes += int64(len(content))
	}

	writeJSON(w, http.StatusOK, map[string]any{
		"ok":             true,
		"folder":         target,
		"files_written":  written,
		"bytes_written":  totalBytes,
		"target_path":    "/game/Mods/" + target,
	})
}

// fsWriteToAgent — small wrapper that sends an FsWriteRequest to a
// specific agent and waits for the FsWriteResult. Errors fold into a
// single returned error for simpler caller code.
//
// Reconnect-aware: if the agent stream blips mid-upload (the gRPC
// bidi Send returns Canceled / Unavailable, or the agent isn't in the
// registry), we wait briefly for the agent to reconnect and retry.
// FsWrite is idempotent at the path level, so a re-send after a dead
// stream is safe even if the agent had partially processed the prior
// envelope. Bounded by `maxAttempts` and the caller-supplied ctx.
func (h *httpServer) fsWriteToAgent(ctx context.Context, agentID, instanceID, p string, content []byte) error {
	const maxAttempts = 4
	var lastErr error
	for attempt := 1; attempt <= maxAttempts; attempt++ {
		if err := ctx.Err(); err != nil {
			return err
		}
		conn := h.registry.get(agentID)
		if conn == nil {
			if attempt < maxAttempts && waitForAgentConnect(ctx, h.registry, agentID, 45*time.Second) {
				continue
			}
			return fmt.Errorf("agent %q not connected", agentID)
		}
		corrID := newCorrelationID("fswrite")
		if sendErr := conn.Send(&panelv1.ControllerEnvelope{
			CorrelationId: corrID,
			SentAt:        timestamppb.Now(),
			Payload: &panelv1.ControllerEnvelope_FsWrite{
				FsWrite: &panelv1.FsWriteRequest{
					InstanceId: instanceID,
					Path:       p,
					Content:    content,
				},
			},
		}); sendErr != nil {
			lastErr = fmt.Errorf("send: %w", sendErr)
			if attempt < maxAttempts && isTransientStreamErr(sendErr) {
				h.log.Warn("fs_write send failed, awaiting agent reconnect",
					"agent_id", agentID, "path", p, "attempt", attempt, "err", sendErr)
				if waitForAgentConnect(ctx, h.registry, agentID, 45*time.Second) {
					continue
				}
			}
			return lastErr
		}
		env, err := h.pending.Await(ctx, corrID, 60*time.Second)
		if err != nil {
			lastErr = fmt.Errorf("await: %w", err)
			if attempt < maxAttempts && ctx.Err() == nil && isTransientAwaitErr(err) {
				h.log.Warn("fs_write await failed, awaiting agent reconnect",
					"agent_id", agentID, "path", p, "attempt", attempt, "err", err)
				if waitForAgentConnect(ctx, h.registry, agentID, 45*time.Second) {
					continue
				}
			}
			return lastErr
		}
		res := env.GetFsWriteResult()
		if res == nil {
			return fmt.Errorf("agent reply missing fs_write_result")
		}
		if res.Error != "" {
			return fmt.Errorf("%s", res.Error)
		}
		return nil
	}
	return lastErr
}

// waitForAgentConnect — polls the agent registry until `agentID` is
// registered, the caller's ctx fires, or `maxWait` elapses. Returns
// true only on a successful (re)connect. Used by the FsWrite retry
// loop so a 30s agent reconnect doesn't fail an in-flight install.
func waitForAgentConnect(ctx context.Context, registry *agentRegistry, agentID string, maxWait time.Duration) bool {
	deadline := time.Now().Add(maxWait)
	t := time.NewTicker(500 * time.Millisecond)
	defer t.Stop()
	for {
		if registry.get(agentID) != nil {
			return true
		}
		if time.Now().After(deadline) {
			return false
		}
		select {
		case <-ctx.Done():
			return false
		case <-t.C:
		}
	}
}

// isTransientStreamErr — true when a conn.Send error looks like the
// gRPC bidi stream died (cancellation/transport close), which means
// retrying after the agent reconnects is safe and worth attempting.
func isTransientStreamErr(err error) bool {
	if err == nil {
		return false
	}
	s := err.Error()
	return strings.Contains(s, "context canceled") ||
		strings.Contains(s, "Canceled") ||
		strings.Contains(s, "Unavailable") ||
		strings.Contains(s, "transport is closing") ||
		strings.Contains(s, "broken pipe")
}

// isTransientAwaitErr — true when pending.Await failed for a reason
// that's plausibly the agent stream dying (its own timeout firing, or
// a cancel-shaped error). Caller is expected to also confirm its own
// ctx is still alive before retrying.
func isTransientAwaitErr(err error) bool {
	if err == nil {
		return false
	}
	if isTransientStreamErr(err) {
		return true
	}
	return strings.Contains(err.Error(), "timeout waiting for agent response")
}

// guessZipTopFolder — if every non-empty entry shares a single top-
// level directory, return that name (without trailing slash); else "".
func guessZipTopFolder(zr *zip.Reader) string {
	prefix := commonZipPrefix(zr)
	if prefix == "" {
		return ""
	}
	return strings.TrimSuffix(prefix, "/")
}

func commonZipPrefix(zr *zip.Reader) string {
	var prefix string
	for _, f := range zr.File {
		name := f.Name
		idx := strings.Index(name, "/")
		if idx < 0 {
			return "" // an entry at the zip root → no common prefix
		}
		top := name[:idx+1]
		if prefix == "" {
			prefix = top
			continue
		}
		if top != prefix {
			return ""
		}
	}
	return prefix
}

// sanitizeModFolderName — keep only characters that are safe on Linux
// filesystems and reasonable for 7DTD's Mods/ scanner. Replaces anything
// outside [A-Za-z0-9_-] with underscore. Trims to a sensible length.
func sanitizeModFolderName(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':
			b.WriteRune(r)
		case r == '_' || r == '-' || r == '.':
			b.WriteRune(r)
		default:
			b.WriteRune('_')
		}
	}
	out := b.String()
	if len(out) > 80 {
		out = out[:80]
	}
	return out
}

// nexusAPICall — GET helper for api.nexusmods.com with the apikey header.
func nexusAPICall(ctx context.Context, p, apiKey string, timeout time.Duration) ([]byte, error) {
	u := "https://api.nexusmods.com" + p
	req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
	if err != nil {
		return nil, err
	}
	req.Header.Set("apikey", apiKey)
	req.Header.Set("Accept", "application/json")
	req.Header.Set("User-Agent", version.UserAgent())
	cli := &http.Client{Timeout: timeout}
	res, err := cli.Do(req)
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()
	if res.StatusCode >= 400 {
		body, _ := io.ReadAll(io.LimitReader(res.Body, 2048))
		return nil, fmt.Errorf("nexus %d: %s", res.StatusCode, strings.TrimSpace(string(body)))
	}
	return io.ReadAll(io.LimitReader(res.Body, 2*1024*1024))
}

// nexusFetchAuth — large-payload fetch (zips up to 1 GB) from a
// Nexus-signed CDN URL. Doesn't send the API key (CDN URLs are pre-
// signed). Times out generously since file downloads can be slow.
func nexusFetchAuth(ctx context.Context, u, _ string, timeout time.Duration, maxBytes int64) ([]byte, error) {
	req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
	if err != nil {
		return nil, err
	}
	req.Header.Set("User-Agent", version.UserAgent())
	cli := &http.Client{Timeout: timeout}
	res, err := cli.Do(req)
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()
	if res.StatusCode >= 400 {
		body, _ := io.ReadAll(io.LimitReader(res.Body, 1024))
		return nil, fmt.Errorf("download %d: %s", res.StatusCode, strings.TrimSpace(string(body)))
	}
	return io.ReadAll(io.LimitReader(res.Body, maxBytes))
}
