// Techlosoft Suite -- the wizard that manages every Techlosoft program on
// this machine.
//
// One price gets the whole catalogue. This is the program that puts it on the
// machine, takes it off again, and tells you when there is something newer.
//
// Usage:
//
//	techlosoft-suite wizard
//	techlosoft-suite list    [--group <name>] [--installed] [--available] [--json]
//	techlosoft-suite search  <words...> [--json]
//	techlosoft-suite install <slug>... | --group <name> | --all
//	techlosoft-suite remove  <slug>... [--purge]
//	techlosoft-suite update  [--check] [--json]
//	techlosoft-suite status  [--json]
//	techlosoft-suite bundle  --from <dir> --out <file.tsb> [<slug>...]
//
// It never touches the network. Programs come from a folder you already have
// (--from) or from a .tsb bundle made from one (--bundle). Where those came
// from is your business.
package main

import (
	"encoding/json"
	"flag"
	"fmt"
	"os"
	"path/filepath"
	"runtime"
	"sort"
	"strings"
	"time"
)

const suiteVersion = "1.0.0"

func main() {
	if len(os.Args) < 2 {
		// Double-clicked in Explorer rather than run from a prompt. This is
		// the program most likely to be double-clicked in the whole
		// catalogue, so the guided flow is the main way in, not a fallback.
		if interactiveConsole() {
			runGuided()
			return
		}
		usage(os.Stderr)
		os.Exit(1)
	}
	switch os.Args[1] {
	case "list":
		cmdList(os.Args[2:])
	case "search":
		cmdSearch(os.Args[2:])
	case "install":
		cmdInstall(os.Args[2:])
	case "remove", "uninstall":
		cmdRemove(os.Args[2:])
	case "update":
		cmdUpdate(os.Args[2:])
	case "status":
		cmdStatus(os.Args[2:])
	case "bundle":
		cmdBundle(os.Args[2:])
	case "wizard", "guided":
		runGuided()
	case "-h", "--help", "help":
		usage(os.Stdout)
	case "--version", "-version", "version":
		fmt.Println("techlosoft-suite " + suiteVersion)
	default:
		fmt.Fprintf(os.Stderr, "unknown command %q\n\n", os.Args[1])
		usage(os.Stderr)
		os.Exit(1)
	}
}

func usage(w *os.File) {
	fmt.Fprintf(w, `Techlosoft Suite %s -- manages every Techlosoft program on this machine

Usage:
  techlosoft-suite wizard
  techlosoft-suite list    [--group <name>] [--installed] [--available] [--json]
  techlosoft-suite search  <words...> [--json]
  techlosoft-suite install <slug>... | --group <name> | --all
  techlosoft-suite remove  <slug>... [--purge] [--json]
  techlosoft-suite update  [--check] [--json]
  techlosoft-suite status  [--json]
  techlosoft-suite bundle  --from <dir> --out <file.tsb> [<slug>... | --group N | --all]

Commands:
  wizard    The guided setup assistant. This is what a double-click runs.
  list      The whole catalogue of %d programs, with what you already have.
  search    Find programs by name, group or description.
  install   Put programs on this machine, from --from <dir> or --bundle <f>.
  remove    Move programs into the install root's trash. Nothing is deleted.
  update    Compare what is installed against what the source folder offers.
  status    Install root, disk used, counts, last run.
  bundle    Write one .tsb archive of the programs you choose.

Common flags:
  --root <dir>    Where programs are installed. Default: %s
                  Also settable with the TECHLOSOFT_ROOT environment variable.
  --from <dir>    The folder holding the program files you downloaded.
  --bundle <f>    A .tsb archive to install out of, instead of --from.
  --yes           Confirm the install root without being asked.
  --json          Machine-readable output.

This program never uses the network. It installs from files you already have.
Removing a program moves it to <root>/trash; only "remove --purge" empties
that, and it empties nothing else.
`, suiteVersion, len(catalog), defaultRoot())
}

// defaultRoot is a folder inside the customer's own profile. Never a system
// directory, never the home folder itself.
func defaultRoot() string {
	if v := strings.TrimSpace(os.Getenv("TECHLOSOFT_ROOT")); v != "" {
		return v
	}
	if runtime.GOOS == "windows" {
		if la := os.Getenv("LOCALAPPDATA"); la != "" {
			return filepath.Join(la, "Techlosoft")
		}
	}
	home, err := os.UserHomeDir()
	if err != nil {
		return filepath.Join(".", "techlosoft")
	}
	return filepath.Join(home, "Techlosoft")
}

// reorderFlags lets flags appear before or after positional arguments, which
// is what people actually type.
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] && !strings.Contains(a, "=") {
			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 die(format string, a ...any) {
	fmt.Fprintf(os.Stderr, format+"\n", a...)
	os.Exit(1)
}

func emitJSON(v any) {
	enc := json.NewEncoder(os.Stdout)
	enc.SetIndent("", "  ")
	if err := enc.Encode(v); err != nil {
		die("error: %v", err)
	}
}

// mustRoot opens the install root, or explains exactly what to do about it.
func mustRoot(path string, yes bool) *Root {
	r, err := openRoot(path)
	if err == nil {
		return r
	}
	if err == ErrUnconfirmedRoot {
		if !yes {
			abs, _ := normalizeRoot(path)
			die("The install root %s has not been confirmed.\n\n"+
				"Nothing has been created. Techlosoft Suite writes to this one folder and\n"+
				"nowhere else, so it wants you to say the folder is right before it starts.\n\n"+
				"  techlosoft-suite <command> --root %s --yes\n\n"+
				"or run `techlosoft-suite wizard` and it will ask.", abs, abs)
		}
		r, err = confirmRoot(path)
		if err != nil {
			die("error: %v", err)
		}
		return r
	}
	die("error: %v", err)
	return nil
}

// ---------------------------------------------------------------- list ----

type listRow struct {
	Rank      int    `json:"rank"`
	Slug      string `json:"slug"`
	Name      string `json:"name"`
	Group     string `json:"group"`
	Plain     string `json:"plain"`
	Popular   bool   `json:"popular"`
	Installed bool   `json:"installed"`
	Version   string `json:"installed_version,omitempty"`
	Available bool   `json:"available_from_source"`
}

func cmdList(args []string) {
	fs := flag.NewFlagSet("list", flag.ContinueOnError)
	fs.SetOutput(os.Stderr)
	group := fs.String("group", "", "only this group")
	from := fs.String("from", "", "source folder, to show what can be installed")
	root := fs.String("root", defaultRoot(), "install root")
	onlyInstalled := fs.Bool("installed", false, "only what is already installed")
	onlyAvailable := fs.Bool("available", false, "only what is not installed yet")
	jsonOut := fs.Bool("json", false, "machine-readable output")
	if err := fs.Parse(reorderFlags(args, map[string]bool{"group": true, "from": true, "root": true})); err != nil {
		os.Exit(1)
	}
	if fs.NArg() > 0 {
		die("list takes no positional arguments (got %q)\n\nusage: techlosoft-suite list [--group <name>] [--installed] [--available] [--json]", fs.Arg(0))
	}

	sel := catalog
	if *group != "" {
		var err error
		sel, err = programsInGroup(*group)
		if err != nil {
			die("error: %v", err)
		}
	}

	db := &InstalledDB{Programs: map[string]InstalledEntry{}}
	if r, err := openRoot(*root); err == nil {
		if loaded, lerr := r.loadInstalled(); lerr == nil {
			db = loaded
		}
	}
	avail := map[string]bool{}
	if *from != "" {
		src, err := openSource(*from)
		if err != nil {
			die("error: %v", err)
		}
		avail = src.available()
	}

	var rows []listRow
	for _, p := range sel {
		inst, isInstalled := db.Programs[p.Slug]
		if *onlyInstalled && !isInstalled {
			continue
		}
		if *onlyAvailable && isInstalled {
			continue
		}
		row := listRow{Rank: p.Rank, Slug: p.Slug, Name: p.Name, Group: p.Group,
			Plain: p.Plain, Popular: p.Popular, Installed: isInstalled,
			Available: avail[p.Slug]}
		if isInstalled {
			row.Version = inst.Version
		}
		rows = append(rows, row)
	}

	if *jsonOut {
		emitJSON(map[string]any{
			"install_root":   *root,
			"total_catalog":  len(catalog),
			"shown":          len(rows),
			"installed":      len(db.Programs),
			"groups":         groups(),
			"source":         *from,
			"programs":       rows,
			"suite_version":  suiteVersion,
			"catalog_frozen": catalogVersion,
		})
		return
	}

	printRows(rows, *from != "")
	fmt.Printf("\n%d of %d programs shown. %d installed in %s\n",
		len(rows), len(catalog), len(db.Programs), *root)
	if *from == "" {
		fmt.Println("Add --from <folder> to see which of these you can install right now.")
	}
}

func printRows(rows []listRow, showAvail bool) {
	byGroup := map[string][]listRow{}
	var order []string
	for _, r := range rows {
		if _, ok := byGroup[r.Group]; !ok {
			order = append(order, r.Group)
		}
		byGroup[r.Group] = append(byGroup[r.Group], r)
	}
	sort.Strings(order)
	for _, g := range order {
		fmt.Printf("\n%s\n", g)
		for _, r := range byGroup[g] {
			mark := " "
			if r.Installed {
				mark = "*"
			}
			extra := ""
			if r.Installed {
				extra = "  [installed " + r.Version + "]"
			} else if showAvail && r.Available {
				extra = "  [ready to install]"
			}
			fmt.Printf("  %s %-18s %-24s %s%s\n", mark, r.Slug, r.Name, r.Plain, extra)
		}
	}
	fmt.Println("\n  * = installed")
}

// -------------------------------------------------------------- search ----

func cmdSearch(args []string) {
	fs := flag.NewFlagSet("search", flag.ContinueOnError)
	fs.SetOutput(os.Stderr)
	root := fs.String("root", defaultRoot(), "install root")
	jsonOut := fs.Bool("json", false, "machine-readable output")
	if err := fs.Parse(reorderFlags(args, map[string]bool{"root": true})); err != nil {
		os.Exit(1)
	}
	words := fs.Args()
	if len(words) == 0 {
		die("usage: techlosoft-suite search <words...> [--json]")
	}

	db := &InstalledDB{Programs: map[string]InstalledEntry{}}
	if r, err := openRoot(*root); err == nil {
		if loaded, lerr := r.loadInstalled(); lerr == nil {
			db = loaded
		}
	}

	var rows []listRow
	for _, p := range catalog {
		if !matchSearch(p, words) {
			continue
		}
		inst, isInstalled := db.Programs[p.Slug]
		row := listRow{Rank: p.Rank, Slug: p.Slug, Name: p.Name, Group: p.Group,
			Plain: p.Plain, Popular: p.Popular, Installed: isInstalled}
		if isInstalled {
			row.Version = inst.Version
		}
		rows = append(rows, row)
	}

	if *jsonOut {
		emitJSON(map[string]any{"query": words, "matches": len(rows), "programs": rows})
		return
	}
	if len(rows) == 0 {
		fmt.Printf("Nothing in the catalogue matches %q.\n", strings.Join(words, " "))
		fmt.Println("Try one word instead of several, or run `techlosoft-suite list` to browse.")
		return
	}
	printRows(rows, false)
	fmt.Printf("\n%d of %d programs match %q\n", len(rows), len(catalog), strings.Join(words, " "))
}

// ------------------------------------------------------------- install ----

func cmdInstall(args []string) {
	fs := flag.NewFlagSet("install", flag.ContinueOnError)
	fs.SetOutput(os.Stderr)
	from := fs.String("from", "", "folder holding the program files")
	bundlePath := fs.String("bundle", "", "a .tsb bundle to install out of")
	group := fs.String("group", "", "install a whole group")
	root := fs.String("root", defaultRoot(), "install root")
	all := fs.Bool("all", false, "install everything on offer")
	popular := fs.Bool("popular", false, "install the popular ones")
	force := fs.Bool("force", false, "re-write even if the bytes are identical")
	yes := fs.Bool("yes", false, "confirm the install root without being asked")
	jsonOut := fs.Bool("json", false, "machine-readable output")
	if err := fs.Parse(reorderFlags(args, map[string]bool{"from": true, "bundle": true, "group": true, "root": true})); err != nil {
		os.Exit(1)
	}

	if *from == "" && *bundlePath == "" {
		die("install needs somewhere to install FROM.\n\n" +
			"  --from <folder>     the folder holding the program files you downloaded\n" +
			"  --bundle <file.tsb> a bundle made earlier with `techlosoft-suite bundle`\n\n" +
			"This program never downloads anything.")
	}
	if *from != "" && *bundlePath != "" {
		die("give either --from or --bundle, not both")
	}

	// --all means "everything on offer here", not "all 100 in the catalogue".
	// Asking for a hundred programs from a bundle of four and being handed
	// ninety-six not-found lines is not an answer anybody wants.
	wanted, err := chooseSlugs(fs.Args(), *group, *popular)
	if err != nil {
		die("error: %v", err)
	}

	r := mustRoot(*root, *yes)
	db, err := r.loadInstalled()
	if err != nil {
		die("error: %v", err)
	}

	var payloads []payload
	var missing []string

	if *bundlePath != "" {
		b, err := openBundle(*bundlePath)
		if err != nil {
			die("error: %v", err)
		}
		defer b.Close()
		if *all || len(wanted) == 0 {
			for _, e := range b.TOC.Entries {
				wanted = appendUnique(wanted, e.Slug)
			}
		}
		for _, slug := range wanted {
			e, ok := b.entry(slug)
			if !ok {
				missing = append(missing, slug)
				continue
			}
			payloads = append(payloads, payloadFromBundle(b, e, *bundlePath))
		}
	} else {
		src, err := openSource(*from)
		if err != nil {
			die("error: %v", err)
		}
		if *all {
			have := src.available()
			for _, p := range catalog {
				if have[p.Slug] {
					wanted = appendUnique(wanted, p.Slug)
				}
			}
		}
		if len(wanted) == 0 {
			die("nothing selected.\n\nName the programs, or use --group <name>, --popular or --all.")
		}
		for _, slug := range wanted {
			sf, err := src.find(slug)
			if err != nil {
				missing = append(missing, slug)
				continue
			}
			payloads = append(payloads, payloadFromSource(sf))
		}
	}

	if len(payloads) == 0 {
		if *jsonOut {
			emitJSON(map[string]any{"installed": 0, "results": []InstallResult{}, "missing": missing})
			return
		}
		die("none of the %d programs asked for are in that source: %s", len(missing), strings.Join(missing, ", "))
	}

	var results []InstallResult
	var okCount, failCount int
	for _, p := range payloads {
		res := r.installOne(db, p, *force)
		results = append(results, res)
		switch res.Action {
		case "failed":
			failCount++
		default:
			okCount++
		}
		if !*jsonOut {
			printInstallLine(res)
		}
	}
	if err := r.saveInstalled(db); err != nil {
		die("error: could not record what was installed: %v", err)
	}

	if *jsonOut {
		emitJSON(map[string]any{
			"install_root": r.Path,
			"results":      results,
			"ok":           okCount,
			"failed":       failCount,
			"missing":      missing,
		})
	} else {
		if len(missing) > 0 {
			fmt.Printf("\nNot found in that source: %s\n", strings.Join(missing, ", "))
		}
		fmt.Printf("\n%d done, %d failed. %d programs now installed in %s\n",
			okCount, failCount, len(db.Programs), r.Path)
	}
	if failCount > 0 {
		os.Exit(1)
	}
}

func printInstallLine(res InstallResult) {
	switch res.Action {
	case "installed":
		fmt.Printf("  installed  %-18s %s (%s)\n", res.Slug, res.Version, humanBytes(res.Size))
	case "updated":
		if res.From == res.Version {
			// Same version number, different bytes: a rebuild, not a release.
			fmt.Printf("  rebuilt    %-18s %s (new build, %s)\n", res.Slug, res.Version, humanBytes(res.Size))
			return
		}
		fmt.Printf("  updated    %-18s %s -> %s (%s)\n", res.Slug, res.From, res.Version, humanBytes(res.Size))
	case "unchanged":
		fmt.Printf("  unchanged  %-18s %s (already exactly this)\n", res.Slug, res.Version)
	case "failed":
		fmt.Printf("  FAILED     %-18s %s\n", res.Slug, res.Error)
	default:
		fmt.Printf("  %-10s %-18s\n", res.Action, res.Slug)
	}
}

// appendUnique adds a slug only if it is not already there, keeping order.
func appendUnique(list []string, slug string) []string {
	for _, s := range list {
		if s == slug {
			return list
		}
	}
	return append(list, slug)
}

// chooseSlugs works out what the customer asked to act on.
func chooseSlugs(positional []string, group string, popular bool) ([]string, error) {
	picked := map[string]bool{}
	var order []string
	add := func(slug string) {
		if !picked[slug] {
			picked[slug] = true
			order = append(order, slug)
		}
	}
	if popular {
		for _, p := range catalog {
			if p.Popular {
				add(p.Slug)
			}
		}
	}
	if group != "" {
		ps, err := programsInGroup(group)
		if err != nil {
			return nil, err
		}
		for _, p := range ps {
			add(p.Slug)
		}
	}
	for _, a := range positional {
		p, ok := resolveName(a)
		if !ok {
			return nil, fmt.Errorf("%q is not a Techlosoft program -- try `techlosoft-suite search %s`", a, a)
		}
		add(p.Slug)
	}
	sort.Slice(order, func(i, j int) bool { return catalogRank(order[i]) < catalogRank(order[j]) })
	return order, nil
}

// -------------------------------------------------------------- remove ----

func cmdRemove(args []string) {
	fs := flag.NewFlagSet("remove", flag.ContinueOnError)
	fs.SetOutput(os.Stderr)
	root := fs.String("root", defaultRoot(), "install root")
	group := fs.String("group", "", "remove a whole group")
	all := fs.Bool("all", false, "remove everything installed")
	purge := fs.Bool("purge", false, "empty the install root's trash folder")
	yes := fs.Bool("yes", false, "confirm the install root without being asked")
	jsonOut := fs.Bool("json", false, "machine-readable output")
	if err := fs.Parse(reorderFlags(args, map[string]bool{"root": true, "group": true})); err != nil {
		os.Exit(1)
	}

	r := mustRoot(*root, *yes)
	db, err := r.loadInstalled()
	if err != nil {
		die("error: %v", err)
	}

	var wanted []string
	if *all {
		wanted = db.installedSlugs()
	} else {
		wanted, err = chooseSlugs(fs.Args(), *group, false)
		if err != nil {
			die("error: %v", err)
		}
	}

	if len(wanted) == 0 && !*purge {
		die("usage: techlosoft-suite remove <slug>... [--purge]\n\n" +
			"Removing moves the program into " + r.trash() + ".\nNothing is deleted.")
	}

	var results []RemoveResult
	for _, slug := range wanted {
		res := r.removeOne(db, slug)
		results = append(results, res)
		if !*jsonOut {
			switch res.Action {
			case "moved-to-trash":
				fmt.Printf("  moved to trash  %-18s %s\n", res.Slug, res.Trash)
			case "not-installed":
				fmt.Printf("  not installed   %-18s nothing to do\n", res.Slug)
			case "record-only":
				fmt.Printf("  record cleared  %-18s the file was already gone\n", res.Slug)
			default:
				fmt.Printf("  FAILED          %-18s %s\n", res.Slug, res.Error)
			}
		}
	}
	if len(wanted) > 0 {
		if err := r.saveInstalled(db); err != nil {
			die("error: %v", err)
		}
	}

	purged := map[string]any{}
	if *purge {
		files, bytes, err := r.purgeTrash()
		if err != nil {
			die("error: %v", err)
		}
		purged = map[string]any{"files": files, "bytes": bytes}
		if !*jsonOut {
			fmt.Printf("\nTrash emptied: %d files, %s freed from %s\n", files, humanBytes(bytes), r.trash())
		}
	}

	if *jsonOut {
		emitJSON(map[string]any{
			"install_root": r.Path,
			"trash":        r.trash(),
			"results":      results,
			"purged":       purged,
			"installed":    len(db.Programs),
		})
		return
	}
	if !*purge && len(results) > 0 {
		fmt.Printf("\nRemoved files are in %s. Nothing was deleted.\n", r.trash())
		fmt.Println("`techlosoft-suite remove --purge` empties that folder, and only that folder.")
	}
}

// -------------------------------------------------------------- update ----

func cmdUpdate(args []string) {
	fs := flag.NewFlagSet("update", flag.ContinueOnError)
	fs.SetOutput(os.Stderr)
	from := fs.String("from", "", "folder holding the newer program files")
	bundlePath := fs.String("bundle", "", "a .tsb bundle to update out of")
	root := fs.String("root", defaultRoot(), "install root")
	check := fs.Bool("check", false, "report only, install nothing")
	yes := fs.Bool("yes", false, "confirm the install root without being asked")
	jsonOut := fs.Bool("json", false, "machine-readable output")
	if err := fs.Parse(reorderFlags(args, map[string]bool{"from": true, "bundle": true, "root": true})); err != nil {
		os.Exit(1)
	}
	if *from == "" && *bundlePath == "" {
		die("update needs somewhere to compare against.\n\n" +
			"  --from <folder>     the folder holding a newer set of program files\n" +
			"  --bundle <file.tsb> a bundle to compare against\n\n" +
			"This program never downloads anything.")
	}

	r := mustRoot(*root, *yes)
	db, err := r.loadInstalled()
	if err != nil {
		die("error: %v", err)
	}
	if len(db.Programs) == 0 {
		if *jsonOut {
			emitJSON(map[string]any{"install_root": r.Path, "installed": 0, "updates": []UpdateInfo{}})
			return
		}
		fmt.Println("Nothing is installed yet, so there is nothing to update.")
		fmt.Println("Run `techlosoft-suite wizard` to pick some programs.")
		return
	}

	// Reduce both sources to the same shape: slug -> SourceFile.
	offered := map[string]SourceFile{}
	var b *Bundle
	if *bundlePath != "" {
		b, err = openBundle(*bundlePath)
		if err != nil {
			die("error: %v", err)
		}
		defer b.Close()
		for _, e := range b.TOC.Entries {
			offered[e.Slug] = SourceFile{Slug: e.Slug, Name: e.Name, Group: e.Group,
				Version: e.Version, Size: e.Size, SHA256: e.SHA256, Filename: e.Filename}
		}
	} else {
		src, err := openSource(*from)
		if err != nil {
			die("error: %v", err)
		}
		for _, slug := range db.installedSlugs() {
			sf, err := src.find(slug)
			if err != nil {
				continue
			}
			offered[slug] = sf
		}
	}

	var updates, current, unknown []UpdateInfo
	for _, slug := range db.installedSlugs() {
		inst := db.Programs[slug]
		sf, ok := offered[slug]
		if !ok {
			unknown = append(unknown, UpdateInfo{Slug: slug, Name: inst.Name,
				Installed: inst.Version, Reason: "no-source"})
			continue
		}
		u := needsUpdate(inst, sf)
		if u.Reason == "up-to-date" {
			current = append(current, u)
		} else {
			updates = append(updates, u)
		}
	}

	if *check || len(updates) == 0 {
		if *jsonOut {
			emitJSON(map[string]any{
				"install_root": r.Path, "installed": len(db.Programs),
				"updates": updates, "up_to_date": current, "no_source": unknown,
				"checked_only": true,
			})
			return
		}
		fmt.Printf("Checked %d installed programs against %s\n\n", len(db.Programs), firstNonEmpty(*from, *bundlePath))
		if len(updates) == 0 {
			fmt.Println("  Everything installed is up to date.")
		}
		for _, u := range updates {
			switch u.Reason {
			case "newer-version":
				fmt.Printf("  update    %-18s %s -> %s\n", u.Slug, u.Installed, u.Available)
			case "rebuilt":
				fmt.Printf("  rebuilt   %-18s %s (same version, different build)\n", u.Slug, u.Installed)
			}
		}
		if len(unknown) > 0 {
			var names []string
			for _, u := range unknown {
				names = append(names, u.Slug)
			}
			fmt.Printf("\n  Not in that source, so not checked: %s\n", strings.Join(names, ", "))
		}
		if len(updates) > 0 {
			fmt.Printf("\n%d update(s) waiting. Run the same command without --check to install them.\n", len(updates))
		}
		return
	}

	// Install the updates.
	var results []InstallResult
	var failCount int
	for _, u := range updates {
		var p payload
		if b != nil {
			e, _ := b.entry(u.Slug)
			p = payloadFromBundle(b, e, *bundlePath)
		} else {
			sf := offered[u.Slug]
			p = payloadFromSource(sf)
		}
		res := r.installOne(db, p, false)
		results = append(results, res)
		if res.Action == "failed" {
			failCount++
		}
		if !*jsonOut {
			printInstallLine(res)
		}
	}
	if err := r.saveInstalled(db); err != nil {
		die("error: %v", err)
	}
	if *jsonOut {
		emitJSON(map[string]any{"install_root": r.Path, "results": results, "failed": failCount})
		return
	}
	fmt.Printf("\n%d updated, %d failed.\n", len(results)-failCount, failCount)
	if failCount > 0 {
		os.Exit(1)
	}
}

func firstNonEmpty(a, b string) string {
	if a != "" {
		return a
	}
	return b
}

// -------------------------------------------------------------- status ----

func cmdStatus(args []string) {
	fs := flag.NewFlagSet("status", flag.ContinueOnError)
	fs.SetOutput(os.Stderr)
	root := fs.String("root", defaultRoot(), "install root")
	jsonOut := fs.Bool("json", false, "machine-readable output")
	if err := fs.Parse(reorderFlags(args, map[string]bool{"root": true})); err != nil {
		os.Exit(1)
	}
	if fs.NArg() > 0 {
		die("status takes no positional arguments (got %q)", fs.Arg(0))
	}

	r, err := openRoot(*root)
	if err == ErrUnconfirmedRoot {
		abs, _ := normalizeRoot(*root)
		if *jsonOut {
			emitJSON(map[string]any{"install_root": abs, "confirmed": false,
				"catalog": len(catalog), "installed": 0})
			return
		}
		fmt.Printf("Techlosoft Suite %s\n\n", suiteVersion)
		fmt.Printf("  install root : %s (not set up yet)\n", abs)
		fmt.Printf("  catalogue    : %d programs in %d groups\n", len(catalog), len(groups()))
		fmt.Printf("  installed    : nothing yet\n\n")
		fmt.Println("Run `techlosoft-suite wizard` to get started.")
		return
	}
	if err != nil {
		die("error: %v", err)
	}

	db, err := r.loadInstalled()
	if err != nil {
		die("error: %v", err)
	}
	used, _ := r.diskUsed()
	trash, _ := r.listTrash()
	var trashBytes int64
	var trashFiles int
	for _, t := range trash {
		trashBytes += t.Bytes
		trashFiles += t.Files
	}
	ledger, _ := r.readLedger()

	byGroup := map[string]int{}
	for _, e := range db.Programs {
		byGroup[e.Group]++
	}

	if *jsonOut {
		emitJSON(map[string]any{
			"suite_version":   suiteVersion,
			"install_root":    r.Path,
			"confirmed":       true,
			"catalog":         len(catalog),
			"groups":          len(groups()),
			"installed":       len(db.Programs),
			"installed_slugs": db.installedSlugs(),
			"by_group":        byGroup,
			"disk_used_bytes": used,
			"trash_items":     len(trash),
			"trash_files":     trashFiles,
			"trash_bytes":     trashBytes,
			"ledger_path":     r.ledgerPath(),
			"ledger_records":  len(ledger),
			"last_run":        db.LastRun,
		})
		return
	}

	fmt.Printf("Techlosoft Suite %s\n\n", suiteVersion)
	fmt.Printf("  install root : %s\n", r.Path)
	fmt.Printf("  disk used    : %s\n", humanBytes(used))
	fmt.Printf("  catalogue    : %d programs in %d groups\n", len(catalog), len(groups()))
	fmt.Printf("  installed    : %d\n", len(db.Programs))
	if len(byGroup) > 0 {
		var gs []string
		for g := range byGroup {
			gs = append(gs, g)
		}
		sort.Strings(gs)
		for _, g := range gs {
			fmt.Printf("                 %2d  %s\n", byGroup[g], g)
		}
	}
	fmt.Printf("  trash        : %d item(s), %d file(s), %s in %s\n",
		len(trash), trashFiles, humanBytes(trashBytes), r.trash())
	fmt.Printf("  ledger       : %d record(s) in %s\n", len(ledger), r.ledgerPath())
	if db.LastRun != "" {
		fmt.Printf("  last run     : %s\n", db.LastRun)
	} else {
		fmt.Printf("  last run     : never\n")
	}
}

// -------------------------------------------------------------- bundle ----

func cmdBundle(args []string) {
	fs := flag.NewFlagSet("bundle", flag.ContinueOnError)
	fs.SetOutput(os.Stderr)
	from := fs.String("from", "", "folder holding the program files")
	out := fs.String("out", "", "the .tsb file to write")
	group := fs.String("group", "", "bundle a whole group")
	all := fs.Bool("all", false, "bundle everything the source folder has")
	popular := fs.Bool("popular", false, "bundle the popular ones")
	jsonOut := fs.Bool("json", false, "machine-readable output")
	if err := fs.Parse(reorderFlags(args, map[string]bool{"from": true, "out": true, "group": true})); err != nil {
		os.Exit(1)
	}
	if *from == "" || *out == "" {
		die("usage: techlosoft-suite bundle --from <dir> --out <file.tsb> [<slug>... | --group <name> | --all]")
	}
	src, err := openSource(*from)
	if err != nil {
		die("error: %v", err)
	}

	wanted, err := chooseSlugs(fs.Args(), *group, *popular)
	if err != nil {
		die("error: %v", err)
	}
	if *all {
		have := src.available()
		for _, p := range catalog {
			if have[p.Slug] {
				wanted = appendUnique(wanted, p.Slug)
			}
		}
	}
	if len(wanted) == 0 {
		die("nothing selected.\n\nName the programs, or use --group <name>, --popular or --all.")
	}

	var items []SourceFile
	var missing []string
	for _, slug := range wanted {
		sf, err := src.find(slug)
		if err != nil {
			missing = append(missing, slug)
			continue
		}
		items = append(items, sf)
		if !*jsonOut {
			fmt.Printf("  adding  %-18s %-8s %s\n", sf.Slug, sf.Version, humanBytes(sf.Size))
		}
	}
	if len(items) == 0 {
		die("none of those programs are in %s", src.Dir)
	}

	created := time.Now().UTC().Format(time.RFC3339)
	if err := writeBundle(*out, items, created); err != nil {
		die("error: %v", err)
	}
	info, err := os.Stat(*out)
	if err != nil {
		die("error: %v", err)
	}

	if *jsonOut {
		var entries []map[string]any
		for _, it := range items {
			entries = append(entries, map[string]any{"slug": it.Slug, "version": it.Version,
				"size": it.Size, "sha256": it.SHA256})
		}
		emitJSON(map[string]any{"bundle": *out, "bytes": info.Size(),
			"programs": len(items), "entries": entries, "missing": missing,
			"format_version": bundleVersion, "created": created})
		return
	}
	if len(missing) > 0 {
		fmt.Printf("\nNot in %s, left out: %s\n", src.Dir, strings.Join(missing, ", "))
	}
	fmt.Printf("\nWrote %s -- %d programs, %s\n", *out, len(items), humanBytes(info.Size()))
	fmt.Printf("Install from it with:  techlosoft-suite install --bundle %s --all\n", *out)
}
