package project

import (
	"bytes"
	"errors"
	"os"
	"path/filepath"
	"slices"
	"strings"
	"testing"
)

func TestLoadYAMLConfig(t *testing.T) {
	root := t.TempDir()
	writeProjectFile(t, filepath.Join(root, ConfigFile), `
module:
  name: Test Module
  resref: testmod
paths:
  source: src
  assets: assets
  build: output
haks:
  - name: core
    priority: 1
    max_bytes: 1024
    split: false
    include:
      - core/**
`)

	proj, err := Load(root)
	if err != nil {
		t.Fatalf("Load returned error: %v", err)
	}
	if proj.ConfigSource.Name != ConfigFile || proj.ConfigSource.Format != "yaml" || proj.ConfigSource.Legacy {
		t.Fatalf("unexpected config source: %#v", proj.ConfigSource)
	}
	if got, want := proj.Config.Module.Name, "Test Module"; got != want {
		t.Fatalf("expected module name %q, got %q", want, got)
	}
	if got, want := proj.BuildDir(), filepath.Join(root, "output"); got != want {
		t.Fatalf("expected build dir %s, got %s", want, got)
	}
}

func TestEffectiveConfigAppliesVisibleToolkitDefaults(t *testing.T) {
	root := t.TempDir()
	writeProjectFile(t, filepath.Join(root, ConfigFile), `
module:
  name: Test Module
  resref: testmod
paths:
  source: src
`)

	proj, err := Load(root)
	if err != nil {
		t.Fatalf("Load returned error: %v", err)
	}

	effective := proj.EffectiveConfig()
	if got, want := effective.Paths.Build, "build"; got != want {
		t.Fatalf("expected default build path %q, got %q", want, got)
	}
	if got, want := effective.Outputs.HAKManifest, "haks.json"; got != want {
		t.Fatalf("expected default HAK manifest %q, got %q", want, got)
	}
	if got, want := strings.Join(effective.Extract.Archives, ","), "testmod.mod"; got != want {
		t.Fatalf("expected default extract archives %q, got %q", want, got)
	}
	if effective.Extract.ConsumeArchives == nil || *effective.Extract.ConsumeArchives {
		t.Fatalf("expected default extract consume_archives false, got %#v", effective.Extract.ConsumeArchives)
	}
	if got, want := effective.TopData.PackageHAK, "sow_top.hak"; got != want {
		t.Fatalf("expected default topdata HAK %q, got %q", want, got)
	}
	if got, want := strings.Join(effective.Validation.BuiltinScriptPrefixes, ","), "ga_,gc_,gen_,gui_,nw_,nwg_,ta_,x0_,x1_,x2_,x3_"; got != want {
		t.Fatalf("expected default built-in script prefixes %q, got %q", want, got)
	}
	if got := strings.Join(effective.Validation.RequiredFields["ifo"], ","); got != "Mod_Name" {
		t.Fatalf("expected default IFO required fields, got %#v", effective.Validation.RequiredFields)
	}
	if got, want := strings.Join(effective.Music.ConvertExtensions, ","), ".flac,.m4a,.mp3,.ogg,.wav"; got != want {
		t.Fatalf("expected default music convert extensions %q, got %q", want, got)
	}
	if prov := effective.Provenance["paths.build"]; prov.Source != "toolkit default" {
		t.Fatalf("expected paths.build toolkit default provenance, got %#v", prov)
	}
	if prov := effective.Provenance["module.name"]; prov.Source != "yaml" {
		t.Fatalf("expected module.name YAML provenance, got %#v", prov)
	}
}

func TestEffectiveConfigHonorsValidationConfig(t *testing.T) {
	root := t.TempDir()
	writeProjectFile(t, filepath.Join(root, ConfigFile), `
module:
  name: Test Module
  resref: testmod
paths:
  source: src
validation:
  builtin_script_prefixes:
    - custom_
    - NW_
  required_fields:
    ifo:
      - Mod_Name
      - Mod_Hak
`)

	proj, err := Load(root)
	if err != nil {
		t.Fatalf("Load returned error: %v", err)
	}
	got := strings.Join(proj.EffectiveConfig().Validation.BuiltinScriptPrefixes, ",")
	if want := "custom_,nw_"; got != want {
		t.Fatalf("expected configured validation prefixes %q, got %q", want, got)
	}
	if got := strings.Join(proj.EffectiveConfig().Validation.RequiredFields["ifo"], ","); got != "Mod_Hak,Mod_Name" {
		t.Fatalf("expected configured required fields, got %#v", proj.EffectiveConfig().Validation.RequiredFields)
	}
}

func TestScanUsesYAMLMusicConvertExtensionsWithinConfiguredDatasetRoots(t *testing.T) {
	root := t.TempDir()
	if err := os.MkdirAll(filepath.Join(root, "assets", "audio", "westgate"), 0o755); err != nil {
		t.Fatalf("mkdir westgate audio: %v", err)
	}
	if err := os.MkdirAll(filepath.Join(root, "assets", "audio", "other"), 0o755); err != nil {
		t.Fatalf("mkdir other audio: %v", err)
	}
	writeProjectFile(t, filepath.Join(root, ConfigFile), `
module:
  name: Test Module
  resref: testmod
paths:
  source: src
  assets: assets
music:
  defaults:
    convert_extensions:
      - .flac
  datasets:
    westgate_audio:
      source: audio/westgate
      convert_extensions:
        - .aac
`)
	writeProjectFile(t, filepath.Join(root, "assets", "audio", "westgate", "theme.aac"), "aac")
	writeProjectFile(t, filepath.Join(root, "assets", "audio", "westgate", "theme.flac"), "flac")
	writeProjectFile(t, filepath.Join(root, "assets", "audio", "other", "ignored.aac"), "aac")

	proj, err := Load(root)
	if err != nil {
		t.Fatalf("Load returned error: %v", err)
	}
	if err := proj.Scan(); err != nil {
		t.Fatalf("Scan returned error: %v", err)
	}
	if !slices.Contains(proj.Inventory.AssetFiles, "audio/westgate/theme.aac") {
		t.Fatalf("expected configured dataset AAC file to be discovered, got %#v", proj.Inventory.AssetFiles)
	}
	if !slices.Contains(proj.Inventory.AssetFiles, "audio/westgate/theme.flac") {
		t.Fatalf("expected configured dataset FLAC file to be discovered, got %#v", proj.Inventory.AssetFiles)
	}
	if slices.Contains(proj.Inventory.AssetFiles, "audio/other/ignored.aac") {
		t.Fatalf("did not expect non-dataset AAC file to be discovered, got %#v", proj.Inventory.AssetFiles)
	}
}

func TestEffectiveConfigHonorsConfiguredOutputAndCacheFields(t *testing.T) {
	root := t.TempDir()
	writeProjectFile(t, filepath.Join(root, ConfigFile), `
module:
  name: Test Module
  resref: testmod
paths:
  build: output
  cache: cache
outputs:
  module_archive: modules/{module.resref}.mod
  hak_manifest: manifests/haks.json
  hak_archive: haks/{hak.name}.hak
scripts:
  cache: "{paths.cache}/compiled"
topdata:
  source: topdata
  build: "{paths.cache}"
  compiled_2da_dir: tables
  compiled_tlk: dialog.tlk
  package_hak: custom_top.hak
  package_tlk: custom_dialog.tlk
  wiki:
    output_root: docs
    pages_dir: pages
    state_file: wiki-state.json
autogen:
  cache:
    root: "{paths.cache}/autogen"
`)

	proj, err := Load(root)
	if err != nil {
		t.Fatalf("Load returned error: %v", err)
	}

	if got, want := proj.ModuleArchivePath(), filepath.Join(root, "output", "modules", "testmod.mod"); got != want {
		t.Fatalf("expected module archive path %s, got %s", want, got)
	}
	if got, want := proj.HAKManifestPath(), filepath.Join(root, "output", "manifests", "haks.json"); got != want {
		t.Fatalf("expected HAK manifest path %s, got %s", want, got)
	}
	if got, want := proj.HAKArchivePath("core_01"), filepath.Join(root, "output", "haks", "core_01.hak"); got != want {
		t.Fatalf("expected HAK archive path %s, got %s", want, got)
	}
	if got, want := proj.ScriptCacheDir(), filepath.Join(root, "cache", "compiled"); got != want {
		t.Fatalf("expected script cache path %s, got %s", want, got)
	}
	if got, want := proj.TopDataCompiled2DADir(), filepath.Join(root, "cache", "tables"); got != want {
		t.Fatalf("expected topdata 2da path %s, got %s", want, got)
	}
	if got, want := proj.TopDataWikiStatePath(), filepath.Join(root, "cache", "docs", "wiki-state.json"); got != want {
		t.Fatalf("expected wiki state path %s, got %s", want, got)
	}
	if got, want := proj.AutogenCachePath("manifest.json"), filepath.Join(root, "cache", "autogen", "manifest.json"); got != want {
		t.Fatalf("expected autogen cache path %s, got %s", want, got)
	}
}

func TestEffectiveConfigHonorsConfiguredCompilerExtractWikiAndAutogenPolicy(t *testing.T) {
	root := t.TempDir()
	writeProjectFile(t, filepath.Join(root, ConfigFile), `
module:
  name: Test Module
  resref: testmod
paths:
  source: src
  tools: custom-tools
scripts:
  source_dir: module-scripts
  compiler:
    path: bin/compiler
    search:
      - "{paths.tools}/compiler"
    env:
      path: TEST_COMPILER
      nwn_root:
        - TEST_NWN_ROOT
      nwn_user_directory:
        - TEST_NWN_USER
extract:
  layout: nwn_canonical_json
  hak_discovery: configured_haks
  cleanup_stale: false
topdata:
  wiki:
    source: topdata/wiki
    managed_namespaces:
      - skills
    renderer: nodebb_tiptap_html
    link_strategy: preserve_westgate_wiki_links
    namespaces_file: namespaces.yaml
    templates_dir: templates
    manual_sections_dir: manual-sections
    deploy_manifest: wiki-manifest.json
    deploy_edit_summary: Custom summary
    title_prefix_min_length: 4
    stale_pages:
      default: report
      live_cleanup: archive
autogen:
  cache:
    root: "{paths.cache}/released"
    max_age: 30m
    refresh_env: TEST_AUTOGEN_REFRESH
  release_source:
    provider: gitea
    server_url_env: TEST_ASSETS_SERVER
    repo_env: TEST_ASSETS_REPO
`)

	proj, err := Load(root)
	if err != nil {
		t.Fatalf("Load returned error: %v", err)
	}
	effective := proj.EffectiveConfig()
	if got, want := proj.ScriptSourceDir(), filepath.Join(root, "src", "module-scripts"); got != want {
		t.Fatalf("expected script source dir %s, got %s", want, got)
	}
	if got, want := proj.ScriptCompilerPath(), filepath.Join(root, "bin", "compiler"); got != want {
		t.Fatalf("expected compiler path %s, got %s", want, got)
	}
	if got, want := effective.Scripts.Compiler.Search[0], "custom-tools/compiler"; got != want {
		t.Fatalf("expected compiler search %q, got %q", want, got)
	}
	if got, want := effective.Scripts.Compiler.Env.Path, "TEST_COMPILER"; got != want {
		t.Fatalf("expected compiler env %q, got %q", want, got)
	}
	if got, want := effective.Extract.HAKDiscovery, "configured_haks"; got != want {
		t.Fatalf("expected extract hak discovery %q, got %q", want, got)
	}
	if effective.Extract.CleanupStale == nil || *effective.Extract.CleanupStale {
		t.Fatalf("expected cleanup_stale false, got %#v", effective.Extract.CleanupStale)
	}
	if got, want := effective.TopData.Wiki.DeployManifest, "wiki-manifest.json"; got != want {
		t.Fatalf("expected wiki deploy manifest %q, got %q", want, got)
	}
	if got, want := effective.TopData.Wiki.Source, "topdata/wiki"; got != want {
		t.Fatalf("expected wiki source %q, got %q", want, got)
	}
	if got, want := effective.TopData.Wiki.Renderer, "nodebb_tiptap_html"; got != want {
		t.Fatalf("expected wiki renderer %q, got %q", want, got)
	}
	if got, want := effective.TopData.Wiki.LinkStrategy, "preserve_westgate_wiki_links"; got != want {
		t.Fatalf("expected wiki link strategy %q, got %q", want, got)
	}
	if got, want := effective.TopData.Wiki.NamespacesFile, "namespaces.yaml"; got != want {
		t.Fatalf("expected wiki namespaces file %q, got %q", want, got)
	}
	if got, want := effective.TopData.Wiki.TemplatesDir, "templates"; got != want {
		t.Fatalf("expected wiki templates dir %q, got %q", want, got)
	}
	if got, want := effective.TopData.Wiki.ManualSectionsDir, "manual-sections"; got != want {
		t.Fatalf("expected wiki manual sections dir %q, got %q", want, got)
	}
	if got, want := effective.TopData.Wiki.StalePages.Default, "report"; got != want {
		t.Fatalf("expected wiki stale default %q, got %q", want, got)
	}
	if got, want := effective.TopData.Wiki.StalePages.LiveCleanup, "archive"; got != want {
		t.Fatalf("expected wiki stale live cleanup %q, got %q", want, got)
	}
	if got, want := effective.TopData.Wiki.TitlePrefixMinLength, 4; got != want {
		t.Fatalf("expected wiki title prefix minimum length %d, got %d", want, got)
	}
	if got, want := effective.Autogen.Cache.MaxAge, "30m"; got != want {
		t.Fatalf("expected autogen cache max age %q, got %q", want, got)
	}
	if got, want := proj.AutogenRefreshEnv(), "TEST_AUTOGEN_REFRESH"; got != want {
		t.Fatalf("expected autogen refresh env %q, got %q", want, got)
	}
}

func TestActiveOverridesAreVisibleAndSensitiveValuesAreMasked(t *testing.T) {
	root := t.TempDir()
	writeProjectFile(t, filepath.Join(root, ConfigFile), `
module:
  name: Test Module
  resref: testmod
paths:
  source: src
`)
	t.Setenv("SOW_BUILD_HAKS_KEEP_EXISTING", "1")
	t.Setenv("NODEBB_API_TOKEN", "secret-token")

	proj, err := Load(root)
	if err != nil {
		t.Fatalf("Load returned error: %v", err)
	}
	overrides := proj.ActiveOverrides()
	var sawKeepExisting, sawMaskedToken bool
	for _, override := range overrides {
		if override.Key == "build.keep_existing_haks" && override.Value == "1" {
			sawKeepExisting = true
		}
		if override.Key == "wiki.token" && override.Value == "<set>" {
			sawMaskedToken = true
		}
	}
	if !sawKeepExisting {
		t.Fatalf("expected build keep existing override in %#v", overrides)
	}
	if !sawMaskedToken {
		t.Fatalf("expected masked wiki token override in %#v", overrides)
	}
}

func TestEffectiveConfigJSONIsDeterministic(t *testing.T) {
	root := t.TempDir()
	writeProjectFile(t, filepath.Join(root, ConfigFile), `
module:
  name: Test Module
  resref: testmod
paths:
  source: src
`)

	proj, err := Load(root)
	if err != nil {
		t.Fatalf("Load returned error: %v", err)
	}
	first, err := proj.EffectiveConfigJSON()
	if err != nil {
		t.Fatalf("EffectiveConfigJSON returned error: %v", err)
	}
	second, err := proj.EffectiveConfigJSON()
	if err != nil {
		t.Fatalf("EffectiveConfigJSON returned error: %v", err)
	}
	if !bytes.Equal(first, second) {
		t.Fatalf("effective config JSON is not deterministic")
	}
	if !bytes.Contains(first, []byte(`"hak_manifest": "haks.json"`)) {
		t.Fatalf("effective config JSON missing HAK manifest default: %s", first)
	}
}

func TestLoadYMLConfig(t *testing.T) {
	root := t.TempDir()
	writeProjectFile(t, filepath.Join(root, ConfigFileYML), `
module:
  name: Test Module
  resref: testmod
paths:
  source: src
  build: build
`)

	proj, err := Load(root)
	if err != nil {
		t.Fatalf("Load returned error: %v", err)
	}
	if proj.ConfigSource.Name != ConfigFileYML || proj.ConfigSource.Format != "yaml" || proj.ConfigSource.Legacy {
		t.Fatalf("unexpected config source: %#v", proj.ConfigSource)
	}
}

func TestLoadLegacyJSONConfig(t *testing.T) {
	root := t.TempDir()
	writeProjectFile(t, filepath.Join(root, LegacyConfigFile), `{
  "module": {
    "name": "Legacy Module",
    "resref": "legacy"
  },
  "paths": {
    "source": "src",
    "build": "build"
  }
}`+"\n")

	proj, err := Load(root)
	if err != nil {
		t.Fatalf("Load returned error: %v", err)
	}
	if proj.ConfigSource.Name != LegacyConfigFile || proj.ConfigSource.Format != "json" || !proj.ConfigSource.Legacy {
		t.Fatalf("unexpected config source: %#v", proj.ConfigSource)
	}
}

func TestLoadPrefersYAMLOverLegacyJSON(t *testing.T) {
	root := t.TempDir()
	writeProjectFile(t, filepath.Join(root, LegacyConfigFile), `{
  "module": {
    "name": "Legacy Module",
    "resref": "legacy"
  }
}`+"\n")
	writeProjectFile(t, filepath.Join(root, ConfigFile), `
module:
  name: YAML Module
  resref: yamlmod
paths:
  source: src
  build: build
`)

	proj, err := Load(root)
	if err != nil {
		t.Fatalf("Load returned error: %v", err)
	}
	if proj.ConfigSource.Name != ConfigFile {
		t.Fatalf("expected YAML config to win, got %#v", proj.ConfigSource)
	}
	if got, want := proj.Config.Module.ResRef, "yamlmod"; got != want {
		t.Fatalf("expected resref %q, got %q", want, got)
	}
}

func TestLoadRejectsMalformedYAML(t *testing.T) {
	root := t.TempDir()
	writeProjectFile(t, filepath.Join(root, ConfigFile), "module:\n  name: Test\n    resref: bad\n")

	if _, err := Load(root); err == nil {
		t.Fatal("expected malformed YAML error")
	}
}

func TestLoadRejectsUnknownYAMLFields(t *testing.T) {
	root := t.TempDir()
	writeProjectFile(t, filepath.Join(root, ConfigFile), `
module:
  name: Test Module
  resref: testmod
paths:
  source: src
  build: build
unexpected: true
`)

	err := loadAndValidate(root)
	if err == nil {
		t.Fatal("expected unknown field error")
	}
	if !strings.Contains(err.Error(), "field unexpected not found") {
		t.Fatalf("expected unknown field error, got %v", err)
	}
}

func TestFindRootUsesYAMLConfig(t *testing.T) {
	root := t.TempDir()
	child := filepath.Join(root, "nested", "dir")
	mkdirAll(t, child)
	writeProjectFile(t, filepath.Join(root, ConfigFile), "module:\n  name: Test\n  resref: test\n")

	got, err := FindRoot(child)
	if err != nil {
		t.Fatalf("FindRoot returned error: %v", err)
	}
	if got != root {
		t.Fatalf("expected root %s, got %s", root, got)
	}
}

func TestTopDataAssetsDirPrefersExplicitConfig(t *testing.T) {
	root := t.TempDir()
	mkdirAll(t, filepath.Join(root, "src"))
	mkdirAll(t, filepath.Join(root, "assets"))
	mkdirAll(t, filepath.Join(root, "build"))
	mkdirAll(t, filepath.Join(root, "custom-assets"))

	proj := &Project{
		Root: root,
		Config: Config{
			Module: ModuleConfig{Name: "Test", ResRef: "test"},
			Paths:  PathConfig{Source: "src", Assets: "assets", Build: "build"},
			TopData: TopDataConfig{
				Assets: "custom-assets",
			},
		},
	}

	result := proj.TopDataAssetsDir()
	expected := filepath.Join(root, "custom-assets")
	if result != expected {
		t.Errorf("expected %s, got %s", expected, result)
	}
}

func TestTopDataAssetsDirDoesNotAssumeSiblingRepo(t *testing.T) {
	root := t.TempDir()
	mkdirAll(t, filepath.Join(root, "src"))
	mkdirAll(t, filepath.Join(root, "assets"))
	mkdirAll(t, filepath.Join(root, "build"))

	parentDir := filepath.Dir(root)
	siblingPath := filepath.Join(parentDir, "sow-assets")
	mkdirAll(t, siblingPath)
	mkdirAll(t, filepath.Join(siblingPath, "assets", "part", "belt"))

	proj := &Project{
		Root: root,
		Config: Config{
			Module: ModuleConfig{Name: "Test", ResRef: "test"},
			Paths:  PathConfig{Source: "src", Assets: "assets", Build: "build"},
			TopData: TopDataConfig{
				Assets: "sow-assets",
			},
		},
	}

	result := proj.TopDataAssetsDir()
	expected := filepath.Join(root, "sow-assets")
	if result != expected {
		t.Errorf("expected unresolved local path %s, got %s", expected, result)
	}
}

func TestTopDataAssetsDirReturnsEmptyForNoConfig(t *testing.T) {
	root := t.TempDir()
	mkdirAll(t, filepath.Join(root, "src"))
	mkdirAll(t, filepath.Join(root, "assets"))
	mkdirAll(t, filepath.Join(root, "build"))

	proj := &Project{
		Root: root,
		Config: Config{
			Module:  ModuleConfig{Name: "Test", ResRef: "test"},
			Paths:   PathConfig{Source: "src", Assets: "assets", Build: "build"},
			TopData: TopDataConfig{},
		},
	}

	result := proj.TopDataAssetsDir()
	if result != "" {
		t.Errorf("expected empty string, got %s", result)
	}
}

func TestModuleArchivePathUsesConfiguredBuildDirAndResRef(t *testing.T) {
	root := t.TempDir()
	mkdirAll(t, filepath.Join(root, "output"))

	proj := &Project{
		Root: root,
		Config: Config{
			Module: ModuleConfig{Name: "Test", ResRef: "testmod"},
			Paths:  PathConfig{Build: "output"},
		},
	}

	got := proj.ModuleArchivePath()
	want := filepath.Join(root, "output", "testmod.mod")
	if got != want {
		t.Fatalf("expected %s, got %s", want, got)
	}
}

func TestHAKPathsUseConfiguredBuildDir(t *testing.T) {
	root := t.TempDir()
	mkdirAll(t, filepath.Join(root, "output"))

	proj := &Project{
		Root: root,
		Config: Config{
			Paths: PathConfig{Build: "output"},
		},
	}

	if got, want := proj.HAKManifestPath(), filepath.Join(root, "output", "haks.json"); got != want {
		t.Fatalf("expected manifest path %s, got %s", want, got)
	}
	if got, want := proj.HAKArchivePath("core_01"), filepath.Join(root, "output", "core_01.hak"); got != want {
		t.Fatalf("expected hak path %s, got %s", want, got)
	}
}

func TestTopDataPackagePathsUseConfiguredNamesAndBuildDir(t *testing.T) {
	root := t.TempDir()
	mkdirAll(t, filepath.Join(root, "output"))

	proj := &Project{
		Root: root,
		Config: Config{
			Paths: PathConfig{Build: "output"},
			TopData: TopDataConfig{
				PackageHAK: "custom_top.hak",
				PackageTLK: "custom_dialog.tlk",
			},
		},
	}

	if got, want := proj.TopDataPackageHAKPath(), filepath.Join(root, "output", "custom_top.hak"); got != want {
		t.Fatalf("expected top hak path %s, got %s", want, got)
	}
	if got, want := proj.TopDataPackageTLKPath(), filepath.Join(root, "output", "custom_dialog.tlk"); got != want {
		t.Fatalf("expected top tlk path %s, got %s", want, got)
	}
}

func TestValidateLayoutAllowsMissingAssetsDir(t *testing.T) {
	root := t.TempDir()
	mkdirAll(t, filepath.Join(root, "src"))
	mkdirAll(t, filepath.Join(root, "build"))

	proj := &Project{
		Root: root,
		Config: Config{
			Module: ModuleConfig{Name: "Test", ResRef: "test"},
			Paths:  PathConfig{Source: "src", Assets: "assets", Build: "build"},
		},
	}

	if err := proj.ValidateLayout(); err != nil {
		t.Fatalf("ValidateLayout returned error: %v", err)
	}
	if _, err := os.Stat(filepath.Join(root, "assets")); !errors.Is(err, os.ErrNotExist) {
		t.Fatalf("expected missing assets dir to remain absent, got err=%v", err)
	}
}

func TestValidateLayoutRejectsInvalidTopDataPackageOutputNames(t *testing.T) {
	root := t.TempDir()
	mkdirAll(t, filepath.Join(root, "src"))
	mkdirAll(t, filepath.Join(root, "build"))

	proj := &Project{
		Root: root,
		Config: Config{
			Module: ModuleConfig{Name: "Test", ResRef: "test"},
			Paths:  PathConfig{Source: "src", Build: "build"},
			TopData: TopDataConfig{
				Source:     "topdata",
				PackageHAK: "nested/custom_top.hak",
				PackageTLK: "custom_dialog.txt",
			},
		},
	}

	err := proj.ValidateLayout()
	if err == nil {
		t.Fatal("expected topdata output validation error")
	}
	if !strings.Contains(err.Error(), "topdata.package_hak") {
		t.Fatalf("expected package_hak validation error, got %v", err)
	}
	if !strings.Contains(err.Error(), "topdata.package_tlk") {
		t.Fatalf("expected package_tlk validation error, got %v", err)
	}
}

func TestValidateLayoutRejectsUnsafeGeneratedOutputPaths(t *testing.T) {
	root := t.TempDir()
	mkdirAll(t, filepath.Join(root, "src"))
	mkdirAll(t, filepath.Join(root, "build"))

	proj := &Project{
		Root: root,
		Config: Config{
			Module: ModuleConfig{Name: "Test", ResRef: "test"},
			Paths:  PathConfig{Source: "src", Build: "build"},
			Outputs: OutputConfig{
				HAKManifest: "../haks.json",
			},
			TopData: TopDataConfig{
				Source:         "topdata",
				Compiled2DADir: "../2da",
			},
		},
	}

	err := proj.ValidateLayout()
	if err == nil {
		t.Fatal("expected unsafe output path validation error")
	}
	if !strings.Contains(err.Error(), "outputs.hak_manifest") {
		t.Fatalf("expected HAK manifest path validation error, got %v", err)
	}
	if !strings.Contains(err.Error(), "topdata.compiled_2da_dir") {
		t.Fatalf("expected topdata path validation error, got %v", err)
	}
}

func TestValidateLayoutRejectsInvalidTopDataWikiConfig(t *testing.T) {
	root := t.TempDir()
	mkdirAll(t, filepath.Join(root, "src"))
	mkdirAll(t, filepath.Join(root, "build"))

	proj := &Project{
		Root: root,
		Config: Config{
			Module: ModuleConfig{Name: "Test", ResRef: "test"},
			Paths:  PathConfig{Source: "src", Build: "build"},
			TopData: TopDataConfig{
				Source: "topdata",
				Wiki: TopDataWikiConfig{
					Source:            "../wiki",
					Renderer:          "dokuwiki",
					LinkStrategy:      "rewrite_links",
					NamespacesFile:    "../namespaces.yaml",
					TemplatesDir:      ".",
					ManualSectionsDir: "../manual",
					StalePages: TopDataWikiStalePagesConfig{
						Default:     "delete",
						LiveCleanup: "delete",
					},
				},
			},
		},
	}

	err := proj.ValidateLayout()
	if err == nil {
		t.Fatal("expected invalid wiki config validation error")
	}
	for _, needle := range []string{
		"topdata.wiki.source",
		"topdata.wiki.renderer",
		"topdata.wiki.link_strategy",
		"topdata.wiki.namespaces_file",
		"topdata.wiki.templates_dir",
		"topdata.wiki.manual_sections_dir",
		"topdata.wiki.stale_pages.default",
		"topdata.wiki.stale_pages.live_cleanup",
	} {
		if !strings.Contains(err.Error(), needle) {
			t.Fatalf("expected validation error to mention %s, got %v", needle, err)
		}
	}
}

func TestValidateLayoutRejectsRootSourceAndAssetPaths(t *testing.T) {
	root := t.TempDir()
	mkdirAll(t, filepath.Join(root, "build"))

	proj := &Project{
		Root: root,
		Config: Config{
			Module: ModuleConfig{Name: "Test", ResRef: "test"},
			Paths:  PathConfig{Source: ".", Assets: ".", Build: "build"},
		},
	}

	err := proj.ValidateLayout()
	if err == nil {
		t.Fatal("expected root source and asset path validation error")
	}
	if !strings.Contains(err.Error(), "paths.source") {
		t.Fatalf("expected source path validation error, got %v", err)
	}
	if !strings.Contains(err.Error(), "paths.assets") {
		t.Fatalf("expected assets path validation error, got %v", err)
	}
}

func TestValidateLayoutRejectsInvalidAutogenConsumerConfig(t *testing.T) {
	root := t.TempDir()
	mkdirAll(t, filepath.Join(root, "src"))
	mkdirAll(t, filepath.Join(root, "build"))

	proj := &Project{
		Root: root,
		Config: Config{
			Module: ModuleConfig{Name: "Test", ResRef: "test"},
			Paths:  PathConfig{Source: "src", Build: "build"},
			Autogen: AutogenConfig{
				Consumers: []AutogenConsumerConfig{
					{
						ID:       "head_visualeffects",
						Producer: "head_visualeffects",
						Dataset:  "visualeffects",
						Mode:     "unsupported",
						Root:     "vfxs",
						Include:  []string{"head_accessories/**/*.mdl"},
						Derive:   AutogenDeriveConfig{Kind: "model_stem", GroupFrom: "first_path_segment"},
						Manifest: AutogenManifestConfig{
							ReleaseTag: "head-vfx-manifest-current",
							AssetName:  "nested/sow-head-vfx-manifest.json",
							CacheName:  "sow-head-vfx-manifest.txt",
						},
					},
				},
			},
		},
	}

	err := proj.ValidateLayout()
	if err == nil {
		t.Fatal("expected autogen validation error")
	}
	if !strings.Contains(err.Error(), "autogen.consumers[0].mode") {
		t.Fatalf("expected autogen mode validation error, got %v", err)
	}
	if !strings.Contains(err.Error(), "autogen.consumers[0].manifest.asset_name") {
		t.Fatalf("expected autogen asset_name validation error, got %v", err)
	}
	if !strings.Contains(err.Error(), "autogen.consumers[0].manifest.cache_name") {
		t.Fatalf("expected autogen cache_name validation error, got %v", err)
	}
}

func TestValidateLayoutAcceptsGeneratedTopData2DAConfig(t *testing.T) {
	root := t.TempDir()
	mkdirAll(t, filepath.Join(root, "assets"))
	mkdirAll(t, filepath.Join(root, "build"))
	proj := Project{
		Root: root,
		Config: Config{
			Module: ModuleConfig{Name: "Test", ResRef: "testmod"},
			Paths:  PathConfig{Assets: "assets"},
			Generated: GeneratedConfig{
				TopData2DA: []GeneratedTopData2DAConfig{
					{
						ID:              "parts",
						Source:          "topdata",
						Output:          "{paths.cache}/generated-assets/parts-2da",
						IncludeDatasets: []string{"parts/**"},
						PackageRoot:     "part",
						Autogen: AutogenConsumerConfig{
							ID:      "parts",
							Mode:    "parts_rows",
							Root:    "part",
							Include: []string{"**/*.mdl"},
							Derive:  AutogenDeriveConfig{Kind: "trailing_numeric_suffix", GroupFrom: "first_path_segment"},
							PartsRows: PartsRowsConfig{
								RowDefaults: map[string]string{"COSTMODIFIER": "0"},
								ACBonus: PartsRowsACBonusConfig{
									Default: PartsRowsACBonusPolicy{Strategy: "descending_row_id_sort_key", MaxRowID: 999, Divisor: 100, Format: "%.2f"},
									Datasets: map[string]PartsRowsACBonusPolicy{
										"chest": {Strategy: "fixed", Value: "0.00"},
									},
								},
							},
						},
					},
				},
			},
		},
	}

	if err := proj.ValidateLayout(); err != nil {
		t.Fatalf("ValidateLayout returned error: %v", err)
	}
	effective := proj.EffectiveConfig()
	if got, want := effective.Generated.TopData2DA[0].Output, ".cache/generated-assets/parts-2da"; got != want {
		t.Fatalf("expected expanded output %q, got %q", want, got)
	}
}

func TestValidateLayoutRejectsEscapingGeneratedTopData2DAConfig(t *testing.T) {
	root := t.TempDir()
	mkdirAll(t, filepath.Join(root, "assets"))
	mkdirAll(t, filepath.Join(root, "build"))
	proj := Project{
		Root: root,
		Config: Config{
			Module: ModuleConfig{Name: "Test", ResRef: "testmod"},
			Paths:  PathConfig{Assets: "assets"},
			Generated: GeneratedConfig{
				TopData2DA: []GeneratedTopData2DAConfig{
					{
						ID:              "parts",
						Source:          "../topdata",
						Output:          "{paths.cache}/generated-assets/parts-2da",
						IncludeDatasets: []string{"parts/**"},
						PackageRoot:     "part",
						Autogen: AutogenConsumerConfig{
							ID:      "parts",
							Mode:    "parts_rows",
							Root:    "part",
							Include: []string{"**/*.mdl"},
							Derive:  AutogenDeriveConfig{Kind: "trailing_numeric_suffix", GroupFrom: "first_path_segment"},
						},
					},
				},
			},
		},
	}

	err := proj.ValidateLayout()
	if err == nil || !strings.Contains(err.Error(), "generated_assets.topdata_2da[0].source") {
		t.Fatalf("expected generated topdata source validation error, got %v", err)
	}
}

func TestValidateLayoutRejectsInvalidGeneratedPartsRowsACBonusConfig(t *testing.T) {
	root := t.TempDir()
	mkdirAll(t, filepath.Join(root, "assets"))
	mkdirAll(t, filepath.Join(root, "build"))
	proj := Project{
		Root: root,
		Config: Config{
			Module: ModuleConfig{Name: "Test", ResRef: "testmod"},
			Paths:  PathConfig{Assets: "assets"},
			Generated: GeneratedConfig{
				TopData2DA: []GeneratedTopData2DAConfig{
					{
						ID:              "parts",
						Source:          "topdata",
						Output:          "{paths.cache}/generated-assets/parts-2da",
						IncludeDatasets: []string{"parts/**"},
						PackageRoot:     "part",
						Autogen: AutogenConsumerConfig{
							ID:      "parts",
							Mode:    "parts_rows",
							Root:    "part",
							Include: []string{"**/*.mdl"},
							Derive:  AutogenDeriveConfig{Kind: "trailing_numeric_suffix", GroupFrom: "first_path_segment"},
							PartsRows: PartsRowsConfig{
								ACBonus: PartsRowsACBonusConfig{
									Default: PartsRowsACBonusPolicy{Strategy: "descending_row_id_sort_key", MaxRowID: 0, Divisor: -1, Format: "%d"},
									Datasets: map[string]PartsRowsACBonusPolicy{
										"chest": {Strategy: "fixed"},
										"robe":  {Strategy: "unknown"},
									},
								},
							},
						},
					},
				},
			},
		},
	}

	err := proj.ValidateLayout()
	if err == nil {
		t.Fatal("expected invalid parts_rows acbonus config to fail")
	}
	text := err.Error()
	for _, want := range []string{
		"generated_assets.topdata_2da[0].autogen.parts_rows.acbonus.default.max_row_id",
		"generated_assets.topdata_2da[0].autogen.parts_rows.acbonus.default.divisor",
		"generated_assets.topdata_2da[0].autogen.parts_rows.acbonus.default.format",
		"generated_assets.topdata_2da[0].autogen.parts_rows.acbonus.datasets.chest.value",
		"generated_assets.topdata_2da[0].autogen.parts_rows.acbonus.datasets.robe.strategy",
	} {
		if !strings.Contains(text, want) {
			t.Fatalf("expected validation error containing %q, got %v", want, err)
		}
	}
}

func TestValidateLayoutRejectsDuplicateHAKNames(t *testing.T) {
	root := t.TempDir()
	mkdirAll(t, filepath.Join(root, "src"))
	mkdirAll(t, filepath.Join(root, "build"))

	proj := &Project{
		Root: root,
		Config: Config{
			Module: ModuleConfig{Name: "Test", ResRef: "test"},
			Paths:  PathConfig{Source: "src", Build: "build"},
			HAKs: []HAKConfig{
				{Name: "core", Priority: 1, Include: []string{"core/**"}},
				{Name: "CORE", Priority: 2, Include: []string{"other/**"}},
			},
		},
	}

	err := proj.ValidateLayout()
	if err == nil {
		t.Fatal("expected duplicate hak validation error")
	}
	if !strings.Contains(err.Error(), "duplicated") {
		t.Fatalf("expected duplicate validation error, got %v", err)
	}
}

func TestValidateLayoutRejectsAmbiguousMusicPrefixes(t *testing.T) {
	root := t.TempDir()
	mkdirAll(t, filepath.Join(root, "assets"))
	mkdirAll(t, filepath.Join(root, "build"))

	proj := &Project{
		Root: root,
		Config: Config{
			Module: ModuleConfig{Name: "Test", ResRef: "test"},
			Paths:  PathConfig{Assets: "assets", Build: "build"},
			Music: MusicConfig{
				Prefixes: map[string]string{
					"envi/music":  "mus_",
					"/envi/music": "mus2_",
				},
			},
		},
	}

	err := proj.ValidateLayout()
	if err == nil {
		t.Fatal("expected music prefix validation error")
	}
	if !strings.Contains(err.Error(), "normalize to the same path") {
		t.Fatalf("expected ambiguous prefix validation error, got %v", err)
	}
}

func TestScanAllowsMissingAssetsDir(t *testing.T) {
	root := t.TempDir()
	mkdirAll(t, filepath.Join(root, "src"))
	mkdirAll(t, filepath.Join(root, "build"))

	proj := &Project{
		Root: root,
		Config: Config{
			Module: ModuleConfig{Name: "Test", ResRef: "test"},
			Paths:  PathConfig{Source: "src", Assets: "assets", Build: "build"},
		},
	}

	if err := proj.Scan(); err != nil {
		t.Fatalf("Scan returned error: %v", err)
	}
	if len(proj.Inventory.AssetFiles) != 0 {
		t.Fatalf("expected no asset files, got %#v", proj.Inventory.AssetFiles)
	}
}

func mkdirAll(t *testing.T, path string) {
	t.Helper()
	if err := os.MkdirAll(path, 0o755); err != nil {
		t.Fatalf("mkdir %s: %v", path, err)
	}
}

func TestCloneWithHAKNamesFiltersConfiguredHAKs(t *testing.T) {
	proj := &Project{
		Root: "/tmp/test",
		Config: Config{
			Module: ModuleConfig{Name: "Test", ResRef: "test"},
			Paths:  PathConfig{Source: "src", Assets: "assets", Build: "build"},
			HAKs: []HAKConfig{
				{Name: "sow_over", Priority: 1, Include: []string{"over/**"}},
				{Name: "sow_core", Priority: 2, Include: []string{"core/**"}},
				{Name: "sow_item", Priority: 3, Include: []string{"item/**"}},
			},
		},
	}

	filtered, err := proj.CloneWithHAKNames([]string{"sow_core", "sow_item"})
	if err != nil {
		t.Fatalf("CloneWithHAKNames returned error: %v", err)
	}

	if filtered == proj {
		t.Fatalf("expected filtered clone, got original pointer")
	}

	got := make([]string, 0, len(filtered.Config.HAKs))
	for _, hak := range filtered.Config.HAKs {
		got = append(got, hak.Name)
	}
	want := []string{"sow_core", "sow_item"}
	if !slices.Equal(got, want) {
		t.Fatalf("unexpected filtered haks: got %v want %v", got, want)
	}

	if len(proj.Config.HAKs) != 3 {
		t.Fatalf("original project config should remain unchanged, got %d haks", len(proj.Config.HAKs))
	}
}

func TestCloneWithHAKNamesRejectsUnknownHAKs(t *testing.T) {
	proj := &Project{
		Config: Config{
			HAKs: []HAKConfig{
				{Name: "sow_core", Priority: 1, Include: []string{"core/**"}},
			},
		},
	}

	if _, err := proj.CloneWithHAKNames([]string{"missing_hak"}); err == nil {
		t.Fatal("expected error for unknown hak name")
	}
}

func loadAndValidate(root string) error {
	proj, err := Load(root)
	if err != nil {
		return err
	}
	return proj.ValidateLayout()
}

func writeProjectFile(t *testing.T, path, content string) {
	t.Helper()
	if err := os.WriteFile(path, []byte(strings.TrimPrefix(content, "\n")), 0o644); err != nil {
		t.Fatalf("write %s: %v", path, err)
	}
}
