package main

// ARK: Survival Ascended mod manager.
//
// ASA ships its own built-in CurseForge downloader — the dedicated
// server reads `-mods=<comma-id-list>` / `-passivemods=<comma-id-list>`
// from the launch command and pulls each mod from CurseForge on first
// start. This is fundamentally different from DayZ: no SteamCMD, no
// sidecar, no async job queue. All we do on the panel side is:
//
//   1. Maintain the instance's `MOD_IDS` (active, client-visible) and
//      `PASSIVE_MODS` (server-side balance/tools) env vars via the
//      persisted `config_values` column.
//   2. Swap the container via the usual stop → delete (preserve
//      volumes) → recreate → start dance when the list changes, so
//      the new env takes effect.
//
// CurseForge's official API requires an account + key. Their website
// itself is cloudflare-protected. The community-run proxy
// `api.curse.tools` exposes the same JSON, no key required, and returns
// everything we need (title, summary, logo, screenshots, authors, file
// sizes, popularity rank). The panel funnels all mod searches + detail
// lookups through it — the acekorneya image still downloads binaries
// directly from CurseForge CDN on server start.
//
// ARK:SA's CurseForge game id is 83374.

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"regexp"
	"sort"
	"strconv"
	"strings"
	"time"

	"github.com/dbledeez/panel/controller/internal/db"
	panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)

const (
	arkCurseGameID      = "83374"
	arkCurseToolsSearch = "https://api.curse.tools/v1/cf/mods/search"
	arkCurseToolsDetail = "https://api.curse.tools/v1/cf/mods/"
	// sortField=6 (LastUpdated) → results prioritise actively-maintained
	// mods, which is what most server operators want when browsing. The
	// user can refine the query text to pull up obscure mods instead.
	arkCurseToolsSortField = "6"
)

type arkModsListResp struct {
	Mods     []arkModEntry `json:"mods"`
	Active   string        `json:"active_csv"`  // comma-joined active mod ids
	Passive  string        `json:"passive_csv"` // comma-joined passive mod ids
}

type arkModEntry struct {
	ID       string `json:"id"`         // CurseForge project id as a string
	Passive  bool   `json:"passive"`    // true if in -passivemods= list
	Order    int    `json:"order"`      // position in the CSV (0-based)
	Title    string `json:"title,omitempty"`
	LogoURL  string `json:"logo_url,omitempty"`
	Summary  string `json:"summary,omitempty"`
	Author   string `json:"author,omitempty"`
}

type arkInstallReq struct {
	ModID   string `json:"mod_id"`   // numeric CF project id OR full URL
	Passive bool   `json:"passive,omitempty"`
}

// ---- list / install / uninstall ----

// handleArkModsList returns the instance's current mod lists with
// hydrated metadata from CurseForge. The UI renders this as the
// "Installed mods" section beneath the hero search.
func (h *httpServer) handleArkModsList(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
	}
	row, err := h.loadArkInstance(r.Context(), id)
	if err != nil {
		writeError(w, http.StatusBadRequest, "bad_module", err.Error())
		return
	}
	active := splitModCSV(row.ConfigValues["MOD_IDS"])
	passive := splitModCSV(row.ConfigValues["PASSIVE_MODS"])

	// Hydrate metadata in one batched call. curse.tools' search endpoint
	// can match by ids via the `modIds` param, but the simplest cross-
	// compat path is to fetch each mod's detail concurrently — ASA mod
	// lists are almost always <20 entries in practice.
	entries := make([]arkModEntry, 0, len(active)+len(passive))
	for i, m := range active {
		entries = append(entries, arkModEntry{ID: m, Order: i})
	}
	for i, m := range passive {
		entries = append(entries, arkModEntry{ID: m, Order: i, Passive: true})
	}
	if len(entries) > 0 {
		hydrateArkMods(r.Context(), entries)
	}
	writeJSON(w, http.StatusOK, arkModsListResp{
		Mods:    entries,
		Active:  strings.Join(active, ","),
		Passive: strings.Join(passive, ","),
	})
}

// handleArkModInstall appends a mod to the active (or passive) list
// and recreates the container. Duplicate adds are no-ops (returns
// ok=true, duplicate=true without triggering a recreate).
func (h *httpServer) handleArkModInstall(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
	}
	row, err := h.loadArkInstance(r.Context(), id)
	if err != nil {
		writeError(w, http.StatusBadRequest, "bad_module", err.Error())
		return
	}
	var req arkInstallReq
	if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "bad_json", err.Error())
		return
	}
	modID, err := parseArkModID(req.ModID)
	if err != nil {
		writeError(w, http.StatusBadRequest, "bad_mod_id", err.Error())
		return
	}

	active := splitModCSV(row.ConfigValues["MOD_IDS"])
	passive := splitModCSV(row.ConfigValues["PASSIVE_MODS"])
	if containsString(active, modID) || containsString(passive, modID) {
		writeJSON(w, http.StatusOK, map[string]any{"ok": true, "duplicate": true, "mod_id": modID})
		return
	}
	if req.Passive {
		passive = append(passive, modID)
	} else {
		active = append(active, modID)
	}
	if err := h.recreateArkWithMods(r.Context(), row, agentID, active, passive); err != nil {
		writeError(w, http.StatusBadGateway, "recreate", err.Error())
		return
	}
	// Best-effort: fetch title for the success toast.
	title := modID
	if d, derr := fetchArkModDetail(modID); derr == nil && d.Name != "" {
		title = d.Name
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"ok":       true,
		"mod_id":   modID,
		"title":    title,
		"passive":  req.Passive,
	})
}

// handleArkModUninstall removes a mod from whichever list it lives in
// and recreates the container.
func (h *httpServer) handleArkModUninstall(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
	}
	row, err := h.loadArkInstance(r.Context(), id)
	if err != nil {
		writeError(w, http.StatusBadRequest, "bad_module", err.Error())
		return
	}
	modID := strings.TrimSpace(r.PathValue("modId"))
	if modID == "" {
		writeError(w, http.StatusBadRequest, "bad_mod_id", "mod id is required")
		return
	}
	active := removeString(splitModCSV(row.ConfigValues["MOD_IDS"]), modID)
	passive := removeString(splitModCSV(row.ConfigValues["PASSIVE_MODS"]), modID)
	if err := h.recreateArkWithMods(r.Context(), row, agentID, active, passive); err != nil {
		writeError(w, http.StatusBadGateway, "recreate", err.Error())
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"ok": true, "mod_id": modID})
}

// handleArkModsReorder accepts full replacement lists (active + passive)
// and recreates once. Used by the UI's drag-to-reorder and the
// "move between active/passive" affordance.
func (h *httpServer) handleArkModsReorder(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
	}
	row, err := h.loadArkInstance(r.Context(), id)
	if err != nil {
		writeError(w, http.StatusBadRequest, "bad_module", err.Error())
		return
	}
	var req struct {
		Active  []string `json:"active"`
		Passive []string `json:"passive"`
	}
	if err := json.NewDecoder(io.LimitReader(r.Body, 32*1024)).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "bad_json", err.Error())
		return
	}
	active := dedupStrings(req.Active)
	passive := dedupStrings(req.Passive)
	if err := h.recreateArkWithMods(r.Context(), row, agentID, active, passive); err != nil {
		writeError(w, http.StatusBadGateway, "recreate", err.Error())
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}

// handleArkModsBatch applies multiple mod changes in a single recreate.
// Body: {adds:[{mod_id,passive}...], removes:[mod_id...], active_order:[...]?, passive_order:[...]?}.
//   - adds with duplicate ids are silently skipped
//   - removes are applied first, then adds, then ordering (if provided)
//   - exactly one stop→delete→create→start cycle on the agent
//
// This replaces N round-trips × N recreates from per-mod install/uninstall
// for cases where the operator stages a batch of changes in the UI.
func (h *httpServer) handleArkModsBatch(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
	}
	row, err := h.loadArkInstance(r.Context(), id)
	if err != nil {
		writeError(w, http.StatusBadRequest, "bad_module", err.Error())
		return
	}
	var req struct {
		Adds         []arkInstallReq `json:"adds"`
		Removes      []string        `json:"removes"`
		ActiveOrder  []string        `json:"active_order"`
		PassiveOrder []string        `json:"passive_order"`
	}
	if err := json.NewDecoder(io.LimitReader(r.Body, 64*1024)).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "bad_json", err.Error())
		return
	}

	active := splitModCSV(row.ConfigValues["MOD_IDS"])
	passive := splitModCSV(row.ConfigValues["PASSIVE_MODS"])

	// 1) Removes first.
	for _, raw := range req.Removes {
		mid := strings.TrimSpace(raw)
		if mid == "" {
			continue
		}
		active = removeString(active, mid)
		passive = removeString(passive, mid)
	}

	// 2) Adds (parsed + deduped against the lists post-remove).
	addedTitles := make([]string, 0, len(req.Adds))
	for _, a := range req.Adds {
		modID, err := parseArkModID(a.ModID)
		if err != nil {
			writeError(w, http.StatusBadRequest, "bad_mod_id", fmt.Sprintf("%s: %s", a.ModID, err.Error()))
			return
		}
		if containsString(active, modID) || containsString(passive, modID) {
			continue
		}
		if a.Passive {
			passive = append(passive, modID)
		} else {
			active = append(active, modID)
		}
		addedTitles = append(addedTitles, modID)
	}

	// 3) Ordering — if the client sent explicit order arrays, use them as
	// the final order, but only honour ids that are actually in the
	// current lists (defensive against stale state). Anything missing
	// from the order falls through to the end in arrival order.
	if len(req.ActiveOrder) > 0 {
		active = applyExplicitOrder(active, req.ActiveOrder)
	}
	if len(req.PassiveOrder) > 0 {
		passive = applyExplicitOrder(passive, req.PassiveOrder)
	}

	active = dedupStrings(active)
	passive = dedupStrings(passive)

	// Skip the recreate entirely if nothing actually changed.
	curA := splitModCSV(row.ConfigValues["MOD_IDS"])
	curP := splitModCSV(row.ConfigValues["PASSIVE_MODS"])
	if stringSlicesEqual(curA, active) && stringSlicesEqual(curP, passive) {
		writeJSON(w, http.StatusOK, map[string]any{"ok": true, "no_changes": true})
		return
	}

	if err := h.recreateArkWithMods(r.Context(), row, agentID, active, passive); err != nil {
		writeError(w, http.StatusBadGateway, "recreate", err.Error())
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"ok":            true,
		"active":        active,
		"passive":       passive,
		"added":         addedTitles,
		"removed_count": len(req.Removes),
	})
}

// applyExplicitOrder returns `current` reordered to match the order
// implied by `desired`, with any current-but-not-in-desired entries
// appended at the end in their original position.
func applyExplicitOrder(current, desired []string) []string {
	in := make(map[string]bool, len(current))
	for _, s := range current {
		in[s] = true
	}
	seen := make(map[string]bool, len(desired))
	out := make([]string, 0, len(current))
	for _, d := range desired {
		if in[d] && !seen[d] {
			out = append(out, d)
			seen[d] = true
		}
	}
	for _, s := range current {
		if !seen[s] {
			out = append(out, s)
			seen[s] = true
		}
	}
	return out
}

func stringSlicesEqual(a, b []string) bool {
	if len(a) != len(b) {
		return false
	}
	for i := range a {
		if a[i] != b[i] {
			return false
		}
	}
	return true
}

// recreateArkWithMods writes the updated MOD_IDS/PASSIVE_MODS env vars
// into config_values and triggers the standard recreate dance. Preserves
// the instance's cluster membership + any other config_values.
func (h *httpServer) recreateArkWithMods(ctx context.Context, row *db.InstanceRow, agentID string, active, passive []string) error {
	conn := h.registry.get(agentID)
	if conn == nil {
		return fmt.Errorf("agent %q not connected", agentID)
	}
	wasRunning := row.Status == "running"
	cctx, cancel := context.WithTimeout(ctx, arkClusterRecreateTimeout)
	defer cancel()

	if wasRunning {
		if err := h.awaitAgentCall(cctx, conn, "stop", &panelv1.ControllerEnvelope_InstanceStop{
			InstanceStop: &panelv1.InstanceStop{InstanceId: row.InstanceID, GraceSeconds: 30},
		}); err != nil {
			return fmt.Errorf("stop: %w", err)
		}
	}
	if err := h.awaitAgentCall(cctx, conn, "delete", &panelv1.ControllerEnvelope_InstanceDelete{
		InstanceDelete: &panelv1.InstanceDelete{InstanceId: row.InstanceID, PurgeVolumes: false},
	}); err != nil {
		return fmt.Errorf("delete: %w", err)
	}

	cfg := map[string]string{}
	for k, v := range row.ConfigValues {
		cfg[k] = v
	}
	cfg["MOD_IDS"] = strings.Join(active, ",")
	cfg["PASSIVE_MODS"] = strings.Join(passive, ",")
	row.ConfigValues = cfg
	row.Status = "creating"
	if err := h.db.UpsertInstance(cctx, *row); err != nil {
		return fmt.Errorf("db upsert: %w", err)
	}

	// Preserve panel-allocated host ports across the recreate so the agent's
	// auto-bumper doesn't re-pick from manifest defaults and collide with
	// sibling instances on the same agent.
	var ports []*panelv1.PortMap
	for name, hp := range row.AssignedPorts {
		ports = append(ports, &panelv1.PortMap{Name: name, HostPort: hp})
	}
	// Suppress agent auto-start if the instance was stopped before this
	// mod-config recreate. Operator changing mods on a stopped server
	// shouldn't see it auto-boot afterwards.
	createCV := cfg
	if !wasRunning {
		createCV = make(map[string]string, len(cfg)+1)
		for k, v := range cfg {
			createCV[k] = v
		}
		createCV["_PANEL_SUPPRESS_AUTOSTART"] = "true"
	}
	// Preserve cluster-mount override if the instance was in a cluster.
	create := &panelv1.InstanceCreate{
		InstanceId:   row.InstanceID,
		ModuleId:     row.ModuleID,
		Version:      row.ModuleVersion,
		RunMode:      panelv1.RunMode_RUN_MODE_DOCKER,
		Ports:        ports,
		ConfigValues: createCV,
	}
	if cid := cfg["CLUSTER_ID"]; cid != "" {
		create.MountOverrides = map[string]string{
			arkClusterDirContainerPath: "$PANEL_DATA_ROOT/arkcluster/" + cid,
		}
	}
	if err := h.awaitAgentCall(cctx, conn, "create", &panelv1.ControllerEnvelope_InstanceCreate{
		InstanceCreate: create,
	}); err != nil {
		return fmt.Errorf("create: %w", err)
	}
	if wasRunning {
		if err := h.awaitAgentCall(cctx, conn, "start", &panelv1.ControllerEnvelope_InstanceStart{
			InstanceStart: &panelv1.InstanceStart{InstanceId: row.InstanceID},
		}); err != nil {
			return fmt.Errorf("start: %w", err)
		}
	}
	return nil
}

// ---- CurseForge proxy (search + detail) ----

// handleArkModSearch proxies a CurseForge search via api.curse.tools.
// We normalise the response shape so the frontend has the exact same
// fields whether it's browsing search results or fetching a detail
// card: id, name, summary, logo_url, author, download_count, updated.
func (h *httpServer) handleArkModSearch(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(arkCurseToolsSearch)
	qs := u.Query()
	qs.Set("gameId", arkCurseGameID)
	qs.Set("searchFilter", q)
	qs.Set("pageSize", "30")
	qs.Set("sortField", arkCurseToolsSortField)
	qs.Set("sortOrder", "desc")
	u.RawQuery = qs.Encode()

	items, err := curseToolsFetchSearch(u.String())
	if err != nil {
		writeError(w, http.StatusBadGateway, "curse", err.Error())
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"items": items, "query": q})
}

// handleArkModDetail fetches a single mod's full metadata — including
// the long HTML description rendered by the mod info modal.
func (h *httpServer) handleArkModDetail(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
	}
	modID, err := parseArkModID(id)
	if err != nil {
		writeError(w, http.StatusBadRequest, "bad_id", err.Error())
		return
	}
	d, err := fetchArkModDetail(modID)
	if err != nil {
		writeError(w, http.StatusBadGateway, "curse", err.Error())
		return
	}
	// Fetch the rendered description separately — the main detail doc
	// carries `summary` (one paragraph) but not the full HTML body.
	desc, _ := fetchArkModDescription(modID)
	out := d.toUI()
	out["description_html"] = desc
	writeJSON(w, http.StatusOK, out)
}

// ---- helpers ----

// curseToolsMod is the subset of the CurseForge mod JSON we care about.
type curseToolsMod struct {
	ID             int    `json:"id"`
	Name           string `json:"name"`
	Slug           string `json:"slug"`
	Summary        string `json:"summary"`
	Status         int    `json:"status"`
	DownloadCount  int64  `json:"downloadCount"`
	GameRank       int    `json:"gamePopularityRank"`
	DateModified   string `json:"dateModified"`
	DateReleased   string `json:"dateReleased"`
	Authors        []struct {
		Name string `json:"name"`
		URL  string `json:"url"`
	} `json:"authors"`
	Logo struct {
		URL string `json:"url"`
	} `json:"logo"`
	Screenshots []struct {
		URL string `json:"url"`
	} `json:"screenshots"`
	LatestFiles []struct {
		FileLength   int64  `json:"fileLength"`
		DisplayName  string `json:"displayName"`
		DateReleased string `json:"fileDate"`
	} `json:"latestFiles"`
	Categories []struct {
		Name string `json:"name"`
	} `json:"categories"`
	Links struct {
		WebsiteURL string `json:"websiteUrl"`
	} `json:"links"`
	RatingDetails struct {
		Rating       float64 `json:"rating"`
		TotalRatings int     `json:"totalRatings"`
	} `json:"ratingDetails"`
	IsAvailable bool `json:"isAvailable"`
}

type curseToolsSearchResp struct {
	Data []curseToolsMod `json:"data"`
}

type curseToolsDetailResp struct {
	Data curseToolsMod `json:"data"`
}

type curseToolsDescriptionResp struct {
	Data string `json:"data"`
}

func (m *curseToolsMod) toUI() map[string]any {
	var latestSize int64
	if len(m.LatestFiles) > 0 {
		latestSize = m.LatestFiles[0].FileLength
	}
	author := ""
	if len(m.Authors) > 0 {
		author = m.Authors[0].Name
	}
	cats := make([]string, 0, len(m.Categories))
	for _, c := range m.Categories {
		cats = append(cats, c.Name)
	}
	return map[string]any{
		"id":             strconv.Itoa(m.ID),
		"name":           m.Name,
		"slug":           m.Slug,
		"summary":        m.Summary,
		"logo_url":       m.Logo.URL,
		"author":         author,
		"download_count": m.DownloadCount,
		"rank":           m.GameRank,
		"updated":        m.DateModified,
		"released":       m.DateReleased,
		"latest_size":    latestSize,
		"categories":     cats,
		"website":        m.Links.WebsiteURL,
		"rating":         m.RatingDetails.Rating,
		"ratings_total":  m.RatingDetails.TotalRatings,
		"is_available":   m.IsAvailable,
	}
}

func curseToolsFetchSearch(u string) ([]map[string]any, error) {
	b, err := curseToolsGet(u)
	if err != nil {
		return nil, err
	}
	var r curseToolsSearchResp
	if err := json.Unmarshal(b, &r); err != nil {
		return nil, fmt.Errorf("decode search: %w", err)
	}
	out := make([]map[string]any, 0, len(r.Data))
	for i := range r.Data {
		out = append(out, r.Data[i].toUI())
	}
	return out, nil
}

func fetchArkModDetail(modID string) (*curseToolsMod, error) {
	b, err := curseToolsGet(arkCurseToolsDetail + modID)
	if err != nil {
		return nil, err
	}
	var r curseToolsDetailResp
	if err := json.Unmarshal(b, &r); err != nil {
		return nil, fmt.Errorf("decode detail: %w", err)
	}
	return &r.Data, nil
}

func fetchArkModDescription(modID string) (string, error) {
	b, err := curseToolsGet(arkCurseToolsDetail + modID + "/description")
	if err != nil {
		return "", err
	}
	var r curseToolsDescriptionResp
	if err := json.Unmarshal(b, &r); err != nil {
		return "", err
	}
	return r.Data, nil
}

var arkCurseClient = &http.Client{Timeout: 15 * time.Second}

func curseToolsGet(u string) ([]byte, error) {
	req, err := http.NewRequest("GET", u, nil)
	if err != nil {
		return nil, err
	}
	// Real-browser UA — curse.tools passes through, and Cloudflare on
	// CurseForge CDN is noticeably less aggressive with a common UA.
	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")
	req.Header.Set("Accept", "application/json")
	resp, err := arkCurseClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	if resp.StatusCode != 200 {
		return nil, fmt.Errorf("curse.tools HTTP %d", resp.StatusCode)
	}
	return io.ReadAll(io.LimitReader(resp.Body, 4*1024*1024))
}

// hydrateArkMods fills in title/logo/summary/author for each installed
// mod entry. Best-effort: if any fetch fails the entry keeps its id.
func hydrateArkMods(ctx context.Context, entries []arkModEntry) {
	type result struct {
		idx int
		d   *curseToolsMod
	}
	ch := make(chan result, len(entries))
	for i := range entries {
		i := i
		go func() {
			d, err := fetchArkModDetail(entries[i].ID)
			if err != nil {
				ch <- result{i, nil}
				return
			}
			ch <- result{i, d}
		}()
	}
	done := 0
	deadline := time.NewTimer(8 * time.Second)
	defer deadline.Stop()
	for done < len(entries) {
		select {
		case r := <-ch:
			done++
			if r.d == nil {
				continue
			}
			entries[r.idx].Title = r.d.Name
			entries[r.idx].LogoURL = r.d.Logo.URL
			entries[r.idx].Summary = r.d.Summary
			if len(r.d.Authors) > 0 {
				entries[r.idx].Author = r.d.Authors[0].Name
			}
		case <-ctx.Done():
			return
		case <-deadline.C:
			return
		}
	}
	sort.SliceStable(entries, func(i, j int) bool {
		if entries[i].Passive != entries[j].Passive {
			return !entries[i].Passive // active first
		}
		return entries[i].Order < entries[j].Order
	})
}

var arkModIDRE = regexp.MustCompile(`(?:[?&/]|^)(\d{4,12})`)

// parseArkModID accepts: raw numeric id OR full CurseForge URL
// (https://www.curseforge.com/ark-survival-ascended/mods/<slug>/N).
// Returns the numeric project id as a string.
func parseArkModID(s string) (string, error) {
	s = strings.TrimSpace(s)
	if s == "" {
		return "", errors.New("mod id is required")
	}
	if _, err := strconv.ParseInt(s, 10, 64); err == nil {
		return s, nil
	}
	m := arkModIDRE.FindStringSubmatch(s)
	if len(m) > 1 {
		return m[1], nil
	}
	return "", fmt.Errorf("couldn't extract a CurseForge mod id from %q", s)
}

func splitModCSV(s string) []string {
	s = strings.TrimSpace(s)
	if s == "" {
		return nil
	}
	out := []string{}
	for _, p := range strings.Split(s, ",") {
		p = strings.TrimSpace(p)
		if p == "" {
			continue
		}
		out = append(out, p)
	}
	return out
}

func containsString(xs []string, s string) bool {
	for _, x := range xs {
		if x == s {
			return true
		}
	}
	return false
}

func removeString(xs []string, s string) []string {
	out := make([]string, 0, len(xs))
	for _, x := range xs {
		if x != s {
			out = append(out, x)
		}
	}
	return out
}

func dedupStrings(xs []string) []string {
	seen := map[string]bool{}
	out := []string{}
	for _, x := range xs {
		x = strings.TrimSpace(x)
		if x == "" || seen[x] {
			continue
		}
		seen[x] = true
		out = append(out, x)
	}
	return out
}
