package topdata

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

	"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf"
	"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
)

type Severity string

const (
	SeverityError   Severity = "error"
	SeverityWarning Severity = "warning"
)

type Diagnostic struct {
	Severity Severity
	Path     string
	Message  string
}

type ValidationReport struct {
	Files       int
	DataFiles   int
	TLKFiles    int
	Diagnostics []Diagnostic
}

func (r ValidationReport) SummaryLines(maxPaths int) []string {
	type key struct {
		severity Severity
		message  string
	}
	groups := map[key][]string{}
	order := make([]key, 0)
	for _, diagnostic := range r.Diagnostics {
		k := key{severity: diagnostic.Severity, message: diagnostic.Message}
		if _, ok := groups[k]; !ok {
			order = append(order, k)
		}
		groups[k] = append(groups[k], diagnostic.Path)
	}
	slices.SortFunc(order, func(a, b key) int {
		if a.severity != b.severity {
			if a.severity == SeverityError {
				return -1
			}
			return 1
		}
		return strings.Compare(a.message, b.message)
	})
	lines := make([]string, 0, len(order))
	for _, k := range order {
		paths := append([]string(nil), groups[k]...)
		slices.Sort(paths)
		paths = slices.Compact(paths)
		lines = append(lines, formatDiagnosticSummaryLine(string(k.severity), k.message, paths, maxPaths))
	}
	return lines
}

func (r ValidationReport) HasErrors() bool {
	for _, diagnostic := range r.Diagnostics {
		if diagnostic.Severity == SeverityError {
			return true
		}
	}
	return false
}

func (r ValidationReport) ErrorCount() int {
	count := 0
	for _, diagnostic := range r.Diagnostics {
		if diagnostic.Severity == SeverityError {
			count++
		}
	}
	return count
}

func (r ValidationReport) WarningCount() int {
	count := 0
	for _, diagnostic := range r.Diagnostics {
		if diagnostic.Severity == SeverityWarning {
			count++
		}
	}
	return count
}

func formatDiagnosticSummaryLine(prefix, message string, paths []string, maxPaths int) string {
	if len(paths) == 0 {
		return fmt.Sprintf("%s: %s", prefix, message)
	}
	if maxPaths <= 0 {
		maxPaths = 3
	}
	display := paths
	if len(display) > maxPaths {
		display = display[:maxPaths]
	}
	location := strings.Join(display, ", ")
	if len(paths) > len(display) {
		location += fmt.Sprintf(", and %d more", len(paths)-len(display))
	}
	return fmt.Sprintf("%s: %s [%s]", prefix, message, location)
}

type BuildResult struct {
	Mode          string
	Output2DADir  string
	OutputTLKDir  string
	Files2DA      int
	FilesTLK      int
	WikiOutputDir string
	WikiPages     int
	WikiStatus    string
}

type PackageResult struct {
	Mode          string
	Output2DADir  string
	OutputTLKDir  string
	OutputHAKPath string
	OutputTLKPath string
	Files2DA      int
	FilesTLK      int
	HAKResources  int
	AssetFiles    int
	WikiOutputDir string
	WikiPages     int
	WikiStatus    string
}

type CompareResult struct {
	Mode            string
	Compared2DA     int
	ComparedTLK     int
	NativePass      int
	NotYetCanonical int
	NativeMismatch  int
}

func ValidateProject(p *project.Project) ValidationReport {
	report := ValidationReport{}
	if !p.HasTopData() {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityWarning,
			Path:     project.ConfigFile,
			Message:  "topdata is not configured for this project",
		})
		return report
	}

	sourceDir := p.TopDataSourceDir()
	info, err := os.Stat(sourceDir)
	if err != nil {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     sourceDir,
			Message:  err.Error(),
		})
		return report
	}
	if !info.IsDir() {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     sourceDir,
			Message:  "topdata source must be a directory",
		})
		return report
	}

	dataDir := filepath.Join(sourceDir, "data")
	// Canonical topdata no longer requires a dedicated source tlk directory.
	// When present, it is only validated for disallowed legacy module input.
	tlkDir := filepath.Join(sourceDir, "tlk")
	baseDialog := filepath.Join(sourceDir, "base_dialog.json")
	statePath := filepath.Join(sourceDir, tlkStateFile)

	for _, requiredDir := range []string{dataDir} {
		info, err := os.Stat(requiredDir)
		if err != nil {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     requiredDir,
				Message:  err.Error(),
			})
			continue
		}
		if !info.IsDir() {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     requiredDir,
				Message:  "must be a directory",
			})
		}
	}

	if info, err := os.Stat(tlkDir); err == nil && info.IsDir() {
		modulesDir := filepath.Join(tlkDir, "modules")
		legacyModuleFiles := 0
		if moduleInfo, moduleErr := os.Stat(modulesDir); moduleErr == nil && moduleInfo.IsDir() {
			_ = walkJSON(modulesDir, func(path string) {
				legacyModuleFiles++
				report.Files++
				report.TLKFiles++
				_ = validateJSONFile(path, "tlk", &report)
			})
		} else if moduleErr != nil && !errors.Is(moduleErr, os.ErrNotExist) {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     modulesDir,
				Message:  moduleErr.Error(),
			})
		}
		if legacyModuleFiles > 0 {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     modulesDir,
				Message:  "topdata/tlk/modules is no longer allowed for canonical authored input; inline TLK-in-data is required",
			})
		}
		if lockInfo, lockErr := os.Stat(filepath.Join(tlkDir, "lock.json")); lockErr == nil && !lockInfo.IsDir() {
			report.Files++
			report.TLKFiles++
			_ = validateJSONFile(filepath.Join(tlkDir, "lock.json"), "tlk", &report)
		} else if lockErr != nil && !errors.Is(lockErr, os.ErrNotExist) {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     filepath.Join(tlkDir, "lock.json"),
				Message:  lockErr.Error(),
			})
		}
	}

	if _, err := os.Stat(baseDialog); err != nil {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityWarning,
			Path:     baseDialog,
			Message:  "base_dialog.json is missing; dialog migration is not canonical yet",
		})
	} else {
		report.Files++
		if err := validateJSONFile(baseDialog, "base_dialog", &report); err == nil {
			// count only once for base dialog.
		}
	}

	_ = walkJSON(dataDir, func(path string) {
		report.Files++
		report.DataFiles++
		_ = validateJSONFile(path, "data", &report)
	})
	validateTopPackageAssets(sourceDir, dataDir, &report)
	validateNativeOutputCatalog(dataDir, &report)
	validateNativeEntryKeyUniqueness(dataDir, &report)
	validateNativeLockAllocation(dataDir, &report)
	validateGeneratedFeatFamilies(dataDir, &report)
	validateItempropsRegistryGraph(dataDir, &report)
	if _, err := os.Stat(statePath); err == nil {
		report.Files++
		_ = validateJSONFile(statePath, "state", &report)
	}
	if !report.HasErrors() {
		validateNativeAuthoringWarnings(dataDir, &report)
		validateNativeBuildability(p, &report)
	}

	return report
}

func validateJSONFile(path, domain string, report *ValidationReport) error {
	raw, err := os.ReadFile(path)
	if err != nil {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  err.Error(),
		})
		return err
	}

	var payload any
	validateDuplicateJSONKeys(path, raw, report)
	if err := json.Unmarshal(raw, &payload); err != nil {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  fmt.Sprintf("invalid JSON: %v", err),
		})
		return err
	}

	obj, ok := payload.(map[string]any)
	if !ok {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  "topdata files must contain a JSON object at the root",
		})
		return errors.New("topdata file root must be object")
	}

	switch domain {
	case "data":
		validateDataObject(path, obj, report)
	case "tlk":
		validateTLKObject(path, obj, report)
	case "state":
		validateStateObject(path, obj, report)
	case "base_dialog":
		validateBaseDialogObject(path, obj, report)
	}
	return nil
}

func validateDuplicateJSONKeys(path string, raw []byte, report *ValidationReport) {
	decoder := json.NewDecoder(bytes.NewReader(raw))
	if err := scanJSONValueForDuplicateKeys(decoder, path, report); err != nil {
		return
	}
}

func scanJSONValueForDuplicateKeys(decoder *json.Decoder, path string, report *ValidationReport) error {
	token, err := decoder.Token()
	if err != nil {
		return err
	}
	switch delim := token.(type) {
	case json.Delim:
		switch delim {
		case '{':
			seen := map[string]struct{}{}
			for decoder.More() {
				rawKey, err := decoder.Token()
				if err != nil {
					return err
				}
				key, ok := rawKey.(string)
				if !ok {
					return fmt.Errorf("expected object key")
				}
				if _, ok := seen[key]; ok {
					report.Diagnostics = append(report.Diagnostics, Diagnostic{
						Severity: SeverityError,
						Path:     path,
						Message:  fmt.Sprintf("duplicate JSON object key %q", key),
					})
				}
				seen[key] = struct{}{}
				if err := scanJSONValueForDuplicateKeys(decoder, path, report); err != nil {
					return err
				}
			}
			_, err := decoder.Token()
			return err
		case '[':
			for decoder.More() {
				if err := scanJSONValueForDuplicateKeys(decoder, path, report); err != nil {
					return err
				}
			}
			_, err := decoder.Token()
			return err
		default:
			return nil
		}
	default:
		return nil
	}
}

func validateDataObject(path string, obj map[string]any, report *ValidationReport) {
	base := filepath.Base(path)
	slashed := filepath.ToSlash(path)
	inModules := strings.Contains(slashed, "/modules/")
	dataPath := parseTopdataDataPath(path)

	if invariant, ok := datasetInvariantForPath(dataPath); ok {
		if dataPath.IsRootBase {
			validateDatasetInvariantBaseFile(path, obj, invariant, report)
			return
		}
		if dataPath.IsRootLock {
			validateDatasetInvariantLockFile(path, obj, invariant, report)
			return
		}
	}
	if strings.Contains(slashed, "/data/feat/generated/") {
		validateFeatGeneratedFile(path, obj, report)
		return
	}
	if strings.Contains(slashed, "/data/parts/overrides/") {
		validateOverridesFile(path, obj, report)
		return
	}

	if strings.Contains(slashed, "/racialtypes/registry/races/") {
		validateRacialtypesRegistryRaceFile(path, obj, report)
		return
	}

	if base == "lock.json" {
		validateLockObject(path, obj, report)
		return
	}
	if base == "base.json" {
		validateRowsFile(path, obj, report, true)
		return
	}
	if inModules {
		if _, hasEntries := obj["entries"]; hasEntries {
			validateEntriesFile(path, obj, report)
			return
		}
		if _, hasOverrides := obj["overrides"]; hasOverrides {
			validateOverridesFile(path, obj, report)
			return
		}
		if _, hasRows := obj["rows"]; hasRows {
			validateRowsFile(path, obj, report, false)
			return
		}
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityWarning,
			Path:     path,
			Message:  "module file does not use a recognized canonical shape yet; expected one of entries, overrides, or rows",
		})
		return
	}

	if _, hasRows := obj["rows"]; hasRows {
		validateRowsFile(path, obj, report, false)
		return
	}
	report.Diagnostics = append(report.Diagnostics, Diagnostic{
		Severity: SeverityWarning,
		Path:     path,
		Message:  "unrecognized data file shape; parseable for compatibility, but not canonical yet",
	})
}

type topdataDataPath struct {
	Segments   []string
	IsRootBase bool
	IsRootLock bool
}

func parseTopdataDataPath(path string) topdataDataPath {
	slashed := filepath.ToSlash(path)
	marker := "/data/"
	index := strings.LastIndex(slashed, marker)
	if index == -1 {
		return topdataDataPath{}
	}
	rel := strings.Trim(slashed[index+len(marker):], "/")
	if rel == "" {
		return topdataDataPath{}
	}
	segments := strings.Split(rel, "/")
	return topdataDataPath{
		Segments:   segments,
		IsRootBase: len(segments) == 2 && segments[1] == "base.json",
		IsRootLock: len(segments) == 2 && segments[1] == "lock.json",
	}
}

type datasetValidationInvariant struct {
	Name            string
	KeyPrefix       string
	RequiredColumns []string
}

var datasetValidationInvariants = map[string]datasetValidationInvariant{
	"feat": {
		Name:      "feat",
		KeyPrefix: "feat:",
	},
	"masterfeats": {
		Name:            "masterfeats",
		KeyPrefix:       "masterfeats:",
		RequiredColumns: []string{"LABEL", "STRREF", "DESCRIPTION"},
	},
}

func datasetInvariantForPath(dataPath topdataDataPath) (datasetValidationInvariant, bool) {
	if len(dataPath.Segments) == 0 {
		return datasetValidationInvariant{}, false
	}
	invariant, ok := datasetValidationInvariants[dataPath.Segments[0]]
	return invariant, ok
}

func validateDatasetInvariantBaseFile(path string, obj map[string]any, invariant datasetValidationInvariant, report *ValidationReport) {
	validateRowsFile(path, obj, report, true)
	validateDerivedBaseOutput(path, obj, invariant.Name, report)
	validateRequiredColumns(path, obj, invariant, report)
	validateRowsKeyPrefix(path, obj, invariant, report)
}

func validateRequiredColumns(path string, obj map[string]any, invariant datasetValidationInvariant, report *ValidationReport) {
	if len(invariant.RequiredColumns) == 0 {
		return
	}
	columns := extractValidationColumns(obj)
	for _, required := range invariant.RequiredColumns {
		if _, ok := canonicalColumn(columns, required); ok {
			continue
		}
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  fmt.Sprintf("%s base.json must include %s in columns", invariant.Name, required),
		})
	}
}

func validateRowsKeyPrefix(path string, obj map[string]any, invariant datasetValidationInvariant, report *ValidationReport) {
	if invariant.KeyPrefix == "" {
		return
	}
	rows, ok := obj["rows"].([]any)
	if !ok {
		return
	}
	for index, raw := range rows {
		row, ok := raw.(map[string]any)
		if !ok {
			continue
		}
		key, ok := row["key"].(string)
		if !ok || strings.TrimSpace(key) == "" {
			continue
		}
		if !strings.HasPrefix(key, invariant.KeyPrefix) {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     path,
				Message:  fmt.Sprintf("%s row %d key %q must start with %s", invariant.Name, index, key, invariant.KeyPrefix),
			})
		}
	}
}

func validateDatasetInvariantLockFile(path string, obj map[string]any, invariant datasetValidationInvariant, report *ValidationReport) {
	validateLockObject(path, obj, report)
	if invariant.KeyPrefix == "" {
		return
	}
	for key := range obj {
		if !strings.HasPrefix(key, invariant.KeyPrefix) {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     path,
				Message:  fmt.Sprintf("%s lock key %q must start with %s", invariant.Name, key, invariant.KeyPrefix),
			})
		}
	}
}

func validateDerivedBaseOutput(path string, obj map[string]any, datasetLabel string, report *ValidationReport) {
	if output, ok := obj["output"]; !ok {
		return
	} else if outputText, ok := output.(string); !ok || strings.TrimSpace(outputText) != nativeDatasetDefaultOutputName(filepath.Dir(path), nativeDatasetBase) {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  fmt.Sprintf("%s output must be %s", datasetLabel, nativeDatasetDefaultOutputName(filepath.Dir(path), nativeDatasetBase)),
		})
	}
}

func validateFeatGeneratedFile(path string, obj map[string]any, report *ValidationReport) {
	family, ok := obj["family"].(string)
	if !ok || strings.TrimSpace(family) == "" {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  "feat generated file must contain a non-empty family string",
		})
		return
	}
	if isFamilyExpansionObject(obj) {
		validateFeatFamilyExpansionFile(path, obj, report)
		return
	}
	switch family {
	default:
		_, hasEntries := obj["entries"]
		_, hasOverrides := obj["overrides"]
		if !hasEntries && !hasOverrides {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     path,
				Message:  "feat generated file must contain entries and/or overrides",
			})
			return
		}
		if hasEntries {
			validateEntriesFile(path, obj, report)
		}
		if hasOverrides {
			validateOverridesFile(path, obj, report)
		}
	}
}

func validateFeatFamilyExpansionFile(path string, obj map[string]any, report *ValidationReport) {
	spec, err := parseFamilyExpansionSpec(path, obj)
	if err != nil {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  err.Error(),
		})
		return
	}
	if !strings.HasPrefix(spec.Template, "masterfeats:") {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  "compact feat generated template must start with masterfeats:",
		})
	}
	if spec.NamePrefix == "" {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  "family expansion file must contain a non-empty name_prefix",
		})
	}
	if spec.LabelPrefix == "" {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  "family expansion file must contain a non-empty label_prefix",
		})
	}
	if spec.ConstantPrefix == "" {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  "family expansion file must contain a non-empty constant_prefix",
		})
	}
	if raw, ok := obj["overrides"]; ok {
		if _, ok := raw.(map[string]any); !ok {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     path,
				Message:  "family expansion overrides must be an object keyed by source dataset key",
			})
		}
	}
}

func validateRacialtypesRegistryRaceFile(path string, obj map[string]any, report *ValidationReport) {
	key, ok := obj["key"]
	if !ok {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  "racialtypes registry race file must contain a key field",
		})
	} else if _, ok := key.(string); !ok {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  "racialtypes registry race key must be a string",
		})
	}

	core, ok := obj["core"]
	if !ok {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  "racialtypes registry race file must contain a core object",
		})
	} else if _, ok := core.(map[string]any); !ok {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  "racialtypes registry race core must be a JSON object",
		})
	}

	if rawOutput, ok := obj["feat_output"]; ok {
		if _, ok := rawOutput.(string); !ok {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     path,
				Message:  "racialtypes registry race feat_output must be a string",
			})
		}
		if rawFeats, ok := obj["feats"]; ok {
			if _, ok := rawFeats.([]any); !ok {
				report.Diagnostics = append(report.Diagnostics, Diagnostic{
					Severity: SeverityError,
					Path:     path,
					Message:  "racialtypes registry race feats must be an array",
				})
			}
		} else {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     path,
				Message:  "racialtypes registry race feat_output requires a feats array",
			})
		}
		return
	}

	if _, ok := obj["feats"]; ok {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  "racialtypes registry race feats requires feat_output",
		})
	}
}

func validateTLKObject(path string, obj map[string]any, report *ValidationReport) {
	if filepath.Base(path) == "lock.json" {
		validateLockObject(path, obj, report)
		return
	}
	validateLegacyDialogEntries(path, obj, report, "TLK")
}

func validateBaseDialogObject(path string, obj map[string]any, report *ValidationReport) {
	validateLegacyDialogEntries(path, obj, report, "base_dialog")
}

func validateLegacyDialogEntries(path string, obj map[string]any, report *ValidationReport, label string) {
	entries, ok := obj["entries"]
	if ok {
		switch entries.(type) {
		case map[string]any, []any:
			// accepted compatibility shapes
		default:
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     path,
				Message:  fmt.Sprintf("%s entries must be a JSON object or array", label),
			})
		}
	}
	if language, ok := obj["language"]; ok {
		switch language.(type) {
		case string, float64:
			// accepted compatibility shapes
		default:
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     path,
				Message:  fmt.Sprintf("%s language must be a string or numeric language id when present", label),
			})
		}
	}
	if label == "TLK" && !ok {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  "TLK file must contain an entries object",
		})
	}
}

func validateLockObject(path string, obj map[string]any, report *ValidationReport) {
	ids := map[int]string{}
	checkDuplicateIDs := !strings.Contains(filepath.ToSlash(path), "/data/itemprops/registry/lock.json")
	for key, value := range obj {
		var rowID int
		switch value.(type) {
		case float64:
			// JSON numbers land here.
			if parsed, err := asInt(value); err == nil {
				rowID = parsed
			}
		default:
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     path,
				Message:  fmt.Sprintf("lock entry %q must be numeric", key),
			})
		}
		if checkDuplicateIDs {
			if rowID <= 0 {
				continue
			}
			if other, ok := ids[rowID]; ok && other != key {
				report.Diagnostics = append(report.Diagnostics, Diagnostic{
					Severity: SeverityError,
					Path:     path,
					Message:  fmt.Sprintf("lock id collision between %q and %q at id %d", other, key, rowID),
				})
			}
			ids[rowID] = key
		}
	}
}

func validateRowsFile(path string, obj map[string]any, report *ValidationReport, requireColumns bool) {
	rows, ok := obj["rows"]
	if !ok {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  "rows file must contain a rows array",
		})
		return
	}
	if _, ok := rows.([]any); !ok {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  "rows must be a JSON array",
		})
	}
	if rowsList, ok := rows.([]any); ok {
		validateRowCollection(path, obj, rowsList, report)
	}
	if columns, ok := obj["columns"]; ok {
		if list, ok := columns.([]any); !ok {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     path,
				Message:  "columns must be a JSON array when present",
			})
		} else {
			for _, raw := range list {
				column, ok := raw.(string)
				if !ok {
					continue
				}
				if isAuthoringOnlyField(column) && !preserveLegacyWikiColumnForPath(path, column) {
					report.Diagnostics = append(report.Diagnostics, Diagnostic{
						Severity: SeverityError,
						Path:     path,
						Message:  fmt.Sprintf("deprecated wiki column %q must be moved into meta.wiki", column),
					})
				}
			}
		}
	} else if requireColumns {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  "base dataset file must contain columns",
		})
	}
}

func validateEntriesFile(path string, obj map[string]any, report *ValidationReport) {
	entries, ok := obj["entries"]
	if !ok {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  "entries file must contain an entries object",
		})
		return
	}
	if _, ok := entries.(map[string]any); !ok {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  "entries must be a JSON object",
		})
	}
	if entryMap, ok := entries.(map[string]any); ok {
		for key, raw := range entryMap {
			entry, ok := raw.(map[string]any)
			if !ok {
				continue
			}
			for field := range entry {
				if isAuthoringOnlyField(field) && !preserveLegacyWikiColumnForPath(path, field) {
					report.Diagnostics = append(report.Diagnostics, Diagnostic{
						Severity: SeverityError,
						Path:     path,
						Message:  fmt.Sprintf("entry %q uses deprecated wiki field %q; move it into meta.wiki", key, field),
					})
				}
			}
			validateRowMetadata(path, fmt.Sprintf("entry %q", key), entry, report)
			validateInlineTextUsage(path, key, entry, report)
		}
	}
}

func validateOverridesFile(path string, obj map[string]any, report *ValidationReport) {
	overrides, ok := obj["overrides"]
	if !ok {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  "override file must contain an overrides array",
		})
		return
	}
	if _, ok := overrides.([]any); !ok {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  "overrides must be a JSON array",
		})
	}
	if overrideList, ok := overrides.([]any); ok {
		container := map[string]any{"rows": overrideList}
		validateRowCollection(path, container, overrideList, report)
	}
}

func validateStateObject(path string, obj map[string]any, report *ValidationReport) {
	entries, ok := obj["entries"].(map[string]any)
	if !ok {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  "TLK state file must contain an entries object",
		})
		return
	}
	ids := map[int]string{}
	for key, raw := range entries {
		entry, ok := raw.(map[string]any)
		if !ok {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     path,
				Message:  fmt.Sprintf("TLK state entry %q must be an object", key),
			})
			continue
		}
		idValue, ok := entry["id"]
		if !ok {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     path,
				Message:  fmt.Sprintf("TLK state entry %q is missing id", key),
			})
			continue
		}
		id, err := asInt(idValue)
		if err != nil {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     path,
				Message:  fmt.Sprintf("TLK state entry %q id must be numeric", key),
			})
			continue
		}
		if other, ok := ids[id]; ok && other != key {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     path,
				Message:  fmt.Sprintf("TLK state id collision between %q and %q", other, key),
			})
		}
		ids[id] = key
	}
}

func validateNativeOutputCatalog(dataDir string, report *ValidationReport) {
	datasets, err := discoverNativeDatasets(dataDir)
	if err != nil {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     dataDir,
			Message:  err.Error(),
		})
		return
	}
	registryDatasets, err := collectGeneratedRegistryDatasets(dataDir)
	if err != nil {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     dataDir,
			Message:  err.Error(),
		})
		return
	}
	outputOwners := map[string]string{}
	for _, dataset := range datasets {
		recordNativeOutputOwner(dataset.OutputName, dataset.Name, outputOwners, report)
	}
	for _, dataset := range registryDatasets {
		recordNativeOutputOwner(dataset.Dataset.OutputName, dataset.Dataset.Name, outputOwners, report)
	}
}

func validateNativeEntryKeyUniqueness(dataDir string, report *ValidationReport) {
	datasets, err := discoverNativeDatasets(dataDir)
	if err != nil {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     dataDir,
			Message:  fmt.Sprintf("discover native datasets for entry key uniqueness validation: %v", err),
		})
		return
	}
	for _, dataset := range datasets {
		if dataset.Kind != nativeDatasetBase {
			continue
		}
		entryKeyPath := map[string]string{}
		for _, dir := range []string{dataset.ModulesDir, dataset.GeneratedDir} {
			paths, err := collectModulePaths(dir)
			if err != nil {
				report.Diagnostics = append(report.Diagnostics, Diagnostic{
					Severity: SeverityError,
					Path:     dir,
					Message:  fmt.Sprintf("collect module paths for entry key uniqueness validation: %v", err),
				})
				continue
			}
			for _, path := range paths {
				obj, err := loadJSONObject(path)
				if err != nil {
					report.Diagnostics = append(report.Diagnostics, Diagnostic{
						Severity: SeverityError,
						Path:     path,
						Message:  fmt.Sprintf("load module for entry key uniqueness validation: %v", err),
					})
					continue
				}
				entries, ok := obj["entries"].(map[string]any)
				if !ok {
					continue
				}
				for _, key := range sortedKeys(entries) {
					if key == "" {
						continue
					}
					if previousPath, exists := entryKeyPath[key]; exists {
						report.Diagnostics = append(report.Diagnostics, Diagnostic{
							Severity: SeverityError,
							Path:     path,
							Message:  fmt.Sprintf("duplicate entries key %q declared by %s and %s", key, previousPath, path),
						})
						continue
					}
					entryKeyPath[key] = path
				}
			}
		}
	}
}

func validateNativeLockAllocation(dataDir string, report *ValidationReport) {
	datasets, err := discoverNativeDatasets(dataDir)
	if err != nil {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     dataDir,
			Message:  fmt.Sprintf("discover native datasets for lock allocation validation: %v", err),
		})
		return
	}
	for _, dataset := range datasets {
		if dataset.Kind != nativeDatasetBase {
			continue
		}
		baseObj, err := loadJSONObject(dataset.BasePath)
		if err != nil {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     dataset.BasePath,
				Message:  fmt.Sprintf("load base rows for lock allocation validation: %v", err),
			})
			continue
		}
		rawRows, ok := baseObj["rows"].([]any)
		if !ok || len(rawRows) == 0 {
			continue
		}
		baseBoundaryID := len(rawRows) - 1
		baseKeys := map[string]struct{}{}
		for index, raw := range rawRows {
			row, ok := raw.(map[string]any)
			if !ok {
				continue
			}
			rowID := index
			if rawID, ok := row["id"]; ok {
				if parsed, err := asInt(rawID); err == nil {
					rowID = parsed
				}
			}
			baseBoundaryID = rowID
			if key, ok := row["key"].(string); ok && key != "" {
				baseKeys[key] = struct{}{}
			}
		}
		explicitModuleIDs := map[string]int{}
		for _, dir := range []string{dataset.ModulesDir, dataset.GeneratedDir} {
			paths, err := collectModulePaths(dir)
			if err != nil {
				report.Diagnostics = append(report.Diagnostics, Diagnostic{
					Severity: SeverityError,
					Path:     dir,
					Message:  fmt.Sprintf("collect module paths for lock allocation validation: %v", err),
				})
				continue
			}
			for _, path := range paths {
				obj, err := loadJSONObject(path)
				if err != nil {
					report.Diagnostics = append(report.Diagnostics, Diagnostic{
						Severity: SeverityError,
						Path:     path,
						Message:  fmt.Sprintf("load module for lock allocation validation: %v", err),
					})
					continue
				}
				ids, err := collectExplicitModuleIDs(dataset.Name, path, obj)
				if err != nil {
					report.Diagnostics = append(report.Diagnostics, Diagnostic{
						Severity: SeverityError,
						Path:     path,
						Message:  err.Error(),
					})
					continue
				}
				for key, rowID := range ids {
					explicitModuleIDs[key] = rowID
				}
			}
		}
		lockData, err := loadLockfile(dataset.LockPath)
		if err != nil {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     dataset.LockPath,
				Message:  fmt.Sprintf("load lockfile for allocation validation: %v", err),
			})
			continue
		}
		if dataset.Name == "feat" {
			generatedModules, err := collectGeneratedModules(dataset, lockData)
			if err != nil {
				report.Diagnostics = append(report.Diagnostics, Diagnostic{
					Severity: SeverityError,
					Path:     dataset.GeneratedDir,
					Message:  fmt.Sprintf("collect generated feat modules for lock allocation validation: %v", err),
				})
				continue
			}
			for _, module := range generatedModules {
				ids, err := collectExplicitModuleIDs(dataset.Name, module.Name, module.Data)
				if err != nil {
					report.Diagnostics = append(report.Diagnostics, Diagnostic{
						Severity: SeverityError,
						Path:     module.Name,
						Message:  err.Error(),
					})
					continue
				}
				for key, rowID := range ids {
					explicitModuleIDs[key] = rowID
				}
			}
		}
		invalid := 0
		for key, rowID := range lockData {
			if rowID > baseBoundaryID {
				continue
			}
			if _, ok := baseKeys[key]; ok {
				continue
			}
			if explicitID, ok := explicitModuleIDs[key]; ok && explicitID == rowID {
				continue
			}
			invalid++
		}
		if invalid > 0 {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityWarning,
				Path:     dataset.LockPath,
				Message:  fmt.Sprintf("%d lock entrie(s) are inside base id space and will be regenerated above row %d", invalid, baseBoundaryID),
			})
		}
	}
}

func validateNativeAuthoringWarnings(dataDir string, report *ValidationReport) {
	datasets, err := discoverNativeDatasets(dataDir)
	if err != nil {
		return
	}
	for _, dataset := range datasets {
		if dataset.Kind != nativeDatasetBase {
			continue
		}
		validateDatasetOverrideTargetWarnings(dataset, report)
		validateDatasetNormalizedLockWarnings(dataset, report)
		validateDatasetGhostRowWarnings(dataset, report)
	}
}

func validateDatasetOverrideTargetWarnings(dataset nativeDataset, report *ValidationReport) {
	if dataset.BasePath == "" {
		return
	}
	if _, err := os.Stat(dataset.BasePath); err != nil {
		return
	}
	baseObj, err := loadJSONObject(dataset.BasePath)
	if err != nil {
		return
	}
	rawRows, ok := baseObj["rows"].([]any)
	if !ok {
		return
	}
	rowIDToKey := map[int]string{}
	keyToID := map[string]int{}
	usedIDs := map[int]struct{}{}
	for index, raw := range rawRows {
		row, ok := raw.(map[string]any)
		if !ok {
			continue
		}
		rowID := index
		if rawID, ok := row["id"]; ok {
			if parsed, err := asInt(rawID); err == nil {
				rowID = parsed
			}
		}
		usedIDs[rowID] = struct{}{}
		if key, ok := row["key"].(string); ok && key != "" {
			rowIDToKey[rowID] = key
			keyToID[key] = rowID
		}
	}
	nextID := nextAvailableID(usedIDs)
	allocateNextID := func() int {
		rowID := nextID
		usedIDs[rowID] = struct{}{}
		nextID = nextAvailableID(usedIDs)
		return rowID
	}
	modulePaths, err := collectModulePaths(dataset.ModulesDir)
	if err != nil {
		return
	}
	for _, path := range modulePaths {
		obj, err := loadJSONObject(path)
		if err != nil {
			continue
		}
		if rawEntries, ok := obj["entries"].(map[string]any); ok {
			for key := range rawEntries {
				if key == "" {
					continue
				}
				if _, exists := keyToID[key]; exists {
					continue
				}
				rowID := allocateNextID()
				keyToID[key] = rowID
				rowIDToKey[rowID] = key
			}
		}
		rawOverrides, ok := obj["overrides"].([]any)
		if !ok {
			continue
		}
		for index, raw := range rawOverrides {
			override, ok := raw.(map[string]any)
			if !ok {
				continue
			}
			keyOnlyTarget := false
			keyText, hasKeyText := override["key"].(string)
			if _, hasID := override["id"]; !hasID && hasKeyText && keyText != "" {
				keyOnlyTarget = true
				if _, exists := keyToID[keyText]; !exists {
					canonical := resolveCanonicalDatasetKey(keyText, keyToID)
					if canonical != keyText {
						report.Diagnostics = append(report.Diagnostics, Diagnostic{
							Severity: SeverityWarning,
							Path:     path,
							Message:  fmt.Sprintf("override %d targets key %q, but the live canonical key at that point is %q; this key-only override will create or retarget a new row", index, keyText, canonical),
						})
					} else {
						report.Diagnostics = append(report.Diagnostics, Diagnostic{
							Severity: SeverityWarning,
							Path:     path,
							Message:  fmt.Sprintf("override %d targets key %q, but no live row owns that key at that point; this key-only override will create or retarget a new row", index, keyText),
						})
					}
				}
			}
			rowID := -1
			if rawID, ok := override["id"]; ok {
				parsed, err := asInt(rawID)
				if err == nil {
					rowID = parsed
				}
			} else if hasKeyText && keyText != "" {
				if mappedID, exists := keyToID[keyText]; exists {
					rowID = mappedID
				}
			}
			if rowID < 0 {
				if keyOnlyTarget {
					rowID = allocateNextID()
					keyToID[keyText] = rowID
					rowIDToKey[rowID] = keyText
				}
				continue
			}
			currentKey := rowIDToKey[rowID]
			if overrideRequestsNullRow(override) {
				if currentKey != "" {
					delete(keyToID, currentKey)
					delete(rowIDToKey, rowID)
				}
				continue
			}
			if rawKey, present := override["key"]; present {
				switch typed := rawKey.(type) {
				case nil:
					if currentKey != "" {
						delete(keyToID, currentKey)
						delete(rowIDToKey, rowID)
					}
				case string:
					if typed == "" || strings.TrimSpace(typed) == nullValue {
						if currentKey != "" {
							delete(keyToID, currentKey)
							delete(rowIDToKey, rowID)
						}
						continue
					}
					if currentKey != "" && currentKey != typed {
						delete(keyToID, currentKey)
					}
					if previousID, exists := keyToID[typed]; exists && previousID != rowID {
						delete(rowIDToKey, previousID)
					}
					keyToID[typed] = rowID
					rowIDToKey[rowID] = typed
				}
			}
		}
	}
}

func validateDatasetNormalizedLockWarnings(dataset nativeDataset, report *ValidationReport) {
	lockData, err := loadLockfile(dataset.LockPath)
	if err != nil {
		return
	}
	groups := map[string][]string{}
	for key := range lockData {
		prefix, suffix, ok := strings.Cut(key, ":")
		if !ok || prefix == "" || suffix == "" {
			continue
		}
		groups[prefix+":"+normalizeKeyIdentity(suffix)] = append(groups[prefix+":"+normalizeKeyIdentity(suffix)], key)
	}
	for _, keys := range groups {
		if len(keys) < 2 {
			continue
		}
		slices.Sort(keys)
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityWarning,
			Path:     dataset.LockPath,
			Message:  fmt.Sprintf("lockfile keeps multiple normalized-equivalent keys alive: %s", strings.Join(keys, ", ")),
		})
	}
}

func validateDatasetGhostRowWarnings(dataset nativeDataset, report *ValidationReport) {
	if dataset.BasePath == "" {
		return
	}
	if _, err := os.Stat(dataset.BasePath); err != nil {
		return
	}
	baseObj, err := loadJSONObject(dataset.BasePath)
	if err != nil {
		return
	}
	rawRows, ok := baseObj["rows"].([]any)
	if !ok {
		return
	}
	baseKeyedIDs := map[int]struct{}{}
	for index, raw := range rawRows {
		row, ok := raw.(map[string]any)
		if !ok {
			continue
		}
		if key, ok := row["key"].(string); !ok || key == "" {
			continue
		}
		rowID := index
		if rawID, ok := row["id"]; ok {
			if parsed, err := asInt(rawID); err == nil {
				rowID = parsed
			}
		}
		baseKeyedIDs[rowID] = struct{}{}
	}
	collected, err := collectNativeDataset(dataset)
	if err != nil {
		return
	}
	for _, row := range collected.Rows {
		if key, ok := row["key"].(string); ok && key != "" {
			continue
		}
		rowID, _ := row["id"].(int)
		if _, hadBaseKey := baseKeyedIDs[rowID]; !hadBaseKey {
			continue
		}
		nonNullFields := make([]string, 0, 4)
		for field, value := range row {
			if field == "id" || field == "key" || field == "meta" {
				continue
			}
			if isEffectivelyNullValue(value) {
				continue
			}
			nonNullFields = append(nonNullFields, field)
		}
		if len(nonNullFields) == 0 || len(nonNullFields) > 3 {
			continue
		}
		slices.Sort(nonNullFields)
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityWarning,
			Path:     dataset.BasePath,
			Message:  fmt.Sprintf("row id %d has no canonical key but still carries fields %s; this usually indicates a key-only override hit a retired row or a row was partially de-identified", rowID, strings.Join(nonNullFields, ", ")),
		})
	}
}

func isEffectivelyNullValue(value any) bool {
	switch typed := value.(type) {
	case nil:
		return true
	case string:
		return strings.TrimSpace(typed) == "" || strings.TrimSpace(typed) == nullValue
	default:
		return false
	}
}

type requiredFeatFamily struct {
	FamilyKey string
	Dataset   string
	Column    string
	Predicate string
}

func validateGeneratedFeatFamilies(dataDir string, report *ValidationReport) {
	featDataset, ok, err := discoverNamedNativeDataset(dataDir, "feat")
	if err != nil {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     dataDir,
			Message:  fmt.Sprintf("discover feat dataset for generated family validation: %v", err),
		})
		return
	}
	if !ok {
		return
	}
	lockData, err := loadLockfile(featDataset.LockPath)
	if err != nil {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     featDataset.LockPath,
			Message:  fmt.Sprintf("load feat lockfile for generated family validation: %v", err),
		})
		return
	}
	ctx, err := newFeatGeneratedContext(featDataset, lockData)
	if err != nil {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     featDataset.GeneratedDir,
			Message:  fmt.Sprintf("prepare generated family validation: %v", err),
		})
		return
	}
	specs, specPaths, err := collectGeneratedFamilySpecs(featDataset.GeneratedDir)
	if err != nil {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     featDataset.GeneratedDir,
			Message:  fmt.Sprintf("load generated family specs: %v", err),
		})
		return
	}
	for familyKey, spec := range specs {
		ctx.familySpecs[familyKey] = spec
	}

	required := []requiredFeatFamily{
		{FamilyKey: "skillfocus", Dataset: "skills", Predicate: "accessible"},
		{FamilyKey: "greaterskillfocus", Dataset: "skills", Predicate: "accessible"},
		{FamilyKey: "weaponfocus", Dataset: "baseitems", Column: "WeaponFocusFeat"},
		{FamilyKey: "weaponspecialization", Dataset: "baseitems", Column: "WeaponSpecializationFeat"},
		{FamilyKey: "improvedcritical", Dataset: "baseitems", Column: "WeaponImprovedCriticalFeat"},
		{FamilyKey: "overwhelmingcritical", Dataset: "baseitems", Column: "EpicWeaponOverwhelmingCriticalFeat"},
		{FamilyKey: "greaterweaponfocus", Dataset: "baseitems", Column: "EpicWeaponFocusFeat"},
		{FamilyKey: "greaterweaponspecialization", Dataset: "baseitems", Column: "EpicWeaponSpecializationFeat"},
		{FamilyKey: "weaponofchoice", Dataset: "baseitems", Column: "WeaponOfChoiceFeat"},
	}
	for _, requirement := range required {
		spec, path, ok := generatedFamilySpecByNormalizedKey(specs, specPaths, requirement.FamilyKey)
		if !ok {
			continue
		}
		if spec.ChildSource.Dataset != requirement.Dataset ||
			spec.ChildSource.Column != requirement.Column ||
			spec.ChildSource.Predicate != requirement.Predicate {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     path,
				Message:  fmt.Sprintf("generated feat family %q must use child source dataset=%q column=%q predicate=%q", requirement.FamilyKey, requirement.Dataset, requirement.Column, requirement.Predicate),
			})
			continue
		}
		validateGeneratedFeatFamilyCompleteness(path, spec, ctx, report)
	}
	if hasGeneratedFamilySpecs(specs, coreWeaponGeneratedFamilyKeys) {
		validateBaseitemsWeaponFeatColumnCompleteness(featDataset.GeneratedDir, ctx, report)
	}
	validateRequiredProductionGeneratedFamilies(featDataset.GeneratedDir, specs, report)
}

var coreWeaponGeneratedFamilyKeys = []string{
	"weaponfocus",
	"weaponspecialization",
	"improvedcritical",
	"overwhelmingcritical",
	"greaterweaponfocus",
	"greaterweaponspecialization",
}

func hasGeneratedFamilySpecs(specs map[string]familyExpansionSpec, familyKeys []string) bool {
	for _, familyKey := range familyKeys {
		if _, _, ok := generatedFamilySpecByNormalizedKey(specs, nil, familyKey); !ok {
			return false
		}
	}
	return true
}

func validateBaseitemsWeaponFeatColumnCompleteness(path string, ctx *featGeneratedContext, report *ValidationReport) {
	rows, err := ctx.datasetRows("baseitems")
	if err != nil {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  fmt.Sprintf("validate baseitems weapon feat coverage: %v", err),
		})
		return
	}
	coreColumns := []string{
		"WeaponFocusFeat",
		"WeaponSpecializationFeat",
		"WeaponImprovedCriticalFeat",
		"EpicWeaponFocusFeat",
		"EpicWeaponSpecializationFeat",
		"EpicWeaponOverwhelmingCriticalFeat",
	}
	for _, sourceKey := range sortedStringMapKeys(rows) {
		row := rows[sourceKey]
		hasAny := false
		missing := make([]string, 0)
		for _, column := range coreColumns {
			value, ok := lookupField(row, column)
			if !ok || isNullishValue(value) {
				missing = append(missing, column)
				continue
			}
			hasAny = true
		}
		if !hasAny || len(missing) == 0 {
			continue
		}
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  fmt.Sprintf("baseitems row %q declares partial core weapon feat coverage; missing %s", sourceKey, strings.Join(missing, ", ")),
		})
	}
}

func generatedFamilySpecByNormalizedKey(specs map[string]familyExpansionSpec, specPaths map[string]string, familyKey string) (familyExpansionSpec, string, bool) {
	normalized := normalizeKeyIdentity(familyKey)
	for candidateKey, spec := range specs {
		if normalizeKeyIdentity(candidateKey) == normalized {
			path := ""
			if specPaths != nil {
				path = specPaths[candidateKey]
			}
			return spec, path, true
		}
	}
	return familyExpansionSpec{}, "", false
}

func validateRequiredProductionGeneratedFamilies(path string, specs map[string]familyExpansionSpec, report *ValidationReport) {
	if !hasGeneratedFamilySpecs(specs, coreWeaponGeneratedFamilyKeys) {
		return
	}
	if _, _, ok := generatedFamilySpecByNormalizedKey(specs, nil, "weaponofchoice"); !ok {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  "missing generated feat family \"weaponofchoice\"",
		})
	}
}

func discoverNamedNativeDataset(dataDir, name string) (nativeDataset, bool, error) {
	datasets, err := discoverNativeDatasets(dataDir)
	if err != nil {
		return nativeDataset{}, false, err
	}
	for _, dataset := range datasets {
		if dataset.Name == name {
			return dataset, true, nil
		}
	}
	return nativeDataset{}, false, nil
}

func collectGeneratedFamilySpecs(generatedDir string) (map[string]familyExpansionSpec, map[string]string, error) {
	paths, err := collectModulePaths(generatedDir)
	if err != nil {
		return nil, nil, err
	}
	specs := map[string]familyExpansionSpec{}
	pathsByFamily := map[string]string{}
	for _, path := range paths {
		obj, err := loadJSONObject(path)
		if err != nil {
			return nil, nil, err
		}
		if !isFamilyExpansionObject(obj) {
			continue
		}
		spec, err := parseFamilyExpansionSpec(path, obj)
		if err != nil {
			return nil, nil, err
		}
		if previous, ok := pathsByFamily[spec.FamilyKey]; ok {
			return nil, nil, fmt.Errorf("generated family %q is declared by both %s and %s", spec.FamilyKey, previous, path)
		}
		specs[spec.FamilyKey] = spec
		pathsByFamily[spec.FamilyKey] = path
	}
	return specs, pathsByFamily, nil
}

func validateGeneratedFeatFamilyCompleteness(path string, spec familyExpansionSpec, ctx *featGeneratedContext, report *ValidationReport) {
	sourceRows, err := ctx.datasetRows(spec.ChildSource.Dataset)
	if err != nil {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  err.Error(),
		})
		return
	}
	moduleData, err := buildFamilyExpansionGeneratedModule(path, map[string]any{
		"family":              spec.Family,
		"family_key":          spec.FamilyKey,
		"template":            spec.Template,
		"name_prefix":         spec.NamePrefix,
		"label_prefix":        spec.LabelPrefix,
		"constant_prefix":     spec.ConstantPrefix,
		"template_fields":     validationStringSliceToAny(spec.TemplateFields),
		"default_fields":      spec.DefaultFields,
		"apply_after_modules": spec.ApplyAfterModules,
		"child_ref_field":     spec.ChildRefField,
		"identity_source":     spec.IdentitySource,
		"allow_existing_only": spec.AllowExistingOnly,
		"auto_prereq_fields":  stringMapToAny(spec.AutoPrereqFields),
		"legacy_family_keys":  validationStringSliceToAny(spec.LegacyFamilyKeys),
		"child_source": map[string]any{
			"dataset":   spec.ChildSource.Dataset,
			"column":    spec.ChildSource.Column,
			"predicate": spec.ChildSource.Predicate,
		},
		"overrides": map[string]any{},
	}, ctx)
	if err != nil {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  err.Error(),
		})
		return
	}
	expected := map[string]struct{}{}
	familyAllowlist := spec.AllowExistingOnly && ctx.familyHasExistingRows(spec.FamilyKey)
	for sourceKey, row := range sourceRows {
		include, err := familyExpansionSourceEligible(spec, row)
		if err != nil {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     path,
				Message:  err.Error(),
			})
			return
		}
		if include {
			if familyAllowlist {
				slug := strings.TrimPrefix(sourceKey, spec.ChildSource.Dataset+":")
				featKey, _, _, err := ctx.resolveGeneratedFeatIdentity(spec, slug, row)
				if err != nil {
					report.Diagnostics = append(report.Diagnostics, Diagnostic{
						Severity: SeverityError,
						Path:     path,
						Message:  err.Error(),
					})
					return
				}
				if !ctx.featKeyExists(featKey) {
					continue
				}
			}
			expected[sourceKey] = struct{}{}
		}
	}
	actual := map[string]string{}
	overrides, _ := moduleData["overrides"].([]any)
	for index, raw := range overrides {
		override, ok := raw.(map[string]any)
		if !ok {
			continue
		}
		source, ok := familySourceFromMeta(override["meta"])
		if !ok || source == "" {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     path,
				Message:  fmt.Sprintf("generated family %q row %d is missing family source metadata", spec.FamilyKey, index),
			})
			continue
		}
		if previousKey, ok := actual[source]; ok {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     path,
				Message:  fmt.Sprintf("generated family %q emits source %q more than once (%s and row %d)", spec.FamilyKey, source, previousKey, index),
			})
		}
		key, _ := override["key"].(string)
		actual[source] = key
	}
	for source := range expected {
		if _, ok := actual[source]; !ok {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     path,
				Message:  fmt.Sprintf("generated family %q is missing source %q", spec.FamilyKey, source),
			})
		}
	}
	for source := range actual {
		if _, ok := expected[source]; !ok {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     path,
				Message:  fmt.Sprintf("generated family %q includes unexpected source %q", spec.FamilyKey, source),
			})
		}
	}
}

func familySourceFromMeta(raw any) (string, bool) {
	meta, ok := raw.(map[string]any)
	if !ok {
		return "", false
	}
	family, ok := meta["family"].(map[string]any)
	if !ok {
		return "", false
	}
	source, ok := family["source"].(string)
	return source, ok
}

func validationStringSliceToAny(items []string) []any {
	if items == nil {
		return nil
	}
	out := make([]any, 0, len(items))
	for _, item := range items {
		out = append(out, item)
	}
	return out
}

func stringMapToAny(items map[string]string) map[string]any {
	if items == nil {
		return nil
	}
	out := make(map[string]any, len(items))
	for key, value := range items {
		out[key] = value
	}
	return out
}

func validateTopPackageAssets(sourceDir, dataDir string, report *ValidationReport) {
	assetsDir := filepath.Join(sourceDir, "assets")
	generated2DADir := filepath.Join(assetsDir, "2da")
	info, err := os.Stat(assetsDir)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return
		}
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     assetsDir,
			Message:  err.Error(),
		})
		return
	}
	if !info.IsDir() {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     assetsDir,
			Message:  "must be a directory",
		})
		return
	}

	projectOutputs, err := discoverNativeOutputCatalog(dataDir)
	if err != nil {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     assetsDir,
			Message:  err.Error(),
		})
		return
	}

	seen := map[string]string{}
	err = filepath.WalkDir(assetsDir, func(path string, d fs.DirEntry, walkErr error) error {
		if walkErr != nil {
			return walkErr
		}
		if d.IsDir() && path == generated2DADir {
			return filepath.SkipDir
		}
		if d.IsDir() {
			return nil
		}
		report.Files++
		rel, err := filepath.Rel(assetsDir, path)
		if err != nil {
			return err
		}
		if strings.HasPrefix(filepath.Base(path), ".") {
			return nil
		}
		base := strings.ToLower(strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)))
		ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), ".")
		if _, ok := erf.HAKResourceTypeForExtension(ext); !ok {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     path,
				Message:  fmt.Sprintf("unsupported topdata asset HAK resource extension %q", filepath.Ext(path)),
			})
			return nil
		}
		key := base + "." + ext
		if previous, ok := seen[key]; ok {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     path,
				Message:  fmt.Sprintf("duplicate topdata asset resource %q also defined by %s", key, previous),
			})
			return nil
		}
		seen[key] = rel
		if owner, ok := projectOutputs[key]; ok {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     path,
				Message:  fmt.Sprintf("topdata asset resource %q collides with compiled topdata output from %s", key, owner),
			})
		}
		return nil
	})
	if err != nil {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     assetsDir,
			Message:  err.Error(),
		})
	}
}

func recordNativeOutputOwner(outputName, owner string, outputOwners map[string]string, report *ValidationReport) {
	if existing, ok := outputOwners[outputName]; ok {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     owner,
			Message:  fmt.Sprintf("duplicate 2da output %q also produced by %s", outputName, existing),
		})
		return
	}
	outputOwners[outputName] = owner
}

func validateNativeBuildability(p *project.Project, report *ValidationReport) {
	dataDir := filepath.Join(p.TopDataSourceDir(), "data")
	datasets, err := discoverNativeDatasets(dataDir)
	if err != nil {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     dataDir,
			Message:  err.Error(),
		})
		return
	}
	registryDatasets, err := collectGeneratedRegistryDatasets(dataDir)
	if err != nil {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     dataDir,
			Message:  err.Error(),
		})
		return
	}
	if len(datasets) == 0 && len(registryDatasets) == 0 {
		return
	}
	tempBuild, err := os.MkdirTemp("", "sow-topdata-validate-*")
	if err != nil {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     p.TopDataSourceDir(),
			Message:  fmt.Sprintf("create validation temp dir: %v", err),
		})
		return
	}
	defer os.RemoveAll(tempBuild)

	clone := *p
	clone.Config.TopData.Build = tempBuild
	lockSnapshot, err := snapshotLockfiles(dataDir)
	if err != nil {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     dataDir,
			Message:  fmt.Sprintf("snapshot lockfiles for validation build: %v", err),
		})
		return
	}
	defer restoreLockfiles(lockSnapshot)
	if _, err := buildNativeUnchecked(&clone, NativeBuildOptions{}, nil, nil); err != nil {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     p.TopDataSourceDir(),
			Message:  fmt.Sprintf("native topdata buildability check failed: %v", err),
		})
	}
}

type lockfileSnapshot struct {
	Root  string
	Files map[string][]byte
}

func snapshotLockfiles(dataDir string) (lockfileSnapshot, error) {
	snapshot := lockfileSnapshot{
		Root:  dataDir,
		Files: map[string][]byte{},
	}
	err := filepath.WalkDir(dataDir, func(path string, d fs.DirEntry, walkErr error) error {
		if walkErr != nil {
			return walkErr
		}
		if d.IsDir() || filepath.Base(path) != "lock.json" {
			return nil
		}
		raw, err := os.ReadFile(path)
		if err != nil {
			return err
		}
		snapshot.Files[path] = raw
		return nil
	})
	return snapshot, err
}

func restoreLockfiles(snapshot lockfileSnapshot) {
	seen := map[string]struct{}{}
	for path, raw := range snapshot.Files {
		seen[path] = struct{}{}
		if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
			continue
		}
		_ = os.WriteFile(path, raw, 0o644)
	}
	_ = filepath.WalkDir(snapshot.Root, func(path string, d fs.DirEntry, walkErr error) error {
		if walkErr != nil || d.IsDir() || filepath.Base(path) != "lock.json" {
			return nil
		}
		if _, ok := seen[path]; !ok {
			_ = os.Remove(path)
		}
		return nil
	})
}

func validateRowCollection(path string, obj map[string]any, rows []any, report *ValidationReport) {
	columns := extractValidationColumns(obj)
	declaredKeys := map[string]int{}
	checkDuplicateKeys := filepath.Base(path) != "base.json"
	rowHasIdentity := func(row map[string]any, fallbackID int) (string, bool) {
		if key, ok := row["key"].(string); ok && strings.TrimSpace(key) != "" {
			return key, true
		}
		if _, ok := row["id"]; ok {
			return fmt.Sprintf("id:%d", fallbackID), true
		}
		return "", false
	}

	for index, raw := range rows {
		row, ok := raw.(map[string]any)
		if !ok {
			continue
		}
		if checkDuplicateKeys {
			if key, ok := row["key"].(string); ok && strings.TrimSpace(key) != "" {
				if previous, ok := declaredKeys[key]; ok {
					report.Diagnostics = append(report.Diagnostics, Diagnostic{
						Severity: SeverityError,
						Path:     path,
						Message:  fmt.Sprintf("row %d repeats key %q already declared by row %d", index, key, previous),
					})
				}
				declaredKeys[key] = index
			}
		}
		for key := range row {
			if isAuthoringOnlyField(key) && !preserveLegacyWikiColumnForPath(path, key) {
				report.Diagnostics = append(report.Diagnostics, Diagnostic{
					Severity: SeverityError,
					Path:     path,
					Message:  fmt.Sprintf("row %d uses deprecated wiki field %q; move it into meta.wiki", index, key),
				})
			}
		}
		if _, hasTLK := row["_tlk"]; hasTLK {
			if _, ok := rowHasIdentity(row, index); !ok {
				report.Diagnostics = append(report.Diagnostics, Diagnostic{
					Severity: SeverityError,
					Path:     path,
					Message:  fmt.Sprintf("row %d uses _tlk but has no resolvable identity", index),
				})
			}
		}
		validateRowMetadata(path, fmt.Sprintf("row %d", index), row, report)
		validateInheritanceUsage(path, fmt.Sprintf("row %d", index), row, columns, report)
		validateGenericInheritanceObject(path, fmt.Sprintf("row %d", index), row, report)
		validateInlineTextUsage(path, fmt.Sprintf("row %d", index), row, report)
	}
	_ = obj
}

func validateRowMetadata(path, label string, row map[string]any, report *ValidationReport) {
	rawMeta, ok := lookupField(row, "meta")
	if !ok {
		return
	}
	if _, err := parseRowMetadata(rawMeta); err != nil {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  fmt.Sprintf("%s meta: %v", label, err),
		})
	}
}

func extractValidationColumns(obj map[string]any) []string {
	rawColumns, ok := obj["columns"].([]any)
	if !ok {
		return nil
	}
	columns := make([]string, 0, len(rawColumns))
	for _, raw := range rawColumns {
		column, ok := raw.(string)
		if !ok {
			continue
		}
		columns = append(columns, column)
	}
	return columns
}

func preserveLegacyWikiColumnForPath(path, field string) bool {
	return strings.HasSuffix(filepath.ToSlash(path), "/data/classes/core/base.json") &&
		strings.EqualFold(strings.TrimSpace(field), "WIKIGENERATE")
}

func validateInheritanceUsage(path, label string, row map[string]any, columns []string, report *ValidationReport) {
	spec, ok, err := parseRowInheritanceSpec(row)
	if err != nil {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  fmt.Sprintf("%s inherit: %v", label, err),
		})
		return
	}
	if !ok {
		return
	}
	if _, ok := row[spec.From]; !ok {
		found := false
		for key := range row {
			if strings.EqualFold(key, spec.From) {
				found = true
				break
			}
		}
		if !found {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     path,
				Message:  fmt.Sprintf("%s inherit.from field %q was not found on row", label, spec.From),
			})
		}
	}
	for _, field := range spec.Fields {
		if len(columns) == 0 {
			continue
		}
		if _, ok := canonicalColumn(columns, field); ok {
			continue
		}
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  fmt.Sprintf("%s inherit field %q is not a known column", label, field),
		})
	}
}

func validateGenericInheritanceObject(path, label string, value any, report *ValidationReport) {
	switch typed := value.(type) {
	case map[string]any:
		spec, ok, err := parseGenericInheritanceSpec(typed)
		if err != nil {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     path,
				Message:  fmt.Sprintf("%s inherit: %v", label, err),
			})
		} else if ok {
			if !strings.Contains(spec.Ref, ":") {
				report.Diagnostics = append(report.Diagnostics, Diagnostic{
					Severity: SeverityError,
					Path:     path,
					Message:  fmt.Sprintf("%s inherit.ref %q must use dataset:key identity", label, spec.Ref),
				})
			}
		}
		for key, child := range typed {
			if key == "inherit" {
				continue
			}
			validateGenericInheritanceObject(path, label+"."+key, child, report)
		}
	case []any:
		for index, child := range typed {
			validateGenericInheritanceObject(path, fmt.Sprintf("%s[%d]", label, index), child, report)
		}
	}
}

func validateInlineTextUsage(path, label string, obj map[string]any, report *ValidationReport) {
	for key, value := range obj {
		if key == "_tlk" {
			typed, ok := value.(map[string]any)
			if !ok {
				report.Diagnostics = append(report.Diagnostics, Diagnostic{
					Severity: SeverityError,
					Path:     path,
					Message:  fmt.Sprintf("%s _tlk must be an object", label),
				})
				continue
			}
			for role, roleValue := range typed {
				if strings.ToLower(role) != "name" && strings.ToLower(role) != "description" {
					report.Diagnostics = append(report.Diagnostics, Diagnostic{
						Severity: SeverityError,
						Path:     path,
						Message:  fmt.Sprintf("%s _tlk role %q is not supported", label, role),
					})
					continue
				}
				if _, _, err := parseTLKPayload(roleValue, true); err != nil {
					report.Diagnostics = append(report.Diagnostics, Diagnostic{
						Severity: SeverityError,
						Path:     path,
						Message:  fmt.Sprintf("%s _tlk.%s: %v", label, role, err),
					})
				}
			}
			continue
		}

		allowBare := true
		if typed, ok := value.(map[string]any); ok {
			_, hasWrappedTLK := typed["tlk"]
			if !hasWrappedTLK {
				if _, hasRef := typed["ref"]; hasRef {
					if _, hasText := typed["text"]; !hasText {
						if _, hasKey := typed["key"]; !hasKey {
							continue
						}
					}
				}
				allowBare = false
			}
		}
		payload, ok, err := parseTLKPayload(value, allowBare)
		if err != nil {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     path,
				Message:  fmt.Sprintf("%s %s: %v", label, key, err),
			})
			continue
		}
		if !ok {
			continue
		}
		if payload.Key == "" && payload.Text == "" && payload.Ref == "" {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     path,
				Message:  fmt.Sprintf("%s %s TLK payload is empty", label, key),
			})
		}
	}
}

func Compare(p *project.Project, progress func(string)) (CompareResult, error) {
	if progress == nil {
		progress = func(string) {}
	}
	output2DA := compiled2DAOutputDir(p)
	outputTLK := compiledTLKOutputDir(p)
	if _, err := os.Stat(output2DA); err != nil {
		return CompareResult{}, fmt.Errorf("topdata build output missing: run build-topdata first")
	}
	compared2DA, comparedTLK, nativePass, nativeMismatch, err := compareNativeSelfCheck(p, output2DA, outputTLK, progress)
	if err != nil {
		return CompareResult{}, err
	}

	return CompareResult{
		Mode:            "native",
		Compared2DA:     compared2DA,
		ComparedTLK:     comparedTLK,
		NativePass:      nativePass,
		NotYetCanonical: 0,
		NativeMismatch:  nativeMismatch,
	}, nil
}

func BuildReference(p *project.Project, progress func(string)) (BuildResult, error) {
	return BuildNative(p, progress)
}

func CompareReference(p *project.Project, progress func(string)) (CompareResult, error) {
	return Compare(p, progress)
}

func compareDirs(actualRoot, expectedRoot string) (int, error) {
	expectedFiles := make([]string, 0)
	err := filepath.WalkDir(expectedRoot, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if d.IsDir() {
			return nil
		}
		rel, err := filepath.Rel(expectedRoot, path)
		if err != nil {
			return err
		}
		expectedFiles = append(expectedFiles, filepath.ToSlash(rel))
		return nil
	})
	if err != nil {
		return 0, err
	}
	slices.Sort(expectedFiles)

	actualFiles := make([]string, 0)
	err = filepath.WalkDir(actualRoot, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if d.IsDir() {
			return nil
		}
		rel, err := filepath.Rel(actualRoot, path)
		if err != nil {
			return err
		}
		actualFiles = append(actualFiles, filepath.ToSlash(rel))
		return nil
	})
	if err != nil {
		return 0, err
	}
	slices.Sort(actualFiles)
	if !slices.Equal(actualFiles, expectedFiles) {
		return 0, fmt.Errorf("topdata output file list differs from reference")
	}

	for _, rel := range expectedFiles {
		actualBytes, err := os.ReadFile(filepath.Join(actualRoot, rel))
		if err != nil {
			return 0, err
		}
		expectedBytes, err := os.ReadFile(filepath.Join(expectedRoot, rel))
		if err != nil {
			return 0, err
		}
		if !bytes.Equal(actualBytes, expectedBytes) {
			return 0, fmt.Errorf("topdata output differs from reference: %s", rel)
		}
	}
	return len(expectedFiles), nil
}

func compareNativeSelfCheck(p *project.Project, actual2DA, actualTLK string, progress func(string)) (int, int, int, int, error) {
	if progress == nil {
		progress = func(string) {}
	}
	projectOutputs, err := discoverNativeOutputCatalog(filepath.Join(p.TopDataSourceDir(), "data"))
	if err != nil {
		return 0, 0, 0, 0, err
	}
	if len(projectOutputs) == 0 {
		return 0, 0, 0, 0, nil
	}

	tempBuild, err := os.MkdirTemp("", "sow-topdata-native-compare-*")
	if err != nil {
		return 0, 0, 0, 0, fmt.Errorf("create native compare temp dir: %w", err)
	}
	defer os.RemoveAll(tempBuild)

	nativeProject := *p
	nativeProject.Config.TopData.Build = tempBuild
	nativeResult, err := buildNativeUnchecked(&nativeProject, NativeBuildOptions{}, nil, nil)
	if err != nil {
		return 0, 0, 0, len(projectOutputs), nil
	}

	pass := 0
	mismatch := 0
	for outputName := range projectOutputs {
		actualBytes, err := os.ReadFile(filepath.Join(actual2DA, outputName))
		if err != nil {
			mismatch++
			continue
		}
		expectedBytes, err := os.ReadFile(filepath.Join(nativeResult.Output2DADir, outputName))
		if err != nil {
			mismatch++
			continue
		}
		if bytes.Equal(normalize2DAForCompare(outputName, actualBytes), normalize2DAForCompare(outputName, expectedBytes)) {
			pass++
			continue
		}
		mismatch++
	}
	actual2DAFiles, err := listRelativeFiles(actual2DA)
	if err != nil {
		return 0, 0, 0, 0, err
	}
	expected2DAFiles, err := listRelativeFiles(nativeResult.Output2DADir)
	if err != nil {
		return 0, 0, 0, 0, err
	}
	if !slices.Equal(actual2DAFiles, expected2DAFiles) {
		mismatch += symmetricDiffCount(actual2DAFiles, expected2DAFiles)
	}
	comparedTLK, tlkMismatch, err := compareExactDirFilesByExtension(actualTLK, nativeResult.OutputTLKDir, ".tlk")
	if err != nil {
		return 0, 0, 0, 0, err
	}
	mismatch += tlkMismatch
	return len(projectOutputs), comparedTLK, pass, mismatch, nil
}

func compareNativeCoverage(p *project.Project, actual2DA string, progress func(string)) (int, int, int, error) {
	actualTLK := compiledTLKOutputDir(p)
	compared2DA, _, pass, mismatch, err := compareNativeSelfCheck(p, actual2DA, actualTLK, progress)
	if err != nil {
		return 0, 0, 0, err
	}
	if compared2DA == 0 {
		return 0, 0, mismatch, nil
	}
	return pass, 0, mismatch, nil
}

func normalize2DAForCompare(outputName string, raw []byte) []byte {
	text := strings.ReplaceAll(string(raw), "\r\n", "\n")
	lines := strings.Split(text, "\n")
	if len(lines) < 3 || !strings.HasPrefix(lines[0], "2DA V2.0") {
		return raw
	}
	headerIndex := 2
	columns := strings.Split(lines[headerIndex], "\t")
	keep := make([]int, 0, len(columns))
	filtered := make([]string, 0, len(columns))
	for index, column := range columns {
		if isAuthoringOnlyField(column) {
			continue
		}
		keep = append(keep, index)
		filtered = append(filtered, column)
	}
	var builder strings.Builder
	builder.WriteString(lines[0])
	builder.WriteByte('\n')
	builder.WriteByte('\n')
	builder.WriteString(strings.Join(filtered, "\t"))
	builder.WriteByte('\n')
	normalizedRows := make([][]string, 0, len(lines))
	for _, line := range lines[headerIndex+1:] {
		if line == "" {
			continue
		}
		fields := strings.Split(line, "\t")
		if len(fields) == 0 {
			continue
		}
		row := make([]string, 0, len(keep))
		for _, index := range keep {
			if index+1 < len(fields) {
				row = append(row, fields[index+1])
			} else {
				row = append(row, nullValue)
			}
		}
		normalizedRows = append(normalizedRows, row)
	}
	if shouldIgnore2DAPlacement(outputName) {
		slices.SortFunc(normalizedRows, func(a, b []string) int {
			return strings.Compare(strings.Join(a, "\x00"), strings.Join(b, "\x00"))
		})
	}
	for index, row := range normalizedRows {
		builder.WriteString(strconv.Itoa(index))
		for _, value := range row {
			builder.WriteByte('\t')
			builder.WriteString(value)
		}
		builder.WriteByte('\n')
	}
	return []byte(builder.String())
}

func shouldIgnore2DAPlacement(outputName string) bool {
	return strings.HasPrefix(outputName, "cls_feat_") && strings.HasSuffix(outputName, ".2da")
}

func walkJSON(root string, fn func(path string)) error {
	return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if d.IsDir() {
			return nil
		}
		if strings.HasSuffix(strings.ToLower(d.Name()), ".json") {
			fn(path)
		}
		return nil
	})
}

func countFiles(root string) (int, error) {
	count := 0
	err := filepath.WalkDir(root, func(_ string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if !d.IsDir() {
			count++
		}
		return nil
	})
	return count, err
}

type progressWriter struct {
	progress func(string)
}

func (w progressWriter) Write(p []byte) (int, error) {
	text := strings.TrimSpace(string(p))
	if text != "" {
		w.progress(text)
	}
	return len(p), nil
}

func ioDiscardProgress(progress func(string)) *progressWriter {
	return &progressWriter{progress: progress}
}

func listRelativeFiles(root string) ([]string, error) {
	return listRelativeFilesByExtension(root, "")
}

func listRelativeFilesByExtension(root, extension string) ([]string, error) {
	files := make([]string, 0)
	if _, err := os.Stat(root); err != nil {
		if os.IsNotExist(err) {
			return files, nil
		}
		return nil, err
	}
	err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if d.IsDir() {
			return nil
		}
		if extension != "" && !strings.EqualFold(filepath.Ext(path), extension) {
			return nil
		}
		rel, err := filepath.Rel(root, path)
		if err != nil {
			return err
		}
		files = append(files, filepath.ToSlash(rel))
		return nil
	})
	if err != nil {
		return nil, err
	}
	slices.Sort(files)
	return files, nil
}

func symmetricDiffCount(left, right []string) int {
	leftSet := map[string]struct{}{}
	rightSet := map[string]struct{}{}
	for _, item := range left {
		leftSet[item] = struct{}{}
	}
	for _, item := range right {
		rightSet[item] = struct{}{}
	}
	count := 0
	for item := range leftSet {
		if _, ok := rightSet[item]; !ok {
			count++
		}
	}
	for item := range rightSet {
		if _, ok := leftSet[item]; !ok {
			count++
		}
	}
	return count
}

func compareExactDirFiles(actualRoot, expectedRoot string) (int, int, error) {
	return compareExactDirFilesByExtension(actualRoot, expectedRoot, "")
}

func compareExactDirFilesByExtension(actualRoot, expectedRoot, extension string) (int, int, error) {
	actualFiles, err := listRelativeFilesByExtension(actualRoot, extension)
	if err != nil {
		return 0, 0, err
	}
	expectedFiles, err := listRelativeFilesByExtension(expectedRoot, extension)
	if err != nil {
		return 0, 0, err
	}
	mismatch := 0
	if !slices.Equal(actualFiles, expectedFiles) {
		mismatch += symmetricDiffCount(actualFiles, expectedFiles)
	}
	shared := make([]string, 0, len(expectedFiles))
	for _, rel := range expectedFiles {
		if slices.Contains(actualFiles, rel) {
			shared = append(shared, rel)
		}
	}
	for _, rel := range shared {
		actualBytes, err := os.ReadFile(filepath.Join(actualRoot, rel))
		if err != nil {
			mismatch++
			continue
		}
		expectedBytes, err := os.ReadFile(filepath.Join(expectedRoot, rel))
		if err != nil {
			mismatch++
			continue
		}
		if !bytes.Equal(actualBytes, expectedBytes) {
			mismatch++
		}
	}
	return len(expectedFiles), mismatch, nil
}
