package topdata

import (
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"slices"
	"strconv"
	"strings"
)

const (
	itempropsRegistryDirName = "itemprops/registry"
	itempropsRegistryLock    = "lock.json"
)

var itempropsAvailabilityColumns = []string{
	"0_Melee",
	"1_Ranged",
	"2_Thrown",
	"3_Staves",
	"4_Rods",
	"5_Ammo",
	"6_Arm_Shld",
	"7_Helm",
	"8_Potions",
	"9_Scrolls",
	"10_Wands",
	"11_Thieves",
	"12_TrapKits",
	"13_Hide",
	"14_Claw",
	"15_Misc_Uneq",
	"16_Misc",
	"17_No_Props",
	"18_Containers",
	"19_HealerKit",
	"20_Torch",
	"21_Glove",
}

var itempropsAvailabilityAliases = map[string]string{
	"0_melee":       "melee",
	"1_ranged":      "ranged",
	"2_thrown":      "thrown",
	"3_staves":      "staves",
	"4_rods":        "rods",
	"5_ammo":        "ammo",
	"6_arm_shld":    "arm_shld",
	"7_helm":        "helm",
	"8_potions":     "potions",
	"9_scrolls":     "scrolls",
	"10_wands":      "wands",
	"11_thieves":    "thieves",
	"12_trapkits":   "trapkits",
	"13_hide":       "hide",
	"14_claw":       "claw",
	"15_misc_uneq":  "misc_uneq",
	"16_misc":       "misc",
	"17_no_props":   "no_props",
	"18_containers": "containers",
	"19_healerkit":  "healerkit",
	"20_torch":      "torch",
	"21_glove":      "glove",
}

var itempropsAvailabilityToColumn = map[string]string{
	"melee":      "0_Melee",
	"ranged":     "1_Ranged",
	"thrown":     "2_Thrown",
	"staves":     "3_Staves",
	"rods":       "4_Rods",
	"ammo":       "5_Ammo",
	"arm_shld":   "6_Arm_Shld",
	"helm":       "7_Helm",
	"potions":    "8_Potions",
	"scrolls":    "9_Scrolls",
	"wands":      "10_Wands",
	"thieves":    "11_Thieves",
	"trapkits":   "12_TrapKits",
	"hide":       "13_Hide",
	"claw":       "14_Claw",
	"misc_uneq":  "15_Misc_Uneq",
	"misc":       "16_Misc",
	"no_props":   "17_No_Props",
	"containers": "18_Containers",
	"healerkit":  "19_HealerKit",
	"torch":      "20_Torch",
	"glove":      "21_Glove",
}

type itempropsRegistry struct {
	RootPath      string
	LockPath      string
	LockData      map[string]int
	Properties    []map[string]any
	CostModels    []map[string]any
	ParamModels   []map[string]any
	SubtypeModels []map[string]any
}

func loadItempropsRegistry(dataDir string) (*itempropsRegistry, error) {
	root := filepath.Join(dataDir, filepath.FromSlash(itempropsRegistryDirName))
	info, err := os.Stat(root)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, nil
		}
		return nil, err
	}
	if !info.IsDir() {
		return nil, fmt.Errorf("itemprops registry root must be a directory: %s", root)
	}

	lockData, err := loadLockfile(filepath.Join(root, itempropsRegistryLock))
	if err != nil {
		return nil, err
	}

	properties, err := loadRegistryRows(filepath.Join(root, "properties.json"))
	if err != nil {
		return nil, err
	}
	costModels, err := loadRegistryRows(filepath.Join(root, "cost_models.json"))
	if err != nil {
		return nil, err
	}
	paramModels, err := loadRegistryRows(filepath.Join(root, "param_models.json"))
	if err != nil {
		return nil, err
	}
	subtypeModels, err := loadRegistryRows(filepath.Join(root, "subtype_models.json"))
	if err != nil {
		return nil, err
	}

	return &itempropsRegistry{
		RootPath:      root,
		LockPath:      filepath.Join(root, itempropsRegistryLock),
		LockData:      lockData,
		Properties:    properties,
		CostModels:    costModels,
		ParamModels:   paramModels,
		SubtypeModels: subtypeModels,
	}, nil
}

func loadRegistryRows(path string) ([]map[string]any, error) {
	obj, err := loadJSONObject(path)
	if err != nil {
		return nil, err
	}
	rawRows, ok := obj["rows"].([]any)
	if !ok {
		return nil, fmt.Errorf("%s: rows must be an array", path)
	}
	rows := make([]map[string]any, 0, len(rawRows))
	for index, raw := range rawRows {
		row, ok := raw.(map[string]any)
		if !ok {
			return nil, fmt.Errorf("%s: row %d must be an object", path, index)
		}
		rows = append(rows, row)
	}
	return rows, nil
}

func collectItempropsRegistryDatasets(dataDir string) ([]nativeCollectedDataset, error) {
	registry, err := loadItempropsRegistry(dataDir)
	if err != nil {
		return nil, err
	}
	if registry == nil {
		return nil, nil
	}

	propertyIDs, lockModified, err := assignRegistryIDs(registry.Properties, registry.LockData, "itemprop:")
	if err != nil {
		return nil, err
	}
	costIDs, costModified, err := assignRegistryIDs(registry.CostModels, registry.LockData, "cost_models:")
	if err != nil {
		return nil, err
	}
	paramIDs, paramModified, err := assignRegistryIDs(registry.ParamModels, registry.LockData, "param_models:")
	if err != nil {
		return nil, err
	}
	lockModified = lockModified || costModified || paramModified
	if lockModified {
		if err := saveLockfile(registry.LockPath, registry.LockData); err != nil {
			return nil, err
		}
	}

	subtypeByKey := map[string]map[string]any{}
	for _, row := range registry.SubtypeModels {
		key := stringField(row, "key")
		if key != "" {
			subtypeByKey[key] = row
		}
	}
	costByKey := map[string]map[string]any{}
	for _, row := range registry.CostModels {
		key := stringField(row, "key")
		if key != "" {
			costByKey[key] = row
		}
	}
	paramByKey := map[string]map[string]any{}
	for _, row := range registry.ParamModels {
		key := stringField(row, "key")
		if key != "" {
			paramByKey[key] = row
		}
	}

	datasets := make([]nativeCollectedDataset, 0)
	itempropsRows := make([]map[string]any, 0, len(registry.Properties))
	itempropdefRows := make([]map[string]any, 0, len(registry.Properties))
	propertyRowsSorted := slices.Clone(registry.Properties)
	slices.SortFunc(propertyRowsSorted, func(a, b map[string]any) int {
		return propertyIDs[stringField(a, "key")] - propertyIDs[stringField(b, "key")]
	})
	for _, property := range propertyRowsSorted {
		key := stringField(property, "key")
		rowID := propertyIDs[key]

		itempropsRow := map[string]any{
			"id":        rowID,
			"key":       "itemprops:" + strings.TrimPrefix(key, "itemprop:"),
			"StringRef": itempropsSetName(property),
			"Label":     itempropsSetLabel(property),
		}
		for _, column := range itempropsAvailabilityColumns {
			itempropsRow[column] = nullValue
		}
		availability, err := canonicalAvailability(property)
		if err != nil {
			return nil, fmt.Errorf("%s: %w", key, err)
		}
		for _, group := range availability {
			column := itempropsAvailabilityToColumn[group]
			itempropsRow[column] = "1"
		}
		itempropsRows = append(itempropsRows, itempropsRow)

		subtypeResRef := nullValue
		if subtypeKey, ok := refField(property["subtype"]); ok {
			subtype, ok := subtypeByKey[subtypeKey]
			if !ok {
				return nil, fmt.Errorf("%s: unknown subtype ref %s", key, subtypeKey)
			}
			subtypeResRef = stringField(subtype, "table_resref")
			if subtypeResRef == "" {
				return nil, fmt.Errorf("%s: subtype model %s is missing table_resref", key, subtypeKey)
			}
		}

		costValue := nullValue
		costRefID := nullValue
		if rawCost, ok := property["cost"].(map[string]any); ok {
			if value, ok := rawCost["value"]; ok {
				costValue = formatRegistryScalar(value)
			}
			costRefKey, ok := refField(rawCost)
			if ok {
				id, ok := costIDs[costRefKey]
				if !ok {
					return nil, fmt.Errorf("%s: unknown cost ref %s", key, costRefKey)
				}
				costRefID = strconv.Itoa(id)
			}
		}

		paramRefID := nullValue
		if paramKey, ok := refField(property["param"]); ok {
			id, ok := paramIDs[paramKey]
			if !ok {
				return nil, fmt.Errorf("%s: unknown param ref %s", key, paramKey)
			}
			paramRefID = strconv.Itoa(id)
		}

		itempropdefRows = append(itempropdefRows, map[string]any{
			"id":              rowID,
			"key":             "itempropdef:" + strings.TrimPrefix(key, "itemprop:"),
			"Name":            deepCopyValue(property["name"]),
			"Label":           itempropsDefLabel(property),
			"SubTypeResRef":   subtypeResRef,
			"Cost":            costValue,
			"CostTableResRef": costRefID,
			"Param1ResRef":    paramRefID,
			"GameStrRef":      deepCopyValue(property["property_text"]),
			"Description":     nullableRegistryField(property, "description"),
		})
	}

	costRows := make([]map[string]any, 0, len(registry.CostModels))
	costRowsSorted := slices.Clone(registry.CostModels)
	slices.SortFunc(costRowsSorted, func(a, b map[string]any) int {
		return costIDs[stringField(a, "key")] - costIDs[stringField(b, "key")]
	})
	for _, costModel := range costRowsSorted {
		key := stringField(costModel, "key")
		costRows = append(costRows, map[string]any{
			"id":         costIDs[key],
			"key":        key,
			"Name":       stringField(costModel, "index_name"),
			"Label":      stringField(costModel, "label"),
			"ClientLoad": formatRegistryScalar(costModel["client_load"]),
		})
	}

	paramRows := make([]map[string]any, 0, len(registry.ParamModels))
	paramRowsSorted := slices.Clone(registry.ParamModels)
	slices.SortFunc(paramRowsSorted, func(a, b map[string]any) int {
		return paramIDs[stringField(a, "key")] - paramIDs[stringField(b, "key")]
	})
	for _, paramModel := range paramRowsSorted {
		key := stringField(paramModel, "key")
		table, ok := paramModel["table"].(map[string]any)
		if !ok {
			return nil, fmt.Errorf("%s: param model missing table", key)
		}
		paramRows = append(paramRows, map[string]any{
			"id":          paramIDs[key],
			"key":         key,
			"Name":        nullableRegistryField(paramModel, "name"),
			"Lable":       stringField(paramModel, "label"),
			"TableResRef": stringField(table, "resref"),
		})
	}

	datasets = append(datasets,
		newGeneratedDataset("itemprops/registry/itemprops", "itemprops.2da", itempropsAvailabilityColumnsWithMeta(), itempropsRows),
		newGeneratedDataset("itemprops/registry/itempropdef", "itempropdef.2da", []string{"Name", "Label", "SubTypeResRef", "Cost", "CostTableResRef", "Param1ResRef", "GameStrRef", "Description"}, itempropdefRows),
		newGeneratedDataset("itemprops/registry/costtable_index", "iprp_costtable.2da", []string{"Name", "Label", "ClientLoad"}, costRows),
		newGeneratedDataset("itemprops/registry/paramtable_index", "iprp_paramtable.2da", []string{"Name", "Lable", "TableResRef"}, paramRows),
	)

	auxDatasets, err := collectItempropsAuxiliaryDatasets(registry.CostModels, "cost_models", "itemprops/registry/cost_models")
	if err != nil {
		return nil, err
	}
	datasets = append(datasets, auxDatasets...)
	auxDatasets, err = collectItempropsAuxiliaryDatasets(registry.ParamModels, "param_models", "itemprops/registry/param_models")
	if err != nil {
		return nil, err
	}
	datasets = append(datasets, auxDatasets...)
	auxDatasets, err = collectItempropsAuxiliaryDatasets(registry.SubtypeModels, "subtype_models", "itemprops/registry/subtype_models")
	if err != nil {
		return nil, err
	}
	datasets = append(datasets, auxDatasets...)
	return datasets, nil
}

func newGeneratedDataset(name, outputName string, columns []string, rows []map[string]any) nativeCollectedDataset {
	return nativeCollectedDataset{
		Dataset: nativeDataset{
			Kind:       nativeDatasetPlain,
			Name:       name,
			OutputName: outputName,
			Spec:       specForDataset(name),
		},
		Columns:  columns,
		Rows:     rows,
		LockData: map[string]int{},
	}
}

func itempropsAvailabilityColumnsWithMeta() []string {
	columns := append([]string{}, itempropsAvailabilityColumns...)
	columns = append(columns, "StringRef", "Label")
	return columns
}

func collectItempropsAuxiliaryDatasets(models []map[string]any, keyPrefix, namePrefix string) ([]nativeCollectedDataset, error) {
	out := make([]nativeCollectedDataset, 0)
	for _, model := range models {
		key := stringField(model, "key")
		table, ok := model["table"].(map[string]any)
		if !ok {
			continue
		}
		if !strings.EqualFold(stringField(table, "ownership"), "owned") {
			continue
		}
		outputName := stringField(table, "output")
		if outputName == "" {
			outputName = stringField(table, "resref") + ".2da"
		}
		rawColumns, ok := table["columns"].([]any)
		if !ok {
			return nil, fmt.Errorf("%s: owned table is missing columns", key)
		}
		columns := make([]string, 0, len(rawColumns))
		for _, rawColumn := range rawColumns {
			column, ok := rawColumn.(string)
			if !ok {
				return nil, fmt.Errorf("%s: owned table columns must be strings", key)
			}
			columns = append(columns, column)
		}
		rawRows, ok := table["rows"].([]any)
		if !ok {
			return nil, fmt.Errorf("%s: owned table rows must be an array", key)
		}
		rows := make([]map[string]any, 0, len(rawRows))
		for index, rawRow := range rawRows {
			row, ok := rawRow.(map[string]any)
			if !ok {
				return nil, fmt.Errorf("%s: owned table row %d must be an object", key, index)
			}
			canonical, err := canonicalizeBaseRow(nativeDataset{Name: keyPrefix + "/" + strings.TrimPrefix(key, keyPrefix+":"), Spec: specForDataset("itemprops")}, columns, row, index)
			if err != nil {
				return nil, err
			}
			rows = append(rows, canonical)
		}
		out = append(out, newGeneratedDataset(namePrefix+"/"+strings.TrimPrefix(key, keyPrefix+":"), outputName, columns, rows))
	}
	return out, nil
}

func assignRegistryIDs(rows []map[string]any, lockData map[string]int, requiredPrefix string) (map[string]int, bool, error) {
	ids := map[string]int{}
	used := map[int]struct{}{}
	for key, id := range lockData {
		if strings.HasPrefix(key, requiredPrefix) {
			used[id] = struct{}{}
		}
	}
	modified := false
	for _, row := range rows {
		key := stringField(row, "key")
		if key == "" {
			return nil, false, fmt.Errorf("registry row is missing key")
		}
		if !strings.HasPrefix(key, requiredPrefix) {
			return nil, false, fmt.Errorf("registry key %q must use prefix %q", key, requiredPrefix)
		}
		if id, ok := lockData[key]; ok {
			ids[key] = id
			used[id] = struct{}{}
			continue
		}
	}
	nextID := nextAvailableID(used)
	for _, row := range rows {
		key := stringField(row, "key")
		if _, ok := ids[key]; ok {
			continue
		}
		lockData[key] = nextID
		ids[key] = nextID
		used[nextID] = struct{}{}
		nextID = nextAvailableID(used)
		modified = true
	}
	return ids, modified, nil
}

func canonicalAvailability(row map[string]any) ([]string, error) {
	rawAvailability, ok := row["availability"]
	if !ok {
		return nil, nil
	}
	rawList, ok := rawAvailability.([]any)
	if !ok {
		return nil, fmt.Errorf("availability must be an array")
	}
	out := make([]string, 0, len(rawList))
	seen := map[string]struct{}{}
	for _, raw := range rawList {
		text, ok := raw.(string)
		if !ok {
			return nil, fmt.Errorf("availability values must be strings")
		}
		normalized := normalizeAvailabilityName(text)
		if _, ok := itempropsAvailabilityToColumn[normalized]; !ok {
			return nil, fmt.Errorf("unknown availability group %q", text)
		}
		if _, ok := seen[normalized]; ok {
			continue
		}
		seen[normalized] = struct{}{}
		out = append(out, normalized)
	}
	slices.Sort(out)
	return out, nil
}

func normalizeAvailabilityName(name string) string {
	normalized := strings.TrimSpace(strings.ToLower(name))
	normalized = strings.ReplaceAll(normalized, " ", "_")
	normalized = strings.ReplaceAll(normalized, "-", "_")
	if alias, ok := itempropsAvailabilityAliases[normalized]; ok {
		return alias
	}
	return normalized
}

func refField(value any) (string, bool) {
	if value == nil {
		return "", false
	}
	refMap, ok := value.(map[string]any)
	if !ok {
		return "", false
	}
	rawRef, ok := refMap["ref"]
	if !ok {
		return "", false
	}
	text, ok := rawRef.(string)
	return text, ok && strings.TrimSpace(text) != ""
}

func stringField(obj map[string]any, field string) string {
	raw, ok := obj[field]
	if !ok || raw == nil {
		return ""
	}
	switch typed := raw.(type) {
	case string:
		return typed
	case float64:
		if typed == float64(int64(typed)) {
			return strconv.FormatInt(int64(typed), 10)
		}
		return strconv.FormatFloat(typed, 'f', -1, 64)
	default:
		return ""
	}
}

func nullableRegistryField(obj map[string]any, field string) any {
	value, ok := obj[field]
	if !ok || value == nil {
		return nullValue
	}
	if text, ok := value.(string); ok && strings.TrimSpace(text) == "" {
		return nullValue
	}
	return deepCopyValue(value)
}

func itempropsDefLabel(obj map[string]any) string {
	return stringField(obj, "label")
}

func itempropsSetName(obj map[string]any) any {
	if value, ok := obj["set_name"]; ok {
		return deepCopyValue(value)
	}
	return deepCopyValue(obj["name"])
}

func itempropsSetLabel(obj map[string]any) string {
	if label := stringField(obj, "set_label"); label != "" {
		return label
	}
	return itempropsDefLabel(obj)
}

func formatRegistryScalar(value any) string {
	if value == nil {
		return nullValue
	}
	switch typed := value.(type) {
	case string:
		if strings.TrimSpace(typed) == "" {
			return nullValue
		}
		return typed
	case float64:
		if typed == float64(int64(typed)) {
			return strconv.FormatInt(int64(typed), 10)
		}
		return strconv.FormatFloat(typed, 'f', -1, 64)
	case int:
		return strconv.Itoa(typed)
	default:
		return fmt.Sprint(typed)
	}
}

func deepCopyValue(value any) any {
	raw, err := json.Marshal(value)
	if err != nil {
		return value
	}
	var out any
	if err := json.Unmarshal(raw, &out); err != nil {
		return value
	}
	return out
}

func importLegacyItempropsRegistry(referenceBuilderDir, dataDir string, legacyTLK *legacyTLKData) (int, error) {
	legacyDataDir := filepath.Join(referenceBuilderDir, "data")
	registryDir := filepath.Join(dataDir, filepath.FromSlash(itempropsRegistryDirName))
	if _, err := os.Stat(filepath.Join(registryDir, "properties.json")); err == nil {
		if refs, err := countLegacyTLKRefsInRegistry(registryDir); err == nil && refs == 0 {
			return 0, nil
		}
	}
	if _, err := os.Stat(filepath.Join(legacyDataDir, "itemprops")); err != nil {
		if os.IsNotExist(err) {
			return 0, nil
		}
		return 0, err
	}

	defs, err := collectBaseDataset(nativeDataset{
		Name:       "itemprops/defs",
		BasePath:   filepath.Join(legacyDataDir, "itemprops", "defs", "base.json"),
		LockPath:   filepath.Join(legacyDataDir, "itemprops", "defs", "lock.json"),
		ModulesDir: filepath.Join(legacyDataDir, "itemprops", "defs", "modules"),
		Spec:       specForDataset("itemprops"),
	})
	if err != nil {
		return 0, err
	}
	sets, err := collectBaseDataset(nativeDataset{
		Name:       "itemprops/sets",
		BasePath:   filepath.Join(legacyDataDir, "itemprops", "sets", "base.json"),
		LockPath:   filepath.Join(legacyDataDir, "itemprops", "sets", "lock.json"),
		ModulesDir: filepath.Join(legacyDataDir, "itemprops", "sets", "modules"),
		Spec:       specForDataset("itemprops"),
	})
	if err != nil {
		return 0, err
	}
	costIndex, err := collectBaseDataset(nativeDataset{
		Name:       "itemprops/costtables/index",
		BasePath:   filepath.Join(legacyDataDir, "itemprops", "costtables", "index", "base.json"),
		LockPath:   filepath.Join(legacyDataDir, "itemprops", "costtables", "index", "lock.json"),
		ModulesDir: filepath.Join(legacyDataDir, "itemprops", "costtables", "index", "modules"),
		Spec:       specForDataset("itemprops"),
	})
	if err != nil {
		return 0, err
	}
	paramIndex, err := collectBaseDataset(nativeDataset{
		Name:       "itemprops/paramtables/index",
		BasePath:   filepath.Join(legacyDataDir, "itemprops", "paramtables", "index", "base.json"),
		LockPath:   filepath.Join(legacyDataDir, "itemprops", "paramtables", "index", "lock.json"),
		ModulesDir: filepath.Join(legacyDataDir, "itemprops", "paramtables", "index", "modules"),
		Spec:       specForDataset("itemprops"),
	})
	if err != nil {
		return 0, err
	}

	ownedCostTables, err := collectLegacyOwnedItempropTables(filepath.Join(legacyDataDir, "itemprops", "costtables"), "index")
	if err != nil {
		return 0, err
	}
	ownedParamTables, err := collectLegacyOwnedItempropTables(filepath.Join(legacyDataDir, "itemprops", "paramtables"), "index")
	if err != nil {
		return 0, err
	}
	ownedSubtypeTables, err := collectLegacyOwnedItempropTables(filepath.Join(legacyDataDir, "itemprops", "subtypes"), "")
	if err != nil && !os.IsNotExist(err) {
		return 0, err
	}

	setsByID := map[int]map[string]any{}
	for _, row := range sets.Rows {
		setsByID[row["id"].(int)] = row
	}
	costByID := map[int]map[string]any{}
	for _, row := range costIndex.Rows {
		costByID[row["id"].(int)] = row
	}
	paramByID := map[int]map[string]any{}
	for _, row := range paramIndex.Rows {
		paramByID[row["id"].(int)] = row
	}

	registryLock := map[string]int{}
	legacyKeyByID := map[string]string{}
	if legacyTLK != nil {
		for key, id := range legacyTLK.Lock {
			legacyKeyByID[strconv.Itoa(id)] = key
		}
	}
	properties := make([]map[string]any, 0, len(defs.Rows))
	subtypeRegistryByKey := map[string]map[string]any{}
	for _, row := range defs.Rows {
		rowID := row["id"].(int)
		key := canonicalItempropPropertyKey(row)
		registryLock[key] = rowID

		property := map[string]any{
			"key":           key,
			"label":         row["Label"],
			"name":          deepCopyValue(row["Name"]),
			"property_text": deepCopyValue(row["GameStrRef"]),
		}
		if description := nullableRegistryField(row, "Description"); description != nullValue {
			property["description"] = description
		}
		if setRow, ok := setsByID[rowID]; ok {
			property["availability"] = importLegacyAvailability(setRow)
			if setName := nullableRegistryField(setRow, "StringRef"); setName != nullValue && setName != stringField(row, "Name") {
				property["set_name"] = setName
			}
			if setLabel := stringField(setRow, "Label"); setLabel != "" && setLabel != stringField(row, "Label") {
				property["set_label"] = setLabel
			}
		} else {
			property["availability"] = []any{}
		}

		if subtypeResRef := stringField(row, "SubTypeResRef"); subtypeResRef != "" && subtypeResRef != nullValue {
			subtypeKey := canonicalModelKey("subtype_models", subtypeResRef)
			property["subtype"] = map[string]any{"ref": subtypeKey}
			if _, ok := subtypeRegistryByKey[subtypeKey]; !ok {
				subtypeRegistryByKey[subtypeKey] = buildLegacySubtypeModel(subtypeKey, subtypeResRef, ownedSubtypeTables)
			}
		}
		costValue := stringField(row, "Cost")
		costID := stringField(row, "CostTableResRef")
		if costValue != "" && costValue != nullValue || costID != "" && costID != nullValue {
			cost := map[string]any{}
			if costValue != "" && costValue != nullValue {
				cost["value"] = costValue
			}
			if costID != "" && costID != nullValue {
				id, err := strconv.Atoi(costID)
				if err != nil {
					return 0, err
				}
				costRow, ok := costByID[id]
				if !ok {
					return 0, fmt.Errorf("%s references missing cost table index %s", key, costID)
				}
				cost["ref"] = canonicalModelKey("cost_models", stringField(costRow, "Name"))
			}
			property["cost"] = cost
		}
		if paramID := stringField(row, "Param1ResRef"); paramID != "" && paramID != nullValue {
			id, err := strconv.Atoi(paramID)
			if err != nil {
				return 0, err
			}
			paramRow, ok := paramByID[id]
			if !ok {
				return 0, fmt.Errorf("%s references missing param table index %s", key, paramID)
			}
			property["param"] = map[string]any{"ref": canonicalModelKey("param_models", stringField(paramRow, "TableResRef"))}
		}
		if legacyTLK != nil {
			property["name"] = importLegacyStrrefValue(property["name"], key+".name", legacyTLK, legacyKeyByID)
			property["property_text"] = importLegacyStrrefValue(property["property_text"], key+".property_text", legacyTLK, legacyKeyByID)
			if description, ok := property["description"]; ok {
				property["description"] = importLegacyStrrefValue(description, key+".description", legacyTLK, legacyKeyByID)
			}
			if updated, _, err := inlineLegacyTLKValue(property, legacyTLK); err != nil {
				return 0, err
			} else if updated {
				// property updated in place
			}
		}
		properties = append(properties, property)
	}

	costModels := make([]map[string]any, 0, len(costIndex.Rows))
	for _, row := range costIndex.Rows {
		key := canonicalModelKey("cost_models", stringField(row, "Name"))
		registryLock[key] = row["id"].(int)
		model := map[string]any{
			"key":        key,
			"index_name": row["Name"],
			"label":      row["Label"],
			"client_load": row["ClientLoad"],
			"table":      buildLegacyOwnedModelTable(stringField(row, "Name"), ownedCostTables),
		}
		costModels = append(costModels, model)
	}

	paramModels := make([]map[string]any, 0, len(paramIndex.Rows))
	for _, row := range paramIndex.Rows {
		key := canonicalModelKey("param_models", stringField(row, "TableResRef"))
		registryLock[key] = row["id"].(int)
		model := map[string]any{
			"key":   key,
			"name":  deepCopyValue(row["Name"]),
			"label": row["Lable"],
			"table": buildLegacyOwnedModelTable(stringField(row, "TableResRef"), ownedParamTables),
		}
		paramModels = append(paramModels, model)
	}

	subtypeModels := make([]map[string]any, 0, len(subtypeRegistryByKey))
	for _, key := range sortedKeysAny(subtypeRegistryByKey) {
		subtypeModels = append(subtypeModels, subtypeRegistryByKey[key])
	}

	writes := []struct {
		path string
		obj  map[string]any
	}{
		{filepath.Join(registryDir, "properties.json"), map[string]any{"rows": properties}},
		{filepath.Join(registryDir, "cost_models.json"), map[string]any{"rows": costModels}},
		{filepath.Join(registryDir, "param_models.json"), map[string]any{"rows": paramModels}},
		{filepath.Join(registryDir, "subtype_models.json"), map[string]any{"rows": subtypeModels}},
		{filepath.Join(registryDir, itempropsRegistryLock), anyMapInt(registryLock)},
	}
	if err := os.MkdirAll(registryDir, 0o755); err != nil {
		return 0, err
	}
	for _, write := range writes {
		raw, err := json.MarshalIndent(write.obj, "", "  ")
		if err != nil {
			return 0, err
		}
		raw = append(raw, '\n')
		if err := os.WriteFile(write.path, raw, 0o644); err != nil {
			return 0, err
		}
	}
	return len(writes), nil
}

func countLegacyTLKRefsInRegistry(registryDir string) (int, error) {
	paths, err := collectDataJSONPaths(registryDir)
	if err != nil {
		return 0, err
	}
	refs := 0
	for _, path := range paths {
		obj, err := loadJSONObject(path)
		if err != nil {
			return 0, err
		}
		refs += countLegacyTLKRefsInValue(obj)
	}
	return refs, nil
}

func collectLegacyOwnedItempropTables(root, skipDir string) (map[string]map[string]any, error) {
	result := map[string]map[string]any{}
	entries, err := os.ReadDir(root)
	if err != nil {
		return nil, err
	}
	for _, entry := range entries {
		if !entry.IsDir() {
			continue
		}
		if skipDir != "" && entry.Name() == skipDir {
			continue
		}
		dir := filepath.Join(root, entry.Name())
		basePath := filepath.Join(dir, "base.json")
		if _, err := os.Stat(basePath); err != nil {
			continue
		}
		collected, err := collectBaseDataset(nativeDataset{
			Name:       filepath.ToSlash(filepath.Join("itemprops", filepath.Base(root), entry.Name())),
			BasePath:   basePath,
			LockPath:   filepath.Join(dir, "lock.json"),
			ModulesDir: filepath.Join(dir, "modules"),
			Spec:       specForDataset("itemprops"),
		})
		if err != nil {
			return nil, err
		}
		result[strings.ToLower(outputStem(collected.Dataset.OutputName))] = map[string]any{
			"ownership": "owned",
			"resref":    outputStem(collected.Dataset.OutputName),
			"output":    collected.Dataset.OutputName,
			"columns":   stringSliceToAny(collected.Columns),
			"rows":      rowsToAny(collected.Rows),
		}
	}
	return result, nil
}

func buildLegacySubtypeModel(key, resref string, owned map[string]map[string]any) map[string]any {
	return map[string]any{
		"key":          key,
		"table_resref": resref,
		"table":        buildLegacyOwnedModelTable(resref, owned),
	}
}

func buildLegacyOwnedModelTable(resref string, owned map[string]map[string]any) map[string]any {
	if table, ok := owned[strings.ToLower(resref)]; ok {
		return table
	}
	return map[string]any{
		"ownership": "external",
		"resref":    resref,
	}
}

func canonicalItempropPropertyKey(row map[string]any) string {
	if key := stringField(row, "key"); key != "" {
		return "itemprop:" + strings.TrimPrefix(key, "itempropdef:")
	}
	return canonicalModelKey("itemprop", stringField(row, "Label"))
}

func canonicalModelKey(prefix, source string) string {
	normalized := strings.ToLower(strings.TrimSpace(source))
	normalized = strings.ReplaceAll(normalized, " ", "")
	normalized = strings.ReplaceAll(normalized, "-", "")
	normalized = strings.ReplaceAll(normalized, "_", "")
	return prefix + ":" + normalized
}

func importLegacyStrrefValue(value any, fallbackKey string, legacy *legacyTLKData, keyByID map[string]string) any {
	if legacy == nil {
		return value
	}
	if _, ok, _ := parseTLKPayload(value, true); ok {
		return value
	}
	_ = keyByID
	key := fallbackKey
	entry, ok := legacy.Entries[key]
	if !ok {
		return value
	}
	payload := map[string]any{
		"key":  key,
		"text": entry.Text,
	}
	if entry.SoundResRef != "" {
		payload["sound_resref"] = entry.SoundResRef
	}
	if entry.SoundLength != 0 {
		payload["sound_length"] = entry.SoundLength
	}
	return map[string]any{"tlk": payload}
}

func importLegacyAvailability(row map[string]any) []any {
	out := make([]any, 0)
	for _, column := range itempropsAvailabilityColumns {
		value, ok := row[column]
		if !ok {
			continue
		}
		text := formatRegistryScalar(value)
		if text != "1" {
			continue
		}
		out = append(out, normalizeAvailabilityName(column))
	}
	return out
}

func anyMapInt(values map[string]int) map[string]any {
	out := make(map[string]any, len(values))
	for key, value := range values {
		out[key] = value
	}
	return out
}

func stringSliceToAny(values []string) []any {
	out := make([]any, 0, len(values))
	for _, value := range values {
		out = append(out, value)
	}
	return out
}

func rowsToAny(rows []map[string]any) []any {
	out := make([]any, 0, len(rows))
	for _, row := range rows {
		out = append(out, deepCopyValue(row))
	}
	return out
}

func sortedKeysAny(values map[string]map[string]any) []string {
	out := make([]string, 0, len(values))
	for key := range values {
		out = append(out, key)
	}
	slices.Sort(out)
	return out
}

func validateItempropsRegistryGraph(dataDir string, report *ValidationReport) {
	registry, err := loadItempropsRegistry(dataDir)
	if err != nil {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     filepath.Join(dataDir, filepath.FromSlash(itempropsRegistryDirName)),
			Message:  err.Error(),
		})
		return
	}
	if registry == nil {
		return
	}

	seenPropertyKeys := map[string]struct{}{}
	seenDefLabels := map[string]string{}
	seenSetLabels := map[string]string{}
	seenCostKeys := map[string]struct{}{}
	seenParamKeys := map[string]struct{}{}
	seenSubtypeKeys := map[string]string{}
	ownedOutputs := map[string]string{}

	for _, row := range registry.CostModels {
		key := stringField(row, "key")
		if key == "" {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     filepath.Join(registry.RootPath, "cost_models.json"),
				Message:  "cost model row is missing key",
			})
			continue
		}
		if _, ok := seenCostKeys[key]; ok {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     filepath.Join(registry.RootPath, "cost_models.json"),
				Message:  fmt.Sprintf("duplicate cost model key %q", key),
			})
		}
		seenCostKeys[key] = struct{}{}
		validateOwnedRegistryTable(filepath.Join(registry.RootPath, "cost_models.json"), key, row, ownedOutputs, report)
	}
	for _, row := range registry.ParamModels {
		key := stringField(row, "key")
		if key == "" {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     filepath.Join(registry.RootPath, "param_models.json"),
				Message:  "param model row is missing key",
			})
			continue
		}
		if _, ok := seenParamKeys[key]; ok {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     filepath.Join(registry.RootPath, "param_models.json"),
				Message:  fmt.Sprintf("duplicate param model key %q", key),
			})
		}
		seenParamKeys[key] = struct{}{}
		validateOwnedRegistryTable(filepath.Join(registry.RootPath, "param_models.json"), key, row, ownedOutputs, report)
	}
	for _, row := range registry.SubtypeModels {
		key := stringField(row, "key")
		if key == "" {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     filepath.Join(registry.RootPath, "subtype_models.json"),
				Message:  "subtype model row is missing key",
			})
			continue
		}
		resref := stringField(row, "table_resref")
		if existing, ok := seenSubtypeKeys[key]; ok {
			if !strings.EqualFold(existing, resref) {
				report.Diagnostics = append(report.Diagnostics, Diagnostic{
					Severity: SeverityError,
					Path:     filepath.Join(registry.RootPath, "subtype_models.json"),
					Message:  fmt.Sprintf("duplicate subtype model key %q", key),
				})
			}
			continue
		}
		seenSubtypeKeys[key] = resref
		validateOwnedRegistryTable(filepath.Join(registry.RootPath, "subtype_models.json"), key, row, ownedOutputs, report)
	}
	for _, row := range registry.Properties {
		key := stringField(row, "key")
		if key == "" {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     filepath.Join(registry.RootPath, "properties.json"),
				Message:  "property row is missing key",
			})
			continue
		}
		if _, ok := seenPropertyKeys[key]; ok {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     filepath.Join(registry.RootPath, "properties.json"),
				Message:  fmt.Sprintf("duplicate property key %q", key),
			})
		}
		seenPropertyKeys[key] = struct{}{}
		defLabel := itempropsDefLabel(row)
		if defLabel == "" {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     filepath.Join(registry.RootPath, "properties.json"),
				Message:  fmt.Sprintf("%s is missing label", key),
			})
		} else if owner, ok := seenDefLabels[strings.ToLower(defLabel)]; ok && owner != key {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     filepath.Join(registry.RootPath, "properties.json"),
				Message:  fmt.Sprintf("generated itempropdef label collision between %q and %q", owner, key),
			})
		} else {
			seenDefLabels[strings.ToLower(defLabel)] = key
		}
		setLabel := itempropsSetLabel(row)
		if setLabel == "" {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     filepath.Join(registry.RootPath, "properties.json"),
				Message:  fmt.Sprintf("%s is missing itemprops set label", key),
			})
		} else if owner, ok := seenSetLabels[strings.ToLower(setLabel)]; ok && owner != key {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     filepath.Join(registry.RootPath, "properties.json"),
				Message:  fmt.Sprintf("generated itemprops set label collision between %q and %q", owner, key),
			})
		} else {
			seenSetLabels[strings.ToLower(setLabel)] = key
		}
		if _, err := canonicalAvailability(row); err != nil {
			report.Diagnostics = append(report.Diagnostics, Diagnostic{
				Severity: SeverityError,
				Path:     filepath.Join(registry.RootPath, "properties.json"),
				Message:  fmt.Sprintf("%s: %v", key, err),
			})
		}
		if subtypeKey, ok := refField(row["subtype"]); ok {
			if _, exists := seenSubtypeKeys[subtypeKey]; !exists {
				report.Diagnostics = append(report.Diagnostics, Diagnostic{
					Severity: SeverityError,
					Path:     filepath.Join(registry.RootPath, "properties.json"),
					Message:  fmt.Sprintf("%s: unknown subtype ref %s", key, subtypeKey),
				})
			}
		}
		if rawCost, ok := row["cost"].(map[string]any); ok {
			if costKey, ok := refField(rawCost); ok {
				if _, exists := seenCostKeys[costKey]; !exists {
					report.Diagnostics = append(report.Diagnostics, Diagnostic{
						Severity: SeverityError,
						Path:     filepath.Join(registry.RootPath, "properties.json"),
						Message:  fmt.Sprintf("%s: unknown cost ref %s", key, costKey),
					})
				}
			}
		}
		if paramKey, ok := refField(row["param"]); ok {
			if _, exists := seenParamKeys[paramKey]; !exists {
				report.Diagnostics = append(report.Diagnostics, Diagnostic{
					Severity: SeverityError,
					Path:     filepath.Join(registry.RootPath, "properties.json"),
					Message:  fmt.Sprintf("%s: unknown param ref %s", key, paramKey),
				})
			}
		}
	}
}

func validateOwnedRegistryTable(path, key string, row map[string]any, ownedOutputs map[string]string, report *ValidationReport) {
	table, ok := row["table"].(map[string]any)
	if !ok {
		return
	}
	if !strings.EqualFold(stringField(table, "ownership"), "owned") {
		return
	}
	resref := stringField(table, "resref")
	if resref == "" {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  fmt.Sprintf("%s: owned table is missing resref", key),
		})
		return
	}
	outputName := stringField(table, "output")
	if outputName == "" {
		outputName = resref + ".2da"
	}
	if owner, ok := ownedOutputs[strings.ToLower(outputName)]; ok && owner != key {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  fmt.Sprintf("owned table output collision between %q and %q", owner, key),
		})
	} else {
		ownedOutputs[strings.ToLower(outputName)] = key
	}
	if _, ok := table["columns"].([]any); !ok {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  fmt.Sprintf("%s: owned table columns must be an array", key),
		})
	}
	if _, ok := table["rows"].([]any); !ok {
		report.Diagnostics = append(report.Diagnostics, Diagnostic{
			Severity: SeverityError,
			Path:     path,
			Message:  fmt.Sprintf("%s: owned table rows must be an array", key),
		})
	}
}
