package topdata

import (
	"crypto/sha256"
	"embed"
	"encoding/json"
	"fmt"
	"html"
	"io/fs"
	"os"
	"path/filepath"
	"slices"
	"strconv"
	"strings"
	"time"

	"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
	"gopkg.in/yaml.v3"
)

const (
	wikiStatusNew         = "new"
	wikiStatusModified    = "modified"
	wikiStatusVanilla     = "vanilla"
	wikiRootDirName       = "wiki"
	legacyWikiRootDirName = ".wiki"
	wikiPagesDirName      = "pages"
	wikiStateFileName     = "state.json"
	wikiGeneratorVersion  = "nodebb-tiptap-html-v1"
	wikiGeneratedStatus   = "generated"
	wikiSkippedStatus     = "skipped"
)

var (
	wikiStatusPriority = map[string]int{wikiStatusVanilla: 0, wikiStatusModified: 1, wikiStatusNew: 2}
	wikiStatusLabels   = map[string]string{wikiStatusNew: "New", wikiStatusModified: "Modified", wikiStatusVanilla: "Vanilla"}
)

//go:embed wiki_templates/*.txt
var wikiTemplateFS embed.FS

type wikiStateDocument struct {
	Version string `json:"version"`
	Digest  string `json:"digest"`
	Pages   int    `json:"pages"`
}

type wikiPageIndex struct {
	Version string               `json:"version"`
	Pages   []wikiPageIndexEntry `json:"pages"`
}

type wikiPageIndexEntry struct {
	PageID        string `json:"page_id"`
	Namespace     string `json:"namespace"`
	SourceDataset string `json:"source_dataset"`
	SourceKey     string `json:"source_key"`
	Title         string `json:"title"`
	Hash          string `json:"hash"`
	OutputPath    string `json:"output_path"`
	EditPolicy    string `json:"edit_policy"`
	StalePolicy   string `json:"stale_policy"`
}

type wikiManualSectionsDocument struct {
	ManualSections []wikiManualSection `json:"manual_sections" yaml:"manual_sections"`
}

type wikiManualSection struct {
	ID          string `json:"id" yaml:"id"`
	Heading     string `json:"heading" yaml:"heading"`
	Placement   string `json:"placement" yaml:"placement"`
	InitialHTML string `json:"initial_html" yaml:"initial_html"`
}

type wikiResult struct {
	OutputDir string
	PageCount int
	Status    string
	Digest    string
}

type wikiTable struct {
	Key        string
	Output     string
	OutputStem string
	Rows       []map[string]any
}

type wikiContext struct {
	dialog           map[string]string
	rowsByKey        map[string]map[string]any
	featRows         map[string]map[string]any
	featIDToKey      map[int]string
	skillRows        map[string]map[string]any
	skillIDToKey     map[int]string
	spellRows        map[string]map[string]any
	baseitemRows     map[string]map[string]any
	classRows        map[string]map[string]any
	classIDToKey     map[int]string
	raceRows         map[string]map[string]any
	raceIDToKey      map[int]string
	masterfeatRows   map[string]map[string]any
	classFeatTables  map[string]wikiTable
	classSkillTables map[string]wikiTable
	classBonusTables map[string]wikiTable
	raceFeatTables   map[string]wikiTable
	statuses         map[string]map[string]string
	implementedFeats map[string]struct{}
}

func buildWiki(p *project.Project, nativeResult BuildResult, force bool, progress func(string)) (wikiResult, error) {
	if progress == nil {
		progress = func(string) {}
	}
	rootDir := wikiOutputRootDir(p)
	outputDir := wikiOutputPagesDir(p)
	statePath := wikiOutputStatePath(p)

	digest, err := computeWikiSourceDigest(p.TopDataSourceDir())
	if err != nil {
		return wikiResult{}, err
	}

	state, _ := loadWikiState(statePath)
	if !force && digest != "" && state.Digest == digest {
		return wikiResult{
			OutputDir: outputDir,
			PageCount: state.Pages,
			Status:    wikiSkippedStatus,
			Digest:    digest,
		}, nil
	}

	progress("Building native wiki pages...")
	ctx, err := loadWikiContext(filepath.Join(p.TopDataSourceDir(), "data"), p.TopDataSourceDir())
	if err != nil {
		return wikiResult{}, err
	}

	if err := os.RemoveAll(outputDir); err != nil {
		return wikiResult{}, fmt.Errorf("clean wiki output: %w", err)
	}
	if err := os.MkdirAll(outputDir, 0o755); err != nil {
		return wikiResult{}, fmt.Errorf("create wiki output dir: %w", err)
	}

	pageTitles := map[string]string{}
	pageStatuses := map[string]string{}
	pageIndex := wikiPageIndex{Version: wikiGeneratorVersion}

	writePage := func(pageID, content, title, status string) error {
		path := filepath.Join(outputDir, wikiPageIDToRelPath(pageID))
		if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
			return err
		}
		content = renderNodeBBManagedHTML(pageID, content, loadWikiManualSections(p))
		if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
			return err
		}
		pageTitles[pageID] = title
		pageStatuses[pageID] = status
		namespace := strings.SplitN(pageID, ":", 2)[0]
		rel, err := filepath.Rel(rootDir, path)
		if err != nil {
			rel = filepath.Base(path)
		}
		pageIndex.Pages = append(pageIndex.Pages, wikiPageIndexEntry{
			PageID:        pageID,
			Namespace:     namespace,
			SourceDataset: categoryFromWikiNamespace(namespace),
			SourceKey:     pageID,
			Title:         title,
			Hash:          computeManagedHash(content),
			OutputPath:    filepath.ToSlash(rel),
			EditPolicy:    wikiEditPolicy(namespace),
			StalePolicy:   p.EffectiveConfig().TopData.Wiki.StalePages.Default,
		})
		return nil
	}

	if err := generateEntityPages(outputDir, ctx, writePage); err != nil {
		return wikiResult{}, err
	}
	if err := generateStatusPages(outputDir, pageStatuses, pageTitles, p.EffectiveConfig().TopData.Wiki.ManagedNamespaces, loadWikiManualSections(p)); err != nil {
		return wikiResult{}, err
	}
	if err := indexUnregisteredWikiPages(rootDir, outputDir, p.EffectiveConfig().TopData.Wiki.StalePages.Default, &pageIndex); err != nil {
		return wikiResult{}, err
	}
	if err := saveWikiPageIndex(filepath.Join(rootDir, "page-index.json"), pageIndex); err != nil {
		return wikiResult{}, err
	}

	if err := os.MkdirAll(rootDir, 0o755); err != nil {
		return wikiResult{}, fmt.Errorf("create wiki root dir: %w", err)
	}
	pageCount := len(pageIndex.Pages)
	state = wikiStateDocument{Version: wikiGeneratorVersion, Digest: digest, Pages: pageCount}
	if err := saveWikiState(statePath, state); err != nil {
		return wikiResult{}, err
	}
	return wikiResult{
		OutputDir: outputDir,
		PageCount: pageCount,
		Status:    wikiGeneratedStatus,
		Digest:    digest,
	}, nil
}

func loadWikiManualSections(p *project.Project) []wikiManualSection {
	defaults := []wikiManualSection{
		{ID: "notes", Heading: "Notes", InitialHTML: "<h2>Notes</h2><p></p>"},
		{ID: "see_also", Heading: "See Also", InitialHTML: "<h2>See Also</h2><p></p>"},
	}
	cfg := p.EffectiveConfig().TopData.Wiki
	path := filepath.Join(p.TopDataWikiSourceDir(), filepath.FromSlash(cfg.ManualSectionsDir), "default.yaml")
	raw, err := os.ReadFile(path)
	if err != nil {
		return defaults
	}
	var doc wikiManualSectionsDocument
	if err := yaml.Unmarshal(raw, &doc); err != nil || len(doc.ManualSections) == 0 {
		return defaults
	}
	out := []wikiManualSection{}
	for _, section := range doc.ManualSections {
		section.ID = strings.TrimSpace(section.ID)
		if section.ID == "" {
			continue
		}
		if strings.TrimSpace(section.InitialHTML) == "" {
			heading := strings.TrimSpace(section.Heading)
			if heading == "" {
				heading = section.ID
			}
			section.InitialHTML = "<h2>" + html.EscapeString(heading) + "</h2><p></p>"
		}
		out = append(out, section)
	}
	if len(out) == 0 {
		return defaults
	}
	return out
}

func saveWikiPageIndex(path string, index wikiPageIndex) error {
	if err := validateWikiPageIndex(index); err != nil {
		return err
	}
	slices.SortFunc(index.Pages, func(a, b wikiPageIndexEntry) int {
		return strings.Compare(a.PageID, b.PageID)
	})
	raw, err := json.MarshalIndent(index, "", "  ")
	if err != nil {
		return fmt.Errorf("marshal wiki page index: %w", err)
	}
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		return err
	}
	return os.WriteFile(path, append(raw, '\n'), 0o644)
}

func validateWikiPageIndex(index wikiPageIndex) error {
	pageIDs := map[string]string{}
	outputPaths := map[string]string{}
	for _, entry := range index.Pages {
		if previous, ok := pageIDs[entry.PageID]; ok {
			return fmt.Errorf("duplicate wiki page-index page_id %q for %s and %s", entry.PageID, previous, entry.OutputPath)
		}
		if previous, ok := outputPaths[entry.OutputPath]; ok {
			return fmt.Errorf("duplicate wiki page-index output_path %q for %s and %s", entry.OutputPath, previous, entry.PageID)
		}
		pageIDs[entry.PageID] = entry.OutputPath
		outputPaths[entry.OutputPath] = entry.PageID
	}
	return nil
}

func indexUnregisteredWikiPages(rootDir, outputDir, stalePolicy string, index *wikiPageIndex) error {
	seen := map[string]struct{}{}
	for _, entry := range index.Pages {
		seen[entry.PageID] = struct{}{}
	}
	return filepath.WalkDir(outputDir, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if d.IsDir() || !strings.EqualFold(filepath.Ext(path), ".html") {
			return nil
		}
		relToPages, err := filepath.Rel(outputDir, path)
		if err != nil {
			return err
		}
		pageID := strings.TrimSuffix(filepath.ToSlash(relToPages), filepath.Ext(relToPages))
		pageID = strings.ReplaceAll(pageID, "/", ":")
		if _, ok := seen[pageID]; ok {
			return nil
		}
		raw, err := os.ReadFile(path)
		if err != nil {
			return err
		}
		namespace := strings.SplitN(pageID, ":", 2)[0]
		relToRoot, err := filepath.Rel(rootDir, path)
		if err != nil {
			relToRoot = filepath.Base(path)
		}
		index.Pages = append(index.Pages, wikiPageIndexEntry{
			PageID:        pageID,
			Namespace:     namespace,
			SourceDataset: categoryFromWikiNamespace(namespace),
			SourceKey:     pageID,
			Title:         extractHTMLTitle(string(raw), pageID),
			Hash:          computeManagedHash(string(raw)),
			OutputPath:    filepath.ToSlash(relToRoot),
			EditPolicy:    wikiEditPolicy(namespace),
			StalePolicy:   stalePolicy,
		})
		seen[pageID] = struct{}{}
		return nil
	})
}

func loadWikiState(path string) (wikiStateDocument, error) {
	raw, err := os.ReadFile(path)
	if err != nil {
		return wikiStateDocument{}, err
	}
	var doc wikiStateDocument
	if err := json.Unmarshal(raw, &doc); err != nil {
		return wikiStateDocument{}, fmt.Errorf("parse wiki state: %w", err)
	}
	return doc, nil
}

func saveWikiState(path string, state wikiStateDocument) error {
	raw, err := json.MarshalIndent(state, "", "  ")
	if err != nil {
		return fmt.Errorf("marshal wiki state: %w", err)
	}
	raw = append(raw, '\n')
	if err := os.WriteFile(path, raw, 0o644); err != nil {
		return fmt.Errorf("write wiki state: %w", err)
	}
	return nil
}

func computeWikiSourceDigest(sourceDir string) (string, error) {
	hasher := sha256.New()
	_, _ = hasher.Write([]byte(wikiGeneratorVersion))
	files := []string{}
	relevantSourceDirs := []string{
		filepath.Join(sourceDir, "data", "feat"),
		filepath.Join(sourceDir, "data", "skills"),
		filepath.Join(sourceDir, "data", "spells"),
		filepath.Join(sourceDir, "data", "baseitems"),
		filepath.Join(sourceDir, "data", "classes"),
		filepath.Join(sourceDir, "data", "racialtypes"),
	}
	for _, dir := range relevantSourceDirs {
		_ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
			if err != nil || d.IsDir() {
				return err
			}
			if strings.EqualFold(filepath.Ext(path), ".json") {
				files = append(files, path)
			}
			return nil
		})
	}
	if len(files) == 0 {
		return "", nil
	}
	slices.Sort(files)
	for _, path := range files {
		if _, err := os.Stat(path); err != nil {
			if os.IsNotExist(err) {
				continue
			}
			return "", err
		}
		rel, err := filepath.Rel(sourceDir, path)
		if err == nil && !strings.HasPrefix(rel, "..") {
			rel = filepath.ToSlash(rel)
		} else {
			rel = filepath.Base(path)
		}
		_, _ = hasher.Write([]byte(rel))
		raw, err := os.ReadFile(path)
		if err != nil {
			return "", fmt.Errorf("read wiki digest input %s: %w", path, err)
		}
		_, _ = hasher.Write(raw)
	}
	return fmt.Sprintf("%x", hasher.Sum(nil)), nil
}

func newestRelevantWikiSource(sourceDir string) (time.Time, string, error) {
	newest := time.Time{}
	newestPath := ""
	relevantSourceDirs := []string{
		filepath.Join(sourceDir, "data", "feat"),
		filepath.Join(sourceDir, "data", "skills"),
		filepath.Join(sourceDir, "data", "spells"),
		filepath.Join(sourceDir, "data", "baseitems"),
		filepath.Join(sourceDir, "data", "classes"),
		filepath.Join(sourceDir, "data", "racialtypes"),
	}
	for _, dir := range relevantSourceDirs {
		err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
			if err != nil {
				if os.IsNotExist(err) {
					return nil
				}
				return err
			}
			if d.IsDir() {
				return nil
			}
			if !strings.EqualFold(filepath.Ext(path), ".json") {
				return nil
			}
			info, err := d.Info()
			if err != nil {
				return err
			}
			if newest.IsZero() || info.ModTime().After(newest) {
				newest = info.ModTime()
				newestPath = path
			}
			return nil
		})
		if err != nil {
			return time.Time{}, "", err
		}
	}
	return newest, newestPath, nil
}

func loadWikiContext(dataDir, sourceDir string) (*wikiContext, error) {
	dialogData, err := loadBaseDialogData(filepath.Join(sourceDir, "base_dialog.json"))
	if err != nil {
		return nil, err
	}
	dialog := map[string]string{}
	if dialogData != nil {
		for key, entry := range dialogData.Entries {
			dialog[key] = entry.Text
		}
	}

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

	featDataset, ok, err := loadBase("feat")
	if err != nil {
		return nil, err
	}
	skillDataset, skillOK, err := loadBase("skills")
	if err != nil {
		return nil, err
	}
	spellDataset, spellOK, err := loadBase("spells")
	if err != nil {
		return nil, err
	}
	baseitemDataset, baseitemOK, err := loadBase("baseitems")
	if err != nil {
		return nil, err
	}
	classDataset, classOK, err := loadBase("classes/core")
	if err != nil {
		return nil, err
	}
	masterfeatDataset, masterfeatOK, err := loadBase("masterfeats")
	if err != nil {
		return nil, err
	}
	raceDatasets, err := collectRacialtypesRegistryDatasets(dataDir)
	if err != nil {
		return nil, err
	}
	raceRows := map[string]map[string]any{}
	raceIDToKey := map[int]string{}
	raceFeatTables := map[string]wikiTable{}
	for _, dataset := range raceDatasets {
		if dataset.Dataset.OutputName == "racialtypes.2da" {
			raceRows, raceIDToKey = rowsByKeyAndID(dataset.Rows)
			continue
		}
		raceFeatTables[outputStem(dataset.Dataset.OutputName)] = wikiTable{
			Key:        dataset.TableKey,
			Output:     dataset.Dataset.OutputName,
			OutputStem: outputStem(dataset.Dataset.OutputName),
			Rows:       cloneRows(dataset.Rows),
		}
	}

	classFeatTables, err := loadWikiTables(filepath.Join(dataDir, "classes", "feats"))
	if err != nil {
		return nil, err
	}
	classSkillTables, err := loadWikiTables(filepath.Join(dataDir, "classes", "skills"))
	if err != nil {
		return nil, err
	}
	classBonusTables, err := loadWikiTables(filepath.Join(dataDir, "classes", "bfeat"))
	if err != nil {
		return nil, err
	}

	ctx := &wikiContext{
		dialog:           dialog,
		rowsByKey:        map[string]map[string]any{},
		statuses:         map[string]map[string]string{},
		raceFeatTables:   raceFeatTables,
		classFeatTables:  classFeatTables,
		classSkillTables: classSkillTables,
		classBonusTables: classBonusTables,
	}
	if ok {
		ctx.featRows, ctx.featIDToKey = rowsByKeyAndID(featDataset.Rows)
	} else {
		ctx.featRows, ctx.featIDToKey = map[string]map[string]any{}, map[int]string{}
	}
	if skillOK {
		ctx.skillRows, ctx.skillIDToKey = rowsByKeyAndID(skillDataset.Rows)
	} else {
		ctx.skillRows, ctx.skillIDToKey = map[string]map[string]any{}, map[int]string{}
	}
	if spellOK {
		ctx.spellRows, _ = rowsByKeyAndID(spellDataset.Rows)
	} else {
		ctx.spellRows = map[string]map[string]any{}
	}
	if baseitemOK {
		ctx.baseitemRows, _ = rowsByKeyAndID(baseitemDataset.Rows)
	} else {
		ctx.baseitemRows = map[string]map[string]any{}
	}
	if classOK {
		ctx.classRows, ctx.classIDToKey = rowsByKeyAndID(classDataset.Rows)
	} else {
		ctx.classRows, ctx.classIDToKey = map[string]map[string]any{}, map[int]string{}
	}
	if masterfeatOK {
		ctx.masterfeatRows, _ = rowsByKeyAndID(masterfeatDataset.Rows)
	} else {
		ctx.masterfeatRows = map[string]map[string]any{}
	}
	ctx.raceRows = raceRows
	ctx.raceIDToKey = raceIDToKey
	for _, group := range []map[string]map[string]any{ctx.featRows, ctx.skillRows, ctx.spellRows, ctx.baseitemRows, ctx.classRows, ctx.raceRows, ctx.masterfeatRows} {
		for key, row := range group {
			ctx.rowsByKey[key] = row
		}
	}
	if ok {
		ctx.statuses["feat"] = collectDatasetStatuses(filepath.Join(dataDir, "feat"), "feat")
	}
	if skillOK {
		ctx.statuses["skills"] = collectDatasetStatuses(filepath.Join(dataDir, "skills"), "skills")
	}
	if spellOK {
		ctx.statuses["spells"] = collectDatasetStatuses(filepath.Join(dataDir, "spells"), "spells")
	}
	if baseitemOK {
		ctx.statuses["baseitems"] = collectDatasetStatuses(filepath.Join(dataDir, "baseitems"), "baseitems")
	}
	if classOK {
		ctx.statuses["classes"] = collectDatasetStatuses(filepath.Join(dataDir, "classes", "core"), "classes/core")
	}
	ctx.statuses["racialtypes"] = collectRaceStatuses(filepath.Join(dataDir, "racialtypes", "registry"))
	ctx.implementedFeats = ctx.collectImplementedFeatKeys()
	return ctx, nil
}

func rowsByKeyAndID(rows []map[string]any) (map[string]map[string]any, map[int]string) {
	byKey := map[string]map[string]any{}
	byID := map[int]string{}
	for _, row := range rows {
		key, _ := row["key"].(string)
		if key == "" {
			continue
		}
		byKey[key] = row
		if id, ok := row["id"].(int); ok {
			byID[id] = key
		}
	}
	return byKey, byID
}

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

func loadWikiTables(root string) (map[string]wikiTable, error) {
	info, err := os.Stat(root)
	if err != nil {
		if os.IsNotExist(err) {
			return map[string]wikiTable{}, nil
		}
		return nil, err
	}
	if !info.IsDir() {
		return nil, fmt.Errorf("%s must be a directory", root)
	}
	tables := map[string]wikiTable{}
	err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if d.IsDir() || !strings.EqualFold(filepath.Ext(path), ".json") || strings.EqualFold(filepath.Base(path), "lock.json") {
			return nil
		}
		obj, err := loadJSONObject(path)
		if err != nil {
			return err
		}
		rawRows, ok := obj["rows"].([]any)
		if !ok {
			return fmt.Errorf("%s: rows must be an array", path)
		}
		rows := make([]map[string]any, 0, len(rawRows))
		for _, raw := range rawRows {
			row, ok := raw.(map[string]any)
			if !ok {
				return fmt.Errorf("%s: row must be an object", path)
			}
			rows = append(rows, row)
		}
		output, _ := obj["output"].(string)
		if output == "" {
			output = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + ".2da"
		}
		table := wikiTable{
			Key:        stringField(obj, "key"),
			Output:     output,
			OutputStem: outputStem(output),
			Rows:       rows,
		}
		tables[table.OutputStem] = table
		return nil
	})
	if err != nil {
		return nil, err
	}
	return tables, nil
}

func generateEntityPages(outputDir string, ctx *wikiContext, writePage func(pageID, content, title, status string) error) error {
	if err := generateCategoryPages("classes", ctx.classRows, ctx, writePage); err != nil {
		return err
	}
	if err := generateCategoryPages("feat", ctx.featRows, ctx, writePage); err != nil {
		return err
	}
	if err := generateCategoryPages("skills", ctx.skillRows, ctx, writePage); err != nil {
		return err
	}
	if err := generateCategoryPages("spells", ctx.spellRows, ctx, writePage); err != nil {
		return err
	}
	if err := generateCategoryPages("baseitems", ctx.baseitemRows, ctx, writePage); err != nil {
		return err
	}
	if err := generateCategoryPages("racialtypes", ctx.raceRows, ctx, writePage); err != nil {
		return err
	}
	_ = outputDir
	return nil
}

func generateCategoryPages(category string, rows map[string]map[string]any, ctx *wikiContext, writePage func(pageID, content, title, status string) error) error {
	keys := sortedKeys(rows)
	for _, key := range keys {
		row := rows[key]
		if !ctx.shouldGeneratePage(category, key, row) {
			continue
		}
		title := ctx.resolveRowName(category, row)
		if title == "" {
			continue
		}
		content, err := ctx.renderPage(category, key, row, title)
		if err != nil {
			return err
		}
		if err := writePage(wikiPageIDForKey(key), content, title, ctx.pageStatus(category, key, row)); err != nil {
			return err
		}
	}
	return nil
}

func (ctx *wikiContext) shouldGeneratePage(category, key string, row map[string]any) bool {
	if row == nil {
		return false
	}
	override := ctx.wikiGenerateOverride(row)
	if override == "0" {
		return false
	}
	name := ctx.resolveRowName(category, row)
	description := ctx.resolveRowDescription(category, row)
	switch category {
	case "classes":
		if isToolLike(name, stringField(row, "Label"), stringField(row, "Constant")) {
			return false
		}
		return override == "1" || stringValue(row, "PlayerClass") == "1"
	case "feat":
		if name == "" || description == "" || strings.EqualFold(stringValue(row, "FEAT"), nullValue) {
			return false
		}
		if override == "1" {
			return true
		}
		if stringValue(row, "ALLCLASSESCANUSE") == "1" {
			return true
		}
		_, ok := ctx.implementedFeats[key]
		return ok
	case "skills":
		if name == "" || description == "" {
			return false
		}
		return override == "1" || stringValue(row, "HideFromLevelUp") != "1"
	case "racialtypes":
		if name == "" || description == "" {
			return false
		}
		return override == "1" || stringValue(row, "PlayerRace") == "1"
	case "spells":
		if isToolLike(name, stringField(row, "Label"), stringField(row, "Constant")) {
			return false
		}
		return name != "" && description != ""
	default:
		return name != "" && description != ""
	}
}

func (ctx *wikiContext) renderPage(category, key string, row map[string]any, title string) (string, error) {
	sections := []string{
		"====== " + title + " ======",
		"",
		buildWikiStatusBlock(ctx.pageStatus(category, key, row), category),
		"",
		toDokuWiki(ctx.resolveRowDescription(category, row)),
		"",
		ctx.renderFacts(category, key, row),
		"",
		ctx.renderProgression(category, row),
		"",
		ctx.renderRaceNameForms(row),
	}
	return ensureTrailingNewline(strings.Join(sections, "\n")), nil
}

func (ctx *wikiContext) renderFacts(category, key string, row map[string]any) string {
	lines := []string{}
	switch category {
	case "feat":
		lines = append(lines, ctx.renderFeatFacts(row)...)
	case "skills":
		lines = append(lines, formatFact("Key Ability", stringValue(row, "KeyAbility")))
		lines = append(lines, formatFact("Untrained", yesNoValue(stringValue(row, "Untrained"))))
		lines = append(lines, formatFact("Armor Check Penalty", yesNoValue(stringValue(row, "ArmorCheckPenalty"))))
	case "spells":
		lines = append(lines, formatFact("Constant", stringValue(row, "Constant")))
	case "baseitems":
		lines = append(lines, formatFact("Item Class", stringValue(row, "ItemClass")))
		lines = append(lines, formatFact("Weapon Type", stringValue(row, "WeaponType")))
		lines = append(lines, formatFact("Required Feats", ctx.renderReferenceList(ctx.wikiReqFeats(row))))
		lines = append(lines, formatFact("Base Item Stats", toInlineDokuWiki(ctx.resolveTextValue(fieldValue(row, "BaseItemStatRef"), nil))))
	case "classes":
		lines = append(lines, formatFact("Hit Die", "d"+stringValue(row, "HitDie")))
		lines = append(lines, formatFact("Skill Points", stringValue(row, "SkillPointBase")))
		lines = append(lines, formatFact("Primary Ability", stringValue(row, "PrimaryAbil")))
	case "racialtypes":
		lines = append(lines, formatFact("Ability Adjustments", formatAbilityAdjustments(row)))
		lines = append(lines, formatFact("Favored Class", ctx.renderReference(fieldValue(row, "Favored"), ctx.classIDToKey, ctx.resolveClassName)))
		lines = append(lines, formatFact("Favored Enemy", ctx.renderReference(fieldValue(row, "FavoredEnemyFeat"), ctx.featIDToKey, ctx.resolveFeatName)))
		lines = append(lines, formatFact("Racial Feats", ctx.renderRaceFeatList(row)))
	}
	out := make([]string, 0, len(lines))
	for _, line := range lines {
		if strings.TrimSpace(line) != "" {
			out = append(out, line)
		}
	}
	return strings.Join(out, "\n")
}

func (ctx *wikiContext) renderFeatFacts(row map[string]any) []string {
	lines := []string{}
	lines = append(lines, formatFact("Prerequisites", ctx.renderFeatPrerequisites(row)))
	lines = append(lines, formatFact("Minimum Attack Bonus", stringValue(row, "MINATTACKBONUS")))
	lines = append(lines, formatFact("Minimum Spell Level", stringValue(row, "MINSPELLLVL")))
	lines = append(lines, formatFact("Minimum Fortitude Save", stringValue(row, "MinFortSave")))
	return lines
}

func (ctx *wikiContext) renderFeatPrerequisites(row map[string]any) string {
	parts := []string{}
	for _, field := range []struct {
		Key   string
		Label string
	}{
		{"MINSTR", "Str"}, {"MINDEX", "Dex"}, {"MINCON", "Con"}, {"MININT", "Int"}, {"MINWIS", "Wis"}, {"MINCHA", "Cha"},
	} {
		if value := stringValue(row, field.Key); value != "" {
			parts = append(parts, field.Label+" "+value)
		}
	}
	for _, field := range []string{"PREREQFEAT1", "PREREQFEAT2"} {
		if rendered := ctx.renderReference(fieldValue(row, field), ctx.featIDToKey, ctx.resolveFeatName); rendered != "" {
			parts = append(parts, rendered)
		}
	}
	orReqs := []string{}
	for _, field := range []string{"OrReqFeat0", "OrReqFeat1", "OrReqFeat2", "OrReqFeat3", "OrReqFeat4"} {
		if rendered := ctx.renderReference(fieldValue(row, field), ctx.featIDToKey, ctx.resolveFeatName); rendered != "" {
			orReqs = append(orReqs, rendered)
		}
	}
	if len(orReqs) > 0 {
		parts = append(parts, strings.Join(orReqs, " or "))
	}
	if rendered := ctx.renderReference(fieldValue(row, "MinLevelClass"), ctx.classIDToKey, ctx.resolveClassName); rendered != "" {
		if value := stringValue(row, "MinLevel"); value != "" {
			parts = append(parts, "Level "+value+" of "+rendered)
		}
	}
	for _, spec := range []struct {
		SkillField string
		RankField  string
	}{
		{"REQSKILL", "ReqSkillMinRanks"},
		{"REQSKILL2", "ReqSkillMinRanks2"},
	} {
		if skill := ctx.renderReference(fieldValue(row, spec.SkillField), ctx.skillIDToKey, ctx.resolveSkillName); skill != "" {
			ranks := stringValue(row, spec.RankField)
			if ranks != "" {
				parts = append(parts, skill+" "+ranks+" ranks")
			} else {
				parts = append(parts, skill)
			}
		}
	}
	return strings.Join(parts, ", ")
}

func (ctx *wikiContext) renderProgression(category string, row map[string]any) string {
	if category != "classes" {
		return ""
	}
	lines := []string{}
	if table := ctx.tableForValue(fieldValue(row, "SkillsTable"), ctx.classSkillTables); table != nil {
		skills := []string{}
		for _, skillRow := range table.Rows {
			if stringValue(skillRow, "ClassSkill") != "1" {
				continue
			}
			name := ctx.renderReference(fieldValue(skillRow, "SkillIndex"), ctx.skillIDToKey, ctx.resolveSkillName)
			if name != "" {
				skills = append(skills, name)
			}
		}
		if len(skills) > 0 {
			lines = append(lines, "==== Class Skills ====")
			lines = append(lines, "")
			lines = append(lines, "  * "+strings.Join(skills, ", "))
			lines = append(lines, "")
		}
	}
	if table := ctx.tableForValue(fieldValue(row, "FeatsTable"), ctx.classFeatTables); table != nil {
		byLevel := map[string][]string{}
		selectable := []string{}
		for _, featRow := range table.Rows {
			name := ctx.renderReference(fieldValue(featRow, "FeatIndex"), ctx.featIDToKey, ctx.resolveFeatName)
			if name == "" {
				name = ctx.renderReference(fieldValue(featRow, "FeatIndex"), nil, func(string) string { return "" })
			}
			if name == "" {
				continue
			}
			level := stringValue(featRow, "GrantedOnLevel")
			if level == "" || strings.HasPrefix(level, "-") {
				selectable = append(selectable, name)
				continue
			}
			byLevel[level] = append(byLevel[level], name)
		}
		if len(byLevel) > 0 {
			lines = append(lines, "==== Granted Feats ====")
			lines = append(lines, "")
			levels := sortedKeys(byLevel)
			for _, level := range levels {
				lines = append(lines, "  * Level "+level+": "+strings.Join(byLevel[level], ", "))
			}
			lines = append(lines, "")
		}
		if len(selectable) > 0 {
			lines = append(lines, "==== Selectable Feats ====")
			lines = append(lines, "")
			lines = append(lines, "  * "+strings.Join(selectable, ", "))
			lines = append(lines, "")
		}
	}
	if table := ctx.tableForValue(fieldValue(row, "BonusFeatsTable"), ctx.classBonusTables); table != nil && len(table.Rows) > 0 {
		lines = append(lines, "==== Bonus Feats ====")
		lines = append(lines, "")
		lines = append(lines, "  * Bonus feat table: "+table.OutputStem)
		lines = append(lines, "")
	}
	if len(lines) == 0 {
		return "No class progression data."
	}
	return strings.TrimSpace(strings.Join(lines, "\n"))
}

func (ctx *wikiContext) renderRaceNameForms(row map[string]any) string {
	parts := []string{}
	if plural := ctx.resolveTextValue(fieldValue(row, "NamePlural"), nil); plural != "" {
		parts = append(parts, "**Plural:** "+plural+`\\`)
	}
	if converted := ctx.resolveTextValue(fieldValue(row, "ConverName"), nil); converted != "" {
		parts = append(parts, "**Converted Name:** "+converted+`\\`)
	}
	if lower := ctx.resolveTextValue(fieldValue(row, "ConverNameLower"), nil); lower != "" {
		parts = append(parts, "**Lower Name:** "+lower+`\\`)
	}
	return strings.Join(parts, "\n")
}

func (ctx *wikiContext) renderRaceFeatList(row map[string]any) string {
	table := ctx.tableForValue(fieldValue(row, "FeatsTable"), ctx.raceFeatTables)
	if table == nil {
		return ""
	}
	parts := []string{}
	for _, featRow := range table.Rows {
		if rendered := ctx.renderReference(fieldValue(featRow, "FeatIndex"), ctx.featIDToKey, ctx.resolveFeatName); rendered != "" {
			parts = append(parts, rendered)
		}
	}
	return strings.Join(parts, ", ")
}

func (ctx *wikiContext) renderReference(value any, idToKey map[int]string, nameResolver func(string) string) string {
	key := resolveReferenceKey(value, idToKey)
	if key == "" {
		return ""
	}
	name := nameResolver(key)
	if name == "" {
		name = key
	}
	if pageID := wikiPageIDForKey(key); pageID != "" {
		return "[[" + pageID + "|" + name + "]]"
	}
	return name
}

func (ctx *wikiContext) renderReferenceList(values []any) string {
	parts := []string{}
	for _, value := range values {
		if rendered := ctx.renderReference(value, ctx.featIDToKey, ctx.resolveFeatName); rendered != "" {
			parts = append(parts, rendered)
		}
	}
	return strings.Join(parts, ", ")
}

func (ctx *wikiContext) resolveRowName(category string, row map[string]any) string {
	spec := specForDataset(category)
	nameFields := spec.NameFields
	if category == "classes" {
		nameFields = []string{"Name", "Plural", "Lower", "CLASS", "Label", "Short"}
	}
	for _, field := range nameFields {
		if value, ok := lookupField(row, field); ok {
			if text := ctx.resolveTextValue(value, nil); text != "" {
				return text
			}
		}
	}
	return ""
}

func (ctx *wikiContext) resolveRowDescription(category string, row map[string]any) string {
	spec := specForDataset(category)
	for _, field := range spec.DescriptionFields {
		if value, ok := lookupField(row, field); ok {
			if text := ctx.resolveTextValue(value, nil); text != "" {
				return text
			}
		}
	}
	return ""
}

func (ctx *wikiContext) resolveTextValue(value any, stack map[string]struct{}) string {
	switch typed := value.(type) {
	case nil:
		return ""
	case string:
		trimmed := strings.TrimSpace(typed)
		if trimmed == "" || trimmed == nullValue {
			return ""
		}
		if entry, ok := ctx.dialog[trimmed]; ok {
			return entry
		}
		return trimmed
	case int:
		if entry, ok := ctx.dialog[strconv.Itoa(typed)]; ok {
			return entry
		}
		return strconv.Itoa(typed)
	case float64:
		text := strconv.Itoa(int(typed))
		if entry, ok := ctx.dialog[text]; ok {
			return entry
		}
		return text
	case map[string]any:
		payload, ok, err := parseTLKPayload(typed, true)
		if err == nil && ok {
			switch {
			case payload.Text != "":
				return payload.Text
			case payload.Ref != "":
				if stack == nil {
					stack = map[string]struct{}{}
				}
				token := payload.Ref + "." + payload.RefField
				if _, seen := stack[token]; seen {
					return ""
				}
				stack[token] = struct{}{}
				defer delete(stack, token)
				target := ctx.rowsByKey[payload.Ref]
				if target == nil {
					return ""
				}
				field, ok := lookupField(target, payload.RefField)
				if !ok {
					return ""
				}
				return ctx.resolveTextValue(field, stack)
			case payload.Key != "":
				if entry, ok := ctx.dialog[payload.Key]; ok {
					return entry
				}
				return ""
			}
		}
	}
	return ""
}

func (ctx *wikiContext) resolveFeatName(key string) string {
	if row := ctx.featRows[key]; row != nil {
		return ctx.resolveRowName("feat", row)
	}
	if row := ctx.masterfeatRows[key]; row != nil {
		return ctx.resolveRowName("masterfeats", row)
	}
	return ""
}

func (ctx *wikiContext) resolveSkillName(key string) string {
	if row := ctx.skillRows[key]; row != nil {
		return ctx.resolveRowName("skills", row)
	}
	return ""
}

func (ctx *wikiContext) resolveClassName(key string) string {
	if row := ctx.classRows[key]; row != nil {
		return ctx.resolveRowName("classes", row)
	}
	return ""
}

func (ctx *wikiContext) wikiGenerateOverride(row map[string]any) string {
	if meta, err := parseExistingMetadata(row); err == nil && meta != nil {
		if wiki, ok := meta["wiki"].(map[string]any); ok {
			if value, ok := wiki["generate"]; ok {
				return normalizeWikiScalar(value)
			}
		}
	}
	if value, ok := lookupField(row, "WIKIGENERATE"); ok {
		return normalizeWikiScalar(value)
	}
	return ""
}

func (ctx *wikiContext) wikiReqFeats(row map[string]any) []any {
	if meta, err := parseExistingMetadata(row); err == nil && meta != nil {
		if wiki, ok := meta["wiki"].(map[string]any); ok {
			if value, ok := wiki["reqfeats"]; ok {
				if typed, ok := value.([]any); ok {
					return typed
				}
			}
		}
	}
	return nil
}

func (ctx *wikiContext) pageStatus(category, key string, row map[string]any) string {
	if meta, err := parseExistingMetadata(row); err == nil && meta != nil {
		if wiki, ok := meta["wiki"].(map[string]any); ok {
			if value, ok := wiki["status"]; ok {
				if normalized := normalizeWikiScalar(value); normalized != "" {
					return normalized
				}
			}
		}
	}
	if categoryStatuses, ok := ctx.statuses[category]; ok {
		if status, ok := categoryStatuses[key]; ok && status != "" {
			return status
		}
	}
	return wikiStatusVanilla
}

func (ctx *wikiContext) collectImplementedFeatKeys() map[string]struct{} {
	implemented := map[string]struct{}{}
	masterfeatGroups := map[string][]string{}
	for key, row := range ctx.featRows {
		if group := masterfeatGroupKey(fieldValue(row, "MASTERFEAT"), ctx.masterfeatRows); group != "" {
			masterfeatGroups[group] = append(masterfeatGroups[group], key)
		}
	}
	classCounts := func(row map[string]any) bool {
		override := ctx.wikiGenerateOverride(row)
		if override == "1" {
			return true
		}
		if override == "0" {
			return false
		}
		return stringValue(row, "PlayerClass") == "1"
	}
	for _, row := range ctx.classRows {
		if !classCounts(row) {
			continue
		}
		for _, tableMap := range []map[string]wikiTable{ctx.classFeatTables, ctx.classBonusTables} {
			if table := ctx.tableForValue(fieldValue(row, "FeatsTable"), tableMap); table != nil {
				ctx.collectImplementedFromTable(implemented, masterfeatGroups, table.Rows)
			}
			if table := ctx.tableForValue(fieldValue(row, "BonusFeatsTable"), tableMap); table != nil {
				ctx.collectImplementedFromTable(implemented, masterfeatGroups, table.Rows)
			}
		}
	}
	for _, row := range ctx.raceRows {
		override := ctx.wikiGenerateOverride(row)
		if override == "0" || (override != "1" && stringValue(row, "PlayerRace") != "1") {
			continue
		}
		if table := ctx.tableForValue(fieldValue(row, "FeatsTable"), ctx.raceFeatTables); table != nil {
			ctx.collectImplementedFromTable(implemented, masterfeatGroups, table.Rows)
		}
	}
	return implemented
}

func (ctx *wikiContext) collectImplementedFromTable(target map[string]struct{}, masterfeatGroups map[string][]string, rows []map[string]any) {
	for _, row := range rows {
		key := resolveReferenceKey(fieldValue(row, "FeatIndex"), ctx.featIDToKey)
		if key == "" {
			if key = resolveReferenceKey(fieldValue(row, "FeatIndex"), nil); key == "" {
				continue
			}
		}
		if strings.HasPrefix(key, "masterfeats:") {
			for _, featKey := range masterfeatGroups[key] {
				target[featKey] = struct{}{}
			}
			continue
		}
		target[key] = struct{}{}
	}
}

func (ctx *wikiContext) tableForValue(value any, tables map[string]wikiTable) *wikiTable {
	switch typed := value.(type) {
	case string:
		if table, ok := tables[typed]; ok {
			return &table
		}
	case map[string]any:
		if tableKey, ok := typed["table"].(string); ok {
			tableKey = outputStem(tableKey)
			if table, ok := tables[tableKey]; ok {
				return &table
			}
		}
	}
	return nil
}

func collectDatasetStatuses(root, datasetName string) map[string]string {
	statuses := map[string]string{}
	basePath := filepath.Join(root, "base.json")
	baseObj, err := loadJSONObject(basePath)
	if err == nil {
		if rows, ok := baseObj["rows"].([]any); ok {
			for _, raw := range rows {
				row, ok := raw.(map[string]any)
				if !ok {
					continue
				}
				if key := stringField(row, "key"); key != "" {
					statuses[key] = wikiStatusVanilla
				}
			}
		}
	}
	mergeExplicitWikiStatuses(basePath, statuses)
	for _, dirName := range []string{"modules", "generated"} {
		dir := filepath.Join(root, dirName)
		paths, err := collectModulePaths(dir)
		if err != nil {
			continue
		}
		for _, path := range paths {
			obj, err := loadJSONObject(path)
			if err != nil {
				continue
			}
			if entries, ok := obj["entries"].(map[string]any); ok {
				for key, raw := range entries {
					row, ok := raw.(map[string]any)
					if !ok {
						continue
					}
					if _, exists := statuses[key]; exists {
						statuses[key] = wikiStatusModified
					} else {
						statuses[key] = wikiStatusNew
					}
					mergeRowWikiStatus(key, row, statuses)
				}
			}
			if overrides, ok := obj["overrides"].([]any); ok {
				for _, raw := range overrides {
					row, ok := raw.(map[string]any)
					if !ok {
						continue
					}
					key := stringField(row, "key")
					if key == "" {
						continue
					}
					if _, exists := statuses[key]; exists {
						statuses[key] = wikiStatusModified
					} else {
						statuses[key] = wikiStatusNew
					}
					mergeRowWikiStatus(key, row, statuses)
				}
			}
		}
	}
	_ = datasetName
	return statuses
}

func collectRaceStatuses(root string) map[string]string {
	statuses := map[string]string{}
	baseObj, err := loadJSONObject(filepath.Join(root, "base.json"))
	if err == nil {
		if rows, ok := baseObj["rows"].([]any); ok {
			for _, raw := range rows {
				row, ok := raw.(map[string]any)
				if !ok {
					continue
				}
				if key := stringField(row, "key"); key != "" {
					statuses[key] = wikiStatusVanilla
				}
			}
		}
	}
	paths, err := collectModulePaths(filepath.Join(root, "races"))
	if err != nil {
		return statuses
	}
	for _, path := range paths {
		row, err := loadJSONObject(path)
		if err != nil {
			continue
		}
		key := stringField(row, "key")
		if key == "" {
			continue
		}
		if _, exists := statuses[key]; exists {
			statuses[key] = wikiStatusModified
		} else {
			statuses[key] = wikiStatusNew
		}
		mergeRowWikiStatus(key, row, statuses)
		if core, ok := row["core"].(map[string]any); ok {
			mergeRowWikiStatus(key, core, statuses)
		}
	}
	return statuses
}

func mergeExplicitWikiStatuses(path string, statuses map[string]string) {
	obj, err := loadJSONObject(path)
	if err != nil {
		return
	}
	if rows, ok := obj["rows"].([]any); ok {
		for _, raw := range rows {
			row, ok := raw.(map[string]any)
			if !ok {
				continue
			}
			if key := stringField(row, "key"); key != "" {
				mergeRowWikiStatus(key, row, statuses)
			}
		}
	}
}

func mergeRowWikiStatus(key string, row map[string]any, statuses map[string]string) {
	if meta, err := parseExistingMetadata(row); err == nil && meta != nil {
		if wiki, ok := meta["wiki"].(map[string]any); ok {
			if value, ok := wiki["status"]; ok {
				if normalized := normalizeWikiScalar(value); normalized != "" {
					statuses[key] = normalized
				}
			}
		}
	}
}

func buildWikiStatusBlock(status, category string) string {
	message := "This " + wikiStatusNoun(category) + " is unchanged from vanilla."
	switch status {
	case wikiStatusNew:
		message = "This is a new " + wikiStatusNoun(category) + "!"
	case wikiStatusModified:
		message = "This " + wikiStatusNoun(category) + " has been altered from vanilla."
	}
	return "<p class=\"wiki-callout wiki-callout--status\">" + html.EscapeString(message) + "</p>"
}

func wikiStatusNoun(category string) string {
	switch category {
	case "feat":
		return "feat"
	case "skills":
		return "skill"
	case "spells":
		return "spell"
	case "classes":
		return "class"
	case "racialtypes":
		return "race"
	case "baseitems":
		return "item type"
	default:
		return "page"
	}
}

func generateStatusPages(outputDir string, pageStatuses map[string]string, pageTitles map[string]string, namespaces []string, manualSections []wikiManualSection) error {
	summaries := buildWikiNamespaceSummaries(pageStatuses, namespaces)
	if err := writeWikiFile(filepath.Join(outputDir, "meta", "wikistatus.html"), renderWikiStatusReportPage(summaries, namespaces), manualSections); err != nil {
		return err
	}
	for _, namespace := range namespaces {
		summary := summaries[namespace]
		if err := writeWikiFile(filepath.Join(outputDir, "meta", "wikistatus", namespace+".html"), renderWikiNamespaceFragment(namespace, summary), manualSections); err != nil {
			return err
		}
		for _, status := range []string{wikiStatusNew, wikiStatusModified, wikiStatusVanilla} {
			pageIDs := filterWikiPageIDs(pageStatuses, namespace, status)
			if err := writeWikiFile(filepath.Join(outputDir, "meta", "wikistatus", namespace, status+".html"), renderWikiStatusListingPage(namespaceTitle(namespace)+" "+wikiStatusLabels[status]+" Pages", pageIDs, pageTitles), manualSections); err != nil {
				return err
			}
		}
	}
	for _, status := range []string{wikiStatusNew, wikiStatusModified, wikiStatusVanilla} {
		pageIDs := filterWikiPageIDs(pageStatuses, "", status)
		if err := writeWikiFile(filepath.Join(outputDir, "meta", "wikistatus", status+".html"), renderWikiStatusListingPage(wikiStatusLabels[status]+" Pages", pageIDs, pageTitles), manualSections); err != nil {
			return err
		}
	}
	return nil
}

func writeWikiFile(path, content string, manualSections []wikiManualSection) error {
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		return err
	}
	content = renderNodeBBManagedHTML(wikiRelPathToPageID(path), content, manualSections)
	return os.WriteFile(path, []byte(ensureTrailingNewline(content)), 0o644)
}

func buildWikiNamespaceSummaries(pageStatuses map[string]string, namespaces []string) map[string]map[string]any {
	summaries := map[string]map[string]any{}
	for _, namespace := range namespaces {
		statuses := []string{}
		for pageID, status := range pageStatuses {
			if strings.SplitN(pageID, ":", 2)[0] == namespace {
				statuses = append(statuses, status)
			}
		}
		summaries[namespace] = map[string]any{
			"status":   rollupWikiStatuses(statuses),
			"new":      countWikiStatus(statuses, wikiStatusNew),
			"modified": countWikiStatus(statuses, wikiStatusModified),
			"vanilla":  countWikiStatus(statuses, wikiStatusVanilla),
			"total":    len(statuses),
		}
	}
	return summaries
}

func countWikiStatus(statuses []string, target string) int {
	count := 0
	for _, status := range statuses {
		if status == target {
			count++
		}
	}
	return count
}

func rollupWikiStatuses(statuses []string) string {
	best := wikiStatusVanilla
	for _, status := range statuses {
		if wikiStatusPriority[status] > wikiStatusPriority[best] {
			best = status
		}
	}
	return best
}

func renderWikiNamespaceFragment(namespace string, summary map[string]any) string {
	return renderStatusTable(namespaceTitle(namespace), summary, "")
}

func renderWikiStatusReportPage(summaries map[string]map[string]any, namespaces []string) string {
	lines := []string{"====== Wiki Status ======", "", "This page is auto-generated from builder source provenance.", ""}
	for _, namespace := range namespaces {
		summary := summaries[namespace]
		lines = append(lines, renderStatusTable(namespaceTitle(namespace), summary, fmt.Sprintf("[[%s:index|%s]]", namespace, namespaceTitle(namespace))))
	}
	return strings.Join(lines, "\n")
}

func renderWikiStatusListingPage(title string, pageIDs []string, pageTitles map[string]string) string {
	lines := []string{"====== " + title + " ======", ""}
	if len(pageIDs) == 0 {
		lines = append(lines, "No matching generated pages.")
	} else {
		for _, pageID := range pageIDs {
			title := pageTitles[pageID]
			if title == "" {
				title = pageID
			}
			lines = append(lines, "  * [["+pageID+"|"+title+"]]")
		}
	}
	return strings.Join(lines, "\n")
}

func renderStatusTable(title string, summary map[string]any, namespaceLink string) string {
	rows := []string{
		"===== " + title + " =====",
		"",
		fmt.Sprintf("**Namespace Status:** %s\\\\", wikiStatusLabels[summary["status"].(string)]),
		fmt.Sprintf("**Generated Pages:** %d\\\\", summary["total"].(int)),
		fmt.Sprintf("**New:** %d\\\\", summary["new"].(int)),
		fmt.Sprintf("**Modified:** %d\\\\", summary["modified"].(int)),
		fmt.Sprintf("**Vanilla:** %d\\\\", summary["vanilla"].(int)),
	}
	if namespaceLink != "" {
		rows = append(rows, fmt.Sprintf("**Namespace:** %s", namespaceLink))
	}
	return strings.Join(rows, "\n")
}

func filterWikiPageIDs(pageStatuses map[string]string, namespace, status string) []string {
	pageIDs := []string{}
	for pageID, candidate := range pageStatuses {
		if candidate != status {
			continue
		}
		if namespace != "" && strings.SplitN(pageID, ":", 2)[0] != namespace {
			continue
		}
		pageIDs = append(pageIDs, pageID)
	}
	slices.Sort(pageIDs)
	return pageIDs
}

func wikiPageIDForKey(key string) string {
	if key == "" {
		return ""
	}
	category, value, ok := strings.Cut(key, ":")
	if !ok {
		return ""
	}
	namespace := map[string]string{
		"baseitems":   "itemtypes",
		"racialtypes": "races",
		"skills":      "skills",
		"spells":      "spells",
		"classes":     "classes",
		"feat":        "feat",
	}[category]
	if namespace == "" {
		return ""
	}
	value = strings.NewReplacer("/", ":", "\\", ":", "_", ":").Replace(value)
	return namespace + ":" + value
}

func wikiPageIDToRelPath(pageID string) string {
	return filepath.FromSlash(strings.ReplaceAll(pageID, ":", "/") + ".html")
}

func wikiRelPathToPageID(path string) string {
	path = filepath.ToSlash(path)
	path = strings.TrimSuffix(path, ".md")
	path = strings.TrimSuffix(path, ".html")
	if index := strings.LastIndex(path, "/pages/"); index >= 0 {
		path = path[index+len("/pages/"):]
	}
	return strings.ReplaceAll(path, "/", ":")
}

func namespaceTitle(namespace string) string {
	switch namespace {
	case "itemtypes":
		return "Itemtypes"
	default:
		return strings.Title(namespace)
	}
}

func categoryFromWikiNamespace(namespace string) string {
	switch namespace {
	case "itemtypes":
		return "baseitems"
	case "races":
		return "racialtypes"
	default:
		return namespace
	}
}

func wikiEditPolicy(namespace string) string {
	if namespace == "meta" {
		return "generated_only"
	}
	return "preserve_manual_sections"
}

func normalizeWikiScalar(value any) string {
	switch typed := value.(type) {
	case string:
		trimmed := strings.TrimSpace(strings.ToLower(typed))
		if _, ok := wikiStatusPriority[trimmed]; ok {
			return trimmed
		}
		if trimmed == "0" || trimmed == "1" {
			return trimmed
		}
	}
	return ""
}

func resolveReferenceKey(value any, idToKey map[int]string) string {
	switch typed := value.(type) {
	case map[string]any:
		if key, ok := typed["key"].(string); ok && strings.Contains(key, ":") {
			return key
		}
		if id, ok := typed["id"].(string); ok && strings.Contains(id, ":") {
			return id
		}
		if idToKey != nil {
			if rawID, ok := typed["id"]; ok {
				if id, err := asInt(rawID); err == nil {
					return idToKey[id]
				}
			}
		}
	case string:
		if strings.Contains(typed, ":") {
			return typed
		}
		if idToKey != nil {
			if id, err := strconv.Atoi(typed); err == nil {
				return idToKey[id]
			}
		}
	case int:
		if idToKey != nil {
			return idToKey[typed]
		}
	case float64:
		if idToKey != nil {
			return idToKey[int(typed)]
		}
	}
	return ""
}

func masterfeatGroupKey(value any, masterfeatRows map[string]map[string]any) string {
	key := resolveReferenceKey(value, nil)
	if strings.HasPrefix(key, "masterfeats:") {
		return key
	}
	id, err := asInt(value)
	if err != nil {
		return ""
	}
	for key, row := range masterfeatRows {
		if rowID, ok := row["id"].(int); ok && rowID == id {
			return key
		}
	}
	return ""
}

func fieldValue(row map[string]any, field string) any {
	value, _ := lookupField(row, field)
	return value
}

func stringValue(row map[string]any, field string) string {
	value, ok := lookupField(row, field)
	if !ok || value == nil {
		return ""
	}
	switch typed := value.(type) {
	case string:
		if typed == nullValue {
			return ""
		}
		return typed
	case int:
		return strconv.Itoa(typed)
	case float64:
		return strconv.Itoa(int(typed))
	default:
		return ""
	}
}

func formatAbilityAdjustments(row map[string]any) string {
	parts := []string{}
	for _, spec := range []struct {
		Field string
		Label string
	}{
		{"StrAdjust", "Strength"},
		{"DexAdjust", "Dexterity"},
		{"ConAdjust", "Constitution"},
		{"IntAdjust", "Intelligence"},
		{"WisAdjust", "Wisdom"},
		{"ChaAdjust", "Charisma"},
	} {
		value := stringValue(row, spec.Field)
		if value == "" || value == "0" {
			continue
		}
		if !strings.HasPrefix(value, "-") {
			value = "+" + value
		}
		parts = append(parts, value+" "+spec.Label)
	}
	return strings.Join(parts, ", ")
}

func yesNoValue(value string) string {
	switch value {
	case "1":
		return "Yes"
	case "0":
		return "No"
	default:
		return ""
	}
}

func formatFact(label, value string) string {
	if strings.TrimSpace(value) == "" {
		return ""
	}
	return "**" + label + ":** " + value + `\\`
}

func toInlineDokuWiki(text string) string {
	lines := strings.Split(strings.TrimSpace(text), "\n")
	for index, line := range lines {
		lines[index] = strings.TrimSpace(line)
	}
	return strings.Join(lines, `\\`)
}

func toDokuWiki(text string) string {
	text = strings.ReplaceAll(strings.TrimSpace(text), "\r\n", "\n")
	if text == "" {
		return ""
	}
	lines := strings.Split(text, "\n")
	out := make([]string, 0, len(lines))
	for _, line := range lines {
		trimmed := strings.TrimSpace(line)
		switch {
		case trimmed == "":
			out = append(out, "")
		case strings.HasPrefix(trimmed, "- "):
			out = append(out, "  * "+strings.TrimSpace(strings.TrimPrefix(trimmed, "- ")))
		default:
			out = append(out, line)
		}
	}
	return strings.Join(out, "\n")
}

func renderNodeBBManagedMarkdown(pageID, dokuText string) string {
	generated, notes := splitDokuWikiNotes(dokuText)
	body := dokuWikiToNodeBBMarkdown(generated)
	parts := []string{
		"[//]: # (sow-topdata-wiki:page=" + pageID + ")",
		"[//]: # (sow-topdata-wiki:managed:start)",
		body,
		"[//]: # (sow-topdata-wiki:managed:end)",
	}
	notesMarkdown := dokuWikiToNodeBBMarkdown("===== Notes =====\n" + notes)
	if strings.TrimSpace(notesMarkdown) != "" {
		parts = append(parts, notesMarkdown)
	}
	return ensureTrailingNewline(strings.Join(parts, "\n"))
}

func renderNodeBBManagedHTML(pageID, sourceText string, manualSections []wikiManualSection) string {
	body := sourceText
	if !strings.Contains(sourceText, "<h1") {
		body = dokuWikiToNodeBBHTML(sourceText)
	}
	hash := computeManagedHash(body)
	parts := []string{
		"<!-- sow-topdata-wiki:page=" + pageID + " -->",
		"<!-- sow-topdata-wiki:managed:start hash=\"sha256:" + hash + "\" -->",
		strings.TrimSpace(body),
		"<!-- sow-topdata-wiki:managed:end -->",
	}
	for _, section := range manualSections {
		parts = append(parts,
			"<!-- sow-topdata-wiki:manual:start id=\""+html.EscapeString(section.ID)+"\" -->",
			strings.TrimSpace(section.InitialHTML),
			"<!-- sow-topdata-wiki:manual:end id=\""+html.EscapeString(section.ID)+"\" -->",
		)
	}
	return ensureTrailingNewline(strings.Join(parts, "\n"))
}

func dokuWikiToNodeBBHTML(text string) string {
	text = strings.ReplaceAll(strings.TrimSpace(text), "\r\n", "\n")
	if text == "" {
		return ""
	}
	lines := strings.Split(text, "\n")
	var out []string
	var paragraph []string
	var list []string
	var tableRows []string

	flushParagraph := func() {
		if len(paragraph) == 0 {
			return
		}
		out = append(out, "<p>"+renderInlineHTML(strings.Join(paragraph, " "))+"</p>")
		paragraph = nil
	}
	flushList := func() {
		if len(list) == 0 {
			return
		}
		out = append(out, "<ul>")
		for _, item := range list {
			out = append(out, "<li>"+renderInlineHTML(item)+"</li>")
		}
		out = append(out, "</ul>")
		list = nil
	}
	flushTable := func() {
		if len(tableRows) == 0 {
			return
		}
		out = append(out, "<table><tbody>")
		out = append(out, tableRows...)
		out = append(out, "</tbody></table>")
		tableRows = nil
	}
	flushBlocks := func() {
		flushParagraph()
		flushList()
		flushTable()
	}

	for _, line := range lines {
		trimmed := strings.TrimSpace(line)
		if trimmed == "" {
			flushBlocks()
			continue
		}
		if strings.HasPrefix(trimmed, "<p class=\"wiki-callout") {
			flushBlocks()
			out = append(out, trimmed)
			continue
		}
		if heading, level, ok := parseDokuHeading(trimmed); ok {
			flushBlocks()
			out = append(out, fmt.Sprintf("<h%d>%s</h%d>", level, renderInlineHTML(heading), level))
			continue
		}
		if strings.HasPrefix(trimmed, "  * ") || strings.HasPrefix(trimmed, "* ") || strings.HasPrefix(trimmed, "- ") {
			flushParagraph()
			flushTable()
			item := strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(strings.TrimPrefix(trimmed, "  * "), "* "), "- "))
			list = append(list, item)
			continue
		}
		if label, value, ok := parseFactLine(trimmed); ok {
			flushParagraph()
			flushList()
			tableRows = append(tableRows, "<tr><th>"+html.EscapeString(label)+"</th><td>"+renderInlineHTML(value)+"</td></tr>")
			continue
		}
		flushList()
		flushTable()
		for _, part := range strings.Split(trimmed, `\\`) {
			part = strings.TrimSpace(part)
			if part != "" {
				paragraph = append(paragraph, part)
			}
		}
	}
	flushBlocks()
	return strings.TrimSpace(strings.Join(out, "\n"))
}

func parseFactLine(line string) (string, string, bool) {
	if !strings.HasPrefix(line, "**") {
		return "", "", false
	}
	rest := strings.TrimPrefix(line, "**")
	label, value, ok := strings.Cut(rest, ":**")
	if !ok {
		return "", "", false
	}
	return strings.TrimSpace(label), strings.Trim(strings.TrimSpace(value), `\`), true
}

func renderInlineHTML(text string) string {
	var out strings.Builder
	for {
		start := strings.Index(text, "[[")
		if start < 0 {
			out.WriteString(html.EscapeString(text))
			break
		}
		end := strings.Index(text[start+2:], "]]")
		if end < 0 {
			out.WriteString(html.EscapeString(text))
			break
		}
		end += start + 2
		out.WriteString(html.EscapeString(text[:start]))
		out.WriteString(text[start : end+2])
		text = text[end+2:]
	}
	return out.String()
}

func splitDokuWikiNotes(text string) (string, string) {
	const delimiter = "===== Notes ====="
	before, after, ok := strings.Cut(text, delimiter)
	if !ok {
		return text, ""
	}
	return before, after
}

func dokuWikiToNodeBBMarkdown(text string) string {
	text = strings.ReplaceAll(strings.TrimSpace(text), "\r\n", "\n")
	if text == "" {
		return ""
	}

	var out []string
	inNotice := false

	closeNotice := func() {
		if inNotice {
			inNotice = false
			if len(out) > 0 && strings.TrimSpace(out[len(out)-1]) != "" {
				out = append(out, "")
			}
		}
	}
	appendBlank := func() {
		if len(out) == 0 || strings.TrimSpace(out[len(out)-1]) == "" {
			return
		}
		out = append(out, "")
	}

	for _, line := range strings.Split(text, "\n") {
		trimmed := strings.TrimSpace(line)
		if trimmed == "" {
			appendBlank()
			continue
		}
		if strings.HasPrefix(trimmed, "<WRAP") {
			appendBlank()
			inNotice = true
			continue
		}
		if trimmed == "</WRAP>" {
			closeNotice()
			continue
		}
		if heading, level, ok := parseDokuHeading(trimmed); ok {
			closeNotice()
			appendBlank()
			out = append(out, strings.Repeat("#", level)+" "+renderInlineNodeBBMarkdown(heading))
			out = append(out, "")
			continue
		}
		if strings.HasPrefix(trimmed, "  * ") || strings.HasPrefix(trimmed, "* ") || strings.HasPrefix(trimmed, "- ") {
			item := strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(strings.TrimPrefix(trimmed, "  * "), "* "), "- "))
			prefix := "- "
			if inNotice {
				prefix = "> - "
			}
			out = append(out, prefix+renderInlineNodeBBMarkdown(item))
			continue
		}

		parts := strings.Split(trimmed, `\\`)
		for _, part := range parts {
			part = strings.TrimSpace(part)
			if part != "" {
				prefix := ""
				if inNotice {
					prefix = "> "
				}
				out = append(out, prefix+renderInlineNodeBBMarkdown(part))
			}
		}
	}
	closeNotice()
	return strings.TrimSpace(strings.Join(out, "\n"))
}

func parseDokuHeading(line string) (string, int, bool) {
	if !strings.HasPrefix(line, "=") || !strings.HasSuffix(line, "=") {
		return "", 0, false
	}
	left := len(line) - len(strings.TrimLeft(line, "="))
	right := len(line) - len(strings.TrimRight(line, "="))
	if left == 0 || left != right {
		return "", 0, false
	}
	title := strings.TrimSpace(line[left : len(line)-right])
	if title == "" {
		return "", 0, false
	}
	level := 7 - left
	if level < 1 {
		level = 1
	}
	if level > 6 {
		level = 6
	}
	return title, level, true
}

func renderInlineNodeBBMarkdown(text string) string {
	return renderWikiLinks(text)
}

func renderWikiLinks(text string) string {
	var out strings.Builder
	for {
		start := strings.Index(text, "[[")
		if start < 0 {
			out.WriteString(text)
			break
		}
		end := strings.Index(text[start+2:], "]]")
		if end < 0 {
			out.WriteString(text)
			break
		}
		end += start + 2
		out.WriteString(text[:start])
		marker := text[start : end+2]
		out.WriteString(marker)
		text = text[end+2:]
	}
	return out.String()
}

func ensureTrailingNewline(text string) string {
	if strings.HasSuffix(text, "\n") {
		return text
	}
	return text + "\n"
}

func isToolLike(values ...string) bool {
	haystack := strings.ToLower(strings.Join(values, " "))
	return strings.Contains(haystack, "dm tool") || strings.Contains(haystack, "player tool") || strings.Contains(haystack, "activate item") || strings.Contains(haystack, "dm_tool") || strings.Contains(haystack, "player_tool") || strings.Contains(haystack, "activate_item")
}
