// Command docsnap is a batch, headless documentation generator. It walks one
// or more folders of pre-captured screenshots, reads an optional captions.txt
// sidecar file, and produces polished Markdown (and optionally HTML) docs
// without ever opening a browser UI. It is the scriptable, bulk-processing
// counterpart to the interactive SnapDocs browser app.
package main

import (
	"flag"
	"fmt"
	htmlpkg "html"
	"os"
	"path/filepath"
	"sort"
	"strings"
)

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

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()
	case "build":
		cmdBuild(os.Args[2:])
	case "validate":
		cmdValidate(os.Args[2:])
	default:
		usage()
		os.Exit(1)
	}
}

func usage() {
	fmt.Fprintln(os.Stderr, `DocSnap - batch, headless documentation generator from screenshot folders

Usage:
  docsnap build <folder> [<folder> ...] --title "Doc Title" [--out-dir dir] [--html] [--sort name|mtime]
  docsnap validate <folder> [<folder> ...]
  docsnap help

Commands:
  build      Generate Markdown (and optionally HTML) docs from folders of
             screenshots plus an optional captions.txt sidecar file.
  validate   Report which screenshots are missing captions, without
             generating any files (read-only, QA-oriented check).

Run 'docsnap build -h' or 'docsnap validate -h' for command-specific help.`)
}

func buildUsage() {
	fmt.Fprintln(os.Stderr, `Usage: docsnap build <folder> [<folder> ...] --title "Doc Title" [options]

For each <folder>, finds image files (.png, .jpg, .jpeg, .gif, .bmp,
case-insensitive), sorts them, reads captions.txt if present, and writes
a Markdown doc (and optionally an HTML doc) with one "## Step N" section
per image. Multiple folders are processed independently in one invocation,
each producing its own output document.

Options:
  --title string    Document title, used as the top-level heading (required)
  --out-dir dir      Directory to write generated docs into (default ".")
  --html             Also generate a standalone HTML version of each doc
  --sort name|mtime  Screenshot ordering: alphabetical name, or modification
                      time ascending (default "name")

Captions sidecar (captions.txt), one line per screenshot:
  01-login.png: Open the login screen and click Sign In

A screenshot with no matching line still becomes a step, with an empty
caption. A missing captions.txt is not an error - captions are omitted.`)
}

func validateUsage() {
	fmt.Fprintln(os.Stderr, `Usage: docsnap validate <folder> [<folder> ...]

Read-only QA check. For each folder, reports which image files have no
matching caption in captions.txt, without generating any output files.
Exits non-zero if any folder contains zero image files.`)
}

// reorderFlags works around the stdlib flag package's habit of stopping
// flag parsing at the first positional argument, by moving all recognized
// flags (and their values) to the front of the argument list.
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 cmdBuild(args []string) {
	for _, a := range args {
		if a == "-h" || a == "--help" || a == "help" {
			buildUsage()
			return
		}
	}

	fs := flag.NewFlagSet("build", flag.ExitOnError)
	title := fs.String("title", "", "Document title (required)")
	outDir := fs.String("out-dir", ".", "Output directory for generated docs")
	genHTML := fs.Bool("html", false, "Also generate standalone HTML output")
	sortMode := fs.String("sort", "name", "Sort order: name or mtime")

	valueFlags := map[string]bool{"title": true, "out-dir": true, "sort": true}
	if err := fs.Parse(reorderFlags(args, valueFlags)); err != nil {
		os.Exit(1)
	}

	folders := fs.Args()
	if len(folders) == 0 {
		fmt.Fprintln(os.Stderr, "docsnap build: at least one folder is required")
		buildUsage()
		os.Exit(1)
	}
	if strings.TrimSpace(*title) == "" {
		fmt.Fprintln(os.Stderr, "docsnap build: --title is required")
		os.Exit(1)
	}
	if *sortMode != "name" && *sortMode != "mtime" {
		fmt.Fprintln(os.Stderr, "docsnap build: --sort must be \"name\" or \"mtime\"")
		os.Exit(1)
	}
	if err := os.MkdirAll(*outDir, 0o755); err != nil {
		fmt.Fprintf(os.Stderr, "docsnap build: failed to create out-dir %q: %v\n", *outDir, err)
		os.Exit(1)
	}

	var totalDocs, totalSteps, totalMatched, totalMissing int
	hadError := false

	for _, folder := range folders {
		steps, matched, missing, outPaths, err := buildOne(folder, *title, *outDir, *genHTML, *sortMode)
		if err != nil {
			fmt.Printf("Folder: %s\n  error: %v\n", folder, err)
			hadError = true
			continue
		}
		totalDocs++
		totalSteps += steps
		totalMatched += matched
		totalMissing += missing

		fmt.Printf("Folder: %s\n", folder)
		fmt.Printf("  Steps found:      %d\n", steps)
		fmt.Printf("  Captions matched: %d/%d\n", matched, steps)
		for _, p := range outPaths {
			fmt.Printf("  Output:           %s\n", p)
		}
	}

	fmt.Println("---")
	fmt.Printf("Batch summary: %d doc(s) generated, %d total step(s), %d caption(s) matched, %d missing\n",
		totalDocs, totalSteps, totalMatched, totalMissing)

	if hadError {
		os.Exit(1)
	}
}

// buildOne processes a single folder and returns step/caption stats and the
// list of files it wrote.
func buildOne(folder, title, outDir string, genHTML bool, sortMode string) (steps, matched, missing int, outPaths []string, err error) {
	images, err := findImages(folder)
	if err != nil {
		return 0, 0, 0, nil, err
	}
	if err := sortImages(images, sortMode); err != nil {
		return 0, 0, 0, nil, err
	}

	captions := loadCaptions(folder)
	base := filepath.Base(filepath.Clean(folder))

	var md strings.Builder
	md.WriteString("# " + title + "\n\n")
	for i, img := range images {
		stepNum := i + 1
		rel, relErr := relImagePath(outDir, img)
		if relErr != nil {
			rel = img
		}
		caption := captions[filepath.Base(img)]
		if strings.TrimSpace(caption) != "" {
			matched++
		}
		md.WriteString(fmt.Sprintf("## Step %d\n\n", stepNum))
		md.WriteString(fmt.Sprintf("![Step %d](%s)\n\n", stepNum, filepath.ToSlash(rel)))
		if strings.TrimSpace(caption) != "" {
			md.WriteString(caption + "\n\n")
		}
	}
	steps = len(images)
	missing = steps - matched

	mdPath := filepath.Join(outDir, base+".md")
	if err := os.WriteFile(mdPath, []byte(md.String()), 0o644); err != nil {
		return steps, matched, missing, nil, err
	}
	outPaths = append(outPaths, mdPath)

	if genHTML {
		htmlContent := buildHTMLDoc(title, images, captions, outDir)
		htmlPath := filepath.Join(outDir, base+".html")
		if err := os.WriteFile(htmlPath, []byte(htmlContent), 0o644); err != nil {
			return steps, matched, missing, outPaths, err
		}
		outPaths = append(outPaths, htmlPath)
	}

	return steps, matched, missing, outPaths, nil
}

func buildHTMLDoc(title string, images []string, captions map[string]string, outDir string) string {
	var sb strings.Builder
	sb.WriteString("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n")
	sb.WriteString("<title>" + htmlpkg.EscapeString(title) + "</title>\n<style>\n")
	sb.WriteString(docCSS)
	sb.WriteString("</style>\n</head>\n<body>\n")
	sb.WriteString("<h1>" + htmlpkg.EscapeString(title) + "</h1>\n")

	for i, img := range images {
		stepNum := i + 1
		rel, err := relImagePath(outDir, img)
		if err != nil {
			rel = img
		}
		caption := captions[filepath.Base(img)]
		sb.WriteString("<section class=\"step\">\n")
		sb.WriteString(fmt.Sprintf("<h2>Step %d</h2>\n", stepNum))
		sb.WriteString(fmt.Sprintf("<img src=\"%s\" alt=\"Step %d\">\n",
			htmlpkg.EscapeString(filepath.ToSlash(rel)), stepNum))
		if strings.TrimSpace(caption) != "" {
			sb.WriteString("<p>" + htmlpkg.EscapeString(caption) + "</p>\n")
		}
		sb.WriteString("</section>\n")
	}

	sb.WriteString("</body>\n</html>\n")
	return sb.String()
}

const docCSS = `body{font-family:-apple-system,Segoe UI,Helvetica,Arial,sans-serif;max-width:800px;margin:2rem auto;padding:0 1rem;color:#1a1a1a;background:#fff;line-height:1.5}
h1{border-bottom:2px solid #ddd;padding-bottom:.5rem}
.step{margin:2rem 0;padding-bottom:1.5rem;border-bottom:1px solid #eee}
.step h2{color:#333}
.step img{max-width:100%;border:1px solid #ccc;border-radius:4px;display:block;margin:.5rem 0}
.step p{color:#444}
`

func cmdValidate(args []string) {
	for _, a := range args {
		if a == "-h" || a == "--help" || a == "help" {
			validateUsage()
			return
		}
	}

	fs := flag.NewFlagSet("validate", flag.ExitOnError)
	valueFlags := map[string]bool{}
	if err := fs.Parse(reorderFlags(args, valueFlags)); err != nil {
		os.Exit(1)
	}

	folders := fs.Args()
	if len(folders) == 0 {
		fmt.Fprintln(os.Stderr, "docsnap validate: at least one folder is required")
		validateUsage()
		os.Exit(1)
	}

	hadError := false
	for _, folder := range folders {
		images, err := findImages(folder)
		if err != nil {
			fmt.Printf("Folder: %s\n  error: %v\n", folder, err)
			hadError = true
			continue
		}
		sort.Strings(images)
		captions := loadCaptions(folder)

		var missing []string
		for _, img := range images {
			name := filepath.Base(img)
			if c, ok := captions[name]; !ok || strings.TrimSpace(c) == "" {
				missing = append(missing, name)
			}
		}

		fmt.Printf("Folder: %s\n", folder)
		fmt.Printf("  Images found:     %d\n", len(images))
		fmt.Printf("  Missing captions: %d\n", len(missing))
		for _, m := range missing {
			fmt.Printf("    - %s\n", m)
		}
		if len(images) == 0 {
			fmt.Println("  WARNING: no image files found in this folder")
			hadError = true
		}
	}

	if hadError {
		os.Exit(1)
	}
}

// findImages returns the paths of every image file directly inside folder,
// matched case-insensitively against the supported extension set.
func findImages(folder string) ([]string, error) {
	entries, err := os.ReadDir(folder)
	if err != nil {
		return nil, err
	}
	var images []string
	for _, e := range entries {
		if e.IsDir() {
			continue
		}
		ext := strings.ToLower(filepath.Ext(e.Name()))
		if imageExts[ext] {
			images = append(images, filepath.Join(folder, e.Name()))
		}
	}
	return images, nil
}

// sortImages sorts image paths in place, either alphabetically by filename
// or by ascending file modification time.
func sortImages(images []string, mode string) error {
	if mode == "mtime" {
		var statErr error
		mtimes := make(map[string]int64, len(images))
		for _, img := range images {
			info, err := os.Stat(img)
			if err != nil {
				statErr = err
				continue
			}
			mtimes[img] = info.ModTime().UnixNano()
		}
		if statErr != nil {
			return statErr
		}
		sort.SliceStable(images, func(i, j int) bool {
			return mtimes[images[i]] < mtimes[images[j]]
		})
		return nil
	}
	sort.Strings(images)
	return nil
}

// loadCaptions reads folder/captions.txt if present and returns a map of
// filename -> caption text. A missing file yields an empty map, not an error.
func loadCaptions(folder string) map[string]string {
	captions := map[string]string{}
	data, err := os.ReadFile(filepath.Join(folder, "captions.txt"))
	if err != nil {
		return captions
	}
	for _, line := range strings.Split(string(data), "\n") {
		line = strings.TrimRight(line, "\r")
		line = strings.TrimSpace(line)
		if line == "" {
			continue
		}
		idx := strings.Index(line, ":")
		if idx < 0 {
			continue
		}
		fname := strings.TrimSpace(line[:idx])
		caption := strings.TrimSpace(line[idx+1:])
		if fname == "" {
			continue
		}
		captions[fname] = caption
	}
	return captions
}

// relImagePath returns target's path relative to outDir, resolving both to
// absolute paths first so the result is correct regardless of the caller's
// working directory.
func relImagePath(outDir, target string) (string, error) {
	absOut, err := filepath.Abs(outDir)
	if err != nil {
		return "", err
	}
	absTarget, err := filepath.Abs(target)
	if err != nil {
		return "", err
	}
	return filepath.Rel(absOut, absTarget)
}
