// Command amp-distill parses the CubeCoders AMPTemplates repository (MIT
// licensed, see CREDITS.md) into a single self-contained catalog.json the
// panel can serve to its Library UI. It is a build-time tool: run it by hand
// whenever the AMPTemplates clone is refreshed.
//
// Usage:
//
//	go run ./tools/amp-distill \
//	    -templates C:/Users/dbled/sources/AMPTemplates \
//	    -modules   ./modules \
//	    -out       ./controller/cmd/controller/static/catalog.json
//
// For every <game>.kvp it reads the sibling <game>ports.json /
// <game>updates.json / <game>metaconfig.json and distills one CatalogEntry.
// Malformed templates are skipped with a logged reason — the tool never
// fails the whole run over one bad template.
package main

import (
	"encoding/json"
	"flag"
	"fmt"
	"os"
	"path/filepath"
	"regexp"
	"sort"
	"strconv"
	"strings"
)

// CatalogEntry is one distilled game template.
type CatalogEntry struct {
	ID          string   `json:"id"`
	Name        string   `json:"name"`
	Description string   `json:"description,omitempty"`
	OS          []string `json:"os,omitempty"`

	Steam *SteamInfo `json:"steam,omitempty"`

	Ports []PortEntry `json:"ports,omitempty"`

	RCONAdapter string `json:"rcon_adapter,omitempty"` // source_rcon, telnet, stdio, ...
	AdminMethod string `json:"admin_method,omitempty"` // raw App.AdminMethod

	ReadyPattern string            `json:"ready_pattern,omitempty"`
	Events       map[string]string `json:"events,omitempty"` // join/leave/chat -> Go regex

	UpdateStages []UpdateStage `json:"update_stages,omitempty"`

	ConfigFiles []string `json:"config_files,omitempty"`

	// SteamLoginRequired: kvp App.SteamUpdateAnonymousLogin=False —
	// SteamCMD refuses `+login anonymous` for this title (DayZ/Arma class).
	SteamLoginRequired bool `json:"steam_login_required,omitempty"`

	ArtSteamAppID  string `json:"art_steam_app_id,omitempty"`
	Wine           bool   `json:"wine"` // needs Wine/Proton on a Linux host
	EntrypointHint string `json:"entrypoint_hint,omitempty"`

	Ported   bool   `json:"ported,omitempty"` // already exists as a panel module
	ModuleID string `json:"module_id,omitempty"`

	Provenance string   `json:"provenance"` // source .kvp filename
	Notes      []string `json:"notes,omitempty"`
}

// SteamInfo carries the SteamCMD install identity.
type SteamInfo struct {
	AppID     string `json:"app_id"`               // dedicated-server app id
	GameAppID string `json:"game_app_id,omitempty"` // client/game app id (art, SteamAppId env)
	Platform  string `json:"platform,omitempty"`    // "windows" when ForceDownloadPlatform=Windows
}

// PortEntry is one declared network port.
type PortEntry struct {
	Name  string `json:"name"`
	Proto string `json:"proto"` // tcp / udp / both
	Port  uint32 `json:"port"`
}

// UpdateStage is one mapped install/update pipeline stage.
type UpdateStage struct {
	Kind       string `json:"kind"` // steamcmd, github, direct
	Name       string `json:"name,omitempty"`
	AppID      string `json:"app_id,omitempty"`
	Platform   string `json:"platform,omitempty"`
	Repo       string `json:"repo,omitempty"`
	AssetRegex string `json:"asset_regex,omitempty"`
	URL        string `json:"url,omitempty"`
	TargetPath string `json:"target_path,omitempty"`
	// Conditional stages (AMP UpdateSourceConditionSetting) are optional
	// extras like ValheimPlus/BepInEx — flagged so the UI can hide them.
	Conditional bool `json:"conditional,omitempty"`
}

// ampPort mirrors the shape of <game>ports.json entries (recursive).
type ampPort struct {
	Protocol   string          `json:"Protocol"`
	Port       json.RawMessage `json:"Port"`
	Name       string          `json:"Name"`
	ChildPorts []ampPort       `json:"ChildPorts"`
}

// ampUpdateStage mirrors <game>updates.json entries.
type ampUpdateStage struct {
	UpdateStageName              string `json:"UpdateStageName"`
	UpdateSourcePlatform         string `json:"UpdateSourcePlatform"`
	UpdateSource                 string `json:"UpdateSource"`
	UpdateSourceData             string `json:"UpdateSourceData"`
	UpdateSourceArgs             string `json:"UpdateSourceArgs"`
	UpdateSourceTarget           string `json:"UpdateSourceTarget"`
	ForceDownloadPlatform        string `json:"ForceDownloadPlatform"`
	UpdateSourceConditionSetting string `json:"UpdateSourceConditionSetting"`
}

// ampMetaConfig mirrors <game>metaconfig.json entries (only the file path).
type ampMetaConfig struct {
	ConfigFile string `json:"ConfigFile"`
}

// moduleInfo is what we scrape from an existing modules/<id>/module.yaml.
type moduleInfo struct {
	ID     string
	Name   string
	AppIDs []string
}

var namedGroupRe = regexp.MustCompile(`\(\?<([A-Za-z][A-Za-z0-9]*)>`)

// toGoRegex converts .NET named groups (?<name>...) to Go (?P<name>...).
// Returns the converted pattern and whether it compiles as a Go regexp.
func toGoRegex(p string) (string, bool) {
	if p == "" {
		return "", true
	}
	conv := namedGroupRe.ReplaceAllString(p, `(?P<$1>`)
	_, err := regexp.Compile(conv)
	return conv, err == nil
}

func parseKVP(path string) (map[string]string, error) {
	raw, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	kv := make(map[string]string)
	for _, line := range strings.Split(string(raw), "\n") {
		line = strings.TrimRight(line, "\r")
		if line == "" || strings.HasPrefix(line, "#") {
			continue
		}
		i := strings.IndexByte(line, '=')
		if i < 0 {
			continue
		}
		key := strings.TrimSpace(line[:i])
		if _, seen := kv[key]; !seen { // first occurrence wins
			kv[key] = line[i+1:]
		}
	}
	return kv, nil
}

func flattenPorts(in []ampPort, out *[]PortEntry, notes *[]string) {
	for _, p := range in {
		var num uint64
		var perr error
		s := strings.Trim(string(p.Port), `"`)
		if s != "" && s != "null" {
			num, perr = strconv.ParseUint(strings.TrimSpace(s), 10, 32)
		}
		if perr != nil || num == 0 || num > 65535 {
			*notes = append(*notes, fmt.Sprintf("skipped port %q: unparseable value %s", p.Name, string(p.Port)))
		} else {
			proto := strings.ToLower(p.Protocol)
			if proto != "tcp" && proto != "udp" {
				proto = "both"
			}
			name := p.Name
			if name == "" {
				name = fmt.Sprintf("port-%d", num)
			}
			*out = append(*out, PortEntry{Name: name, Proto: proto, Port: uint32(num)})
		}
		flattenPorts(p.ChildPorts, out, notes)
	}
}

func adapterFor(adminMethod string) string {
	switch adminMethod {
	case "SourceRCON":
		return "source_rcon"
	case "TelnetRCON", "Telnet":
		return "telnet"
	case "STDIO":
		return "stdio"
	case "BattlEyeRCON":
		return "battleye"
	case "QuakeRCON":
		return "quake_rcon"
	case "GoldSrcRCON":
		return "goldsrc_rcon"
	default:
		return ""
	}
}

// norm reduces a name to lowercase alphanumerics for fuzzy matching.
func norm(s string) string {
	var b strings.Builder
	for _, r := range strings.ToLower(s) {
		if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
			b.WriteRune(r)
		}
	}
	return b.String()
}

// moduleAliases maps our module id -> extra normalized names it answers to,
// covering cases where the AMP template id differs from ours.
var moduleAliases = map[string][]string{
	"7dtd":        {"sevendaystodie", "sdtd"},
	"ark-sa":      {"arksamin", "arksurvivalascended"},
	"dragonwilds": {"runescapedragonwilds"},
	"empyrion":    {"empyriongalacticsurvival"},
	"conan-exiles": {"conanexiles"},
}

var yamlAppIDRe = regexp.MustCompile(`(?m)^\s*app_id:\s*["']?(\d+)`)
var yamlIDRe = regexp.MustCompile(`(?m)^id:\s*["']?([A-Za-z0-9._-]+)`)
var yamlNameRe = regexp.MustCompile(`(?m)^name:\s*["']?([^"'\r\n]+)`)

func loadModules(dir string) ([]moduleInfo, error) {
	entries, err := os.ReadDir(dir)
	if err != nil {
		return nil, err
	}
	// Helper/infra modules that are not real game ports — never claim an
	// AMP template as "ported" via these (steamcmd-test uses HLDS app 90,
	// which would falsely mark half-life/counter-strike as ported).
	nonGame := map[string]bool{"demo": true, "steamcmd-test": true, "empyrion-bridge": true}
	var out []moduleInfo
	for _, e := range entries {
		if !e.IsDir() || nonGame[e.Name()] {
			continue
		}
		raw, err := os.ReadFile(filepath.Join(dir, e.Name(), "module.yaml"))
		if err != nil {
			continue
		}
		mi := moduleInfo{ID: e.Name()}
		if m := yamlIDRe.FindSubmatch(raw); m != nil {
			mi.ID = string(m[1])
		}
		if m := yamlNameRe.FindSubmatch(raw); m != nil {
			mi.Name = strings.TrimSpace(string(m[1]))
		}
		for _, m := range yamlAppIDRe.FindAllSubmatch(raw, -1) {
			mi.AppIDs = append(mi.AppIDs, string(m[1]))
		}
		out = append(out, mi)
	}
	return out, nil
}

// matchModule returns the module id an entry corresponds to, or "".
func matchModule(e *CatalogEntry, mods []moduleInfo) string {
	entryNames := map[string]bool{norm(e.ID): true, norm(e.Name): true}
	for _, m := range mods {
		// by steam app id (server app id or game app id)
		if e.Steam != nil {
			for _, id := range m.AppIDs {
				if id == e.Steam.AppID || id == e.Steam.GameAppID {
					return m.ID
				}
			}
		}
		// by name heuristics
		names := []string{norm(m.ID), norm(m.Name)}
		names = append(names, moduleAliases[m.ID]...)
		for _, n := range names {
			if n != "" && entryNames[n] {
				return m.ID
			}
		}
	}
	return ""
}

func distillOne(dir, kvpFile string) (*CatalogEntry, error) {
	id := strings.TrimSuffix(kvpFile, ".kvp")
	kv, err := parseKVP(filepath.Join(dir, kvpFile))
	if err != nil {
		return nil, fmt.Errorf("read kvp: %w", err)
	}
	name := kv["Meta.DisplayName"]
	if name == "" {
		name = kv["App.DisplayName"]
	}
	if name == "" {
		return nil, fmt.Errorf("no Meta.DisplayName/App.DisplayName")
	}
	e := &CatalogEntry{
		ID:          id,
		Name:        name,
		Description: kv["Meta.Description"],
		AdminMethod: kv["App.AdminMethod"],
		RCONAdapter: adapterFor(kv["App.AdminMethod"]),
		Provenance:  kvpFile,
	}
	if osList := kv["Meta.OS"]; osList != "" {
		for _, o := range strings.Split(osList, ",") {
			if o = strings.TrimSpace(o); o != "" {
				e.OS = append(e.OS, o)
			}
		}
	}
	e.SteamLoginRequired = strings.EqualFold(strings.TrimSpace(kv["App.SteamUpdateAnonymousLogin"]), "false")
	if hint := strings.TrimSpace(kv["App.CommandLineArgs"]); hint != "" {
		e.EntrypointHint = hint
	}
	if src := kv["Meta.DisplayImageSource"]; strings.HasPrefix(src, "steam:") {
		e.ArtSteamAppID = strings.TrimPrefix(src, "steam:")
	}

	// Regexes: AppReady + join/leave/chat.
	if p, ok := toGoRegex(kv["Console.AppReadyRegex"]); ok {
		e.ReadyPattern = p
	} else {
		e.Notes = append(e.Notes, "ready_pattern dropped: does not compile as Go regexp")
	}
	e.Events = map[string]string{}
	for kind, key := range map[string]string{
		"join":  "Console.UserJoinRegex",
		"leave": "Console.UserLeaveRegex",
		"chat":  "Console.UserChatRegex",
	} {
		if raw := kv[key]; raw != "" {
			if p, ok := toGoRegex(raw); ok {
				e.Events[kind] = p
			} else {
				e.Notes = append(e.Notes, kind+" regex dropped: does not compile as Go regexp")
			}
		}
	}
	if len(e.Events) == 0 {
		e.Events = nil
	}

	// Ports.
	if raw, err := os.ReadFile(filepath.Join(dir, id+"ports.json")); err == nil {
		var ports []ampPort
		if err := json.Unmarshal(raw, &ports); err != nil {
			e.Notes = append(e.Notes, "ports.json unparseable: "+err.Error())
		} else {
			flattenPorts(ports, &e.Ports, &e.Notes)
		}
	}

	// Update stages.
	wineSeen := false
	if raw, err := os.ReadFile(filepath.Join(dir, id+"updates.json")); err == nil {
		var stages []ampUpdateStage
		if err := json.Unmarshal(raw, &stages); err != nil {
			e.Notes = append(e.Notes, "updates.json unparseable: "+err.Error())
		} else {
			for _, s := range stages {
				if strings.Contains(strings.ToLower(s.UpdateSourceArgs), "wine") ||
					strings.Contains(strings.ToLower(s.UpdateStageName), "wine") {
					wineSeen = true
				}
				st := UpdateStage{Name: s.UpdateStageName, Conditional: s.UpdateSourceConditionSetting != ""}
				switch s.UpdateSource {
				case "SteamCMD":
					st.Kind = "steamcmd"
					st.AppID = s.UpdateSourceData
					if strings.EqualFold(s.ForceDownloadPlatform, "Windows") {
						st.Platform = "windows"
					}
					if e.Steam == nil && !st.Conditional {
						e.Steam = &SteamInfo{AppID: s.UpdateSourceData, GameAppID: s.UpdateSourceArgs, Platform: st.Platform}
					}
				case "GithubRelease":
					st.Kind = "github"
					st.Repo = s.UpdateSourceArgs
					st.AssetRegex = s.UpdateSourceData
					st.TargetPath = s.UpdateSourceTarget
				case "FetchURL":
					st.Kind = "direct"
					st.URL = s.UpdateSourceData
					st.TargetPath = s.UpdateSourceTarget
					if st.TargetPath == "" {
						st.TargetPath = s.UpdateSourceArgs
					}
				default:
					continue // Executable/CreateDirectory/etc. — entrypoint territory
				}
				e.UpdateStages = append(e.UpdateStages, st)
			}
		}
	}

	// Config file paths from metaconfig.
	if raw, err := os.ReadFile(filepath.Join(dir, id+"metaconfig.json")); err == nil {
		var metas []ampMetaConfig
		if err := json.Unmarshal(raw, &metas); err == nil {
			seen := map[string]bool{}
			for _, m := range metas {
				if m.ConfigFile != "" && !seen[m.ConfigFile] {
					seen[m.ConfigFile] = true
					e.ConfigFiles = append(e.ConfigFiles, m.ConfigFile)
				}
			}
		} else {
			e.Notes = append(e.Notes, "metaconfig.json unparseable: "+err.Error())
		}
	}

	// Wine flag: Windows-only template, forced Windows depots, or explicit
	// wine bootstrap in the update pipeline.
	linuxNative := false
	for _, o := range e.OS {
		if strings.EqualFold(o, "Linux") {
			linuxNative = true
		}
	}
	e.Wine = wineSeen || !linuxNative || (e.Steam != nil && e.Steam.Platform == "windows")
	return e, nil
}

func main() {
	templates := flag.String("templates", `C:\Users\dbled\sources\AMPTemplates`, "path to the AMPTemplates clone")
	modules := flag.String("modules", "modules", "path to the panel modules/ directory")
	out := flag.String("out", filepath.Join("controller", "cmd", "controller", "static", "catalog.json"), "output catalog path")
	flag.Parse()

	mods, err := loadModules(*modules)
	if err != nil {
		fmt.Fprintf(os.Stderr, "FATAL: load modules: %v\n", err)
		os.Exit(1)
	}
	entries, err := os.ReadDir(*templates)
	if err != nil {
		fmt.Fprintf(os.Stderr, "FATAL: read templates dir: %v\n", err)
		os.Exit(1)
	}

	var catalog []*CatalogEntry
	var skipped []string
	portedMap := map[string]string{}
	for _, de := range entries {
		if de.IsDir() || !strings.HasSuffix(de.Name(), ".kvp") {
			continue
		}
		e, err := distillOne(*templates, de.Name())
		if err != nil {
			skipped = append(skipped, fmt.Sprintf("%s: %v", de.Name(), err))
			continue
		}
		if mid := matchModule(e, mods); mid != "" {
			e.Ported = true
			e.ModuleID = mid
			portedMap[e.ID] = mid
		}
		catalog = append(catalog, e)
	}
	sort.Slice(catalog, func(i, j int) bool { return catalog[i].ID < catalog[j].ID })

	buf, err := json.MarshalIndent(catalog, "", "  ")
	if err != nil {
		fmt.Fprintf(os.Stderr, "FATAL: marshal: %v\n", err)
		os.Exit(1)
	}
	buf = append(buf, '\n')
	if err := os.MkdirAll(filepath.Dir(*out), 0o755); err != nil {
		fmt.Fprintf(os.Stderr, "FATAL: mkdir: %v\n", err)
		os.Exit(1)
	}
	if err := os.WriteFile(*out, buf, 0o644); err != nil {
		fmt.Fprintf(os.Stderr, "FATAL: write: %v\n", err)
		os.Exit(1)
	}

	fmt.Printf("catalog: %d entries -> %s (%d KB)\n", len(catalog), *out, len(buf)/1024)
	fmt.Printf("ported matches (%d):\n", len(portedMap))
	var ids []string
	for k := range portedMap {
		ids = append(ids, k)
	}
	sort.Strings(ids)
	for _, k := range ids {
		fmt.Printf("  %s -> module %s\n", k, portedMap[k])
	}
	if len(skipped) > 0 {
		fmt.Printf("skipped (%d):\n", len(skipped))
		for _, s := range skipped {
			fmt.Println("  " + s)
		}
	}
}
