// Package module defines the on-disk manifest format (module.yaml) that
// declares a game plugin: how to install it, how to run it, what ports it
// exposes, what RCON dialect it speaks, and what patterns identify state
// and player events in its logs.
//
// Both Controller and Target load manifests from a local modules/ directory.
// The Controller owns authoring/UI form generation; the Target uses the
// manifest at runtime to shape container launch and state-tracking pipelines.
package module

import (
	"fmt"
	"time"

	"gopkg.in/yaml.v3"
)

// Manifest is the root structure parsed from a module.yaml file.
type Manifest struct {
	ID              string           `yaml:"id"`
	Name            string           `yaml:"name"`
	Version         string           `yaml:"version"`
	Authors         []string         `yaml:"authors,omitempty"`
	SupportedModes  []string         `yaml:"supported_modes"`
	Runtime         Runtime          `yaml:"runtime"`
	Ports           []Port           `yaml:"ports"`
	Resources       Resources        `yaml:"resources,omitempty"`
	ConfigFiles     []ConfigFile     `yaml:"config_files,omitempty"`
	RCON            *RCONConfig      `yaml:"rcon,omitempty"`
	StateSources    []StateSource    `yaml:"state_sources,omitempty"`
	Events          map[string]Event `yaml:"events,omitempty"`
	UpdateProviders []UpdateProvider `yaml:"update_providers"`
	Secrets         []Secret         `yaml:"secrets,omitempty"`
	Lifecycle       *Lifecycle       `yaml:"lifecycle,omitempty"`
	// AutoUpdateOnCreate — run the first update_provider automatically
	// after a successful create, so the operator doesn't have to click
	// Update as a separate step. Default is `nil` which the loader
	// interprets as true for modules that declare a real provider. Set
	// explicitly to `false` for modules whose image handles its own
	// downloading at runtime (e.g. the ark-sa community image).
	AutoUpdateOnCreate *bool `yaml:"auto_update_on_create,omitempty"`
	// AutoStartOnCreate — after a successful create (and first-time update,
	// if one ran), boot the server automatically. Default nil = true for any
	// module. Opt out with `auto_start_on_create: false` for modules whose
	// first boot needs operator attention (e.g. filling in secrets first).
	// Independent of AutoUpdateOnCreate: images like ark-sa/ASA skip the
	// external updater but still need to auto-start so their in-container
	// entrypoint can run the bundled SteamCMD + launch the game in one go.
	AutoStartOnCreate *bool `yaml:"auto_start_on_create,omitempty"`
	// Backup declares which paths the panel should archive when an
	// operator clicks Back-up-now. Without this the agent tars the
	// whole BrowseableRoot — fine for tiny games, catastrophic for ARK
	// SA where that's a 18 GB SteamCMD install plus 200 MB of saves.
	// Paths are container-relative (resolved against BrowseableRoot)
	// and use shell-style globs. Saves-only is the goal.
	Backup *BackupSpec `yaml:"backup,omitempty"`

	// ConfigValues is the on-disk *declaration* of operator-tunable knobs.
	// The dashboard hardcodes its config-form schemas separately (see
	// static/index.html `configSchemas`), so this field is not consumed by
	// the loader yet — it's accepted only to keep the strict YAML decoder
	// happy when modules document their tunables in the manifest. Future
	// work: drive the dashboard form generator off this field directly so
	// the schema lives in one place.
	ConfigValues []map[string]any `yaml:"config_values,omitempty"`

	// ReadyPattern is the log regex that marks the server as actually
	// joinable (world loaded, accepting connections) — CONTROLLER/UI
	// metadata only; the agent never compiles it. The string is a
	// JavaScript regex: either a bare source ("Done \\(...\\)! For help")
	// or slash-delimited with flags ("/StartGame done/i"). It may use JS
	// constructs Go's regexp lacks (lookahead) — do not compile in Go.
	// Source of truth moved here from the dashboard's READY_PATTERNS map.
	ReadyPattern string `yaml:"ready_pattern,omitempty"`
	// Appearance carries the dashboard's visual identity for this module
	// (emoji, tile gradient, packaged art, Steam appid, optional display
	// name/tagline/accent). CONTROLLER/UI metadata only. Source of truth
	// moved here from the dashboard's moduleAppearance map.
	Appearance *Appearance `yaml:"appearance,omitempty"`

	// Path is the absolute path to module.yaml, set by the loader.
	Path string `yaml:"-"`
	// Dir is the directory containing the manifest; template/schema paths
	// in this manifest are resolved relative to Dir.
	Dir string `yaml:"-"`
}

// Runtime declares how the module launches under each supported mode.
type Runtime struct {
	Docker *DockerSpec `yaml:"docker,omitempty"`
	Host   *HostSpec   `yaml:"host,omitempty"`
}

// DockerSpec is the Docker-mode launch recipe.
type DockerSpec struct {
	Image      string            `yaml:"image"`
	Volumes    []Volume          `yaml:"volumes,omitempty"`
	Entrypoint []string          `yaml:"entrypoint,omitempty"`
	Command    []string          `yaml:"command,omitempty"`
	Env        map[string]string `yaml:"env,omitempty"`
	User       string            `yaml:"user,omitempty"`
	// BrowseableRoot is the container-absolute path the file manager
	// treats as "root" when operating on this container (e.g. "/data").
	// If empty, the resolver defaults to the first volume's container
	// path. If neither is set, the file manager falls back to the
	// host-side data_path bind-mount root.
	BrowseableRoot string `yaml:"browseable_root,omitempty"`
	// BrowseableRoots lets a module expose multiple independent "views"
	// to the file manager — e.g. 7DTD's /game-saves (worlds, configs)
	// AND /game (mods, plugins, binaries). Each entry becomes a picker
	// option in the Files tab. First entry is the default view.
	// Empty → fall back to BrowseableRoot / first volume.
	BrowseableRoots []BrowseableRoot `yaml:"browseable_roots,omitempty"`
	// NetworkMode is the Docker network mode. Empty → default bridge with
	// published ports. Use "host" for modules that rely on long-lived UDP
	// sessions to external services (e.g. Windrose's P2P coop gateway, where
	// Docker Desktop's UDP NAT rebinds break TURN consent checks). In host
	// mode the Ports block is informational only — the container listens on
	// the host's interfaces directly; the default bind-mount NAT is skipped.
	NetworkMode string `yaml:"network_mode,omitempty"`
}

// BrowseableRoot names one container-absolute directory for the file
// manager to surface in its roots picker. JSON tags match what the
// dashboard's Files-tab picker consumes via GET /api/modules.
type BrowseableRoot struct {
	Name string `yaml:"name" json:"name"`                     // human label — shown in the UI
	Path string `yaml:"path" json:"path"`                     // container-absolute path
	Hint string `yaml:"hint,omitempty" json:"hint,omitempty"` // optional sub-line shown in the picker
}

// Appearance is UI-only visual metadata for a module. Every field is
// optional; the dashboard falls back to its built-in defaults for any
// missing field. JSON keys mirror the dashboard's moduleAppearance map
// entries (emoji/grad/art/steam) so the payload drops in unchanged.
type Appearance struct {
	// DisplayName overrides Manifest.Name in UI card headers when set.
	DisplayName string `yaml:"display_name,omitempty" json:"display_name,omitempty"`
	// Tagline is an optional one-line blurb shown under the name.
	Tagline string `yaml:"tagline,omitempty" json:"tagline,omitempty"`
	// Accent is an optional CSS color for module-tinted UI chrome.
	Accent string `yaml:"accent,omitempty" json:"accent,omitempty"`
	// Emoji shown when no art is available.
	Emoji string `yaml:"emoji,omitempty" json:"emoji,omitempty"`
	// Grad is the CSS gradient behind the tile / while art loads.
	Grad string `yaml:"grad,omitempty" json:"grad,omitempty"`
	// Art is the panel-hosted art path, e.g. "/game-art/7dtd.jpg".
	Art string `yaml:"art,omitempty" json:"art,omitempty"`
	// Steam is the Steam appid used for CDN header-art fallback.
	Steam string `yaml:"steam,omitempty" json:"steam,omitempty"`
}

// HostSpec is the host-mode launch recipe.
type HostSpec struct {
	ExecutableLinux   string            `yaml:"executable_linux,omitempty"`
	ExecutableWindows string            `yaml:"executable_windows,omitempty"`
	Args              []string          `yaml:"args,omitempty"`
	Env               map[string]string `yaml:"env,omitempty"`
	WorkingDir        string            `yaml:"working_dir,omitempty"`
}

// Volume declares a mount point on the instance's container.
//
// Two kinds are supported:
//
//	type: bind     — Target is the host path (may contain $DATA_PATH).
//	                 Useful for configs + logs operators want to read/edit.
//	type: volume   — Name is a Docker named volume (may contain $INSTANCE_ID).
//	                 Lives inside the WSL2/container-storage filesystem;
//	                 dramatically faster than Windows bind-mounts for
//	                 high-I/O data like game installs.
//
// The agent's docker runtime auto-creates named volumes on first use.
type Volume struct {
	Type      string `yaml:"type,omitempty"`   // "bind" (default if Target set) or "volume"
	Target    string `yaml:"target,omitempty"` // host path for bind (may contain $DATA_PATH)
	Name      string `yaml:"name,omitempty"`   // volume name for named volumes (may contain $INSTANCE_ID)
	Container string `yaml:"container"`
	ReadOnly  bool   `yaml:"read_only,omitempty"`
}

// Port is a declared network port.
type Port struct {
	Name     string `yaml:"name" json:"name"`
	Proto    string `yaml:"proto" json:"proto"`     // "tcp" or "udp"
	Default  uint32 `yaml:"default" json:"default"` // default port number
	Required bool   `yaml:"required,omitempty" json:"required,omitempty"`
	Internal bool   `yaml:"internal,omitempty" json:"internal,omitempty"` // if true, not exposed outside the Target
	// Env, when set, names the container env var the agent should
	// populate with this port's allocated HOST port at create time.
	// Lets the image's entrypoint translate panel-allocated ports
	// into game launch args (e.g. ARK's ASA_PORT/QUERY_PORT/RCON_PORT
	// → ?Port=N ?QueryPort=N ?RCONPort=N). Without this, two ARK
	// instances all advertise the manifest defaults to Steam and only
	// one of them is reachable via the server browser.
	Env      string `yaml:"env,omitempty" json:"env,omitempty"`
}

// Resources declares RAM / CPU guidance for the UI and default limits.
type Resources struct {
	MinRAMMB         uint32 `yaml:"min_ram_mb,omitempty"`
	RecommendedRAMMB uint32 `yaml:"recommended_ram_mb,omitempty"`
	MinCPUShares     uint32 `yaml:"min_cpu_shares,omitempty"`
}

// BackupSpec narrows what gets included when a backup runs.
//
// Paths are interpreted relative to BrowseableRoot (the same path the
// File Manager treats as "the instance root"). Both Include and Exclude
// support tar-style patterns (e.g. "ShooterGame/Saved/SavedArks").
//
// Without a BackupSpec, the agent falls back to "tar the entire
// BrowseableRoot" — preserves backwards compat for modules that
// haven't been profiled yet but is wasteful for any game with bulky
// install dirs (ARK SA, Empyrion, Conan). Each module's manifest
// should ship one of these as soon as someone has time to verify
// which paths actually need saving.
//
// Notes label is shown in the UI alongside backups, e.g. "Saves +
// configs (~250 MB typical)".
type BackupSpec struct {
	// Root is the absolute in-container path to cd into before tarring.
	// Defaults to the runtime's BrowseableRoot when empty. Set this when
	// the module's save data lives in a different volume than the file
	// browser default (7DTD: file browser shows /game by default, but
	// saves live in /game-saves).
	Root    string   `yaml:"root,omitempty"`
	Include []string `yaml:"include,omitempty"`
	Exclude []string `yaml:"exclude,omitempty"`
	Notes   string   `yaml:"notes,omitempty"`
}

// ConfigFile declares a config file the panel manages for the instance.
type ConfigFile struct {
	Path     string `yaml:"path"`             // relative to $DATA_PATH
	Format   string `yaml:"format"`           // xml, properties, yaml, ini, json, toml, kv
	Schema   string `yaml:"schema,omitempty"` // relative path to JSON-schema file in the module dir
	Template string `yaml:"template,omitempty"`
	// TemplateByBranch overrides Template for instances installed from a
	// specific Steam branch (the normalized branch from resolveCreateBranch,
	// e.g. "" for public/2.6 default, "latest_experimental" / "v3.0" for
	// 3.0). Keyed by the normalized branch string; an exact match wins,
	// otherwise Template (the default) is used. This is how 7DTD selects
	// serverconfig.v3.xml.tmpl for a 3.0 server while ≤2.6 servers keep the
	// untouched serverconfig.xml.tmpl. Kept data-driven so adding a future
	// version is a manifest edit, not a code change. See
	// panel/memory/7dtd-v3-version-and-sandboxcode-plan.md (Workstream D).
	TemplateByBranch map[string]string `yaml:"template_by_branch,omitempty"`
}

// TemplateForBranch returns the template path to render for this config file
// on the given normalized branch — a branch-specific override if one is
// declared, else the default Template. An empty result means "no template"
// (the file is operator-managed and Render skips it).
func (cf ConfigFile) TemplateForBranch(branch string) string {
	if cf.TemplateByBranch != nil {
		if t, ok := cf.TemplateByBranch[branch]; ok {
			return t
		}
	}
	return cf.Template
}

// RCONConfig declares the instance's RCON adapter.
type RCONConfig struct {
	Adapter         string            `yaml:"adapter"`                    // source_rcon, telnet, http, stdin_pipe, custom_wasm
	HostPort        string            `yaml:"host_port"`                  // port name from Ports
	Auth            string            `yaml:"auth,omitempty"`             // password_prompt, source_rcon_login, bearer, none
	PasswordSecret  string            `yaml:"password_secret,omitempty"`  // name from Secrets to use as RCON password
	PasswordLiteral string            `yaml:"password_literal,omitempty"` // literal password (useful when the module's env pins one — lower priority than PasswordSecret / config_values)
	// PasswordFromFile reads the password at dial time from a path *inside
	// the container*. Lowest priority — used as a fallback when the module's
	// entrypoint generates its own RCON secret on first boot (e.g. 7DTD's
	// TelnetPassword, which we auto-populate to force 0.0.0.0 binding and
	// stash in /game-saves/.panel-telnet-password).
	PasswordFromFile string `yaml:"password_from_file,omitempty"`
	// PasswordFromINI extracts the password from a specific key in a
	// specific section of an INI file inside the container. Useful for
	// modules whose config file holds the RCON password alongside other
	// settings (ARK: SA stores it as
	// [ServerSettings]ServerAdminPassword=... in GameUserSettings.ini).
	// Read fresh on each dial so changes the operator makes via the Config
	// tab take effect as soon as the server picks them up on restart.
	PasswordFromINI *PasswordFromINI  `yaml:"password_from_ini,omitempty"`
	Commands        map[string]string `yaml:"commands,omitempty"` // named command templates
}

// PasswordFromINI locates a single key/value in an INI file inside the
// container. Section is optional — when empty we match the key anywhere.
type PasswordFromINI struct {
	File    string `yaml:"file"`
	Section string `yaml:"section,omitempty"`
	Key     string `yaml:"key"`
}

// StateSource is a state-feed declaration — either an RCON poll or a log tail.
type StateSource struct {
	Type    string       `yaml:"type"` // rcon, log_tail, http_probe
	Command string       `yaml:"command,omitempty"`
	Every   Duration     `yaml:"every,omitempty"`
	Parse   *ParseConfig `yaml:"parse,omitempty"`
	Files   []string     `yaml:"files,omitempty"`
	URL     string       `yaml:"url,omitempty"`
}

// ParseConfig describes how to turn a raw response into named fields.
type ParseConfig struct {
	Kind    string            `yaml:"kind"` // regex, json, kv
	Pattern string            `yaml:"pattern,omitempty"`
	Fields  map[string]string `yaml:"fields,omitempty"` // output_name -> named_group
}

// Event is a log-derived event mapping: regex with named groups, mapped to a kind.
type Event struct {
	Pattern string `yaml:"pattern"`
	Kind    string `yaml:"kind"` // join, leave, chat, death, custom
}

// UpdateProvider declares a source the panel can fetch new versions from.
type UpdateProvider struct {
	ID         string `yaml:"id"`
	Kind       string `yaml:"kind"`        // steamcmd, github, direct
	AppID      string `yaml:"app_id,omitempty"`
	Beta       string `yaml:"beta,omitempty"`
	// Platform, when non-empty, forces SteamCMD to download a specific
	// platform's depots via +@sSteamCmdForcePlatformType. Used for Windows-
	// only server builds installed on Linux hosts (Empyrion, a few others).
	// Values: "windows", "linux", "macos".
	Platform   string `yaml:"platform,omitempty"`
	Repo       string `yaml:"repo,omitempty"`
	AssetRegex string `yaml:"asset_regex,omitempty"`
	URL        string `yaml:"url,omitempty"`
	// TargetPath is where downloaded bytes land, relative to the
	// instance's BrowseableRoot (if the container exists) or its
	// DataPath bind mount. Required for `direct` + `github` kinds.
	TargetPath string `yaml:"target_path,omitempty"`
	// InstallPath is the container-absolute install dir for SteamCMD's
	// +force_install_dir. Defaults to BrowseableRoot if empty.
	InstallPath string `yaml:"install_path,omitempty"`
	// Extract tells the `direct` / `github` provider to treat the
	// downloaded bytes as an archive (zip / tar / tar.gz / tar.bz2 /
	// tar.xz) and extract its contents INTO TargetPath (a directory)
	// instead of writing the raw bytes as a single file. Factorio's
	// headless .tar.xz uses this. When false, the provider still
	// auto-extracts if the content is a recognized archive AND
	// TargetPath has no file extension (looks like a directory) —
	// but a TargetPath like /server.jar or /terraria-server.zip is
	// always written verbatim (minecraft jars ARE zips; terraria's
	// entrypoint expects the zip file on disk).
	Extract bool `yaml:"extract,omitempty"`
	// SkipValidate omits the `validate` keyword from the `app_update`
	// command. Conan Exiles (app 443030) and a handful of other apps
	// fail with "Missing configuration" when validate runs against an
	// empty install dir; plain `app_update` succeeds. Operators who
	// need periodic integrity checks can run Update manually later.
	SkipValidate bool `yaml:"skip_validate,omitempty"`
	// RequiresSteamLogin means `+login anonymous` won't work — the app
	// is paid content (DayZ 223350, Arma 3 229470, etc.) and SteamCMD
	// needs a real Steam account that owns it. When true, the controller
	// halts the update flow with {steam_login_required: true} if no
	// cached credential is available, letting the UI pop a login modal.
	// Once creds are stored, they're injected as `+login <user> <pass>`.
	RequiresSteamLogin bool `yaml:"requires_steam_login,omitempty"`
}

// Secret declares an operator-supplied or panel-generated credential.
type Secret struct {
	Name        string `yaml:"name"`
	Description string `yaml:"description,omitempty"`
	Generated   bool   `yaml:"generated,omitempty"`
}

// Lifecycle declares shell commands (or WASM hooks — future) that run at
// specific lifecycle phases. Commands run inside the instance sandbox.
type Lifecycle struct {
	PreInstall  []string `yaml:"pre_install,omitempty"`
	PostInstall []string `yaml:"post_install,omitempty"`
	PreStart    []string `yaml:"pre_start,omitempty"`
	PostStart   []string `yaml:"post_start,omitempty"`
	PreStop     []string `yaml:"pre_stop,omitempty"`
	PostStop    []string `yaml:"post_stop,omitempty"`
}

// Duration is a time.Duration that parses from YAML strings like "30s".
type Duration time.Duration

// UnmarshalYAML parses "30s" / "5m" / "1h" style values into a Duration.
func (d *Duration) UnmarshalYAML(node *yaml.Node) error {
	var s string
	if err := node.Decode(&s); err != nil {
		return err
	}
	if s == "" {
		*d = 0
		return nil
	}
	v, err := time.ParseDuration(s)
	if err != nil {
		return fmt.Errorf("invalid duration %q: %w", s, err)
	}
	*d = Duration(v)
	return nil
}

// Std returns the underlying time.Duration.
func (d Duration) Std() time.Duration { return time.Duration(d) }

// HasMode reports whether the module supports the given runtime mode.
func (m *Manifest) HasMode(mode string) bool {
	for _, x := range m.SupportedModes {
		if x == mode {
			return true
		}
	}
	return false
}

// Port returns the named port declaration, or nil if not declared.
func (m *Manifest) Port(name string) *Port {
	for i := range m.Ports {
		if m.Ports[i].Name == name {
			return &m.Ports[i]
		}
	}
	return nil
}

// UpdateProvider returns the named update provider, or nil.
func (m *Manifest) UpdateProvider(id string) *UpdateProvider {
	for i := range m.UpdateProviders {
		if m.UpdateProviders[i].ID == id {
			return &m.UpdateProviders[i]
		}
	}
	return nil
}
