package project

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"io/fs"
	"os"
	"path/filepath"
	"slices"
	"strings"
	"time"

	"gopkg.in/yaml.v3"
)

const (
	ConfigFile       = "nwn-tool.yaml"
	ConfigFileYML    = "nwn-tool.yml"
	LegacyConfigFile = "nwn-tool.json"
)

var ConfigFileCandidates = []string{ConfigFile, ConfigFileYML, LegacyConfigFile}

var SourceExtensions = []string{
	".are", ".dlg", ".fac", ".git", ".ifo", ".jrl",
	".utc", ".utd", ".ute", ".uti", ".utm", ".utp", ".uts", ".utt", ".utw",
}

var AssetExtensions = []string{
	".2da", ".bik", ".bmp", ".bmu", ".dds", ".dwk", ".gr2", ".itp", ".jpg", ".lod", ".lyt", ".mdb", ".mdl", ".mdx", ".mp3", ".mtr", ".ogg", ".plt", ".png", ".pwk", ".set", ".shd", ".tga", ".txi", ".uti", ".vis", ".wav", ".wlk", ".wok", ".xml",
}

var BuiltinScriptPrefixes = []string{
	"nw_",
	"x0_",
	"x1_",
	"x2_",
	"x3_",
	"ga_",
	"gc_",
	"gen_",
	"gui_",
	"nwg_",
	"ta_",
}

var RequiredFields = map[string][]string{
	"ifo": {"Mod_Name"},
}

func DefaultBuiltinScriptPrefixes() []string {
	return slices.Clone(BuiltinScriptPrefixes)
}

func DefaultRequiredFields() map[string][]string {
	out := make(map[string][]string, len(RequiredFields))
	for extension, fields := range RequiredFields {
		out[extension] = slices.Clone(fields)
	}
	return out
}

type Project struct {
	Root         string
	Config       Config
	ConfigSource ConfigSource
	Provenance   ConfigProvenance
	Inventory    Inventory
}

type ConfigSource struct {
	Path   string
	Name   string
	Format string
	Legacy bool
}

type Config struct {
	Module     ModuleConfig     `json:"module" yaml:"module"`
	Paths      PathConfig       `json:"paths" yaml:"paths"`
	Outputs    OutputConfig     `json:"outputs" yaml:"outputs"`
	Build      BuildConfig      `json:"build" yaml:"build"`
	Generated  GeneratedConfig  `json:"generated_assets" yaml:"generated_assets"`
	HAKs       []HAKConfig      `json:"haks" yaml:"haks"`
	Inventory  InventoryConfig  `json:"inventory" yaml:"inventory"`
	Validation ValidationConfig `json:"validation" yaml:"validation"`
	Scripts    ScriptsConfig    `json:"scripts" yaml:"scripts"`
	Music      MusicConfig      `json:"music" yaml:"music"`
	TopData    TopDataConfig    `json:"topdata" yaml:"topdata"`
	Extract    ExtractConfig    `json:"extract" yaml:"extract"`
	Autogen    AutogenConfig    `json:"autogen" yaml:"autogen"`
}

type ModuleConfig struct {
	Name        string   `json:"name" yaml:"name"`
	ResRef      string   `json:"resref" yaml:"resref"`
	Description string   `json:"description" yaml:"description"`
	HAKOrder    []string `json:"hak_order" yaml:"hak_order"`
}

type PathConfig struct {
	Source string `json:"source" yaml:"source"`
	Assets string `json:"assets" yaml:"assets"`
	Build  string `json:"build" yaml:"build"`
	Cache  string `json:"cache" yaml:"cache"`
	Tools  string `json:"tools" yaml:"tools"`
}

type OutputConfig struct {
	ModuleArchive string `json:"module_archive" yaml:"module_archive"`
	HAKManifest   string `json:"hak_manifest" yaml:"hak_manifest"`
	HAKArchive    string `json:"hak_archive" yaml:"hak_archive"`
}

type BuildConfig struct {
	KeepExistingHAKs bool `json:"keep_existing_haks,omitempty" yaml:"keep_existing_haks,omitempty"`
}

type GeneratedConfig struct {
	TopData2DA []GeneratedTopData2DAConfig `json:"topdata_2da" yaml:"topdata_2da"`
}

type GeneratedTopData2DAConfig struct {
	ID              string                `json:"id" yaml:"id"`
	Source          string                `json:"source" yaml:"source"`
	Output          string                `json:"output" yaml:"output"`
	IncludeDatasets []string              `json:"include_datasets" yaml:"include_datasets"`
	PackageRoot     string                `json:"package_root" yaml:"package_root"`
	Autogen         AutogenConsumerConfig `json:"autogen" yaml:"autogen"`
}

type InventoryConfig struct {
	SourceExtensions  []string `json:"source_extensions" yaml:"source_extensions"`
	AssetExtensions   []string `json:"asset_extensions" yaml:"asset_extensions"`
	SourceJSONPattern string   `json:"source_json_pattern" yaml:"source_json_pattern"`
}

type ValidationConfig struct {
	Profile               string              `json:"profile" yaml:"profile"`
	BuiltinScriptPrefixes []string            `json:"builtin_script_prefixes" yaml:"builtin_script_prefixes"`
	RequiredFields        map[string][]string `json:"required_fields" yaml:"required_fields"`
}

type ScriptsConfig struct {
	SourceDir string               `json:"source_dir" yaml:"source_dir"`
	Cache     string               `json:"cache" yaml:"cache"`
	Compiler  ScriptCompilerConfig `json:"compiler" yaml:"compiler"`
}

type ScriptCompilerConfig struct {
	Path   string                  `json:"path" yaml:"path"`
	Search []string                `json:"search" yaml:"search"`
	Env    ScriptCompilerEnvConfig `json:"env" yaml:"env"`
}

type ScriptCompilerEnvConfig struct {
	Path             string   `json:"path" yaml:"path"`
	NWNRoot          []string `json:"nwn_root" yaml:"nwn_root"`
	NWNUserDirectory []string `json:"nwn_user_directory" yaml:"nwn_user_directory"`
}

type HAKConfig struct {
	Name     string   `json:"name" yaml:"name"`
	Priority int      `json:"priority" yaml:"priority"`
	MaxBytes int64    `json:"max_bytes" yaml:"max_bytes"`
	Split    bool     `json:"split" yaml:"split"`
	Optional bool     `json:"optional,omitempty" yaml:"optional,omitempty"`
	Include  []string `json:"include" yaml:"include"`
}

type MusicConfig struct {
	Prefixes map[string]string             `json:"prefixes,omitempty" yaml:"prefixes,omitempty"`
	Tools    MusicToolsConfig              `json:"tools,omitempty" yaml:"tools,omitempty"`
	Defaults *MusicDefaultsConfig          `json:"defaults,omitempty" yaml:"defaults,omitempty"`
	Datasets map[string]MusicDatasetConfig `json:"datasets,omitempty" yaml:"datasets,omitempty"`
}

type MusicToolsConfig struct {
	FFmpeg  string `json:"ffmpeg,omitempty" yaml:"ffmpeg,omitempty"`
	FFprobe string `json:"ffprobe,omitempty" yaml:"ffprobe,omitempty"`
}

type MusicDefaultsConfig struct {
	StageRoot         string   `json:"stage_root,omitempty" yaml:"stage_root,omitempty"`
	CreditsRoot       string   `json:"credits_root,omitempty" yaml:"credits_root,omitempty"`
	CreditsOverlay    string   `json:"credits_overlay,omitempty" yaml:"credits_overlay,omitempty"`
	MaxStemLength     *int     `json:"max_stem_length,omitempty" yaml:"max_stem_length,omitempty"`
	NamingScheme      string   `json:"naming_scheme,omitempty" yaml:"naming_scheme,omitempty"`
	OutputExtension   string   `json:"output_extension,omitempty" yaml:"output_extension,omitempty"`
	ConvertExtensions []string `json:"convert_extensions,omitempty" yaml:"convert_extensions,omitempty"`
}

type MusicDatasetConfig struct {
	Source            string   `json:"source" yaml:"source"`
	Output            string   `json:"output,omitempty" yaml:"output,omitempty"`
	Prefix            string   `json:"prefix,omitempty" yaml:"prefix,omitempty"`
	StageRoot         string   `json:"stage_root,omitempty" yaml:"stage_root,omitempty"`
	CreditsRoot       string   `json:"credits_root,omitempty" yaml:"credits_root,omitempty"`
	NamingScheme      string   `json:"naming_scheme,omitempty" yaml:"naming_scheme,omitempty"`
	OutputExtension   string   `json:"output_extension,omitempty" yaml:"output_extension,omitempty"`
	PackageMode       string   `json:"package_mode,omitempty" yaml:"package_mode,omitempty"`
	ConvertExtensions []string `json:"convert_extensions,omitempty" yaml:"convert_extensions,omitempty"`
}

type TopDataConfig struct {
	Source           string            `json:"source" yaml:"source"`
	Build            string            `json:"build" yaml:"build"`
	ReferenceBuilder string            `json:"reference_builder" yaml:"reference_builder"`
	Assets           string            `json:"assets" yaml:"assets"`
	Compiled2DADir   string            `json:"compiled_2da_dir" yaml:"compiled_2da_dir"`
	CompiledTLK      string            `json:"compiled_tlk" yaml:"compiled_tlk"`
	PackageHAK       string            `json:"package_hak" yaml:"package_hak"`
	PackageTLK       string            `json:"package_tlk" yaml:"package_tlk"`
	Wiki             TopDataWikiConfig `json:"wiki" yaml:"wiki"`
}

type TopDataWikiConfig struct {
	OutputRoot           string                         `json:"output_root" yaml:"output_root"`
	PagesDir             string                         `json:"pages_dir" yaml:"pages_dir"`
	StateFile            string                         `json:"state_file" yaml:"state_file"`
	ManagedNamespaces    []string                       `json:"managed_namespaces" yaml:"managed_namespaces"`
	DeployManifest       string                         `json:"deploy_manifest" yaml:"deploy_manifest"`
	DeployEditSummary    string                         `json:"deploy_edit_summary" yaml:"deploy_edit_summary"`
	Source               string                         `json:"source" yaml:"source"`
	Renderer             string                         `json:"renderer" yaml:"renderer"`
	LinkStrategy         string                         `json:"link_strategy" yaml:"link_strategy"`
	NamespacesFile       string                         `json:"namespaces_file" yaml:"namespaces_file"`
	TemplatesDir         string                         `json:"templates_dir" yaml:"templates_dir"`
	ManualSectionsDir    string                         `json:"manual_sections_dir" yaml:"manual_sections_dir"`
	TitlePrefixMinLength int                            `json:"title_prefix_min_length" yaml:"title_prefix_min_length"`
	StalePages           TopDataWikiStalePagesConfig    `json:"stale_pages" yaml:"stale_pages"`
	ManagedRegion        TopDataWikiManagedRegionConfig `json:"managed_region" yaml:"managed_region"`
}

type TopDataWikiStalePagesConfig struct {
	Default     string `json:"default" yaml:"default"`
	LiveCleanup string `json:"live_cleanup" yaml:"live_cleanup"`
}

type TopDataWikiManagedRegionConfig struct {
	MarkerFormat     string `json:"marker_format" yaml:"marker_format"`
	PageMarkerPrefix string `json:"page_marker_prefix" yaml:"page_marker_prefix"`
}

type ExtractConfig struct {
	IgnoreExtensions                []string `json:"ignore_extensions" yaml:"ignore_extensions"`
	Archives                        []string `json:"archives,omitempty" yaml:"archives,omitempty"`
	Layout                          string   `json:"layout" yaml:"layout"`
	HAKDiscovery                    string   `json:"hak_discovery" yaml:"hak_discovery"`
	CleanupStale                    *bool    `json:"cleanup_stale,omitempty" yaml:"cleanup_stale,omitempty"`
	ConsumeArchives                 *bool    `json:"consume_archives,omitempty" yaml:"consume_archives,omitempty"`
	DeleteModuleArchiveAfterSuccess bool     `json:"delete_module_archive_after_success,omitempty" yaml:"delete_module_archive_after_success,omitempty"`
}

type AutogenConfig struct {
	Producers     []AutogenProducerConfig    `json:"producers" yaml:"producers"`
	Consumers     []AutogenConsumerConfig    `json:"consumers" yaml:"consumers"`
	Cache         AutogenCacheConfig         `json:"cache" yaml:"cache"`
	ReleaseSource AutogenReleaseSourceConfig `json:"release_source" yaml:"release_source"`
}

type AutogenCacheConfig struct {
	Root       string `json:"root" yaml:"root"`
	MaxAge     string `json:"max_age" yaml:"max_age"`
	RefreshEnv string `json:"refresh_env" yaml:"refresh_env"`
}

type AutogenReleaseSourceConfig struct {
	Provider     string `json:"provider" yaml:"provider"`
	ServerURLEnv string `json:"server_url_env" yaml:"server_url_env"`
	RepoEnv      string `json:"repo_env" yaml:"repo_env"`
}

type AutogenProducerConfig struct {
	ID       string                `json:"id" yaml:"id"`
	Root     string                `json:"root" yaml:"root"`
	Include  []string              `json:"include" yaml:"include"`
	Derive   AutogenDeriveConfig   `json:"derive" yaml:"derive"`
	Manifest AutogenManifestConfig `json:"manifest" yaml:"manifest"`
}

type AutogenConsumerConfig struct {
	ID                string                `json:"id" yaml:"id"`
	Producer          string                `json:"producer" yaml:"producer"`
	Dataset           string                `json:"dataset" yaml:"dataset"`
	Mode              string                `json:"mode" yaml:"mode"`
	Optional          bool                  `json:"optional,omitempty" yaml:"optional,omitempty"`
	Root              string                `json:"root" yaml:"root"`
	Include           []string              `json:"include" yaml:"include"`
	Derive            AutogenDeriveConfig   `json:"derive" yaml:"derive"`
	PartsRows         PartsRowsConfig       `json:"parts_rows" yaml:"parts_rows"`
	Manifest          AutogenManifestConfig `json:"manifest" yaml:"manifest"`
	LocalOverrideRoot string                `json:"local_override_root,omitempty" yaml:"local_override_root,omitempty"`
}

type PartsRowsConfig struct {
	RowDefaults map[string]string                 `json:"row_defaults" yaml:"row_defaults"`
	ACBonus     PartsRowsACBonusConfig            `json:"acbonus" yaml:"acbonus"`
	Datasets    map[string]PartsRowsDatasetConfig `json:"datasets" yaml:"datasets"`
}

type PartsRowsDatasetConfig struct {
	ACBonus PartsRowsACBonusPolicy `json:"acbonus" yaml:"acbonus"`
}

type PartsRowsACBonusConfig struct {
	Default  PartsRowsACBonusPolicy            `json:"default" yaml:"default"`
	Datasets map[string]PartsRowsACBonusPolicy `json:"datasets" yaml:"datasets"`
}

type PartsRowsACBonusPolicy struct {
	Strategy string `json:"strategy" yaml:"strategy"`
	MaxRowID int    `json:"max_row_id" yaml:"max_row_id"`
	Divisor  int    `json:"divisor" yaml:"divisor"`
	Format   string `json:"format" yaml:"format"`
	Value    string `json:"value" yaml:"value"`
}

type AutogenDeriveConfig struct {
	Kind      string `json:"kind" yaml:"kind"`
	GroupFrom string `json:"group_from,omitempty" yaml:"group_from,omitempty"`
}

type AutogenManifestConfig struct {
	ReleaseTag string `json:"release_tag" yaml:"release_tag"`
	AssetName  string `json:"asset_name" yaml:"asset_name"`
	CacheName  string `json:"cache_name" yaml:"cache_name"`
}

type Inventory struct {
	SourceFiles []string
	ScriptFiles []string
	AssetFiles  []string
	Extensions  []string
}

type InventoryReport struct {
	SourceFiles int
	ScriptFiles int
	AssetFiles  int
	Extensions  []string
}

func FindRoot(start string) (string, error) {
	current := start
	for {
		for _, name := range ConfigFileCandidates {
			candidate := filepath.Join(current, name)
			if _, err := os.Stat(candidate); err == nil {
				return current, nil
			}
		}
		parent := filepath.Dir(current)
		if parent == current {
			return "", fmt.Errorf("could not find %s, %s, or legacy %s from %s", ConfigFile, ConfigFileYML, LegacyConfigFile, start)
		}
		current = parent
	}
}

func Load(root string) (*Project, error) {
	source, err := findConfigSource(root)
	if err != nil {
		return nil, err
	}
	raw, err := os.ReadFile(source.Path)
	if err != nil {
		return nil, fmt.Errorf("read %s: %w", source.Name, err)
	}

	cfg := defaultConfig()
	provenance, err := configProvenance(raw, source)
	if err != nil {
		return nil, err
	}
	if source.Format == "yaml" {
		decoder := yaml.NewDecoder(bytes.NewReader(raw))
		decoder.KnownFields(true)
		if err := decoder.Decode(&cfg); err != nil {
			return nil, fmt.Errorf("parse %s: %w", source.Name, err)
		}
		var extra any
		if err := decoder.Decode(&extra); err != io.EOF {
			return nil, fmt.Errorf("parse %s: multiple YAML documents are not supported", source.Name)
		}
	} else {
		decoder := json.NewDecoder(bytes.NewReader(raw))
		decoder.DisallowUnknownFields()
		if err := decoder.Decode(&cfg); err != nil {
			return nil, fmt.Errorf("parse legacy %s: %w", source.Name, err)
		}
		var extra any
		if err := decoder.Decode(&extra); err != io.EOF {
			return nil, fmt.Errorf("parse legacy %s: multiple JSON documents are not supported", source.Name)
		}
	}
	normalizeConfig(&cfg)

	return &Project{
		Root:         root,
		Config:       cfg,
		ConfigSource: source,
		Provenance:   provenance,
	}, nil
}

func findConfigSource(root string) (ConfigSource, error) {
	for _, name := range ConfigFileCandidates {
		path := filepath.Join(root, name)
		if _, err := os.Stat(path); err == nil {
			format := "yaml"
			legacy := false
			if name == LegacyConfigFile {
				format = "json"
				legacy = true
			}
			return ConfigSource{
				Path:   path,
				Name:   name,
				Format: format,
				Legacy: legacy,
			}, nil
		}
	}
	return ConfigSource{}, fmt.Errorf("could not find %s, %s, or legacy %s in %s", ConfigFile, ConfigFileYML, LegacyConfigFile, root)
}

func configProvenance(raw []byte, source ConfigSource) (ConfigProvenance, error) {
	var data any
	if source.Format == "yaml" {
		if err := yaml.Unmarshal(raw, &data); err != nil {
			return nil, fmt.Errorf("parse %s for provenance: %w", source.Name, err)
		}
	} else {
		if err := json.Unmarshal(raw, &data); err != nil {
			return nil, fmt.Errorf("parse legacy %s for provenance: %w", source.Name, err)
		}
	}
	provenance := ConfigProvenance{}
	sourceName := "yaml"
	if source.Legacy {
		sourceName = "legacy json"
	}
	recordProvenance(provenance, "", data, ConfigValueProvenance{
		Source:     sourceName,
		Detail:     source.Name,
		Deprecated: source.Legacy,
	})
	return provenance, nil
}

func recordProvenance(provenance ConfigProvenance, prefix string, value any, source ConfigValueProvenance) {
	switch typed := value.(type) {
	case map[string]any:
		for key, nested := range typed {
			next := key
			if prefix != "" {
				next = prefix + "." + key
			}
			recordProvenance(provenance, next, nested, source)
		}
	case []any:
		if prefix != "" {
			provenance[prefix] = source
		}
	default:
		if prefix != "" {
			provenance[prefix] = source
		}
	}
}

func (p *Project) ValidateLayout() error {
	var failures []error

	if strings.TrimSpace(p.Config.Module.Name) == "" {
		failures = append(failures, errors.New("module.name is required"))
	}
	if strings.TrimSpace(p.Config.Module.ResRef) == "" {
		failures = append(failures, errors.New("module.resref is required"))
	}
	if len(p.Config.Module.ResRef) > 16 {
		failures = append(failures, fmt.Errorf("module.resref %q exceeds 16 characters", p.Config.Module.ResRef))
	}
	if strings.TrimSpace(p.Config.Paths.Source) == "" && strings.TrimSpace(p.Config.Paths.Assets) == "" && !p.HasTopData() {
		failures = append(failures, errors.New("at least one of paths.source, paths.assets, or topdata.source is required"))
	}
	for field, value := range map[string]string{
		"paths.source":                          p.Config.Paths.Source,
		"paths.assets":                          p.Config.Paths.Assets,
		"paths.build":                           p.Config.Paths.Build,
		"paths.cache":                           p.Config.Paths.Cache,
		"paths.tools":                           p.Config.Paths.Tools,
		"outputs.module_archive":                p.Config.Outputs.ModuleArchive,
		"outputs.hak_manifest":                  p.Config.Outputs.HAKManifest,
		"outputs.hak_archive":                   p.Config.Outputs.HAKArchive,
		"generated_assets.topdata_2da":          "",
		"validation.profile":                    p.Config.Validation.Profile,
		"scripts.source_dir":                    p.Config.Scripts.SourceDir,
		"scripts.cache":                         p.Config.Scripts.Cache,
		"scripts.compiler.path":                 p.Config.Scripts.Compiler.Path,
		"scripts.compiler.env.path":             p.Config.Scripts.Compiler.Env.Path,
		"topdata.source":                        p.Config.TopData.Source,
		"topdata.build":                         p.Config.TopData.Build,
		"topdata.reference_builder":             p.Config.TopData.ReferenceBuilder,
		"topdata.assets":                        p.Config.TopData.Assets,
		"topdata.compiled_2da_dir":              p.Config.TopData.Compiled2DADir,
		"topdata.compiled_tlk":                  p.Config.TopData.CompiledTLK,
		"topdata.wiki.output_root":              p.Config.TopData.Wiki.OutputRoot,
		"topdata.wiki.pages_dir":                p.Config.TopData.Wiki.PagesDir,
		"topdata.wiki.state_file":               p.Config.TopData.Wiki.StateFile,
		"topdata.wiki.deploy_manifest":          p.Config.TopData.Wiki.DeployManifest,
		"topdata.wiki.deploy_edit_summary":      p.Config.TopData.Wiki.DeployEditSummary,
		"extract.layout":                        p.Config.Extract.Layout,
		"extract.hak_discovery":                 p.Config.Extract.HAKDiscovery,
		"autogen.cache.root":                    p.Config.Autogen.Cache.Root,
		"autogen.cache.max_age":                 p.Config.Autogen.Cache.MaxAge,
		"autogen.cache.refresh_env":             p.Config.Autogen.Cache.RefreshEnv,
		"autogen.release_source.provider":       p.Config.Autogen.ReleaseSource.Provider,
		"autogen.release_source.server_url_env": p.Config.Autogen.ReleaseSource.ServerURLEnv,
		"autogen.release_source.repo_env":       p.Config.Autogen.ReleaseSource.RepoEnv,
	} {
		if strings.Contains(value, "\x00") {
			failures = append(failures, fmt.Errorf("%s must not contain NUL bytes", field))
		}
	}
	failures = append(failures, validateValidationConfig(p.Config.Validation)...)
	effective := p.EffectiveConfig()
	failures = append(failures, validateRelativePath("paths.cache", effective.Paths.Cache)...)
	failures = append(failures, validateTreeRootPath("paths.source", effective.Paths.Source)...)
	failures = append(failures, validateTreeRootPath("paths.assets", effective.Paths.Assets)...)
	failures = append(failures, validateRelativePath("outputs.module_archive", effective.Outputs.ModuleArchive)...)
	failures = append(failures, validateRelativePath("outputs.hak_manifest", effective.Outputs.HAKManifest)...)
	failures = append(failures, validateRelativePath("outputs.hak_archive", effective.Outputs.HAKArchive)...)
	failures = append(failures, validateGeneratedConfig(effective.Generated)...)
	failures = append(failures, validateRelativePath("scripts.cache", effective.Scripts.Cache)...)
	failures = append(failures, validateRelativePath("scripts.source_dir", effective.Scripts.SourceDir)...)
	failures = append(failures, validateRelativePath("topdata.compiled_2da_dir", effective.TopData.Compiled2DADir)...)
	failures = append(failures, validateRelativePath("topdata.compiled_tlk", effective.TopData.CompiledTLK)...)
	failures = append(failures, validateRelativePath("topdata.wiki.output_root", effective.TopData.Wiki.OutputRoot)...)
	failures = append(failures, validateRelativePath("topdata.wiki.pages_dir", effective.TopData.Wiki.PagesDir)...)
	failures = append(failures, validateRelativePath("topdata.wiki.state_file", effective.TopData.Wiki.StateFile)...)
	failures = append(failures, validateRelativePath("topdata.wiki.deploy_manifest", effective.TopData.Wiki.DeployManifest)...)
	failures = append(failures, validateTreeRootPath("topdata.wiki.source", effective.TopData.Wiki.Source)...)
	failures = append(failures, validateRelativePath("topdata.wiki.namespaces_file", effective.TopData.Wiki.NamespacesFile)...)
	failures = append(failures, validateTreeRootPath("topdata.wiki.templates_dir", effective.TopData.Wiki.TemplatesDir)...)
	failures = append(failures, validateTreeRootPath("topdata.wiki.manual_sections_dir", effective.TopData.Wiki.ManualSectionsDir)...)
	failures = append(failures, validateRelativePath("autogen.cache.root", effective.Autogen.Cache.Root)...)
	if err := validateOutputFileName("outputs.module_archive", filepath.Base(effective.Outputs.ModuleArchive), ".mod"); err != nil {
		failures = append(failures, err)
	}
	if err := validateOutputFileName("outputs.hak_manifest", filepath.Base(effective.Outputs.HAKManifest), ".json"); err != nil {
		failures = append(failures, err)
	}
	if err := validateOutputFileName("outputs.hak_archive", filepath.Base(effective.Outputs.HAKArchive), ".hak"); err != nil {
		failures = append(failures, err)
	}
	if err := validateOutputFileName("topdata.wiki.deploy_manifest", filepath.Base(effective.TopData.Wiki.DeployManifest), ".json"); err != nil {
		failures = append(failures, err)
	}
	switch effective.TopData.Wiki.Renderer {
	case "nodebb_tiptap_html":
	default:
		failures = append(failures, fmt.Errorf("topdata.wiki.renderer %q is not supported", effective.TopData.Wiki.Renderer))
	}
	switch effective.TopData.Wiki.LinkStrategy {
	case "preserve_westgate_wiki_links":
	default:
		failures = append(failures, fmt.Errorf("topdata.wiki.link_strategy %q is not supported", effective.TopData.Wiki.LinkStrategy))
	}
	if effective.TopData.Wiki.TitlePrefixMinLength < 1 {
		failures = append(failures, fmt.Errorf("topdata.wiki.title_prefix_min_length must be at least 1"))
	}
	for key, policy := range map[string]string{
		"topdata.wiki.stale_pages.default":      effective.TopData.Wiki.StalePages.Default,
		"topdata.wiki.stale_pages.live_cleanup": effective.TopData.Wiki.StalePages.LiveCleanup,
	} {
		switch policy {
		case "report", "archive", "unpublish":
		default:
			failures = append(failures, fmt.Errorf("%s %q is not supported", key, policy))
		}
	}
	switch effective.Extract.Layout {
	case "nwn_canonical_json":
	default:
		failures = append(failures, fmt.Errorf("extract.layout %q is not supported", effective.Extract.Layout))
	}
	switch effective.Extract.HAKDiscovery {
	case "build_glob", "configured_haks":
	default:
		failures = append(failures, fmt.Errorf("extract.hak_discovery %q is not supported", effective.Extract.HAKDiscovery))
	}
	failures = append(failures, validateGlobList("extract.archives", effective.Extract.Archives)...)
	if _, err := time.ParseDuration(effective.Autogen.Cache.MaxAge); err != nil {
		failures = append(failures, fmt.Errorf("autogen.cache.max_age must be a duration: %w", err))
	}
	if effective.Autogen.ReleaseSource.Provider != "gitea" {
		failures = append(failures, fmt.Errorf("autogen.release_source.provider %q is not supported", effective.Autogen.ReleaseSource.Provider))
	}
	if p.HasTopData() {
		if err := validateOutputFileName("topdata.package_hak", p.TopDataPackageHAKName(), ".hak"); err != nil {
			failures = append(failures, err)
		}
		if err := validateOutputFileName("topdata.package_tlk", p.TopDataPackageTLKName(), ".tlk"); err != nil {
			failures = append(failures, err)
		}
		if err := validateOutputFileName("topdata.compiled_tlk", filepath.Base(p.TopDataCompiledTLKPath()), ".tlk"); err != nil {
			failures = append(failures, err)
		}
	}
	failures = append(failures, validateAutogenConfig(p.Config.Autogen)...)
	failures = append(failures, validateMusicConfig(p.Config.Music)...)

	hakNames := map[string]struct{}{}
	for index, hak := range p.Config.HAKs {
		name := strings.TrimSpace(hak.Name)
		if strings.TrimSpace(hak.Name) == "" {
			failures = append(failures, fmt.Errorf("haks[%d].name is required", index))
		} else {
			normalizedName := strings.ToLower(name)
			if _, exists := hakNames[normalizedName]; exists {
				failures = append(failures, fmt.Errorf("haks[%d].name %q is duplicated", index, name))
			}
			hakNames[normalizedName] = struct{}{}
			if err := validateOutputFileName(fmt.Sprintf("haks[%d].name", index), name+".hak", ".hak"); err != nil {
				failures = append(failures, err)
			}
		}
		if hak.Priority <= 0 {
			failures = append(failures, fmt.Errorf("haks[%d].priority must be greater than zero", index))
		}
		if hak.MaxBytes < 0 {
			failures = append(failures, fmt.Errorf("haks[%d].max_bytes must be zero or greater", index))
		}
		if len(hak.Include) == 0 {
			failures = append(failures, fmt.Errorf("haks[%d].include must have at least one pattern", index))
		}
		failures = append(failures, validateGlobList(fmt.Sprintf("haks[%d].include", index), hak.Include)...)
	}

	for label, pathFn := range map[string]func() string{
		"paths.build": p.BuildDir,
	} {
		path := pathFn()
		if path == "" || filepath.Clean(path) == p.Root {
			continue
		}
		info, err := os.Stat(path)
		if err != nil {
			failures = append(failures, fmt.Errorf("%s: %w", label, err))
			continue
		}
		if !info.IsDir() {
			failures = append(failures, fmt.Errorf("%s must be a directory: %s", label, path))
		}
	}

	if len(failures) > 0 {
		return errors.Join(failures...)
	}

	return nil
}

func (p *Project) Scan() error {
	var sourceFiles []string
	var sourceExts []string
	effective := p.EffectiveConfig()

	sourceDir := p.SourceDir()
	if sourceDir != "" && filepath.Clean(sourceDir) != filepath.Clean(p.Root) {
		var err error
		sourceFiles, sourceExts, err = scanDir(sourceDir, func(path string) bool {
			ext := strings.ToLower(filepath.Ext(path))
			return ext == ".json" || ext == ".nss"
		})
		if err != nil {
			if !errors.Is(err, os.ErrNotExist) {
				return fmt.Errorf("scan source tree: %w", err)
			}
		}
	}

	assetDir := p.AssetsDir()
	var assetFiles []string
	if assetDir != "" && filepath.Clean(assetDir) != filepath.Clean(p.Root) {
		musicSourceExts := make(map[string]struct{})
		for _, ext := range effective.Music.ConvertExtensions {
			musicSourceExts[ext] = struct{}{}
		}
		musicSourceRoots := make(map[string]map[string]struct{})
		for _, ds := range effective.Music.Datasets {
			if strings.TrimSpace(ds.Source) == "" {
				continue
			}
			exts := musicSourceRoots[ds.Source]
			if exts == nil {
				exts = make(map[string]struct{})
				musicSourceRoots[ds.Source] = exts
			}
			for _, ext := range ds.ConvertExtensions {
				exts[ext] = struct{}{}
			}
			for ext := range musicSourceExts {
				exts[ext] = struct{}{}
			}
		}
		var err error
		assetFiles, _, err = scanDir(assetDir, func(path string) bool {
			rel, err := filepath.Rel(assetDir, path)
			if err != nil {
				return false
			}
			rel = filepath.ToSlash(rel)
			ext := strings.ToLower(filepath.Ext(rel))
			if slices.Contains(effective.Inventory.AssetExtensions, ext) {
				return true
			}
			for root, exts := range musicSourceRoots {
				if !pathIsUnder(root, rel) {
					continue
				}
				_, ok := exts[ext]
				return ok
			}
			return false
		})
		if err != nil {
			if errors.Is(err, os.ErrNotExist) {
				assetFiles = nil
			} else {
				return fmt.Errorf("scan assets tree: %w", err)
			}
		}
	}

	var scripts []string
	var sources []string
	extensionSet := map[string]struct{}{}
	for _, ext := range sourceExts {
		extensionSet[ext] = struct{}{}
	}

	for _, path := range sourceFiles {
		switch strings.ToLower(filepath.Ext(path)) {
		case ".nss":
			scripts = append(scripts, path)
		case ".json":
			sources = append(sources, path)
		}
	}

	extensions := make([]string, 0, len(extensionSet))
	for ext := range extensionSet {
		extensions = append(extensions, ext)
	}
	slices.Sort(extensions)

	p.Inventory = Inventory{
		SourceFiles: sources,
		ScriptFiles: scripts,
		AssetFiles:  assetFiles,
		Extensions:  extensions,
	}
	return nil
}

func (p *Project) SourceDir() string {
	return p.rootPath(p.EffectiveConfig().Paths.Source)
}

func (p *Project) AssetsDir() string {
	assets := p.EffectiveConfig().Paths.Assets
	if assets == "" {
		return ""
	}
	return p.rootPath(assets)
}

func (p *Project) BuildDir() string {
	return p.rootPath(p.EffectiveConfig().Paths.Build)
}

func (p *Project) CacheDir() string {
	return p.rootPath(p.EffectiveConfig().Paths.Cache)
}

func (p *Project) ModuleArchivePath() string {
	effective := p.EffectiveConfig()
	return filepath.Join(p.BuildDir(), effective.Outputs.ModuleArchiveName(effective.Module))
}

func (p *Project) HAKManifestPath() string {
	return filepath.Join(p.BuildDir(), filepath.FromSlash(p.EffectiveConfig().Outputs.HAKManifest))
}

func (p *Project) HAKArchivePath(name string) string {
	return filepath.Join(p.BuildDir(), filepath.FromSlash(p.EffectiveConfig().Outputs.HAKArchiveName(name)))
}

func (p *Project) CloneWithHAKNames(names []string) (*Project, error) {
	if len(names) == 0 {
		return p, nil
	}

	wanted := make(map[string]struct{}, len(names))
	for _, name := range names {
		trimmed := strings.TrimSpace(strings.ToLower(name))
		if trimmed == "" {
			continue
		}
		wanted[trimmed] = struct{}{}
	}
	if len(wanted) == 0 {
		return p, nil
	}

	filtered := make([]HAKConfig, 0, len(p.Config.HAKs))
	found := make(map[string]struct{}, len(wanted))
	for _, hak := range p.Config.HAKs {
		key := strings.ToLower(strings.TrimSpace(hak.Name))
		if _, ok := wanted[key]; !ok {
			continue
		}
		filtered = append(filtered, hak)
		found[key] = struct{}{}
	}

	var missing []string
	for _, name := range names {
		key := strings.TrimSpace(strings.ToLower(name))
		if key == "" {
			continue
		}
		if _, ok := found[key]; !ok {
			missing = append(missing, name)
		}
	}
	if len(missing) > 0 {
		return nil, fmt.Errorf("unknown hak name(s): %s", strings.Join(missing, ", "))
	}

	clone := *p
	clone.Config = p.Config
	clone.Config.HAKs = filtered
	clone.Inventory = p.Inventory
	return &clone, nil
}

func (p *Project) HasTopData() bool {
	return strings.TrimSpace(p.Config.TopData.Source) != ""
}

func (p *Project) TopDataSourceDir() string {
	if !p.HasTopData() {
		return ""
	}
	return filepath.Join(p.Root, p.Config.TopData.Source)
}

func (p *Project) TopDataBuildDir() string {
	return p.rootPath(p.EffectiveConfig().TopData.Build)
}

func (p *Project) TopDataReferenceBuilderDir() string {
	ref := strings.TrimSpace(p.Config.TopData.ReferenceBuilder)
	if ref == "" {
		return ""
	}
	if filepath.IsAbs(ref) {
		return ref
	}
	return filepath.Join(p.Root, ref)
}

func (p *Project) TopDataAssetsDir() string {
	assets := strings.TrimSpace(p.Config.TopData.Assets)
	if assets == "" {
		return ""
	}
	if filepath.IsAbs(assets) {
		return assets
	}
	absPath := filepath.Join(p.Root, assets)
	if _, err := os.Stat(absPath); err == nil {
		return absPath
	}
	return absPath
}

func (p *Project) TopDataUsesRepoCache() bool {
	return sameCleanPath(p.TopDataBuildDir(), p.CacheDir())
}

func (p *Project) TopDataCompiled2DADir() string {
	return filepath.Join(p.TopDataBuildDir(), filepath.FromSlash(p.EffectiveConfig().TopData.Compiled2DADir))
}

func (p *Project) TopDataCompiledTLKPath() string {
	tlkName := p.EffectiveConfig().TopData.CompiledTLK
	if p.TopDataUsesRepoCache() {
		return filepath.Join(p.BuildDir(), filepath.FromSlash(tlkName))
	}
	return filepath.Join(p.TopDataBuildDir(), filepath.FromSlash(tlkName))
}

func (p *Project) TopDataCompiledTLKDir() string {
	return filepath.Dir(p.TopDataCompiledTLKPath())
}

func (p *Project) TopDataWikiRootDir() string {
	wikiRoot := filepath.FromSlash(p.EffectiveConfig().TopData.Wiki.OutputRoot)
	if p.TopDataUsesRepoCache() {
		return filepath.Join(p.CacheDir(), wikiRoot)
	}
	return filepath.Join(p.TopDataBuildDir(), wikiRoot)
}

func (p *Project) TopDataWikiSourceDir() string {
	source := filepath.FromSlash(p.EffectiveConfig().TopData.Wiki.Source)
	if filepath.IsAbs(source) {
		return source
	}
	return filepath.Join(p.Root, source)
}

func (p *Project) TopDataWikiPagesDir() string {
	return filepath.Join(p.TopDataWikiRootDir(), filepath.FromSlash(p.EffectiveConfig().TopData.Wiki.PagesDir))
}

func (p *Project) TopDataWikiStatePath() string {
	return filepath.Join(p.TopDataWikiRootDir(), filepath.FromSlash(p.EffectiveConfig().TopData.Wiki.StateFile))
}

func (p *Project) TopDataPackageHAKName() string {
	return p.EffectiveConfig().TopData.PackageHAK
}

func (p *Project) TopDataPackageTLKName() string {
	return p.EffectiveConfig().TopData.PackageTLK
}

func (p *Project) TopDataPackageHAKPath() string {
	return filepath.Join(p.BuildDir(), p.TopDataPackageHAKName())
}

func (p *Project) TopDataPackageTLKPath() string {
	return filepath.Join(p.BuildDir(), p.TopDataPackageTLKName())
}

func (p *Project) ScriptSourceDir() string {
	return filepath.Join(p.SourceDir(), filepath.FromSlash(p.EffectiveConfig().Scripts.SourceDir))
}

func (p *Project) ScriptCacheDir() string {
	return p.rootPath(p.EffectiveConfig().Scripts.Cache)
}

func (p *Project) ScriptCompilerPath() string {
	path := strings.TrimSpace(p.EffectiveConfig().Scripts.Compiler.Path)
	if path == "" {
		return ""
	}
	if filepath.IsAbs(path) {
		return path
	}
	return filepath.Join(p.Root, filepath.FromSlash(path))
}

func (p *Project) ScriptCompilerSearchPaths() []string {
	search := p.EffectiveConfig().Scripts.Compiler.Search
	out := make([]string, 0, len(search))
	for _, candidate := range search {
		candidate = strings.TrimSpace(candidate)
		if candidate == "" {
			continue
		}
		if strings.HasPrefix(candidate, "PATH:") || filepath.IsAbs(candidate) {
			out = append(out, candidate)
			continue
		}
		out = append(out, filepath.Join(p.Root, filepath.FromSlash(candidate)))
	}
	return out
}

func (p *Project) ScriptCompilerPathEnv() string {
	return p.EffectiveConfig().Scripts.Compiler.Env.Path
}

func (p *Project) ScriptCompilerNWNRootEnvKeys() []string {
	return slices.Clone(p.EffectiveConfig().Scripts.Compiler.Env.NWNRoot)
}

func (p *Project) ScriptCompilerNWNUserDirectoryEnvKeys() []string {
	return slices.Clone(p.EffectiveConfig().Scripts.Compiler.Env.NWNUserDirectory)
}

func (p *Project) MusicStageDir() string {
	return p.rootPath(p.EffectiveConfig().Music.StageRoot)
}

func (p *Project) CreditsDir() string {
	return p.rootPath(p.EffectiveConfig().Music.CreditsRoot)
}

func (p *Project) MusicToolPath(path string) string {
	path = strings.TrimSpace(path)
	if path == "" {
		return ""
	}
	if filepath.IsAbs(path) {
		return path
	}
	return filepath.Join(p.Root, filepath.FromSlash(path))
}

func (p *Project) MusicFFmpegPath() string {
	return p.MusicToolPath(p.EffectiveConfig().Music.Tools.FFmpeg)
}

func (p *Project) MusicFFprobePath() string {
	return p.MusicToolPath(p.EffectiveConfig().Music.Tools.FFprobe)
}

func (p *Project) AutogenCacheDir() string {
	return p.rootPath(p.EffectiveConfig().Autogen.Cache.Root)
}

func (p *Project) AutogenCachePath(name string) string {
	return filepath.Join(p.AutogenCacheDir(), filepath.FromSlash(strings.TrimSpace(name)))
}

func (p *Project) AutogenCacheMaxAge() time.Duration {
	duration, err := time.ParseDuration(p.EffectiveConfig().Autogen.Cache.MaxAge)
	if err != nil {
		return DefaultAutogenCacheMaxAge
	}
	return duration
}

func (p *Project) AutogenRefreshEnv() string {
	return p.EffectiveConfig().Autogen.Cache.RefreshEnv
}

func (p *Project) PartsManifestRefreshEnv() string {
	return p.EffectiveConfig().Autogen.Cache.PartsManifestRefreshEnv
}

func (p *Project) AssetsServerURLEnv() string {
	return p.EffectiveConfig().Autogen.ReleaseSource.ServerURLEnv
}

func (p *Project) AssetsRepoEnv() string {
	return p.EffectiveConfig().Autogen.ReleaseSource.RepoEnv
}

func (p *Project) rootPath(path string) string {
	if strings.TrimSpace(path) == "" {
		return p.Root
	}
	if filepath.IsAbs(path) {
		return path
	}
	return filepath.Join(p.Root, filepath.FromSlash(path))
}

func (i Inventory) Report() InventoryReport {
	return InventoryReport{
		SourceFiles: len(i.SourceFiles),
		ScriptFiles: len(i.ScriptFiles),
		AssetFiles:  len(i.AssetFiles),
		Extensions:  i.Extensions,
	}
}

func defaultConfig() Config {
	return Config{
		Paths: PathConfig{
			Build: DefaultBuildPath,
		},
	}
}

func normalizeConfig(cfg *Config) {
	cfg.Inventory.SourceExtensions = normalizeExtensionSlice(cfg.Inventory.SourceExtensions)
	cfg.Inventory.AssetExtensions = normalizeExtensionSlice(cfg.Inventory.AssetExtensions)
	cfg.Validation.BuiltinScriptPrefixes = normalizeLowerStringSlice(cfg.Validation.BuiltinScriptPrefixes)
	cfg.Validation.Profile = strings.ToLower(strings.TrimSpace(cfg.Validation.Profile))
	cfg.Validation.RequiredFields = normalizeRequiredFields(cfg.Validation.RequiredFields)
	for i := range cfg.HAKs {
		cfg.HAKs[i].Include = normalizeStringSlice(cfg.HAKs[i].Include)
	}
	cfg.Extract.IgnoreExtensions = normalizeStringSlice(cfg.Extract.IgnoreExtensions)
	cfg.Extract.Archives = normalizeStringSlice(cfg.Extract.Archives)
	for i := range cfg.Autogen.Producers {
		cfg.Autogen.Producers[i].Include = normalizeStringSlice(cfg.Autogen.Producers[i].Include)
	}
	for i := range cfg.Autogen.Consumers {
		cfg.Autogen.Consumers[i].Include = normalizeStringSlice(cfg.Autogen.Consumers[i].Include)
	}
	normalizeMusicConfig(&cfg.Music)
}

func normalizeMusicConfig(music *MusicConfig) {
	if music.Defaults != nil {
		music.Defaults.ConvertExtensions = normalizeExtensionSlice(music.Defaults.ConvertExtensions)
		music.Defaults.OutputExtension = normalizeExtension(music.Defaults.OutputExtension)
	}
	if len(music.Prefixes) > 0 && len(music.Datasets) == 0 {
		datasets := make(map[string]MusicDatasetConfig, len(music.Prefixes))
		for dir, prefix := range music.Prefixes {
			source := trimConfigSlashes(filepath.ToSlash(strings.TrimSpace(dir)))
			if source == "" {
				continue
			}
			key := strings.ReplaceAll(source, "/", "_")
			datasets[key] = MusicDatasetConfig{
				Source: source,
				Prefix: strings.TrimSpace(prefix),
			}
		}
		music.Datasets = datasets
	}
	for id, ds := range music.Datasets {
		ds.Source = trimConfigSlashes(filepath.ToSlash(strings.TrimSpace(ds.Source)))
		ds.Output = trimConfigSlashes(filepath.ToSlash(strings.TrimSpace(ds.Output)))
		ds.OutputExtension = normalizeExtension(ds.OutputExtension)
		ds.ConvertExtensions = normalizeExtensionSlice(ds.ConvertExtensions)
		music.Datasets[id] = ds
	}
}

func normalizeStringSlice(input []string) []string {
	if len(input) == 0 {
		return input
	}
	out := slices.Clone(input)
	slices.SortFunc(out, func(a, b string) int {
		return strings.Compare(strings.TrimSpace(a), strings.TrimSpace(b))
	})
	return out
}

func normalizeExtensionSlice(input []string) []string {
	if len(input) == 0 {
		return input
	}
	out := make([]string, 0, len(input))
	for _, value := range input {
		trimmed := strings.ToLower(strings.TrimSpace(value))
		if trimmed != "" && !strings.HasPrefix(trimmed, ".") {
			trimmed = "." + trimmed
		}
		out = append(out, trimmed)
	}
	slices.Sort(out)
	return out
}

func normalizeExtension(input string) string {
	trimmed := strings.ToLower(strings.TrimSpace(input))
	if trimmed != "" && !strings.HasPrefix(trimmed, ".") {
		trimmed = "." + trimmed
	}
	return trimmed
}

func normalizeLowerStringSlice(input []string) []string {
	if len(input) == 0 {
		return input
	}
	out := make([]string, 0, len(input))
	for _, value := range input {
		trimmed := strings.ToLower(strings.TrimSpace(value))
		if trimmed != "" {
			out = append(out, trimmed)
		}
	}
	slices.Sort(out)
	return out
}

func normalizeRequiredFields(input map[string][]string) map[string][]string {
	if len(input) == 0 {
		return input
	}
	out := make(map[string][]string, len(input))
	for extension, labels := range input {
		key := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(extension)), ".")
		if key == "" {
			continue
		}
		normalizedLabels := make([]string, 0, len(labels))
		for _, label := range labels {
			label = strings.TrimSpace(label)
			if label != "" {
				normalizedLabels = append(normalizedLabels, label)
			}
		}
		slices.Sort(normalizedLabels)
		out[key] = normalizedLabels
	}
	return out
}

func validateValidationConfig(cfg ValidationConfig) []error {
	var failures []error
	switch strings.TrimSpace(cfg.Profile) {
	case "", DefaultValidationProfile:
	default:
		failures = append(failures, fmt.Errorf("validation.profile %q is not supported", cfg.Profile))
	}
	seen := map[string]struct{}{}
	for index, prefix := range cfg.BuiltinScriptPrefixes {
		prefix = strings.TrimSpace(prefix)
		if prefix == "" {
			failures = append(failures, fmt.Errorf("validation.builtin_script_prefixes[%d] must not be empty", index))
			continue
		}
		if strings.Contains(prefix, "\x00") {
			failures = append(failures, fmt.Errorf("validation.builtin_script_prefixes[%d] must not contain NUL bytes", index))
		}
		normalized := strings.ToLower(prefix)
		if _, exists := seen[normalized]; exists {
			failures = append(failures, fmt.Errorf("validation.builtin_script_prefixes[%d] %q is duplicated", index, prefix))
		}
		seen[normalized] = struct{}{}
	}
	seenExtensions := map[string]struct{}{}
	for extension, labels := range cfg.RequiredFields {
		normalizedExtension := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(extension)), ".")
		if normalizedExtension == "" {
			failures = append(failures, errors.New("validation.required_fields contains an empty extension"))
			continue
		}
		if strings.Contains(normalizedExtension, "\x00") {
			failures = append(failures, fmt.Errorf("validation.required_fields[%q] must not contain NUL bytes", extension))
		}
		if _, exists := seenExtensions[normalizedExtension]; exists {
			failures = append(failures, fmt.Errorf("validation.required_fields[%q] is duplicated after normalization", extension))
		}
		seenExtensions[normalizedExtension] = struct{}{}
		seenLabels := map[string]struct{}{}
		for index, label := range labels {
			label = strings.TrimSpace(label)
			if label == "" {
				failures = append(failures, fmt.Errorf("validation.required_fields[%q][%d] must not be empty", extension, index))
				continue
			}
			if strings.Contains(label, "\x00") {
				failures = append(failures, fmt.Errorf("validation.required_fields[%q][%d] must not contain NUL bytes", extension, index))
			}
			if _, exists := seenLabels[label]; exists {
				failures = append(failures, fmt.Errorf("validation.required_fields[%q][%d] %q is duplicated", extension, index, label))
			}
			seenLabels[label] = struct{}{}
		}
	}
	return failures
}

func validateAutogenConfig(cfg AutogenConfig) []error {
	var failures []error
	producerIDs := map[string]struct{}{}
	consumerIDs := map[string]struct{}{}
	datasetIDs := map[string]struct{}{}

	for index, producer := range cfg.Producers {
		fieldPrefix := fmt.Sprintf("autogen.producers[%d]", index)
		id := strings.TrimSpace(producer.ID)
		if id == "" {
			failures = append(failures, fmt.Errorf("%s.id is required", fieldPrefix))
		} else {
			if _, exists := producerIDs[id]; exists {
				failures = append(failures, fmt.Errorf("%s.id %q is duplicated", fieldPrefix, id))
			}
			producerIDs[id] = struct{}{}
		}
		if strings.TrimSpace(producer.Root) == "" {
			failures = append(failures, fmt.Errorf("%s.root is required", fieldPrefix))
		}
		if len(producer.Include) == 0 {
			failures = append(failures, fmt.Errorf("%s.include must contain at least one glob", fieldPrefix))
		}
		failures = append(failures, validateGlobList(fieldPrefix+".include", producer.Include)...)
		failures = append(failures, validateAutogenDeriveConfig(fieldPrefix+".derive", producer.Derive)...)
		failures = append(failures, validateAutogenManifestConfig(fieldPrefix+".manifest", producer.Manifest)...)
	}

	for index, consumer := range cfg.Consumers {
		fieldPrefix := fmt.Sprintf("autogen.consumers[%d]", index)
		id := strings.TrimSpace(consumer.ID)
		if id == "" {
			failures = append(failures, fmt.Errorf("%s.id is required", fieldPrefix))
		} else {
			if _, exists := consumerIDs[id]; exists {
				failures = append(failures, fmt.Errorf("%s.id %q is duplicated", fieldPrefix, id))
			}
			consumerIDs[id] = struct{}{}
		}
		if strings.TrimSpace(consumer.Producer) == "" {
			failures = append(failures, fmt.Errorf("%s.producer is required", fieldPrefix))
		}
		if strings.TrimSpace(consumer.Dataset) == "" {
			failures = append(failures, fmt.Errorf("%s.dataset is required", fieldPrefix))
		} else {
			dataset := strings.TrimSpace(consumer.Dataset)
			if _, exists := datasetIDs[dataset]; exists {
				failures = append(failures, fmt.Errorf("%s.dataset %q is duplicated", fieldPrefix, dataset))
			}
			datasetIDs[dataset] = struct{}{}
		}
		if strings.TrimSpace(consumer.Producer) != "" {
			if _, exists := producerIDs[strings.TrimSpace(consumer.Producer)]; len(producerIDs) > 0 && !exists {
				failures = append(failures, fmt.Errorf("%s.producer %q does not match a configured producer", fieldPrefix, consumer.Producer))
			}
		}
		switch strings.TrimSpace(consumer.Mode) {
		case "parts_rows", "head_visualeffects":
		case "":
			failures = append(failures, fmt.Errorf("%s.mode is required", fieldPrefix))
		default:
			failures = append(failures, fmt.Errorf("%s.mode %q is not supported", fieldPrefix, consumer.Mode))
		}
		if strings.TrimSpace(consumer.Root) == "" {
			failures = append(failures, fmt.Errorf("%s.root is required", fieldPrefix))
		}
		if len(consumer.Include) == 0 {
			failures = append(failures, fmt.Errorf("%s.include must contain at least one glob", fieldPrefix))
		}
		failures = append(failures, validateGlobList(fieldPrefix+".include", consumer.Include)...)
		failures = append(failures, validateAutogenDeriveConfig(fieldPrefix+".derive", consumer.Derive)...)
		failures = append(failures, validateAutogenManifestConfig(fieldPrefix+".manifest", consumer.Manifest)...)
	}

	return failures
}

func validateGeneratedConfig(cfg GeneratedConfig) []error {
	var failures []error
	seen := map[string]struct{}{}
	for index, top2da := range cfg.TopData2DA {
		fieldPrefix := fmt.Sprintf("generated_assets.topdata_2da[%d]", index)
		id := strings.TrimSpace(top2da.ID)
		if id == "" {
			failures = append(failures, fmt.Errorf("%s.id is required", fieldPrefix))
		} else {
			if _, exists := seen[id]; exists {
				failures = append(failures, fmt.Errorf("%s.id %q is duplicated", fieldPrefix, id))
			}
			seen[id] = struct{}{}
		}
		if strings.TrimSpace(top2da.Source) == "" {
			failures = append(failures, fmt.Errorf("%s.source is required", fieldPrefix))
		}
		failures = append(failures, validateTreeRootPath(fieldPrefix+".source", top2da.Source)...)
		if strings.TrimSpace(top2da.Output) == "" {
			failures = append(failures, fmt.Errorf("%s.output is required", fieldPrefix))
		}
		failures = append(failures, validateRelativePath(fieldPrefix+".output", top2da.Output)...)
		if strings.TrimSpace(top2da.PackageRoot) == "" {
			failures = append(failures, fmt.Errorf("%s.package_root is required", fieldPrefix))
		}
		failures = append(failures, validateTreeRootPath(fieldPrefix+".package_root", top2da.PackageRoot)...)
		if len(top2da.IncludeDatasets) == 0 {
			failures = append(failures, fmt.Errorf("%s.include_datasets must contain at least one pattern", fieldPrefix))
		}
		failures = append(failures, validateGlobList(fieldPrefix+".include_datasets", top2da.IncludeDatasets)...)
		switch strings.TrimSpace(top2da.Autogen.Mode) {
		case "parts_rows":
		case "":
			failures = append(failures, fmt.Errorf("%s.autogen.mode is required", fieldPrefix))
		default:
			failures = append(failures, fmt.Errorf("%s.autogen.mode %q is not supported", fieldPrefix, top2da.Autogen.Mode))
		}
		if strings.TrimSpace(top2da.Autogen.Root) == "" {
			failures = append(failures, fmt.Errorf("%s.autogen.root is required", fieldPrefix))
		}
		failures = append(failures, validateTreeRootPath(fieldPrefix+".autogen.root", top2da.Autogen.Root)...)
		if len(top2da.Autogen.Include) == 0 {
			failures = append(failures, fmt.Errorf("%s.autogen.include must contain at least one glob", fieldPrefix))
		}
		failures = append(failures, validateGlobList(fieldPrefix+".autogen.include", top2da.Autogen.Include)...)
		failures = append(failures, validateAutogenDeriveConfig(fieldPrefix+".autogen.derive", top2da.Autogen.Derive)...)
		if strings.TrimSpace(top2da.Autogen.Mode) == "parts_rows" && partsRowsConfigConfigured(top2da.Autogen.PartsRows) {
			failures = append(failures, validatePartsRowsConfig(fieldPrefix+".autogen.parts_rows", top2da.Autogen.PartsRows)...)
		}
	}
	return failures
}

func partsRowsConfigConfigured(cfg PartsRowsConfig) bool {
	if len(cfg.RowDefaults) > 0 {
		return true
	}
	if strings.TrimSpace(cfg.ACBonus.Default.Strategy) != "" {
		return true
	}
	if len(cfg.ACBonus.Datasets) > 0 {
		return true
	}
	if len(cfg.Datasets) > 0 {
		return true
	}
	return false
}

func validatePartsRowsConfig(fieldPrefix string, cfg PartsRowsConfig) []error {
	var failures []error
	failures = append(failures, validatePartsRowsACBonusPolicy(fieldPrefix+".acbonus.default", cfg.ACBonus.Default, false)...)
	for dataset, policy := range cfg.ACBonus.Datasets {
		dataset = strings.TrimSpace(dataset)
		if dataset == "" {
			failures = append(failures, fmt.Errorf("%s.acbonus.datasets contains an empty dataset key", fieldPrefix))
			continue
		}
		failures = append(failures, validatePartsRowsACBonusPolicy(fieldPrefix+".acbonus.datasets."+dataset, policy, true)...)
	}
	for dataset, datasetCfg := range cfg.Datasets {
		dataset = strings.TrimSpace(dataset)
		if dataset == "" {
			failures = append(failures, fmt.Errorf("%s.datasets contains an empty dataset key", fieldPrefix))
			continue
		}
		failures = append(failures, validatePartsRowsACBonusPolicy(fieldPrefix+".datasets."+dataset+".acbonus", datasetCfg.ACBonus, true)...)
	}
	return failures
}

func validatePartsRowsACBonusPolicy(fieldPrefix string, policy PartsRowsACBonusPolicy, allowEmpty bool) []error {
	strategy := strings.TrimSpace(policy.Strategy)
	if strategy == "" {
		if allowEmpty {
			return nil
		}
		return []error{fmt.Errorf("%s.strategy is required", fieldPrefix)}
	}
	switch strategy {
	case "descending_row_id_sort_key":
		var failures []error
		if policy.MaxRowID <= 0 {
			failures = append(failures, fmt.Errorf("%s.max_row_id must be greater than 0", fieldPrefix))
		}
		if policy.Divisor <= 0 {
			failures = append(failures, fmt.Errorf("%s.divisor must be greater than 0", fieldPrefix))
		}
		if err := validatePartsRowsFormat(policy.Format); err != nil {
			failures = append(failures, fmt.Errorf("%s.format %q is invalid: %w", fieldPrefix, policy.Format, err))
		}
		return failures
	case "fixed":
		if strings.TrimSpace(policy.Value) == "" {
			return []error{fmt.Errorf("%s.value is required for fixed strategy", fieldPrefix)}
		}
		return nil
	default:
		return []error{fmt.Errorf("%s.strategy %q is not supported", fieldPrefix, policy.Strategy)}
	}
}

func validatePartsRowsFormat(format string) error {
	if strings.TrimSpace(format) == "" {
		return fmt.Errorf("must not be empty")
	}
	formatted := fmt.Sprintf(format, 1.25)
	if strings.Contains(formatted, "%!") {
		return fmt.Errorf("must format a numeric value")
	}
	return nil
}

func validateMusicConfig(cfg MusicConfig) []error {
	var failures []error
	if cfg.Defaults != nil {
		if cfg.Defaults.MaxStemLength != nil && *cfg.Defaults.MaxStemLength < 3 {
			failures = append(failures, errors.New("music.defaults.max_stem_length must be at least 3"))
		}
		if cfg.Defaults.NamingScheme != "" && cfg.Defaults.NamingScheme != "nwn_bmu" && cfg.Defaults.NamingScheme != "passthrough" {
			failures = append(failures, fmt.Errorf("music.defaults.naming_scheme %q is not supported", cfg.Defaults.NamingScheme))
		}
		if cfg.Defaults.OutputExtension != "" && !strings.HasPrefix(cfg.Defaults.OutputExtension, ".") {
			failures = append(failures, errors.New("music.defaults.output_extension must start with ."))
		}
	}
	normalizedRoots := map[string]string{}
	for root, prefix := range cfg.Prefixes {
		normalizedRoot := trimConfigSlashes(filepath.ToSlash(strings.TrimSpace(root)))
		if normalizedRoot == "" {
			failures = append(failures, errors.New("music.prefixes contains an empty root"))
			continue
		}
		if prior, exists := normalizedRoots[normalizedRoot]; exists {
			failures = append(failures, fmt.Errorf("music.prefixes roots %q and %q normalize to the same path", prior, root))
		}
		normalizedRoots[normalizedRoot] = root
		if strings.TrimSpace(prefix) == "" {
			failures = append(failures, fmt.Errorf("music.prefixes[%q] must not be empty", root))
		}
	}
	seenSources := map[string]string{}
	for id, ds := range cfg.Datasets {
		if strings.TrimSpace(id) == "" {
			failures = append(failures, errors.New("music.datasets contains an empty key"))
		} else if strings.Contains(id, "/") || strings.Contains(id, "\\") {
			failures = append(failures, fmt.Errorf("music.datasets id %q must not contain path separators", id))
		}
		source := trimConfigSlashes(filepath.ToSlash(strings.TrimSpace(ds.Source)))
		if source == "" {
			failures = append(failures, fmt.Errorf("music.datasets[%q].source is required", id))
		} else if pathEscapesRoot(source) {
			failures = append(failures, fmt.Errorf("music.datasets[%q].source must not escape project root", id))
		} else if prior, exists := seenSources[source]; exists {
			failures = append(failures, fmt.Errorf("music.datasets[%q].source %q conflicts with dataset %q", id, ds.Source, prior))
		} else {
			seenSources[source] = id
		}
		output := trimConfigSlashes(filepath.ToSlash(strings.TrimSpace(ds.Output)))
		if output != "" && pathEscapesRoot(output) {
			failures = append(failures, fmt.Errorf("music.datasets[%q].output must not escape project root", id))
		}
		if ds.NamingScheme != "" && ds.NamingScheme != "nwn_bmu" && ds.NamingScheme != "passthrough" {
			failures = append(failures, fmt.Errorf("music.datasets[%q].naming_scheme %q is not supported", id, ds.NamingScheme))
		}
		if ds.PackageMode != "" && ds.PackageMode != "hak_asset" && ds.PackageMode != "none" {
			failures = append(failures, fmt.Errorf("music.datasets[%q].package_mode %q is not supported", id, ds.PackageMode))
		}
		if ds.OutputExtension != "" && !strings.HasPrefix(ds.OutputExtension, ".") {
			failures = append(failures, fmt.Errorf("music.datasets[%q].output_extension must start with .", id))
		}
	}
	return failures
}

func pathEscapesRoot(path string) bool {
	clean := filepath.Clean(filepath.FromSlash(path))
	return clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator))
}

func validateGlobList(field string, patterns []string) []error {
	var failures []error
	seen := map[string]struct{}{}
	for index, pattern := range patterns {
		trimmed := strings.TrimSpace(pattern)
		if trimmed == "" {
			failures = append(failures, fmt.Errorf("%s[%d] must not be empty", field, index))
			continue
		}
		if strings.Contains(trimmed, "\x00") {
			failures = append(failures, fmt.Errorf("%s[%d] must not contain NUL bytes", field, index))
		}
		if _, exists := seen[trimmed]; exists {
			failures = append(failures, fmt.Errorf("%s[%d] duplicates %q", field, index, trimmed))
		}
		seen[trimmed] = struct{}{}
	}
	return failures
}

func trimConfigSlashes(path string) string {
	return strings.Trim(path, "/")
}

func validateAutogenDeriveConfig(fieldPrefix string, cfg AutogenDeriveConfig) []error {
	var failures []error
	switch strings.TrimSpace(cfg.Kind) {
	case "trailing_numeric_suffix", "model_stem":
	case "":
		failures = append(failures, fmt.Errorf("%s.kind is required", fieldPrefix))
	default:
		failures = append(failures, fmt.Errorf("%s.kind %q is not supported", fieldPrefix, cfg.Kind))
	}
	switch strings.TrimSpace(cfg.GroupFrom) {
	case "", "first_path_segment":
	default:
		failures = append(failures, fmt.Errorf("%s.group_from %q is not supported", fieldPrefix, cfg.GroupFrom))
	}
	return failures
}

func validateAutogenManifestConfig(fieldPrefix string, cfg AutogenManifestConfig) []error {
	var failures []error
	if strings.TrimSpace(cfg.ReleaseTag) == "" {
		failures = append(failures, fmt.Errorf("%s.release_tag is required", fieldPrefix))
	}
	if err := validateOutputFileName(fieldPrefix+".asset_name", cfg.AssetName, ".json"); err != nil {
		failures = append(failures, err)
	}
	if err := validateOutputFileName(fieldPrefix+".cache_name", cfg.CacheName, ".json"); err != nil {
		failures = append(failures, err)
	}
	return failures
}

func scanDir(root string, include func(path string) bool) ([]string, []string, error) {
	var files []string
	extSet := map[string]struct{}{}

	err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if d.IsDir() {
			return nil
		}
		if !include(path) {
			return nil
		}

		rel, err := filepath.Rel(root, path)
		if err != nil {
			return err
		}
		rel = filepath.ToSlash(rel)
		files = append(files, rel)

		if strings.HasSuffix(rel, ".json") {
			base := strings.TrimSuffix(filepath.Base(rel), ".json")
			ext := strings.ToLower(filepath.Ext(base))
			if ext != "" {
				extSet[ext] = struct{}{}
			}
		}

		return nil
	})
	if err != nil {
		return nil, nil, err
	}

	slices.Sort(files)

	exts := make([]string, 0, len(extSet))
	for ext := range extSet {
		exts = append(exts, ext)
	}
	slices.Sort(exts)
	return files, exts, nil
}

func validateOutputFileName(field, name, expectedExt string) error {
	trimmed := strings.TrimSpace(name)
	if trimmed == "" {
		return fmt.Errorf("%s is required", field)
	}
	if filepath.Base(trimmed) != trimmed {
		return fmt.Errorf("%s must be a file name, not a path: %q", field, name)
	}
	if !strings.EqualFold(filepath.Ext(trimmed), expectedExt) {
		return fmt.Errorf("%s must use %s extension: %q", field, expectedExt, name)
	}
	return nil
}

func pathIsUnder(root, rel string) bool {
	root = strings.Trim(strings.TrimSpace(filepath.ToSlash(root)), "/")
	rel = strings.Trim(strings.TrimSpace(filepath.ToSlash(rel)), "/")
	if root == "" || rel == "" {
		return false
	}
	return rel == root || strings.HasPrefix(rel, root+"/")
}

func validateRelativePath(field, path string) []error {
	var failures []error
	trimmed := strings.TrimSpace(path)
	if trimmed == "" {
		return failures
	}
	if filepath.IsAbs(trimmed) {
		failures = append(failures, fmt.Errorf("%s must be relative to the repository root: %q", field, path))
	}
	clean := filepath.Clean(filepath.FromSlash(trimmed))
	if clean == "." {
		return failures
	}
	if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
		failures = append(failures, fmt.Errorf("%s must not escape the repository root: %q", field, path))
	}
	return failures
}

func validateTreeRootPath(field, path string) []error {
	failures := validateRelativePath(field, path)
	trimmed := strings.TrimSpace(path)
	if trimmed == "" {
		return failures
	}
	if filepath.Clean(filepath.FromSlash(trimmed)) == "." {
		failures = append(failures, fmt.Errorf("%s must not resolve to the repository root: %q", field, path))
	}
	return failures
}

func sameCleanPath(a, b string) bool {
	return filepath.Clean(a) == filepath.Clean(b)
}
