package app

import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"

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

type command struct {
	name        string
	description string
	run         func(context) error
}

type context struct {
	stdout   io.Writer
	stderr   io.Writer
	cwd      string
	args     []string
	logLevel logLevel
}

type spinner struct {
	mu      sync.Mutex
	on      bool
	text    string
	msg     []string
	painted bool
	writer  io.Writer
	enabled bool
}

func newSpinner() *spinner {
	return &spinner{
		msg: []string{"-", "\\", "|", "/"},
	}
}

var spin = newSpinner()

func (s *spinner) configure(writer io.Writer, enabled bool) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.writer = writer
	s.enabled = enabled
}

func (s *spinner) start(text string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if !s.enabled || s.writer == nil {
		s.on = false
		s.painted = false
		s.text = text
		return
	}
	if s.on {
		s.text = text
		return
	}
	s.on = true
	s.text = text
	go s.run()
}

func (s *spinner) stop() {
	s.mu.Lock()
	defer s.mu.Unlock()
	if !s.enabled || s.writer == nil {
		s.on = false
		s.painted = false
		return
	}
	s.on = false
	if s.painted {
		fmt.Fprint(s.writer, "\r\033[K")
		s.painted = false
	}
}

func (s *spinner) linebreak() {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.enabled && s.on && s.painted {
		fmt.Fprint(s.writer, "\r\033[K")
		s.painted = false
	}
}

func (s *spinner) update(text string) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.text = text
}

func (s *spinner) run() {
	i := 0
	for {
		s.mu.Lock()
		if !s.on {
			s.mu.Unlock()
			return
		}
		msg := s.msg[i%len(s.msg)]
		fmt.Fprintf(s.writer, "\r[%s] %s", msg, s.text)
		s.painted = true
		s.mu.Unlock()
		i++
		time.Sleep(100 * time.Millisecond)
	}
}

func isInteractiveTTY(w io.Writer) bool {
	file, ok := w.(*os.File)
	if !ok {
		return false
	}
	info, err := file.Stat()
	if err != nil {
		return false
	}
	return (info.Mode() & os.ModeCharDevice) != 0
}

func spinnerEnabledFor(w io.Writer, level logLevel) bool {
	mode := strings.TrimSpace(strings.ToLower(os.Getenv("SOW_TOOLS_TTY_MODE")))
	switch mode {
	case "plain", "off", "0", "false", "no":
		return false
	case "spinner", "on", "1", "true", "yes":
		return level == logLevelNormal && isInteractiveTTY(w)
	case "", "auto":
		return isInteractiveTTY(w) && strings.TrimSpace(os.Getenv("CI")) == "" && level == logLevelNormal
	default:
		return isInteractiveTTY(w) && strings.TrimSpace(os.Getenv("CI")) == "" && level == logLevelNormal
	}
}

var commands = []command{
	{
		name:        "build",
		description: "Build module, HAK, and configured top package outputs.",
		run:         runBuild,
	},
	{
		name:        "build-module",
		description: "Build only the configured module archive.",
		run:         runBuildModule,
	},
	{
		name:        "build-haks",
		description: "Build only configured HAK archives and manifest.",
		run:         runBuildHAKs,
	},
	{
		name:        "extract",
		description: "Extract built archives back into the configured source and asset trees.",
		run:         runExtract,
	},
	{
		name:        "validate",
		description: "Validate project layout, config, and source inventory.",
		run:         runValidate,
	},
	{
		name:        "config",
		description: "Inspect, explain, and validate the resolved project configuration.",
		run:         runConfig,
	},
	{
		name:        "music",
		description: "List, scan, build, and validate configured music datasets.",
		run:         runMusic,
	},
	{
		name:        "compare",
		description: "Compare current source content against the built archives.",
		run:         runCompare,
	},
	{
		name:        "apply-hak-manifest",
		description: "Apply a generated HAK manifest to the configured module source.",
		run:         runApplyHAKManifest,
	},
	{
		name:        "validate-topdata",
		description: "Validate topdata source layout and canonical JSON compatibility.",
		run:         runValidateTopData,
	},
	{
		name:        "build-topdata",
		description: "Build configured topdata outputs, then refresh the configured top package.",
		run:         runBuildTopData,
	},
	{
		name:        "build-top-package",
		description: "Build the configured top package from cached native topdata output and authored topdata assets.",
		run:         runBuildTopPackage,
	},
	{
		name:        "compare-topdata",
		description: "Rebuild topdata natively and compare the current build output against that fresh native result.",
		run:         runCompareTopData,
	},
	{
		name:        "convert-topdata",
		description: "Convert between 2DA and native topdata JSON/module formats.",
		run:         runConvertTopData,
	},
	{
		name:        "build-wiki",
		description: "Build wiki pages from the current topdata state.",
		run:         runBuildWiki,
	},
	{
		name:        "deploy-wiki",
		description: "Deploy generated wiki pages to NodeBB.",
		run:         runDeployWiki,
	},
	{
		name:        "build-changelog",
		description: "Build a release changelog from tagged pushes and write it to stdout or a file.",
		run:         runBuildChangelog,
	},
}

func Run(args []string) (int, error) {
	ctx, err := newContext()
	if err != nil {
		return 1, err
	}

	if len(args) == 0 {
		printUsage(ctx.stdout)
		return 0, nil
	}

	switch args[0] {
	case "-h", "--help", "help":
		printUsage(ctx.stdout)
		return 0, nil
	default:
		cmdArgs, level := parseGlobalFlags(args[1:])
		ctx.logLevel = level
		ctx.args = append([]string{args[0]}, cmdArgs...)
		for _, cmd := range commands {
			if cmd.name == args[0] {
				if err := cmd.run(ctx); err != nil {
					return 1, err
				}
				return 0, nil
			}
		}
	}

	return 1, fmt.Errorf("unknown command %q", args[0])
}

func parseGlobalFlags(args []string) ([]string, logLevel) {
	level := logLevelNormal
	var filtered []string
	for _, arg := range args {
		switch arg {
		case "--quiet":
			level = logLevelQuiet
		case "--verbose":
			level = logLevelVerbose
		case "--debug":
			level = logLevelDebug
		default:
			filtered = append(filtered, arg)
		}
	}
	return filtered, level
}

func newContext() (context, error) {
	cwd, err := os.Getwd()
	if err != nil {
		return context{}, fmt.Errorf("resolve working directory: %w", err)
	}

	return context{
		stdout: os.Stdout,
		stderr: os.Stderr,
		cwd:    cwd,
		args:   os.Args[1:],
	}, nil
}

func runBuild(ctx context) error {
	p, err := loadProject(ctx)
	if err != nil {
		return err
	}
	preflight, err := runBuildModulePreflight(ctx, p, nil)
	if err != nil {
		return err
	}
	console := newProjectConsole(ctx, p, "build")

	result, err := pipeline.Build(p)
	if err != nil {
		return err
	}

	console.emitBuildResult(result, preflight)
	return nil
}

func runBuildModule(ctx context) error {
	p, err := loadProject(ctx)
	if err != nil {
		return err
	}
	console := newProjectConsole(ctx, p, "build-module")
	spin.configure(ctx.stderr, console.spinnerEnabled)
	spin.start("Build Module: starting")
	defer spin.stop()

	progress := func(message string) {
		console.progress(message)
	}
	preflight, err := runBuildModulePreflight(ctx, p, progress)
	if err != nil {
		return err
	}

	result, err := pipeline.BuildModuleWithProgress(p, func(message string) {
		progress(message)
	})
	if err != nil {
		return err
	}

	console.emitBuildModuleResult(result, preflight)
	return nil
}

type buildModulePreflightResult struct {
	ManifestStatus          string
	ManifestPath            string
	ScriptCompilationStatus string
}

func runBuildModulePreflight(ctx context, p *project.Project, progress func(string)) (buildModulePreflightResult, error) {
	result := buildModulePreflightResult{}
	if progress == nil {
		progress = func(string) {}
	}
	manifestStatus, manifestPath, err := refreshBuildModuleManifest(ctx, p, progress)
	if err != nil {
		return result, err
	}
	result.ManifestStatus = manifestStatus
	result.ManifestPath = manifestPath
	scriptStatus, err := prepareBuildModuleCompiler(ctx, p, progress)
	if err != nil {
		return result, err
	}
	result.ScriptCompilationStatus = scriptStatus
	return result, nil
}

func refreshBuildModuleManifest(ctx context, p *project.Project, progress func(string)) (string, string, error) {
	manifestPath := p.HAKManifestPath()
	if override := strings.TrimSpace(os.Getenv("SOW_HAK_MANIFEST_PATH")); override != "" {
		manifestPath = override
		if !filepath.IsAbs(manifestPath) {
			manifestPath = filepath.Join(p.Root, manifestPath)
		}
	}

	if envEnabled("SOW_MODULE_SKIP_HAK_MANIFEST_REFRESH") {
		if _, err := os.Stat(manifestPath); err != nil {
			return "", "", fmt.Errorf("SOW_MODULE_SKIP_HAK_MANIFEST_REFRESH is set, but %s does not exist", manifestPath)
		}
		progress(fmt.Sprintf("Using existing hak manifest at %s; skipping published sow-assets refresh.", relPathFromRoot(p.Root, manifestPath)))
		return "reused", manifestPath, nil
	}

	progress("Refreshing hak list from the latest published sow-assets manifest...")
	if err := runProjectScript(ctx, p, []string{"scripts", "fetch-hak-manifest"}, manifestPath); err != nil {
		return "", "", err
	}
	if _, err := pipeline.ApplyHAKManifest(p, manifestPath); err != nil {
		return "", "", err
	}
	return "refreshed", manifestPath, nil
}

func prepareBuildModuleCompiler(ctx context, p *project.Project, progress func(string)) (string, error) {
	if envEnabled("SOW_MODULE_SKIP_NSS_COMPILATION") {
		progress("SOW_MODULE_SKIP_NSS_COMPILATION is set; skipping NWScript compiler resolution and NSS compilation for this run.")
		return "skipped", nil
	}
	if !projectHasScriptSources(p) {
		return "not-needed", nil
	}

	compilerPathEnv := p.ScriptCompilerPathEnv()
	explicitCompiler := strings.TrimSpace(os.Getenv(compilerPathEnv))
	if explicitCompiler != "" {
		if scriptCompilerRunnable(explicitCompiler) {
			return "enabled", nil
		}
		if fileExists(explicitCompiler) {
			return disableBuildModuleCompiler(compilerPathEnv, progress, fmt.Sprintf("Configured NWScript compiler is present but cannot start: %s", explicitCompiler))
		}
	}

	if _, ok := findUsableBuildModuleCompiler(p); ok {
		return "enabled", nil
	}

	installScript := projectScriptPath(p.Root, "scripts", "install-script-compiler")
	if !fileExists(installScript) {
		return disableBuildModuleCompiler(compilerPathEnv, progress, "NWScript compiler not found locally and automatic installer script is unavailable.")
	}

	progress("NWScript compiler not found locally. Installing it now...")
	if err := runProjectScript(ctx, p, []string{"scripts", "install-script-compiler"}); err != nil {
		return disableBuildModuleCompiler(compilerPathEnv, progress, "Automatic NWScript compiler install failed.")
	}
	if _, ok := findUsableBuildModuleCompiler(p); ok {
		return "installed", nil
	}

	return disableBuildModuleCompiler(compilerPathEnv, progress, "Installed NWScript compiler but it still cannot start.")
}

func disableBuildModuleCompiler(compilerPathEnv string, progress func(string), reason string) (string, error) {
	if err := os.Setenv("SOW_MODULE_SKIP_NSS_COMPILATION", "1"); err != nil {
		return "", fmt.Errorf("set SOW_MODULE_SKIP_NSS_COMPILATION: %w", err)
	}
	progress("Skipping NWScript compilation for this build. " + reason)
	if compilerPathEnv != "" {
		_ = os.Unsetenv(compilerPathEnv)
	}
	return "skipped", nil
}

func projectHasScriptSources(p *project.Project) bool {
	for _, rel := range p.Inventory.ScriptFiles {
		if strings.EqualFold(filepath.Ext(rel), ".nss") {
			return true
		}
	}
	return false
}

func findUsableBuildModuleCompiler(p *project.Project) (string, bool) {
	candidates := make([]string, 0, 2+len(p.ScriptCompilerSearchPaths()))
	if configured := p.ScriptCompilerPath(); configured != "" {
		candidates = append(candidates, configured)
	}
	if configured := strings.TrimSpace(os.Getenv(p.ScriptCompilerPathEnv())); configured != "" {
		candidates = append(candidates, configured)
	}
	candidates = append(candidates, p.ScriptCompilerSearchPaths()...)

	for _, candidate := range candidates {
		if compiler, ok := resolveRunnableCompilerCandidate(candidate); ok {
			return compiler, true
		}
	}
	return "", false
}

func resolveRunnableCompilerCandidate(candidate string) (string, bool) {
	if candidate == "" {
		return "", false
	}
	if binary, ok := strings.CutPrefix(candidate, "PATH:"); ok {
		compiler, err := exec.LookPath(binary)
		if err != nil {
			return "", false
		}
		if scriptCompilerRunnable(compiler) {
			return compiler, true
		}
		return "", false
	}
	if !fileExists(candidate) {
		return "", false
	}
	if scriptCompilerRunnable(candidate) {
		return candidate, true
	}
	return "", false
}

func scriptCompilerRunnable(path string) bool {
	if !fileExists(path) {
		return false
	}
	cmd := exec.Command(path, "--help")
	cmd.Stdout = io.Discard
	cmd.Stderr = io.Discard
	return cmd.Run() == nil
}

func runProjectScript(ctx context, p *project.Project, scriptParts []string, args ...string) error {
	if len(scriptParts) == 0 {
		return errors.New("project script path is required")
	}
	scriptPath := projectScriptPath(p.Root, scriptParts...)
	if !fileExists(scriptPath) {
		return fmt.Errorf("required project script is missing: %s", scriptPath)
	}

	var cmd *exec.Cmd
	switch filepath.Ext(scriptPath) {
	case ".ps1":
		powershell := "powershell"
		if runtime.GOOS != "windows" {
			powershell = "pwsh"
		}
		commandArgs := []string{"-NoProfile", "-File", scriptPath}
		commandArgs = append(commandArgs, args...)
		cmd = exec.Command(powershell, commandArgs...)
	default:
		cmd = exec.Command(scriptPath, args...)
	}
	cmd.Dir = p.Root
	cmd.Stdout = ctx.stderr
	cmd.Stderr = ctx.stderr
	return cmd.Run()
}

func projectScriptPath(root string, pathParts ...string) string {
	parts := append([]string{root}, pathParts...)
	base := filepath.Join(parts...)
	if runtime.GOOS == "windows" {
		if strings.HasSuffix(base, ".ps1") || strings.HasSuffix(base, ".sh") {
			return base
		}
		return base + ".ps1"
	}
	if strings.HasSuffix(base, ".ps1") || strings.HasSuffix(base, ".sh") {
		return base
	}
	return base + ".sh"
}

func envEnabled(name string) bool {
	switch strings.ToLower(strings.TrimSpace(os.Getenv(name))) {
	case "1", "true", "yes", "on":
		return true
	default:
		return false
	}
}

func fileExists(path string) bool {
	info, err := os.Stat(path)
	return err == nil && !info.IsDir()
}

func relPathFromRoot(root, path string) string {
	rel, err := filepath.Rel(root, path)
	if err != nil {
		return filepath.ToSlash(path)
	}
	return filepath.ToSlash(rel)
}

type buildHAKOptions struct {
	filteredHAKs     []string
	filteredArchives []string
	sourceManifest   string
	musicDatasets    []string
	skipMusic        bool
	planOnly         bool
	logLevel         logLevel
}

type logLevel int

const (
	logLevelNormal logLevel = iota
	logLevelQuiet
	logLevelVerbose
	logLevelDebug
)

type projectConsole struct {
	stdout         io.Writer
	projectRoot    string
	projectName    string
	commandName    string
	commandLabel   string
	level          logLevel
	spinnerEnabled bool
}

func newProjectConsole(ctx context, p *project.Project, commandName string) *projectConsole {
	return &projectConsole{
		stdout:         ctx.stdout,
		projectRoot:    p.Root,
		projectName:    p.Config.Module.Name,
		commandName:    commandName,
		commandLabel:   projectCommandLabel(commandName),
		level:          ctx.logLevel,
		spinnerEnabled: spinnerEnabledFor(ctx.stderr, ctx.logLevel),
	}
}

func projectCommandLabel(commandName string) string {
	switch commandName {
	case "build":
		return "Build"
	case "build-module":
		return "Build Module"
	case "extract":
		return "Extract"
	case "validate":
		return "Validate"
	case "compare":
		return "Compare"
	case "apply-hak-manifest":
		return "Apply HAK Manifest"
	default:
		return commandName
	}
}

func (c *projectConsole) progress(message string) {
	if phase := c.phaseLabel(message); phase != "" {
		spin.update(c.commandLabel + ": " + phase)
	}
	if c.level != logLevelDebug {
		return
	}
	spin.linebreak()
	fmt.Fprintf(c.stdout, "[debug] %s\n", message)
}

func (c *projectConsole) phaseLabel(message string) string {
	switch {
	case strings.HasPrefix(message, "Refreshing hak list from the latest published sow-assets manifest"):
		return "refreshing HAK manifest"
	case strings.HasPrefix(message, "Using existing hak manifest at "):
		return "reusing HAK manifest"
	case strings.HasPrefix(message, "NWScript compiler not found locally. Installing it now"):
		return "installing script compiler"
	case strings.HasPrefix(message, "Skipping NWScript compilation for this build."):
		return "skipping script compilation"
	case strings.HasPrefix(message, "Validating project"):
		return "validating project"
	case strings.HasPrefix(message, "Resolving module HAK order"):
		return "resolving HAK order"
	case strings.HasPrefix(message, "Collecting module resources"):
		return "collecting module resources"
	case strings.HasPrefix(message, "Writing module archive"):
		return "writing module archive"
	default:
		return ""
	}
}

func (c *projectConsole) emitBuildResult(result pipeline.BuildResult, preflight buildModulePreflightResult) {
	spin.linebreak()
	fmt.Fprintln(c.stdout, "Build ----------")
	fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
	fmt.Fprintf(c.stdout, "module: %s\n", c.relPath(result.ModulePath))
	fmt.Fprintf(c.stdout, "resources: %d\n", result.Resources)
	if preflight.ManifestStatus != "" {
		fmt.Fprintf(c.stdout, "hak manifest: %s", preflight.ManifestStatus)
		if preflight.ManifestPath != "" {
			fmt.Fprintf(c.stdout, " (%s)", c.relPath(preflight.ManifestPath))
		}
		fmt.Fprintln(c.stdout)
	}
	if preflight.ScriptCompilationStatus != "" && preflight.ScriptCompilationStatus != "not-needed" {
		fmt.Fprintf(c.stdout, "script compilation: %s\n", preflight.ScriptCompilationStatus)
	}
	if result.TopPackageHAK != "" {
		fmt.Fprintf(c.stdout, "top package hak: %s\n", c.relPath(result.TopPackageHAK))
		fmt.Fprintf(c.stdout, "top package tlk: %s\n", c.relPath(result.TopPackageTLK))
	}
	if len(result.HAKPaths) > 0 {
		fmt.Fprintf(c.stdout, "hak archives: %d\n", len(result.HAKPaths))
		fmt.Fprintf(c.stdout, "hak assets: %d\n", result.HAKAssets)
		fmt.Fprintf(c.stdout, "hak manifest: %s\n", c.relPath(result.Manifest))
	}
}

func (c *projectConsole) emitBuildModuleResult(result pipeline.BuildResult, preflight buildModulePreflightResult) {
	spin.linebreak()
	fmt.Fprintln(c.stdout, "Build Module ----------")
	fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
	fmt.Fprintf(c.stdout, "module: %s\n", c.relPath(result.ModulePath))
	fmt.Fprintf(c.stdout, "resources: %d\n", result.Resources)
	if preflight.ManifestStatus != "" {
		fmt.Fprintf(c.stdout, "hak manifest: %s", preflight.ManifestStatus)
		if preflight.ManifestPath != "" {
			fmt.Fprintf(c.stdout, " (%s)", c.relPath(preflight.ManifestPath))
		}
		fmt.Fprintln(c.stdout)
	}
	if preflight.ScriptCompilationStatus != "" && preflight.ScriptCompilationStatus != "not-needed" {
		fmt.Fprintf(c.stdout, "script compilation: %s\n", preflight.ScriptCompilationStatus)
	}
}

func (c *projectConsole) emitExtractResult(result pipeline.ExtractResult) {
	spin.linebreak()
	fmt.Fprintln(c.stdout, "Extract ----------")
	fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
	fmt.Fprintf(c.stdout, "module: %s\n", c.relPath(result.ModulePath))
	if len(result.HAKPaths) > 0 {
		fmt.Fprintf(c.stdout, "hak archives: %d\n", len(result.HAKPaths))
	}
	if len(result.DeletedArchivePaths) > 0 {
		fmt.Fprintf(c.stdout, "deleted archives: %d\n", len(result.DeletedArchivePaths))
	}
	fmt.Fprintf(c.stdout, "written: %d\n", result.Written)
	fmt.Fprintf(c.stdout, "overwritten: %d\n", result.Overwritten)
	fmt.Fprintf(c.stdout, "removed: %d\n", result.Removed)
	fmt.Fprintf(c.stdout, "skipped: %d\n", result.Skipped)
}

func (c *projectConsole) emitValidationResult(report validator.Report, root string, inventoryReport project.InventoryReport) {
	spin.linebreak()
	fmt.Fprintln(c.stdout, "Validate ----------")
	fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
	fmt.Fprintf(c.stdout, "root: %s\n", c.relPath(root))
	fmt.Fprintf(c.stdout, "source files: %d\n", inventoryReport.SourceFiles)
	fmt.Fprintf(c.stdout, "script files: %d\n", inventoryReport.ScriptFiles)
	fmt.Fprintf(c.stdout, "asset files: %d\n", inventoryReport.AssetFiles)
	fmt.Fprintf(c.stdout, "module file types: %s\n", strings.Join(inventoryReport.Extensions, ", "))
	if warnings := report.WarningCount(); warnings > 0 {
		fmt.Fprintf(c.stdout, "warnings: %d\n", warnings)
	}
	fmt.Fprintln(c.stdout, "validation: ok")
}

func (c *projectConsole) emitCompareResult(result pipeline.CompareResult) {
	spin.linebreak()
	fmt.Fprintln(c.stdout, "Compare ----------")
	fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
	fmt.Fprintf(c.stdout, "module: %s\n", c.relPath(result.ModulePath))
	if len(result.HAKPaths) > 0 {
		fmt.Fprintf(c.stdout, "hak archives: %d\n", len(result.HAKPaths))
	}
	fmt.Fprintf(c.stdout, "checked resources: %d\n", result.Checked)
	fmt.Fprintln(c.stdout, "compare: ok")
}

func (c *projectConsole) emitApplyManifestResult(result pipeline.ApplyManifestResult) {
	spin.linebreak()
	fmt.Fprintln(c.stdout, "Apply HAK Manifest ----------")
	fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
	fmt.Fprintf(c.stdout, "manifest: %s\n", c.relPath(result.ManifestPath))
	fmt.Fprintf(c.stdout, "module source: %s\n", c.relPath(result.ModuleSource))
	fmt.Fprintf(c.stdout, "hak entries: %d\n", result.HAKCount)
}

func (c *projectConsole) relPath(path string) string {
	if path == "" {
		return path
	}
	rel, err := filepath.Rel(c.projectRoot, path)
	if err != nil {
		return filepath.ToSlash(path)
	}
	return filepath.ToSlash(rel)
}

type buildHAKConsole struct {
	stdout         io.Writer
	projectRoot    string
	projectName    string
	planOnly       bool
	level          logLevel
	spinnerEnabled bool
}

func newBuildHAKConsole(ctx context, p *project.Project, opts buildHAKOptions) *buildHAKConsole {
	return &buildHAKConsole{
		stdout:         ctx.stdout,
		projectRoot:    p.Root,
		projectName:    p.Config.Module.Name,
		planOnly:       opts.planOnly,
		level:          opts.logLevel,
		spinnerEnabled: spinnerEnabledFor(ctx.stderr, opts.logLevel),
	}
}

func (c *buildHAKConsole) progress(message string) {
	if phase := c.phaseLabel(message); phase != "" {
		spin.update("Build HAKs: " + phase)
	}
	if c.level != logLevelDebug {
		return
	}
	spin.linebreak()
	fmt.Fprintf(c.stdout, "[debug] %s\n", message)
}

func (c *buildHAKConsole) phaseLabel(message string) string {
	switch {
	case strings.HasPrefix(message, "Validating project"):
		return "validating project"
	case strings.HasPrefix(message, "Collecting asset resources"):
		return "collecting asset resources"
	case strings.HasPrefix(message, "Planning HAK chunks"):
		return "planning HAK chunks"
	case strings.HasPrefix(message, "Resolving module HAK order"):
		return "resolving module HAK order"
	case strings.HasPrefix(message, "Reusing unchanged generated HAKs"):
		return "reusing unchanged HAKs"
	case strings.HasPrefix(message, "Reusing HAK "), strings.HasPrefix(message, "Writing HAK "):
		return "writing HAK archives"
	case strings.HasPrefix(message, "Cleaning stale generated HAKs"):
		return "cleaning stale generated HAKs"
	case strings.HasPrefix(message, "Cleaning previous generated HAKs"):
		return "cleaning previous generated HAKs"
	case strings.HasPrefix(message, "Writing HAK manifest"):
		return "writing HAK manifest"
	case strings.HasPrefix(message, "Writing autogen manifest "):
		return "writing autogen manifests"
	default:
		return ""
	}
}

func (c *buildHAKConsole) emitResult(result pipeline.BuildResult) {
	spin.linebreak()
	fmt.Fprintln(c.stdout, "Build HAKs ----------")
	if c.level != logLevelQuiet {
		c.emitCreditsSummary(result)
		c.emitCoreSteps(result)
	}
	c.emitFinalSummary(result)
}

func (c *buildHAKConsole) emitCreditsSummary(result pipeline.BuildResult) {
	summary := result.CreditsSummary
	if len(summary.ArtifactPaths) == 0 {
		return
	}
	fmt.Fprintln(c.stdout, "Refreshing credits cache")
	if len(summary.ScannedDirs) > 0 {
		fmt.Fprintf(c.stdout, "  scanned: %s\n", strings.Join(summary.ScannedDirs, ", "))
	}
	fmt.Fprintf(c.stdout, "  tracks: %d\n", summary.TrackCount)
	if len(summary.GeneratedPaths) > 0 {
		for _, path := range summary.GeneratedPaths {
			fmt.Fprintf(c.stdout, "  output: %s\n", c.relPath(path))
		}
	}
	switch {
	case summary.ChangedFiles == 0:
		fmt.Fprintln(c.stdout, "  status: unchanged")
	default:
		fmt.Fprintf(c.stdout, "  updated: %d artifact(s)\n", summary.ChangedFiles)
	}
	if summary.TrackCount > 0 {
		if c.level >= logLevelVerbose {
			fmt.Fprintln(c.stdout, "  mappings:")
			for _, mapping := range summary.Mappings {
				fmt.Fprintf(c.stdout, "    %s -> %s\n", mapping.Source, mapping.Output)
			}
		} else {
			fmt.Fprintf(c.stdout, "  mapped: %d music file(s); use --verbose to list mappings\n", summary.TrackCount)
		}
	}
	fmt.Fprintln(c.stdout)
}

func (c *buildHAKConsole) emitCoreSteps(result pipeline.BuildResult) {
	fmt.Fprintln(c.stdout, "Validating project")
	fmt.Fprintln(c.stdout, "Collecting asset resources")
	if len(result.AutogenManifestPaths) > 0 {
		fmt.Fprintln(c.stdout, "Writing autogen manifests")
		for _, path := range result.AutogenManifestPaths {
			fmt.Fprintf(c.stdout, "  %s\n", filepath.Base(path))
		}
		fmt.Fprintln(c.stdout)
	}
	fmt.Fprintln(c.stdout, "Planning HAK chunks")
	fmt.Fprintln(c.stdout, "Resolving module HAK order")
	if !c.planOnly {
		fmt.Fprintln(c.stdout, "Reusing unchanged HAKs")
		fmt.Fprintf(c.stdout, "  reused: %d / %d\n", result.HAKSummary.Reused, result.HAKSummary.Total)
		fmt.Fprintf(c.stdout, "  written: %d / %d\n", result.HAKSummary.Written, result.HAKSummary.Total)
		fmt.Fprintf(c.stdout, "  assets: %s\n", formatCount(result.HAKAssets))
		if c.level >= logLevelVerbose {
			for _, action := range result.HAKSummary.Actions {
				verb := "wrote"
				if action.Reused {
					verb = "reused"
				}
				fmt.Fprintf(c.stdout, "  %s: %s (%s assets)\n", verb, action.Name, formatCount(action.AssetCount))
			}
		}
		fmt.Fprintln(c.stdout)
		fmt.Fprintln(c.stdout, "Cleaning stale generated HAKs")
	} else {
		fmt.Fprintf(c.stdout, "Planned HAK archives: %d\n", result.HAKSummary.Total)
	}
	fmt.Fprintln(c.stdout, "Writing HAK manifest")
	fmt.Fprintln(c.stdout)
}

func (c *buildHAKConsole) emitFinalSummary(result pipeline.BuildResult) {
	fmt.Fprintln(c.stdout, "Summary ----------")
	fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
	if c.planOnly {
		fmt.Fprintf(c.stdout, "planned archives: %d\n", result.HAKSummary.Total)
	} else {
		fmt.Fprintf(c.stdout, "hak archives: %d\n", len(result.HAKPaths))
		fmt.Fprintf(c.stdout, "hak assets: %s\n", formatCount(result.HAKAssets))
	}
	if result.Manifest != "" {
		fmt.Fprintf(c.stdout, "manifest: %s\n", c.relPath(result.Manifest))
	}
	if len(result.CreditsArtifactPaths) > 0 {
		fmt.Fprintf(c.stdout, "credits artifacts: %d\n", len(result.CreditsArtifactPaths))
	}
	if len(result.AutogenManifestPaths) > 0 {
		fmt.Fprintf(c.stdout, "autogen manifests: %d\n", len(result.AutogenManifestPaths))
	}
	status := "complete"
	if c.planOnly {
		status = "planned"
	}
	fmt.Fprintf(c.stdout, "status: %s\n", status)
}

func (c *buildHAKConsole) relPath(path string) string {
	if path == "" {
		return path
	}
	rel, err := filepath.Rel(c.projectRoot, path)
	if err != nil {
		return filepath.ToSlash(path)
	}
	return filepath.ToSlash(rel)
}

type topdataConsole struct {
	stdout         io.Writer
	projectRoot    string
	projectName    string
	commandName    string
	commandLabel   string
	level          logLevel
	spinnerEnabled bool
}

func newTopdataConsole(ctx context, p *project.Project, commandName string) *topdataConsole {
	return &topdataConsole{
		stdout:         ctx.stdout,
		projectRoot:    p.Root,
		projectName:    p.Config.Module.Name,
		commandName:    commandName,
		commandLabel:   topdataCommandLabel(commandName),
		level:          ctx.logLevel,
		spinnerEnabled: spinnerEnabledFor(ctx.stderr, ctx.logLevel),
	}
}

func topdataCommandLabel(commandName string) string {
	switch commandName {
	case "validate-topdata":
		return "Validate Topdata"
	case "build-topdata":
		return "Build Topdata"
	case "build-top-package":
		return "Build Top Package"
	case "compare-topdata":
		return "Compare Topdata"
	case "build-wiki":
		return "Build Wiki"
	case "deploy-wiki":
		return "Deploy Wiki"
	default:
		return commandName
	}
}

func (c *topdataConsole) progress(message string) {
	if phase := c.phaseLabel(message); phase != "" {
		spin.update(c.commandLabel + ": " + phase)
	}
	if c.level != logLevelDebug {
		return
	}
	spin.linebreak()
	fmt.Fprintf(c.stdout, "[debug] %s\n", message)
}

func (c *topdataConsole) phaseLabel(message string) string {
	switch {
	case strings.HasPrefix(message, "Building native topdata outputs"):
		return "building native outputs"
	case strings.HasPrefix(message, "Topdata outputs are current"):
		return "checking cached outputs"
	case strings.HasPrefix(message, "Compiled topdata outputs are current"):
		return "refreshing top package"
	case strings.HasPrefix(message, "Packaging compiled topdata resources into "):
		return "packaging HAK"
	case strings.HasPrefix(message, "Preparing compiled TLK release output "):
		return "packaging TLK"
	case strings.HasPrefix(message, "Building native wiki pages"):
		return "building wiki pages"
	case strings.HasPrefix(message, "NodeBB wiki plan: "):
		return "planning wiki deploy"
	case strings.HasPrefix(message, "Collecting "):
		return "collecting pages"
	default:
		return ""
	}
}

func (c *topdataConsole) emitValidationResult(report topdata.ValidationReport, topdataRoot string) {
	spin.linebreak()
	fmt.Fprintln(c.stdout, "Validate Topdata ----------")
	fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
	fmt.Fprintf(c.stdout, "topdata root: %s\n", c.relPath(topdataRoot))
	fmt.Fprintf(c.stdout, "topdata files: %d\n", report.Files)
	fmt.Fprintf(c.stdout, "data files: %d\n", report.DataFiles)
	fmt.Fprintf(c.stdout, "tlk files: %d\n", report.TLKFiles)
	if warnings := report.WarningCount(); warnings > 0 {
		fmt.Fprintf(c.stdout, "warnings: %d\n", warnings)
	}
	fmt.Fprintln(c.stdout, "topdata validation: ok")
}

func (c *topdataConsole) emitPackageResult(result topdata.PackageResult) {
	spin.linebreak()
	fmt.Fprintf(c.stdout, "%s ----------\n", c.commandLabel)
	fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
	fmt.Fprintf(c.stdout, "mode: %s\n", result.Mode)
	fmt.Fprintf(c.stdout, "topdata 2da output: %s\n", c.relPath(result.Output2DADir))
	fmt.Fprintf(c.stdout, "topdata tlk output: %s\n", c.relPath(result.OutputTLKDir))
	fmt.Fprintf(c.stdout, "top package hak: %s\n", c.relPath(result.OutputHAKPath))
	fmt.Fprintf(c.stdout, "top package tlk: %s\n", c.relPath(result.OutputTLKPath))
	fmt.Fprintf(c.stdout, "2da files: %d\n", result.Files2DA)
	fmt.Fprintf(c.stdout, "tlk files: %d\n", result.FilesTLK)
	fmt.Fprintf(c.stdout, "top package resources: %d\n", result.HAKResources)
	fmt.Fprintf(c.stdout, "top package assets: %d\n", result.AssetFiles)
	if result.WikiOutputDir != "" {
		fmt.Fprintf(c.stdout, "wiki output: %s\n", c.relPath(result.WikiOutputDir))
		fmt.Fprintf(c.stdout, "wiki pages: %d\n", result.WikiPages)
		fmt.Fprintf(c.stdout, "wiki status: %s\n", result.WikiStatus)
	}
}

func (c *topdataConsole) emitCompareResult(result topdata.CompareResult) {
	spin.linebreak()
	fmt.Fprintln(c.stdout, "Compare Topdata ----------")
	fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
	fmt.Fprintf(c.stdout, "mode: %s\n", result.Mode)
	fmt.Fprintf(c.stdout, "checked 2da files: %d\n", result.Compared2DA)
	fmt.Fprintf(c.stdout, "checked tlk files: %d\n", result.ComparedTLK)
	fmt.Fprintf(c.stdout, "native-pass: %d\n", result.NativePass)
	fmt.Fprintf(c.stdout, "not-yet-canonical: %d\n", result.NotYetCanonical)
	fmt.Fprintf(c.stdout, "native-mismatch: %d\n", result.NativeMismatch)
	fmt.Fprintln(c.stdout, "topdata compare: ok")
}

func (c *topdataConsole) emitWikiBuildResult(outputDir string, pageCount int, status string) {
	spin.linebreak()
	fmt.Fprintln(c.stdout, "Build Wiki ----------")
	fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
	fmt.Fprintf(c.stdout, "wiki output: %s\n", c.relPath(outputDir))
	fmt.Fprintf(c.stdout, "wiki pages: %d\n", pageCount)
	fmt.Fprintf(c.stdout, "wiki status: %s\n", status)
}

func (c *topdataConsole) emitWikiDeployResult(localPages, created, updated, skipped, stale, archived, drifted int, manifest string) {
	spin.linebreak()
	fmt.Fprintln(c.stdout, "Deploy Wiki ----------")
	fmt.Fprintf(c.stdout, "project: %s\n", c.projectName)
	fmt.Fprintf(c.stdout, "local pages: %d\n", localPages)
	fmt.Fprintf(c.stdout, "created: %d\n", created)
	fmt.Fprintf(c.stdout, "updated: %d\n", updated)
	fmt.Fprintf(c.stdout, "skipped: %d\n", skipped)
	fmt.Fprintf(c.stdout, "stale: %d\n", stale)
	fmt.Fprintf(c.stdout, "archived: %d\n", archived)
	fmt.Fprintf(c.stdout, "drifted: %d\n", drifted)
	fmt.Fprintf(c.stdout, "manifest: %s\n", c.relPath(manifest))
}

func (c *topdataConsole) relPath(path string) string {
	if path == "" {
		return path
	}
	rel, err := filepath.Rel(c.projectRoot, path)
	if err != nil {
		return filepath.ToSlash(path)
	}
	return filepath.ToSlash(rel)
}

func formatCount(value int) string {
	raw := strconv.Itoa(value)
	if value < 1000 && value > -1000 {
		return raw
	}
	sign := ""
	if strings.HasPrefix(raw, "-") {
		sign = "-"
		raw = strings.TrimPrefix(raw, "-")
	}
	parts := make([]string, 0, (len(raw)+2)/3)
	for len(raw) > 3 {
		parts = append(parts, raw[len(raw)-3:])
		raw = raw[:len(raw)-3]
	}
	parts = append(parts, raw)
	slices := make([]string, 0, len(parts))
	for index := len(parts) - 1; index >= 0; index-- {
		slices = append(slices, parts[index])
	}
	return sign + strings.Join(slices, ",")
}

func runBuildHAKs(ctx context) error {
	p, err := loadProject(ctx)
	if err != nil {
		return err
	}

	opts, err := parseBuildHAKArgs(ctx.args[1:])
	if err != nil {
		return err
	}
	if ctx.logLevel > opts.logLevel {
		opts.logLevel = ctx.logLevel
	}
	p, err = p.CloneWithHAKNames(opts.filteredHAKs)
	if err != nil {
		return err
	}

	console := newBuildHAKConsole(ctx, p, opts)
	spin.configure(ctx.stderr, console.spinnerEnabled)
	spin.start("Build HAKs: starting")
	defer spin.stop()
	var result pipeline.BuildResult
	pipelineOpts := pipeline.BuildHAKOptions{
		Progress:           console.progress,
		ArchiveNames:       opts.filteredArchives,
		SourceManifestPath: opts.sourceManifest,
		SkipMusic:          opts.skipMusic,
		MusicDatasetIDs:    opts.musicDatasets,
	}
	if opts.planOnly {
		result, err = pipeline.PlanHAKsWithOptions(p, pipelineOpts)
	} else {
		result, err = pipeline.BuildHAKsWithOptions(p, pipelineOpts)
	}
	if err != nil {
		return err
	}

	console.emitResult(result)
	return nil
}

func parseBuildHAKArgs(args []string) (buildHAKOptions, error) {
	opts := buildHAKOptions{}
	if len(args) == 0 {
		return opts, nil
	}

	for index := 0; index < len(args); index++ {
		arg := args[index]
		switch arg {
		case "-h", "--help":
			return opts, errors.New("usage: build-haks [--hak <hak-name> ...] [--archive <archive-name> ...] [--source-manifest <path>] [--plan-only] [--skip-music] [--music-dataset <id> ...] [--quiet|--verbose|--debug]")
		case "--hak":
			index++
			if index >= len(args) {
				return opts, errors.New("--hak requires a value")
			}
			opts.filteredHAKs = append(opts.filteredHAKs, args[index])
		case "--archive":
			index++
			if index >= len(args) {
				return opts, errors.New("--archive requires a value")
			}
			opts.filteredArchives = append(opts.filteredArchives, args[index])
		case "--plan-only":
			opts.planOnly = true
		case "--skip-music":
			opts.skipMusic = true
		case "--music-dataset":
			index++
			if index >= len(args) {
				return opts, errors.New("--music-dataset requires a value")
			}
			opts.musicDatasets = append(opts.musicDatasets, args[index])
		case "--quiet":
			opts.logLevel = logLevelQuiet
		case "--verbose":
			opts.logLevel = logLevelVerbose
		case "--debug":
			opts.logLevel = logLevelDebug
		case "--source-manifest":
			index++
			if index >= len(args) {
				return opts, errors.New("--source-manifest requires a value")
			}
			opts.sourceManifest = args[index]
		default:
			if value, ok, err := requireInlineFlagValue(arg, "--hak"); ok || err != nil {
				if err != nil {
					return opts, err
				}
				opts.filteredHAKs = append(opts.filteredHAKs, value)
				continue
			}
			if value, ok, err := requireInlineFlagValue(arg, "--archive"); ok || err != nil {
				if err != nil {
					return opts, err
				}
				opts.filteredArchives = append(opts.filteredArchives, value)
				continue
			}
			if value, ok, err := requireInlineFlagValue(arg, "--source-manifest"); ok || err != nil {
				if err != nil {
					return opts, err
				}
				opts.sourceManifest = value
				continue
			}
			if value, ok, err := requireInlineFlagValue(arg, "--music-dataset"); ok || err != nil {
				if err != nil {
					return opts, err
				}
				opts.musicDatasets = append(opts.musicDatasets, value)
				continue
			}
			if arg == "--quiet=true" {
				opts.logLevel = logLevelQuiet
				continue
			}
			return opts, fmt.Errorf("unknown build-haks argument %q", arg)
		}
	}
	return opts, nil
}

func parseInlineFlagValue(arg, name string) (string, bool) {
	prefix := name + "="
	if !strings.HasPrefix(arg, prefix) {
		return "", false
	}
	return strings.TrimSpace(strings.TrimPrefix(arg, prefix)), true
}

func requireInlineFlagValue(arg, name string) (string, bool, error) {
	value, ok := parseInlineFlagValue(arg, name)
	if !ok {
		return "", false, nil
	}
	if value == "" {
		return "", true, fmt.Errorf("%s requires a value", name)
	}
	return value, true, nil
}

type musicCommandOptions struct {
	datasets []string
	json     bool
	dryRun   bool
	check    bool
	force    bool
}

func runMusic(ctx context) error {
	if len(ctx.args) < 2 {
		return errors.New("usage: music <list-datasets|scan|build|credits|validate|manifest|normalize> [--dataset <id>] [--json] [--dry-run] [--check] [--force]")
	}
	p, err := loadProject(ctx)
	if err != nil {
		return err
	}
	if err := p.Scan(); err != nil {
		return err
	}
	subcommand := ctx.args[1]
	opts, err := parseMusicCommandArgs(ctx.args[2:])
	if err != nil {
		return err
	}
	switch subcommand {
	case "list-datasets":
		return emitMusicDatasets(ctx, p, opts)
	case "scan":
		result, err := pipeline.BuildMusic(p, pipeline.MusicBuildOptions{DatasetIDs: opts.datasets, Write: false})
		if err != nil {
			return err
		}
		return emitMusicResult(ctx, "scan", result, opts)
	case "build", "credits":
		write := true
		if subcommand == "build" && opts.dryRun {
			write = false
		}
		if subcommand == "credits" && opts.check {
			write = false
		}
		result, err := pipeline.BuildMusic(p, pipeline.MusicBuildOptions{DatasetIDs: opts.datasets, Write: write})
		if err != nil {
			return err
		}
		return emitMusicResult(ctx, subcommand, result, opts)
	case "manifest", "normalize":
		result, err := pipeline.BuildMusic(p, pipeline.MusicBuildOptions{DatasetIDs: opts.datasets, Write: false})
		if err != nil {
			return err
		}
		return emitMusicResult(ctx, subcommand, result, opts)
	case "validate":
		if err := p.ValidateLayout(); err != nil {
			return err
		}
		result, err := pipeline.BuildMusic(p, pipeline.MusicBuildOptions{DatasetIDs: opts.datasets, Write: false})
		if err != nil {
			return err
		}
		return emitMusicResult(ctx, "validate", result, opts)
	default:
		return fmt.Errorf("unknown music subcommand %q", subcommand)
	}
}

func parseMusicCommandArgs(args []string) (musicCommandOptions, error) {
	opts := musicCommandOptions{}
	for index := 0; index < len(args); index++ {
		arg := args[index]
		switch arg {
		case "--dataset":
			index++
			if index >= len(args) || strings.TrimSpace(args[index]) == "" {
				return opts, errors.New("--dataset requires a value")
			}
			opts.datasets = append(opts.datasets, args[index])
		case "--json":
			opts.json = true
		case "--dry-run":
			opts.dryRun = true
		case "--check":
			opts.check = true
		case "--force":
			opts.force = true
		default:
			if value, ok, err := requireInlineFlagValue(arg, "--dataset"); ok || err != nil {
				if err != nil {
					return opts, err
				}
				opts.datasets = append(opts.datasets, value)
				continue
			}
			return opts, fmt.Errorf("unknown music argument %q", arg)
		}
	}
	return opts, nil
}

func emitMusicDatasets(ctx context, p *project.Project, opts musicCommandOptions) error {
	effective := p.EffectiveConfig()
	if opts.json {
		return emitConfigValue(ctx.stdout, effective.Music.Datasets, "json")
	}
	if len(effective.Music.Datasets) == 0 {
		fmt.Fprintln(ctx.stdout, "music datasets: none")
		return nil
	}
	fmt.Fprintln(ctx.stdout, "music datasets:")
	ids := make([]string, 0, len(effective.Music.Datasets))
	for id := range effective.Music.Datasets {
		ids = append(ids, id)
	}
	sort.Strings(ids)
	for _, id := range ids {
		ds := effective.Music.Datasets[id]
		fmt.Fprintf(ctx.stdout, "  %s: source=%s output=%s prefix=%s\n", id, ds.Source, ds.Output, ds.Prefix)
	}
	return nil
}

func emitMusicResult(ctx context, action string, result pipeline.BuildResult, opts musicCommandOptions) error {
	if opts.json {
		return emitConfigValue(ctx.stdout, result.CreditsSummary, "json")
	}
	fmt.Fprintf(ctx.stdout, "music %s\n", action)
	if len(result.CreditsSummary.ScannedDirs) > 0 {
		fmt.Fprintf(ctx.stdout, "scanned: %s\n", strings.Join(result.CreditsSummary.ScannedDirs, ", "))
	}
	fmt.Fprintf(ctx.stdout, "tracks: %d\n", result.CreditsSummary.TrackCount)
	if action != "scan" && len(result.CreditsArtifactPaths) > 0 {
		fmt.Fprintf(ctx.stdout, "credits artifacts: %d\n", len(result.CreditsArtifactPaths))
	}
	if len(result.CreditsSummary.Mappings) > 0 && ctx.logLevel >= logLevelVerbose {
		fmt.Fprintln(ctx.stdout, "mappings:")
		for _, mapping := range result.CreditsSummary.Mappings {
			fmt.Fprintf(ctx.stdout, "  %s -> %s\n", mapping.Source, mapping.Output)
		}
	}
	fmt.Fprintln(ctx.stdout, "status: ok")
	return nil
}

func runExtract(ctx context) error {
	p, err := loadProject(ctx)
	if err != nil {
		return err
	}
	console := newProjectConsole(ctx, p, "extract")

	files := ctx.args[1:]
	result, err := pipeline.Extract(p, files...)
	if err != nil {
		return err
	}

	console.emitExtractResult(result)
	return nil
}

func runValidate(ctx context) error {
	p, err := loadProject(ctx)
	if err != nil {
		return err
	}
	console := newProjectConsole(ctx, p, "validate")

	validationReport := validator.ValidateProject(p)
	emitValidatorReport(ctx.stderr, validationReport)
	if validationReport.HasErrors() {
		return fmt.Errorf("validation failed with %d error(s)", validationReport.ErrorCount())
	}

	inventoryReport := p.Inventory.Report()
	console.emitValidationResult(validationReport, p.Root, inventoryReport)
	return nil
}

func runConfig(ctx context) error {
	if len(ctx.args) < 2 {
		return errors.New("usage: config <validate|effective|inspect|explain|sources> ...")
	}
	root, err := project.FindRoot(ctx.cwd)
	if err != nil {
		return err
	}
	p, err := project.Load(root)
	if err != nil {
		return err
	}

	switch ctx.args[1] {
	case "validate":
		if len(ctx.args) > 2 {
			return fmt.Errorf("usage: config validate")
		}
		if err := p.ValidateLayout(); err != nil {
			return err
		}
		fmt.Fprintf(ctx.stdout, "config: ok\n")
		fmt.Fprintf(ctx.stdout, "source: %s\n", p.ConfigSource.Path)
		return nil
	case "effective":
		format, err := parseConfigFormatArgs("config effective", ctx.args[2:])
		if err != nil {
			return err
		}
		return emitConfigValue(ctx.stdout, p.EffectiveConfig(), format)
	case "inspect":
		key := ""
		format := "yaml"
		for _, arg := range ctx.args[2:] {
			switch arg {
			case "--json":
				format = "json"
			case "--yaml":
				format = "yaml"
			default:
				if key != "" {
					return fmt.Errorf("usage: config inspect [<key>] [--json|--yaml]")
				}
				key = arg
			}
		}
		if key == "" {
			return emitConfigValue(ctx.stdout, p.EffectiveConfig(), format)
		}
		value, _, ok := p.ExplainConfig(key)
		if !ok {
			return fmt.Errorf("unknown config key %q", key)
		}
		return emitConfigValue(ctx.stdout, value, format)
	case "explain":
		if len(ctx.args) != 3 {
			return fmt.Errorf("usage: config explain <key>")
		}
		key := ctx.args[2]
		value, provenance, ok := p.ExplainConfig(key)
		if !ok {
			return fmt.Errorf("unknown config key %q", key)
		}
		fmt.Fprintf(ctx.stdout, "key: %s\n", key)
		fmt.Fprintf(ctx.stdout, "value: %s\n", formatConfigScalar(value))
		fmt.Fprintf(ctx.stdout, "source: %s\n", provenance.Source)
		if provenance.Detail != "" {
			fmt.Fprintf(ctx.stdout, "detail: %s\n", provenance.Detail)
		}
		if provenance.Deprecated {
			fmt.Fprintf(ctx.stdout, "deprecated: true\n")
		}
		if rules := configValidationRules(key); len(rules) > 0 {
			fmt.Fprintf(ctx.stdout, "validation: %s\n", strings.Join(rules, "; "))
		}
		return nil
	case "sources":
		if len(ctx.args) > 2 {
			return fmt.Errorf("usage: config sources")
		}
		fmt.Fprintf(ctx.stdout, "loaded: %s\n", p.ConfigSource.Path)
		fmt.Fprintf(ctx.stdout, "name: %s\n", p.ConfigSource.Name)
		fmt.Fprintf(ctx.stdout, "format: %s\n", p.ConfigSource.Format)
		fmt.Fprintf(ctx.stdout, "legacy: %t\n", p.ConfigSource.Legacy)
		fmt.Fprintf(ctx.stdout, "candidates: %s\n", strings.Join(project.ConfigFileCandidates, ", "))
		overrides := p.ActiveOverrides()
		if len(overrides) == 0 {
			fmt.Fprintln(ctx.stdout, "active overrides: none")
			return nil
		}
		fmt.Fprintln(ctx.stdout, "active overrides:")
		for _, override := range overrides {
			fmt.Fprintf(ctx.stdout, "  %s=%s (%s)\n", override.Key, override.Value, override.Source)
		}
		return nil
	default:
		return fmt.Errorf("unknown config subcommand %q", ctx.args[1])
	}
}

func configValidationRules(key string) []string {
	switch {
	case strings.HasPrefix(key, "paths."), strings.Contains(key, ".root"), strings.Contains(key, ".dir"), strings.Contains(key, ".cache"):
		return []string{"relative paths must not escape the repository root unless explicitly documented"}
	case strings.HasPrefix(key, "outputs."):
		return []string{"generated output names must use the configured extension and must not escape the repository root"}
	case key == "extract.layout":
		return []string{"supported value: nwn_canonical_json"}
	case key == "extract.archives":
		return []string{"build-relative .mod/.hak glob patterns; .erf is intentionally rejected"}
	case key == "extract.consume_archives":
		return []string{"when true, selected build archives are deleted only after successful extraction"}
	case key == "extract.hak_discovery":
		return []string{"deprecated; use extract.archives instead"}
	case key == "autogen.cache.max_age":
		return []string{"Go duration string, for example 1h or 30m"}
	case strings.HasPrefix(key, "topdata.package_hak"):
		return []string{"must be a .hak file name"}
	case strings.HasPrefix(key, "topdata.package_tlk"), strings.HasPrefix(key, "topdata.compiled_tlk"):
		return []string{"must be a .tlk file name"}
	default:
		return nil
	}
}

func parseConfigFormatArgs(commandName string, args []string) (string, error) {
	format := "yaml"
	for _, arg := range args {
		switch arg {
		case "--json":
			format = "json"
		case "--yaml":
			format = "yaml"
		default:
			return "", fmt.Errorf("usage: %s [--json|--yaml]", commandName)
		}
	}
	return format, nil
}

func emitConfigValue(w io.Writer, value any, format string) error {
	var (
		raw []byte
		err error
	)
	switch format {
	case "json":
		raw, err = json.MarshalIndent(value, "", "  ")
	case "yaml":
		raw, err = yaml.Marshal(value)
	default:
		return fmt.Errorf("unsupported config output format %q", format)
	}
	if err != nil {
		return err
	}
	if len(raw) == 0 || raw[len(raw)-1] != '\n' {
		raw = append(raw, '\n')
	}
	_, err = w.Write(raw)
	return err
}

func formatConfigScalar(value any) string {
	raw, err := json.Marshal(value)
	if err != nil {
		return fmt.Sprint(value)
	}
	return string(raw)
}

func emitValidatorReport(w io.Writer, report validator.Report) {
	lines := report.SummaryLines(5)
	emitSummaryLines(w, lines, 20, "validation diagnostics")
	if line := report.DecompiledSummaryLine(12); line != "" {
		fmt.Fprintln(w, line)
	}
}

func emitTopdataValidationReport(w io.Writer, report topdata.ValidationReport) {
	lines := report.SummaryLines(5)
	emitSummaryLines(w, lines, 20, "topdata validation diagnostics")
}

func emitSummaryLines(w io.Writer, lines []string, maxLines int, label string) {
	if maxLines <= 0 || len(lines) <= maxLines {
		for _, line := range lines {
			fmt.Fprintln(w, line)
		}
		return
	}
	for _, line := range lines[:maxLines] {
		fmt.Fprintln(w, line)
	}
	fmt.Fprintf(w, "info: %d additional %s omitted\n", len(lines)-maxLines, label)
}

func runCompare(ctx context) error {
	p, err := loadProject(ctx)
	if err != nil {
		return err
	}
	console := newProjectConsole(ctx, p, "compare")

	result, err := pipeline.Compare(p)
	if err != nil {
		return err
	}

	console.emitCompareResult(result)
	return nil
}

func runApplyHAKManifest(ctx context) error {
	p, err := loadProject(ctx)
	if err != nil {
		return err
	}
	console := newProjectConsole(ctx, p, "apply-hak-manifest")

	manifestPath := p.HAKManifestPath()
	if len(ctx.args) > 1 {
		manifestPath = ctx.args[1]
		if !filepath.IsAbs(manifestPath) {
			manifestPath = filepath.Join(ctx.cwd, manifestPath)
		}
	}

	result, err := pipeline.ApplyHAKManifest(p, manifestPath)
	if err != nil {
		return err
	}

	console.emitApplyManifestResult(result)
	return nil
}

func runValidateTopData(ctx context) error {
	p, err := loadProject(ctx)
	if err != nil {
		return err
	}

	console := newTopdataConsole(ctx, p, "validate-topdata")
	report := topdata.ValidateProject(p)
	emitTopdataValidationReport(ctx.stderr, report)
	if report.HasErrors() {
		return fmt.Errorf("topdata validation failed with %d error(s)", report.ErrorCount())
	}

	console.emitValidationResult(report, p.TopDataSourceDir())
	return nil
}

func runBuildTopData(ctx context) error {
	p, err := loadProject(ctx)
	if err != nil {
		return err
	}

	opts, err := parseBuildTopDataArgs("build-topdata", ctx.args[1:])
	if err != nil {
		return err
	}

	console := newTopdataConsole(ctx, p, "build-topdata")
	spin.configure(ctx.stderr, console.spinnerEnabled)
	spin.start("Build Topdata: starting")
	defer spin.stop()
	result, err := topdata.BuildAndPackageWithOptions(p, opts, console.progress)
	if err != nil {
		return err
	}

	console.emitPackageResult(result)
	return nil
}

func runBuildTopPackage(ctx context) error {
	p, err := loadProject(ctx)
	if err != nil {
		return err
	}

	opts, err := parseBuildTopDataArgs("build-top-package", ctx.args[1:])
	if err != nil {
		return err
	}

	if opts.BuildWiki {
		return fmt.Errorf("--wiki is not supported with build-top-package; use build-topdata when wiki generation is required")
	}

	console := newTopdataConsole(ctx, p, "build-top-package")
	spin.configure(ctx.stderr, console.spinnerEnabled)
	spin.start("Build Top Package: starting")
	defer spin.stop()
	result, err := topdata.BuildPackage(p, console.progress)
	if err != nil {
		return err
	}

	console.emitPackageResult(result)
	return nil
}

func parseBuildTopDataArgs(commandName string, args []string) (topdata.BuildAndPackageOptions, error) {
	opts := topdata.BuildAndPackageOptions{}
	for _, arg := range args {
		switch arg {
		case "--force":
			opts.Force = true
		case "--wiki":
			opts.BuildWiki = true
		case "-h", "--help":
			return opts, fmt.Errorf("usage: %s [--force] [--wiki]", commandName)
		default:
			return opts, fmt.Errorf("unknown %s argument %q", commandName, arg)
		}
	}
	return opts, nil
}

func runCompareTopData(ctx context) error {
	p, err := loadProject(ctx)
	if err != nil {
		return err
	}

	console := newTopdataConsole(ctx, p, "compare-topdata")
	spin.configure(ctx.stderr, console.spinnerEnabled)
	spin.start("Compare Topdata: starting")
	defer spin.stop()
	result, err := topdata.Compare(p, console.progress)
	if err != nil {
		return err
	}

	console.emitCompareResult(result)
	return nil
}

func runConvertTopData(ctx context) error {
	if len(ctx.args) < 2 {
		return errors.New("usage: convert-topdata <2da-to-json|2da-to-module|json-to-2da> ...")
	}
	return topdata.RunConvertCommand(ctx.args[1:], ctx.stdout)
}

func runBuildWiki(ctx context) error {
	p, err := loadProject(ctx)
	if err != nil {
		return err
	}

	opts, err := parseBuildWikiArgs("build-wiki", ctx.args[1:])
	if err != nil {
		return err
	}

	console := newTopdataConsole(ctx, p, "build-wiki")
	spin.configure(ctx.stderr, console.spinnerEnabled)
	spin.start("Build Wiki: starting")
	defer spin.stop()
	result, err := topdata.BuildWikiWithOptions(p, opts, console.progress)
	if err != nil {
		return err
	}

	console.emitWikiBuildResult(result.OutputDir, result.PageCount, result.Status)
	return nil
}

func parseBuildWikiArgs(commandName string, args []string) (topdata.BuildWikiOptions, error) {
	opts := topdata.BuildWikiOptions{}
	for _, arg := range args {
		switch arg {
		case "--force":
			opts.Force = true
		case "-h", "--help":
			return opts, fmt.Errorf("usage: %s [--force]", commandName)
		default:
			return opts, fmt.Errorf("unknown %s argument %q", commandName, arg)
		}
	}
	return opts, nil
}

func runDeployWiki(ctx context) error {
	p, err := loadProject(ctx)
	if err != nil {
		return err
	}

	opts, err := parseDeployWikiArgs("deploy-wiki", ctx.args[1:])
	if err != nil {
		return err
	}

	console := newTopdataConsole(ctx, p, "deploy-wiki")
	spin.configure(ctx.stderr, console.spinnerEnabled)
	spin.start("Deploy Wiki: starting")
	defer spin.stop()
	result, err := topdata.DeployWikiWithOptions(p, opts, console.progress)
	if err != nil {
		return err
	}

	console.emitWikiDeployResult(result.LocalPages, result.Created, result.Updated, result.Skipped, result.Stale, result.Archived, result.Drifted, result.Manifest)
	return nil
}

func parseDeployWikiArgs(commandName string, args []string) (topdata.DeployWikiOptions, error) {
	opts := topdata.DeployWikiOptions{}
	for i := 0; i < len(args); i++ {
		arg := args[i]
		switch arg {
		case "--source-dir":
			i++
			if i >= len(args) {
				return opts, fmt.Errorf("--source-dir requires a value")
			}
			opts.SourceDir = args[i]
		case "--endpoint":
			i++
			if i >= len(args) {
				return opts, fmt.Errorf("--endpoint requires a value")
			}
			opts.Endpoint = args[i]
		case "--username":
			i++
			if i >= len(args) {
				return opts, fmt.Errorf("--username requires a value")
			}
			opts.Username = args[i]
		case "--password":
			i++
			if i >= len(args) {
				return opts, fmt.Errorf("--password requires a value")
			}
			opts.Password = args[i]
		case "--token":
			i++
			if i >= len(args) {
				return opts, fmt.Errorf("--token requires a value")
			}
			opts.Token = args[i]
		case "--version":
			i++
			if i >= len(args) {
				return opts, fmt.Errorf("--version requires a value")
			}
			opts.Version = args[i]
		case "--namespace":
			i++
			if i >= len(args) {
				return opts, fmt.Errorf("--namespace requires a value")
			}
			opts.Namespaces = append(opts.Namespaces, args[i])
		case "--category":
			i++
			if i >= len(args) {
				return opts, fmt.Errorf("--category requires a namespace=cid value")
			}
			if opts.CategoryIDs == nil {
				opts.CategoryIDs = map[string]int{}
			}
			if err := parseWikiCategoryArg(args[i], opts.CategoryIDs); err != nil {
				return opts, err
			}
		case "--manifest":
			i++
			if i >= len(args) {
				return opts, fmt.Errorf("--manifest requires a value")
			}
			opts.ManifestPath = args[i]
		case "--stale-policy":
			i++
			if i >= len(args) {
				return opts, fmt.Errorf("--stale-policy requires a value")
			}
			opts.StalePolicy = args[i]
		case "--dry-run":
			opts.DryRun = true
		case "--force":
			opts.Force = true
		case "--create":
			opts.AllowCreates = true
		case "-h", "--help":
			return opts, fmt.Errorf("usage: %s [--source-dir <path>] [--endpoint <url>] [--token <token>] [--version <ver>] [--namespace <ns> ...] [--category <namespace=cid> ...] [--manifest <path>] [--stale-policy <report|archive>] [--dry-run] [--create] [--force]", commandName)
		default:
			if value, ok, err := requireInlineFlagValue(arg, "--source-dir"); ok || err != nil {
				if err != nil {
					return opts, err
				}
				opts.SourceDir = value
			} else if value, ok, err := requireInlineFlagValue(arg, "--endpoint"); ok || err != nil {
				if err != nil {
					return opts, err
				}
				opts.Endpoint = value
			} else if value, ok, err := requireInlineFlagValue(arg, "--username"); ok || err != nil {
				if err != nil {
					return opts, err
				}
				opts.Username = value
			} else if value, ok, err := requireInlineFlagValue(arg, "--password"); ok || err != nil {
				if err != nil {
					return opts, err
				}
				opts.Password = value
			} else if value, ok, err := requireInlineFlagValue(arg, "--token"); ok || err != nil {
				if err != nil {
					return opts, err
				}
				opts.Token = value
			} else if value, ok, err := requireInlineFlagValue(arg, "--version"); ok || err != nil {
				if err != nil {
					return opts, err
				}
				opts.Version = value
			} else if value, ok, err := requireInlineFlagValue(arg, "--namespace"); ok || err != nil {
				if err != nil {
					return opts, err
				}
				opts.Namespaces = append(opts.Namespaces, value)
			} else if value, ok, err := requireInlineFlagValue(arg, "--category"); ok || err != nil {
				if err != nil {
					return opts, err
				}
				if opts.CategoryIDs == nil {
					opts.CategoryIDs = map[string]int{}
				}
				if err := parseWikiCategoryArg(value, opts.CategoryIDs); err != nil {
					return opts, err
				}
			} else if value, ok, err := requireInlineFlagValue(arg, "--manifest"); ok || err != nil {
				if err != nil {
					return opts, err
				}
				opts.ManifestPath = value
			} else if value, ok, err := requireInlineFlagValue(arg, "--stale-policy"); ok || err != nil {
				if err != nil {
					return opts, err
				}
				opts.StalePolicy = value
			} else if arg == "--dry-run" {
				opts.DryRun = true
			} else if arg == "--force" {
				opts.Force = true
			} else if arg == "--create" {
				opts.AllowCreates = true
			} else {
				return opts, fmt.Errorf("unknown %s argument %q", commandName, arg)
			}
		}
	}

	if opts.Endpoint == "" {
		opts.Endpoint = os.Getenv("NODEBB_API_ENDPOINT")
	}
	if opts.Token == "" {
		opts.Token = os.Getenv("NODEBB_API_TOKEN")
	}
	if opts.Version == "" {
		opts.Version = os.Getenv("GITHUB_REF_NAME")
	}
	if opts.CategoryIDs == nil {
		opts.CategoryIDs = map[string]int{}
	}
	if raw := os.Getenv("NODEBB_WIKI_CATEGORIES"); raw != "" {
		if err := parseWikiCategoryList(raw, opts.CategoryIDs); err != nil {
			return opts, err
		}
	}

	return opts, nil
}

type buildChangelogOptions struct {
	configPath  string
	outputPath  string
	currentTag  string
	previousTag string
	apiBaseURL  string
	token       string
}

func runBuildChangelog(ctx context) error {
	opts, err := parseBuildChangelogArgs("build-changelog", ctx.args[1:])
	if err != nil {
		return err
	}

	return changelog.Generate(changelog.Options{
		RepoRoot:    ctx.cwd,
		ConfigPath:  opts.configPath,
		OutputPath:  opts.outputPath,
		CurrentTag:  opts.currentTag,
		PreviousTag: opts.previousTag,
		APIBaseURL:  opts.apiBaseURL,
		Token:       opts.token,
		Stdout:      ctx.stdout,
	})
}

func parseBuildChangelogArgs(commandName string, args []string) (buildChangelogOptions, error) {
	opts := buildChangelogOptions{}
	usage := fmt.Errorf("usage: %s [--config <path>] [--output <path>] [--current-tag <tag>] [--previous-tag <tag>] [--api-base-url <url>] [--token <token>]", commandName)

	for i := 0; i < len(args); i++ {
		arg := args[i]
		switch arg {
		case "--config":
			i++
			if i >= len(args) || strings.TrimSpace(args[i]) == "" {
				return opts, usage
			}
			opts.configPath = args[i]
		case "--output":
			i++
			if i >= len(args) || strings.TrimSpace(args[i]) == "" {
				return opts, usage
			}
			opts.outputPath = args[i]
		case "--current-tag":
			i++
			if i >= len(args) || strings.TrimSpace(args[i]) == "" {
				return opts, usage
			}
			opts.currentTag = args[i]
		case "--previous-tag":
			i++
			if i >= len(args) || strings.TrimSpace(args[i]) == "" {
				return opts, usage
			}
			opts.previousTag = args[i]
		case "--api-base-url":
			i++
			if i >= len(args) || strings.TrimSpace(args[i]) == "" {
				return opts, usage
			}
			opts.apiBaseURL = args[i]
		case "--token":
			i++
			if i >= len(args) || strings.TrimSpace(args[i]) == "" {
				return opts, usage
			}
			opts.token = args[i]
		case "-h", "--help":
			return opts, usage
		default:
			if value, ok, err := requireInlineFlagValue(arg, "--config"); ok || err != nil {
				if err != nil {
					return opts, err
				}
				opts.configPath = value
			} else if value, ok, err := requireInlineFlagValue(arg, "--output"); ok || err != nil {
				if err != nil {
					return opts, err
				}
				opts.outputPath = value
			} else if value, ok, err := requireInlineFlagValue(arg, "--current-tag"); ok || err != nil {
				if err != nil {
					return opts, err
				}
				opts.currentTag = value
			} else if value, ok, err := requireInlineFlagValue(arg, "--previous-tag"); ok || err != nil {
				if err != nil {
					return opts, err
				}
				opts.previousTag = value
			} else if value, ok, err := requireInlineFlagValue(arg, "--api-base-url"); ok || err != nil {
				if err != nil {
					return opts, err
				}
				opts.apiBaseURL = value
			} else if value, ok, err := requireInlineFlagValue(arg, "--token"); ok || err != nil {
				if err != nil {
					return opts, err
				}
				opts.token = value
			} else {
				return opts, fmt.Errorf("unknown %s argument %q", commandName, arg)
			}
		}
	}

	return opts, nil
}

func parseWikiCategoryList(raw string, target map[string]int) error {
	for _, part := range strings.Split(raw, ",") {
		part = strings.TrimSpace(part)
		if part == "" {
			continue
		}
		if err := parseWikiCategoryArg(part, target); err != nil {
			return err
		}
	}
	return nil
}

func parseWikiCategoryArg(raw string, target map[string]int) error {
	namespace, rawCID, ok := strings.Cut(raw, "=")
	if !ok {
		return fmt.Errorf("wiki category mapping %q must be namespace=cid", raw)
	}
	namespace = strings.TrimSpace(namespace)
	rawCID = strings.TrimSpace(rawCID)
	if namespace == "" || rawCID == "" {
		return fmt.Errorf("wiki category mapping %q must be namespace=cid", raw)
	}
	cid, err := strconv.Atoi(rawCID)
	if err != nil || cid <= 0 {
		return fmt.Errorf("wiki category mapping %q has invalid cid", raw)
	}
	target[namespace] = cid
	return nil
}

func loadProject(ctx context) (*project.Project, error) {
	root, err := project.FindRoot(ctx.cwd)
	if err != nil {
		return nil, err
	}

	p, err := project.Load(root)
	if err != nil {
		return nil, err
	}
	if ctx.stderr != nil && ctx.logLevel != logLevelQuiet {
		fmt.Fprintf(ctx.stderr, "Loaded config: %s\n", p.ConfigSource.Name)
		if p.ConfigSource.Legacy {
			fmt.Fprintf(ctx.stderr, "Warning: %s is legacy repository configuration; migrate to %s.\n", project.LegacyConfigFile, project.ConfigFile)
		}
	}

	var failures []error
	if err := p.ValidateLayout(); err != nil {
		failures = append(failures, err)
	}
	if err := p.Scan(); err != nil {
		failures = append(failures, err)
	}

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

	return p, nil
}

func printUsage(w io.Writer) {
	exe := filepath.Base(os.Args[0])
	fmt.Fprintf(w, "%s <command>\n\n", exe)
	fmt.Fprintln(w, "Commands:")
	for _, cmd := range commands {
		fmt.Fprintf(w, "  %-8s %s\n", cmd.name, cmd.description)
	}
}
