package main

// Template Library (WI-09) — /api/template-catalog + /api/scaffold-module.
//
// The catalog is the amp-distill output (static/catalog.json, go:embed'd
// with the rest of static/*): 236 game-server templates distilled from the
// CubeCoders AMPTemplates repo (MIT). The Library pane browses it; the
// scaffolder turns one entry into a starter modules/<id>/ directory —
// module.yaml + Dockerfile + entrypoint.sh following the two house
// patterns (native debian:12-slim+tini vs Wine+Xvfb).
//
// Scaffold output is EXPERIMENTAL by construction: it has never booted a
// real server. Every generated file carries a header saying so.

import (
	"crypto/subtle"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"path/filepath"
	"regexp"
	"sort"
	"strings"
	"sync"

	"github.com/dbledeez/panel/controller/internal/db"
)

// catalogEntry mirrors tools/amp-distill's CatalogEntry JSON shape (the
// subset the scaffolder consumes; unknown fields pass through untouched
// on the GET endpoint because that serves the raw embedded bytes).
type catalogEntry struct {
	ID          string   `json:"id"`
	Name        string   `json:"name"`
	Description string   `json:"description"`
	OS          []string `json:"os"`
	Steam       *struct {
		AppID     string `json:"app_id"`
		GameAppID string `json:"game_app_id"`
		Platform  string `json:"platform"`
	} `json:"steam"`
	Ports []struct {
		Name  string `json:"name"`
		Proto string `json:"proto"`
		Port  uint32 `json:"port"`
	} `json:"ports"`
	RCONAdapter        string            `json:"rcon_adapter"`
	AdminMethod        string            `json:"admin_method"`
	ReadyPattern       string            `json:"ready_pattern"`
	Events             map[string]string `json:"events"`
	SteamLoginRequired bool              `json:"steam_login_required"`
	UpdateStages       []struct {
		Kind        string `json:"kind"`
		Name        string `json:"name"`
		AppID       string `json:"app_id"`
		Platform    string `json:"platform"`
		Repo        string `json:"repo"`
		AssetRegex  string `json:"asset_regex"`
		URL         string `json:"url"`
		TargetPath  string `json:"target_path"`
		Conditional bool   `json:"conditional"`
	} `json:"update_stages"`
	ConfigFiles    []string `json:"config_files"`
	ArtSteamAppID  string   `json:"art_steam_app_id"`
	Wine           bool     `json:"wine"`
	EntrypointHint string   `json:"entrypoint_hint"`
	Ported         bool     `json:"ported"`
	ModuleID       string   `json:"module_id"`
	Provenance     string   `json:"provenance"`
}

var (
	catalogOnce  sync.Once
	catalogRaw   []byte
	catalogByID  map[string]*catalogEntry
	catalogErr   error
)

func loadCatalog() error {
	catalogOnce.Do(func() {
		raw, err := staticFS.ReadFile("static/catalog.json")
		if err != nil {
			catalogErr = fmt.Errorf("embedded catalog.json: %w", err)
			return
		}
		var entries []*catalogEntry
		if err := json.Unmarshal(raw, &entries); err != nil {
			catalogErr = fmt.Errorf("parse catalog.json: %w", err)
			return
		}
		catalogRaw = raw
		catalogByID = make(map[string]*catalogEntry, len(entries))
		for _, e := range entries {
			catalogByID[e.ID] = e
		}
	})
	return catalogErr
}

// handleTemplateCatalog — GET /api/template-catalog. Serves the raw embedded
// catalog bytes (same auth as every other /api route via the api mux).
func (h *httpServer) handleTemplateCatalog(w http.ResponseWriter, _ *http.Request) {
	if err := loadCatalog(); err != nil {
		writeError(w, http.StatusInternalServerError, "catalog_unavailable", err.Error())
		return
	}
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(http.StatusOK)
	_, _ = w.Write(catalogRaw)
}

// isAdminRequest: session role "admin" OR a valid X-Panel-Admin-Token header
// (same header/token the /admin/v1 surface uses). Either grants the
// scaffold endpoint.
func (h *httpServer) isAdminRequest(r *http.Request) bool {
	if u, ok := r.Context().Value(sessionUserKey{}).(*db.UserRow); ok && u != nil && u.Role == "admin" {
		return true
	}
	if h.adminToken != "" {
		got := r.Header.Get(adminTokenHeader)
		if got != "" && subtle.ConstantTimeCompare([]byte(got), []byte(h.adminToken)) == 1 {
			return true
		}
	}
	return false
}

type scaffoldRequest struct {
	// ID is the catalog entry id (required).
	ID string `json:"id"`
	// ModuleID overrides the target directory / manifest id (optional;
	// defaults to the catalog id).
	ModuleID string `json:"module_id,omitempty"`
}

// handleScaffoldModule — POST /api/scaffold-module (ADMIN-gated).
// Writes modules/<id>/{module.yaml,Dockerfile,entrypoint.sh} from the
// catalog entry. Refuses to overwrite an existing module directory.
func (h *httpServer) handleScaffoldModule(w http.ResponseWriter, r *http.Request) {
	if !h.isAdminRequest(r) {
		writeError(w, http.StatusForbidden, "forbidden", "admin only (session admin or "+adminTokenHeader+")")
		return
	}
	if err := loadCatalog(); err != nil {
		writeError(w, http.StatusInternalServerError, "catalog_unavailable", err.Error())
		return
	}
	var req scaffoldRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "bad_json", err.Error())
		return
	}
	entry, ok := catalogByID[req.ID]
	if !ok {
		writeError(w, http.StatusNotFound, "unknown_template", "no catalog entry "+req.ID)
		return
	}
	moduleID := req.ModuleID
	if moduleID == "" {
		moduleID = entry.ID
	}
	if !moduleIDRe.MatchString(moduleID) {
		writeError(w, http.StatusBadRequest, "bad_module_id", "module_id must match "+moduleIDRe.String())
		return
	}
	modulesDir := h.scaffoldModulesDir
	if modulesDir == "" {
		modulesDir = "modules"
	}
	dir := filepath.Join(modulesDir, moduleID)
	if _, err := os.Stat(dir); err == nil {
		writeError(w, http.StatusConflict, "module_exists", "module directory already exists: "+dir)
		return
	} else if !os.IsNotExist(err) {
		writeError(w, http.StatusInternalServerError, "stat_failed", err.Error())
		return
	}

	files, warnings, err := scaffoldModule(dir, moduleID, entry)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "scaffold_failed", err.Error())
		return
	}
	h.log.Info("scaffolded module from AMP template", "template", entry.ID, "module", moduleID, "dir", dir)
	writeJSON(w, http.StatusCreated, map[string]any{
		"module_id": moduleID,
		"dir":       dir,
		"files":     files,
		"warnings":  warnings,
	})
}

var moduleIDRe = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`)

// scaffoldPort is a normalized, module.yaml-ready port row.
type scaffoldPort struct {
	Slug    string // yaml port name
	Proto   string // tcp | udp
	Default uint32
	Env     string // env var the allocator injects the host port into
	Label   string // original catalog name (comment)
}

// normalizePorts flattens catalog ports into manifest-valid rows:
// proto "both" splits into tcp+udp entries (distinct names + env vars —
// pkg/module requires proto ∈ {tcp,udp} and unique names).
func normalizePorts(e *catalogEntry) []scaffoldPort {
	var out []scaffoldPort
	seen := map[string]bool{}
	slugFor := func(raw string, port uint32) string {
		s := strings.ToLower(raw)
		s = strings.ReplaceAll(s, "port", "")
		s = regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(s, "-")
		s = strings.Trim(s, "-")
		if s == "" {
			s = fmt.Sprintf("p%d", port)
		}
		base, i := s, 2
		for seen[s] {
			s = fmt.Sprintf("%s-%d", base, i)
			i++
		}
		seen[s] = true
		return s
	}
	envFor := func(slug string) string {
		v := strings.ToUpper(regexp.MustCompile(`[^A-Za-z0-9]+`).ReplaceAllString(slug, "_"))
		v = strings.Trim(v, "_")
		if v == "" || !regexp.MustCompile(`^[A-Z]`).MatchString(v) {
			v = "P" + v
		}
		return v + "_PORT"
	}
	for _, p := range e.Ports {
		protos := []string{p.Proto}
		if p.Proto == "both" {
			protos = []string{"tcp", "udp"}
		}
		for _, proto := range protos {
			raw := p.Name
			if p.Proto == "both" {
				raw = p.Name + " " + proto
			}
			slug := slugFor(raw, p.Port)
			out = append(out, scaffoldPort{
				Slug: slug, Proto: proto, Default: p.Port,
				Env: envFor(slug), Label: p.Name,
			})
		}
	}
	if len(out) == 0 {
		// pkg/module requires ≥1 port; synthesize a placeholder the porter
		// must fix during the real boot test.
		out = append(out, scaffoldPort{Slug: "game", Proto: "udp", Default: 27015, Env: "GAME_PORT", Label: "TODO: template declared no ports"})
	}
	return out
}

// yq single-quotes a YAML scalar ('' escaping) — safe for regexes.
func yq(s string) string {
	return "'" + strings.ReplaceAll(s, "'", "''") + "'"
}

// scaffoldModule writes the three files and returns their repo-relative
// names plus human-readable warnings for the UI.
func scaffoldModule(dir, moduleID string, e *catalogEntry) (files []string, warnings []string, err error) {
	ports := normalizePorts(e)

	adapter := e.RCONAdapter
	switch adapter {
	case "source_rcon", "telnet", "stdio":
	case "":
		adapter = "stdio"
		warnings = append(warnings, "template declares no admin method; defaulted rcon adapter to stdio")
	default:
		warnings = append(warnings, fmt.Sprintf("template admin method %q (%s) has no agent adapter; scaffolded stdio — verify console control during the boot test", e.AdminMethod, adapter))
		adapter = "stdio"
	}
	for _, p := range e.Ports {
		if p.Proto == "both" {
			warnings = append(warnings, fmt.Sprintf("port %q was proto 'both' — split into separate tcp/udp entries", p.Name))
		}
	}

	if err := os.MkdirAll(dir, 0o755); err != nil {
		return nil, nil, err
	}
	cleanup := func() { _ = os.RemoveAll(dir) }

	manifest := renderManifest(moduleID, e, ports, adapter)
	dockerfile := renderDockerfile(moduleID, e, ports)
	entrypoint := renderEntrypoint(moduleID, e, ports)

	for name, body := range map[string]string{
		"module.yaml":   manifest,
		"Dockerfile":    dockerfile,
		"entrypoint.sh": entrypoint,
	} {
		if werr := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); werr != nil {
			cleanup()
			return nil, nil, werr
		}
		files = append(files, filepath.ToSlash(filepath.Join(filepath.Base(dir), name)))
	}
	sort.Strings(files)
	return files, warnings, nil
}

const experimentalHeader = `EXPERIMENTAL — scaffolded from AMP template, needs a real boot test.
Generated by the panel's Template Library scaffolder. Nothing here has
ever started a real server: review the launch args, ports, env wiring and
save paths against the game's docs, then run a full boot + join test
before treating this module as real.`

func commentBlock(prefix, text string) string {
	var b strings.Builder
	for _, line := range strings.Split(text, "\n") {
		if line == "" {
			b.WriteString(prefix + "\n")
		} else {
			b.WriteString(prefix + " " + line + "\n")
		}
	}
	return b.String()
}

func renderManifest(moduleID string, e *catalogEntry, ports []scaffoldPort, adapter string) string {
	var b strings.Builder
	b.WriteString(commentBlock("#", experimentalHeader))
	b.WriteString("#\n")
	fmt.Fprintf(&b, "# Reference: https://github.com/CubeCoders/AMPTemplates (%s, MIT) —\n", e.Provenance)
	b.WriteString("# ports, regexes and update pipeline credit the CubeCoders AMP template.\n\n")

	fmt.Fprintf(&b, "id: %s\n", moduleID)
	fmt.Fprintf(&b, "name: %q\n", e.Name)
	b.WriteString("version: 0.1.0\n")
	b.WriteString("authors:\n  - panel scaffolder (AMP template distill)\n\n")
	b.WriteString("supported_modes:\n  - docker\n\n")

	b.WriteString("runtime:\n  docker:\n")
	fmt.Fprintf(&b, "    # Built locally from ./Dockerfile:\n    #   docker build -t panel-%s:latest modules/%s\n", moduleID, moduleID)
	fmt.Fprintf(&b, "    image: panel-%s:latest\n", moduleID)
	b.WriteString("    network_mode: host\n")
	b.WriteString("    browseable_root: /game-saves\n")
	b.WriteString("    browseable_roots:\n")
	b.WriteString("      - name: \"Saves & Configs\"\n        path: /game-saves\n")
	b.WriteString("      - name: \"Game Files\"\n        path: /game\n")
	b.WriteString("    # Pre-declare EVERY env var the entrypoint reads. The agent's\n")
	b.WriteString("    # resolve.go only propagates config_values / allocator ports whose\n")
	b.WriteString("    # key already exists in docker.env — undeclared keys are silently\n")
	b.WriteString("    # dropped (see modules/vein for the canonical write-up).\n")
	b.WriteString("    env:\n")
	if e.Steam != nil && e.Steam.GameAppID != "" {
		fmt.Fprintf(&b, "      STEAM_APP_ID: %q\n", e.Steam.GameAppID)
	} else if e.Steam != nil {
		fmt.Fprintf(&b, "      STEAM_APP_ID: %q\n", e.Steam.AppID)
	}
	for _, p := range ports {
		fmt.Fprintf(&b, "      %s: \"%d\"\n", p.Env, p.Default)
	}
	b.WriteString("      SERVER_NAME: \"\"\n")
	b.WriteString("      SERVER_PASSWORD: \"\"\n")
	b.WriteString("      MAX_PLAYERS: \"\"\n")
	b.WriteString("      EXTRA_ARGS: \"\"\n")
	b.WriteString("    volumes:\n")
	b.WriteString("      - { type: volume, name: \"panel-$INSTANCE_ID-saves\", container: \"/game-saves\" }\n")
	b.WriteString("      - { type: volume, name: \"panel-$INSTANCE_ID-game\",  container: \"/game\" }\n")
	b.WriteString("      - { target: \"$DATA_PATH/logs\", container: \"/game-saves/logs\" }\n\n")

	b.WriteString("# Multi-instance safety: env on each port maps the panel-allocated host\n")
	b.WriteString("# port into the container env the entrypoint launches with.\n")
	b.WriteString("ports:\n")
	for _, p := range ports {
		fmt.Fprintf(&b, "  - { name: %s, proto: %s, default: %d, required: true, env: %s }  # %s\n",
			p.Slug, p.Proto, p.Default, p.Env, p.Label)
	}
	b.WriteString("\nresources:\n  min_ram_mb: 2048\n  recommended_ram_mb: 4096\n\n")

	fmt.Fprintf(&b, "rcon:\n  # AMP AdminMethod: %s\n  adapter: %s\n", e.AdminMethod, adapter)
	b.WriteString("\n")

	if len(e.Events) > 0 {
		b.WriteString("# Log-derived events — regexes converted from the AMP template\n")
		b.WriteString("# (.NET (?<n>) → Go (?P<n>)). Untested against a live server.\n")
		b.WriteString("events:\n")
		kinds := make([]string, 0, len(e.Events))
		for k := range e.Events {
			kinds = append(kinds, k)
		}
		sort.Strings(kinds)
		for _, k := range kinds {
			fmt.Fprintf(&b, "  %s:\n    pattern: %s\n    kind: %s\n", k, yq(e.Events[k]), k)
		}
		b.WriteString("\n")
	}

	b.WriteString("update_providers:\n")
	provSeen := map[string]bool{}
	wrote := false
	for _, s := range e.UpdateStages {
		id := ""
		switch s.Kind {
		case "steamcmd":
			id = "stable"
		case "github":
			id = "github"
		case "direct":
			id = "direct"
		default:
			continue
		}
		base, i := id, 2
		for provSeen[id] {
			id = fmt.Sprintf("%s-%d", base, i)
			i++
		}
		provSeen[id] = true
		wrote = true
		if s.Conditional {
			fmt.Fprintf(&b, "  # (optional in AMP: %q)\n", s.Name)
		}
		fmt.Fprintf(&b, "  - id: %s\n    kind: %s\n", id, s.Kind)
		switch s.Kind {
		case "steamcmd":
			fmt.Fprintf(&b, "    app_id: %q\n", s.AppID)
			if s.Platform != "" {
				fmt.Fprintf(&b, "    platform: %q\n", s.Platform)
			}
			b.WriteString("    install_path: /game\n")
			if e.SteamLoginRequired {
				b.WriteString("    requires_steam_login: true\n")
			}
		case "github":
			fmt.Fprintf(&b, "    repo: %q\n    asset_regex: %s\n    target_path: %q\n", s.Repo, yq(s.AssetRegex), s.TargetPath)
		case "direct":
			fmt.Fprintf(&b, "    url: %q\n    target_path: %q\n", s.URL, s.TargetPath)
		}
	}
	if !wrote {
		b.WriteString("  # TODO: template had no mappable update stages — fill in the real source.\n")
		b.WriteString("  - id: stable\n    kind: direct\n    url: \"https://example.invalid/TODO\"\n    target_path: \"/game\"\n")
	}
	b.WriteString("\n")

	if e.ReadyPattern != "" {
		b.WriteString("# Dashboard readiness regex from the AMP template (untested).\n")
		fmt.Fprintf(&b, "ready_pattern: %s\n", yq(e.ReadyPattern))
	}
	b.WriteString("appearance:\n")
	fmt.Fprintf(&b, "  grad: \"linear-gradient(135deg,#10141c,#232a38)\"\n")
	if e.ArtSteamAppID != "" {
		fmt.Fprintf(&b, "  steam: %q\n", e.ArtSteamAppID)
	}
	return b.String()
}

func renderDockerfile(moduleID string, e *catalogEntry, ports []scaffoldPort) string {
	var b strings.Builder
	b.WriteString(commentBlock("#", experimentalHeader))
	fmt.Fprintf(&b, "#\n# panel-native %s runtime image", e.Name)
	if e.Wine {
		b.WriteString(" (Wine + Xvfb — Windows-only server build).\n")
		b.WriteString("# Pattern copied from modules/sons-of-the-forest/Dockerfile.\n")
	} else {
		b.WriteString(" (native Linux).\n")
		b.WriteString("# Pattern copied from modules/vein/Dockerfile.\n")
	}
	b.WriteString("FROM debian:12-slim\n\n")
	if e.Wine {
		b.WriteString(`ENV DEBIAN_FRONTEND=noninteractive \
    TZ=Etc/UTC \
    LANG=en_US.UTF-8 \
    LC_ALL=en_US.UTF-8 \
    WINEDEBUG=-all \
    WINEPREFIX=/home/panel/.wine \
    DISPLAY=:99

RUN dpkg --add-architecture i386 \
 && apt-get update \
 && apt-get install -y --no-install-recommends \
    ca-certificates \
    locales \
    tini \
    curl \
    xvfb \
    xauth \
    wine64 \
    wine \
    winbind \
    cabextract \
    procps \
 && sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen \
 && locale-gen \
 && apt-get clean \
 && rm -rf /var/lib/apt/lists/*
`)
	} else {
		b.WriteString(`ENV DEBIAN_FRONTEND=noninteractive \
    TZ=Etc/UTC \
    LANG=en_US.UTF-8 \
    LC_ALL=en_US.UTF-8

RUN dpkg --add-architecture i386 \
 && apt-get update \
 && apt-get install -y --no-install-recommends \
    ca-certificates \
    locales \
    tini \
    curl \
    lib32gcc-s1 \
    lib32stdc++6 \
    libc6:i386 \
    libatomic1 \
 && sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen \
 && locale-gen \
 && apt-get clean \
 && rm -rf /var/lib/apt/lists/*
`)
	}
	b.WriteString(`
RUN groupadd -g 1000 panel \
 && useradd -u 1000 -g 1000 -m -s /bin/bash panel \
 && mkdir -p /game /game-saves \
 && chown -R panel:panel /game /game-saves /home/panel

COPY entrypoint.sh /entrypoint.sh
# Strip CRs so a Windows-checkout can't break the shebang (exit 127 loop).
RUN sed -i 's/\r$//' /entrypoint.sh && chmod +x /entrypoint.sh

`)
	var exposes []string
	for _, p := range ports {
		exposes = append(exposes, fmt.Sprintf("%d/%s", p.Default, p.Proto))
	}
	fmt.Fprintf(&b, "EXPOSE %s\n\n", strings.Join(exposes, " "))
	b.WriteString("ENTRYPOINT [\"/usr/bin/tini\", \"--\", \"/entrypoint.sh\"]\n")
	return b.String()
}

func renderEntrypoint(moduleID string, e *catalogEntry, ports []scaffoldPort) string {
	var b strings.Builder
	b.WriteString("#!/bin/bash\n")
	b.WriteString(commentBlock("#", experimentalHeader))
	fmt.Fprintf(&b, "#\n# panel-native %s entrypoint (two-stage setpriv skeleton).\n", e.Name)
	b.WriteString(`#
# Contract:
#   /game       — server files installed by the panel updater
#   /game-saves — persistent saves/config volume
#
# Stage 1 (root): chown volumes, drop to the panel user.
# Stage 2 (panel): TODO — locate the server binary, stamp configs from
#   env, exec the server with panel-allocated ports.

set -euo pipefail
`)
	fmt.Fprintf(&b, "log() { printf '[panel-%s] %%s\\n' \"$*\"; }\n\n", moduleID)
	b.WriteString(`GAME_DIR=/game
SAVE_DIR=/game-saves

# ----- stage 1: root-only bootstrap -----
if [[ "$(id -u)" = "0" ]]; then
  chown -R panel:panel /game /game-saves /home/panel 2>/dev/null || true
  log "dropping privileges to panel (UID 1000)"
  exec setpriv --reuid=panel --regid=panel --clear-groups \
       --inh-caps=-all --bounding-set=-all \
       env \
         HOME=/home/panel \
         PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
`)
	if e.Steam != nil {
		app := e.Steam.GameAppID
		if app == "" {
			app = e.Steam.AppID
		}
		fmt.Fprintf(&b, "         STEAM_APP_ID=\"${STEAM_APP_ID:-%s}\" \\\n", app)
		fmt.Fprintf(&b, "         SteamAppId=\"${STEAM_APP_ID:-%s}\" \\\n", app)
	}
	for _, p := range ports {
		fmt.Fprintf(&b, "         %s=\"${%s:-%d}\" \\\n", p.Env, p.Env, p.Default)
	}
	b.WriteString(`         SERVER_NAME="${SERVER_NAME:-}" \
         SERVER_PASSWORD="${SERVER_PASSWORD:-}" \
         MAX_PLAYERS="${MAX_PLAYERS:-}" \
         EXTRA_ARGS="${EXTRA_ARGS:-}" \
         /entrypoint.sh "$@"
fi

# ----- stage 2: panel user -----
`)
	if e.Wine {
		b.WriteString(`# Windows server build — run under Wine with a headless X display.
Xvfb :99 -screen 0 1024x768x16 &
export DISPLAY=:99

`)
	}
	b.WriteString("# TODO: locate the real server binary under /game and build the launch\n")
	b.WriteString("# command. The AMP template's CommandLineArgs (its {{moustache}} vars are\n")
	b.WriteString("# AMP-side settings — map them to env above or hardcode sane defaults):\n")
	b.WriteString("#\n")
	hint := e.EntrypointHint
	if hint == "" {
		hint = "(template declared no CommandLineArgs)"
	}
	b.WriteString(commentBlock("#  ", hint))
	b.WriteString("#\n")
	b.WriteString("log \"scaffolded entrypoint — no launch command implemented yet\"\n")
	b.WriteString("log \"edit modules/" + moduleID + "/entrypoint.sh stage 2 before first boot\"\n")
	b.WriteString("exit 78  # EX_CONFIG until the launch command is written\n")
	return b.String()
}
