// SetupPilot builds and checks Windows unattended answer files
// (unattend.xml).
//
// Unattended answer files are the single point of failure in a Windows
// deployment: one misspelled settings pass, one over-long ComputerName, or
// one mismatched processorArchitecture and Windows silently ignores the
// section -- 200 machines image "successfully" and every one of them is
// wrong. SetupPilot generates answer files from plain options and audits
// existing ones for exactly those silent faults, reporting each finding with
// the line number it lives on. Because answer files are just XML, all of
// this works from any desktop, not only from Windows.
//
// SetupPilot validates STRUCTURE and known-value correctness. It is not a
// full Microsoft schema (XSD) validator -- see README.txt.
package main

import (
	"bytes"
	"encoding/json"
	"encoding/xml"
	"flag"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"sort"
	"strings"
)

const version = "0.1.0"

// unattendNS is the namespace every real answer file must declare on its
// root element. A file with the wrong namespace is not an answer file as far
// as Windows Setup is concerned.
const unattendNS = "urn:schemas-microsoft-com:unattend"

// wcmNS is the configuration-action namespace used by wcm:action attributes.
const wcmNS = "http://schemas.microsoft.com/WMIConfig/2002/State"

// Guard rails so that a hostile or accidental input cannot exhaust memory or
// stack. Real answer files are a few kilobytes and under ten levels deep.
const (
	maxFileSize  = 512 << 20 // 512 MiB
	maxDepth     = 128
	maxLeaves    = 200000
	maxNameChars = 15 // NetBIOS computer name limit
)

// ---------------------------------------------------------------------
// flag reordering (shared convention across the tool suite)
// ---------------------------------------------------------------------

func reorderFlags(args []string, valueFlags map[string]bool) []string {
	var flags, positional []string
	for i := 0; i < len(args); i++ {
		a := args[i]
		name := strings.TrimLeft(a, "-")
		if strings.HasPrefix(a, "-") && valueFlags[name] {
			flags = append(flags, a)
			if i+1 < len(args) {
				i++
				flags = append(flags, args[i])
			}
			continue
		}
		if strings.HasPrefix(a, "-") {
			flags = append(flags, a)
			continue
		}
		positional = append(positional, a)
	}
	return append(flags, positional...)
}

func humanBytes(n int64) string {
	const unit = 1024
	if n < unit {
		return fmt.Sprintf("%d B", n)
	}
	div, exp := int64(unit), 0
	for x := n / unit; x >= unit; x /= unit {
		div *= unit
		exp++
	}
	return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}

// ---------------------------------------------------------------------
// the real configuration passes
// ---------------------------------------------------------------------

// validPasses is the complete set of configuration passes Windows Setup
// understands. Anything else in pass="..." is silently skipped by Windows,
// which is the most expensive typo in deployment.
var validPasses = []string{
	"windowsPE",
	"offlineServicing",
	"generalize",
	"specialize",
	"auditSystem",
	"auditUser",
	"oobeSystem",
}

func isValidPass(s string) bool {
	for _, p := range validPasses {
		if p == s {
			return true
		}
	}
	return false
}

// nearestPass returns the canonical pass name that differs from s only by
// letter case, if there is one. That is by far the most common mistake
// (oobesystem, WindowsPE, specialise).
func nearestPass(s string) string {
	for _, p := range validPasses {
		if strings.EqualFold(p, s) {
			return p
		}
	}
	return ""
}

var knownArch = map[string]bool{
	"amd64": true,
	"x86":   true,
	"arm64": true,
	"wow64": true,
}

// ---------------------------------------------------------------------
// findings
// ---------------------------------------------------------------------

type Finding struct {
	Level   string `json:"level"` // ERROR | WARN | INFO
	Line    int    `json:"line"`  // 0 == file-level, no single line
	Code    string `json:"code"`
	Message string `json:"message"`
}

// maxPerCode bounds how many times one kind of finding is reported. A file
// with 100,000 duplicate components has one problem, not 100,000, and a
// terminal full of identical lines helps nobody.
const maxPerCode = 25

type findingList struct {
	items      []Finding
	perCode    map[string]int
	suppressed map[string]int
	codeOrder  []string
}

func (f *findingList) add(level string, line int, code, format string, a ...any) {
	if f.perCode == nil {
		f.perCode = map[string]int{}
		f.suppressed = map[string]int{}
	}
	if _, seen := f.perCode[code]; !seen {
		f.codeOrder = append(f.codeOrder, code)
	}
	f.perCode[code]++
	if f.perCode[code] > maxPerCode {
		f.suppressed[code]++
		return
	}
	f.items = append(f.items, Finding{
		Level:   level,
		Line:    line,
		Code:    code,
		Message: fmt.Sprintf(format, a...),
	})
}

// finish reports, once per finding kind, how many repeats were withheld.
func (f *findingList) finish() {
	for _, code := range f.codeOrder {
		if n := f.suppressed[code]; n > 0 {
			f.items = append(f.items, Finding{
				Level: "INFO", Line: 0, Code: "suppressed",
				Message: fmt.Sprintf("%d further %q finding(s) not shown (capped at %d per kind)",
					n, code, maxPerCode),
			})
		}
	}
}

func (f *findingList) errorf(line int, code, format string, a ...any) {
	f.add("ERROR", line, code, format, a...)
}
func (f *findingList) warnf(line int, code, format string, a ...any) {
	f.add("WARN", line, code, format, a...)
}
func (f *findingList) infof(line int, code, format string, a ...any) {
	f.add("INFO", line, code, format, a...)
}

func (f *findingList) counts() (errs, warns, infos int) {
	for _, it := range f.items {
		switch it.Level {
		case "ERROR":
			errs++
		case "WARN":
			warns++
		default:
			infos++
		}
	}
	return
}

func levelRank(l string) int {
	switch l {
	case "ERROR":
		return 0
	case "WARN":
		return 1
	default:
		return 2
	}
}

// sorted returns the findings ordered by line, then severity, keeping the
// discovery order for ties so output is stable run to run.
func (f *findingList) sorted() []Finding {
	out := make([]Finding, len(f.items))
	copy(out, f.items)
	sort.SliceStable(out, func(i, j int) bool {
		if out[i].Line != out[j].Line {
			return out[i].Line < out[j].Line
		}
		return levelRank(out[i].Level) < levelRank(out[j].Level)
	})
	return out
}

// ---------------------------------------------------------------------
// line index
// ---------------------------------------------------------------------

// lineIndex maps a byte offset in the source document to a 1-based line
// number, so every finding can point at the element it is about.
type lineIndex struct {
	starts []int
}

func newLineIndex(data []byte) *lineIndex {
	li := &lineIndex{starts: []int{0}}
	for i, b := range data {
		if b == '\n' {
			li.starts = append(li.starts, i+1)
		}
	}
	return li
}

func (li *lineIndex) lineOf(offset int) int {
	if offset < 0 {
		return 1
	}
	// Largest i with starts[i] <= offset.
	i := sort.SearchInts(li.starts, offset+1) - 1
	if i < 0 {
		i = 0
	}
	return i + 1
}

// ---------------------------------------------------------------------
// parsed document model
// ---------------------------------------------------------------------

type leaf struct {
	Path  string `json:"path"`  // element path relative to the component
	Value string `json:"value"` // text content (secrets already masked)
	Line  int    `json:"line"`
}

type componentInfo struct {
	Name         string `json:"name"`
	Arch         string `json:"processor_architecture"`
	PublicKey    string `json:"public_key_token"`
	Language     string `json:"language"`
	VersionScope string `json:"version_scope"`
	Line         int    `json:"line"`
	HasName      bool   `json:"-"`
	HasArch      bool   `json:"-"`
	HasKey       bool   `json:"-"`
	Settings     []leaf `json:"settings"`
}

type passInfo struct {
	Pass       string          `json:"pass"`
	HasPass    bool            `json:"-"`
	Line       int             `json:"line"`
	Components []componentInfo `json:"components"`
}

// secretRef is a password/product-key text node: the byte range so redact
// can splice it out, and the metadata check needs to describe it. The value
// itself is deliberately never stored.
type secretRef struct {
	Element   string // e.g. "AdministratorPassword/Value"
	Line      int
	Start     int // byte offset of the raw text node
	End       int
	PlainText string // sibling <PlainText> value, if the parent had one
	Empty     bool
}

type document struct {
	Path     string
	Size     int64
	RootName string
	RootNS   string
	RootLine int
	HasRoot  bool

	Passes     []passInfo
	RootOther  []leaf // unexpected direct children of <unattend>
	Secrets    []secretRef
	CompNames  []leaf // every <ComputerName> found, with line numbers
	FatalErr   error  // XML syntax / structural read failure
	FatalLine  int
	FatalCode  string // "malformed-xml" or "depth-limit"
	LeafCutoff bool
}

// stkNode is one level of the element stack during the streaming parse.
type stkNode struct {
	name      string
	line      int
	passIdx   int // index into document.Passes, or -1
	compIdx   int // index into Passes[passIdx].Components, or -1
	pathBelow string
	isSecretP bool   // this element is a password / product-key container
	secretIdx []int  // secrets recorded for this container
	plainText string // value of a <PlainText> child seen so far
	sawText   bool
	textStart int
	textEnd   int
	textVal   string
}

// parseDocument streams the answer file, building just enough of a model for
// check/show/redact. It never materialises the whole tree, so a very large
// or very deeply nested file costs bounded memory.
func parseDocument(path string, data []byte, size int64, collectAll bool) *document {
	doc := &document{Path: path, Size: size}
	li := newLineIndex(data)

	dec := xml.NewDecoder(bytes.NewReader(data))
	dec.Strict = true

	var stack []stkNode
	prev := 0

	for {
		off := int(dec.InputOffset())
		if off > prev {
			prev = off
		}
		tokStart := prev
		tok, err := dec.Token()
		after := int(dec.InputOffset())
		prev = after
		if err == io.EOF {
			break
		}
		if err != nil {
			doc.FatalErr = err
			if se, ok := err.(*xml.SyntaxError); ok {
				doc.FatalLine = se.Line
			} else {
				doc.FatalLine = li.lineOf(after)
			}
			return doc
		}

		switch t := tok.(type) {
		case xml.StartElement:
			// Locate the '<' that opened this tag for an exact line number.
			lt := bytes.LastIndexByte(data[:min(after, len(data))], '<')
			line := li.lineOf(lt)

			if len(stack) >= maxDepth {
				doc.FatalErr = fmt.Errorf("nesting depth exceeds the %d-level limit at <%s>; a real answer file is under ten levels deep",
					maxDepth, t.Name.Local)
				doc.FatalLine = line
				doc.FatalCode = "depth-limit"
				return doc
			}

			node := stkNode{name: t.Name.Local, line: line, passIdx: -1, compIdx: -1}

			switch {
			case len(stack) == 0:
				doc.HasRoot = true
				doc.RootName = t.Name.Local
				doc.RootNS = t.Name.Space
				doc.RootLine = line

			case len(stack) == 1 && t.Name.Local == "settings":
				p := passInfo{Line: line}
				for _, a := range t.Attr {
					if a.Name.Local == "pass" && a.Name.Space == "" {
						p.Pass = a.Value
						p.HasPass = true
					}
				}
				doc.Passes = append(doc.Passes, p)
				node.passIdx = len(doc.Passes) - 1

			case len(stack) == 1:
				doc.RootOther = append(doc.RootOther, leaf{Path: t.Name.Local, Line: line})

			case len(stack) == 2 && t.Name.Local == "component" && stack[1].passIdx >= 0:
				pi := stack[1].passIdx
				c := componentInfo{Line: line}
				for _, a := range t.Attr {
					if a.Name.Space != "" && a.Name.Space != "xmlns" {
						continue
					}
					switch a.Name.Local {
					case "name":
						c.Name, c.HasName = a.Value, true
					case "processorArchitecture":
						c.Arch, c.HasArch = a.Value, true
					case "publicKeyToken":
						c.PublicKey, c.HasKey = a.Value, true
					case "language":
						c.Language = a.Value
					case "versionScope":
						c.VersionScope = a.Value
					}
				}
				doc.Passes[pi].Components = append(doc.Passes[pi].Components, c)
				node.passIdx = pi
				node.compIdx = len(doc.Passes[pi].Components) - 1

			default:
				parent := stack[len(stack)-1]
				node.passIdx = parent.passIdx
				node.compIdx = parent.compIdx
				if parent.compIdx >= 0 {
					if parent.pathBelow == "" {
						node.pathBelow = t.Name.Local
					} else {
						node.pathBelow = parent.pathBelow + "/" + t.Name.Local
					}
				}
			}

			if isSecretContainer(t.Name.Local) {
				node.isSecretP = true
			}
			stack = append(stack, node)

		case xml.CharData:
			if len(stack) == 0 {
				break
			}
			top := &stack[len(stack)-1]
			if strings.TrimSpace(string(t)) == "" {
				break
			}
			top.sawText = true
			if top.textStart == 0 && top.textEnd == 0 {
				top.textStart = tokStart
			}
			top.textEnd = after
			top.textVal += string(t)

		case xml.EndElement:
			if len(stack) == 0 {
				break
			}
			node := stack[len(stack)-1]
			stack = stack[:len(stack)-1]

			var parent *stkNode
			if len(stack) > 0 {
				parent = &stack[len(stack)-1]
			}

			val := strings.TrimSpace(node.textVal)

			// Record secrets by byte range so redact can splice them out
			// without reserialising (and thus without reformatting) the file.
			secret := false
			if parent != nil && parent.isSecretP && (node.name == "Value" || node.name == "Key") {
				secret = true
			}
			if node.isSecretP && node.sawText {
				// e.g. <PlainTextPassword>hunter2</PlainTextPassword>
				secret = true
			}
			if secret {
				ref := secretRef{
					Element: node.name,
					Line:    node.line,
					Start:   node.textStart,
					End:     node.textEnd,
					Empty:   val == "",
				}
				if parent != nil && parent.isSecretP {
					ref.Element = parent.name + "/" + node.name
					// <PlainText> may have been declared before <Value>.
					ref.PlainText = parent.plainText
				}
				if node.textStart == 0 && node.textEnd == 0 {
					ref.Empty = true
				}
				doc.Secrets = append(doc.Secrets, ref)
				if parent != nil {
					parent.secretIdx = append(parent.secretIdx, len(doc.Secrets)-1)
				}
			}

			// A <PlainText> sibling tells us whether the password was stored
			// as clear text or as the (trivially reversible) base64 form. It
			// commonly follows <Value>, so back-fill the secrets already
			// recorded for this container.
			if parent != nil && parent.isSecretP && node.name == "PlainText" {
				parent.plainText = val
				for _, i := range parent.secretIdx {
					doc.Secrets[i].PlainText = val
				}
			}

			if val != "" && node.compIdx < 0 && node.name == "ComputerName" {
				doc.CompNames = append(doc.CompNames, leaf{Path: "ComputerName", Value: val, Line: node.line})
			}
			if val != "" && node.compIdx >= 0 && node.pathBelow != "" {
				if node.name == "ComputerName" {
					doc.CompNames = append(doc.CompNames, leaf{Path: node.pathBelow, Value: val, Line: node.line})
				}
				if collectAll || isKeyLeaf(node.name) {
					if len(doc.Passes[node.passIdx].Components[node.compIdx].Settings) < maxLeaves {
						shown := val
						if secret {
							shown = "<redacted>"
						}
						doc.Passes[node.passIdx].Components[node.compIdx].Settings = append(
							doc.Passes[node.passIdx].Components[node.compIdx].Settings,
							leaf{Path: node.pathBelow, Value: shown, Line: node.line})
					} else {
						doc.LeafCutoff = true
					}
				}
			}
		}
	}

	if len(stack) != 0 {
		doc.FatalErr = fmt.Errorf("unexpected end of file: <%s> opened at line %d is never closed",
			stack[len(stack)-1].name, stack[len(stack)-1].line)
		doc.FatalLine = stack[len(stack)-1].line
	}
	return doc
}

func isSecretContainer(name string) bool {
	l := strings.ToLower(name)
	if strings.Contains(l, "password") {
		return true
	}
	if l == "productkey" {
		return true
	}
	return false
}

// keyLeaves are the elements a human actually cares about when reading a
// summary of an answer file.
var keyLeaves = map[string]bool{
	"ComputerName": true, "TimeZone": true, "InputLocale": true,
	"SystemLocale": true, "UILanguage": true, "UserLocale": true,
	"UILanguageFallback": true, "RegisteredOwner": true,
	"RegisteredOrganization": true, "AcceptEula": true,
	"HideEULAPage": true, "HideLocalAccountScreen": true,
	"HideOEMRegistrationScreen": true, "HideOnlineAccountScreens": true,
	"HideWirelessSetupInOOBE": true, "ProtectYourPC": true,
	"SkipMachineOOBE": true, "SkipUserOOBE": true, "NetworkLocation": true,
	"Name": true, "DisplayName": true, "Group": true, "Description": true,
	"Enabled": true, "Username": true, "LogonCount": true,
	"WillShowUI": true, "InstallToAvailablePartition": true,
	"WillWipeDisk": true, "DiskID": true, "Type": true, "Size": true,
	"Extend": true, "Active": true, "Label": true, "Letter": true,
	"Format": true, "PlainText": true, "Order": true, "PartitionID": true,
	"Value": true, "Key": true, "Identification": true, "JoinDomain": true,
	"CopyProfile": true, "OEMInformation": true, "Manufacturer": true,
	"Model": true, "SupportPhone": true, "SupportURL": true,
}

func isKeyLeaf(name string) bool { return keyLeaves[name] }

// ---------------------------------------------------------------------
// validation
// ---------------------------------------------------------------------

func validate(doc *document) *findingList {
	f := &findingList{}
	inspect(doc, f)
	f.finish()
	return f
}

func inspect(doc *document, f *findingList) {
	f.infof(0, "file", "file %s (%s)", filepath.Base(doc.Path), humanBytes(doc.Size))

	if doc.Size == 0 {
		f.errorf(0, "empty-file", "file is empty; an answer file must contain an <unattend> document")
		return
	}

	if doc.FatalErr != nil {
		if doc.FatalCode == "depth-limit" {
			f.errorf(doc.FatalLine, "depth-limit", "refusing to parse further: %s", cleanErr(doc.FatalErr))
		} else {
			f.errorf(doc.FatalLine, "malformed-xml", "XML is not well-formed: %s", cleanErr(doc.FatalErr))
		}
		// Nothing structural can be trusted after a syntax error.
		return
	}

	if !doc.HasRoot {
		f.errorf(0, "no-root", "document has no root element")
		return
	}

	// --- root element and namespace -----------------------------------
	if doc.RootName != "unattend" {
		f.errorf(doc.RootLine, "wrong-root",
			"root element is <%s>; a Windows answer file must have <unattend> as its root", doc.RootName)
	}
	switch doc.RootNS {
	case unattendNS:
		// correct
	case "":
		f.errorf(doc.RootLine, "missing-namespace",
			"root element declares no XML namespace; expected xmlns=%q", unattendNS)
	default:
		f.errorf(doc.RootLine, "wrong-namespace",
			"root namespace is %q; expected %q", doc.RootNS, unattendNS)
	}

	for _, o := range doc.RootOther {
		if o.Path == "servicing" || o.Path == "extensions" {
			f.infof(o.Line, "root-child", "<%s> section present", o.Path)
			continue
		}
		f.warnf(o.Line, "unexpected-root-child",
			"<%s> is not a recognised child of <unattend> and will be ignored", o.Path)
	}

	// --- passes --------------------------------------------------------
	if len(doc.Passes) == 0 {
		f.warnf(doc.RootLine, "no-passes",
			"no <settings pass=\"...\"> sections found; this answer file configures nothing")
	}

	seenPass := map[string]int{}
	for _, p := range doc.Passes {
		if !p.HasPass {
			f.errorf(p.Line, "missing-pass",
				"<settings> has no pass attribute; Windows cannot place it and will ignore it")
			continue
		}
		if p.Pass == "" {
			f.errorf(p.Line, "empty-pass", "<settings pass=\"\"> is empty")
			continue
		}
		if !isValidPass(p.Pass) {
			if near := nearestPass(p.Pass); near != "" {
				f.errorf(p.Line, "unknown-pass",
					"pass %q is not a real configuration pass (case-sensitive) -- did you mean %q? Windows silently ignores the whole section",
					p.Pass, near)
			} else {
				f.errorf(p.Line, "unknown-pass",
					"pass %q is not a real configuration pass; valid passes are %s. Windows silently ignores the whole section",
					p.Pass, strings.Join(validPasses, ", "))
			}
			continue
		}
		if first, dup := seenPass[p.Pass]; dup {
			f.errorf(p.Line, "duplicate-pass",
				"configuration pass %q is declared more than once (first at line %d); behaviour is undefined and one copy is lost",
				p.Pass, first)
			continue
		}
		seenPass[p.Pass] = p.Line
	}

	// --- components -----------------------------------------------------
	archLines := map[string][]int{}
	totalComponents := 0

	for _, p := range doc.Passes {
		label := p.Pass
		if label == "" {
			label = "(no pass)"
		}
		seenComp := map[string]int{}
		for _, c := range p.Components {
			totalComponents++

			if !c.HasName || strings.TrimSpace(c.Name) == "" {
				f.errorf(c.Line, "component-no-name",
					"<component> in pass %q has no name attribute; Windows cannot resolve it", label)
			}
			if !c.HasArch || strings.TrimSpace(c.Arch) == "" {
				f.errorf(c.Line, "component-no-arch",
					"component %s in pass %q is missing processorArchitecture; Setup will not apply it",
					compLabel(c), label)
			} else if !knownArch[c.Arch] {
				f.errorf(c.Line, "component-bad-arch",
					"component %s declares processorArchitecture=%q, which is not one of amd64, x86, arm64, wow64",
					compLabel(c), c.Arch)
			} else {
				archLines[c.Arch] = append(archLines[c.Arch], c.Line)
			}
			if !c.HasKey || strings.TrimSpace(c.PublicKey) == "" {
				f.errorf(c.Line, "component-no-token",
					"component %s in pass %q is missing publicKeyToken; the component reference is incomplete",
					compLabel(c), label)
			} else if c.PublicKey != "31bf3856ad364e35" {
				f.warnf(c.Line, "component-odd-token",
					"component %s has publicKeyToken=%q; in-box Windows components use 31bf3856ad364e35",
					compLabel(c), c.PublicKey)
			}
			if c.Language == "" {
				f.infof(c.Line, "component-no-language",
					"component %s does not set language (usually language=\"neutral\")", compLabel(c))
			}
			if c.VersionScope == "" {
				f.infof(c.Line, "component-no-versionscope",
					"component %s does not set versionScope (usually versionScope=\"nonSxS\")", compLabel(c))
			}

			if c.HasName && c.Name != "" {
				key := c.Name + "|" + c.Arch
				if first, dup := seenComp[key]; dup {
					f.errorf(c.Line, "duplicate-component",
						"component %s appears twice in pass %q (first at line %d); the later copy overrides the earlier one",
						compLabel(c), label, first)
				} else {
					seenComp[key] = c.Line
				}
			}
		}
	}

	// --- architecture consistency ---------------------------------------
	var archs []string
	for a := range archLines {
		archs = append(archs, a)
	}
	sort.Strings(archs)
	if len(archs) > 1 {
		_, has64 := archLines["amd64"]
		_, has86 := archLines["x86"]
		_, hasArm := archLines["arm64"]
		var parts []string
		for _, a := range archs {
			parts = append(parts, fmt.Sprintf("%s (line %s)", a, joinInts(archLines[a])))
		}
		detail := strings.Join(parts, ", ")
		line := firstLine(archLines, archs)
		switch {
		case (has64 && has86) || (has64 && hasArm) || (has86 && hasArm):
			f.errorf(line, "arch-mismatch",
				"components mix processor architectures: %s. A component built for the wrong architecture is skipped by Setup",
				detail)
		default:
			f.warnf(line, "arch-mixed",
				"components use more than one processorArchitecture: %s (wow64 alongside amd64 is legitimate in some images)",
				detail)
		}
	}

	// --- computer name ---------------------------------------------------
	for _, cn := range doc.CompNames {
		checkComputerName(f, cn)
	}

	// --- secrets ----------------------------------------------------------
	for _, s := range doc.Secrets {
		if s.Empty {
			f.infof(s.Line, "empty-secret", "<%s> is present but empty", s.Element)
			continue
		}
		kind := "password"
		if strings.Contains(strings.ToLower(s.Element), "productkey") || s.Element == "Key" {
			kind = "product key"
		}
		switch strings.ToLower(s.PlainText) {
		case "false":
			f.warnf(s.Line, "secret-present",
				"<%s> holds a %s stored with PlainText=false; that encoding is trivially reversible -- treat this file as a secret (value not shown)",
				s.Element, kind)
		default:
			f.warnf(s.Line, "plaintext-secret",
				"<%s> holds a plaintext %s; anyone who reads this file learns it -- use `setuppilot redact` before sharing (value not shown)",
				s.Element, kind)
		}
	}

	f.infof(0, "summary", "%d configuration pass(es), %d component(s), %d secret value(s)",
		len(doc.Passes), totalComponents, len(doc.Secrets))

	return
}

// checkComputerName applies the NetBIOS rules that bite in the field.
func checkComputerName(f *findingList, cn leaf) {
	v := cn.Value
	if v == "*" {
		f.infof(cn.Line, "computername-random",
			"<ComputerName> is \"*\"; Windows will generate a random name")
		return
	}
	if v == "" {
		f.errorf(cn.Line, "computername-empty", "<ComputerName> is empty")
		return
	}
	if n := len([]rune(v)); n > maxNameChars {
		f.errorf(cn.Line, "computername-too-long",
			"<ComputerName> %q is %d characters; Windows truncates or rejects names over %d",
			v, n, maxNameChars)
	}
	var bad []string
	seenBad := map[rune]bool{}
	for _, r := range v {
		ok := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-'
		if !ok && !seenBad[r] {
			seenBad[r] = true
			bad = append(bad, fmt.Sprintf("%q", string(r)))
		}
	}
	if len(bad) > 0 {
		f.errorf(cn.Line, "computername-illegal-char",
			"<ComputerName> %q contains character(s) Windows forbids in a computer name: %s (allowed: A-Z a-z 0-9 and hyphen)",
			v, strings.Join(bad, ", "))
	}
	if strings.HasPrefix(v, "-") || strings.HasSuffix(v, "-") {
		f.warnf(cn.Line, "computername-hyphen-edge",
			"<ComputerName> %q starts or ends with a hyphen; DNS registration may fail", v)
	}
	allDigits := true
	for _, r := range v {
		if r < '0' || r > '9' {
			allDigits = false
			break
		}
	}
	if allDigits {
		f.warnf(cn.Line, "computername-all-digits",
			"<ComputerName> %q is all digits; Windows discourages purely numeric names", v)
	}
}

func compLabel(c componentInfo) string {
	if c.Name == "" {
		return "<unnamed>"
	}
	return c.Name
}

func joinInts(v []int) string {
	var s []string
	for _, i := range v {
		s = append(s, fmt.Sprintf("%d", i))
	}
	return strings.Join(s, "/")
}

func firstLine(m map[string][]int, keys []string) int {
	best := 0
	for _, k := range keys {
		for _, l := range m[k] {
			if best == 0 || l < best {
				best = l
			}
		}
	}
	return best
}

func cleanErr(err error) string {
	s := err.Error()
	if se, ok := err.(*xml.SyntaxError); ok {
		s = se.Msg
	}
	return strings.TrimSpace(s)
}

// ---------------------------------------------------------------------
// file reading
// ---------------------------------------------------------------------

func readAnswerFile(path string) ([]byte, int64, error) {
	st, err := os.Stat(path)
	if err != nil {
		return nil, 0, fmt.Errorf("reading %s: %w", path, err)
	}
	if st.IsDir() {
		return nil, 0, fmt.Errorf("reading %s: is a directory, not an answer file", path)
	}
	if st.Size() > maxFileSize {
		return nil, st.Size(), fmt.Errorf("reading %s: file is %s, which exceeds the %s limit for an answer file",
			path, humanBytes(st.Size()), humanBytes(maxFileSize))
	}
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, st.Size(), fmt.Errorf("reading %s: %w", path, err)
	}
	return data, int64(len(data)), nil
}

// ---------------------------------------------------------------------
// answer file generation
// ---------------------------------------------------------------------

type newOpts struct {
	computerName string
	locale       string
	timezone     string
	adminUser    string
	skipOOBE     bool
	diskLayout   string
	arch         string
}

func esc(s string) string {
	var b bytes.Buffer
	xml.EscapeText(&b, []byte(s))
	return b.String()
}

type xw struct {
	b      strings.Builder
	indent int
}

func (w *xw) line(format string, a ...any) {
	w.b.WriteString(strings.Repeat("  ", w.indent))
	fmt.Fprintf(&w.b, format, a...)
	w.b.WriteByte('\n')
}
func (w *xw) open(format string, a ...any)  { w.line(format, a...); w.indent++ }
func (w *xw) close(format string, a ...any) { w.indent--; w.line(format, a...) }
func (w *xw) leaf(name, value string) {
	w.line("<%s>%s</%s>", name, esc(value), name)
}

func componentOpen(w *xw, name, arch string) {
	w.open(`<component name="%s" processorArchitecture="%s" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">`,
		esc(name), esc(arch))
}

func generate(o newOpts) string {
	w := &xw{}
	w.line(`<?xml version="1.0" encoding="utf-8"?>`)
	w.line(`<!-- Generated by SetupPilot %s. Review before deploying. -->`, version)
	w.open(`<unattend xmlns="%s" xmlns:wcm="%s">`, unattendNS, wcmNS)

	// ---- windowsPE ----
	w.open(`<settings pass="windowsPE">`)
	componentOpen(w, "Microsoft-Windows-International-Core-WinPE", o.arch)
	w.open(`<SetupUILanguage>`)
	w.leaf("UILanguage", o.locale)
	w.close(`</SetupUILanguage>`)
	w.leaf("InputLocale", o.locale)
	w.leaf("SystemLocale", o.locale)
	w.leaf("UILanguage", o.locale)
	w.leaf("UserLocale", o.locale)
	w.close(`</component>`)

	componentOpen(w, "Microsoft-Windows-Setup", o.arch)
	if o.diskLayout != "" {
		writeDiskConfiguration(w, o.diskLayout)
		w.open(`<ImageInstall>`)
		w.open(`<OSImage>`)
		w.open(`<InstallTo>`)
		w.leaf("DiskID", "0")
		if o.diskLayout == "uefi" {
			w.leaf("PartitionID", "3")
		} else {
			w.leaf("PartitionID", "2")
		}
		w.close(`</InstallTo>`)
		w.leaf("WillShowUI", "OnError")
		w.close(`</OSImage>`)
		w.close(`</ImageInstall>`)
	}
	w.open(`<UserData>`)
	w.leaf("AcceptEula", "true")
	w.close(`</UserData>`)
	w.close(`</component>`)
	w.close(`</settings>`)

	// ---- specialize ----
	w.open(`<settings pass="specialize">`)
	componentOpen(w, "Microsoft-Windows-Shell-Setup", o.arch)
	w.leaf("ComputerName", o.computerName)
	w.leaf("TimeZone", o.timezone)
	w.close(`</component>`)
	w.close(`</settings>`)

	// ---- oobeSystem ----
	w.open(`<settings pass="oobeSystem">`)
	componentOpen(w, "Microsoft-Windows-International-Core", o.arch)
	w.leaf("InputLocale", o.locale)
	w.leaf("SystemLocale", o.locale)
	w.leaf("UILanguage", o.locale)
	w.leaf("UserLocale", o.locale)
	w.close(`</component>`)

	if o.skipOOBE || o.adminUser != "" {
		componentOpen(w, "Microsoft-Windows-Shell-Setup", o.arch)
		if o.skipOOBE {
			w.open(`<OOBE>`)
			w.leaf("HideEULAPage", "true")
			w.leaf("HideLocalAccountScreen", "true")
			w.leaf("HideOEMRegistrationScreen", "true")
			w.leaf("HideOnlineAccountScreens", "true")
			w.leaf("HideWirelessSetupInOOBE", "true")
			w.leaf("ProtectYourPC", "3")
			w.leaf("NetworkLocation", "Work")
			w.close(`</OOBE>`)
		}
		if o.adminUser != "" {
			w.open(`<UserAccounts>`)
			w.open(`<LocalAccounts>`)
			w.open(`<LocalAccount wcm:action="add">`)
			w.leaf("Name", o.adminUser)
			w.leaf("DisplayName", o.adminUser)
			w.leaf("Group", "Administrators")
			w.leaf("Description", "Local administrator created by unattended setup")
			w.close(`</LocalAccount>`)
			w.close(`</LocalAccounts>`)
			w.close(`</UserAccounts>`)
			w.line(`<!-- SetupPilot never writes a password into an answer file. -->`)
			w.line(`<!-- Set one interactively at first logon, or add it yourself and keep the file secret. -->`)
		}
		w.close(`</component>`)
	}
	w.close(`</settings>`)

	w.close(`</unattend>`)
	return w.b.String()
}

func writeDiskConfiguration(w *xw, layout string) {
	w.open(`<DiskConfiguration>`)
	w.leaf("WillShowUI", "OnError")
	w.open(`<Disk wcm:action="add">`)
	w.leaf("DiskID", "0")
	w.leaf("WillWipeDisk", "true")
	w.open(`<CreatePartitions>`)
	if layout == "uefi" {
		createPart(w, "1", "EFI", "260", false)
		createPart(w, "2", "MSR", "128", false)
		createPart(w, "3", "Primary", "", true)
	} else {
		createPart(w, "1", "Primary", "500", false)
		createPart(w, "2", "Primary", "", true)
	}
	w.close(`</CreatePartitions>`)
	w.open(`<ModifyPartitions>`)
	if layout == "uefi" {
		modifyPart(w, "1", "1", "System", "FAT32", "", false)
		modifyPart(w, "2", "2", "", "", "", false)
		modifyPart(w, "3", "3", "Windows", "NTFS", "C", false)
	} else {
		modifyPart(w, "1", "1", "System Reserved", "NTFS", "", true)
		modifyPart(w, "2", "2", "Windows", "NTFS", "C", false)
	}
	w.close(`</ModifyPartitions>`)
	w.close(`</Disk>`)
	w.close(`</DiskConfiguration>`)
}

func createPart(w *xw, order, typ, size string, extend bool) {
	w.open(`<CreatePartition wcm:action="add">`)
	w.leaf("Order", order)
	w.leaf("Type", typ)
	if extend {
		w.leaf("Extend", "true")
	} else {
		w.leaf("Size", size)
	}
	w.close(`</CreatePartition>`)
}

func modifyPart(w *xw, order, id, label, format, letter string, active bool) {
	w.open(`<ModifyPartition wcm:action="add">`)
	w.leaf("Order", order)
	w.leaf("PartitionID", id)
	if active {
		w.leaf("Active", "true")
	}
	if label != "" {
		w.leaf("Label", label)
	}
	if format != "" {
		w.leaf("Format", format)
	}
	if letter != "" {
		w.leaf("Letter", letter)
	}
	w.close(`</ModifyPartition>`)
}

// ---------------------------------------------------------------------
// derived summary (used by show, and by the round-trip test)
// ---------------------------------------------------------------------

type derived struct {
	ComputerName string   `json:"computer_name"`
	Locale       string   `json:"locale"`
	TimeZone     string   `json:"time_zone"`
	AdminUsers   []string `json:"admin_accounts"`
	OOBESkipped  bool     `json:"oobe_skipped"`
	OOBESettings int      `json:"oobe_settings"`
	DiskLayout   string   `json:"disk_layout"`
	Secrets      int      `json:"secret_values"`
	Passes       []string `json:"passes"`
	Components   int      `json:"component_count"`
}

func derive(doc *document) derived {
	d := derived{DiskLayout: "none", Secrets: len(doc.Secrets)}
	sawEFI, sawMSR, sawActive := false, false, false
	accounts := map[string]bool{}
	var accountOrder []string

	for _, p := range doc.Passes {
		if p.HasPass {
			d.Passes = append(d.Passes, p.Pass)
		} else {
			d.Passes = append(d.Passes, "(no pass)")
		}
		for _, c := range p.Components {
			d.Components++
			for _, s := range c.Settings {
				base := s.Path
				if i := strings.LastIndex(base, "/"); i >= 0 {
					base = base[i+1:]
				}
				switch base {
				case "ComputerName":
					if d.ComputerName == "" {
						d.ComputerName = s.Value
					}
				case "TimeZone":
					if d.TimeZone == "" {
						d.TimeZone = s.Value
					}
				case "UILanguage", "SystemLocale", "InputLocale", "UserLocale":
					if d.Locale == "" {
						d.Locale = s.Value
					}
				case "Type":
					if strings.EqualFold(s.Value, "EFI") {
						sawEFI = true
					}
					if strings.EqualFold(s.Value, "MSR") {
						sawMSR = true
					}
				case "Active":
					if strings.EqualFold(s.Value, "true") {
						sawActive = true
					}
				case "Name":
					if strings.Contains(s.Path, "LocalAccount") && !accounts[s.Value] {
						accounts[s.Value] = true
						accountOrder = append(accountOrder, s.Value)
					}
				}
				if strings.HasPrefix(s.Path, "OOBE/") {
					d.OOBESettings++
				}
			}
		}
	}
	d.AdminUsers = accountOrder
	if d.AdminUsers == nil {
		d.AdminUsers = []string{}
	}
	d.OOBESkipped = d.OOBESettings > 0
	switch {
	case sawEFI || sawMSR:
		d.DiskLayout = "uefi"
	case sawActive:
		d.DiskLayout = "bios"
	}
	if d.Passes == nil {
		d.Passes = []string{}
	}
	return d
}

// ---------------------------------------------------------------------
// redaction
// ---------------------------------------------------------------------

const redactedMarker = "[REDACTED-BY-SETUPPILOT]"

// redactBytes splices every recorded secret text node out of the original
// bytes. Everything else -- formatting, comments, attribute order -- is
// preserved byte for byte, so the redacted file diffs cleanly against the
// original.
func redactBytes(data []byte, secrets []secretRef) ([]byte, int) {
	type rng struct{ start, end int }
	var ranges []rng
	for _, s := range secrets {
		if s.Empty || s.End <= s.Start || s.Start < 0 || s.End > len(data) {
			continue
		}
		ranges = append(ranges, rng{s.Start, s.End})
	}
	sort.Slice(ranges, func(i, j int) bool { return ranges[i].start < ranges[j].start })

	var out bytes.Buffer
	last := 0
	n := 0
	for _, r := range ranges {
		if r.start < last {
			continue
		}
		out.Write(data[last:r.start])
		out.WriteString(redactedMarker)
		last = r.end
		n++
	}
	out.Write(data[last:])
	return out.Bytes(), n
}

// ---------------------------------------------------------------------
// safe writing
// ---------------------------------------------------------------------

func writeNewFile(path string, data []byte) error {
	if _, err := os.Stat(path); err == nil {
		return fmt.Errorf("refusing to overwrite existing file %s (delete it or choose another --out)", path)
	} else if !os.IsNotExist(err) {
		return fmt.Errorf("checking %s: %w", path, err)
	}
	dir := filepath.Dir(path)
	tmp, err := os.CreateTemp(dir, ".setuppilot-*.tmp")
	if err != nil {
		return fmt.Errorf("creating temporary file in %s: %w", dir, err)
	}
	tmpName := tmp.Name()
	if _, err := tmp.Write(data); err != nil {
		tmp.Close()
		os.Remove(tmpName)
		return fmt.Errorf("writing %s: %w", tmpName, err)
	}
	if err := tmp.Close(); err != nil {
		os.Remove(tmpName)
		return fmt.Errorf("closing %s: %w", tmpName, err)
	}
	if err := os.Rename(tmpName, path); err != nil {
		os.Remove(tmpName)
		return fmt.Errorf("renaming into place: %w", err)
	}
	return nil
}

// ---------------------------------------------------------------------
// output helpers
// ---------------------------------------------------------------------

func lineLabel(n int) string {
	if n <= 0 {
		return "   -"
	}
	return fmt.Sprintf("%4d", n)
}

func printFindings(f *findingList) {
	for _, it := range f.sorted() {
		fmt.Printf("%-5s line %s  %s\n", it.Level, lineLabel(it.Line), it.Message)
	}
}

type checkJSON struct {
	File     string    `json:"file"`
	Size     int64     `json:"size_bytes"`
	OK       bool      `json:"ok"`
	Errors   int       `json:"errors"`
	Warnings int       `json:"warnings"`
	Infos    int       `json:"infos"`
	Findings []Finding `json:"findings"`
}

// ---------------------------------------------------------------------
// commands
// ---------------------------------------------------------------------

func usage() {
	fmt.Fprintf(os.Stderr, `SetupPilot %s -- build and check Windows unattended answer files (unattend.xml)

Usage:
  setuppilot new --out <file> [options] [--apply]
  setuppilot check <unattend.xml> [--json]
  setuppilot show  <unattend.xml> [--json]
  setuppilot redact <unattend.xml> --out <file> [--apply]
  setuppilot -h | --help | help

Commands:
  new       Generate a valid answer file from plain options. Writes nothing
            unless --apply is given; without it the document is printed to
            stdout as a preview.
  check     Audit an answer file and report ERROR / WARN / INFO findings with
            line numbers: malformed XML, wrong root or namespace, misspelled
            configuration passes, duplicate passes or components, components
            missing name/processorArchitecture/publicKeyToken, mixed processor
            architectures, illegal or over-long ComputerName, and plaintext
            passwords (reported, never printed).
  show      Print a readable summary: passes, components, and the settings
            that were actually set.
  redact    Write a copy with every password and product-key value replaced,
            so an answer file can be attached to a ticket safely. Writes
            nothing unless --apply is given.

Flags for new:
  --out <file>            Destination path (required).
  --computer-name <name>  ComputerName to set (default "*", random).
  --locale <tag>          Locale for input/system/UI/user (default en-US).
  --timezone <name>       Windows time zone id (default UTC).
  --admin-user <name>     Create this local account in Administrators.
  --skip-oobe             Suppress the out-of-box-experience screens.
  --disk-layout uefi|bios Emit a wipe-and-partition DiskConfiguration.
  --apply                 Actually write the file.

Flags for check / show:
  --json                  Emit machine-readable JSON instead of text.

Flags for redact:
  --out <file>            Destination path (required).
  --apply                 Actually write the file.

Exit status:
  0  success, no ERROR findings
  1  bad invocation, or the file could not be read or written
  2  the answer file has at least one ERROR finding

SetupPilot validates structure and known-value correctness, not the full
Microsoft schema. See README.txt.
`, version)
}

func main() {
	if len(os.Args) < 2 {
		// Double-clicked in Explorer rather than run from a prompt: ask the
		// one question the program needs and stay on screen. Printing usage
		// and exiting here is what made the window vanish instantly.
		if interactiveConsole() {
			runGuided()
			return
		}
		usage()
		os.Exit(1)
	}

	switch os.Args[1] {
	case "-h", "--help", "help":
		usage()
		os.Exit(0)
	case "new":
		cmdNew(os.Args[2:])
	case "check":
		cmdCheck(os.Args[2:])
	case "show":
		cmdShow(os.Args[2:])
	case "redact":
		cmdRedact(os.Args[2:])
	default:
		usage()
		os.Exit(1)
	}
}

func newFlagSet(name string) *flag.FlagSet {
	fs := flag.NewFlagSet(name, flag.ContinueOnError)
	fs.SetOutput(io.Discard)
	return fs
}

func failUsage(line string) {
	fmt.Fprintf(os.Stderr, "Usage: %s\n", line)
	os.Exit(1)
}

func failf(format string, a ...any) {
	fmt.Fprintf(os.Stderr, "error: "+format+"\n", a...)
	os.Exit(1)
}

func cmdNew(args []string) {
	const use = `setuppilot new --out <file> [--computer-name NAME] [--locale TAG] [--timezone NAME] [--admin-user NAME] [--skip-oobe] [--disk-layout uefi|bios] [--apply]`

	args = reorderFlags(args, map[string]bool{
		"out": true, "computer-name": true, "locale": true,
		"timezone": true, "admin-user": true, "disk-layout": true,
	})

	fs := newFlagSet("new")
	out := fs.String("out", "", "destination path")
	computerName := fs.String("computer-name", "*", "ComputerName")
	locale := fs.String("locale", "en-US", "locale tag")
	timezone := fs.String("timezone", "UTC", "Windows time zone id")
	adminUser := fs.String("admin-user", "", "local administrator account name")
	skipOOBE := fs.Bool("skip-oobe", false, "suppress OOBE screens")
	diskLayout := fs.String("disk-layout", "", "uefi or bios")
	apply := fs.Bool("apply", false, "actually write the file")

	if err := fs.Parse(args); err != nil {
		if err == flag.ErrHelp {
			fmt.Println("Usage: " + use)
			os.Exit(0)
		}
		fmt.Fprintf(os.Stderr, "error: %v\n", err)
		failUsage(use)
	}
	if fs.NArg() > 0 {
		fmt.Fprintf(os.Stderr, "error: unexpected argument %q\n", fs.Arg(0))
		failUsage(use)
	}
	if *out == "" {
		fmt.Fprintln(os.Stderr, "error: --out is required")
		failUsage(use)
	}
	if *diskLayout != "" && *diskLayout != "uefi" && *diskLayout != "bios" {
		failf("--disk-layout must be uefi or bios, got %q", *diskLayout)
	}
	if strings.TrimSpace(*locale) == "" {
		failf("--locale must not be empty")
	}

	doc := generate(newOpts{
		computerName: *computerName,
		locale:       *locale,
		timezone:     *timezone,
		adminUser:    *adminUser,
		skipOOBE:     *skipOOBE,
		diskLayout:   *diskLayout,
		arch:         "amd64",
	})

	if !*apply {
		fmt.Print(doc)
		fmt.Fprintf(os.Stderr,
			"\nDRY RUN: nothing was written. %s would receive %s.\nRe-run with --apply to write it.\n",
			*out, humanBytes(int64(len(doc))))
		return
	}

	if err := writeNewFile(*out, []byte(doc)); err != nil {
		failf("%v", err)
	}
	fmt.Printf("Wrote %s (%s)\n", *out, humanBytes(int64(len(doc))))
	fmt.Printf("Next: setuppilot check %s\n", *out)
}

func cmdCheck(args []string) {
	const use = `setuppilot check <unattend.xml> [--json]`
	args = reorderFlags(args, map[string]bool{})

	fs := newFlagSet("check")
	asJSON := fs.Bool("json", false, "emit JSON")
	if err := fs.Parse(args); err != nil {
		if err == flag.ErrHelp {
			fmt.Println("Usage: " + use)
			os.Exit(0)
		}
		fmt.Fprintf(os.Stderr, "error: %v\n", err)
		failUsage(use)
	}
	if fs.NArg() != 1 {
		failUsage(use)
	}
	path := fs.Arg(0)

	data, size, err := readAnswerFile(path)
	if err != nil {
		failf("%v", err)
	}

	doc := parseDocument(path, data, size, false)
	f := validate(doc)
	errs, warns, infos := f.counts()

	if *asJSON {
		payload := checkJSON{
			File: path, Size: size, OK: errs == 0,
			Errors: errs, Warnings: warns, Infos: infos,
			Findings: f.sorted(),
		}
		enc := json.NewEncoder(os.Stdout)
		enc.SetIndent("", "  ")
		if err := enc.Encode(payload); err != nil {
			failf("encoding JSON: %v", err)
		}
	} else {
		fmt.Printf("Checking %s\n\n", path)
		printFindings(f)
		fmt.Printf("\n%d error(s), %d warning(s), %d note(s)\n", errs, warns, infos)
		if errs == 0 {
			fmt.Println("RESULT: PASS -- no structural errors found")
		} else {
			fmt.Println("RESULT: FAIL -- do not deploy this answer file")
		}
	}

	if errs > 0 {
		os.Exit(2)
	}
}

type showJSON struct {
	File       string     `json:"file"`
	Size       int64      `json:"size_bytes"`
	Root       string     `json:"root"`
	Namespace  string     `json:"namespace"`
	Passes     []passInfo `json:"passes"`
	Summary    derived    `json:"summary"`
	Truncated  bool       `json:"settings_truncated"`
	ParseError string     `json:"parse_error,omitempty"`
}

func cmdShow(args []string) {
	const use = `setuppilot show <unattend.xml> [--json]`
	args = reorderFlags(args, map[string]bool{})

	fs := newFlagSet("show")
	asJSON := fs.Bool("json", false, "emit JSON")
	if err := fs.Parse(args); err != nil {
		if err == flag.ErrHelp {
			fmt.Println("Usage: " + use)
			os.Exit(0)
		}
		fmt.Fprintf(os.Stderr, "error: %v\n", err)
		failUsage(use)
	}
	if fs.NArg() != 1 {
		failUsage(use)
	}
	path := fs.Arg(0)

	data, size, err := readAnswerFile(path)
	if err != nil {
		failf("%v", err)
	}
	doc := parseDocument(path, data, size, true)
	if doc.FatalErr != nil {
		failf("%s is not well-formed XML (line %d): %s -- run `setuppilot check` for detail",
			path, doc.FatalLine, cleanErr(doc.FatalErr))
	}
	d := derive(doc)

	if *asJSON {
		payload := showJSON{
			File: path, Size: size, Root: doc.RootName, Namespace: doc.RootNS,
			Passes: doc.Passes, Summary: d, Truncated: doc.LeafCutoff,
		}
		if payload.Passes == nil {
			payload.Passes = []passInfo{}
		}
		enc := json.NewEncoder(os.Stdout)
		enc.SetIndent("", "  ")
		if err := enc.Encode(payload); err != nil {
			failf("encoding JSON: %v", err)
		}
		return
	}

	fmt.Printf("Answer file : %s (%s)\n", path, humanBytes(size))
	fmt.Printf("Root        : <%s>\n", doc.RootName)
	ns := doc.RootNS
	if ns == "" {
		ns = "(none declared)"
	}
	fmt.Printf("Namespace   : %s\n", ns)
	fmt.Println()

	fmt.Println("Derived settings")
	fmt.Println("----------------")
	fmt.Printf("  computer name : %s\n", orNone(d.ComputerName))
	fmt.Printf("  locale        : %s\n", orNone(d.Locale))
	fmt.Printf("  time zone     : %s\n", orNone(d.TimeZone))
	if len(d.AdminUsers) > 0 {
		fmt.Printf("  local accounts: %s\n", strings.Join(d.AdminUsers, ", "))
	} else {
		fmt.Printf("  local accounts: (none)\n")
	}
	if d.OOBESkipped {
		fmt.Printf("  OOBE          : skipped (%d setting(s))\n", d.OOBESettings)
	} else {
		fmt.Printf("  OOBE          : not customised\n")
	}
	fmt.Printf("  disk layout   : %s\n", d.DiskLayout)
	fmt.Printf("  secret values : %d\n", d.Secrets)
	fmt.Println()

	fmt.Printf("Configuration passes (%d)\n", len(doc.Passes))
	fmt.Println("------------------------")
	for _, p := range doc.Passes {
		name := p.Pass
		if !p.HasPass {
			name = "(no pass attribute)"
		}
		mark := ""
		if p.HasPass && !isValidPass(p.Pass) {
			mark = "  <-- NOT A REAL PASS"
		}
		fmt.Printf("\npass %q  (line %d, %d component(s))%s\n", name, p.Line, len(p.Components), mark)
		for _, c := range p.Components {
			arch := c.Arch
			if arch == "" {
				arch = "?"
			}
			fmt.Printf("  component %s [%s]  line %d\n", compLabel(c), arch, c.Line)
			shown := 0
			for _, s := range c.Settings {
				base := s.Path
				if i := strings.LastIndex(base, "/"); i >= 0 {
					base = base[i+1:]
				}
				if !isKeyLeaf(base) {
					continue
				}
				if shown >= 40 {
					fmt.Printf("      ... (%d more settings)\n", len(c.Settings)-shown)
					break
				}
				fmt.Printf("      %-46s = %s\n", s.Path, s.Value)
				shown++
			}
			if shown == 0 {
				fmt.Printf("      (no leaf settings)\n")
			}
		}
	}
	if doc.LeafCutoff {
		fmt.Printf("\nNote: setting list truncated at %d entries per component.\n", maxLeaves)
	}
}

func orNone(s string) string {
	if s == "" {
		return "(not set)"
	}
	return s
}

func cmdRedact(args []string) {
	const use = `setuppilot redact <unattend.xml> --out <file> [--apply]`
	args = reorderFlags(args, map[string]bool{"out": true})

	fs := newFlagSet("redact")
	out := fs.String("out", "", "destination path")
	apply := fs.Bool("apply", false, "actually write the file")
	if err := fs.Parse(args); err != nil {
		if err == flag.ErrHelp {
			fmt.Println("Usage: " + use)
			os.Exit(0)
		}
		fmt.Fprintf(os.Stderr, "error: %v\n", err)
		failUsage(use)
	}
	if fs.NArg() != 1 {
		failUsage(use)
	}
	if *out == "" {
		fmt.Fprintln(os.Stderr, "error: --out is required")
		failUsage(use)
	}
	path := fs.Arg(0)

	if sameFile(path, *out) {
		failf("--out must differ from the input file; SetupPilot never modifies the original")
	}

	data, size, err := readAnswerFile(path)
	if err != nil {
		failf("%v", err)
	}
	doc := parseDocument(path, data, size, false)
	if doc.FatalErr != nil {
		failf("%s is not well-formed XML (line %d): %s -- refusing to redact a file I cannot parse",
			path, doc.FatalLine, cleanErr(doc.FatalErr))
	}

	redacted, n := redactBytes(data, doc.Secrets)

	// Never emit something we just broke.
	if err := wellFormed(redacted); err != nil {
		failf("internal check failed: redacted output is not well-formed (%v)", err)
	}

	for _, s := range doc.Secrets {
		if s.Empty {
			continue
		}
		fmt.Printf("  line %d: <%s> -> %s\n", s.Line, s.Element, redactedMarker)
	}
	if n == 0 {
		fmt.Println("  (no password or product-key values found)")
	}

	if !*apply {
		fmt.Printf("\nDRY RUN: %d value(s) would be redacted; nothing was written.\n", n)
		fmt.Printf("Re-run with --apply to write %s.\n", *out)
		return
	}

	if err := writeNewFile(*out, redacted); err != nil {
		failf("%v", err)
	}
	fmt.Printf("\nRedacted %d value(s); wrote %s (%s). Original %s is unchanged.\n",
		n, *out, humanBytes(int64(len(redacted))), path)
}

// wellFormed re-reads a document token by token, which is the cheapest
// honest way to assert that what we are about to write still parses.
func wellFormed(data []byte) error {
	dec := xml.NewDecoder(bytes.NewReader(data))
	dec.Strict = true
	for {
		_, err := dec.Token()
		if err == io.EOF {
			return nil
		}
		if err != nil {
			return err
		}
	}
}

func sameFile(a, b string) bool {
	if a == b {
		return true
	}
	sa, err := os.Stat(a)
	if err != nil {
		return false
	}
	sb, err := os.Stat(b)
	if err != nil {
		return false
	}
	return os.SameFile(sa, sb)
}
