// Command snapdocs turns a folder of screenshots plus a plain-text steps file
// into a finished, self-contained documentation file: a single HTML document
// with every image embedded as a base64 data URI, or Markdown with a sidecar
// images folder.
package main

import (
	"bufio"
	"encoding/base64"
	"flag"
	"fmt"
	"html"
	"image"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"time"

	_ "image/gif"
	_ "image/jpeg"
	_ "image/png"
)

const toolVersion = "1.0.0"

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])
}

// ---------------------------------------------------------------- usage

func usage() {
	fmt.Fprint(os.Stderr, usageText)
}

const usageText = `snapdocs ` + toolVersion + ` - turn a folder of screenshots into one self-contained document

USAGE
  snapdocs init  <dir> [--steps <file>] [--force] [--apply]
  snapdocs build <dir> --steps <file> --out <file> [options]
  snapdocs check <dir> --steps <file>
  snapdocs help

COMMANDS
  init    Scan <dir> for images in sorted order and write a starter steps file
          with one empty stanza per image, ready to fill in.
          DRY RUN unless --apply is given.
  build   Read the steps file, pair each stanza with its image and write the
          finished document. DRY RUN unless --apply is given.
  check   Validate a steps file against <dir> without writing anything.
          Exits 1 if any problem is found. Always read-only.

OPTIONS
  --steps <file>   Steps file to read (build/check) or write (init).
                   Default "steps.txt". A relative path is tried as given
                   first, then relative to <dir>.
  --out <file>     Path of the document to write (build only, required).
  --format <fmt>   "html" (default) or "md". If --format is not given it is
                   inferred from the --out extension (.md/.markdown -> md).
  --title <T>      Document title. Default "Documentation".
  --author <A>     Author line printed under the title. Optional.
  --force          Allow an existing output file to be replaced. The old file
                   is first renamed aside to <name>.bak-<timestamp>; snapdocs
                   never deletes anything.
  --apply          Actually write files. Without it nothing is created.
  -h, --help       Show this help and exit 0.

STEPS-FILE FORMAT
  Plain text. One stanza per step, stanzas separated by one or more blank
  lines. A stanza is a set of "key: value" lines. Three keys are recognised:

      image: shot1.png        (required) file name of the screenshot,
                              relative to <dir>
      title: Open Settings    (required) short heading for the step
      body:  Press Win+I ...  (optional) explanatory text for the step

  A line that does not begin with one of those three keys is treated as a
  continuation of the previous key's value, so bodies may span many lines.
  Lines whose first non-space character is # are comments and are ignored.
  Blank lines inside a stanza's body are preserved as paragraph breaks only
  if the continuation lines are indented; a truly blank line ends the stanza.
  Steps appear in the document in STEPS-FILE ORDER, not directory order.

  Example:

      # my guide
      image: shot1.png
      title: Open Settings
      body: Press Win+I to open the Settings app.

      image: shot2.png
      title: Choose Bluetooth
      body: Click "Bluetooth & devices" in the left sidebar.
        The pane on the right refreshes.

OUTPUT
  html  ONE file, fully self-contained. Every image is inlined as a base64
        data: URI and all CSS is inlined. There are no external references of
        any kind, so the file can be emailed and opened offline.
  md    A Markdown file plus an "images" folder written beside it; each image
        is copied there and linked relatively.

SUPPORTED IMAGES
  .png .jpg .jpeg .gif - each candidate is really decoded, so a text file
  named "shot.png" is reported as an error rather than silently embedded.

EXAMPLES
  snapdocs init ./shots --apply
  snapdocs check ./shots --steps ./shots/steps.txt
  snapdocs build ./shots --steps ./shots/steps.txt --out guide.html \
      --title "Pairing a Bluetooth Mouse" --author "IT Support" --apply
  snapdocs build ./shots --steps ./shots/steps.txt --out guide.md \
      --format md --apply
`

func fail(format string, args ...any) {
	fmt.Fprintf(os.Stderr, "snapdocs: "+format+"\n", args...)
	os.Exit(1)
}

// ---------------------------------------------------------------- images

var imageExts = map[string]bool{
	".png":  true,
	".jpg":  true,
	".jpeg": true,
	".gif":  true,
}

type imageInfo struct {
	name   string // base name
	path   string // absolute path
	format string // "png", "jpeg", "gif"
	mime   string
	width  int
	height int
	size   int64
}

func mimeFor(format string) string {
	switch format {
	case "png":
		return "image/png"
	case "jpeg":
		return "image/jpeg"
	case "gif":
		return "image/gif"
	}
	return "application/octet-stream"
}

// probeImage really decodes the file header, so a non-image with an image
// extension is rejected instead of being embedded as garbage.
func probeImage(path string) (imageInfo, error) {
	fi, err := os.Stat(path)
	if err != nil {
		return imageInfo{}, err
	}
	if fi.IsDir() {
		return imageInfo{}, fmt.Errorf("%s is a directory, not an image", filepath.Base(path))
	}
	f, err := os.Open(path)
	if err != nil {
		return imageInfo{}, err
	}
	defer f.Close()
	cfg, format, err := image.DecodeConfig(bufio.NewReader(f))
	if err != nil {
		return imageInfo{}, fmt.Errorf("%s is not a readable PNG/JPEG/GIF image: %v", filepath.Base(path), err)
	}
	return imageInfo{
		name:   filepath.Base(path),
		path:   path,
		format: format,
		mime:   mimeFor(format),
		width:  cfg.Width,
		height: cfg.Height,
		size:   fi.Size(),
	}, nil
}

type badImage struct {
	name string
	err  error
}

// scanImages lists the top-level image files of dir in sorted name order.
// Files carrying an image extension that fail to decode are returned
// separately so callers can report them instead of ignoring them.
func scanImages(dir string) ([]imageInfo, []badImage, error) {
	ents, err := os.ReadDir(dir)
	if err != nil {
		return nil, nil, err
	}
	var names []string
	for _, e := range ents {
		if e.IsDir() {
			continue
		}
		if !imageExts[strings.ToLower(filepath.Ext(e.Name()))] {
			continue
		}
		names = append(names, e.Name())
	}
	sort.Strings(names)
	var good []imageInfo
	var bad []badImage
	for _, n := range names {
		info, err := probeImage(filepath.Join(dir, n))
		if err != nil {
			bad = append(bad, badImage{name: n, err: err})
			continue
		}
		good = append(good, info)
	}
	return good, bad, nil
}

// ---------------------------------------------------------------- steps file

type step struct {
	image string
	title string
	body  string
	line  int // line number the stanza started on
}

var stepKeys = []string{"image", "title", "body"}

// matchKey reports the recognised key a line starts with, plus the value.
func matchKey(line string) (string, string, bool) {
	for _, k := range stepKeys {
		if len(line) <= len(k) {
			continue
		}
		if !strings.EqualFold(line[:len(k)], k) {
			continue
		}
		rest := line[len(k):]
		trimmed := strings.TrimLeft(rest, " \t")
		if strings.HasPrefix(trimmed, ":") {
			return k, strings.TrimSpace(trimmed[1:]), true
		}
	}
	return "", "", false
}

func parseSteps(path string) ([]step, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	text := strings.ReplaceAll(string(data), "\r\n", "\n")
	lines := strings.Split(text, "\n")

	var out []step
	var cur *step
	var curKey string
	seenKeys := map[string]int{}

	flush := func() error {
		if cur == nil {
			return nil
		}
		s := *cur
		s.title = strings.TrimSpace(s.title)
		s.body = strings.TrimRight(s.body, " \t\n")
		if strings.TrimSpace(s.image) == "" {
			return fmt.Errorf("line %d: stanza has no \"image:\" line", s.line)
		}
		out = append(out, s)
		cur, curKey = nil, ""
		seenKeys = map[string]int{}
		return nil
	}

	for i, raw := range lines {
		lineNo := i + 1
		trimmed := strings.TrimSpace(raw)
		if strings.HasPrefix(trimmed, "#") {
			continue
		}
		if trimmed == "" {
			if err := flush(); err != nil {
				return nil, err
			}
			continue
		}
		key, val, ok := matchKey(trimmed)
		if ok {
			if cur == nil {
				cur = &step{line: lineNo}
			}
			if prev, dup := seenKeys[key]; dup {
				return nil, fmt.Errorf("line %d: duplicate %q key in one stanza (already given on line %d); separate steps with a blank line", lineNo, key, prev)
			}
			seenKeys[key] = lineNo
			switch key {
			case "image":
				cur.image = val
			case "title":
				cur.title = val
			case "body":
				cur.body = val
			}
			curKey = key
			continue
		}
		// Continuation of the previous key.
		if cur == nil || curKey == "" {
			return nil, fmt.Errorf("line %d: %q is not a recognised \"key: value\" line and there is no previous key to continue (valid keys: image, title, body)", lineNo, truncate(trimmed, 40))
		}
		switch curKey {
		case "image":
			return nil, fmt.Errorf("line %d: %q continues an \"image:\" line; an image name must fit on one line", lineNo, truncate(trimmed, 40))
		case "title":
			cur.title = cur.title + " " + trimmed
		case "body":
			cur.body = cur.body + "\n" + trimmed
		}
	}
	if err := flush(); err != nil {
		return nil, err
	}
	if len(out) == 0 {
		return nil, fmt.Errorf("no step stanzas found; expected at least one \"image:\" line")
	}
	return out, nil
}

func truncate(s string, n int) string {
	if len(s) <= n {
		return s
	}
	return s[:n] + "..."
}

// resolveSteps accepts a steps path as given, falling back to <dir>/<steps>.
func resolveSteps(dir, steps string) (string, error) {
	if filepath.IsAbs(steps) {
		if _, err := os.Stat(steps); err != nil {
			return "", err
		}
		return steps, nil
	}
	if _, err := os.Stat(steps); err == nil {
		return filepath.Abs(steps)
	}
	cand := filepath.Join(dir, steps)
	if _, err := os.Stat(cand); err == nil {
		return filepath.Abs(cand)
	}
	return "", fmt.Errorf("steps file %q not found (also tried %s)", steps, cand)
}

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

type problem struct {
	kind   string
	detail string
}

// validate pairs the steps against the directory and reports every problem
// class: missing images, unreferenced images, empty titles, duplicates and
// files that carry an image extension but do not decode.
func validate(steps []step, images []imageInfo, bad []badImage, dir string) []problem {
	var probs []problem
	present := make(map[string]imageInfo, len(images))
	for _, im := range images {
		present[im.name] = im
	}
	badSet := make(map[string]error, len(bad))
	for _, b := range bad {
		badSet[b.name] = b.err
	}

	referenced := make(map[string]int)
	var refOrder []string
	for _, s := range steps {
		if _, seen := referenced[s.image]; !seen {
			refOrder = append(refOrder, s.image)
		}
		referenced[s.image]++
	}

	for _, s := range steps {
		if strings.TrimSpace(s.title) == "" {
			probs = append(probs, problem{"empty-title",
				fmt.Sprintf("line %d: step for %q has an empty title", s.line, s.image)})
		}
	}
	for _, s := range steps {
		if _, ok := present[s.image]; ok {
			continue
		}
		if err, isBad := badSet[s.image]; isBad {
			probs = append(probs, problem{"unreadable-image",
				fmt.Sprintf("line %d: %v", s.line, err)})
			continue
		}
		probs = append(probs, problem{"missing-image",
			fmt.Sprintf("line %d: referenced image %q does not exist in %s", s.line, s.image, dir)})
	}
	for _, name := range refOrder {
		if n := referenced[name]; n > 1 {
			probs = append(probs, problem{"duplicate-reference",
				fmt.Sprintf("image %q is referenced by %d steps; each image should appear once", name, n)})
		}
	}
	for _, im := range images {
		if referenced[im.name] == 0 {
			probs = append(probs, problem{"unreferenced-image",
				fmt.Sprintf("image %q exists in %s but no step references it", im.name, dir)})
		}
	}
	for _, b := range bad {
		if referenced[b.name] == 0 {
			probs = append(probs, problem{"unreadable-image",
				fmt.Sprintf("%v (and no step references it)", b.err)})
		}
	}
	return probs
}

// ---------------------------------------------------------------- rendering

type renderStep struct {
	step  step
	image imageInfo
}

type docMeta struct {
	title     string
	author    string
	generated string
}

const styleSheet = `:root{color-scheme:light dark}
*{box-sizing:border-box}
body{margin:0;padding:2rem 1rem;background:#f6f7f9;color:#1b1f24;
font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif}
main{max-width:52rem;margin:0 auto;background:#fff;border:1px solid #dfe3e8;
border-radius:10px;padding:2.5rem}
h1{margin:0 0 .35rem;font-size:1.9rem;line-height:1.25}
.meta{margin:0;color:#5a6472;font-size:.9rem}
.toc{margin:2rem 0 0;padding:1rem 1.25rem;background:#f2f5f9;border:1px solid #e1e7ef;border-radius:8px}
.toc h2{margin:0 0 .5rem;font-size:.8rem;letter-spacing:.08em;text-transform:uppercase;color:#5a6472}
.toc ol{margin:0;padding-left:1.4rem}
.toc li{margin:.15rem 0}
ol.steps{list-style:none;margin:2rem 0 0;padding:0;counter-reset:snapstep}
li.step{counter-increment:snapstep;margin:0 0 2.25rem;padding:0 0 2.25rem;border-bottom:1px solid #eceff3}
li.step:last-child{border-bottom:0;margin-bottom:0;padding-bottom:0}
li.step h2{display:flex;align-items:center;gap:.6rem;margin:0 0 .5rem;font-size:1.2rem}
li.step h2::before{content:counter(snapstep);flex:0 0 auto;width:1.9rem;height:1.9rem;
border-radius:50%;background:#1b64da;color:#fff;font-size:.95rem;
display:inline-flex;align-items:center;justify-content:center}
.body p{margin:.5rem 0}
figure{margin:1rem 0 0}
figure img{display:block;max-width:100%;height:auto;border:1px solid #d8dee6;border-radius:6px;background:#fff}
figcaption{margin-top:.4rem;color:#6b7480;font-size:.8rem}
footer{margin-top:2.5rem;padding-top:1rem;border-top:1px solid #eceff3;color:#6b7480;font-size:.8rem}
@media print{body{background:#fff;padding:0}main{border:0;padding:0;max-width:none}}
@media (prefers-color-scheme:dark){
body{background:#14171b;color:#e6e9ee}
main{background:#1b1f24;border-color:#2c333c}
.meta,figcaption,footer,.toc h2{color:#9aa4b1}
.toc{background:#20252c;border-color:#2c333c}
li.step{border-bottom-color:#272d35}
figure img{border-color:#333b45;background:#0f1216}
footer{border-top-color:#272d35}}
`

func esc(s string) string { return html.EscapeString(s) }

// bodyHTML turns plain text into paragraphs; blank lines separate paragraphs
// and single newlines become line breaks.
func bodyHTML(body string) string {
	body = strings.TrimSpace(body)
	if body == "" {
		return ""
	}
	var b strings.Builder
	for _, para := range strings.Split(body, "\n\n") {
		para = strings.TrimSpace(para)
		if para == "" {
			continue
		}
		lines := strings.Split(para, "\n")
		for i := range lines {
			lines[i] = esc(strings.TrimSpace(lines[i]))
		}
		b.WriteString("      <p>" + strings.Join(lines, "<br>") + "</p>\n")
	}
	return b.String()
}

func renderHTML(meta docMeta, rs []renderStep) (string, error) {
	var b strings.Builder
	b.WriteString("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n")
	b.WriteString("<meta charset=\"utf-8\">\n")
	b.WriteString("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n")
	b.WriteString("<meta name=\"generator\" content=\"snapdocs " + toolVersion + "\">\n")
	b.WriteString("<title>" + esc(meta.title) + "</title>\n")
	b.WriteString("<style>\n" + styleSheet + "</style>\n")
	b.WriteString("</head>\n<body>\n<main>\n")
	b.WriteString("  <header>\n    <h1>" + esc(meta.title) + "</h1>\n")
	sub := meta.generated
	if meta.author != "" {
		sub = esc(meta.author) + " &middot; " + sub
	}
	b.WriteString("    <p class=\"meta\">" + sub + " &middot; " +
		fmt.Sprintf("%d step(s)", len(rs)) + "</p>\n  </header>\n")

	b.WriteString("  <section class=\"toc\">\n    <h2>Steps</h2>\n    <ol>\n")
	for _, r := range rs {
		b.WriteString("      <li>" + esc(r.step.title) + "</li>\n")
	}
	b.WriteString("    </ol>\n  </section>\n")

	b.WriteString("  <ol class=\"steps\">\n")
	for _, r := range rs {
		data, err := os.ReadFile(r.image.path)
		if err != nil {
			return "", fmt.Errorf("reading %s: %w", r.image.name, err)
		}
		b.WriteString("    <li class=\"step\">\n")
		b.WriteString("      <h2>" + esc(r.step.title) + "</h2>\n")
		if bh := bodyHTML(r.step.body); bh != "" {
			b.WriteString("      <div class=\"body\">\n" + bh + "      </div>\n")
		}
		b.WriteString("      <figure>\n        <img src=\"data:" + r.image.mime + ";base64," +
			base64.StdEncoding.EncodeToString(data) +
			"\" alt=\"" + esc(r.step.title) + "\">\n")
		b.WriteString("        <figcaption>" + esc(r.image.name) +
			fmt.Sprintf(" &mdash; %d&times;%d px, %s", r.image.width, r.image.height, humanBytes(r.image.size)) +
			"</figcaption>\n      </figure>\n")
		b.WriteString("    </li>\n")
	}
	b.WriteString("  </ol>\n")
	b.WriteString("  <footer>Self-contained document generated by snapdocs " + toolVersion +
		". Every image is embedded; nothing is loaded from the network.</footer>\n")
	b.WriteString("</main>\n</body>\n</html>\n")
	return b.String(), nil
}

func renderMarkdown(meta docMeta, rs []renderStep, imagesDir string) string {
	var b strings.Builder
	b.WriteString("# " + meta.title + "\n\n")
	sub := meta.generated
	if meta.author != "" {
		sub = meta.author + " - " + sub
	}
	b.WriteString("*" + sub + fmt.Sprintf(" - %d step(s)*\n\n", len(rs)))
	for i, r := range rs {
		b.WriteString(fmt.Sprintf("## %d. %s\n\n", i+1, r.step.title))
		if body := strings.TrimSpace(r.step.body); body != "" {
			b.WriteString(body + "\n\n")
		}
		link := imagesDir + "/" + r.image.name
		b.WriteString("![" + r.step.title + "](" + link + ")\n\n")
		b.WriteString(fmt.Sprintf("*%s - %dx%d px, %s*\n\n",
			r.image.name, r.image.width, r.image.height, humanBytes(r.image.size)))
	}
	b.WriteString("---\n\nGenerated by snapdocs " + toolVersion + ".\n")
	return b.String()
}

// ---------------------------------------------------------------- writing

// backupAside renames an existing path out of the way instead of deleting it.
func backupAside(path string) (string, error) {
	stamp := time.Now().Format("20060102-150405")
	for attempt := 0; attempt < 100; attempt++ {
		cand := fmt.Sprintf("%s.bak-%s", path, stamp)
		if attempt > 0 {
			cand = fmt.Sprintf("%s.bak-%s-%d", path, stamp, attempt)
		}
		if _, err := os.Lstat(cand); os.IsNotExist(err) {
			if err := os.Rename(path, cand); err != nil {
				return "", err
			}
			return cand, nil
		}
	}
	return "", fmt.Errorf("could not find a free backup name for %s", path)
}

func copyFile(src, dst string) (int64, error) {
	data, err := os.ReadFile(src)
	if err != nil {
		return 0, err
	}
	if err := os.WriteFile(dst, data, 0o644); err != nil {
		return 0, err
	}
	return int64(len(data)), nil
}

// ---------------------------------------------------------------- main

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":
		fmt.Print(usageText)
		os.Exit(0)
	case "init":
		cmdInit(os.Args[2:])
	case "build":
		cmdBuild(os.Args[2:])
	case "check":
		cmdCheck(os.Args[2:])
	default:
		fmt.Fprintf(os.Stderr, "snapdocs: unknown command %q\n\n", os.Args[1])
		usage()
		os.Exit(1)
	}
}

func newFlagSet(name string) *flag.FlagSet {
	fs := flag.NewFlagSet(name, flag.ContinueOnError)
	fs.SetOutput(os.Stderr)
	fs.Usage = usage
	return fs
}

func wantsHelp(args []string) bool {
	for _, a := range args {
		if a == "-h" || a == "--help" || a == "help" {
			return true
		}
	}
	return false
}

func mustDir(cmd string, rest []string) string {
	if len(rest) != 1 {
		fmt.Fprintf(os.Stderr, "snapdocs: %s needs exactly one directory argument (got %d)\n\n", cmd, len(rest))
		usage()
		os.Exit(1)
	}
	dir, err := filepath.Abs(rest[0])
	if err != nil {
		fail("resolving %s: %v", rest[0], err)
	}
	info, err := os.Stat(dir)
	if err != nil {
		fail("cannot read directory %s: %v", rest[0], err)
	}
	if !info.IsDir() {
		fail("%s is not a directory", rest[0])
	}
	return dir
}

// ---------------------------------------------------------------- init

func cmdInit(rawArgs []string) {
	if wantsHelp(rawArgs) {
		fmt.Print(usageText)
		os.Exit(0)
	}
	args := reorderFlags(rawArgs, map[string]bool{"steps": true})
	fs := newFlagSet("init")
	var (
		steps = fs.String("steps", "steps.txt", "steps file to write")
		force = fs.Bool("force", false, "replace an existing steps file")
		apply = fs.Bool("apply", false, "actually write the steps file")
	)
	if err := fs.Parse(args); err != nil {
		os.Exit(1)
	}
	dir := mustDir("init", fs.Args())

	images, bad, err := scanImages(dir)
	if err != nil {
		fail("scanning %s: %v", dir, err)
	}
	for _, b := range bad {
		fmt.Fprintf(os.Stderr, "snapdocs: warning - skipping %v\n", b.err)
	}
	if len(images) == 0 {
		fail("no usable images (.png .jpg .jpeg .gif) found in %s", dir)
	}

	out := *steps
	if !filepath.IsAbs(out) {
		out = filepath.Join(dir, out)
	}
	out, err = filepath.Abs(out)
	if err != nil {
		fail("resolving --steps path: %v", err)
	}

	var b strings.Builder
	b.WriteString("# steps file generated by snapdocs " + toolVersion + "\n")
	b.WriteString("# " + fmt.Sprintf("%d image(s) found in %s", len(images), dir) + "\n")
	b.WriteString("#\n")
	b.WriteString("# Fill in the title and body of each stanza. Stanzas are separated by a\n")
	b.WriteString("# blank line and appear in the document in the order they appear here,\n")
	b.WriteString("# so reorder these stanzas to reorder the document.\n")
	b.WriteString("# Lines beginning with # are comments.\n\n")
	var total int64
	for _, im := range images {
		b.WriteString("image: " + im.name + "\n")
		b.WriteString("title: \n")
		b.WriteString("body: \n\n")
		total += im.size
	}
	content := b.String()

	exists := false
	if _, err := os.Lstat(out); err == nil {
		exists = true
	}
	if exists && !*force {
		fail("steps file %s already exists; pass --force to replace it (the old file is kept as %s.bak-<timestamp>)", out, out)
	}

	if !*apply {
		fmt.Println("DRY RUN - nothing was written. Re-run with --apply to commit.")
		fmt.Printf("would write %s (%s)\n", out, humanBytes(int64(len(content))))
		fmt.Printf("%d image(s), %s total, in sorted order:\n", len(images), humanBytes(total))
		for i, im := range images {
			fmt.Printf("  %2d. %-28s %dx%d %s %s\n", i+1, im.name, im.width, im.height,
				strings.ToUpper(im.format), humanBytes(im.size))
		}
		os.Exit(0)
	}

	if exists {
		bak, err := backupAside(out)
		if err != nil {
			fail("backing up existing %s: %v", out, err)
		}
		fmt.Printf("existing steps file kept as %s\n", bak)
	}
	if err := os.WriteFile(out, []byte(content), 0o644); err != nil {
		fail("writing %s: %v", out, err)
	}
	fmt.Println("APPLIED - starter steps file written:")
	fmt.Printf("  %s (%s)\n", out, humanBytes(int64(len(content))))
	fmt.Printf("%d image(s), %s total, in sorted order:\n", len(images), humanBytes(total))
	for i, im := range images {
		fmt.Printf("  %2d. %-28s %dx%d %s %s\n", i+1, im.name, im.width, im.height,
			strings.ToUpper(im.format), humanBytes(im.size))
	}
	fmt.Println("next: fill in the titles and bodies, then run snapdocs check and snapdocs build.")
}

// ---------------------------------------------------------------- check

func cmdCheck(rawArgs []string) {
	if wantsHelp(rawArgs) {
		fmt.Print(usageText)
		os.Exit(0)
	}
	args := reorderFlags(rawArgs, map[string]bool{"steps": true})
	fs := newFlagSet("check")
	steps := fs.String("steps", "steps.txt", "steps file to validate")
	if err := fs.Parse(args); err != nil {
		os.Exit(1)
	}
	dir := mustDir("check", fs.Args())

	stepsPath, err := resolveSteps(dir, *steps)
	if err != nil {
		fail("%v", err)
	}
	parsed, err := parseSteps(stepsPath)
	if err != nil {
		fail("%s: %v", stepsPath, err)
	}
	images, bad, err := scanImages(dir)
	if err != nil {
		fail("scanning %s: %v", dir, err)
	}

	probs := validate(parsed, images, bad, dir)
	fmt.Printf("checked %d step(s) in %s against %d image(s) in %s\n",
		len(parsed), stepsPath, len(images), dir)
	if len(probs) == 0 {
		fmt.Println("OK - no problems found. Ready to build.")
		os.Exit(0)
	}
	fmt.Fprintf(os.Stderr, "snapdocs: %d problem(s) found:\n", len(probs))
	for _, p := range probs {
		fmt.Fprintf(os.Stderr, "  [%s] %s\n", p.kind, p.detail)
	}
	os.Exit(1)
}

// ---------------------------------------------------------------- build

func cmdBuild(rawArgs []string) {
	if wantsHelp(rawArgs) {
		fmt.Print(usageText)
		os.Exit(0)
	}
	valueFlags := map[string]bool{
		"steps": true, "out": true, "format": true, "title": true, "author": true,
	}
	args := reorderFlags(rawArgs, valueFlags)
	fs := newFlagSet("build")
	var (
		steps  = fs.String("steps", "steps.txt", "steps file to read")
		out    = fs.String("out", "", "path of the document to write")
		format = fs.String("format", "", "html or md")
		title  = fs.String("title", "Documentation", "document title")
		author = fs.String("author", "", "author line")
		force  = fs.Bool("force", false, "replace an existing output file")
		apply  = fs.Bool("apply", false, "actually write the document")
	)
	if err := fs.Parse(args); err != nil {
		os.Exit(1)
	}
	dir := mustDir("build", fs.Args())
	if strings.TrimSpace(*out) == "" {
		fmt.Fprintln(os.Stderr, "snapdocs: build requires --out <file>")
		fmt.Fprintln(os.Stderr, "")
		usage()
		os.Exit(1)
	}

	outPath, err := filepath.Abs(*out)
	if err != nil {
		fail("resolving --out path: %v", err)
	}
	fmtName := strings.ToLower(strings.TrimSpace(*format))
	if fmtName == "" {
		switch strings.ToLower(filepath.Ext(outPath)) {
		case ".md", ".markdown":
			fmtName = "md"
		default:
			fmtName = "html"
		}
	}
	if fmtName != "html" && fmtName != "md" {
		fail("unknown --format %q (valid: html, md)", *format)
	}

	stepsPath, err := resolveSteps(dir, *steps)
	if err != nil {
		fail("%v", err)
	}
	parsed, err := parseSteps(stepsPath)
	if err != nil {
		fail("%s: %v", stepsPath, err)
	}
	images, bad, err := scanImages(dir)
	if err != nil {
		fail("scanning %s: %v", dir, err)
	}
	byName := make(map[string]imageInfo, len(images))
	for _, im := range images {
		byName[im.name] = im
	}
	badSet := make(map[string]error, len(bad))
	for _, b := range bad {
		badSet[b.name] = b.err
	}

	// Blocking problems only: a document cannot be produced without its images.
	var blocking []problem
	for _, s := range parsed {
		if _, ok := byName[s.image]; ok {
			continue
		}
		if err, isBad := badSet[s.image]; isBad {
			blocking = append(blocking, problem{"unreadable-image", fmt.Sprintf("line %d: %v", s.line, err)})
			continue
		}
		blocking = append(blocking, problem{"missing-image",
			fmt.Sprintf("line %d: referenced image %q does not exist in %s", s.line, s.image, dir)})
	}
	if len(blocking) > 0 {
		fmt.Fprintf(os.Stderr, "snapdocs: cannot build, %d problem(s):\n", len(blocking))
		for _, p := range blocking {
			fmt.Fprintf(os.Stderr, "  [%s] %s\n", p.kind, p.detail)
		}
		fmt.Fprintln(os.Stderr, "snapdocs: run snapdocs check for the full report. Nothing was written.")
		os.Exit(1)
	}
	// Non-blocking notes.
	for _, p := range validate(parsed, images, bad, dir) {
		switch p.kind {
		case "missing-image", "unreadable-image":
		default:
			fmt.Fprintf(os.Stderr, "snapdocs: warning - [%s] %s\n", p.kind, p.detail)
		}
	}

	// Steps-file order is the document order; directory order is irrelevant here.
	rs := make([]renderStep, 0, len(parsed))
	var srcBytes int64
	for _, s := range parsed {
		im := byName[s.image]
		rs = append(rs, renderStep{step: s, image: im})
		srcBytes += im.size
	}

	meta := docMeta{
		title:     strings.TrimSpace(*title),
		author:    strings.TrimSpace(*author),
		generated: "generated " + time.Now().Format("2006-01-02"),
	}
	if meta.title == "" {
		meta.title = "Documentation"
	}

	const imagesSubdir = "images"
	imgDir := filepath.Join(filepath.Dir(outPath), imagesSubdir)

	var doc string
	if fmtName == "html" {
		doc, err = renderHTML(meta, rs)
		if err != nil {
			fail("%v", err)
		}
	} else {
		doc = renderMarkdown(meta, rs, imagesSubdir)
	}

	// Collect every path that would be written.
	type target struct {
		path string
		size int64
		note string
	}
	targets := []target{{outPath, int64(len(doc)), fmtName + " document"}}
	if fmtName == "md" {
		for _, r := range rs {
			targets = append(targets, target{filepath.Join(imgDir, r.image.name), r.image.size, "copied image"})
		}
	}
	var existing []string
	for _, t := range targets {
		if _, err := os.Lstat(t.path); err == nil {
			existing = append(existing, t.path)
		}
	}

	if !*apply {
		fmt.Println("DRY RUN - nothing was written. Re-run with --apply to commit.")
		fmt.Printf("format:  %s\ntitle:   %s\n", fmtName, meta.title)
		if meta.author != "" {
			fmt.Printf("author:  %s\n", meta.author)
		}
		fmt.Printf("steps:   %s\nimages:  %s\n", stepsPath, dir)
		fmt.Println("would write:")
		for _, t := range targets {
			fmt.Printf("  %s (%s, %s)\n", t.path, humanBytes(t.size), t.note)
		}
		fmt.Println("document order (steps-file order):")
		for i, r := range rs {
			fmt.Printf("  %2d. %-24s <- %-20s %dx%d %s\n", i+1, r.step.title, r.image.name,
				r.image.width, r.image.height, humanBytes(r.image.size))
		}
		if len(existing) > 0 {
			fmt.Println("note: these paths already exist and would be REFUSED without --force:")
			for _, p := range existing {
				fmt.Printf("  %s\n", p)
			}
		}
		fmt.Printf("%d step(s), %s of source images, %s document\n",
			len(rs), humanBytes(srcBytes), humanBytes(int64(len(doc))))
		os.Exit(0)
	}

	if len(existing) > 0 && !*force {
		fmt.Fprintf(os.Stderr, "snapdocs: REFUSED - %d output path(s) already exist. Nothing was written.\n", len(existing))
		for _, p := range existing {
			fmt.Fprintf(os.Stderr, "  exists: %s\n", p)
		}
		fmt.Fprintln(os.Stderr, "snapdocs: pass --force to replace them; each old file is renamed aside to <name>.bak-<timestamp> rather than deleted.")
		os.Exit(1)
	}

	if fmtName == "md" {
		if err := os.MkdirAll(imgDir, 0o755); err != nil {
			fail("creating %s: %v", imgDir, err)
		}
	}
	var backups []string
	for _, p := range existing {
		bak, err := backupAside(p)
		if err != nil {
			fail("backing up %s: %v", p, err)
		}
		backups = append(backups, bak)
	}
	if err := os.WriteFile(outPath, []byte(doc), 0o644); err != nil {
		fail("writing %s: %v", outPath, err)
	}
	written := int64(len(doc))
	if fmtName == "md" {
		for _, r := range rs {
			n, err := copyFile(r.image.path, filepath.Join(imgDir, r.image.name))
			if err != nil {
				fail("copying %s: %v", r.image.name, err)
			}
			written += n
		}
	}

	fmt.Println("APPLIED - document written:")
	fmt.Printf("  %s (%s, %s)\n", outPath, fmtName, humanBytes(int64(len(doc))))
	if fmtName == "md" {
		fmt.Printf("  %s%c (%d image(s) copied)\n", imgDir, os.PathSeparator, len(rs))
	} else {
		fmt.Printf("  self-contained: %d image(s) embedded as base64 data URIs, no external references\n", len(rs))
	}
	for _, b := range backups {
		fmt.Printf("  previous file kept as %s\n", b)
	}
	fmt.Println("document order (steps-file order):")
	for i, r := range rs {
		fmt.Printf("  %2d. %-24s <- %-20s %dx%d %s\n", i+1, r.step.title, r.image.name,
			r.image.width, r.image.height, humanBytes(r.image.size))
	}
	fmt.Printf("%d step(s), %s of source images, %s written in total\n",
		len(rs), humanBytes(srcBytes), humanBytes(written))
}
